https://github.com/user-attachments/assets/cb559264-e247-4161-bb8e-27c1153751c0

Open install-dmg in Script Kit

// Name: Install app from dmg in Downloads
/// Implementation notes:
/// - dmg file name can be different from mounted volume name, e.g. UIBrowser302.dmg -> /Volumes/UI Browser 3.0.2.0
/// - dmg might contain License agreement that needs to be accepted, e.g. UIBrowser302.dmg
/// - dmg might contain other files than just the app, e.g. Extras folder and README.rtf, see UIBrowser302.dmg
import "@johnlindquist/kit"
import fs, {statSync, unlinkSync} from "fs";
import {join} from "path";
import * as luxon from "luxon"
import {execa} from "execa";
import {execSync} from "child_process"
let downloadsDir = home("Downloads") // Feel free to change
let dmgPaths = await globby("*.dmg", { cwd: downloadsDir })
let dmgObjs = dmgPaths.map(path => ({
fullPath: join(downloadsDir, path),
baseName: path.split("/").pop()?.replace(".dmg", ""),
createdAt: statSync(join(downloadsDir, path)).ctime.getTime(),
sizeInMb: statSync(join(downloadsDir, path)).size / 1024 / 1024
})).sort((a, b) => b.createdAt - a.createdAt)
if (dmgObjs.length === 0) {
setPlaceholder("No DMG files found in Downloads directory")
} else {
let selectedDmgPath = await arg({
placeholder: "Which dmg?",
choices: dmgObjs.map(dmg => ({
value: dmg.fullPath,
name: dmg.baseName,
description: `${luxon.DateTime.fromMillis(dmg.createdAt).toFormat('yyyy-MM-dd HH:mm')}${dmg.sizeInMb.toFixed(2)} MB`
}))
})
console.log(`Mounting ${selectedDmgPath}`)
let volumeName = await attachDmg(selectedDmgPath)
let mountPath = `/Volumes/${volumeName}`;
console.log(`Mounted to ${mountPath}`)
// Note: Globby did not work for me for mounted volumes
let apps = fs.readdirSync(mountPath).filter(f => f.endsWith(".app"))
if (apps.length === 0) {
setPlaceholder("No apps found in the mounted volume")
// TODO: Find a better way to do early returns/exits
} else {
let confirmed = await arg({
placeholder: `Found ${apps.length} apps: ${apps.join(", ")}, install?`,
choices: ["yes", "no"]
})
if (confirmed !== "yes") {
notify("Aborted")
process.exit(0)
}
for (let app of apps) {
console.log(`Copying ${app} to /Applications folder`);
await execa(`cp`, [
'-a', `${mountPath}/${app}`,
'/Applications/'
]);
}
console.log(`Detaching ${mountPath}`)
await detachDmg(mountPath)
let confirmDeletion = await arg({
placeholder: `Delete ${selectedDmgPath}?`,
choices: ["yes", "no"]
})
if (confirmDeletion === "yes") {
console.log(`Deleting ${selectedDmgPath}`)
await trash(selectedDmgPath)
}
}
}
// Helpers
// ===
async function attachDmg(dmgPath: string): Promise<string> {
// https://superuser.com/questions/221136/bypass-a-licence-agreement-when-mounting-a-dmg-on-the-command-line
let out = execSync(`yes | PAGER=cat hdiutil attach "${dmgPath}"`).toString()
let lines = out.split("\n").reverse()
// from the end, find line with volume name
// /dev/disk6s2 Apple_HFS /Volumes/UI Browser 3.0.2.0
let lineWithVolume = lines.find(line => line.includes("/Volumes/"))
if (!lineWithVolume) {
throw new Error(`Failed to find volume name in output: ${out}`)
}
let volumeName = lineWithVolume.split(`/Volumes/`)[1]
return volumeName
}
async function detachDmg(mountPoint: string) {
await execa('hdiutil', ['detach', mountPoint])
}