buddymac 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/cli.mjs +112 -21
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
# BuddyMac installer
|
|
2
2
|
|
|
3
3
|
```sh
|
|
4
|
-
npx buddymac
|
|
4
|
+
npx buddymac@latest
|
|
5
5
|
```
|
|
6
6
|
|
|
7
7
|
Downloads BuddyMac to `~/Applications` and opens it. Requires an Apple Silicon Mac running macOS 26 or later and Node.js 20 or later. No Bun installation is needed.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Run the same command to update. The installer downloads and verifies the new app first, then waits for you to quit BuddyMac before replacing it. Your settings and app data stay saved. A current or newer installed version is opened without downloading again.
|
|
10
|
+
|
|
11
|
+
The installer checks a pinned SHA-256 checksum, Francesco Oddo's Apple Developer signature, and macOS Gatekeeper approval. It keeps the previous app at `~/Applications/.buddymac-previous.app` and restores it if replacement fails. Interrupted replacements are recovered on the next run. The backup is replaced on the next successful update.
|
|
10
12
|
|
|
11
13
|
```sh
|
|
12
|
-
npx buddymac --no-open
|
|
13
|
-
npx buddymac --destination /Applications
|
|
14
|
-
npx buddymac --help
|
|
14
|
+
npx buddymac@latest --no-open
|
|
15
|
+
npx buddymac@latest --destination /Applications
|
|
16
|
+
npx buddymac@latest --help
|
|
15
17
|
```
|
|
16
18
|
|
|
17
19
|
The app is signed and notarized by Apple, with the notarization ticket included. The installer preserves macOS download protection.
|
package/cli.mjs
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
-
import { createWriteStream } from 'node:fs';
|
|
4
|
-
import {
|
|
3
|
+
import { createWriteStream, renameSync } from 'node:fs';
|
|
4
|
+
import { lstat, mkdir, mkdtemp, readFile, rm, stat } from 'node:fs/promises';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
import { join, resolve } from 'node:path';
|
|
7
7
|
import { spawn, spawnSync } from 'node:child_process';
|
|
8
8
|
import { Transform } from 'node:stream';
|
|
9
9
|
import { pipeline } from 'node:stream/promises';
|
|
10
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
10
11
|
|
|
11
12
|
const release = JSON.parse(await readFile(new URL('./release.json', import.meta.url), 'utf8'));
|
|
12
13
|
const { version: installerVersion } = JSON.parse(await readFile(new URL('./package.json', import.meta.url), 'utf8'));
|
|
13
14
|
const args = process.argv.slice(2);
|
|
14
15
|
const help = `Usage: npx buddymac [--destination <folder>] [--no-open]
|
|
15
16
|
|
|
16
|
-
Installs BuddyMac ${release.version}
|
|
17
|
+
Installs or updates BuddyMac ${release.version} in ~/Applications and opens it.
|
|
17
18
|
Requires Apple Silicon, macOS 26+, and Node.js 20+.
|
|
18
|
-
|
|
19
|
+
Updates preserve settings and keep the previous app for recovery.
|
|
20
|
+
If BuddyMac is running, quit it when prompted to finish the update.
|
|
19
21
|
The download is signed and notarized by Apple. macOS download protection stays enabled.
|
|
20
22
|
|
|
21
23
|
--destination <folder> Install in a different folder
|
|
@@ -31,6 +33,65 @@ function run(command, parameters) {
|
|
|
31
33
|
return result.stdout.trim();
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
const identifier = 'org.buddytools.BuddyMac';
|
|
37
|
+
const signingRequirement = '=anchor apple generic and certificate leaf[subject.OU] = "G2442WAF29"';
|
|
38
|
+
function appVersion(app) {
|
|
39
|
+
const plist = join(app, 'Contents/Info.plist');
|
|
40
|
+
if (run('/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleIdentifier', plist]) !== identifier) {
|
|
41
|
+
throw new Error(`Refusing to replace an unrelated app at ${app}.`);
|
|
42
|
+
}
|
|
43
|
+
const version = run('/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleShortVersionString', plist]);
|
|
44
|
+
if (!/^\d+\.\d+\.\d+$/.test(version)) throw new Error(`Unrecognized app version at ${app}: ${version}`);
|
|
45
|
+
return version;
|
|
46
|
+
}
|
|
47
|
+
function compareVersions(left, right) {
|
|
48
|
+
const a = left.split('.').map(Number), b = right.split('.').map(Number);
|
|
49
|
+
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] - b[i];
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
async function exists(path) {
|
|
53
|
+
try { await lstat(path); return true; }
|
|
54
|
+
catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
55
|
+
}
|
|
56
|
+
async function installedVersion(app) {
|
|
57
|
+
if (!await exists(app)) return null;
|
|
58
|
+
const info = await lstat(app);
|
|
59
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`Expected an app folder at ${app}. Move it before installing.`);
|
|
60
|
+
const version = appVersion(app);
|
|
61
|
+
run('/usr/bin/codesign', ['--verify', '--deep', '--strict', '-R', signingRequirement, app]);
|
|
62
|
+
return version;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const runningAppsScript = `ObjC.import('AppKit');
|
|
66
|
+
const apps = $.NSRunningApplication.runningApplicationsWithBundleIdentifier('${identifier}');
|
|
67
|
+
const paths = [];
|
|
68
|
+
for (let i = 0; i < apps.count; i++) paths.push(ObjC.unwrap(apps.objectAtIndex(i).bundleURL.path));
|
|
69
|
+
JSON.stringify(paths);`;
|
|
70
|
+
async function isRunning(app) {
|
|
71
|
+
const executable = join(app, 'Contents/MacOS/BuddyMac');
|
|
72
|
+
const original = await stat(executable, { bigint: true });
|
|
73
|
+
const paths = JSON.parse(run('/usr/bin/osascript', ['-l', 'JavaScript', '-e', runningAppsScript]));
|
|
74
|
+
for (const path of paths) {
|
|
75
|
+
if (path === app) return true;
|
|
76
|
+
try {
|
|
77
|
+
const running = await stat(join(path, 'Contents/MacOS/BuddyMac'), { bigint: true });
|
|
78
|
+
// App Translocation changes the device, but retains the original file identity.
|
|
79
|
+
if (running.ino === original.ino && running.birthtimeNs === original.birthtimeNs &&
|
|
80
|
+
(running.dev === original.dev || path.includes('/AppTranslocation/'))) return true;
|
|
81
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
async function waitForQuit(app, signal) {
|
|
86
|
+
if (!await isRunning(app)) return;
|
|
87
|
+
process.stdout.write('Quit BuddyMac from its menu bar menu to finish updating. Waiting up to 2 minutes; Ctrl+C cancels.\n');
|
|
88
|
+
const deadline = Date.now() + 120_000;
|
|
89
|
+
while (await isRunning(app)) {
|
|
90
|
+
if (Date.now() >= deadline) throw new Error('BuddyMac is still running. Nothing was replaced. Quit it and run this command again.');
|
|
91
|
+
await sleep(1000, undefined, { signal });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
34
95
|
async function install() {
|
|
35
96
|
const started = performance.now();
|
|
36
97
|
let destination = join(homedir(), 'Applications');
|
|
@@ -52,18 +113,27 @@ async function install() {
|
|
|
52
113
|
if (Number(macOS.split('.')[0]) < 26) throw new Error(`BuddyMac requires macOS 26 or later; found ${macOS}.`);
|
|
53
114
|
await mkdir(destination, { recursive: true });
|
|
54
115
|
const target = join(destination, 'BuddyMac.app');
|
|
116
|
+
const backup = join(destination, '.buddymac-previous.app');
|
|
55
117
|
const lock = join(destination, '.buddymac-install-lock');
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
});
|
|
118
|
+
const locked = spawnSync('/usr/bin/shlock', ['-f', lock, '-p', String(process.pid)], { encoding: 'utf8' });
|
|
119
|
+
if (locked.error) throw locked.error;
|
|
120
|
+
if (locked.status !== 0) throw new Error(`Another install may be running. If an older installer stopped, remove ${lock} and retry.`);
|
|
60
121
|
let stage;
|
|
122
|
+
const cancel = new AbortController();
|
|
123
|
+
const onCancel = () => cancel.abort(new Error('Installation cancelled.'));
|
|
124
|
+
process.on('SIGINT', onCancel);
|
|
125
|
+
process.on('SIGTERM', onCancel);
|
|
61
126
|
try {
|
|
62
|
-
|
|
63
|
-
await
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
127
|
+
if (!await exists(target) && await exists(backup)) {
|
|
128
|
+
await installedVersion(backup);
|
|
129
|
+
renameSync(backup, target);
|
|
130
|
+
process.stdout.write('Recovered the previous app from an interrupted update.\n');
|
|
131
|
+
}
|
|
132
|
+
const previousVersion = await installedVersion(target);
|
|
133
|
+
if (previousVersion && compareVersions(previousVersion, release.version) >= 0) {
|
|
134
|
+
process.stdout.write(`BuddyMac ${previousVersion} is already installed${previousVersion === release.version ? ' and up to date' : '; keeping the newer version'}.\n`);
|
|
135
|
+
if (open) run('/usr/bin/open', [target]);
|
|
136
|
+
return;
|
|
67
137
|
}
|
|
68
138
|
stage = await mkdtemp(join(destination, '.buddymac-'));
|
|
69
139
|
const archive = join(stage, 'BuddyMac.tar.xz');
|
|
@@ -72,7 +142,7 @@ async function install() {
|
|
|
72
142
|
const download = spawn('/usr/bin/curl', [
|
|
73
143
|
'--disable', '--fail', '--location', '--http2', '--proto', '=https', '--proto-redir', '=https',
|
|
74
144
|
'--silent', '--show-error', '--connect-timeout', '20', '--max-time', '300', release.url,
|
|
75
|
-
], { env: { ...process.env }, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
145
|
+
], { env: { ...process.env }, stdio: ['ignore', 'pipe', 'pipe'], signal: cancel.signal });
|
|
76
146
|
let downloadError = '';
|
|
77
147
|
download.stderr.on('data', chunk => { downloadError = (downloadError + chunk.toString()).slice(-4096); });
|
|
78
148
|
const finished = new Promise((resolve, reject) => {
|
|
@@ -110,18 +180,39 @@ async function install() {
|
|
|
110
180
|
const app = join(stage, 'BuddyMac.app');
|
|
111
181
|
process.stdout.write(`Unpacked in ${((performance.now() - unpackStarted) / 1000).toFixed(1)}s. Checking Apple signature and notarization…\n`);
|
|
112
182
|
const verificationStarted = performance.now();
|
|
113
|
-
run('/usr/bin/codesign', ['--verify', '--deep', '--strict', '-R',
|
|
114
|
-
|
|
115
|
-
if (identifier !== 'org.buddytools.BuddyMac') throw new Error('Unexpected application identifier.');
|
|
183
|
+
run('/usr/bin/codesign', ['--verify', '--deep', '--strict', '-R', signingRequirement, app]);
|
|
184
|
+
if (appVersion(app) !== release.version) throw new Error('Downloaded app version does not match the release.');
|
|
116
185
|
run('/usr/bin/xattr', ['-w', 'com.apple.quarantine', `0081;${Math.floor(Date.now() / 1000).toString(16)};BuddyMac Installer;`, app]);
|
|
117
186
|
run('/usr/sbin/spctl', ['--assess', '--type', 'execute', app]);
|
|
118
187
|
process.stdout.write(`Apple verification passed in ${((performance.now() - verificationStarted) / 1000).toFixed(1)}s.\n`);
|
|
119
|
-
|
|
120
|
-
|
|
188
|
+
if (previousVersion) {
|
|
189
|
+
await waitForQuit(target, cancel.signal);
|
|
190
|
+
if (await installedVersion(target) !== previousVersion) throw new Error('The installed app changed during download. Run the installer again.');
|
|
191
|
+
if (await exists(backup)) {
|
|
192
|
+
await installedVersion(backup);
|
|
193
|
+
await waitForQuit(backup, cancel.signal);
|
|
194
|
+
await rm(backup, { recursive: true });
|
|
195
|
+
}
|
|
196
|
+
} else if (await exists(target)) throw new Error('An app appeared at the destination during download. Run the installer again.');
|
|
197
|
+
cancel.signal.throwIfAborted();
|
|
198
|
+
if (previousVersion) renameSync(target, backup);
|
|
199
|
+
try { renameSync(app, target); }
|
|
200
|
+
catch (error) {
|
|
201
|
+
if (previousVersion) {
|
|
202
|
+
try { renameSync(backup, target); }
|
|
203
|
+
catch { throw new Error(`Update failed and automatic recovery could not finish. Your previous app is safe at ${backup}. Move it back to ${target}.`, { cause: error }); }
|
|
204
|
+
process.stderr.write('Update failed. The previous app was restored.\n');
|
|
205
|
+
}
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
process.stdout.write(`${previousVersion ? `Updated BuddyMac ${previousVersion} → ${release.version} at` : 'Installed'} ${target} in ${((performance.now() - started) / 1000).toFixed(1)}s.\n`);
|
|
209
|
+
if (previousVersion) process.stdout.write(`Settings are unchanged. Previous app saved at ${backup}.\n`);
|
|
121
210
|
if (open) run('/usr/bin/open', [target]);
|
|
122
211
|
} finally {
|
|
123
|
-
|
|
124
|
-
|
|
212
|
+
process.removeListener('SIGINT', onCancel);
|
|
213
|
+
process.removeListener('SIGTERM', onCancel);
|
|
214
|
+
try { if (stage) await rm(stage, { recursive: true, force: true }); }
|
|
215
|
+
finally { await rm(lock, { force: true }); }
|
|
125
216
|
}
|
|
126
217
|
}
|
|
127
218
|
|