buddymac 0.1.4 → 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 CHANGED
@@ -1,21 +1,23 @@
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
- The installer shows download progress and checks a pinned SHA-256 checksum, Francesco Oddo's Apple Developer signature, and macOS Gatekeeper approval. It does not overwrite existing apps or change your app data. To update, quit BuddyMac and move the previous app out of the destination first.
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.
18
20
 
19
21
  Source and downloads: https://github.com/OxFrancesco/BuddyMac
20
22
 
21
- Installer 0.1.4 downloads the same notarized BuddyMac 0.1.3 app using macOS curl with HTTP/2 and a smaller tar.xz archive. The installed app remains in Applications after Terminal closes.
23
+ The installer downloads the notarized app using macOS curl with HTTP/2 and a smaller tar.xz archive. The installed app remains in Applications after Terminal closes.
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 { access, mkdir, mkdtemp, readFile, rename, rm } from 'node:fs/promises';
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} to ~/Applications and opens it.
17
+ Installs or updates BuddyMac ${release.version} in ~/Applications and opens it.
17
18
  Requires Apple Silicon, macOS 26+, and Node.js 20+.
18
- Existing apps are never overwritten. Quit and move an old copy before updating.
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
- await mkdir(lock).catch(error => {
57
- if (error.code === 'EEXIST') throw new Error(`Another install may be running. If it stopped, remove ${lock} and retry.`);
58
- throw error;
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
- try {
63
- await access(target);
64
- throw new Error(`${target} already exists. Quit BuddyMac and move that copy before installing, or choose another --destination.`);
65
- } catch (error) {
66
- if (error.code !== 'ENOENT') throw error;
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', '=anchor apple generic and certificate leaf[subject.OU] = "G2442WAF29"', app]);
114
- const identifier = run('/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleIdentifier', join(app, 'Contents/Info.plist')]);
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
- await rename(app, target);
120
- process.stdout.write(`Installed ${target} in ${((performance.now() - started) / 1000).toFixed(1)}s.\n`);
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
- if (stage) await rm(stage, { recursive: true, force: true });
124
- await rm(lock, { recursive: true, force: true });
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buddymac",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Download and install BuddyMac for Apple Silicon Macs",
5
5
  "type": "module",
6
6
  "bin": {
package/release.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "0.1.3",
3
- "url": "https://github.com/OxFrancesco/BuddyMac/releases/download/v0.1.3/BuddyMac-darwin-arm64.tar.xz",
4
- "sha256": "5baa5ab5d4c45154c3b63f78e14bd7cbab0dc6886173f5e839feaa995943a206",
5
- "bytes": 28078884
2
+ "version": "0.1.5",
3
+ "url": "https://github.com/OxFrancesco/BuddyMac/releases/download/v0.1.5/BuddyMac-darwin-arm64.tar.xz",
4
+ "sha256": "f4eeeaee0482def2dd51e8ba868f64a23937c3b23d9f940818f4e5357816f6a9",
5
+ "bytes": 28083068
6
6
  }