@mmerterden/multi-agent-toolkit-mcp 3.7.1 → 3.11.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/CHANGELOG.md +275 -0
- package/README.md +86 -2
- package/README.tr.md +13 -3
- package/index.js +345 -87
- package/package.json +3 -3
- package/tools/code-intel/index.js +665 -0
- package/tools/code-intel/kotlin.js +159 -0
- package/tools/code-intel/lsp-client.js +422 -0
- package/tools/code-intel/pool.js +273 -0
- package/tools/code-intel/positions.js +195 -0
- package/tools/code-intel/swift.js +249 -0
- package/tools/design-check/index.js +3 -2
- package/tools/design-check/report.js +15 -5
- package/tools/ios-app-store-audit/index.js +16 -2
- package/tools/ios-testflight/index.js +54 -11
- package/tools/memory/index.js +18 -3
- package/tools/offload/index.js +39 -13
- package/tools/pass-kit/index.js +432 -0
- package/tools/pass-kit/sign.js +255 -0
- package/tools/pass-kit/spec.js +317 -0
- package/tools/pass-kit/validate.js +329 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* swift.js - sourcekit-lsp, and an honest account of what it can answer.
|
|
3
|
+
*
|
|
4
|
+
* CAPABILITY IS NOT CAPABILITY. `initialize` returns `referencesProvider: true`
|
|
5
|
+
* whether or not references will ever be non-empty. Measured on this machine:
|
|
6
|
+
* against a package root, `textDocument/references` answered `[]` at 694ms and
|
|
7
|
+
* `[3]` at 5.8s, with nothing changed in between except that the background
|
|
8
|
+
* index had finished. Against a real 658-file package with dependencies it was
|
|
9
|
+
* still `[]` at 70s, having spent that time resolving and fetching. So the
|
|
10
|
+
* useful question is never "does the server say it supports references" but
|
|
11
|
+
* "has the index for this root settled", and that is what `indexReport` answers
|
|
12
|
+
* and what the reference tools wait on.
|
|
13
|
+
*
|
|
14
|
+
* WORKSPACE ROOT decides everything else. sourcekit-lsp takes its compiler
|
|
15
|
+
* arguments from a build system, and which one it finds determines whether the
|
|
16
|
+
* answers are semantic or a guess. The resolution order below is by strength of
|
|
17
|
+
* evidence, and whichever one matched is returned in every result, because a
|
|
18
|
+
* caller reading "0 references" deserves to know it came from a fallback.
|
|
19
|
+
*
|
|
20
|
+
* @module tools/code-intel/swift
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
24
|
+
import { dirname, join, basename } from "node:path";
|
|
25
|
+
import { homedir } from "node:os";
|
|
26
|
+
import { execFileSync } from "node:child_process";
|
|
27
|
+
|
|
28
|
+
/** Directories a root walk must not climb into or count. */
|
|
29
|
+
const SKIP_DIRS = new Set([".git", ".build", "build", "DerivedData", "Pods", "node_modules"]);
|
|
30
|
+
|
|
31
|
+
let cachedBin;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Where sourcekit-lsp is.
|
|
35
|
+
*
|
|
36
|
+
* PATH before `xcrun` on purpose: it is what lets a test put a fake server on
|
|
37
|
+
* PATH and have it used, and on a real machine `/usr/bin/sourcekit-lsp` is on
|
|
38
|
+
* PATH anyway. `SOURCEKIT_LSP_PATH` wins over both.
|
|
39
|
+
*/
|
|
40
|
+
export function resolveSwiftBin() {
|
|
41
|
+
if (cachedBin !== undefined) return cachedBin;
|
|
42
|
+
const explicit = process.env.SOURCEKIT_LSP_PATH;
|
|
43
|
+
if (explicit && existsSync(explicit)) return (cachedBin = explicit);
|
|
44
|
+
const onPath = which("sourcekit-lsp");
|
|
45
|
+
if (onPath) return (cachedBin = onPath);
|
|
46
|
+
try {
|
|
47
|
+
const found = execFileSync("xcrun", ["--find", "sourcekit-lsp"], {
|
|
48
|
+
encoding: "utf8",
|
|
49
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
50
|
+
timeout: 10000,
|
|
51
|
+
}).trim();
|
|
52
|
+
if (found && existsSync(found)) return (cachedBin = found);
|
|
53
|
+
} catch {
|
|
54
|
+
/* no Xcode */
|
|
55
|
+
}
|
|
56
|
+
return (cachedBin = null);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function which(cmd) {
|
|
60
|
+
try {
|
|
61
|
+
const p = execFileSync("/usr/bin/which", [cmd], {
|
|
62
|
+
encoding: "utf8",
|
|
63
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
64
|
+
timeout: 5000,
|
|
65
|
+
}).trim();
|
|
66
|
+
return p && existsSync(p) ? p : null;
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** For tests, which change PATH between cases. */
|
|
73
|
+
export function resetSwiftBinCache() {
|
|
74
|
+
cachedBin = undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The strongest build-settings source at or above `file`.
|
|
79
|
+
*
|
|
80
|
+
* @returns {{root: string, source: string}}
|
|
81
|
+
*/
|
|
82
|
+
export function resolveWorkspaceRoot(file, explicitRoot) {
|
|
83
|
+
if (explicitRoot) return { root: explicitRoot, source: classify(explicitRoot) };
|
|
84
|
+
let dir = statSafe(file)?.isDirectory() ? file : dirname(file);
|
|
85
|
+
const seen = [];
|
|
86
|
+
for (let i = 0; i < 40 && dir && dir !== "/"; i++) {
|
|
87
|
+
seen.push(dir);
|
|
88
|
+
const s = classify(dir);
|
|
89
|
+
if (s !== "none") return { root: dir, source: s };
|
|
90
|
+
dir = dirname(dir);
|
|
91
|
+
}
|
|
92
|
+
const git = gitRoot(file);
|
|
93
|
+
if (git) return { root: git, source: "fallback" };
|
|
94
|
+
return { root: seen[0] || dirname(file), source: "fallback" };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function classify(dir) {
|
|
98
|
+
if (existsSync(join(dir, "buildServer.json"))) return "buildServer";
|
|
99
|
+
if (existsSync(join(dir, "Package.swift"))) return "swiftpm";
|
|
100
|
+
if (existsSync(join(dir, "compile_commands.json"))) return "compilationDatabase";
|
|
101
|
+
if (existsSync(join(dir, "compile_flags.txt"))) return "compilationDatabase";
|
|
102
|
+
let entries;
|
|
103
|
+
try {
|
|
104
|
+
entries = readdirSync(dir);
|
|
105
|
+
} catch {
|
|
106
|
+
return "none";
|
|
107
|
+
}
|
|
108
|
+
if (entries.some((e) => e.endsWith(".xcworkspace") || e.endsWith(".xcodeproj"))) return "xcode";
|
|
109
|
+
return "none";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function statSafe(p) {
|
|
113
|
+
try {
|
|
114
|
+
return statSync(p);
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function gitRoot(file) {
|
|
121
|
+
try {
|
|
122
|
+
return execFileSync("git", ["-C", dirname(file), "rev-parse", "--show-toplevel"], {
|
|
123
|
+
encoding: "utf8",
|
|
124
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
125
|
+
timeout: 5000,
|
|
126
|
+
}).trim();
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* What an answer from this root is worth, before any question is asked.
|
|
134
|
+
*
|
|
135
|
+
* `semantic` is the headline: true when cross-file answers can be trusted,
|
|
136
|
+
* false when the server will still reply but from fallback arguments. Nothing
|
|
137
|
+
* here starts a server - it is the tool a caller runs BECAUSE something is
|
|
138
|
+
* missing, so it must never be the thing that fails.
|
|
139
|
+
*/
|
|
140
|
+
export function indexReport(root, source) {
|
|
141
|
+
const spm = [
|
|
142
|
+
join(root, ".build", "index-build"),
|
|
143
|
+
join(root, ".build", "index-store"),
|
|
144
|
+
].filter(existsSync);
|
|
145
|
+
const derived = derivedDataStore(root);
|
|
146
|
+
const built = spm.length > 0 || Boolean(derived);
|
|
147
|
+
|
|
148
|
+
const semantic = source === "swiftpm" || source === "buildServer" || source === "compilationDatabase";
|
|
149
|
+
const report = {
|
|
150
|
+
root,
|
|
151
|
+
buildSettingsSource: source,
|
|
152
|
+
semantic,
|
|
153
|
+
indexStore: derived || spm[0] || null,
|
|
154
|
+
indexBuilt: built,
|
|
155
|
+
remedy: [],
|
|
156
|
+
};
|
|
157
|
+
if (derived) {
|
|
158
|
+
const units = countUnits(join(derived, "v5", "units"));
|
|
159
|
+
if (units) {
|
|
160
|
+
report.unitCount = units.count;
|
|
161
|
+
report.newestUnit = new Date(units.newest).toISOString();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (source === "xcode") {
|
|
165
|
+
report.remedy.push(
|
|
166
|
+
"This root is an Xcode project with no buildServer.json, so sourcekit-lsp falls back to default compiler arguments: cross-module answers and diagnostics will be wrong rather than missing. `brew install xcode-build-server && xcode-build-server config -project <X>.xcodeproj -scheme <S>` fixes it. Nothing here writes that file for you.",
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
if (source === "fallback") {
|
|
170
|
+
report.remedy.push(
|
|
171
|
+
"No build system was found at or above this file. Point --workspace_root at the package or project root.",
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
if (semantic && !built) {
|
|
175
|
+
report.remedy.push(
|
|
176
|
+
"No index on disk yet. The first cross-file question builds one in the background; on a package with dependencies that is minutes, not seconds.",
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
return report;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Xcode writes its index under DerivedData, in `<name>-<hash>`.
|
|
184
|
+
*
|
|
185
|
+
* `<name>` is the PROJECT's name, not the directory's, and the two differ often
|
|
186
|
+
* enough that assuming they match is the failure mode this whole file exists to
|
|
187
|
+
* avoid. Measured on a checkout whose directory name and `.xcodeproj` name had
|
|
188
|
+
* nothing in common: reported as having no index at all, while an 18,205-unit
|
|
189
|
+
* store sat under the project's name, and the remedy printed underneath told
|
|
190
|
+
* the caller to build one. So the candidate names come from the project and
|
|
191
|
+
* workspace files in the root, with the directory name last.
|
|
192
|
+
*
|
|
193
|
+
* Several folders can share a prefix - the same project checked out twice. The
|
|
194
|
+
* newest store wins, because the question being asked is about freshness.
|
|
195
|
+
*/
|
|
196
|
+
function derivedDataStore(root) {
|
|
197
|
+
const dd = join(homedir(), "Library", "Developer", "Xcode", "DerivedData");
|
|
198
|
+
let entries;
|
|
199
|
+
try {
|
|
200
|
+
entries = readdirSync(dd);
|
|
201
|
+
} catch {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
const names = new Set([basename(root)]);
|
|
205
|
+
try {
|
|
206
|
+
for (const e of readdirSync(root)) {
|
|
207
|
+
const m = /^(.+)\.(xcodeproj|xcworkspace)$/.exec(e);
|
|
208
|
+
if (m) names.add(m[1]);
|
|
209
|
+
}
|
|
210
|
+
} catch {
|
|
211
|
+
/* unreadable root: the directory name is still a candidate */
|
|
212
|
+
}
|
|
213
|
+
let best = null;
|
|
214
|
+
for (const c of entries) {
|
|
215
|
+
const dash = c.lastIndexOf("-");
|
|
216
|
+
if (dash <= 0 || !names.has(c.slice(0, dash))) continue;
|
|
217
|
+
const store = join(dd, c, "Index.noindex", "DataStore");
|
|
218
|
+
if (!existsSync(store)) continue;
|
|
219
|
+
const s = statSafe(join(store, "v5", "units")) || statSafe(store);
|
|
220
|
+
const at = s ? s.mtimeMs : 0;
|
|
221
|
+
if (!best || at > best.at) best = { store, at };
|
|
222
|
+
}
|
|
223
|
+
return best ? best.store : null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* How many index units there are, and when one was last written.
|
|
228
|
+
*
|
|
229
|
+
* Freshness is taken from the DIRECTORY's mtime, not from a sample of the files
|
|
230
|
+
* in it. A store here holds 34,380 units: stat-ing all of them costs 1.8s, and
|
|
231
|
+
* stat-ing the first 200 of a readdir costs nothing but answers about an
|
|
232
|
+
* arbitrary 200 - directory order is a hash, not a timeline, so the number it
|
|
233
|
+
* produces is the newest of whichever units happened to come first. It was
|
|
234
|
+
* correct on the measured store by luck and nothing made that visible. A unit
|
|
235
|
+
* is written as a new file, which bumps the directory, so one syscall answers
|
|
236
|
+
* exactly: measured at 2ms from the true maximum over all 34,380.
|
|
237
|
+
*/
|
|
238
|
+
function countUnits(dir) {
|
|
239
|
+
let entries;
|
|
240
|
+
try {
|
|
241
|
+
entries = readdirSync(dir);
|
|
242
|
+
} catch {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
const s = statSafe(dir);
|
|
246
|
+
return { count: entries.length, newest: s ? s.mtimeMs : Date.now() };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export { SKIP_DIRS };
|
|
@@ -128,7 +128,7 @@ function safeArg(x, label) {
|
|
|
128
128
|
return x;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
// ctx: { run, iosDevice, adbFlag, dumperScript }
|
|
131
|
+
// ctx: { run, iosDevice, adbFlag, dumperScript, dumperCommand }
|
|
132
132
|
export async function handleDesign(name, args, ctx) {
|
|
133
133
|
switch (name) {
|
|
134
134
|
case "design_mock_detect":
|
|
@@ -172,7 +172,8 @@ export async function handleDesign(name, args, ctx) {
|
|
|
172
172
|
}
|
|
173
173
|
}
|
|
174
174
|
const depth = Number(args.max_depth) || 12;
|
|
175
|
-
const
|
|
175
|
+
const dumper = ctx.dumperCommand ? ctx.dumperCommand() : `swift ${shq(ctx.dumperScript)}`;
|
|
176
|
+
const raw = ctx.run(`${dumper} ${depth}`, { timeout: 15000 });
|
|
176
177
|
let tree; try { tree = JSON.parse(raw); } catch { return `ERROR: idb unavailable and AX dumper failed: ${String(raw).slice(0, 200)}`; }
|
|
177
178
|
const elements = flattenIosAxTree(tree);
|
|
178
179
|
const rf = tree.frame || {};
|
|
@@ -541,6 +541,19 @@ ${variants.map((v, i) => variantSection(v, i, fileKey, L)).join("")}
|
|
|
541
541
|
</body></html>`;
|
|
542
542
|
}
|
|
543
543
|
|
|
544
|
+
// The browser is closed on every path: a failed setContent/pdf used to leave a
|
|
545
|
+
// headless Chromium running for the life of the server.
|
|
546
|
+
export async function renderPdf({ html, pdfPath, chromium }) {
|
|
547
|
+
const browser = await chromium.launch();
|
|
548
|
+
try {
|
|
549
|
+
const page = await browser.newPage();
|
|
550
|
+
await page.setContent(html, { waitUntil: "networkidle" });
|
|
551
|
+
await page.pdf({ path: pdfPath, format: "A4", printBackground: true, margin: { top: "12mm", bottom: "12mm", left: "10mm", right: "10mm" } });
|
|
552
|
+
} finally {
|
|
553
|
+
await browser.close();
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
544
557
|
export async function writeReport({ report, outDir, formats = ["html"] }) {
|
|
545
558
|
const html = renderHtml(report);
|
|
546
559
|
const out = {};
|
|
@@ -571,11 +584,8 @@ export async function writeReport({ report, outDir, formats = ["html"] }) {
|
|
|
571
584
|
let ok = false, err = "";
|
|
572
585
|
try {
|
|
573
586
|
const { chromium } = await import("playwright");
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
await page.setContent(html, { waitUntil: "networkidle" });
|
|
577
|
-
await page.pdf({ path: pdfPath, format: "A4", printBackground: true, margin: { top: "12mm", bottom: "12mm", left: "10mm", right: "10mm" } });
|
|
578
|
-
await browser.close(); ok = true;
|
|
587
|
+
await renderPdf({ html, pdfPath, chromium });
|
|
588
|
+
ok = true;
|
|
579
589
|
} catch (e) {
|
|
580
590
|
err = e.message;
|
|
581
591
|
// Fallback: use the chrome-headless-shell binary directly (no `playwright` package).
|
|
@@ -67,8 +67,18 @@ const RULE_GROUPS = {
|
|
|
67
67
|
function resolveRuleSelection(rules) {
|
|
68
68
|
if (!rules || rules === "all" || rules === "deep") return RULE_GROUPS.all();
|
|
69
69
|
if (rules === "core") return RULE_GROUPS.core();
|
|
70
|
-
// Comma-separated explicit list.
|
|
71
|
-
|
|
70
|
+
// Comma-separated explicit list. An unknown id used to select nothing, and
|
|
71
|
+
// nothing selected audited nothing and reported PASS.
|
|
72
|
+
const known = RULE_REGISTRY.map((r) => r.id);
|
|
73
|
+
const asked = String(rules).split(",").map((s) => s.trim()).filter(Boolean);
|
|
74
|
+
const unknown = asked.filter((id) => !known.includes(id));
|
|
75
|
+
if (unknown.length > 0) {
|
|
76
|
+
throw new Error(`unknown rule id(s): ${unknown.join(", ")}. Valid values: all, core, deep, or a comma-separated subset of ${known.join(", ")}`);
|
|
77
|
+
}
|
|
78
|
+
if (asked.length === 0) {
|
|
79
|
+
throw new Error(`no rules selected from "${rules}". Valid values: all, core, deep, or a comma-separated subset of ${known.join(", ")}`);
|
|
80
|
+
}
|
|
81
|
+
return asked;
|
|
72
82
|
}
|
|
73
83
|
|
|
74
84
|
// ---------- Public entry ---------------------------------------------------
|
|
@@ -87,6 +97,7 @@ export async function runAudit({ archivePath, rules = "all", options = {} } = {}
|
|
|
87
97
|
|
|
88
98
|
const selectedIDs = new Set(resolveRuleSelection(rules));
|
|
89
99
|
const toRun = RULE_REGISTRY.filter((r) => selectedIDs.has(r.id));
|
|
100
|
+
if (toRun.length === 0) throw new Error("no rules selected; an audit that runs nothing cannot pass");
|
|
90
101
|
|
|
91
102
|
const violations = [];
|
|
92
103
|
const ranIDs = [];
|
|
@@ -135,6 +146,9 @@ export async function runAudit({ archivePath, rules = "all", options = {} } = {}
|
|
|
135
146
|
info: violations.filter((v) => v.severity === "info").length,
|
|
136
147
|
total: violations.length,
|
|
137
148
|
};
|
|
149
|
+
if (ranIDs.length === 0) {
|
|
150
|
+
throw new Error(`no rule ran: ${skippedIDs.map((s) => `${s.id} (${s.reason})`).join("; ")}`);
|
|
151
|
+
}
|
|
138
152
|
const verdict = summary.error > 0 ? "FAIL" : summary.warning > 0 ? "WARN" : "PASS";
|
|
139
153
|
|
|
140
154
|
return {
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import { execFile } from "child_process";
|
|
27
|
-
import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "fs";
|
|
27
|
+
import { existsSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from "fs";
|
|
28
28
|
import { join } from "path";
|
|
29
29
|
import { tmpdir } from "os";
|
|
30
30
|
|
|
@@ -227,6 +227,15 @@ export function resolveAuth(opts = {}) {
|
|
|
227
227
|
* @param {string} [opts.signingStyle] "automatic" | "manual"
|
|
228
228
|
* @returns {string} plist XML
|
|
229
229
|
*/
|
|
230
|
+
export function xmlEscape(value) {
|
|
231
|
+
return String(value ?? "")
|
|
232
|
+
.replace(/&/g, "&")
|
|
233
|
+
.replace(/</g, "<")
|
|
234
|
+
.replace(/>/g, ">")
|
|
235
|
+
.replace(/"/g, """)
|
|
236
|
+
.replace(/'/g, "'");
|
|
237
|
+
}
|
|
238
|
+
|
|
230
239
|
export function buildExportOptionsPlist(opts) {
|
|
231
240
|
const {
|
|
232
241
|
method = "app-store-connect",
|
|
@@ -236,17 +245,17 @@ export function buildExportOptionsPlist(opts) {
|
|
|
236
245
|
signingStyle,
|
|
237
246
|
} = opts;
|
|
238
247
|
|
|
239
|
-
const entries = [` <key>method</key>\n <string>${method}</string>`];
|
|
248
|
+
const entries = [` <key>method</key>\n <string>${xmlEscape(method)}</string>`];
|
|
240
249
|
entries.push(
|
|
241
250
|
` <key>uploadSymbols</key>\n <${uploadSymbols ? "true" : "false"}/>`,
|
|
242
251
|
);
|
|
243
|
-
if (teamId) entries.push(` <key>teamID</key>\n <string>${teamId}</string>`);
|
|
252
|
+
if (teamId) entries.push(` <key>teamID</key>\n <string>${xmlEscape(teamId)}</string>`);
|
|
244
253
|
if (signingStyle) {
|
|
245
|
-
entries.push(` <key>signingStyle</key>\n <string>${signingStyle}</string>`);
|
|
254
|
+
entries.push(` <key>signingStyle</key>\n <string>${xmlEscape(signingStyle)}</string>`);
|
|
246
255
|
}
|
|
247
256
|
if (provisioningProfiles && Object.keys(provisioningProfiles).length > 0) {
|
|
248
257
|
const rows = Object.entries(provisioningProfiles)
|
|
249
|
-
.map(([bundleId, profile]) => ` <key>${bundleId}</key>\n <string>${profile}</string>`)
|
|
258
|
+
.map(([bundleId, profile]) => ` <key>${xmlEscape(bundleId)}</key>\n <string>${xmlEscape(profile)}</string>`)
|
|
250
259
|
.join("\n");
|
|
251
260
|
entries.push(` <key>provisioningProfiles</key>\n <dict>\n${rows}\n </dict>`);
|
|
252
261
|
}
|
|
@@ -263,6 +272,36 @@ export function buildExportOptionsPlist(opts) {
|
|
|
263
272
|
].join("\n");
|
|
264
273
|
}
|
|
265
274
|
|
|
275
|
+
/**
|
|
276
|
+
* The .ipa this export wrote: the newest one in output_dir, and only if it was
|
|
277
|
+
* written after the export started. An older .ipa left by a previous run was
|
|
278
|
+
* reported as this run's result.
|
|
279
|
+
*
|
|
280
|
+
* @param {string} outputDir
|
|
281
|
+
* @param {number} notBefore - epoch ms; files modified earlier are stale
|
|
282
|
+
* @returns {{ipaPath: string|null, stale: string[]}}
|
|
283
|
+
*/
|
|
284
|
+
export function pickExportedIpa(outputDir, notBefore) {
|
|
285
|
+
if (!outputDir || !existsSync(outputDir)) return { ipaPath: null, stale: [] };
|
|
286
|
+
const candidates = readdirSync(outputDir)
|
|
287
|
+
.filter((f) => f.endsWith(".ipa"))
|
|
288
|
+
.map((f) => {
|
|
289
|
+
const full = join(outputDir, f);
|
|
290
|
+
try {
|
|
291
|
+
return { full, mtime: statSync(full).mtimeMs };
|
|
292
|
+
} catch {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
.filter(Boolean)
|
|
297
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
298
|
+
const fresh = candidates.find((c) => c.mtime >= notBefore);
|
|
299
|
+
return {
|
|
300
|
+
ipaPath: fresh ? fresh.full : null,
|
|
301
|
+
stale: candidates.filter((c) => c.mtime < notBefore).map((c) => c.full),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
266
305
|
/**
|
|
267
306
|
* Export a .xcarchive to a signed .ipa.
|
|
268
307
|
*
|
|
@@ -305,6 +344,8 @@ export async function exportIpa(opts) {
|
|
|
305
344
|
// want that.
|
|
306
345
|
if (allowProvisioningUpdates) argv.push("-allowProvisioningUpdates");
|
|
307
346
|
|
|
347
|
+
// 2s of slack for filesystems with coarse mtime granularity.
|
|
348
|
+
const startedAt = Date.now() - 2000;
|
|
308
349
|
const { err, stdout, stderr } = await execFileAsync("xcodebuild", argv, {
|
|
309
350
|
encoding: "utf-8",
|
|
310
351
|
timeout: timeoutSec * 1000,
|
|
@@ -317,13 +358,15 @@ export async function exportIpa(opts) {
|
|
|
317
358
|
: stdout;
|
|
318
359
|
|
|
319
360
|
const errors = (log.match(/^.*error:.*$/gim) || []).map((l) => l.trim());
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
const ipa = readdirSync(outputDir).find((f) => f.endsWith(".ipa"));
|
|
323
|
-
if (ipa) ipaPath = join(outputDir, ipa);
|
|
324
|
-
}
|
|
361
|
+
const picked = pickExportedIpa(outputDir, startedAt);
|
|
362
|
+
const ipaPath = picked.ipaPath || undefined;
|
|
325
363
|
// xcodebuild can exit 0 and still produce nothing useful.
|
|
326
|
-
if (!ipaPath)
|
|
364
|
+
if (!ipaPath) {
|
|
365
|
+
ok = false;
|
|
366
|
+
if (picked.stale.length > 0) {
|
|
367
|
+
errors.push(`error: no .ipa was written by this export; ${picked.stale.join(", ")} in output_dir predates this run`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
327
370
|
|
|
328
371
|
// The plist is consumed by the time xcodebuild returns. Leaving the temp dir
|
|
329
372
|
// behind would accumulate one per export for the life of the machine; the path
|
package/tools/memory/index.js
CHANGED
|
@@ -132,17 +132,32 @@ export function parseMeminfoOutput(raw) {
|
|
|
132
132
|
return { measurable: true, reason: null, pss, totalPssKb, totalRssKb, totalSwapKb };
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Accept both snapshot shapes: the parser's (pss, totalPssKb) and the one the
|
|
137
|
+
* android_meminfo tool emits and hands back as baseline_json (pss_kb,
|
|
138
|
+
* total_pss_kb). The diff read only the parser shape while the tool emitted
|
|
139
|
+
* the other, so mode=diff never compared anything.
|
|
140
|
+
*/
|
|
141
|
+
function normalizeSnapshot(s) {
|
|
142
|
+
if (!s || typeof s !== "object") return null;
|
|
143
|
+
const pss = s.pss && typeof s.pss === "object" ? s.pss : s.pss_kb && typeof s.pss_kb === "object" ? s.pss_kb : {};
|
|
144
|
+
const totalPssKb = typeof s.totalPssKb === "number" ? s.totalPssKb : typeof s.total_pss_kb === "number" ? s.total_pss_kb : null;
|
|
145
|
+
return { measurable: s.measurable === true, pss, totalPssKb };
|
|
146
|
+
}
|
|
147
|
+
|
|
135
148
|
/**
|
|
136
149
|
* Difference between two meminfo snapshots, in KB.
|
|
137
150
|
*
|
|
138
151
|
* Only keys present in both are compared; a key missing from either side is
|
|
139
152
|
* absent from the result rather than counted as zero growth.
|
|
140
153
|
*
|
|
141
|
-
* @param {object} before - parseMeminfoOutput result
|
|
142
|
-
* @param {object} after - parseMeminfoOutput result
|
|
154
|
+
* @param {object} before - parseMeminfoOutput result, or the android_meminfo snapshot payload
|
|
155
|
+
* @param {object} after - parseMeminfoOutput result, or the android_meminfo snapshot payload
|
|
143
156
|
* @returns {{comparable: boolean, reason: string|null, deltaKb: object, totalPssDeltaKb: number|null}}
|
|
144
157
|
*/
|
|
145
|
-
export function diffMeminfo(
|
|
158
|
+
export function diffMeminfo(beforeRaw, afterRaw) {
|
|
159
|
+
const before = normalizeSnapshot(beforeRaw);
|
|
160
|
+
const after = normalizeSnapshot(afterRaw);
|
|
146
161
|
if (!before?.measurable || !after?.measurable) {
|
|
147
162
|
return { comparable: false, reason: "one of the snapshots was not measurable", deltaKb: {}, totalPssDeltaKb: null };
|
|
148
163
|
}
|
package/tools/offload/index.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// outputSchema answers with JSON the host parses as structuredContent, and
|
|
16
16
|
// replacing that with a summary would break the parse.
|
|
17
17
|
|
|
18
|
-
import { writeFileSync, readFileSync, mkdirSync, existsSync, readdirSync, statSync,
|
|
18
|
+
import { writeFileSync, readFileSync, mkdirSync, existsSync, readdirSync, statSync, rmSync } from "fs";
|
|
19
19
|
import { join, resolve, sep } from "path";
|
|
20
20
|
import { homedir } from "os";
|
|
21
21
|
|
|
@@ -31,20 +31,24 @@ const TAIL_LINES = 20;
|
|
|
31
31
|
export const OFFLOAD_KEEP_FILES = 50;
|
|
32
32
|
export const OFFLOAD_KEEP_DAYS = 7;
|
|
33
33
|
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
export
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
34
|
+
// The server's own scratch directory (screenshots, UI dumps, push payloads,
|
|
35
|
+
// build logs, .xcresult bundles) gets the same treatment with a wider count,
|
|
36
|
+
// since a single design audit writes 100+ captures.
|
|
37
|
+
export const WORK_KEEP_ENTRIES = 200;
|
|
38
|
+
export const WORK_KEEP_DAYS = 7;
|
|
39
|
+
|
|
40
|
+
// `keep` names the file(s) the caller was just promised. They are never deleted,
|
|
41
|
+
// whatever the retention numbers say: the returned text promises that path to
|
|
42
|
+
// the caller, and this module exists precisely because losing the payload is the
|
|
43
|
+
// failure mode. A keepFiles of 0 must bound the directory, not break the answer.
|
|
44
|
+
function pruneEntries(dir, { keepFiles, keepDays, now, keep, match }) {
|
|
45
|
+
const keepList = Array.isArray(keep) ? keep : keep ? [keep] : [];
|
|
46
|
+
const keepSet = new Set(keepList.filter((k) => typeof k === "string").map((k) => resolve(k)));
|
|
43
47
|
const cutoff = now - keepDays * 24 * 60 * 60 * 1000;
|
|
44
48
|
let removed = 0;
|
|
45
49
|
try {
|
|
46
50
|
const entries = readdirSync(dir)
|
|
47
|
-
.filter(
|
|
51
|
+
.filter(match)
|
|
48
52
|
.map((n) => {
|
|
49
53
|
const full = join(dir, n);
|
|
50
54
|
try {
|
|
@@ -57,12 +61,12 @@ export function pruneOffloadDir(dir, opts = {}) {
|
|
|
57
61
|
.sort((a, b) => b.mtime - a.mtime);
|
|
58
62
|
|
|
59
63
|
for (let i = 0; i < entries.length; i++) {
|
|
60
|
-
if (
|
|
64
|
+
if (keepSet.has(resolve(entries[i].full))) continue;
|
|
61
65
|
const tooOld = entries[i].mtime < cutoff;
|
|
62
66
|
const tooMany = i >= keepFiles;
|
|
63
67
|
if (!tooOld && !tooMany) continue;
|
|
64
68
|
try {
|
|
65
|
-
|
|
69
|
+
rmSync(entries[i].full, { recursive: true, force: true });
|
|
66
70
|
removed++;
|
|
67
71
|
} catch {
|
|
68
72
|
// A file another process holds open is skipped, not fatal.
|
|
@@ -74,6 +78,28 @@ export function pruneOffloadDir(dir, opts = {}) {
|
|
|
74
78
|
return removed;
|
|
75
79
|
}
|
|
76
80
|
|
|
81
|
+
export function pruneOffloadDir(dir, opts = {}) {
|
|
82
|
+
return pruneEntries(dir, {
|
|
83
|
+
keepFiles: opts.keepFiles ?? OFFLOAD_KEEP_FILES,
|
|
84
|
+
keepDays: opts.keepDays ?? OFFLOAD_KEEP_DAYS,
|
|
85
|
+
now: opts.now ?? Date.now(),
|
|
86
|
+
keep: opts.keep,
|
|
87
|
+
match: (n) => n.endsWith(".txt"),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Every entry counts here, directories included: an .xcresult bundle is a
|
|
92
|
+
// directory and was the largest thing nothing ever removed.
|
|
93
|
+
export function pruneWorkDir(dir, opts = {}) {
|
|
94
|
+
return pruneEntries(dir, {
|
|
95
|
+
keepFiles: opts.keepFiles ?? WORK_KEEP_ENTRIES,
|
|
96
|
+
keepDays: opts.keepDays ?? WORK_KEEP_DAYS,
|
|
97
|
+
now: opts.now ?? Date.now(),
|
|
98
|
+
keep: opts.keep,
|
|
99
|
+
match: () => true,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
77
103
|
let lastOffload = null;
|
|
78
104
|
|
|
79
105
|
export function lastOffloadRecord() {
|