#!/usr/bin/env node import { spawn } from "node:child_process";import fs from "node:fs/promises";import os from "node:os";import path from "node:path";import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url));const repoRoot = path.resolve(__dirname, "..");const PRODUCTION_ICON_SOURCE = path.join(repoRoot, "assets/macos-icon-1024.png"); const ARCH_CHOICES = new Set(["arm64", "x64", "universal"]); function printUsage() { console.log(`Build a shareable macOS .dmg for T3 Code desktop. Usage: node scripts/build-desktop-dmg.mjs [options] Options: --arch <arm64|x64|universal> Target arch (default: host arch if supported) --version <semver> App version in DMG metadata (default: apps/server version) --output-dir <path> Output folder for final artifacts (default: release) --skip-build Skip 'bun run build:desktop' (requires existing dist artifacts) --keep-stage Keep temporary staging directory --signed Allow automatic code signing discovery --help Show this message`);} function fail(message) { console.error(`[desktop-dmg] ${message}`); process.exit(1);} function parseArgs(argv) { const defaultArch = process.arch === "arm64" || process.arch === "x64" ? process.arch : "arm64"; const options = { arch: defaultArch, version: null, skipBuild: false, keepStage: false, signed: false, outputDir: path.join(repoRoot, "release"), }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (arg === "--help") { options.help = true; continue; } if (arg === "--skip-build") { options.skipBuild = true; continue; } if (arg === "--keep-stage") { options.keepStage = true; continue; } if (arg === "--signed") { options.signed = true; continue; } if (arg === "--arch" || arg === "--version" || arg === "--output-dir") { const value = argv[i + 1]; if (!value || value.startsWith("--")) { fail(`Missing value for ${arg}`); } i += 1; if (arg === "--arch") options.arch = value; if (arg === "--version") options.version = value; if (arg === "--output-dir") options.outputDir = path.resolve(repoRoot, value); continue; } fail(`Unknown argument: ${arg}`); } if (!ARCH_CHOICES.has(options.arch)) { fail(`Invalid arch '${options.arch}'. Expected one of: ${Array.from(ARCH_CHOICES).join(", ")}`); } return options;} async function readJson(filePath) { const raw = await fs.readFile(filePath, "utf8"); return JSON.parse(raw);} async function exists(filePath) { try { await fs.access(filePath); return true; } catch { return false; }} function run(command, args, { cwd, env = process.env, stdio = "inherit" } = {}) { return new Promise((resolve, reject) => { const child = spawn(command, args, { cwd, env, stdio, }); child.on("error", reject); child.on("exit", (code, signal) => { if (signal) { reject(new Error(`${command} exited via signal ${signal}`)); return; } if (code !== 0) { reject(new Error(`${command} exited with code ${code}`)); return; } resolve(); }); });} async function generateMacIconSet(sourcePng, targetIcns, tmpRoot) { const iconsetDir = path.join(tmpRoot, "icon.iconset"); await fs.mkdir(iconsetDir, { recursive: true }); const iconSizes = [16, 32, 128, 256, 512]; for (const size of iconSizes) { await run( "sips", [ "-z", String(size), String(size), sourcePng, "--out", path.join(iconsetDir, `icon_${size}x${size}.png`), ], { stdio: "ignore" }, ); const retinaSize = size * 2; await run( "sips", [ "-z", String(retinaSize), String(retinaSize), sourcePng, "--out", path.join(iconsetDir, `icon_${size}x${size}@2x.png`), ], { stdio: "ignore" }, ); } await run("iconutil", ["-c", "icns", iconsetDir, "-o", targetIcns], { stdio: "ignore" });} async function stageProductionIcons(stageResourcesDir) { if (!(await exists(PRODUCTION_ICON_SOURCE))) { fail(`Production icon source is missing at ${PRODUCTION_ICON_SOURCE}`); } const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "t3code-icon-build-")); try { const iconPngPath = path.join(stageResourcesDir, "icon.png"); const iconIcnsPath = path.join(stageResourcesDir, "icon.icns"); await run("sips", ["-z", "512", "512", PRODUCTION_ICON_SOURCE, "--out", iconPngPath], { stdio: "ignore", }); await generateMacIconSet(PRODUCTION_ICON_SOURCE, iconIcnsPath, tmpRoot); } finally { await fs.rm(tmpRoot, { recursive: true, force: true }); }} async function validateBundledClientAssets(clientDir) { const indexPath = path.join(clientDir, "index.html"); const indexHtml = await fs.readFile(indexPath, "utf8"); const refs = [...indexHtml.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)].map((match) => match[1]); const missing = []; for (const ref of refs) { const normalizedRef = ref.split("#")[0]?.split("?")[0] ?? ""; if (!normalizedRef) continue; if (normalizedRef.startsWith("http://") || normalizedRef.startsWith("https://")) continue; if (normalizedRef.startsWith("data:") || normalizedRef.startsWith("mailto:")) continue; const ext = path.extname(normalizedRef); if (!ext) continue; const relativePath = normalizedRef.replace(/^\/+/, ""); const assetPath = path.join(clientDir, relativePath); if (!(await exists(assetPath))) { missing.push(normalizedRef); } } if (missing.length > 0) { const preview = missing.slice(0, 6).join(", "); const suffix = missing.length > 6 ? ` (+${missing.length - 6} more)` : ""; fail( `Bundled client references missing files in ${indexPath}: ${preview}${suffix}. Rebuild web/server artifacts.`, ); }} async function main() { const options = parseArgs(process.argv.slice(2)); if (options.help) { printUsage(); return; } if (process.platform !== "darwin") { fail("This script only builds macOS .dmg artifacts and must run on macOS."); } const desktopPkg = await readJson(path.join(repoRoot, "apps/desktop/package.json")); const serverPkg = await readJson(path.join(repoRoot, "apps/server/package.json")); const electronVersion = desktopPkg.dependencies?.electron; if (typeof electronVersion !== "string" || electronVersion.length === 0) { fail("Could not resolve apps/desktop electron dependency version."); } const dependencies = serverPkg.dependencies; if (!dependencies || Object.keys(dependencies).length === 0) { fail("Could not resolve production dependencies from apps/server/package.json."); } const appVersion = options.version ?? serverPkg.version ?? "0.1.0"; const stageRoot = path.join(os.tmpdir(), "t3code-desktop-dmg-stage"); const stageAppDir = path.join(stageRoot, "app"); const distDirs = { desktopDist: path.join(repoRoot, "apps/desktop/dist-electron"), desktopResources: path.join(repoRoot, "apps/desktop/resources"), serverDist: path.join(repoRoot, "apps/server/dist"), }; const bundledClientEntry = path.join(distDirs.serverDist, "client/index.html"); if (!options.skipBuild) { console.log("[desktop-dmg] Building desktop/server/web artifacts..."); await run("bun", ["run", "build:desktop"], { cwd: repoRoot }); } for (const [label, dir] of Object.entries(distDirs)) { if (!(await exists(dir))) { fail(`Missing ${label} at ${dir}. Run 'bun run build:desktop' first.`); } } if (!(await exists(bundledClientEntry))) { fail( `Missing bundled server client at ${bundledClientEntry}. Run 'bun run build:desktop' first.`, ); } await validateBundledClientAssets(path.dirname(bundledClientEntry)); await fs.rm(stageRoot, { recursive: true, force: true }); await fs.mkdir(path.join(stageAppDir, "apps/desktop"), { recursive: true }); await fs.mkdir(path.join(stageAppDir, "apps/server"), { recursive: true }); console.log("[desktop-dmg] Staging release app..."); await fs.cp(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron"), { recursive: true, }); await fs.cp(distDirs.desktopResources, path.join(stageAppDir, "apps/desktop/resources"), { recursive: true, }); await fs.cp(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist"), { recursive: true }); await stageProductionIcons(path.join(stageAppDir, "apps/desktop/resources")); const stagePackageJson = { name: "t3-code-desktop", version: appVersion, private: true, description: "T3 Code desktop build", author: "T3 Tools", main: "apps/desktop/dist-electron/main.js", build: { appId: "com.t3tools.t3code", productName: desktopPkg.productName ?? "T3 Code", artifactName: "T3-Code-${version}-${arch}.${ext}", directories: { buildResources: "apps/desktop/resources", }, mac: { target: ["dmg"], icon: "icon.icns", category: "public.app-category.developer-tools", }, }, dependencies, devDependencies: { electron: electronVersion, }, }; await fs.writeFile( path.join(stageAppDir, "package.json"), `${JSON.stringify(stagePackageJson, null, 2)}\n`, "utf8", ); console.log("[desktop-dmg] Installing staged production dependencies..."); await run("bun", ["install", "--production"], { cwd: stageAppDir }); const buildEnv = { ...process.env, }; if (!options.signed) { buildEnv.CSC_IDENTITY_AUTO_DISCOVERY = "false"; } const buildArgs = ["--bun", "electron-builder", "--mac", "dmg", `--${options.arch}`]; console.log(`[desktop-dmg] Building .dmg (arch=${options.arch}, version=${appVersion})...`); await run("bunx", buildArgs, { cwd: stageAppDir, env: buildEnv }); const stageDistDir = path.join(stageAppDir, "dist"); const stageFiles = await fs.readdir(stageDistDir); const dmgFiles = stageFiles.filter((file) => file.endsWith(".dmg")); if (dmgFiles.length === 0) { fail(`Build completed but no .dmg found in ${stageDistDir}`); } await fs.mkdir(options.outputDir, { recursive: true }); const copiedArtifacts = []; for (const file of stageFiles) { if (!file.endsWith(".dmg") && !file.endsWith(".dmg.blockmap")) continue; const from = path.join(stageDistDir, file); const to = path.join(options.outputDir, file); await fs.copyFile(from, to); copiedArtifacts.push(to); } if (!options.keepStage) { await fs.rm(stageRoot, { recursive: true, force: true }); } console.log("[desktop-dmg] Done. Artifacts:"); for (const artifact of copiedArtifacts) { console.log(` - ${artifact}`); }} main().catch((error) => { const message = error instanceof Error ? error.message : String(error); fail(message);});
#!/usr/bin/env node
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, "..");
const PRODUCTION_ICON_SOURCE = path.join(repoRoot, "assets/macos-icon-1024.png");
const ARCH_CHOICES = new Set(["arm64", "x64", "universal"]);
function printUsage() {
console.log(`Build a shareable macOS .dmg for T3 Code desktop.
Usage:
node scripts/build-desktop-dmg.mjs [options]
Options:
--arch <arm64|x64|universal> Target arch (default: host arch if supported)
--version <semver> App version in DMG metadata (default: apps/server version)
--output-dir <path> Output folder for final artifacts (default: release)
--skip-build Skip 'bun run build:desktop' (requires existing dist artifacts)
--keep-stage Keep temporary staging directory
--signed Allow automatic code signing discovery
--help Show this message
`);
}
function fail(message) {
console.error(`[desktop-dmg] ${message}`);
process.exit(1);
}
function parseArgs(argv) {
const defaultArch = process.arch === "arm64" || process.arch === "x64" ? process.arch : "arm64";
const options = {
arch: defaultArch,
version: null,
skipBuild: false,
keepStage: false,
signed: false,
outputDir: path.join(repoRoot, "release"),
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--help") {
options.help = true;
continue;
}
if (arg === "--skip-build") {
options.skipBuild = true;
continue;
}
if (arg === "--keep-stage") {
options.keepStage = true;
continue;
}
if (arg === "--signed") {
options.signed = true;
continue;
}
if (arg === "--arch" || arg === "--version" || arg === "--output-dir") {
const value = argv[i + 1];
if (!value || value.startsWith("--")) {
fail(`Missing value for ${arg}`);
}
i += 1;
if (arg === "--arch") options.arch = value;
if (arg === "--version") options.version = value;
if (arg === "--output-dir") options.outputDir = path.resolve(repoRoot, value);
continue;
}
fail(`Unknown argument: ${arg}`);
}
if (!ARCH_CHOICES.has(options.arch)) {
fail(`Invalid arch '${options.arch}'. Expected one of: ${Array.from(ARCH_CHOICES).join(", ")}`);
}
return options;
}
async function readJson(filePath) {
const raw = await fs.readFile(filePath, "utf8");
return JSON.parse(raw);
}
async function exists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
function run(command, args, { cwd, env = process.env, stdio = "inherit" } = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd,
env,
stdio,
});
child.on("error", reject);
child.on("exit", (code, signal) => {
if (signal) {
reject(new Error(`${command} exited via signal ${signal}`));
return;
}
if (code !== 0) {
reject(new Error(`${command} exited with code ${code}`));
return;
}
resolve();
});
});
}
async function generateMacIconSet(sourcePng, targetIcns, tmpRoot) {
const iconsetDir = path.join(tmpRoot, "icon.iconset");
await fs.mkdir(iconsetDir, { recursive: true });
const iconSizes = [16, 32, 128, 256, 512];
for (const size of iconSizes) {
await run(
"sips",
[
"-z",
String(size),
String(size),
sourcePng,
"--out",
path.join(iconsetDir, `icon_${size}x${size}.png`),
],
{ stdio: "ignore" },
);
const retinaSize = size * 2;
await run(
"sips",
[
"-z",
String(retinaSize),
String(retinaSize),
sourcePng,
"--out",
path.join(iconsetDir, `icon_${size}x${size}@2x.png`),
],
{ stdio: "ignore" },
);
}
await run("iconutil", ["-c", "icns", iconsetDir, "-o", targetIcns], { stdio: "ignore" });
}
async function stageProductionIcons(stageResourcesDir) {
if (!(await exists(PRODUCTION_ICON_SOURCE))) {
fail(`Production icon source is missing at ${PRODUCTION_ICON_SOURCE}`);
}
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "t3code-icon-build-"));
try {
const iconPngPath = path.join(stageResourcesDir, "icon.png");
const iconIcnsPath = path.join(stageResourcesDir, "icon.icns");
await run("sips", ["-z", "512", "512", PRODUCTION_ICON_SOURCE, "--out", iconPngPath], {
stdio: "ignore",
});
await generateMacIconSet(PRODUCTION_ICON_SOURCE, iconIcnsPath, tmpRoot);
} finally {
await fs.rm(tmpRoot, { recursive: true, force: true });
}
}
async function validateBundledClientAssets(clientDir) {
const indexPath = path.join(clientDir, "index.html");
const indexHtml = await fs.readFile(indexPath, "utf8");
const refs = [...indexHtml.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)].map((match) => match[1]);
const missing = [];
for (const ref of refs) {
const normalizedRef = ref.split("#")[0]?.split("?")[0] ?? "";
if (!normalizedRef) continue;
if (normalizedRef.startsWith("http://") || normalizedRef.startsWith("https://")) continue;
if (normalizedRef.startsWith("data:") || normalizedRef.startsWith("mailto:")) continue;
const ext = path.extname(normalizedRef);
if (!ext) continue;
const relativePath = normalizedRef.replace(/^\/+/, "");
const assetPath = path.join(clientDir, relativePath);
if (!(await exists(assetPath))) {
missing.push(normalizedRef);
}
}
if (missing.length > 0) {
const preview = missing.slice(0, 6).join(", ");
const suffix = missing.length > 6 ? ` (+${missing.length - 6} more)` : "";
fail(
`Bundled client references missing files in ${indexPath}: ${preview}${suffix}. Rebuild web/server artifacts.`,
);
}
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printUsage();
return;
}
if (process.platform !== "darwin") {
fail("This script only builds macOS .dmg artifacts and must run on macOS.");
}
const desktopPkg = await readJson(path.join(repoRoot, "apps/desktop/package.json"));
const serverPkg = await readJson(path.join(repoRoot, "apps/server/package.json"));
const electronVersion = desktopPkg.dependencies?.electron;
if (typeof electronVersion !== "string" || electronVersion.length === 0) {
fail("Could not resolve apps/desktop electron dependency version.");
}
const dependencies = serverPkg.dependencies;
if (!dependencies || Object.keys(dependencies).length === 0) {
fail("Could not resolve production dependencies from apps/server/package.json.");
}
const appVersion = options.version ?? serverPkg.version ?? "0.1.0";
const stageRoot = path.join(os.tmpdir(), "t3code-desktop-dmg-stage");
const stageAppDir = path.join(stageRoot, "app");
const distDirs = {
desktopDist: path.join(repoRoot, "apps/desktop/dist-electron"),
desktopResources: path.join(repoRoot, "apps/desktop/resources"),
serverDist: path.join(repoRoot, "apps/server/dist"),
};
const bundledClientEntry = path.join(distDirs.serverDist, "client/index.html");
if (!options.skipBuild) {
console.log("[desktop-dmg] Building desktop/server/web artifacts...");
await run("bun", ["run", "build:desktop"], { cwd: repoRoot });
}
for (const [label, dir] of Object.entries(distDirs)) {
if (!(await exists(dir))) {
fail(`Missing ${label} at ${dir}. Run 'bun run build:desktop' first.`);
}
}
if (!(await exists(bundledClientEntry))) {
fail(
`Missing bundled server client at ${bundledClientEntry}. Run 'bun run build:desktop' first.`,
);
}
await validateBundledClientAssets(path.dirname(bundledClientEntry));
await fs.rm(stageRoot, { recursive: true, force: true });
await fs.mkdir(path.join(stageAppDir, "apps/desktop"), { recursive: true });
await fs.mkdir(path.join(stageAppDir, "apps/server"), { recursive: true });
console.log("[desktop-dmg] Staging release app...");
await fs.cp(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron"), {
recursive: true,
});
await fs.cp(distDirs.desktopResources, path.join(stageAppDir, "apps/desktop/resources"), {
recursive: true,
});
await fs.cp(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist"), { recursive: true });
await stageProductionIcons(path.join(stageAppDir, "apps/desktop/resources"));
const stagePackageJson = {
name: "t3-code-desktop",
version: appVersion,
private: true,
description: "T3 Code desktop build",
author: "T3 Tools",
main: "apps/desktop/dist-electron/main.js",
build: {
appId: "com.t3tools.t3code",
productName: desktopPkg.productName ?? "T3 Code",
artifactName: "T3-Code-${version}-${arch}.${ext}",
directories: {
buildResources: "apps/desktop/resources",
},
mac: {
target: ["dmg"],
icon: "icon.icns",
category: "public.app-category.developer-tools",
},
},
dependencies,
devDependencies: {
electron: electronVersion,
},
};
await fs.writeFile(
path.join(stageAppDir, "package.json"),
`${JSON.stringify(stagePackageJson, null, 2)}\n`,
"utf8",
);
console.log("[desktop-dmg] Installing staged production dependencies...");
await run("bun", ["install", "--production"], { cwd: stageAppDir });
const buildEnv = {
...process.env,
};
if (!options.signed) {
buildEnv.CSC_IDENTITY_AUTO_DISCOVERY = "false";
}
const buildArgs = ["--bun", "electron-builder", "--mac", "dmg", `--${options.arch}`];
console.log(`[desktop-dmg] Building .dmg (arch=${options.arch}, version=${appVersion})...`);
await run("bunx", buildArgs, { cwd: stageAppDir, env: buildEnv });
const stageDistDir = path.join(stageAppDir, "dist");
const stageFiles = await fs.readdir(stageDistDir);
const dmgFiles = stageFiles.filter((file) => file.endsWith(".dmg"));
if (dmgFiles.length === 0) {
fail(`Build completed but no .dmg found in ${stageDistDir}`);
}
await fs.mkdir(options.outputDir, { recursive: true });
const copiedArtifacts = [];
for (const file of stageFiles) {
if (!file.endsWith(".dmg") && !file.endsWith(".dmg.blockmap")) continue;
const from = path.join(stageDistDir, file);
const to = path.join(options.outputDir, file);
await fs.copyFile(from, to);
copiedArtifacts.push(to);
}
if (!options.keepStage) {
await fs.rm(stageRoot, { recursive: true, force: true });
}
console.log("[desktop-dmg] Done. Artifacts:");
for (const artifact of copiedArtifacts) {
console.log(` - ${artifact}`);
}
}
main().catch((error) => {
const message = error instanceof Error ? error.message : String(error);
fail(message);
});