Monorepo for Aesthetic.Computer
aesthetic.computer
1#!/usr/bin/env node
2// Publish Electron release to DigitalOcean Spaces + register with silo.
3//
4// Usage:
5// node scripts/publish-release.mjs [--notes "Release notes"] [--dry-run]
6//
7// Requires env vars (from silo/.env or environment):
8// SPACES_KEY, SPACES_SECRET, SPACES_ENDPOINT
9// SILO_URL (default: https://silo.aesthetic.computer)
10// PUBLISH_SECRET (shared secret for silo admin API)
11
12import fs from "fs";
13import path from "path";
14import { fileURLToPath } from "url";
15import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
16
17const __dirname = path.dirname(fileURLToPath(import.meta.url));
18const distDir = path.join(__dirname, "..", "dist");
19const pkgPath = path.join(__dirname, "..", "package.json");
20
21// Load silo/.env if available
22const siloEnvPath = path.join(__dirname, "..", "..", "silo", ".env");
23if (fs.existsSync(siloEnvPath)) {
24 for (const line of fs.readFileSync(siloEnvPath, "utf8").split("\n")) {
25 const match = line.match(/^([A-Z_]+)=(.*)$/);
26 if (match && !process.env[match[1]]) {
27 process.env[match[1]] = match[2].replace(/^["']|["']$/g, "");
28 }
29 }
30}
31
32const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
33const version = pkg.version;
34
35const BUCKET = "releases-aesthetic-computer";
36const PREFIX = "desktop/";
37const BASE_URL = "https://releases.aesthetic.computer/desktop";
38const SILO_URL = process.env.SILO_URL || "https://silo.aesthetic.computer";
39const PUBLISH_SECRET = process.env.PUBLISH_SECRET || "";
40
41const args = process.argv.slice(2);
42const dryRun = args.includes("--dry-run");
43const notesIdx = args.indexOf("--notes");
44const releaseNotes = notesIdx >= 0 ? args[notesIdx + 1] || "" : "";
45
46const s3 = new S3Client({
47 endpoint: process.env.SPACES_ENDPOINT || "https://sfo3.digitaloceanspaces.com",
48 region: "us-east-1",
49 credentials: {
50 accessKeyId: process.env.SPACES_KEY || "",
51 secretAccessKey: process.env.SPACES_SECRET || "",
52 },
53 forcePathStyle: false,
54});
55
56// Files to upload: manifests + binaries matching current version
57const manifestFiles = ["latest-linux.yml", "latest-mac.yml", "latest.yml"];
58const binaryPatterns = [".AppImage", ".dmg", ".zip", ".exe", ".deb", ".rpm", ".blockmap"];
59
60function collectFiles() {
61 if (!fs.existsSync(distDir)) {
62 console.error("No dist/ directory found. Run a build first.");
63 process.exit(1);
64 }
65
66 const files = [];
67 for (const name of fs.readdirSync(distDir)) {
68 const fullPath = path.join(distDir, name);
69 if (!fs.statSync(fullPath).isFile()) continue;
70
71 // Include manifests
72 if (manifestFiles.includes(name)) {
73 files.push({ name, path: fullPath, isManifest: true });
74 continue;
75 }
76
77 // Include binaries for this version
78 if (binaryPatterns.some((ext) => name.endsWith(ext)) && name.includes(version)) {
79 files.push({ name, path: fullPath, isManifest: false });
80 }
81 }
82
83 return files;
84}
85
86function parseYml(content) {
87 // Simple YAML parser for electron-builder manifest format
88 const result = {};
89 for (const line of content.split("\n")) {
90 const match = line.match(/^(\w+):\s*(.+)$/);
91 if (match) {
92 let val = match[2].trim().replace(/^['"]|['"]$/g, "");
93 if (!isNaN(val) && val !== "") val = Number(val);
94 result[match[1]] = val;
95 }
96 }
97 return result;
98}
99
100function platformFromManifest(name) {
101 if (name === "latest-linux.yml") return "linux";
102 if (name === "latest-mac.yml") return "mac";
103 if (name === "latest.yml") return "win";
104 return null;
105}
106
107async function uploadFile(file) {
108 const key = PREFIX + file.name;
109 const body = fs.readFileSync(file.path);
110 const contentType = file.isManifest
111 ? "text/yaml"
112 : "application/octet-stream";
113 const cacheControl = file.isManifest
114 ? "no-cache, no-store, must-revalidate"
115 : "public, max-age=31536000";
116
117 console.log(` Uploading ${file.name} (${(body.length / 1024 / 1024).toFixed(1)} MB) → s3://${BUCKET}/${key}`);
118
119 if (dryRun) return;
120
121 await s3.send(
122 new PutObjectCommand({
123 Bucket: BUCKET,
124 Key: key,
125 Body: body,
126 ContentType: contentType,
127 CacheControl: cacheControl,
128 ACL: "public-read",
129 }),
130 );
131}
132
133async function registerWithSilo(platforms) {
134 const url = `${SILO_URL}/api/desktop/register`;
135 const body = { version, platforms, releaseNotes };
136
137 console.log(`\n Registering v${version} with silo...`);
138 if (dryRun) {
139 console.log(" [dry-run] Would POST:", JSON.stringify(body, null, 2));
140 return;
141 }
142
143 const resp = await fetch(url, {
144 method: "POST",
145 headers: {
146 "Content-Type": "application/json",
147 "X-Publish-Secret": PUBLISH_SECRET,
148 },
149 body: JSON.stringify(body),
150 });
151
152 if (!resp.ok) {
153 const err = await resp.text();
154 throw new Error(`Silo register failed (${resp.status}): ${err}`);
155 }
156
157 const data = await resp.json();
158 console.log(` Registered: ${JSON.stringify(data)}`);
159}
160
161async function main() {
162 console.log(`\nPublishing Aesthetic Computer Desktop v${version}`);
163 console.log(` Bucket: ${BUCKET}`);
164 console.log(` Silo: ${SILO_URL}`);
165 if (dryRun) console.log(" [DRY RUN - no actual uploads]");
166
167 const files = collectFiles();
168 if (files.length === 0) {
169 console.error("\nNo files to upload. Build first with: npm run build:linux");
170 process.exit(1);
171 }
172
173 console.log(`\nFound ${files.length} files to upload:`);
174 for (const f of files) console.log(` ${f.name} (${f.isManifest ? "manifest" : "binary"})`);
175
176 // Upload all files
177 console.log("\nUploading to DigitalOcean Spaces...");
178 for (const file of files) {
179 await uploadFile(file);
180 }
181
182 // Parse manifests to build platform metadata
183 const platforms = {};
184 for (const file of files.filter((f) => f.isManifest)) {
185 const platform = platformFromManifest(file.name);
186 if (!platform) continue;
187 const content = fs.readFileSync(file.path, "utf8");
188 const parsed = parseYml(content);
189 if (parsed.path) {
190 // Get size from actual file on disk (yml may not have top-level size)
191 const binaryPath = path.join(distDir, parsed.path);
192 const size = parsed.size || (fs.existsSync(binaryPath) ? fs.statSync(binaryPath).size : 0);
193 platforms[platform] = {
194 filename: parsed.path,
195 size,
196 sha512: parsed.sha512 || "",
197 url: `${BASE_URL}/${parsed.path}`,
198 };
199 }
200 }
201
202 if (Object.keys(platforms).length === 0) {
203 console.warn("\nNo manifest files found - skipping silo registration.");
204 console.log("Upload the latest-*.yml files to register.");
205 return;
206 }
207
208 // Register with silo
209 await registerWithSilo(platforms);
210
211 console.log(`\nDone! v${version} published.`);
212 console.log(` Download: ${BASE_URL}/`);
213 console.log(` Silo API: ${SILO_URL}/desktop/latest`);
214}
215
216main().catch((err) => {
217 console.error("\nPublish failed:", err.message);
218 process.exit(1);
219});