@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.
@@ -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/cli.js ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "./commands.js";
3
+ const code = await main(process.argv.slice(2), {
4
+ out: (s) => process.stdout.write(s),
5
+ err: (s) => process.stderr.write(s),
6
+ color: process.stdout.isTTY === true && !process.env.NO_COLOR,
7
+ });
8
+ process.exitCode = code;
@@ -0,0 +1,103 @@
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
+ }
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
+ }