taphound 0.2.0-dev.11 → 0.2.0-dev.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/assets/skills/taphound-case-suite/SKILL.md +8 -6
- package/assets/skills/taphound-case-suite/scripts/ledger.mjs +84 -19
- package/assets/skills/taphound-journey-brief-author/SKILL.md +8 -3
- package/assets/skills/taphound-journey-brief-author/prompts/brief-author-role.md +3 -1
- package/assets/skills/taphound-journey-brief-author/prompts/brief-author-role.zh-CN.md +1 -1
- package/assets/skills/taphound-journey-generator/SKILL.md +21 -5
- package/assets/skills/taphound-journey-generator/prompts/consume-journey-brief.md +3 -1
- package/assets/skills/taphound-journey-generator/prompts/generate-step.md +9 -0
- package/assets/skills/taphound-journey-generator/scripts/envelope.mjs +80 -10
- package/assets/skills/taphound-verify-change/references/preserve.md +15 -7
- package/assets/skills/taphound-verify-change/scripts/ui-refactor.mjs +1 -1
- package/dist/adapters/appium/appium-ui-snapshot-provider.d.ts +8 -0
- package/dist/adapters/appium/appium-ui-snapshot-provider.js +123 -54
- package/dist/adapters/filesystem/diagnostics-journal.d.ts +17 -0
- package/dist/adapters/filesystem/diagnostics-journal.js +130 -0
- package/dist/application/diagnostics/diagnostics-exporter.d.ts +29 -0
- package/dist/application/diagnostics/diagnostics-exporter.js +283 -0
- package/dist/application/diagnostics/ui-capture-telemetry.d.ts +16 -0
- package/dist/application/diagnostics/ui-capture-telemetry.js +81 -0
- package/dist/application/generation/proposed-step-validator.js +5 -1
- package/dist/application/journey/journey-check-service.d.ts +7 -1
- package/dist/application/journey/journey-check-service.js +16 -1
- package/dist/application/report/report-writer.d.ts +5 -0
- package/dist/application/report/report-writer.js +20 -0
- package/dist/application/runtime/verify-runtime.js +32 -2
- package/dist/application/ui/observed-ui-snapshot-provider.d.ts +15 -0
- package/dist/application/ui/observed-ui-snapshot-provider.js +66 -0
- package/dist/application/ui/ui-stability-probe.d.ts +6 -0
- package/dist/application/ui/ui-stability-probe.js +21 -5
- package/dist/application/wait/idle-waiter.js +29 -6
- package/dist/cli/commands/diagnose.d.ts +3 -0
- package/dist/cli/commands/diagnose.js +84 -0
- package/dist/cli/commands/generation/session-commands.js +4 -1
- package/dist/cli/commands/journey.js +3 -1
- package/dist/cli/commands/verify.js +55 -3
- package/dist/cli/dependencies.d.ts +11 -0
- package/dist/cli/dependencies.js +58 -5
- package/dist/cli/diagnostics-recorder.d.ts +31 -0
- package/dist/cli/diagnostics-recorder.js +82 -0
- package/dist/cli/main.d.ts +1 -1
- package/dist/cli/main.js +58 -1
- package/dist/cli/program.js +3 -1
- package/dist/domain/contract.d.ts +6 -6
- package/dist/domain/diagnostics.d.ts +1396 -0
- package/dist/domain/diagnostics.js +193 -0
- package/dist/domain/failure.js +1 -1
- package/dist/domain/report.d.ts +11 -3
- package/dist/domain/report.js +1 -0
- package/dist/domain/verify-receipt.d.ts +17 -0
- package/dist/domain/verify-receipt.js +17 -0
- package/dist/domain/workspace.d.ts +11 -0
- package/dist/domain/workspace.js +14 -0
- package/dist/ports/diagnostics.d.ts +15 -0
- package/dist/ports/diagnostics.js +1 -0
- package/package.json +4 -3
- package/scripts/feedback-pack.mjs +399 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Redact and pack TapHound generation evidence for a feedback report.
|
|
3
|
+
// Standalone: needs only Node.js and `tar`, not a TapHound checkout.
|
|
4
|
+
//
|
|
5
|
+
// Usage (run from the Android project root, or pass --project):
|
|
6
|
+
// node feedback-pack.mjs [--project <root>] [--package <pkg>]
|
|
7
|
+
// [--generation <id>]... [--run <runId>]... [--out <file.tgz>]
|
|
8
|
+
//
|
|
9
|
+
// Unlike `taphound diagnose export` (allowlisted counters, no evidence), this
|
|
10
|
+
// keeps step timing, idle telemetry, and full layout snapshots, which is what
|
|
11
|
+
// timing and screen-state problems need. See docs/diagnostics.md.
|
|
12
|
+
//
|
|
13
|
+
// Collects every JSON file under .taphound/build/generations/<id>/ (including
|
|
14
|
+
// active .<id>.work bundles) and, optionally, .taphound/build/runs/<runId>/
|
|
15
|
+
// report.json. Screenshots, Logcat text, and other non-JSON files are never
|
|
16
|
+
// copied. Package, Activity/class names, resource ids, UI text, Logcat
|
|
17
|
+
// tags/patterns, device serials, and absolute paths are replaced by stable
|
|
18
|
+
// pseudonyms. The pseudonym mapping is written next to the archive, never
|
|
19
|
+
// inside it, so you can translate references back.
|
|
20
|
+
|
|
21
|
+
import { spawnSync } from "node:child_process";
|
|
22
|
+
import process from "node:process";
|
|
23
|
+
import {
|
|
24
|
+
existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync,
|
|
25
|
+
statSync, writeFileSync
|
|
26
|
+
} from "node:fs";
|
|
27
|
+
import { homedir, tmpdir } from "node:os";
|
|
28
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
29
|
+
|
|
30
|
+
const HELP = `Usage: node feedback-pack.mjs [options]
|
|
31
|
+
|
|
32
|
+
--project <root> Android project root (default: current directory)
|
|
33
|
+
--package <pkg> App package (default: run.packageName in .taphound/config.json)
|
|
34
|
+
--org-prefix <pfx> Extra class-name prefix to redact, e.g. com.acme (repeatable)
|
|
35
|
+
--generation <id> Only this generation id (repeatable; default: all)
|
|
36
|
+
--run <runId> Also include .taphound/build/runs/<runId>/report.json (repeatable)
|
|
37
|
+
--out <file> Archive path (default: .taphound/build/diagnostics/
|
|
38
|
+
taphound-feedback-<timestamp>.tgz)
|
|
39
|
+
--help Show this help
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
// Keys whose string values are free UI/business text.
|
|
43
|
+
const TEXT_KEYS = new Set([
|
|
44
|
+
"text", "contentDescription", "hint", "title", "label", "tooltipText",
|
|
45
|
+
"pattern", "tag", "event", "literal", "inputText"
|
|
46
|
+
]);
|
|
47
|
+
// Keys whose whole subtree holds business values.
|
|
48
|
+
const VALUE_TREE_KEYS = new Set(["fields", "values", "captures", "bindings"]);
|
|
49
|
+
const SERIAL_KEYS = new Set(["deviceSerial", "serial", "device"]);
|
|
50
|
+
// Keys holding diagnostic sentences that may quote UI text.
|
|
51
|
+
const FREE_TEXT_KEYS = new Set([
|
|
52
|
+
"message", "reason", "detail", "details", "error", "summary", "diagnostic",
|
|
53
|
+
"description", "remediation", "stderr", "stdout", "hint"
|
|
54
|
+
]);
|
|
55
|
+
// Packages kept verbatim: they describe the platform, not the app.
|
|
56
|
+
const PUBLIC_PREFIXES = [
|
|
57
|
+
"android.", "androidx.", "com.android.", "com.google.android.", "java.",
|
|
58
|
+
"kotlin.", "dalvik.", "io.appium.", "com.github.uiautomator"
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
const QUALIFIED = /^(?:[A-Za-z_$][\w$]*\.)+[A-Za-z_$][\w$]*$/;
|
|
62
|
+
const RESOURCE_ID = /^([A-Za-z_][\w.]*):(id|string|drawable|layout)\/(.+)$/;
|
|
63
|
+
const HEX_HASH = /^[a-f\d]{40,64}$/;
|
|
64
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}T[\d:.]+Z?$/;
|
|
65
|
+
|
|
66
|
+
function parseArgs(argv) {
|
|
67
|
+
const options = {
|
|
68
|
+
project: process.cwd(), package: undefined, orgPrefixes: [],
|
|
69
|
+
generations: [], runs: [], out: undefined, help: false
|
|
70
|
+
};
|
|
71
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
72
|
+
const flag = argv[index];
|
|
73
|
+
const value = () => {
|
|
74
|
+
const next = argv[index + 1];
|
|
75
|
+
if (next === undefined || next.startsWith("--")) {
|
|
76
|
+
fail(`${flag} requires a value`);
|
|
77
|
+
}
|
|
78
|
+
index += 1;
|
|
79
|
+
return next;
|
|
80
|
+
};
|
|
81
|
+
switch (flag) {
|
|
82
|
+
case "--project": options.project = value(); break;
|
|
83
|
+
case "--package": options.package = value(); break;
|
|
84
|
+
case "--org-prefix": options.orgPrefixes.push(value()); break;
|
|
85
|
+
case "--generation": options.generations.push(value()); break;
|
|
86
|
+
case "--run": options.runs.push(value()); break;
|
|
87
|
+
case "--out": options.out = value(); break;
|
|
88
|
+
case "--help": case "-h": options.help = true; break;
|
|
89
|
+
default: fail(`Unknown option ${flag}\n\n${HELP}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
options.project = resolve(options.project);
|
|
93
|
+
return options;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function fail(message) {
|
|
97
|
+
process.stderr.write(`error: ${message}\n`);
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function readPackage(projectRoot) {
|
|
102
|
+
const configPath = join(projectRoot, ".taphound", "config.json");
|
|
103
|
+
if (!existsSync(configPath)) return undefined;
|
|
104
|
+
try {
|
|
105
|
+
return JSON.parse(readFileSync(configPath, "utf8"))?.run?.packageName;
|
|
106
|
+
} catch {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function listJsonFiles(directory) {
|
|
112
|
+
const files = [];
|
|
113
|
+
const walk = (current) => {
|
|
114
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
115
|
+
const path = join(current, entry.name);
|
|
116
|
+
if (entry.isDirectory()) walk(path);
|
|
117
|
+
// Skip macOS AppleDouble "._*" companions of copied files.
|
|
118
|
+
else if (entry.isFile() && entry.name.endsWith(".json")
|
|
119
|
+
&& !entry.name.startsWith("._")) files.push(path);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
walk(directory);
|
|
123
|
+
return files.sort();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function collectSources(options) {
|
|
127
|
+
const buildRoot = join(options.project, ".taphound", "build");
|
|
128
|
+
const generationsRoot = join(buildRoot, "generations");
|
|
129
|
+
if (!existsSync(generationsRoot)) {
|
|
130
|
+
fail(`No generations directory at ${generationsRoot}`);
|
|
131
|
+
}
|
|
132
|
+
const wanted = new Set(options.generations);
|
|
133
|
+
const bundles = readdirSync(generationsRoot, { withFileTypes: true })
|
|
134
|
+
.filter((entry) => entry.isDirectory() && entry.name !== ".locks")
|
|
135
|
+
.filter((entry) => {
|
|
136
|
+
const id = entry.name.replace(/^\.(.+)\.work$/, "$1");
|
|
137
|
+
return wanted.size === 0 || wanted.has(id);
|
|
138
|
+
})
|
|
139
|
+
.map((entry) => join(generationsRoot, entry.name));
|
|
140
|
+
if (bundles.length === 0) fail("No matching generation bundles found");
|
|
141
|
+
|
|
142
|
+
const files = bundles.flatMap(listJsonFiles);
|
|
143
|
+
for (const runId of options.runs) {
|
|
144
|
+
const report = join(buildRoot, "runs", runId, "report.json");
|
|
145
|
+
if (!existsSync(report)) fail(`Run report not found: ${report}`);
|
|
146
|
+
files.push(report);
|
|
147
|
+
const verdict = join(buildRoot, "runs", runId, "verdict.json");
|
|
148
|
+
if (existsSync(verdict)) files.push(verdict);
|
|
149
|
+
}
|
|
150
|
+
return { files, bundles };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
class Redactor {
|
|
154
|
+
constructor({ packageName, orgPrefixes, projectRoot }) {
|
|
155
|
+
this.appPackage = packageName;
|
|
156
|
+
this.prefixes = [...new Set([
|
|
157
|
+
packageName,
|
|
158
|
+
packageName.split(".").slice(0, 2).join("."),
|
|
159
|
+
...orgPrefixes
|
|
160
|
+
])].filter((prefix) => prefix.includes("."));
|
|
161
|
+
this.paths = [[projectRoot, "<project>"], [homedir(), "~"]]
|
|
162
|
+
.filter(([path]) => path.length > 1);
|
|
163
|
+
this.maps = {
|
|
164
|
+
package: new Map([[packageName, "com.example.app"]]),
|
|
165
|
+
class: new Map(),
|
|
166
|
+
resource: new Map(),
|
|
167
|
+
text: new Map(),
|
|
168
|
+
resourceName: new Map(),
|
|
169
|
+
serial: new Map()
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
isPrivateName(value) {
|
|
174
|
+
if (PUBLIC_PREFIXES.some((prefix) => value.startsWith(prefix))) return false;
|
|
175
|
+
return this.prefixes.some(
|
|
176
|
+
(prefix) => value === prefix || value.startsWith(`${prefix}.`)
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
pseudo(kind, value, make) {
|
|
181
|
+
const map = this.maps[kind];
|
|
182
|
+
if (!map.has(value)) map.set(value, make(map.size + 1));
|
|
183
|
+
return map.get(value);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
packageFor(value) {
|
|
187
|
+
if (this.maps.package.has(value)) return this.maps.package.get(value);
|
|
188
|
+
if (value.startsWith(`${this.appPackage}.`)) return undefined;
|
|
189
|
+
return this.pseudo("package", value, (n) => `com.example.other${n}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
classFor(value) {
|
|
193
|
+
if (this.maps.package.has(value)) return this.maps.package.get(value);
|
|
194
|
+
return this.pseudo("class", value, (n) => {
|
|
195
|
+
const last = value.split(".").at(-1) ?? "";
|
|
196
|
+
const kind = /Activity$/.test(last) ? "Activity"
|
|
197
|
+
: /Fragment$/.test(last) ? "Fragment" : "Class";
|
|
198
|
+
return `com.example.app.${kind}${n}`;
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Pass 1: learn every sensitive value.
|
|
203
|
+
learn(value, key, inValueTree) {
|
|
204
|
+
if (Array.isArray(value)) {
|
|
205
|
+
for (const item of value) this.learn(item, key, inValueTree);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (value !== null && typeof value === "object") {
|
|
209
|
+
for (const [childKey, child] of Object.entries(value)) {
|
|
210
|
+
this.learn(child, childKey, inValueTree || VALUE_TREE_KEYS.has(childKey));
|
|
211
|
+
}
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (typeof value !== "string" || value.length === 0) return;
|
|
215
|
+
if (SERIAL_KEYS.has(key) && !QUALIFIED.test(value)) {
|
|
216
|
+
this.pseudo("serial", value, (n) => `device-${n}`);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (key === "resourceId" && !value.includes(":")) {
|
|
220
|
+
// Some UI backends report bare resource names (e.g. "btn_submit").
|
|
221
|
+
this.pseudo("resourceName", value, (n) => `r${n}`);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const resource = RESOURCE_ID.exec(value);
|
|
225
|
+
if (resource !== null) {
|
|
226
|
+
const [, owner, type] = resource;
|
|
227
|
+
if (owner !== "android" && !PUBLIC_PREFIXES.some((p) => `${owner}.`.startsWith(p))) {
|
|
228
|
+
this.pseudo("resource", value, (n) => {
|
|
229
|
+
const pkg = owner === this.appPackage ? "com.example.app" : "com.example.other";
|
|
230
|
+
return `${pkg}:${type}/r${n}`;
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (QUALIFIED.test(value) && this.isPrivateName(value)) {
|
|
236
|
+
if (key.toLowerCase().includes("package")) this.packageFor(value);
|
|
237
|
+
else this.classFor(value);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (TEXT_KEYS.has(key) || inValueTree) {
|
|
241
|
+
if (HEX_HASH.test(value) || ISO_DATE.test(value)) return;
|
|
242
|
+
this.pseudo("text", value, (n) => `T${n}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
finish() {
|
|
247
|
+
// Longest originals first so a substring never shadows its container.
|
|
248
|
+
const byLength = (a, b) => b[0].length - a[0].length;
|
|
249
|
+
const names = [...this.paths];
|
|
250
|
+
for (const kind of ["serial", "resource", "class", "package"]) {
|
|
251
|
+
for (const pair of this.maps[kind]) names.push(pair);
|
|
252
|
+
}
|
|
253
|
+
this.nameSubstrings = names.sort(byLength);
|
|
254
|
+
this.textSubstrings = [...this.maps.text, ...this.maps.resourceName]
|
|
255
|
+
.filter(([original]) => original.length >= 3)
|
|
256
|
+
.sort(byLength);
|
|
257
|
+
this.exactNames = new Map(names);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Pass 2: rewrite values. Keys stay verbatim so the schema remains readable.
|
|
261
|
+
rewrite(value, key = "", inValueTree = false) {
|
|
262
|
+
if (Array.isArray(value)) {
|
|
263
|
+
return value.map((item) => this.rewrite(item, key, inValueTree));
|
|
264
|
+
}
|
|
265
|
+
if (value !== null && typeof value === "object") {
|
|
266
|
+
return Object.fromEntries(Object.entries(value).map(([childKey, child]) => [
|
|
267
|
+
childKey,
|
|
268
|
+
this.rewrite(child, childKey, inValueTree || VALUE_TREE_KEYS.has(childKey))
|
|
269
|
+
]));
|
|
270
|
+
}
|
|
271
|
+
if (typeof value !== "string") return value;
|
|
272
|
+
if (this.exactNames.has(value)) return this.exactNames.get(value);
|
|
273
|
+
if (key === "resourceId" && this.maps.resourceName.has(value)) {
|
|
274
|
+
return this.maps.resourceName.get(value);
|
|
275
|
+
}
|
|
276
|
+
if ((TEXT_KEYS.has(key) || inValueTree) && this.maps.text.has(value)) {
|
|
277
|
+
return this.maps.text.get(value);
|
|
278
|
+
}
|
|
279
|
+
const withNames = this.rewriteString(value, this.nameSubstrings);
|
|
280
|
+
return FREE_TEXT_KEYS.has(key)
|
|
281
|
+
? this.rewriteString(withNames, this.textSubstrings)
|
|
282
|
+
: withNames;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
rewriteString(value, pairs = this.nameSubstrings) {
|
|
286
|
+
// Placeholders keep one replacement's output from matching another original.
|
|
287
|
+
let result = value;
|
|
288
|
+
const pending = [];
|
|
289
|
+
for (const [original, replacement] of pairs) {
|
|
290
|
+
if (!result.includes(original)) continue;
|
|
291
|
+
const token = `\u0000${pending.length}\u0000`;
|
|
292
|
+
result = result.split(original).join(token);
|
|
293
|
+
pending.push([token, replacement]);
|
|
294
|
+
}
|
|
295
|
+
for (const [token, replacement] of pending) {
|
|
296
|
+
result = result.split(token).join(replacement);
|
|
297
|
+
}
|
|
298
|
+
return result;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
mapping() {
|
|
302
|
+
return Object.fromEntries(Object.entries(this.maps).map(
|
|
303
|
+
([kind, map]) => [kind, Object.fromEntries(
|
|
304
|
+
[...map].map(([original, replacement]) => [replacement, original])
|
|
305
|
+
)]
|
|
306
|
+
));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
counts() {
|
|
310
|
+
return Object.fromEntries(Object.entries(this.maps).map(
|
|
311
|
+
([kind, map]) => [kind, map.size]
|
|
312
|
+
));
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function main() {
|
|
317
|
+
const options = parseArgs(process.argv.slice(2));
|
|
318
|
+
if (options.help) {
|
|
319
|
+
process.stdout.write(HELP);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const packageName = options.package ?? readPackage(options.project);
|
|
323
|
+
if (typeof packageName !== "string" || !QUALIFIED.test(packageName)) {
|
|
324
|
+
fail("Cannot determine the app package; pass --package <pkg>");
|
|
325
|
+
}
|
|
326
|
+
const { files, bundles } = collectSources(options);
|
|
327
|
+
const redactor = new Redactor({
|
|
328
|
+
packageName, orgPrefixes: options.orgPrefixes, projectRoot: options.project
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const documents = [];
|
|
332
|
+
for (const file of files) {
|
|
333
|
+
try {
|
|
334
|
+
documents.push([file, JSON.parse(readFileSync(file, "utf8"))]);
|
|
335
|
+
} catch {
|
|
336
|
+
process.stderr.write(`skip (not valid JSON): ${relative(options.project, file)}\n`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
for (const [, document] of documents) redactor.learn(document, "", false);
|
|
340
|
+
redactor.finish();
|
|
341
|
+
|
|
342
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
343
|
+
// The diagnostics directory sits in the Git-ignored build subtree.
|
|
344
|
+
const out = resolve(options.out ?? join(
|
|
345
|
+
options.project, ".taphound", "build", "diagnostics",
|
|
346
|
+
`taphound-feedback-${stamp}.tgz`
|
|
347
|
+
));
|
|
348
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
349
|
+
const staging = mkdtempSync(join(tmpdir(), "taphound-feedback-"));
|
|
350
|
+
const root = join(staging, "taphound-feedback");
|
|
351
|
+
try {
|
|
352
|
+
for (const [file, document] of documents) {
|
|
353
|
+
const relativePath = redactor.rewriteString(
|
|
354
|
+
relative(options.project, file).split(sep).join("/")
|
|
355
|
+
);
|
|
356
|
+
const target = join(root, ...relativePath.split("/"));
|
|
357
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
358
|
+
writeFileSync(target, `${JSON.stringify(redactor.rewrite(document), null, 2)}\n`);
|
|
359
|
+
}
|
|
360
|
+
writeFileSync(join(root, "README.txt"), [
|
|
361
|
+
"TapHound feedback bundle (redacted).",
|
|
362
|
+
`Generation bundles: ${bundles.length}`,
|
|
363
|
+
`JSON files: ${documents.length}`,
|
|
364
|
+
`Pseudonyms: ${JSON.stringify(redactor.counts())}`,
|
|
365
|
+
"Screenshots, Logcat text, and non-JSON files are excluded.",
|
|
366
|
+
""
|
|
367
|
+
].join("\n"));
|
|
368
|
+
|
|
369
|
+
const tar = spawnSync(
|
|
370
|
+
"tar", ["-czf", out, "-C", staging, "taphound-feedback"],
|
|
371
|
+
{
|
|
372
|
+
shell: false,
|
|
373
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
374
|
+
// Stop macOS bsdtar from adding AppleDouble "._*" xattr entries.
|
|
375
|
+
env: { ...process.env, COPYFILE_DISABLE: "1" }
|
|
376
|
+
}
|
|
377
|
+
);
|
|
378
|
+
if (tar.error !== undefined || tar.status !== 0) {
|
|
379
|
+
fail(`tar failed${tar.error ? `: ${tar.error.message}` : ""}`);
|
|
380
|
+
}
|
|
381
|
+
} finally {
|
|
382
|
+
rmSync(staging, { recursive: true, force: true });
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const mappingPath = out.replace(/\.t(?:ar\.)?gz$/, "") + ".mapping.json";
|
|
386
|
+
writeFileSync(mappingPath, `${JSON.stringify(redactor.mapping(), null, 2)}\n`);
|
|
387
|
+
|
|
388
|
+
process.stdout.write([
|
|
389
|
+
`Archive: ${out} (${statSync(out).size} bytes)`,
|
|
390
|
+
`Mapping: ${mappingPath} <- keep this local, do NOT share`,
|
|
391
|
+
`Bundles: ${bundles.map((bundle) => basename(bundle)).join(", ")}`,
|
|
392
|
+
`Files: ${documents.length} JSON`,
|
|
393
|
+
`Redacted: ${JSON.stringify(redactor.counts())}`,
|
|
394
|
+
"Review: tar -xzf <archive> and grep for anything still sensitive before sharing.",
|
|
395
|
+
""
|
|
396
|
+
].join("\n"));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
main();
|