@intincrab/updiff 0.0.0-stage → 0.1.1
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 +310 -2
- package/dist/agent.js +63 -0
- package/dist/asar.js +91 -0
- package/dist/bundle.js +628 -0
- package/dist/cli.js +8 -0
- package/dist/codesign.js +62 -0
- package/dist/commands.js +510 -0
- package/dist/diff.js +525 -0
- package/dist/exec.js +19 -0
- package/dist/hosts.js +120 -0
- package/dist/plist.js +196 -0
- package/dist/report.js +124 -0
- package/dist/store.js +92 -0
- package/dist/types.js +1 -0
- package/dist/util.js +39 -0
- package/dist/version.js +2 -0
- package/package.json +55 -4
package/dist/codesign.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { run } from "./exec.js";
|
|
2
|
+
import { isDict, parsePlistXml } from "./plist.js";
|
|
3
|
+
/** Parses the stderr of `codesign -dvvv`. Exported for tests. */
|
|
4
|
+
export function parseCodesignDetails(stderr, code) {
|
|
5
|
+
if (/not signed at all/i.test(stderr)) {
|
|
6
|
+
return { status: 'unsigned', authorities: [], hardenedRuntime: false, flags: [] };
|
|
7
|
+
}
|
|
8
|
+
if (code !== 0 && !/^Identifier=/m.test(stderr)) {
|
|
9
|
+
return { status: 'error', authorities: [], hardenedRuntime: false, flags: [], error: stderr.trim().split('\n').pop() };
|
|
10
|
+
}
|
|
11
|
+
const authorities = [];
|
|
12
|
+
let teamId;
|
|
13
|
+
let identifier;
|
|
14
|
+
let flags = [];
|
|
15
|
+
let adhoc = false;
|
|
16
|
+
for (const line of stderr.split('\n')) {
|
|
17
|
+
const eq = line.indexOf('=');
|
|
18
|
+
if (eq < 0)
|
|
19
|
+
continue;
|
|
20
|
+
const key = line.slice(0, eq).trim();
|
|
21
|
+
const value = line.slice(eq + 1).trim();
|
|
22
|
+
if (key === 'Authority')
|
|
23
|
+
authorities.push(value);
|
|
24
|
+
else if (key === 'TeamIdentifier')
|
|
25
|
+
teamId = value === 'not set' ? undefined : value;
|
|
26
|
+
else if (key === 'Identifier')
|
|
27
|
+
identifier = value;
|
|
28
|
+
else if (key === 'Signature' && value === 'adhoc')
|
|
29
|
+
adhoc = true;
|
|
30
|
+
else if (key === 'CodeDirectory v') {
|
|
31
|
+
// CodeDirectory v=20500 size=1234 flags=0x10000(runtime) hashes=...
|
|
32
|
+
const m = /flags=0x[0-9a-f]+\(([^)]*)\)/i.exec(line);
|
|
33
|
+
if (m?.[1])
|
|
34
|
+
flags = m[1].split(',').map((s) => s.trim()).filter((s) => s && s !== 'none');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
status: adhoc ? 'adhoc' : 'signed',
|
|
39
|
+
teamId,
|
|
40
|
+
identifier,
|
|
41
|
+
authorities,
|
|
42
|
+
hardenedRuntime: flags.includes('runtime'),
|
|
43
|
+
flags,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export async function readSigning(path) {
|
|
47
|
+
const res = await run('/usr/bin/codesign', ['-dvvv', path]);
|
|
48
|
+
return parseCodesignDetails(res.stderr, res.code);
|
|
49
|
+
}
|
|
50
|
+
export async function readEntitlements(path) {
|
|
51
|
+
const res = await run('/usr/bin/codesign', ['-d', '--entitlements', '-', '--xml', path]);
|
|
52
|
+
const xml = res.stdout.trim();
|
|
53
|
+
if (res.code !== 0 || !xml.startsWith('<'))
|
|
54
|
+
return {};
|
|
55
|
+
try {
|
|
56
|
+
const value = parsePlistXml(xml);
|
|
57
|
+
return isDict(value) ? value : {};
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return {};
|
|
61
|
+
}
|
|
62
|
+
}
|
package/dist/commands.js
ADDED
|
@@ -0,0 +1,510 @@
|
|
|
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 { renderJson, renderMarkdown, renderScan, renderText } from "./report.js";
|
|
10
|
+
import { Store } from "./store.js";
|
|
11
|
+
import { mapLimit } from "./util.js";
|
|
12
|
+
import { TOOL_VERSION } from "./version.js";
|
|
13
|
+
export const USAGE = `updiff ${TOOL_VERSION}: see what a macOS app update actually changed
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
updiff scan <app> Inspect one app; nothing is stored
|
|
17
|
+
updiff snapshot [app...] Store snapshots (all apps when none are given)
|
|
18
|
+
updiff diff <app> Compare the two newest snapshots of an app
|
|
19
|
+
updiff diff <a> <b> Compare two apps, snapshot files or stored apps
|
|
20
|
+
updiff check Snapshot apps that changed and report the differences
|
|
21
|
+
updiff list [app] List stored snapshots
|
|
22
|
+
updiff agent install|uninstall|status
|
|
23
|
+
Run 'check' automatically after app updates
|
|
24
|
+
|
|
25
|
+
<app> is a path to an .app, an app name in your Applications folders ("Slack"),
|
|
26
|
+
or a bundle id that has stored snapshots ("com.tinyspeck.slackmacgap").
|
|
27
|
+
|
|
28
|
+
Options:
|
|
29
|
+
--format text|md|json Output format (default text)
|
|
30
|
+
--min-severity high|notable|info Hide less important findings (default info)
|
|
31
|
+
--out <file> Write the report to a file
|
|
32
|
+
--full Do not shorten long details in text output
|
|
33
|
+
--deep Also scan framework binaries for hostnames (slower)
|
|
34
|
+
--force Store a snapshot even if the app has not changed
|
|
35
|
+
--home <dir> Data folder (default ~/.updiff, or $UPDIFF_HOME)
|
|
36
|
+
--apps-dir <dir> Folder to look for apps (repeatable; default
|
|
37
|
+
/Applications and ~/Applications, or
|
|
38
|
+
$UPDIFF_APPS_DIRS, colon-separated)
|
|
39
|
+
--notify check: show a notification for each changed app
|
|
40
|
+
--settle <seconds> check: wait until apps stop changing for this long
|
|
41
|
+
--keep <n> check/snapshot: snapshots to keep per app (default 10)
|
|
42
|
+
--quiet Print less
|
|
43
|
+
--interval <seconds> agent install: fallback timer (default 21600)
|
|
44
|
+
--dry-run agent install: print the LaunchAgent, change nothing
|
|
45
|
+
--no-load agent install/uninstall: write files, skip launchctl
|
|
46
|
+
--no-color Plain text output
|
|
47
|
+
-h, --help Show this help
|
|
48
|
+
-v, --version Show the version
|
|
49
|
+
|
|
50
|
+
Hostnames found in code are not proof that the app connects to them.
|
|
51
|
+
`;
|
|
52
|
+
class UsageError extends Error {
|
|
53
|
+
}
|
|
54
|
+
function parse(argv) {
|
|
55
|
+
return parseArgs({
|
|
56
|
+
args: argv,
|
|
57
|
+
allowPositionals: true,
|
|
58
|
+
strict: true,
|
|
59
|
+
options: {
|
|
60
|
+
format: { type: 'string' },
|
|
61
|
+
'min-severity': { type: 'string' },
|
|
62
|
+
out: { type: 'string' },
|
|
63
|
+
full: { type: 'boolean' },
|
|
64
|
+
deep: { type: 'boolean' },
|
|
65
|
+
force: { type: 'boolean' },
|
|
66
|
+
home: { type: 'string' },
|
|
67
|
+
'apps-dir': { type: 'string', multiple: true },
|
|
68
|
+
notify: { type: 'boolean' },
|
|
69
|
+
settle: { type: 'string' },
|
|
70
|
+
keep: { type: 'string' },
|
|
71
|
+
quiet: { type: 'boolean' },
|
|
72
|
+
interval: { type: 'string' },
|
|
73
|
+
'dry-run': { type: 'boolean' },
|
|
74
|
+
'no-load': { type: 'boolean' },
|
|
75
|
+
'no-color': { type: 'boolean' },
|
|
76
|
+
help: { type: 'boolean', short: 'h' },
|
|
77
|
+
version: { type: 'boolean', short: 'v' },
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
/** `$UPDIFF_APPS_DIRS` (colon-separated) overrides the default app folders. */
|
|
82
|
+
function defaultAppsDirs() {
|
|
83
|
+
const fromEnv = process.env.UPDIFF_APPS_DIRS?.split(':').filter(Boolean);
|
|
84
|
+
return fromEnv?.length ? fromEnv : ['/Applications', join(homedir(), 'Applications')];
|
|
85
|
+
}
|
|
86
|
+
/** Apps at the top of each folder and one folder down (for suites like "Adobe X/"). */
|
|
87
|
+
export async function discoverApps(dirs) {
|
|
88
|
+
const found = [];
|
|
89
|
+
for (const dir of dirs) {
|
|
90
|
+
let dirents;
|
|
91
|
+
try {
|
|
92
|
+
dirents = await readdir(dir, { withFileTypes: true });
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
for (const d of dirents) {
|
|
98
|
+
if (!d.isDirectory())
|
|
99
|
+
continue;
|
|
100
|
+
const p = join(dir, d.name);
|
|
101
|
+
if (d.name.endsWith('.app'))
|
|
102
|
+
found.push(p);
|
|
103
|
+
else if (!d.name.startsWith('.')) {
|
|
104
|
+
try {
|
|
105
|
+
for (const inner of await readdir(p, { withFileTypes: true })) {
|
|
106
|
+
if (inner.isDirectory() && inner.name.endsWith('.app'))
|
|
107
|
+
found.push(join(p, inner.name));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// Unreadable folder: skip it.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return found.sort((a, b) => basename(a).localeCompare(basename(b)));
|
|
117
|
+
}
|
|
118
|
+
function isAppDir(p) {
|
|
119
|
+
try {
|
|
120
|
+
return statSync(p).isDirectory() && existsSync(join(p, 'Contents', 'Info.plist'));
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** Finds an installed app by path or by name. */
|
|
127
|
+
function findApp(arg, dirs) {
|
|
128
|
+
const asPath = resolve(arg);
|
|
129
|
+
if (isAppDir(asPath))
|
|
130
|
+
return asPath;
|
|
131
|
+
const name = arg.endsWith('.app') ? arg : `${arg}.app`;
|
|
132
|
+
for (const dir of dirs) {
|
|
133
|
+
const p = join(dir, name);
|
|
134
|
+
if (isAppDir(p))
|
|
135
|
+
return p;
|
|
136
|
+
}
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
async function findAppByName(arg, dirs) {
|
|
140
|
+
const direct = findApp(arg, dirs);
|
|
141
|
+
if (direct)
|
|
142
|
+
return direct;
|
|
143
|
+
const q = arg.toLowerCase().replace(/\.app$/, '');
|
|
144
|
+
return (await discoverApps(dirs)).find((p) => basename(p, '.app').toLowerCase() === q);
|
|
145
|
+
}
|
|
146
|
+
async function resolveSide(ctx, arg) {
|
|
147
|
+
if (arg.endsWith('.json') && existsSync(arg))
|
|
148
|
+
return { kind: 'snapshot', snapshot: await ctx.store.load(arg) };
|
|
149
|
+
const app = await findAppByName(arg, ctx.appsDirs);
|
|
150
|
+
if (app)
|
|
151
|
+
return { kind: 'app', path: app };
|
|
152
|
+
const id = await ctx.store.resolveBundleId(arg);
|
|
153
|
+
if (id)
|
|
154
|
+
return { kind: 'stored', bundleId: id };
|
|
155
|
+
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.`);
|
|
156
|
+
}
|
|
157
|
+
async function sideSnapshot(ctx, side) {
|
|
158
|
+
if (side.kind === 'snapshot')
|
|
159
|
+
return side.snapshot;
|
|
160
|
+
if (side.kind === 'app')
|
|
161
|
+
return scanBundle(side.path, ctx.scan);
|
|
162
|
+
const [latest] = await ctx.store.latest(side.bundleId, 1);
|
|
163
|
+
if (!latest)
|
|
164
|
+
throw new UsageError(`No snapshots stored for ${side.bundleId}.`);
|
|
165
|
+
return latest;
|
|
166
|
+
}
|
|
167
|
+
async function emit(ctx, report) {
|
|
168
|
+
const r = filterBySeverity(report, ctx.minSeverity);
|
|
169
|
+
const text = ctx.format === 'json'
|
|
170
|
+
? renderJson(r)
|
|
171
|
+
: ctx.format === 'md'
|
|
172
|
+
? renderMarkdown(r)
|
|
173
|
+
: renderText(r, { color: ctx.io.color && !ctx.out, full: ctx.full });
|
|
174
|
+
if (ctx.out) {
|
|
175
|
+
await writeFile(ctx.out, text);
|
|
176
|
+
ctx.io.err(`Wrote ${ctx.out}\n`);
|
|
177
|
+
}
|
|
178
|
+
else
|
|
179
|
+
ctx.io.out(text);
|
|
180
|
+
}
|
|
181
|
+
async function cmdScan(ctx, args) {
|
|
182
|
+
if (args.length !== 1)
|
|
183
|
+
throw new UsageError('scan needs exactly one app.');
|
|
184
|
+
const app = await findAppByName(args[0], ctx.appsDirs);
|
|
185
|
+
if (!app)
|
|
186
|
+
throw new UsageError(`Can't find app "${args[0]}".`);
|
|
187
|
+
const s = await scanBundle(app, ctx.scan);
|
|
188
|
+
const text = ctx.format === 'json' ? renderJson(s) : renderScan(s, { color: ctx.io.color && !ctx.out });
|
|
189
|
+
if (ctx.out) {
|
|
190
|
+
await writeFile(ctx.out, text);
|
|
191
|
+
ctx.io.err(`Wrote ${ctx.out}\n`);
|
|
192
|
+
}
|
|
193
|
+
else
|
|
194
|
+
ctx.io.out(text);
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
async function snapshotApp(ctx, appPath) {
|
|
198
|
+
try {
|
|
199
|
+
const id = await readIdentity(appPath);
|
|
200
|
+
if (!id)
|
|
201
|
+
return { app: appPath, status: 'failed', error: 'no readable Info.plist' };
|
|
202
|
+
const [previous] = await ctx.store.latest(id.bundleId, 1);
|
|
203
|
+
if (previous && !ctx.force) {
|
|
204
|
+
const fp = await quickFingerprint(appPath);
|
|
205
|
+
if (fp === previous.fingerprint)
|
|
206
|
+
return { app: appPath, status: 'unchanged', previous };
|
|
207
|
+
}
|
|
208
|
+
const snapshot = await scanBundle(appPath, ctx.scan);
|
|
209
|
+
await ctx.store.save(snapshot);
|
|
210
|
+
await ctx.store.prune(snapshot.app.bundleId, ctx.keep);
|
|
211
|
+
return { app: appPath, status: previous ? 'changed' : 'new', snapshot, previous };
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
return { app: appPath, status: 'failed', error: err.message };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
async function cmdSnapshot(ctx, args) {
|
|
218
|
+
let apps;
|
|
219
|
+
if (args.length) {
|
|
220
|
+
apps = [];
|
|
221
|
+
for (const a of args) {
|
|
222
|
+
const p = await findAppByName(a, ctx.appsDirs);
|
|
223
|
+
if (!p)
|
|
224
|
+
throw new UsageError(`Can't find app "${a}".`);
|
|
225
|
+
apps.push(p);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
else
|
|
229
|
+
apps = await discoverApps(ctx.appsDirs);
|
|
230
|
+
const results = await mapLimit(apps, 3, async (app) => {
|
|
231
|
+
const r = await snapshotApp(ctx, app);
|
|
232
|
+
if (!ctx.quiet) {
|
|
233
|
+
const s = r.snapshot ?? r.previous;
|
|
234
|
+
const label = s ? `${s.app.name} ${s.app.version}` : basename(app);
|
|
235
|
+
const time = r.snapshot ? ` (${(r.snapshot.stats.scanMs / 1000).toFixed(1)} s)` : '';
|
|
236
|
+
ctx.io.err(`${r.status.padEnd(9)} ${label}${time}${r.error ? `: ${r.error}` : ''}\n`);
|
|
237
|
+
}
|
|
238
|
+
return r;
|
|
239
|
+
});
|
|
240
|
+
const failed = results.filter((r) => r.status === 'failed').length;
|
|
241
|
+
if (ctx.format === 'json') {
|
|
242
|
+
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 }))));
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
const count = (s) => results.filter((r) => r.status === s).length;
|
|
246
|
+
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`);
|
|
247
|
+
}
|
|
248
|
+
return failed && failed === apps.length ? 1 : 0;
|
|
249
|
+
}
|
|
250
|
+
async function cmdDiff(ctx, args) {
|
|
251
|
+
if (args.length === 2) {
|
|
252
|
+
const [a, b] = await Promise.all(args.map((x) => resolveSide(ctx, x)));
|
|
253
|
+
const [sa, sb] = await Promise.all([sideSnapshot(ctx, a), sideSnapshot(ctx, b)]);
|
|
254
|
+
await emit(ctx, diffSnapshots(sa, sb));
|
|
255
|
+
return 0;
|
|
256
|
+
}
|
|
257
|
+
if (args.length !== 1)
|
|
258
|
+
throw new UsageError('diff needs one app, or two things to compare.');
|
|
259
|
+
const side = await resolveSide(ctx, args[0]);
|
|
260
|
+
let bundleId;
|
|
261
|
+
if (side.kind === 'snapshot')
|
|
262
|
+
throw new UsageError('Give two snapshot files to compare them.');
|
|
263
|
+
if (side.kind === 'app') {
|
|
264
|
+
const id = await readIdentity(side.path);
|
|
265
|
+
if (!id)
|
|
266
|
+
throw new UsageError(`${side.path} has no readable Info.plist.`);
|
|
267
|
+
bundleId = id.bundleId;
|
|
268
|
+
}
|
|
269
|
+
else
|
|
270
|
+
bundleId = side.bundleId;
|
|
271
|
+
const stored = await ctx.store.latest(bundleId, 2);
|
|
272
|
+
if (side.kind === 'app' && stored[0]) {
|
|
273
|
+
// Compare the installed app against the newest snapshot when it has changed since.
|
|
274
|
+
const fp = await quickFingerprint(side.path);
|
|
275
|
+
if (fp !== stored[0].fingerprint) {
|
|
276
|
+
await emit(ctx, diffSnapshots(stored[0], await scanBundle(side.path, ctx.scan)));
|
|
277
|
+
return 0;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (stored.length < 2) {
|
|
281
|
+
throw new UsageError(stored.length
|
|
282
|
+
? `Only one snapshot of ${bundleId}, and the app has not changed since. Run 'updiff snapshot' again after it updates.`
|
|
283
|
+
: `No snapshots of ${bundleId} yet. Run 'updiff snapshot "${args[0]}"' first.`);
|
|
284
|
+
}
|
|
285
|
+
await emit(ctx, diffSnapshots(stored[1], stored[0]));
|
|
286
|
+
return 0;
|
|
287
|
+
}
|
|
288
|
+
async function waitUntilSettled(apps, seconds, io, quiet) {
|
|
289
|
+
if (seconds <= 0)
|
|
290
|
+
return;
|
|
291
|
+
const deadline = Date.now() + 10 * 60 * 1000;
|
|
292
|
+
for (;;) {
|
|
293
|
+
let newest = 0;
|
|
294
|
+
for (const app of apps) {
|
|
295
|
+
for (const p of [app, join(app, 'Contents'), join(app, 'Contents', 'Info.plist'), join(app, 'Contents', '_CodeSignature', 'CodeResources')]) {
|
|
296
|
+
try {
|
|
297
|
+
newest = Math.max(newest, (await stat(p)).mtimeMs);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
// Missing while the update is being written.
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const age = (Date.now() - newest) / 1000;
|
|
305
|
+
if (age >= seconds || Date.now() > deadline)
|
|
306
|
+
return;
|
|
307
|
+
if (!quiet)
|
|
308
|
+
io.err(`An app changed ${Math.round(age)} s ago; waiting for the update to finish…\n`);
|
|
309
|
+
await new Promise((r) => setTimeout(r, Math.min(15, seconds - age) * 1000));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
async function cmdCheck(ctx, args, notifyEnabled, settle) {
|
|
313
|
+
if (args.length)
|
|
314
|
+
throw new UsageError('check takes no app names; use --apps-dir to choose folders.');
|
|
315
|
+
let apps = await discoverApps(ctx.appsDirs);
|
|
316
|
+
await waitUntilSettled(apps, settle, ctx.io, ctx.quiet);
|
|
317
|
+
apps = await discoverApps(ctx.appsDirs);
|
|
318
|
+
const results = await mapLimit(apps, 3, (app) => snapshotApp(ctx, app));
|
|
319
|
+
const reports = [];
|
|
320
|
+
for (const r of results) {
|
|
321
|
+
if (r.status !== 'changed' || !r.previous || !r.snapshot)
|
|
322
|
+
continue;
|
|
323
|
+
const report = diffSnapshots(r.previous, r.snapshot);
|
|
324
|
+
const file = await ctx.store.saveReport(report.app.bundleId, report.from.version, report.to.version, 'md', renderMarkdown(report));
|
|
325
|
+
reports.push({ report, file });
|
|
326
|
+
}
|
|
327
|
+
const baseline = results.filter((r) => r.status === 'new');
|
|
328
|
+
const failed = results.filter((r) => r.status === 'failed');
|
|
329
|
+
if (ctx.format === 'json') {
|
|
330
|
+
ctx.io.out(renderJson({
|
|
331
|
+
checked: apps.length,
|
|
332
|
+
baseline: baseline.map((r) => r.snapshot.app.bundleId),
|
|
333
|
+
failed: failed.map((r) => ({ app: r.app, error: r.error })),
|
|
334
|
+
changed: reports.map(({ report, file }) => ({ ...filterBySeverity(report, ctx.minSeverity), reportFile: file })),
|
|
335
|
+
}));
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
for (const { report, file } of reports) {
|
|
339
|
+
ctx.io.out(renderText(filterBySeverity(report, ctx.minSeverity), { color: ctx.io.color, full: ctx.full }));
|
|
340
|
+
ctx.io.out(`Report: ${file}\n\n`);
|
|
341
|
+
}
|
|
342
|
+
if (!ctx.quiet || reports.length || failed.length) {
|
|
343
|
+
const parts = [`Checked ${apps.length} app${apps.length === 1 ? '' : 's'}`, `${reports.length} updated`];
|
|
344
|
+
if (baseline.length)
|
|
345
|
+
parts.push(`${baseline.length} new baseline${baseline.length === 1 ? '' : 's'}`);
|
|
346
|
+
if (failed.length)
|
|
347
|
+
parts.push(`${failed.length} failed (${failed.map((f) => basename(f.app)).join(', ')})`);
|
|
348
|
+
ctx.io.out(`${parts.join(', ')}.\n`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (notifyEnabled) {
|
|
352
|
+
for (const { report } of reports) {
|
|
353
|
+
const { high, notable } = report.counts;
|
|
354
|
+
const summary = high || notable ? `${high} high, ${notable} notable change${high + notable === 1 ? '' : 's'}` : 'No notable changes';
|
|
355
|
+
await notify(`Updiff: ${report.app.name} ${report.to.version}`, `${summary}. Run: updiff diff "${report.app.bundleId}"`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return 0;
|
|
359
|
+
}
|
|
360
|
+
async function cmdList(ctx, args) {
|
|
361
|
+
if (args.length > 1)
|
|
362
|
+
throw new UsageError('list takes at most one app.');
|
|
363
|
+
if (args.length === 1) {
|
|
364
|
+
const side = await resolveSide(ctx, args[0]);
|
|
365
|
+
let id;
|
|
366
|
+
if (side.kind === 'stored')
|
|
367
|
+
id = side.bundleId;
|
|
368
|
+
else if (side.kind === 'app')
|
|
369
|
+
id = (await readIdentity(side.path))?.bundleId;
|
|
370
|
+
else
|
|
371
|
+
id = side.snapshot.app.bundleId;
|
|
372
|
+
const list = id ? await ctx.store.list(id) : [];
|
|
373
|
+
if (ctx.format === 'json')
|
|
374
|
+
ctx.io.out(renderJson(list));
|
|
375
|
+
else if (!list.length)
|
|
376
|
+
ctx.io.out(`No snapshots of ${id ?? args[0]}.\n`);
|
|
377
|
+
else
|
|
378
|
+
for (const s of list)
|
|
379
|
+
ctx.io.out(`${s.version.padEnd(18)} ${s.build.padEnd(14)} ${s.takenAt} ${s.file}\n`);
|
|
380
|
+
return 0;
|
|
381
|
+
}
|
|
382
|
+
const ids = await ctx.store.bundleIds();
|
|
383
|
+
const rows = [];
|
|
384
|
+
for (const id of ids) {
|
|
385
|
+
const list = await ctx.store.list(id);
|
|
386
|
+
const [latest] = await ctx.store.latest(id, 1);
|
|
387
|
+
rows.push({ bundleId: id, name: latest?.app.name ?? id, version: latest?.app.version ?? '?', snapshots: list.length });
|
|
388
|
+
}
|
|
389
|
+
if (ctx.format === 'json')
|
|
390
|
+
ctx.io.out(renderJson(rows));
|
|
391
|
+
else if (!rows.length)
|
|
392
|
+
ctx.io.out(`No snapshots yet. Run 'updiff snapshot'.\n`);
|
|
393
|
+
else {
|
|
394
|
+
const w = Math.max(...rows.map((r) => r.name.length), 4);
|
|
395
|
+
for (const r of rows)
|
|
396
|
+
ctx.io.out(`${r.name.padEnd(w)} ${r.version.padEnd(18)} ${String(r.snapshots).padStart(3)} snapshot${r.snapshots === 1 ? ' ' : 's'} ${r.bundleId}\n`);
|
|
397
|
+
}
|
|
398
|
+
return 0;
|
|
399
|
+
}
|
|
400
|
+
function cliPath() {
|
|
401
|
+
const p = process.argv[1];
|
|
402
|
+
return p ? realpathSync(p) : resolve('dist/cli.js');
|
|
403
|
+
}
|
|
404
|
+
async function cmdAgent(ctx, args, opts) {
|
|
405
|
+
const action = args[0];
|
|
406
|
+
if (action === 'install') {
|
|
407
|
+
const options = {
|
|
408
|
+
nodePath: process.execPath,
|
|
409
|
+
cliPath: cliPath(),
|
|
410
|
+
home: ctx.store.home,
|
|
411
|
+
intervalSeconds: opts.interval,
|
|
412
|
+
watchPaths: ctx.appsDirs.filter((d) => existsSync(d)),
|
|
413
|
+
appsDirs: ctx.appsDirs,
|
|
414
|
+
};
|
|
415
|
+
if (opts.dryRun) {
|
|
416
|
+
ctx.io.out(agentPlist(options));
|
|
417
|
+
return 0;
|
|
418
|
+
}
|
|
419
|
+
const res = await installAgent(options, opts.load);
|
|
420
|
+
ctx.io.out(`Installed ${AGENT_LABEL} at ${res.path}${opts.load ? (res.loaded ? ' and loaded it' : ` but launchctl failed: ${res.message}`) : ' (not loaded)'}.\n`);
|
|
421
|
+
if (opts.load && res.loaded)
|
|
422
|
+
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`);
|
|
423
|
+
ctx.io.out(`Run 'updiff snapshot' once now so the first update has a baseline.\n`);
|
|
424
|
+
return opts.load && !res.loaded ? 1 : 0;
|
|
425
|
+
}
|
|
426
|
+
if (action === 'uninstall') {
|
|
427
|
+
const res = await uninstallAgent(opts.load);
|
|
428
|
+
ctx.io.out(res.existed ? `Removed ${res.path}.\n` : `Not installed (${res.path}).\n`);
|
|
429
|
+
return 0;
|
|
430
|
+
}
|
|
431
|
+
if (action === 'status') {
|
|
432
|
+
const s = await agentStatus();
|
|
433
|
+
ctx.io.out(`${AGENT_LABEL}: ${s.installed ? 'installed' : 'not installed'}, ${s.loaded ? 'loaded' : 'not loaded'} (${s.path})\n`);
|
|
434
|
+
return 0;
|
|
435
|
+
}
|
|
436
|
+
throw new UsageError('agent needs install, uninstall or status.');
|
|
437
|
+
}
|
|
438
|
+
function intOption(value, name, fallback) {
|
|
439
|
+
if (value === undefined)
|
|
440
|
+
return fallback;
|
|
441
|
+
const n = Number(value);
|
|
442
|
+
if (!Number.isInteger(n) || n < 0)
|
|
443
|
+
throw new UsageError(`--${name} must be a whole number.`);
|
|
444
|
+
return n;
|
|
445
|
+
}
|
|
446
|
+
export async function main(argv, io) {
|
|
447
|
+
let parsed;
|
|
448
|
+
try {
|
|
449
|
+
parsed = parse(argv);
|
|
450
|
+
}
|
|
451
|
+
catch (err) {
|
|
452
|
+
io.err(`${err.message}\n\nRun 'updiff --help' for usage.\n`);
|
|
453
|
+
return 2;
|
|
454
|
+
}
|
|
455
|
+
const { values: v, positionals } = parsed;
|
|
456
|
+
if (v.version) {
|
|
457
|
+
io.out(`${TOOL_VERSION}\n`);
|
|
458
|
+
return 0;
|
|
459
|
+
}
|
|
460
|
+
const [command, ...args] = positionals;
|
|
461
|
+
if (v.help || !command) {
|
|
462
|
+
io.out(USAGE);
|
|
463
|
+
return command || v.help ? 0 : 2;
|
|
464
|
+
}
|
|
465
|
+
try {
|
|
466
|
+
const format = (v.format ?? 'text');
|
|
467
|
+
if (!['text', 'md', 'json'].includes(format))
|
|
468
|
+
throw new UsageError('--format must be text, md or json.');
|
|
469
|
+
const minSeverity = (v['min-severity'] ?? 'info');
|
|
470
|
+
if (!['high', 'notable', 'info'].includes(minSeverity))
|
|
471
|
+
throw new UsageError('--min-severity must be high, notable or info.');
|
|
472
|
+
const ctx = {
|
|
473
|
+
io: { ...io, color: io.color && !v['no-color'] && format === 'text' },
|
|
474
|
+
store: new Store(v.home ? resolve(v.home) : undefined),
|
|
475
|
+
appsDirs: (v['apps-dir'] ?? defaultAppsDirs()).map((d) => resolve(d)),
|
|
476
|
+
scan: { deep: v.deep === true },
|
|
477
|
+
format,
|
|
478
|
+
minSeverity,
|
|
479
|
+
out: v.out,
|
|
480
|
+
full: v.full === true,
|
|
481
|
+
quiet: v.quiet === true,
|
|
482
|
+
force: v.force === true,
|
|
483
|
+
keep: Math.max(2, intOption(v.keep, 'keep', 10)),
|
|
484
|
+
};
|
|
485
|
+
switch (command) {
|
|
486
|
+
case 'scan':
|
|
487
|
+
return await cmdScan(ctx, args);
|
|
488
|
+
case 'snapshot':
|
|
489
|
+
return await cmdSnapshot(ctx, args);
|
|
490
|
+
case 'diff':
|
|
491
|
+
return await cmdDiff(ctx, args);
|
|
492
|
+
case 'check':
|
|
493
|
+
return await cmdCheck(ctx, args, v.notify === true, intOption(v.settle, 'settle', 0));
|
|
494
|
+
case 'list':
|
|
495
|
+
return await cmdList(ctx, args);
|
|
496
|
+
case 'agent':
|
|
497
|
+
return await cmdAgent(ctx, args, { interval: Math.max(600, intOption(v.interval, 'interval', 21600)), dryRun: v['dry-run'] === true, load: !v['no-load'] });
|
|
498
|
+
default:
|
|
499
|
+
throw new UsageError(`Unknown command "${command}".`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
catch (err) {
|
|
503
|
+
if (err instanceof UsageError) {
|
|
504
|
+
io.err(`${err.message}\n`);
|
|
505
|
+
return 2;
|
|
506
|
+
}
|
|
507
|
+
io.err(`updiff: ${err.stack ?? err}\n`);
|
|
508
|
+
return 1;
|
|
509
|
+
}
|
|
510
|
+
}
|