@intincrab/updiff 0.1.1 → 0.2.0
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 +64 -18
- package/dist/agent.js +1 -1
- package/dist/bundle.js +63 -5
- package/dist/cask-history.js +216 -0
- package/dist/cask.js +79 -0
- package/dist/codesign.js +41 -0
- package/dist/commands.js +156 -5
- package/dist/diff.js +99 -3
- package/dist/extensions.js +453 -0
- package/dist/fetch.js +81 -0
- package/dist/hosts.js +17 -0
- package/dist/installer.js +177 -0
- package/dist/macho.js +35 -0
- package/dist/pkg-report.js +211 -0
- package/dist/pkg.js +211 -0
- package/dist/report.js +31 -2
- package/dist/store.js +21 -0
- package/dist/util.js +34 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,10 +16,14 @@ camera, register something that starts at login, ship a new helper that runs as
|
|
|
16
16
|
bundle a new analytics package, or start talking to a new service. updiff takes a
|
|
17
17
|
snapshot of each app and, after every update, shows you exactly what changed inside it.
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
It also covers what updates without asking: **browser and editor extensions**, and
|
|
20
|
+
**installer packages** you're about to run.
|
|
21
|
+
|
|
22
|
+
- **Read-only.** It never runs the app, an installer or its scripts, and never changes them.
|
|
23
|
+
- **Local.** Snapshots and reports stay in `~/.updiff`. Nothing is uploaded. Only `fetch`
|
|
24
|
+
downloads anything, and Gatekeeper may ask Apple whether an app is notarized.
|
|
25
|
+
- **Fast.** The first snapshot of 28 apps takes about 40 s on an Apple Silicon Mac (11 s
|
|
26
|
+
with `--no-gatekeeper`); later checks skip unchanged apps and take about 0.1 s.
|
|
23
27
|
|
|
24
28
|
## What it looks like
|
|
25
29
|
|
|
@@ -131,7 +135,27 @@ updiff agent install # runs `updiff check` whenever your app folders change
|
|
|
131
135
|
|
|
132
136
|
The agent waits until an update has finished writing, then posts a notification titled
|
|
133
137
|
"Updiff: Google Chrome 156.0.8078.80" that says "0 high, 2 notable changes". The full
|
|
134
|
-
report is saved in `~/.updiff/reports/`.
|
|
138
|
+
report is saved in `~/.updiff/reports/`. It also checks your browser and editor extensions.
|
|
139
|
+
|
|
140
|
+
No baseline yet? Compare an installed app with its previous release, downloaded from
|
|
141
|
+
Homebrew's history and checked against the checksum Homebrew recorded:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
updiff fetch Ghostty # Ghostty 1.3.0 → 1.3.1: 0 high · 0 notable · 2 info
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Before running an installer, see what it would do:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
updiff pkg ~/Downloads/Installer.pkg
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
And see what your extensions can reach, or what changed since last time:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
updiff extensions
|
|
157
|
+
updiff extensions check
|
|
158
|
+
```
|
|
135
159
|
|
|
136
160
|
## Commands
|
|
137
161
|
|
|
@@ -143,6 +167,9 @@ report is saved in `~/.updiff/reports/`.
|
|
|
143
167
|
| `updiff diff <app>` | If the installed app changed since its newest snapshot, compare the two; otherwise compare its two newest snapshots. |
|
|
144
168
|
| `updiff diff <a> <b>` | Compare any two things: `.app` bundles, snapshot `.json` files or stored apps. Good for a downloaded beta against the installed version. |
|
|
145
169
|
| `updiff list [app]` | List stored apps, or one app's snapshots. |
|
|
170
|
+
| `updiff fetch <app> [version]` | Download an earlier release from the app's Homebrew cask history, verify it against the checksum Homebrew pinned, store it as a snapshot and compare it with the installed app. With no version, the release before the installed one. `--list` shows the history. |
|
|
171
|
+
| `updiff pkg <installer.pkg> [older.pkg]` | Show what an installer package would do before you run it: scripts with their text, installer JavaScript, apps, launch daemons and agents (including ones apps register from inside their bundle), privileged helpers, system extensions, everything written outside /Applications, signature and Gatekeeper's verdict. With two packages, what changed. Nothing is installed or run. |
|
|
172
|
+
| `updiff extensions [check]` | List browser and editor extensions with what they can reach. `check` reports what changed since the last check. |
|
|
146
173
|
| `updiff agent install \| uninstall \| status` | Manage the LaunchAgent that runs `check` after updates. |
|
|
147
174
|
|
|
148
175
|
`<app>` can be a path (`/Applications/Slack.app`), a name from your Applications folders
|
|
@@ -162,6 +189,10 @@ report is saved in `~/.updiff/reports/`.
|
|
|
162
189
|
| `--home <dir>` | all | Data folder. Default `~/.updiff`, or `$UPDIFF_HOME`. |
|
|
163
190
|
| `--keep <n>` | `snapshot`, `check` | Snapshots kept per app (default 10). |
|
|
164
191
|
| `--notify` | `check` | Post a notification for each updated app. |
|
|
192
|
+
| `--extensions` | `check` | Also check browser and editor extensions (the agent does). |
|
|
193
|
+
| `--no-gatekeeper` | scanning | Skip the Gatekeeper/notarization check (faster). |
|
|
194
|
+
| `--cask <name>` | `fetch` | The Homebrew cask, when it isn't the app's name. |
|
|
195
|
+
| `--list` | `fetch` | List the releases in the cask's history. |
|
|
165
196
|
| `--settle <seconds>` | `check` | Wait until no app has changed for this long before scanning. |
|
|
166
197
|
| `--quiet`, `--no-color` | all | Print less; plain text. `NO_COLOR` is respected too. |
|
|
167
198
|
| `--interval <seconds>` | `agent install` | Fallback timer between checks (default 21600, i.e. 6 hours). |
|
|
@@ -174,17 +205,26 @@ Exit codes: `0` success, `2` usage error (unknown app, bad option), `1` unexpect
|
|
|
174
205
|
|
|
175
206
|
| | High | Notable | Info |
|
|
176
207
|
|---|---|---|---|
|
|
177
|
-
| **Signing** | different developer team; no longer developer-signed; hardened runtime removed | signing certificate changed | hardened runtime added |
|
|
208
|
+
| **Signing** | different developer team; no longer developer-signed; hardened runtime removed; no longer notarized; Gatekeeper rejects it or can't verify its signature | signing certificate changed; distribution changed (Developer ID ↔ App Store) | hardened runtime added; notarization ticket stapled or unstapled |
|
|
178
209
|
| **Entitlements**, for every executable in the bundle | camera, microphone, location, contacts, calendars, photos, Apple Events, USB, Bluetooth, library validation off, debugging, Endpoint Security, network extensions, sandbox exceptions; sandbox removed | any other new entitlement; new values in app groups, keychain groups or associated domains | removals |
|
|
179
210
|
| **Privacy prompts** (`NS…UsageDescription`) | a new prompt | | reworded or removed prompts |
|
|
180
211
|
| **Background** | new LaunchAgent or LaunchDaemon, login item, privileged helper, system extension | changed background item | removals |
|
|
181
|
-
| **Updater** (Sparkle) | update feed moved; update signing key changed | feed added or removed | |
|
|
212
|
+
| **Updater** (Sparkle) | update feed moved; update signing key changed | feed added or removed; feed set in the code changed | |
|
|
182
213
|
| **Network** | App Transport Security allows plain HTTP | new ATS exception domains | |
|
|
183
214
|
| **Code** | | new helper apps, XPC services, app extensions, plug-ins, executables, native Node modules, frameworks | framework versions; removals |
|
|
184
215
|
| **Electron** | | Electron version; new bundled npm packages | package version changes and removals |
|
|
185
216
|
| **Hostnames** | | new domains | new hosts under domains already present; domains no longer mentioned |
|
|
186
217
|
| **URL schemes** | | new `scheme://` handlers | removals |
|
|
187
|
-
| **Identity** | the two sides are different apps | Dock icon shown or hidden | version, minimum macOS, size |
|
|
218
|
+
| **Identity** | the two sides are different apps | Dock icon shown or hidden; moved to or from the Mac App Store | version, minimum macOS, size |
|
|
219
|
+
| **Browser extensions** | access to all websites; sensitive APIs (cookies, history, webRequest, debugger, nativeMessaging, proxy…); a different update server; a new extension with access to all websites | other new permissions; access to specific new sites; a new extension | low-reach permissions (storage, alarms…); optional permissions; removals; versions |
|
|
220
|
+
| **Editor extensions** (VS Code, Cursor, Windsurf, VSCodium) | starts installing other extensions | now starts with every window; new npm packages; new native code; new domains in the code; a new extension | removals; versions |
|
|
221
|
+
|
|
222
|
+
Gatekeeper's verdict comes from `spctl`, which may contact Apple for apps without a
|
|
223
|
+
stapled notarization ticket. Sparkle feeds are read from `Info.plist` and, for apps that
|
|
224
|
+
set them in code (Ghostty, ChatGPT), from URLs in the binary; `scan` also shows this Mac's
|
|
225
|
+
update settings for the app. Binaries that are FairPlay-encrypted (iPhone and iPad apps)
|
|
226
|
+
are flagged, because their strings can't be read. Mac App Store apps for macOS are not
|
|
227
|
+
encrypted, so their hostnames are read like any other app's.
|
|
188
228
|
|
|
189
229
|
Code inside a bundle is matched between versions by its path and, if that fails, by its
|
|
190
230
|
bundle id. So a renamed helper isn't reported as removed plus added, and Chrome's
|
|
@@ -217,6 +257,8 @@ Framework binaries are only searched with `--deep`. Electron apps keep their own
|
|
|
217
257
|
~/.updiff/
|
|
218
258
|
├── snapshots/<bundle id>/<time>__<version>__<build>.json one JSON file per snapshot
|
|
219
259
|
├── reports/<bundle id>/<time>__<from>__to__<to>.md one report per detected update
|
|
260
|
+
├── extensions/<time>.json installed extensions (last 10)
|
|
261
|
+
├── reports/extensions/<time>__<from>__to__<to>.md one report per extension change
|
|
220
262
|
└── agent.log output of background checks
|
|
221
263
|
```
|
|
222
264
|
|
|
@@ -227,12 +269,14 @@ Delete the folder at any time to start over.
|
|
|
227
269
|
## Privacy and safety
|
|
228
270
|
|
|
229
271
|
- updiff reads files and runs Apple's own tools:
|
|
230
|
-
- `plutil` to read plists
|
|
231
|
-
- `codesign` to read signatures and
|
|
232
|
-
|
|
233
|
-
- `
|
|
234
|
-
-
|
|
235
|
-
- It never
|
|
272
|
+
- `plutil` and `defaults` to read plists and an app's update settings
|
|
273
|
+
- `codesign`, `spctl` and `stapler` to read signatures, Gatekeeper's verdict and
|
|
274
|
+
notarization tickets
|
|
275
|
+
- `hdiutil` (read-only) and `pkgutil --expand-full` to open disk images and packages
|
|
276
|
+
- `osascript` for notifications, `launchctl` for the agent
|
|
277
|
+
- It never runs app code, installer scripts or installer JavaScript.
|
|
278
|
+
- Nothing it records leaves your Mac. `fetch` downloads from Homebrew, GitHub and the
|
|
279
|
+
app's vendor; `spctl` may ask Apple about notarization. Nothing else uses the network.
|
|
236
280
|
- The agent is a LaunchAgent in your user account (`dev.updiff.check`). It runs with your
|
|
237
281
|
permissions, at low priority. `updiff agent install --dry-run` shows exactly what it
|
|
238
282
|
installs; `updiff agent uninstall` removes it.
|
|
@@ -243,10 +287,12 @@ Delete the folder at any time to start over.
|
|
|
243
287
|
the bundle, so updiff can't see them.
|
|
244
288
|
- **Hostnames are pattern-matched.** Hosts assembled at runtime (`"api." + region + ".x.com"`)
|
|
245
289
|
are missed, and a mentioned host is not necessarily contacted.
|
|
246
|
-
- **
|
|
247
|
-
|
|
248
|
-
- **
|
|
249
|
-
|
|
290
|
+
- **Baselines.** `check` compares with the last snapshot. For an app you never snapshotted,
|
|
291
|
+
`updiff fetch` gets its previous release, if the app is in Homebrew.
|
|
292
|
+
- **Update feeds built at runtime** (AltTab assembles its URL from pieces) can't be found;
|
|
293
|
+
`scan` says when Sparkle is bundled but no feed was found.
|
|
294
|
+
- **Not covered yet:** Safari web extensions' permissions, managed browser policies, and
|
|
295
|
+
extensions for JetBrains IDEs.
|
|
250
296
|
|
|
251
297
|
## The ledger
|
|
252
298
|
|
package/dist/agent.js
CHANGED
|
@@ -9,7 +9,7 @@ export function launchAgentsDir() {
|
|
|
9
9
|
return process.env.UPDIFF_LAUNCH_AGENTS_DIR || join(homedir(), 'Library', 'LaunchAgents');
|
|
10
10
|
}
|
|
11
11
|
export function agentPlist(o) {
|
|
12
|
-
const args = [o.nodePath, o.cliPath, 'check', '--notify', '--quiet', '--settle', '90', '--home', o.home];
|
|
12
|
+
const args = [o.nodePath, o.cliPath, 'check', '--extensions', '--notify', '--quiet', '--settle', '90', '--home', o.home];
|
|
13
13
|
for (const d of o.appsDirs)
|
|
14
14
|
args.push('--apps-dir', d);
|
|
15
15
|
return toPlistXml({
|
package/dist/bundle.js
CHANGED
|
@@ -2,9 +2,11 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { open, readdir, readFile, readlink, lstat, realpath, stat } from 'node:fs/promises';
|
|
3
3
|
import { basename, join } from 'node:path';
|
|
4
4
|
import { AsarArchive } from "./asar.js";
|
|
5
|
-
import { readEntitlements, readSigning } from "./codesign.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
5
|
+
import { readEntitlements, readNotarization, readSigning } from "./codesign.js";
|
|
6
|
+
import { run } from "./exec.js";
|
|
7
|
+
import { extractFeedUrls, extractHosts, mergeHosts } from "./hosts.js";
|
|
8
|
+
import { isEncryptedMachO } from "./macho.js";
|
|
9
|
+
import { isDict, parsePlistXml, readPlistFile, str } from "./plist.js";
|
|
8
10
|
import { SNAPSHOT_SCHEMA, } from "./types.js";
|
|
9
11
|
import { mapLimit, sortedUnique } from "./util.js";
|
|
10
12
|
import { TOOL_NAME, TOOL_VERSION } from "./version.js";
|
|
@@ -208,6 +210,35 @@ async function fingerprintOf(appPath, infoPath, mainExe) {
|
|
|
208
210
|
}
|
|
209
211
|
return hash.digest('hex').slice(0, 32);
|
|
210
212
|
}
|
|
213
|
+
/** This Mac's update settings for an app, from its user defaults domain. */
|
|
214
|
+
async function readLocalSettings(bundleId) {
|
|
215
|
+
if (!bundleId)
|
|
216
|
+
return {};
|
|
217
|
+
const res = await run('/usr/bin/defaults', ['export', bundleId, '-']);
|
|
218
|
+
if (res.code !== 0)
|
|
219
|
+
return {};
|
|
220
|
+
let prefs = {};
|
|
221
|
+
try {
|
|
222
|
+
const value = parsePlistXml(res.stdout);
|
|
223
|
+
if (isDict(value))
|
|
224
|
+
prefs = value;
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return {};
|
|
228
|
+
}
|
|
229
|
+
const out = {};
|
|
230
|
+
const feed = str(prefs.SUFeedURL);
|
|
231
|
+
if (feed)
|
|
232
|
+
out.sparkleFeedInDefaults = feed;
|
|
233
|
+
const flag = (v) => v === undefined ? undefined : v === true || v === 1 || v === '1' || v === 'YES';
|
|
234
|
+
const checks = flag(prefs.SUEnableAutomaticChecks);
|
|
235
|
+
const auto = flag(prefs.SUAutomaticallyUpdate);
|
|
236
|
+
if (checks !== undefined)
|
|
237
|
+
out.sparkleAutomaticChecks = checks;
|
|
238
|
+
if (auto !== undefined)
|
|
239
|
+
out.sparkleAutomaticallyUpdate = auto;
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
211
242
|
/** Reads the Info.plist identity of a bundle without scanning it. */
|
|
212
243
|
export async function readIdentity(appPath) {
|
|
213
244
|
const info = await readPlistFile(join(appPath, 'Contents/Info.plist'));
|
|
@@ -275,6 +306,10 @@ class HostScanner {
|
|
|
275
306
|
truncatedFiles = [];
|
|
276
307
|
domainLists = [];
|
|
277
308
|
totalLimitHit = false;
|
|
309
|
+
/** Set once the bundle is known to ship Sparkle; widens what counts as an update feed. */
|
|
310
|
+
sparkle = false;
|
|
311
|
+
feeds = new Set();
|
|
312
|
+
encrypted = [];
|
|
278
313
|
maxFile;
|
|
279
314
|
maxTotal;
|
|
280
315
|
constructor(maxFile, maxTotal) {
|
|
@@ -309,7 +344,9 @@ class HostScanner {
|
|
|
309
344
|
scanBuffer(buf, where, binary) {
|
|
310
345
|
this.filesScanned++;
|
|
311
346
|
this.bytesScanned += buf.length;
|
|
312
|
-
|
|
347
|
+
const text = buf.toString(binary ? 'latin1' : 'utf8');
|
|
348
|
+
extractFeedUrls(text, this.sparkle, this.feeds);
|
|
349
|
+
this.commit(extractHosts(text, where, new Map(), true), where);
|
|
313
350
|
}
|
|
314
351
|
async scanFile(abs, size, where, binary) {
|
|
315
352
|
let fh;
|
|
@@ -325,6 +362,12 @@ class HostScanner {
|
|
|
325
362
|
if (slice)
|
|
326
363
|
({ offset: start, size: length } = slice);
|
|
327
364
|
}
|
|
365
|
+
if (binary) {
|
|
366
|
+
const head = Buffer.alloc(Math.min(65536, length));
|
|
367
|
+
const { bytesRead } = await fh.read(head, 0, head.length, start);
|
|
368
|
+
if (isEncryptedMachO(head.subarray(0, bytesRead)))
|
|
369
|
+
this.encrypted.push(where);
|
|
370
|
+
}
|
|
328
371
|
const n = this.budget(length, where);
|
|
329
372
|
if (n <= 0)
|
|
330
373
|
return;
|
|
@@ -337,7 +380,9 @@ class HostScanner {
|
|
|
337
380
|
const buf = Buffer.alloc(len);
|
|
338
381
|
const { bytesRead } = await fh.read(buf, 0, len, start + pos);
|
|
339
382
|
this.bytesScanned += Math.min(bytesRead, CHUNK);
|
|
340
|
-
|
|
383
|
+
const text = buf.toString(binary ? 'latin1' : 'utf8', 0, bytesRead);
|
|
384
|
+
extractFeedUrls(text, this.sparkle, this.feeds);
|
|
385
|
+
extractHosts(text, where, found, true);
|
|
341
386
|
if (bytesRead < len)
|
|
342
387
|
break;
|
|
343
388
|
}
|
|
@@ -443,6 +488,8 @@ export async function scanBundle(inputPath, options = {}) {
|
|
|
443
488
|
return item;
|
|
444
489
|
});
|
|
445
490
|
// Privileged helpers installed through SMJobBless.
|
|
491
|
+
scanner.sparkle = nestedItems.some((c) => c.kind === 'framework' && c.path.endsWith('/Sparkle.framework'));
|
|
492
|
+
summary.appStoreReceipt = entries.some((e) => e.rel === 'Contents/_MASReceipt');
|
|
446
493
|
const helperFiles = files.filter((f) => /(^|\/)Contents\/Library\/LaunchServices\/[^/]+$/.test(f.rel));
|
|
447
494
|
const helperItems = await mapLimit(helperFiles, 4, async (f) => {
|
|
448
495
|
bundleExecutables.add(f.rel);
|
|
@@ -592,6 +639,14 @@ export async function scanBundle(inputPath, options = {}) {
|
|
|
592
639
|
for (const list of scanner.domainLists) {
|
|
593
640
|
warnings.push(`Skipped ${list.count} bare hostnames in ${list.where}: it looks like a bundled domain list, not code.`);
|
|
594
641
|
}
|
|
642
|
+
if (scanner.encrypted.length) {
|
|
643
|
+
warnings.push(`${scanner.encrypted.length} binar${scanner.encrypted.length === 1 ? 'y is' : 'ies are'} encrypted (an iPhone or iPad app): hostnames inside can't be read (${scanner.encrypted.slice(0, 3).join(', ')}).`);
|
|
644
|
+
}
|
|
645
|
+
const feedsInCode = [...scanner.feeds].filter((u) => u !== summary.sparkleFeedUrl).sort();
|
|
646
|
+
if (feedsInCode.length)
|
|
647
|
+
summary.sparkleFeedsInCode = feedsInCode.slice(0, 10);
|
|
648
|
+
const notarization = options.notarization === false ? undefined : await readNotarization(appPath);
|
|
649
|
+
const local = options.userDefaults ? await readLocalSettings(str(info.CFBundleIdentifier)) : undefined;
|
|
595
650
|
const hosts = {};
|
|
596
651
|
for (const host of [...scanner.hosts.keys()].sort())
|
|
597
652
|
hosts[host] = scanner.hosts.get(host);
|
|
@@ -617,6 +672,9 @@ export async function scanBundle(inputPath, options = {}) {
|
|
|
617
672
|
backgroundItems,
|
|
618
673
|
electron,
|
|
619
674
|
hosts,
|
|
675
|
+
...(notarization ? { notarization } : {}),
|
|
676
|
+
...(scanner.encrypted.length ? { encryptedBinaries: [...scanner.encrypted].sort() } : {}),
|
|
677
|
+
...(local && Object.keys(local).length ? { local } : {}),
|
|
620
678
|
stats: {
|
|
621
679
|
scanMs: Date.now() - started,
|
|
622
680
|
filesScanned: scanner.filesScanned,
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { fetchCask } from "./cask.js";
|
|
2
|
+
import { mapLimit } from "./util.js";
|
|
3
|
+
import { TOOL_VERSION } from "./version.js";
|
|
4
|
+
const OLDER_ONLY = /:or_older/;
|
|
5
|
+
/**
|
|
6
|
+
* Keeps the lines of a cask that apply to a current Apple silicon Mac: `on_arm` and
|
|
7
|
+
* `on_<macOS> :or_newer` blocks and top-level stanzas. Drops `on_intel`, blocks for
|
|
8
|
+
* older macOS versions, and blocks such as `livecheck`, `zap` and `uninstall`.
|
|
9
|
+
*/
|
|
10
|
+
function linesForThisMac(source) {
|
|
11
|
+
const kept = [];
|
|
12
|
+
const stack = [];
|
|
13
|
+
for (const line of source.split('\n')) {
|
|
14
|
+
const opens = /^\s*(\S+)(.*?)\bdo(\s*\|[^|]*\|)?\s*$/.exec(line);
|
|
15
|
+
if (opens) {
|
|
16
|
+
const name = opens[1];
|
|
17
|
+
const args = opens[2] ?? '';
|
|
18
|
+
let keep;
|
|
19
|
+
if (name === 'cask')
|
|
20
|
+
keep = true;
|
|
21
|
+
else if (name === 'on_arm')
|
|
22
|
+
keep = true;
|
|
23
|
+
else if (name === 'on_intel')
|
|
24
|
+
keep = false;
|
|
25
|
+
else if (/^on_[a-z_]+$/.test(name) && name !== 'on_arm')
|
|
26
|
+
keep = /:or_newer/.test(args) && !OLDER_ONLY.test(args);
|
|
27
|
+
else
|
|
28
|
+
keep = false;
|
|
29
|
+
stack.push(keep);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (/^\s*end\s*$/.test(line)) {
|
|
33
|
+
stack.pop();
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (stack.every(Boolean))
|
|
37
|
+
kept.push(line);
|
|
38
|
+
}
|
|
39
|
+
return kept;
|
|
40
|
+
}
|
|
41
|
+
/** The last `version` stanza outside livecheck, for casks that only set it inside OS blocks. */
|
|
42
|
+
function lastVersionAnywhere(source) {
|
|
43
|
+
const withoutLivecheck = source.replace(/\n\s*livecheck do[\s\S]*?\n\s*end\b/g, '\n');
|
|
44
|
+
const all = [...withoutLivecheck.matchAll(/^\s*version\s+"([^"]+)"/gm)];
|
|
45
|
+
return all.at(-1)?.[1];
|
|
46
|
+
}
|
|
47
|
+
/** Reads version, URL template, checksum and arch from a cask's Ruby source. */
|
|
48
|
+
export function parseCaskSource(source) {
|
|
49
|
+
const text = linesForThisMac(source).join('\n');
|
|
50
|
+
const version = /^\s*version\s+"([^"]+)"/m.exec(text)?.[1] ?? lastVersionAnywhere(source);
|
|
51
|
+
const url = /^\s*url\s+"([^"]+)"/m.exec(text)?.[1];
|
|
52
|
+
const shaArm = /sha256\s+arm:\s*"([0-9a-f]{64})"/i.exec(text)?.[1];
|
|
53
|
+
const shaOne = /^\s*sha256\s+"([0-9a-f]{64})"/im.exec(text)?.[1];
|
|
54
|
+
const arch = /^\s*arch\s+arm:\s*"([^"]*)"/m.exec(source)?.[1];
|
|
55
|
+
return { version, url, sha256: (shaArm ?? shaOne)?.toLowerCase() ?? null, arch };
|
|
56
|
+
}
|
|
57
|
+
/** Applies a chain of Homebrew version methods: `version.csv.first.no_dots`. */
|
|
58
|
+
export function applyVersionMethods(version, chain) {
|
|
59
|
+
let value = version;
|
|
60
|
+
for (const method of chain) {
|
|
61
|
+
const s = Array.isArray(value) ? undefined : value;
|
|
62
|
+
switch (method) {
|
|
63
|
+
case 'csv':
|
|
64
|
+
if (s === undefined)
|
|
65
|
+
return undefined;
|
|
66
|
+
value = s.split(',');
|
|
67
|
+
break;
|
|
68
|
+
case 'first':
|
|
69
|
+
case 'second':
|
|
70
|
+
case 'third': {
|
|
71
|
+
if (!Array.isArray(value))
|
|
72
|
+
return undefined;
|
|
73
|
+
const item = value[['first', 'second', 'third'].indexOf(method)];
|
|
74
|
+
if (item === undefined)
|
|
75
|
+
return undefined;
|
|
76
|
+
value = item;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
case 'before_comma':
|
|
80
|
+
if (s === undefined)
|
|
81
|
+
return undefined;
|
|
82
|
+
value = s.split(',')[0];
|
|
83
|
+
break;
|
|
84
|
+
case 'after_comma': {
|
|
85
|
+
const after = s?.split(',')[1];
|
|
86
|
+
if (after === undefined)
|
|
87
|
+
return undefined;
|
|
88
|
+
value = after;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
case 'major':
|
|
92
|
+
case 'minor':
|
|
93
|
+
case 'patch': {
|
|
94
|
+
const part = s?.split(/[.,-]/)[['major', 'minor', 'patch'].indexOf(method)];
|
|
95
|
+
if (part === undefined)
|
|
96
|
+
return undefined;
|
|
97
|
+
value = part;
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
case 'major_minor':
|
|
101
|
+
case 'major_minor_patch': {
|
|
102
|
+
if (s === undefined)
|
|
103
|
+
return undefined;
|
|
104
|
+
value = s.split('.').slice(0, method === 'major_minor' ? 2 : 3).join('.');
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
case 'no_dots':
|
|
108
|
+
if (s === undefined)
|
|
109
|
+
return undefined;
|
|
110
|
+
value = s.replace(/\./g, '');
|
|
111
|
+
break;
|
|
112
|
+
case 'dots_to_underscores':
|
|
113
|
+
if (s === undefined)
|
|
114
|
+
return undefined;
|
|
115
|
+
value = s.replace(/\./g, '_');
|
|
116
|
+
break;
|
|
117
|
+
case 'dots_to_hyphens':
|
|
118
|
+
if (s === undefined)
|
|
119
|
+
return undefined;
|
|
120
|
+
value = s.replace(/\./g, '-');
|
|
121
|
+
break;
|
|
122
|
+
case 'no_hyphens':
|
|
123
|
+
if (s === undefined)
|
|
124
|
+
return undefined;
|
|
125
|
+
value = s.replace(/-/g, '');
|
|
126
|
+
break;
|
|
127
|
+
default:
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return Array.isArray(value) ? undefined : value;
|
|
132
|
+
}
|
|
133
|
+
/** Fills `#{version…}` and `#{arch}` in a cask URL. Undefined if anything is left unresolved. */
|
|
134
|
+
export function resolveCaskUrl(template, version, arch) {
|
|
135
|
+
let unresolved = false;
|
|
136
|
+
const url = template.replace(/#\{([^}]+)\}/g, (_, expr) => {
|
|
137
|
+
const e = expr.trim();
|
|
138
|
+
if (e === 'arch') {
|
|
139
|
+
if (arch === undefined)
|
|
140
|
+
unresolved = true;
|
|
141
|
+
return arch ?? '';
|
|
142
|
+
}
|
|
143
|
+
const parts = e.split('.');
|
|
144
|
+
if (parts[0] !== 'version') {
|
|
145
|
+
unresolved = true;
|
|
146
|
+
return '';
|
|
147
|
+
}
|
|
148
|
+
const value = applyVersionMethods(version, parts.slice(1));
|
|
149
|
+
if (value === undefined)
|
|
150
|
+
unresolved = true;
|
|
151
|
+
return value ?? '';
|
|
152
|
+
});
|
|
153
|
+
return unresolved ? undefined : url;
|
|
154
|
+
}
|
|
155
|
+
export function githubApiBase() {
|
|
156
|
+
return (process.env.UPDIFF_GITHUB_API ?? 'https://api.github.com').replace(/\/$/, '');
|
|
157
|
+
}
|
|
158
|
+
export function githubRawBase() {
|
|
159
|
+
return (process.env.UPDIFF_GITHUB_RAW ?? 'https://raw.githubusercontent.com').replace(/\/$/, '');
|
|
160
|
+
}
|
|
161
|
+
function githubHeaders() {
|
|
162
|
+
const headers = { 'user-agent': `updiff/${TOOL_VERSION}`, accept: 'application/vnd.github+json' };
|
|
163
|
+
const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
|
|
164
|
+
if (token)
|
|
165
|
+
headers.authorization = `Bearer ${token}`;
|
|
166
|
+
return headers;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Past releases of a cask, newest first: walks the commits that touched the cask
|
|
170
|
+
* file in Homebrew/homebrew-cask and reads the cask at each one.
|
|
171
|
+
*/
|
|
172
|
+
export async function caskHistory(token, options = {}) {
|
|
173
|
+
const info = options.info ?? (await fetchCask(token));
|
|
174
|
+
if (!info.sourcePath)
|
|
175
|
+
throw new Error(`The Homebrew API did not say where the ${token} cask lives.`);
|
|
176
|
+
const perPage = Math.min(Math.max(options.commits ?? 60, 1), 100);
|
|
177
|
+
const res = await fetch(`${githubApiBase()}/repos/Homebrew/homebrew-cask/commits?path=${encodeURIComponent(info.sourcePath)}&per_page=${perPage}`, { headers: githubHeaders(), signal: AbortSignal.timeout(30_000) });
|
|
178
|
+
if (res.status === 403 || res.status === 429) {
|
|
179
|
+
throw new Error('GitHub is rate-limiting history lookups (60 an hour without a token). Set GITHUB_TOKEN to raise the limit, or try again later.');
|
|
180
|
+
}
|
|
181
|
+
if (!res.ok)
|
|
182
|
+
throw new Error(`GitHub answered HTTP ${res.status} for the ${token} cask's history.`);
|
|
183
|
+
const commits = (await res.json());
|
|
184
|
+
const parsed = await mapLimit(commits, 6, async (c) => {
|
|
185
|
+
const raw = await fetch(`${githubRawBase()}/Homebrew/homebrew-cask/${c.sha}/${info.sourcePath}`, {
|
|
186
|
+
headers: { 'user-agent': `updiff/${TOOL_VERSION}` },
|
|
187
|
+
signal: AbortSignal.timeout(30_000),
|
|
188
|
+
});
|
|
189
|
+
// A commit can delete or move the file; skip it.
|
|
190
|
+
if (!raw.ok)
|
|
191
|
+
return undefined;
|
|
192
|
+
const cask = parseCaskSource(await raw.text());
|
|
193
|
+
if (!cask.version || cask.version === 'latest')
|
|
194
|
+
return undefined;
|
|
195
|
+
const release = {
|
|
196
|
+
version: cask.version,
|
|
197
|
+
sha256: cask.sha256,
|
|
198
|
+
commit: c.sha,
|
|
199
|
+
date: c.commit.committer?.date ?? c.commit.author?.date ?? '',
|
|
200
|
+
};
|
|
201
|
+
const url = cask.url ? resolveCaskUrl(cask.url, cask.version, cask.arch) : undefined;
|
|
202
|
+
if (url)
|
|
203
|
+
release.url = url;
|
|
204
|
+
return release;
|
|
205
|
+
});
|
|
206
|
+
// Commits come newest first; keep the newest commit for each version.
|
|
207
|
+
const releases = [];
|
|
208
|
+
const seen = new Set();
|
|
209
|
+
for (const r of parsed) {
|
|
210
|
+
if (!r || seen.has(r.version))
|
|
211
|
+
continue;
|
|
212
|
+
seen.add(r.version);
|
|
213
|
+
releases.push(r);
|
|
214
|
+
}
|
|
215
|
+
return { info, releases };
|
|
216
|
+
}
|
package/dist/cask.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
export function caskApiBase() {
|
|
2
|
+
return (process.env.UPDIFF_CASK_API ?? process.env.LEDGER_CASK_API ?? 'https://formulae.brew.sh/api/cask').replace(/\/$/, '');
|
|
3
|
+
}
|
|
4
|
+
function firstStrings(artifacts, key) {
|
|
5
|
+
const out = [];
|
|
6
|
+
for (const a of artifacts) {
|
|
7
|
+
const v = a[key];
|
|
8
|
+
if (!Array.isArray(v))
|
|
9
|
+
continue;
|
|
10
|
+
// Each entry is ["Name.app"] or ["Name.app", { target: "..." }].
|
|
11
|
+
for (const item of v) {
|
|
12
|
+
if (typeof item === 'string')
|
|
13
|
+
out.push(item);
|
|
14
|
+
else if (Array.isArray(item) && typeof item[0] === 'string')
|
|
15
|
+
out.push(item[0]);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
/** Turns a cask API document into the fields the ledger needs. */
|
|
21
|
+
export function parseCask(json) {
|
|
22
|
+
const j = json;
|
|
23
|
+
if (typeof j?.token !== 'string' || typeof j.version !== 'string' || typeof j.url !== 'string') {
|
|
24
|
+
throw new Error('Not a Homebrew cask document');
|
|
25
|
+
}
|
|
26
|
+
const artifacts = Array.isArray(j.artifacts) ? j.artifacts : [];
|
|
27
|
+
const names = Array.isArray(j.name) ? j.name : [];
|
|
28
|
+
const sha = typeof j.sha256 === 'string' && /^[0-9a-f]{64}$/i.test(j.sha256) ? j.sha256.toLowerCase() : null;
|
|
29
|
+
return {
|
|
30
|
+
token: j.token,
|
|
31
|
+
name: typeof names[0] === 'string' ? names[0] : j.token,
|
|
32
|
+
desc: typeof j.desc === 'string' ? j.desc : undefined,
|
|
33
|
+
homepage: typeof j.homepage === 'string' ? j.homepage : undefined,
|
|
34
|
+
version: j.version,
|
|
35
|
+
url: j.url,
|
|
36
|
+
sha256: sha,
|
|
37
|
+
appNames: firstStrings(artifacts, 'app'),
|
|
38
|
+
pkgNames: firstStrings(artifacts, 'pkg'),
|
|
39
|
+
sourcePath: typeof j.ruby_source_path === 'string' ? j.ruby_source_path : undefined,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export async function fetchCask(token) {
|
|
43
|
+
if (!/^[a-z0-9@._+-]+$/.test(token))
|
|
44
|
+
throw new Error(`Invalid cask token "${token}"`);
|
|
45
|
+
const res = await fetch(`${caskApiBase()}/${token}.json`, { signal: AbortSignal.timeout(30_000) });
|
|
46
|
+
if (!res.ok)
|
|
47
|
+
throw new Error(`Cask "${token}": HTTP ${res.status} from the Homebrew API`);
|
|
48
|
+
return parseCask(await res.json());
|
|
49
|
+
}
|
|
50
|
+
/** The forms a version can take inside a download URL. */
|
|
51
|
+
function versionForms(version) {
|
|
52
|
+
const forms = [version, version.replace(/\./g, '_'), version.replace(/\./g, '-')];
|
|
53
|
+
return [...new Set(forms)];
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Best-effort URL of another release: substitutes `version` for the cask's current
|
|
57
|
+
* version (and each part of comma-separated versions such as "1.2.3,456") in the
|
|
58
|
+
* current download URL. Returns undefined when the URL does not contain the version.
|
|
59
|
+
*/
|
|
60
|
+
export function urlForVersion(info, version) {
|
|
61
|
+
const currentParts = info.version.split(',');
|
|
62
|
+
const nextParts = version.split(',');
|
|
63
|
+
if (currentParts.length !== nextParts.length)
|
|
64
|
+
return undefined;
|
|
65
|
+
let url = info.url;
|
|
66
|
+
let replaced = false;
|
|
67
|
+
for (let i = 0; i < currentParts.length; i++) {
|
|
68
|
+
const from = versionForms(currentParts[i]);
|
|
69
|
+
const to = versionForms(nextParts[i]);
|
|
70
|
+
for (let f = 0; f < from.length; f++) {
|
|
71
|
+
if (from[f] && url.includes(from[f])) {
|
|
72
|
+
url = url.split(from[f]).join(to[f]);
|
|
73
|
+
replaced = true;
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return replaced ? url : undefined;
|
|
79
|
+
}
|
package/dist/codesign.js
CHANGED
|
@@ -60,3 +60,44 @@ export async function readEntitlements(path) {
|
|
|
60
60
|
return {};
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Parses `spctl -a -vvv -t exec` output:
|
|
65
|
+
* /Applications/X.app: accepted
|
|
66
|
+
* source=Notarized Developer ID
|
|
67
|
+
* origin=Developer ID Application: Example Inc (TEAMID)
|
|
68
|
+
*/
|
|
69
|
+
export function parseSpctl(output) {
|
|
70
|
+
const verdict = /:\s*(accepted|rejected)\s*$/m.exec(output)?.[1];
|
|
71
|
+
const source = /^source=(.+)$/m.exec(output)?.[1]?.trim();
|
|
72
|
+
const origin = /^origin=(.+)$/m.exec(output)?.[1]?.trim();
|
|
73
|
+
const assessment = verdict === 'accepted' || verdict === 'rejected' ? verdict : 'unknown';
|
|
74
|
+
// Without a verdict spctl prints "<path>: <reason>", e.g. a broken seal.
|
|
75
|
+
const error = assessment === 'unknown' ? /^.*?:\s*(.+)$/m.exec(output.trim())?.[1]?.trim() : undefined;
|
|
76
|
+
return {
|
|
77
|
+
assessment,
|
|
78
|
+
...(source ? { source } : {}),
|
|
79
|
+
...(origin ? { origin } : {}),
|
|
80
|
+
...(error ? { error } : {}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Reads `stapler validate` by its message: its exit status is 0 whether or not a
|
|
85
|
+
* ticket is stapled. Undefined when stapler isn't available (no Xcode tools).
|
|
86
|
+
*/
|
|
87
|
+
export function parseStapler(res) {
|
|
88
|
+
const text = `${res.stdout}\n${res.stderr}`;
|
|
89
|
+
if (/The validate action worked/i.test(text))
|
|
90
|
+
return true;
|
|
91
|
+
if (/does not have a ticket stapled/i.test(text))
|
|
92
|
+
return false;
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
/** Asks Gatekeeper about a bundle and checks for a stapled notarization ticket. */
|
|
96
|
+
export async function readNotarization(path) {
|
|
97
|
+
const spctl = await run('/usr/sbin/spctl', ['-a', '-vvv', '-t', 'exec', path], 120_000);
|
|
98
|
+
const info = parseSpctl(`${spctl.stdout}\n${spctl.stderr}`);
|
|
99
|
+
const stapled = parseStapler((await run('/usr/bin/xcrun', ['stapler', 'validate', path], 60_000)));
|
|
100
|
+
if (stapled !== undefined)
|
|
101
|
+
info.stapled = stapled;
|
|
102
|
+
return info;
|
|
103
|
+
}
|