@intincrab/updiff 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +356 -2
- package/dist/agent.js +63 -0
- package/dist/asar.js +91 -0
- package/dist/bundle.js +686 -0
- package/dist/cask-history.js +216 -0
- package/dist/cask.js +79 -0
- package/dist/cli.js +8 -0
- package/dist/codesign.js +103 -0
- package/dist/commands.js +661 -0
- package/dist/diff.js +621 -0
- package/dist/exec.js +19 -0
- package/dist/extensions.js +453 -0
- package/dist/fetch.js +81 -0
- package/dist/hosts.js +137 -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/plist.js +196 -0
- package/dist/report.js +153 -0
- package/dist/store.js +113 -0
- package/dist/types.js +1 -0
- package/dist/util.js +73 -0
- package/dist/version.js +2 -0
- package/package.json +55 -4
package/dist/commands.js
ADDED
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
import { existsSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { readdir, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { basename, join, resolve } from 'node:path';
|
|
5
|
+
import { parseArgs } from 'node:util';
|
|
6
|
+
import { AGENT_LABEL, agentPlist, agentStatus, installAgent, notify, uninstallAgent } from "./agent.js";
|
|
7
|
+
import { quickFingerprint, readIdentity, scanBundle } from "./bundle.js";
|
|
8
|
+
import { diffSnapshots, filterBySeverity } from "./diff.js";
|
|
9
|
+
import { diffExtensions, discoverExtensions, renderExtensions } from "./extensions.js";
|
|
10
|
+
import { caskHistory, fetchRelease, pickRelease, resolveCask, sameVersion } from "./fetch.js";
|
|
11
|
+
import { renderJson, renderMarkdown, renderScan, renderText } from "./report.js";
|
|
12
|
+
import { inspectPkg } from "./pkg.js";
|
|
13
|
+
import { diffPkgs, renderPkg, renderPkgMarkdown } from "./pkg-report.js";
|
|
14
|
+
import { Store } from "./store.js";
|
|
15
|
+
import { mapLimit } from "./util.js";
|
|
16
|
+
import { TOOL_VERSION } from "./version.js";
|
|
17
|
+
export const USAGE = `updiff ${TOOL_VERSION}: see what a macOS app update actually changed
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
updiff scan <app> Inspect one app; nothing is stored
|
|
21
|
+
updiff snapshot [app...] Store snapshots (all apps when none are given)
|
|
22
|
+
updiff diff <app> Compare the two newest snapshots of an app
|
|
23
|
+
updiff diff <a> <b> Compare two apps, snapshot files or stored apps
|
|
24
|
+
updiff check Snapshot apps that changed and report the differences
|
|
25
|
+
updiff list [app] List stored snapshots
|
|
26
|
+
updiff fetch <app> [version] Download an earlier release from Homebrew's cask
|
|
27
|
+
history, store it as a snapshot and compare it
|
|
28
|
+
with the installed app (no baseline needed)
|
|
29
|
+
updiff extensions [check] List browser and editor extensions with what they
|
|
30
|
+
can reach; 'check' reports what changed since the
|
|
31
|
+
last check (permissions, sites, bundled code)
|
|
32
|
+
updiff pkg <installer.pkg> [older.pkg]
|
|
33
|
+
Show what an installer package would do: its
|
|
34
|
+
scripts, launch daemons and install locations.
|
|
35
|
+
With two packages, what changed. Nothing is run.
|
|
36
|
+
updiff agent install|uninstall|status
|
|
37
|
+
Run 'check' automatically after app updates
|
|
38
|
+
|
|
39
|
+
<app> is a path to an .app, an app name in your Applications folders ("Slack"),
|
|
40
|
+
or a bundle id that has stored snapshots ("com.tinyspeck.slackmacgap").
|
|
41
|
+
|
|
42
|
+
Options:
|
|
43
|
+
--format text|md|json Output format (default text)
|
|
44
|
+
--min-severity high|notable|info Hide less important findings (default info)
|
|
45
|
+
--out <file> Write the report to a file
|
|
46
|
+
--full Do not shorten long details in text output
|
|
47
|
+
--deep Also scan framework binaries for hostnames (slower)
|
|
48
|
+
--force Store a snapshot even if the app has not changed
|
|
49
|
+
--home <dir> Data folder (default ~/.updiff, or $UPDIFF_HOME)
|
|
50
|
+
--apps-dir <dir> Folder to look for apps (repeatable; default
|
|
51
|
+
/Applications and ~/Applications, or
|
|
52
|
+
$UPDIFF_APPS_DIRS, colon-separated)
|
|
53
|
+
--notify check: show a notification for each changed app
|
|
54
|
+
--extensions check: also check browser and editor extensions
|
|
55
|
+
--settle <seconds> check: wait until apps stop changing for this long
|
|
56
|
+
--keep <n> check/snapshot: snapshots to keep per app (default 10)
|
|
57
|
+
--quiet Print less
|
|
58
|
+
--interval <seconds> agent install: fallback timer (default 21600)
|
|
59
|
+
--dry-run agent install: print the LaunchAgent, change nothing
|
|
60
|
+
--no-load agent install/uninstall: write files, skip launchctl
|
|
61
|
+
--no-gatekeeper Skip the Gatekeeper/notarization check (faster)
|
|
62
|
+
--cask <name> fetch: the Homebrew cask, when it isn't the app's name
|
|
63
|
+
--list fetch: list the releases in the cask's history
|
|
64
|
+
--no-color Plain text output
|
|
65
|
+
-h, --help Show this help
|
|
66
|
+
-v, --version Show the version
|
|
67
|
+
|
|
68
|
+
Hostnames found in code are not proof that the app connects to them.
|
|
69
|
+
`;
|
|
70
|
+
class UsageError extends Error {
|
|
71
|
+
}
|
|
72
|
+
function parse(argv) {
|
|
73
|
+
return parseArgs({
|
|
74
|
+
args: argv,
|
|
75
|
+
allowPositionals: true,
|
|
76
|
+
strict: true,
|
|
77
|
+
options: {
|
|
78
|
+
format: { type: 'string' },
|
|
79
|
+
'min-severity': { type: 'string' },
|
|
80
|
+
out: { type: 'string' },
|
|
81
|
+
full: { type: 'boolean' },
|
|
82
|
+
deep: { type: 'boolean' },
|
|
83
|
+
force: { type: 'boolean' },
|
|
84
|
+
home: { type: 'string' },
|
|
85
|
+
'apps-dir': { type: 'string', multiple: true },
|
|
86
|
+
notify: { type: 'boolean' },
|
|
87
|
+
settle: { type: 'string' },
|
|
88
|
+
keep: { type: 'string' },
|
|
89
|
+
quiet: { type: 'boolean' },
|
|
90
|
+
interval: { type: 'string' },
|
|
91
|
+
'dry-run': { type: 'boolean' },
|
|
92
|
+
'no-load': { type: 'boolean' },
|
|
93
|
+
'no-color': { type: 'boolean' },
|
|
94
|
+
'no-gatekeeper': { type: 'boolean' },
|
|
95
|
+
cask: { type: 'string' },
|
|
96
|
+
list: { type: 'boolean' },
|
|
97
|
+
extensions: { type: 'boolean' },
|
|
98
|
+
help: { type: 'boolean', short: 'h' },
|
|
99
|
+
version: { type: 'boolean', short: 'v' },
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/** `$UPDIFF_APPS_DIRS` (colon-separated) overrides the default app folders. */
|
|
104
|
+
function defaultAppsDirs() {
|
|
105
|
+
const fromEnv = process.env.UPDIFF_APPS_DIRS?.split(':').filter(Boolean);
|
|
106
|
+
return fromEnv?.length ? fromEnv : ['/Applications', join(homedir(), 'Applications')];
|
|
107
|
+
}
|
|
108
|
+
/** Apps at the top of each folder and one folder down (for suites like "Adobe X/"). */
|
|
109
|
+
export async function discoverApps(dirs) {
|
|
110
|
+
const found = [];
|
|
111
|
+
for (const dir of dirs) {
|
|
112
|
+
let dirents;
|
|
113
|
+
try {
|
|
114
|
+
dirents = await readdir(dir, { withFileTypes: true });
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
for (const d of dirents) {
|
|
120
|
+
if (!d.isDirectory())
|
|
121
|
+
continue;
|
|
122
|
+
const p = join(dir, d.name);
|
|
123
|
+
if (d.name.endsWith('.app'))
|
|
124
|
+
found.push(p);
|
|
125
|
+
else if (!d.name.startsWith('.')) {
|
|
126
|
+
try {
|
|
127
|
+
for (const inner of await readdir(p, { withFileTypes: true })) {
|
|
128
|
+
if (inner.isDirectory() && inner.name.endsWith('.app'))
|
|
129
|
+
found.push(join(p, inner.name));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// Unreadable folder: skip it.
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return found.sort((a, b) => basename(a).localeCompare(basename(b)));
|
|
139
|
+
}
|
|
140
|
+
function isAppDir(p) {
|
|
141
|
+
try {
|
|
142
|
+
return statSync(p).isDirectory() && existsSync(join(p, 'Contents', 'Info.plist'));
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/** Finds an installed app by path or by name. */
|
|
149
|
+
function findApp(arg, dirs) {
|
|
150
|
+
const asPath = resolve(arg);
|
|
151
|
+
if (isAppDir(asPath))
|
|
152
|
+
return asPath;
|
|
153
|
+
const name = arg.endsWith('.app') ? arg : `${arg}.app`;
|
|
154
|
+
for (const dir of dirs) {
|
|
155
|
+
const p = join(dir, name);
|
|
156
|
+
if (isAppDir(p))
|
|
157
|
+
return p;
|
|
158
|
+
}
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
async function findAppByName(arg, dirs) {
|
|
162
|
+
const direct = findApp(arg, dirs);
|
|
163
|
+
if (direct)
|
|
164
|
+
return direct;
|
|
165
|
+
const q = arg.toLowerCase().replace(/\.app$/, '');
|
|
166
|
+
return (await discoverApps(dirs)).find((p) => basename(p, '.app').toLowerCase() === q);
|
|
167
|
+
}
|
|
168
|
+
async function resolveSide(ctx, arg) {
|
|
169
|
+
if (arg.endsWith('.json') && existsSync(arg))
|
|
170
|
+
return { kind: 'snapshot', snapshot: await ctx.store.load(arg) };
|
|
171
|
+
const app = await findAppByName(arg, ctx.appsDirs);
|
|
172
|
+
if (app)
|
|
173
|
+
return { kind: 'app', path: app };
|
|
174
|
+
const id = await ctx.store.resolveBundleId(arg);
|
|
175
|
+
if (id)
|
|
176
|
+
return { kind: 'stored', bundleId: id };
|
|
177
|
+
throw new UsageError(`Can't find "${arg}": not an app path, an app in ${ctx.appsDirs.join(' or ')}, a snapshot file, or a stored app.`);
|
|
178
|
+
}
|
|
179
|
+
async function sideSnapshot(ctx, side) {
|
|
180
|
+
if (side.kind === 'snapshot')
|
|
181
|
+
return side.snapshot;
|
|
182
|
+
if (side.kind === 'app')
|
|
183
|
+
return scanBundle(side.path, ctx.scan);
|
|
184
|
+
const [latest] = await ctx.store.latest(side.bundleId, 1);
|
|
185
|
+
if (!latest)
|
|
186
|
+
throw new UsageError(`No snapshots stored for ${side.bundleId}.`);
|
|
187
|
+
return latest;
|
|
188
|
+
}
|
|
189
|
+
async function emit(ctx, report) {
|
|
190
|
+
const r = filterBySeverity(report, ctx.minSeverity);
|
|
191
|
+
const text = ctx.format === 'json'
|
|
192
|
+
? renderJson(r)
|
|
193
|
+
: ctx.format === 'md'
|
|
194
|
+
? renderMarkdown(r)
|
|
195
|
+
: renderText(r, { color: ctx.io.color && !ctx.out, full: ctx.full });
|
|
196
|
+
if (ctx.out) {
|
|
197
|
+
await writeFile(ctx.out, text);
|
|
198
|
+
ctx.io.err(`Wrote ${ctx.out}\n`);
|
|
199
|
+
}
|
|
200
|
+
else
|
|
201
|
+
ctx.io.out(text);
|
|
202
|
+
}
|
|
203
|
+
async function cmdScan(ctx, args) {
|
|
204
|
+
if (args.length !== 1)
|
|
205
|
+
throw new UsageError('scan needs exactly one app.');
|
|
206
|
+
const app = await findAppByName(args[0], ctx.appsDirs);
|
|
207
|
+
if (!app)
|
|
208
|
+
throw new UsageError(`Can't find app "${args[0]}".`);
|
|
209
|
+
// A one-off scan of an installed app also shows this Mac's update settings for it.
|
|
210
|
+
const s = await scanBundle(app, { ...ctx.scan, userDefaults: true });
|
|
211
|
+
const text = ctx.format === 'json' ? renderJson(s) : renderScan(s, { color: ctx.io.color && !ctx.out });
|
|
212
|
+
if (ctx.out) {
|
|
213
|
+
await writeFile(ctx.out, text);
|
|
214
|
+
ctx.io.err(`Wrote ${ctx.out}\n`);
|
|
215
|
+
}
|
|
216
|
+
else
|
|
217
|
+
ctx.io.out(text);
|
|
218
|
+
return 0;
|
|
219
|
+
}
|
|
220
|
+
async function snapshotApp(ctx, appPath) {
|
|
221
|
+
try {
|
|
222
|
+
const id = await readIdentity(appPath);
|
|
223
|
+
if (!id)
|
|
224
|
+
return { app: appPath, status: 'failed', error: 'no readable Info.plist' };
|
|
225
|
+
const [previous] = await ctx.store.latest(id.bundleId, 1);
|
|
226
|
+
if (previous && !ctx.force) {
|
|
227
|
+
const fp = await quickFingerprint(appPath);
|
|
228
|
+
if (fp === previous.fingerprint)
|
|
229
|
+
return { app: appPath, status: 'unchanged', previous };
|
|
230
|
+
}
|
|
231
|
+
const snapshot = await scanBundle(appPath, ctx.scan);
|
|
232
|
+
await ctx.store.save(snapshot);
|
|
233
|
+
await ctx.store.prune(snapshot.app.bundleId, ctx.keep);
|
|
234
|
+
return { app: appPath, status: previous ? 'changed' : 'new', snapshot, previous };
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
return { app: appPath, status: 'failed', error: err.message };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
async function cmdSnapshot(ctx, args) {
|
|
241
|
+
let apps;
|
|
242
|
+
if (args.length) {
|
|
243
|
+
apps = [];
|
|
244
|
+
for (const a of args) {
|
|
245
|
+
const p = await findAppByName(a, ctx.appsDirs);
|
|
246
|
+
if (!p)
|
|
247
|
+
throw new UsageError(`Can't find app "${a}".`);
|
|
248
|
+
apps.push(p);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
else
|
|
252
|
+
apps = await discoverApps(ctx.appsDirs);
|
|
253
|
+
const results = await mapLimit(apps, 3, async (app) => {
|
|
254
|
+
const r = await snapshotApp(ctx, app);
|
|
255
|
+
if (!ctx.quiet) {
|
|
256
|
+
const s = r.snapshot ?? r.previous;
|
|
257
|
+
const label = s ? `${s.app.name} ${s.app.version}` : basename(app);
|
|
258
|
+
const time = r.snapshot ? ` (${(r.snapshot.stats.scanMs / 1000).toFixed(1)} s)` : '';
|
|
259
|
+
ctx.io.err(`${r.status.padEnd(9)} ${label}${time}${r.error ? `: ${r.error}` : ''}\n`);
|
|
260
|
+
}
|
|
261
|
+
return r;
|
|
262
|
+
});
|
|
263
|
+
const failed = results.filter((r) => r.status === 'failed').length;
|
|
264
|
+
if (ctx.format === 'json') {
|
|
265
|
+
ctx.io.out(renderJson(results.map((r) => ({ app: r.app, status: r.status, error: r.error, bundleId: (r.snapshot ?? r.previous)?.app.bundleId, version: (r.snapshot ?? r.previous)?.app.version }))));
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
const count = (s) => results.filter((r) => r.status === s).length;
|
|
269
|
+
ctx.io.out(`${apps.length} app${apps.length === 1 ? '' : 's'}: ${count('new')} new, ${count('changed')} changed, ${count('unchanged')} unchanged, ${failed} failed. Stored in ${ctx.store.snapshotsDir}\n`);
|
|
270
|
+
}
|
|
271
|
+
return failed && failed === apps.length ? 1 : 0;
|
|
272
|
+
}
|
|
273
|
+
async function cmdDiff(ctx, args) {
|
|
274
|
+
if (args.length === 2) {
|
|
275
|
+
const [a, b] = await Promise.all(args.map((x) => resolveSide(ctx, x)));
|
|
276
|
+
const [sa, sb] = await Promise.all([sideSnapshot(ctx, a), sideSnapshot(ctx, b)]);
|
|
277
|
+
await emit(ctx, diffSnapshots(sa, sb));
|
|
278
|
+
return 0;
|
|
279
|
+
}
|
|
280
|
+
if (args.length !== 1)
|
|
281
|
+
throw new UsageError('diff needs one app, or two things to compare.');
|
|
282
|
+
const side = await resolveSide(ctx, args[0]);
|
|
283
|
+
let bundleId;
|
|
284
|
+
if (side.kind === 'snapshot')
|
|
285
|
+
throw new UsageError('Give two snapshot files to compare them.');
|
|
286
|
+
if (side.kind === 'app') {
|
|
287
|
+
const id = await readIdentity(side.path);
|
|
288
|
+
if (!id)
|
|
289
|
+
throw new UsageError(`${side.path} has no readable Info.plist.`);
|
|
290
|
+
bundleId = id.bundleId;
|
|
291
|
+
}
|
|
292
|
+
else
|
|
293
|
+
bundleId = side.bundleId;
|
|
294
|
+
const stored = await ctx.store.latest(bundleId, 2);
|
|
295
|
+
if (side.kind === 'app' && stored[0]) {
|
|
296
|
+
// Compare the installed app against the newest snapshot when it has changed since.
|
|
297
|
+
const fp = await quickFingerprint(side.path);
|
|
298
|
+
if (fp !== stored[0].fingerprint) {
|
|
299
|
+
await emit(ctx, diffSnapshots(stored[0], await scanBundle(side.path, ctx.scan)));
|
|
300
|
+
return 0;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (stored.length < 2) {
|
|
304
|
+
throw new UsageError(stored.length
|
|
305
|
+
? `Only one snapshot of ${bundleId}, and the app has not changed since. Run 'updiff snapshot' again after it updates.`
|
|
306
|
+
: `No snapshots of ${bundleId} yet. Run 'updiff snapshot "${args[0]}"' first.`);
|
|
307
|
+
}
|
|
308
|
+
await emit(ctx, diffSnapshots(stored[1], stored[0]));
|
|
309
|
+
return 0;
|
|
310
|
+
}
|
|
311
|
+
async function waitUntilSettled(apps, seconds, io, quiet) {
|
|
312
|
+
if (seconds <= 0)
|
|
313
|
+
return;
|
|
314
|
+
const deadline = Date.now() + 10 * 60 * 1000;
|
|
315
|
+
for (;;) {
|
|
316
|
+
let newest = 0;
|
|
317
|
+
for (const app of apps) {
|
|
318
|
+
for (const p of [app, join(app, 'Contents'), join(app, 'Contents', 'Info.plist'), join(app, 'Contents', '_CodeSignature', 'CodeResources')]) {
|
|
319
|
+
try {
|
|
320
|
+
newest = Math.max(newest, (await stat(p)).mtimeMs);
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
// Missing while the update is being written.
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const age = (Date.now() - newest) / 1000;
|
|
328
|
+
if (age >= seconds || Date.now() > deadline)
|
|
329
|
+
return;
|
|
330
|
+
if (!quiet)
|
|
331
|
+
io.err(`An app changed ${Math.round(age)} s ago; waiting for the update to finish…\n`);
|
|
332
|
+
await new Promise((r) => setTimeout(r, Math.min(15, seconds - age) * 1000));
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/** Compares installed extensions with the last stored set and stores the new one. */
|
|
336
|
+
async function checkExtensions(ctx) {
|
|
337
|
+
const current = { schema: 1, takenAt: new Date().toISOString(), extensions: await discoverExtensions() };
|
|
338
|
+
const previous = await ctx.store.latestExtensions();
|
|
339
|
+
if (!previous) {
|
|
340
|
+
await ctx.store.saveExtensions(current);
|
|
341
|
+
return { count: current.extensions.length, baseline: true };
|
|
342
|
+
}
|
|
343
|
+
const report = diffExtensions(previous, current);
|
|
344
|
+
if (report.identical)
|
|
345
|
+
return { count: current.extensions.length, baseline: false };
|
|
346
|
+
await ctx.store.saveExtensions(current);
|
|
347
|
+
const file = await ctx.store.saveReport('extensions', previous.takenAt.slice(0, 10), current.takenAt.slice(0, 10), 'md', renderMarkdown(report));
|
|
348
|
+
return { count: current.extensions.length, baseline: false, report, file };
|
|
349
|
+
}
|
|
350
|
+
function describeExtensionCheck(r) {
|
|
351
|
+
if (r.baseline)
|
|
352
|
+
return `Saved a baseline of ${r.count} extension${r.count === 1 ? '' : 's'}; the next check reports what changes.`;
|
|
353
|
+
return r.report ? `Extensions: ${r.report.counts.high} high, ${r.report.counts.notable} notable, ${r.report.counts.info} info.` : `No changes in ${r.count} extension${r.count === 1 ? '' : 's'}.`;
|
|
354
|
+
}
|
|
355
|
+
async function cmdExtensions(ctx, args) {
|
|
356
|
+
const [action, ...rest] = args;
|
|
357
|
+
if (rest.length || (action && action !== 'check' && action !== 'list'))
|
|
358
|
+
throw new UsageError('extensions takes `list` (default) or `check`.');
|
|
359
|
+
if (action !== 'check') {
|
|
360
|
+
const list = await discoverExtensions();
|
|
361
|
+
ctx.io.out(ctx.format === 'json' ? renderJson(list) : renderExtensions(list, { color: ctx.io.color }));
|
|
362
|
+
return 0;
|
|
363
|
+
}
|
|
364
|
+
const r = await checkExtensions(ctx);
|
|
365
|
+
if (ctx.format === 'json') {
|
|
366
|
+
ctx.io.out(renderJson({ count: r.count, baseline: r.baseline, ...(r.report ? { changes: filterBySeverity(r.report, ctx.minSeverity), reportFile: r.file } : {}) }));
|
|
367
|
+
return 0;
|
|
368
|
+
}
|
|
369
|
+
if (r.report) {
|
|
370
|
+
ctx.io.out(renderText(filterBySeverity(r.report, ctx.minSeverity), { color: ctx.io.color, full: ctx.full }));
|
|
371
|
+
ctx.io.out(`Report: ${r.file}\n\n`);
|
|
372
|
+
}
|
|
373
|
+
ctx.io.out(`${describeExtensionCheck(r)}\n`);
|
|
374
|
+
return 0;
|
|
375
|
+
}
|
|
376
|
+
async function cmdCheck(ctx, args, notifyEnabled, settle, withExtensions) {
|
|
377
|
+
if (args.length)
|
|
378
|
+
throw new UsageError('check takes no app names; use --apps-dir to choose folders.');
|
|
379
|
+
let apps = await discoverApps(ctx.appsDirs);
|
|
380
|
+
await waitUntilSettled(apps, settle, ctx.io, ctx.quiet);
|
|
381
|
+
apps = await discoverApps(ctx.appsDirs);
|
|
382
|
+
const results = await mapLimit(apps, 3, (app) => snapshotApp(ctx, app));
|
|
383
|
+
const reports = [];
|
|
384
|
+
for (const r of results) {
|
|
385
|
+
if (r.status !== 'changed' || !r.previous || !r.snapshot)
|
|
386
|
+
continue;
|
|
387
|
+
const report = diffSnapshots(r.previous, r.snapshot);
|
|
388
|
+
const file = await ctx.store.saveReport(report.app.bundleId, report.from.version, report.to.version, 'md', renderMarkdown(report));
|
|
389
|
+
reports.push({ report, file });
|
|
390
|
+
}
|
|
391
|
+
const baseline = results.filter((r) => r.status === 'new');
|
|
392
|
+
const failed = results.filter((r) => r.status === 'failed');
|
|
393
|
+
const ext = withExtensions ? await checkExtensions(ctx) : undefined;
|
|
394
|
+
if (ctx.format === 'json') {
|
|
395
|
+
ctx.io.out(renderJson({
|
|
396
|
+
checked: apps.length,
|
|
397
|
+
baseline: baseline.map((r) => r.snapshot.app.bundleId),
|
|
398
|
+
failed: failed.map((r) => ({ app: r.app, error: r.error })),
|
|
399
|
+
changed: reports.map(({ report, file }) => ({ ...filterBySeverity(report, ctx.minSeverity), reportFile: file })),
|
|
400
|
+
...(ext ? { extensions: { count: ext.count, baseline: ext.baseline, ...(ext.report ? { changes: filterBySeverity(ext.report, ctx.minSeverity), reportFile: ext.file } : {}) } } : {}),
|
|
401
|
+
}));
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
for (const { report, file } of reports) {
|
|
405
|
+
ctx.io.out(renderText(filterBySeverity(report, ctx.minSeverity), { color: ctx.io.color, full: ctx.full }));
|
|
406
|
+
ctx.io.out(`Report: ${file}\n\n`);
|
|
407
|
+
}
|
|
408
|
+
if (!ctx.quiet || reports.length || failed.length) {
|
|
409
|
+
const parts = [`Checked ${apps.length} app${apps.length === 1 ? '' : 's'}`, `${reports.length} updated`];
|
|
410
|
+
if (baseline.length)
|
|
411
|
+
parts.push(`${baseline.length} new baseline${baseline.length === 1 ? '' : 's'}`);
|
|
412
|
+
if (failed.length)
|
|
413
|
+
parts.push(`${failed.length} failed (${failed.map((f) => basename(f.app)).join(', ')})`);
|
|
414
|
+
ctx.io.out(`${parts.join(', ')}.\n`);
|
|
415
|
+
}
|
|
416
|
+
if (ext?.report) {
|
|
417
|
+
ctx.io.out('\n' + renderText(filterBySeverity(ext.report, ctx.minSeverity), { color: ctx.io.color, full: ctx.full }));
|
|
418
|
+
ctx.io.out(`Report: ${ext.file}\n`);
|
|
419
|
+
}
|
|
420
|
+
if (ext && (!ctx.quiet || ext.report))
|
|
421
|
+
ctx.io.out(`${describeExtensionCheck(ext)}\n`);
|
|
422
|
+
}
|
|
423
|
+
if (notifyEnabled) {
|
|
424
|
+
for (const { report } of reports) {
|
|
425
|
+
const { high, notable } = report.counts;
|
|
426
|
+
const summary = high || notable ? `${high} high, ${notable} notable change${high + notable === 1 ? '' : 's'}` : 'No notable changes';
|
|
427
|
+
await notify(`Updiff: ${report.app.name} ${report.to.version}`, `${summary}. Run: updiff diff "${report.app.bundleId}"`);
|
|
428
|
+
}
|
|
429
|
+
if (ext?.report && (ext.report.counts.high || ext.report.counts.notable)) {
|
|
430
|
+
await notify('Updiff: extensions changed', `${ext.report.counts.high} high, ${ext.report.counts.notable} notable. Report: ${ext.file}`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return 0;
|
|
434
|
+
}
|
|
435
|
+
async function cmdFetch(ctx, args, opts) {
|
|
436
|
+
if (args.length < 1 || args.length > 2)
|
|
437
|
+
throw new UsageError('fetch needs an app (or cask) and optionally a version.');
|
|
438
|
+
const [target, wanted] = args;
|
|
439
|
+
const installed = await findAppByName(target, ctx.appsDirs);
|
|
440
|
+
const identity = installed ? await readIdentity(installed) : undefined;
|
|
441
|
+
const candidates = opts.cask ? [opts.cask] : [target, ...(identity ? [identity.name, basename(installed, '.app')] : [])];
|
|
442
|
+
const log = (s) => {
|
|
443
|
+
if (!ctx.quiet)
|
|
444
|
+
ctx.io.err(`${s}\n`);
|
|
445
|
+
};
|
|
446
|
+
const info = await resolveCask(candidates);
|
|
447
|
+
log(`Reading the history of the Homebrew cask "${info.token}"…`);
|
|
448
|
+
const { releases } = await caskHistory(info.token, { info });
|
|
449
|
+
if (!releases.length)
|
|
450
|
+
throw new UsageError(`Found no releases in the history of the ${info.token} cask.`);
|
|
451
|
+
if (opts.list) {
|
|
452
|
+
const rows = releases.map((r) => ({
|
|
453
|
+
version: r.version,
|
|
454
|
+
date: r.date.slice(0, 10),
|
|
455
|
+
checksum: r.sha256 ? 'pinned' : 'not pinned',
|
|
456
|
+
url: r.url ?? '(derived from the current URL)',
|
|
457
|
+
installed: sameVersion(r.version, identity?.version),
|
|
458
|
+
}));
|
|
459
|
+
if (ctx.format === 'json')
|
|
460
|
+
ctx.io.out(renderJson(rows));
|
|
461
|
+
else {
|
|
462
|
+
const w = Math.max(...rows.map((r) => r.version.length), 7);
|
|
463
|
+
for (const r of rows)
|
|
464
|
+
ctx.io.out(`${r.version.padEnd(w)} ${r.date} ${r.checksum.padEnd(10)} ${r.installed ? 'installed' : ''}\n`);
|
|
465
|
+
}
|
|
466
|
+
return 0;
|
|
467
|
+
}
|
|
468
|
+
const release = pickRelease(releases, wanted, identity?.version);
|
|
469
|
+
const fetched = await fetchRelease(info, release, { scan: ctx.scan, log });
|
|
470
|
+
const file = await ctx.store.save(fetched.snapshot);
|
|
471
|
+
log(`Stored ${fetched.snapshot.app.name} ${fetched.snapshot.app.version} as a snapshot (${file}).`);
|
|
472
|
+
if (!installed) {
|
|
473
|
+
log(`${info.name} isn't installed here, so there's nothing to compare it with yet.`);
|
|
474
|
+
return 0;
|
|
475
|
+
}
|
|
476
|
+
if (identity && identity.bundleId !== fetched.snapshot.app.bundleId) {
|
|
477
|
+
ctx.io.err(`Warning: the cask's app (${fetched.snapshot.app.bundleId}) isn't the installed app (${identity.bundleId}); check --cask.\n`);
|
|
478
|
+
}
|
|
479
|
+
log(`Comparing with the installed ${identity?.name ?? basename(installed)} ${identity?.version ?? ''}…\n`);
|
|
480
|
+
await emit(ctx, diffSnapshots(fetched.snapshot, await scanBundle(installed, ctx.scan)));
|
|
481
|
+
return 0;
|
|
482
|
+
}
|
|
483
|
+
async function cmdPkg(ctx, args) {
|
|
484
|
+
if (args.length < 1 || args.length > 2)
|
|
485
|
+
throw new UsageError('pkg needs an installer package, or two to compare.');
|
|
486
|
+
for (const a of args)
|
|
487
|
+
if (!existsSync(a))
|
|
488
|
+
throw new UsageError(`Can't find "${a}".`);
|
|
489
|
+
const reports = await Promise.all(args.map((a) => inspectPkg(resolve(a))));
|
|
490
|
+
if (reports.length === 2) {
|
|
491
|
+
// Arguments are new then old, matching "what does this update change".
|
|
492
|
+
await emit(ctx, diffPkgs(reports[1], reports[0]));
|
|
493
|
+
return 0;
|
|
494
|
+
}
|
|
495
|
+
const r = reports[0];
|
|
496
|
+
const text = ctx.format === 'json' ? renderJson(r) : ctx.format === 'md' ? renderPkgMarkdown(r) : renderPkg(r, { color: ctx.io.color && !ctx.out, full: ctx.full });
|
|
497
|
+
if (ctx.out) {
|
|
498
|
+
await writeFile(ctx.out, text);
|
|
499
|
+
ctx.io.err(`Wrote ${ctx.out}\n`);
|
|
500
|
+
}
|
|
501
|
+
else
|
|
502
|
+
ctx.io.out(text);
|
|
503
|
+
return 0;
|
|
504
|
+
}
|
|
505
|
+
async function cmdList(ctx, args) {
|
|
506
|
+
if (args.length > 1)
|
|
507
|
+
throw new UsageError('list takes at most one app.');
|
|
508
|
+
if (args.length === 1) {
|
|
509
|
+
const side = await resolveSide(ctx, args[0]);
|
|
510
|
+
let id;
|
|
511
|
+
if (side.kind === 'stored')
|
|
512
|
+
id = side.bundleId;
|
|
513
|
+
else if (side.kind === 'app')
|
|
514
|
+
id = (await readIdentity(side.path))?.bundleId;
|
|
515
|
+
else
|
|
516
|
+
id = side.snapshot.app.bundleId;
|
|
517
|
+
const list = id ? await ctx.store.list(id) : [];
|
|
518
|
+
if (ctx.format === 'json')
|
|
519
|
+
ctx.io.out(renderJson(list));
|
|
520
|
+
else if (!list.length)
|
|
521
|
+
ctx.io.out(`No snapshots of ${id ?? args[0]}.\n`);
|
|
522
|
+
else
|
|
523
|
+
for (const s of list)
|
|
524
|
+
ctx.io.out(`${s.version.padEnd(18)} ${s.build.padEnd(14)} ${s.takenAt} ${s.file}\n`);
|
|
525
|
+
return 0;
|
|
526
|
+
}
|
|
527
|
+
const ids = await ctx.store.bundleIds();
|
|
528
|
+
const rows = [];
|
|
529
|
+
for (const id of ids) {
|
|
530
|
+
const list = await ctx.store.list(id);
|
|
531
|
+
const [latest] = await ctx.store.latest(id, 1);
|
|
532
|
+
rows.push({ bundleId: id, name: latest?.app.name ?? id, version: latest?.app.version ?? '?', snapshots: list.length });
|
|
533
|
+
}
|
|
534
|
+
if (ctx.format === 'json')
|
|
535
|
+
ctx.io.out(renderJson(rows));
|
|
536
|
+
else if (!rows.length)
|
|
537
|
+
ctx.io.out(`No snapshots yet. Run 'updiff snapshot'.\n`);
|
|
538
|
+
else {
|
|
539
|
+
const w = Math.max(...rows.map((r) => r.name.length), 4);
|
|
540
|
+
for (const r of rows)
|
|
541
|
+
ctx.io.out(`${r.name.padEnd(w)} ${r.version.padEnd(18)} ${String(r.snapshots).padStart(3)} snapshot${r.snapshots === 1 ? ' ' : 's'} ${r.bundleId}\n`);
|
|
542
|
+
}
|
|
543
|
+
return 0;
|
|
544
|
+
}
|
|
545
|
+
function cliPath() {
|
|
546
|
+
const p = process.argv[1];
|
|
547
|
+
return p ? realpathSync(p) : resolve('dist/cli.js');
|
|
548
|
+
}
|
|
549
|
+
async function cmdAgent(ctx, args, opts) {
|
|
550
|
+
const action = args[0];
|
|
551
|
+
if (action === 'install') {
|
|
552
|
+
const options = {
|
|
553
|
+
nodePath: process.execPath,
|
|
554
|
+
cliPath: cliPath(),
|
|
555
|
+
home: ctx.store.home,
|
|
556
|
+
intervalSeconds: opts.interval,
|
|
557
|
+
watchPaths: ctx.appsDirs.filter((d) => existsSync(d)),
|
|
558
|
+
appsDirs: ctx.appsDirs,
|
|
559
|
+
};
|
|
560
|
+
if (opts.dryRun) {
|
|
561
|
+
ctx.io.out(agentPlist(options));
|
|
562
|
+
return 0;
|
|
563
|
+
}
|
|
564
|
+
const res = await installAgent(options, opts.load);
|
|
565
|
+
ctx.io.out(`Installed ${AGENT_LABEL} at ${res.path}${opts.load ? (res.loaded ? ' and loaded it' : ` but launchctl failed: ${res.message}`) : ' (not loaded)'}.\n`);
|
|
566
|
+
if (opts.load && res.loaded)
|
|
567
|
+
ctx.io.out(`It runs 'updiff check' after changes in ${options.watchPaths.join(', ')} and every ${Math.round(opts.interval / 3600)} h. Log: ${join(ctx.store.home, 'agent.log')}\n`);
|
|
568
|
+
ctx.io.out(`Run 'updiff snapshot' once now so the first update has a baseline.\n`);
|
|
569
|
+
return opts.load && !res.loaded ? 1 : 0;
|
|
570
|
+
}
|
|
571
|
+
if (action === 'uninstall') {
|
|
572
|
+
const res = await uninstallAgent(opts.load);
|
|
573
|
+
ctx.io.out(res.existed ? `Removed ${res.path}.\n` : `Not installed (${res.path}).\n`);
|
|
574
|
+
return 0;
|
|
575
|
+
}
|
|
576
|
+
if (action === 'status') {
|
|
577
|
+
const s = await agentStatus();
|
|
578
|
+
ctx.io.out(`${AGENT_LABEL}: ${s.installed ? 'installed' : 'not installed'}, ${s.loaded ? 'loaded' : 'not loaded'} (${s.path})\n`);
|
|
579
|
+
return 0;
|
|
580
|
+
}
|
|
581
|
+
throw new UsageError('agent needs install, uninstall or status.');
|
|
582
|
+
}
|
|
583
|
+
function intOption(value, name, fallback) {
|
|
584
|
+
if (value === undefined)
|
|
585
|
+
return fallback;
|
|
586
|
+
const n = Number(value);
|
|
587
|
+
if (!Number.isInteger(n) || n < 0)
|
|
588
|
+
throw new UsageError(`--${name} must be a whole number.`);
|
|
589
|
+
return n;
|
|
590
|
+
}
|
|
591
|
+
export async function main(argv, io) {
|
|
592
|
+
let parsed;
|
|
593
|
+
try {
|
|
594
|
+
parsed = parse(argv);
|
|
595
|
+
}
|
|
596
|
+
catch (err) {
|
|
597
|
+
io.err(`${err.message}\n\nRun 'updiff --help' for usage.\n`);
|
|
598
|
+
return 2;
|
|
599
|
+
}
|
|
600
|
+
const { values: v, positionals } = parsed;
|
|
601
|
+
if (v.version) {
|
|
602
|
+
io.out(`${TOOL_VERSION}\n`);
|
|
603
|
+
return 0;
|
|
604
|
+
}
|
|
605
|
+
const [command, ...args] = positionals;
|
|
606
|
+
if (v.help || !command) {
|
|
607
|
+
io.out(USAGE);
|
|
608
|
+
return command || v.help ? 0 : 2;
|
|
609
|
+
}
|
|
610
|
+
try {
|
|
611
|
+
const format = (v.format ?? 'text');
|
|
612
|
+
if (!['text', 'md', 'json'].includes(format))
|
|
613
|
+
throw new UsageError('--format must be text, md or json.');
|
|
614
|
+
const minSeverity = (v['min-severity'] ?? 'info');
|
|
615
|
+
if (!['high', 'notable', 'info'].includes(minSeverity))
|
|
616
|
+
throw new UsageError('--min-severity must be high, notable or info.');
|
|
617
|
+
const ctx = {
|
|
618
|
+
io: { ...io, color: io.color && !v['no-color'] && format === 'text' },
|
|
619
|
+
store: new Store(v.home ? resolve(v.home) : undefined),
|
|
620
|
+
appsDirs: (v['apps-dir'] ?? defaultAppsDirs()).map((d) => resolve(d)),
|
|
621
|
+
scan: { deep: v.deep === true, notarization: !v['no-gatekeeper'] },
|
|
622
|
+
format,
|
|
623
|
+
minSeverity,
|
|
624
|
+
out: v.out,
|
|
625
|
+
full: v.full === true,
|
|
626
|
+
quiet: v.quiet === true,
|
|
627
|
+
force: v.force === true,
|
|
628
|
+
keep: Math.max(2, intOption(v.keep, 'keep', 10)),
|
|
629
|
+
};
|
|
630
|
+
switch (command) {
|
|
631
|
+
case 'scan':
|
|
632
|
+
return await cmdScan(ctx, args);
|
|
633
|
+
case 'snapshot':
|
|
634
|
+
return await cmdSnapshot(ctx, args);
|
|
635
|
+
case 'diff':
|
|
636
|
+
return await cmdDiff(ctx, args);
|
|
637
|
+
case 'check':
|
|
638
|
+
return await cmdCheck(ctx, args, v.notify === true, intOption(v.settle, 'settle', 0), v.extensions === true);
|
|
639
|
+
case 'extensions':
|
|
640
|
+
return await cmdExtensions(ctx, args);
|
|
641
|
+
case 'list':
|
|
642
|
+
return await cmdList(ctx, args);
|
|
643
|
+
case 'pkg':
|
|
644
|
+
return await cmdPkg(ctx, args);
|
|
645
|
+
case 'fetch':
|
|
646
|
+
return await cmdFetch(ctx, args, { cask: v.cask, list: v.list === true });
|
|
647
|
+
case 'agent':
|
|
648
|
+
return await cmdAgent(ctx, args, { interval: Math.max(600, intOption(v.interval, 'interval', 21600)), dryRun: v['dry-run'] === true, load: !v['no-load'] });
|
|
649
|
+
default:
|
|
650
|
+
throw new UsageError(`Unknown command "${command}".`);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
catch (err) {
|
|
654
|
+
if (err instanceof UsageError) {
|
|
655
|
+
io.err(`${err.message}\n`);
|
|
656
|
+
return 2;
|
|
657
|
+
}
|
|
658
|
+
io.err(`updiff: ${process.env.UPDIFF_DEBUG ? (err.stack ?? err) : (err.message ?? err)}\n`);
|
|
659
|
+
return 1;
|
|
660
|
+
}
|
|
661
|
+
}
|