@deeeed/metamask-harness 0.43.0 → 0.44.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.
@@ -28,6 +28,12 @@ import {
28
28
  validateMetaMaskActionInputs
29
29
  } from "../metamask-action-validation.js";
30
30
  import { gitLibraryProvenance } from "../library-provenance.js";
31
+ import {
32
+ captureExecutionProvenance,
33
+ executionProvenanceDrift,
34
+ ProvenanceDriftError,
35
+ writeExecutionProvenance
36
+ } from "../execution-provenance.js";
31
37
  import {
32
38
  authorizeTrustedMutationPlan,
33
39
  loadTrustedMutationBase
@@ -36,7 +42,7 @@ import {
36
42
  beginRunDiagnostics,
37
43
  finishRunDiagnostics
38
44
  } from "../run-diagnostics.js";
39
- import { EXIT, spawnScriptStreaming } from "./shared.js";
45
+ import { EXIT } from "./shared.js";
40
46
  import {
41
47
  actionManifestPathOption,
42
48
  optionString,
@@ -46,13 +52,14 @@ import {
46
52
  targetPath,
47
53
  usageError
48
54
  } from "./parse-args.js";
49
- async function runRecipe(adapter, recipe, artifactsDir, projectRoot, actionManifestPath, runtimeOptions = {}, preflightedExecution) {
55
+ async function runRecipe(adapter, recipe, artifactsDir, projectRoot, actionManifestPath, runtimeOptions = {}, preflightedExecution, effectsState = newHealState()) {
50
56
  const restoreRuntimeEnvironment = activateRecipeRuntimeEnvironment(
51
57
  adapter,
52
58
  projectRoot,
53
59
  runtimeOptions
54
60
  );
55
61
  try {
62
+ const wasPreflighted = preflightedExecution !== void 0;
56
63
  const execution = preflightedExecution ?? await resolveRecipeExecution(
57
64
  adapter,
58
65
  recipe,
@@ -65,27 +72,48 @@ async function runRecipe(adapter, recipe, artifactsDir, projectRoot, actionManif
65
72
  runner,
66
73
  runRequest,
67
74
  absoluteArtifactsDir,
68
- useFramedExtensionRecording
75
+ useFramedExtensionRecording,
76
+ provenanceInput,
77
+ provenanceSnapshots
69
78
  } = execution;
70
- await runner.preflight(runRequest);
79
+ if (!wasPreflighted) await runner.preflight(runRequest);
71
80
  await prepareRuntimeIfNeeded(adapter, projectRoot, runtimeOptions);
81
+ await runner.preflight(runRequest);
82
+ const preExecute = await captureExecutionProvenance(
83
+ provenanceInput,
84
+ "pre-execute"
85
+ );
86
+ provenanceSnapshots.push(preExecute);
87
+ const preExecuteDrift = executionProvenanceDrift(
88
+ provenanceSnapshots[0],
89
+ preExecute
90
+ );
91
+ if (preExecuteDrift.length > 0) {
92
+ const provenancePath2 = writeExecutionProvenance(
93
+ absoluteArtifactsDir,
94
+ provenanceSnapshots,
95
+ preExecuteDrift
96
+ );
97
+ throw new ProvenanceDriftError(provenancePath2, preExecuteDrift);
98
+ }
72
99
  const diagnosticBaseline = await beginRunDiagnostics(adapter, projectRoot);
73
100
  const recording = useFramedExtensionRecording ? await startRecipeRecording(adapter, projectRoot, absoluteArtifactsDir, {
74
101
  record: true,
75
102
  cdpPort: runtimeOptions.cdpPort
76
103
  }) : void 0;
77
104
  let result;
105
+ let executionError;
78
106
  try {
79
107
  result = await runner.run(runRequest);
80
108
  } catch (error) {
109
+ executionError = error;
81
110
  try {
82
111
  await stopRecipeRecording(recording);
83
- } catch (recordingError) {
112
+ } catch (recordingError2) {
84
113
  console.error(
85
- `WARN: recipe and video recording both failed; preserving recipe failure: ${recordingError instanceof Error ? recordingError.message : String(recordingError)}`
114
+ `WARN: recipe and video recording both failed; preserving recipe failure: ${recordingError2 instanceof Error ? recordingError2.message : String(recordingError2)}`
86
115
  );
87
116
  }
88
- throw error;
89
117
  } finally {
90
118
  if (adapter === "mobile") {
91
119
  const { hideMobileHudOnTeardown } = await import("../adapters.js");
@@ -95,8 +123,36 @@ async function runRecipe(adapter, recipe, artifactsDir, projectRoot, actionManif
95
123
  );
96
124
  }
97
125
  }
98
- await stopRecipeRecording(recording, result);
99
- return finishRunDiagnostics(diagnosticBaseline, result);
126
+ let recordingError;
127
+ if (executionError === void 0 && result) {
128
+ try {
129
+ await stopRecipeRecording(recording, result);
130
+ } catch (error) {
131
+ recordingError = error;
132
+ }
133
+ }
134
+ const end = await captureExecutionProvenance(provenanceInput, "end");
135
+ provenanceSnapshots.push(end);
136
+ const endDrift = executionProvenanceDrift(provenanceSnapshots[0], end);
137
+ const provenancePath = writeExecutionProvenance(
138
+ absoluteArtifactsDir,
139
+ provenanceSnapshots,
140
+ endDrift,
141
+ result?.artifactManifestPath
142
+ );
143
+ if (endDrift.length > 0) {
144
+ throw new ProvenanceDriftError(provenancePath, endDrift, executionError);
145
+ }
146
+ if (executionError !== void 0) throw executionError;
147
+ if (recordingError !== void 0) throw recordingError;
148
+ if (!result) throw new Error("Recipe execution returned no result.");
149
+ const finalized = finishRunDiagnostics(diagnosticBaseline, result);
150
+ persistRunEffects(
151
+ finalized.summaryPath,
152
+ finalized.artifactManifestPath,
153
+ effectsState
154
+ );
155
+ return finalized;
100
156
  } finally {
101
157
  restoreRuntimeEnvironment();
102
158
  }
@@ -122,6 +178,23 @@ async function preflightRecipe(adapter, recipe, artifactsDir, projectRoot, actio
122
178
  restoreRuntimeEnvironment();
123
179
  }
124
180
  }
181
+ function persistRunEffects(summaryPath, artifactManifestPath, state) {
182
+ for (const filePath of [summaryPath, artifactManifestPath]) {
183
+ let document;
184
+ try {
185
+ document = JSON.parse(fs.readFileSync(filePath, "utf8"));
186
+ } catch {
187
+ document = null;
188
+ }
189
+ if (!isRecord(document)) {
190
+ throw new Error(`Recipe evidence file is not valid JSON: ${filePath}`);
191
+ }
192
+ document.recovered = [...state.recovered];
193
+ document.mutations = state.mutations.map((mutation) => ({ ...mutation }));
194
+ fs.writeFileSync(filePath, `${JSON.stringify(document, null, 2)}
195
+ `);
196
+ }
197
+ }
125
198
  async function resolveRecipeExecution(adapter, recipe, artifactsDir, projectRoot, actionManifestPath, runtimeOptions) {
126
199
  const absoluteArtifactsDir = path.resolve(artifactsDir);
127
200
  const absoluteRecipePath = typeof recipe === "string" ? path.resolve(recipe) : void 0;
@@ -173,7 +246,7 @@ async function resolveRecipeExecution(adapter, recipe, artifactsDir, projectRoot
173
246
  });
174
247
  const useFramedExtensionRecording = adapter === "extension" && recordVideo === "full-run" && trust.source?.trust !== "untrusted" && trust.source?.trust !== "unknown" && captureHelperSupportsRecordSessionSnapshots(projectRoot);
175
248
  const runRequest = {
176
- ...absoluteRecipePath ? { recipePath: absoluteRecipePath } : { recipeDocument: recipe },
249
+ ...recipeDocument !== void 0 ? { recipeDocument } : absoluteRecipePath ? { recipePath: absoluteRecipePath } : { recipeDocument: recipe },
177
250
  artifactsDir: absoluteArtifactsDir,
178
251
  projectRoot,
179
252
  inheritProcessEnv: false,
@@ -196,12 +269,138 @@ async function resolveRecipeExecution(adapter, recipe, artifactsDir, projectRoot
196
269
  trustedMutation: authorizedMutation
197
270
  });
198
271
  }
272
+ const provenanceInput = {
273
+ adapter,
274
+ projectRoot,
275
+ recipeDocument: recipeDocument ?? recipe,
276
+ ...absoluteRecipePath ? { recipePath: absoluteRecipePath } : {},
277
+ ...librarySources ? { librarySources } : {},
278
+ excludedProductRoots: [absoluteArtifactsDir],
279
+ helperPaths: commandHelperPaths(recipeDocument ?? recipe, projectRoot)
280
+ };
281
+ const startProvenance = await captureExecutionProvenance(
282
+ provenanceInput,
283
+ "start"
284
+ );
199
285
  return {
200
286
  runner,
201
287
  absoluteArtifactsDir,
202
288
  useFramedExtensionRecording,
203
- runRequest
289
+ runRequest,
290
+ provenanceInput,
291
+ provenanceSnapshots: [startProvenance]
292
+ };
293
+ }
294
+ function commandHelperPaths(recipe, projectRoot) {
295
+ const helpers = /* @__PURE__ */ new Set();
296
+ visitRecipeValues(recipe, (value) => {
297
+ if (!isRecord(value) || value.action !== "command" || typeof value.cmd !== "string") return;
298
+ const candidates = commandHelperTokens(value.cmd);
299
+ for (const token of candidates) {
300
+ if (/[$`{}]/u.test(token)) {
301
+ throw new Error(`Command helper path cannot be bound before execution: ${token}`);
302
+ }
303
+ if (!isHelperToken(token)) continue;
304
+ const absolute = path.isAbsolute(token) ? path.resolve(token) : path.resolve(projectRoot, token);
305
+ let stat;
306
+ try {
307
+ stat = fs.lstatSync(absolute);
308
+ } catch {
309
+ throw new Error(`Command helper source does not exist: ${token}`);
310
+ }
311
+ if (stat.isSymbolicLink() || !stat.isFile()) {
312
+ throw new Error(`Command helper source must be a regular file: ${token}`);
313
+ }
314
+ helpers.add(absolute);
315
+ }
316
+ });
317
+ return [...helpers].sort();
318
+ }
319
+ function commandHelperTokens(command, depth = 0) {
320
+ if (depth > 4) {
321
+ throw new Error("Command helper sources exceed the supported shell nesting depth.");
322
+ }
323
+ const tokens = shellTokens(command);
324
+ const candidates = new Set(tokens.filter(isHelperToken));
325
+ for (const token of tokens) {
326
+ const assignment = /^(?:--)?(?:config|configuration|manifest|project|recipe|schema|tsconfig)=([^=]+)$/iu.exec(
327
+ token
328
+ );
329
+ const value = assignment?.[1];
330
+ if (value && (isHelperToken(value) || isConfigInputToken(value))) {
331
+ candidates.add(value);
332
+ }
333
+ }
334
+ for (let index = 0; index < tokens.length - 1; index += 1) {
335
+ if (!isScriptInterpreter(tokens[index])) continue;
336
+ const option = tokens[index + 1];
337
+ if (isShellInterpreter(tokens[index]) && /^-[a-z]*c[a-z]*$/iu.test(option) && tokens[index + 2]) {
338
+ for (const nested of commandHelperTokens(tokens[index + 2], depth + 1)) {
339
+ candidates.add(nested);
340
+ }
341
+ continue;
342
+ }
343
+ if (!option.startsWith("-")) candidates.add(option);
344
+ }
345
+ return candidates;
346
+ }
347
+ function visitRecipeValues(value, visit) {
348
+ visit(value);
349
+ if (Array.isArray(value)) {
350
+ for (const child of value) visitRecipeValues(child, visit);
351
+ return;
352
+ }
353
+ if (!isRecord(value)) return;
354
+ for (const child of Object.values(value)) visitRecipeValues(child, visit);
355
+ }
356
+ function isHelperToken(token) {
357
+ return !token.startsWith("-") && !token.includes("=") && !/^[a-z][a-z0-9+.-]*:\/\//iu.test(token) && /^\S+\.(?:cjs|mjs|js|jsx|ts|tsx|sh|py)$/u.test(token);
358
+ }
359
+ function isConfigInputToken(token) {
360
+ return !token.startsWith("-") && !token.includes("=") && /^\S+\.(?:json|json5|ya?ml|toml)$/u.test(token);
361
+ }
362
+ function isScriptInterpreter(token) {
363
+ return /^(?:node|bash|sh|python|python3)$/u.test(path.basename(token));
364
+ }
365
+ function isShellInterpreter(token) {
366
+ return /^(?:bash|sh)$/u.test(path.basename(token));
367
+ }
368
+ function shellTokens(command) {
369
+ const tokens = [];
370
+ let token = "";
371
+ let quote = null;
372
+ let escaped = false;
373
+ const push = () => {
374
+ if (token) tokens.push(token);
375
+ token = "";
204
376
  };
377
+ for (const character of command) {
378
+ if (escaped) {
379
+ token += character;
380
+ escaped = false;
381
+ continue;
382
+ }
383
+ if (character === "\\" && quote !== "'") {
384
+ escaped = true;
385
+ continue;
386
+ }
387
+ if (quote) {
388
+ if (character === quote) quote = null;
389
+ else token += character;
390
+ continue;
391
+ }
392
+ if (character === "'" || character === '"') {
393
+ quote = character;
394
+ continue;
395
+ }
396
+ if (/\s|[;&|<>]/u.test(character)) push();
397
+ else token += character;
398
+ }
399
+ if (quote || escaped) {
400
+ throw new Error("Command helper sources cannot be bound from an unterminated shell token.");
401
+ }
402
+ push();
403
+ return tokens;
205
404
  }
206
405
  function runtimeRecipeDocument(recipe, absoluteRecipePath) {
207
406
  if (typeof recipe !== "string") return recipe;
@@ -938,19 +1137,15 @@ async function prepareHeal(adapter, target, options, json, prepareOptions = {})
938
1137
  target,
939
1138
  heal,
940
1139
  state,
941
- json,
942
- {
943
- onRecovery: (code) => prepareOptions.onPhase?.("recover", { code })
944
- }
1140
+ json
945
1141
  );
946
1142
  if (current !== null) return current;
947
1143
  }
948
1144
  return { state, heal };
949
1145
  }
950
- async function ensureMobileProofRuntime(target, heal, state, json, deps = {}) {
1146
+ async function ensureMobileProofRuntime(target, _heal, state, json, deps = {}) {
951
1147
  const source = await import("../adapters/mobile/source-freshness.js");
952
1148
  const check = deps.check ?? (() => source.mobileSourceCheck(target));
953
- const record = deps.record ?? ((fingerprint) => source.recordMobileSourceBaseline(target, fingerprint));
954
1149
  const initial = check();
955
1150
  if (initial.status === "unavailable") {
956
1151
  if (json) {
@@ -975,99 +1170,26 @@ async function ensureMobileProofRuntime(target, heal, state, json, deps = {}) {
975
1170
  if (initial.status === "current") return null;
976
1171
  const targetArg = JSON.stringify(path.resolve(target));
977
1172
  const userAction = `mm-harness call app.lifecycle --arg command=restart --adapter mobile --target ${targetArg}`;
978
- if (heal === "off") {
979
- const message = `Mobile loaded source is not current (${initial.status}).`;
980
- if (json) {
981
- console.log(
982
- JSON.stringify(
983
- {
984
- schemaVersion: 1,
985
- status: "fail",
986
- recoverable: true,
987
- mutations: state.mutations,
988
- error: { code: "MOBILE_SOURCE_NOT_LOADED", message, userAction }
989
- },
990
- null,
991
- 2
992
- )
993
- );
994
- } else {
995
- console.error(`\u2717 ${message}`);
996
- console.error(` Next: ${userAction}`);
997
- }
998
- return EXIT.infra;
999
- }
1000
- const recoveryCode = "mobile.source_reloaded";
1001
- state.attemptedRecoveries.push(recoveryCode);
1002
- deps.onRecovery?.(recoveryCode);
1003
- if (!json)
1004
- console.error(
1005
- `\u2192 Mobile proof preflight: source ${initial.status}; restarting the app before execution`
1006
- );
1007
- const restart = deps.restart ?? (async () => {
1008
- const result2 = await runOneNode(
1009
- "mobile",
1010
- "app.lifecycle",
1011
- { command: "restart" },
1012
- target,
1013
- void 0
1173
+ const message = `Mobile loaded source is not current (${initial.status}).`;
1174
+ if (json) {
1175
+ console.log(
1176
+ JSON.stringify(
1177
+ {
1178
+ schemaVersion: 1,
1179
+ status: "fail",
1180
+ recoverable: true,
1181
+ mutations: state.mutations,
1182
+ error: { code: "MOBILE_SOURCE_NOT_LOADED", message, userAction }
1183
+ },
1184
+ null,
1185
+ 2
1186
+ )
1014
1187
  );
1015
- return {
1016
- status: result2.status === "pass" ? 0 : EXIT.runtime,
1017
- output: result2.status
1018
- };
1019
- });
1020
- const result = await restart();
1021
- const platform = resolveRunMobilePlatform();
1022
- const bridge = result.status === 0 ? await (deps.waitForBridge ? deps.waitForBridge() : spawnScriptStreaming(
1023
- path.join(runnerDir, "adapters/mobile/wait-for-bridge.sh"),
1024
- ["--target", target, "--platform", platform],
1025
- target
1026
- )) : { status: result.status, output: result.output };
1027
- const recorded = result.status === 0 && bridge.status === 0 && record(initial.fingerprint);
1028
- const verified = recorded ? check() : initial;
1029
- if (result.status !== 0 || bridge.status !== 0 || !recorded || verified.status !== "current") {
1030
- const message = bridge.status !== 0 ? "Mobile source restart completed but the platform-matched bridge did not become ready." : "Mobile source restart did not produce a current loaded-source baseline.";
1031
- if (json) {
1032
- console.log(
1033
- JSON.stringify(
1034
- {
1035
- schemaVersion: 1,
1036
- status: "fail",
1037
- recoverable: false,
1038
- attemptedRecoveries: state.attemptedRecoveries,
1039
- error: {
1040
- code: bridge.status !== 0 ? "MOBILE_BRIDGE_NOT_READY" : "MOBILE_SOURCE_RELOAD_FAILED",
1041
- message,
1042
- userAction,
1043
- originalError: bridge.output || result.output
1044
- }
1045
- },
1046
- null,
1047
- 2
1048
- )
1049
- );
1050
- } else {
1051
- console.error(`\u2717 ${message}`);
1052
- console.error(
1053
- ` Next: ${bridge.status !== 0 ? "inspect the bridge-status log and rerun mm-harness launch <platform>" : userAction}`
1054
- );
1055
- }
1056
- return EXIT.infra;
1057
- }
1058
- state.recovered.push(recoveryCode);
1059
- state.mutations.push({
1060
- type: "runtime",
1061
- action: "restarted",
1062
- reasonCode: `source-${initial.status}`
1063
- });
1064
- return null;
1065
- }
1066
- function resolveRunMobilePlatform() {
1067
- if (process.env.PLATFORM === "android" || process.env.PLATFORM === "ios") {
1068
- return process.env.PLATFORM;
1188
+ } else {
1189
+ console.error(`\u2717 ${message}`);
1190
+ console.error(` Next: ${userAction}`);
1069
1191
  }
1070
- return process.env.ADB_SERIAL || process.env.ANDROID_SERIAL ? "android" : "ios";
1192
+ return EXIT.infra;
1071
1193
  }
1072
1194
  async function ensureExtensionProofRuntime(target, heal, state, json, deps = {}) {
1073
1195
  const decide = deps.decide ?? (async () => {
@@ -1263,26 +1385,10 @@ async function recoverRunInfra(adapter, target, json, runtimeOptions = {}) {
1263
1385
  return launchExtension(target, "quick", false);
1264
1386
  }
1265
1387
  if (adapter === "mobile") {
1266
- try {
1267
- const artifact = requireCurrentMobileReleaseArtifactState(target);
1268
- if (artifact) {
1269
- const { relaunchAndroidReleaseArtifactRuntime } = await import("../adapters/mobile/release-artifact.js");
1270
- await relaunchAndroidReleaseArtifactRuntime(artifact);
1271
- return {
1272
- status: 0,
1273
- output: `Relaunched ${artifact.packageId} ${artifact.versionName} (${artifact.versionCode}) on ${artifact.deviceSerial}.`
1274
- };
1275
- }
1276
- } catch (error) {
1277
- return {
1278
- status: 1,
1279
- output: `Release artifact recovery could not revalidate and relaunch the pinned Android package: ${error instanceof Error ? error.message : String(error)}
1280
- Next: rerun runtime-launch with the same artifact source.`
1281
- };
1282
- }
1283
- const { launchMobile } = await import("./launch/mobile.js");
1284
- const platform = resolveMobileRecoveryPlatform();
1285
- return launchMobile(target, platform, "quick", json);
1388
+ return {
1389
+ status: 1,
1390
+ output: "Automatic Mobile runtime recovery is disabled.\nNext: use an explicit app.lifecycle action when the recipe intends to relaunch the app."
1391
+ };
1286
1392
  }
1287
1393
  return { status: 0, output: "headless run retry" };
1288
1394
  } finally {
@@ -1290,15 +1396,6 @@ Next: rerun runtime-launch with the same artifact source.`
1290
1396
  restoreEnv("RECIPE_CDP_PORT", previousRecipeCdpPort);
1291
1397
  }
1292
1398
  }
1293
- function resolveMobileRecoveryPlatform(env = process.env) {
1294
- if (env.PLATFORM === "android" || env.PLATFORM === "ios") return env.PLATFORM;
1295
- const androidPinned = Boolean(
1296
- env.ADB_SERIAL || env.ANDROID_SERIAL || env.ANDROID_TARGET_DEVICE_NAME || env.ANDROID_DEVICE
1297
- );
1298
- const iosPinned = Boolean(env.IOS_SIMULATOR || env.SIM_UDID);
1299
- if (androidPinned && !iosPinned) return "android";
1300
- return "ios";
1301
- }
1302
1399
  function readRunFailureText(result) {
1303
1400
  try {
1304
1401
  const trace = JSON.parse(fs.readFileSync(result.tracePath, "utf8"));
@@ -1320,11 +1417,22 @@ async function executeWithHealBounds(exec, adapter, target, heal, state, recover
1320
1417
  state
1321
1418
  );
1322
1419
  if (violation !== null) return { result, violation };
1420
+ if (adapter === "mobile") return { result, violation: null };
1323
1421
  if (heal === "off") return { result, violation: null };
1324
1422
  const recoveryCode = RUN_RECOVERY_CODE[adapter];
1325
1423
  state.attemptedRecoveries.push(recoveryCode);
1326
1424
  onRecovery(recoveryCode);
1327
- await recover();
1425
+ const recovery = await recover();
1426
+ if (recovery.status !== 0) {
1427
+ return { result, violation: null };
1428
+ }
1429
+ if (adapter === "extension") {
1430
+ state.mutations.push({
1431
+ type: "runtime",
1432
+ action: "reloaded",
1433
+ reasonCode: recoveryCode
1434
+ });
1435
+ }
1328
1436
  result = await exec();
1329
1437
  if (result.status === "pass") {
1330
1438
  state.recovered.push(recoveryCode);
@@ -1387,12 +1495,12 @@ export {
1387
1495
  ensureMobileProofRuntime,
1388
1496
  executeWithHealBounds,
1389
1497
  listRunnableRecipes,
1498
+ persistRunEffects,
1390
1499
  preflightRecipe,
1391
1500
  prepareHeal,
1392
1501
  prepareRuntimeIfNeeded,
1393
1502
  recoverRunInfra,
1394
1503
  resolveMetaMaskLibrarySources,
1395
- resolveMobileRecoveryPlatform,
1396
1504
  resolveRunRecipeArg,
1397
1505
  runOneNode,
1398
1506
  runRecipe,
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { execFileSync } from "node:child_process";
3
4
  import { isRecord } from "./parse-args.js";
4
5
  function writeRunReport(result) {
5
6
  const summaryPath = String(result.summaryPath);
@@ -19,6 +20,72 @@ function writeRunReport(result) {
19
20
  preview: lines.filter((line) => line.startsWith("- ")).slice(0, 6).map((line) => line.slice(2))
20
21
  };
21
22
  }
23
+ function indexProductProvenanceArtifact(artifactManifestPath, target, adapter) {
24
+ const artifactsDir = path.dirname(artifactManifestPath);
25
+ let gitRef = "unknown";
26
+ let branch = "unknown";
27
+ let dirty = true;
28
+ try {
29
+ gitRef = execFileSync("git", ["rev-parse", "HEAD"], {
30
+ cwd: target,
31
+ encoding: "utf8",
32
+ stdio: ["ignore", "pipe", "ignore"]
33
+ }).trim();
34
+ branch = execFileSync("git", ["branch", "--show-current"], {
35
+ cwd: target,
36
+ encoding: "utf8",
37
+ stdio: ["ignore", "pipe", "ignore"]
38
+ }).trim() || "detached";
39
+ dirty = execFileSync(
40
+ "git",
41
+ ["status", "--porcelain", "--untracked-files=normal"],
42
+ {
43
+ cwd: target,
44
+ encoding: "utf8",
45
+ stdio: ["ignore", "pipe", "ignore"]
46
+ }
47
+ ).trim().length > 0;
48
+ } catch {
49
+ }
50
+ const provenance = {
51
+ schemaVersion: 1,
52
+ adapter,
53
+ repository: path.basename(path.resolve(target)),
54
+ git_ref: gitRef,
55
+ branch,
56
+ dirty
57
+ };
58
+ fs.writeFileSync(
59
+ path.join(artifactsDir, "product-provenance.json"),
60
+ `${JSON.stringify(provenance, null, 2)}
61
+ `
62
+ );
63
+ let manifest;
64
+ try {
65
+ manifest = JSON.parse(fs.readFileSync(artifactManifestPath, "utf8"));
66
+ } catch {
67
+ return;
68
+ }
69
+ if (!isRecord(manifest)) return;
70
+ const artifacts = Array.isArray(manifest.artifacts) ? manifest.artifacts : [];
71
+ manifest.artifacts = [
72
+ ...artifacts.filter(
73
+ (artifact) => !isRecord(artifact) || artifact.path !== "product-provenance.json"
74
+ ),
75
+ {
76
+ path: "product-provenance.json",
77
+ type: "json",
78
+ label: "Product checkout provenance",
79
+ category: "system",
80
+ metadata: { adapter, git_ref: gitRef, branch, dirty }
81
+ }
82
+ ];
83
+ fs.writeFileSync(
84
+ artifactManifestPath,
85
+ `${JSON.stringify(manifest, null, 2)}
86
+ `
87
+ );
88
+ }
22
89
  function renderRunReport(summary, entries) {
23
90
  const lines = [
24
91
  "# MetaMask Recipe Run",
@@ -120,5 +187,6 @@ function indexRunReportArtifact(artifactManifestPath) {
120
187
  `);
121
188
  }
122
189
  export {
190
+ indexProductProvenanceArtifact,
123
191
  writeRunReport
124
192
  };