@deeeed/metamask-harness 0.28.0 → 0.29.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +41 -0
  3. package/adapters/extension/build-lavamoat.sh +2 -1
  4. package/adapters/extension/ensure-browser.sh +82 -9
  5. package/adapters/extension/inject.mjs +1 -0
  6. package/adapters/extension/launch-browser.cjs +83 -1
  7. package/adapters/extension/lib/chrome-args.cjs +325 -1
  8. package/adapters/extension/lib/playwright-cdp.cjs +34 -0
  9. package/adapters/extension/lib/slot-title.cjs +2 -4
  10. package/adapters/extension/lib/validation-launch-supervisor.cjs +292 -0
  11. package/adapters/extension/lib/validation-process-ownership.cjs +69 -0
  12. package/adapters/extension/reattach.sh +2 -1
  13. package/adapters/extension/sidepanel-toggle.sh +14 -96
  14. package/adapters/extension/wallet-fixture-state.cjs +8 -31
  15. package/adapters/manifest.json +16 -0
  16. package/adapters/shared/private-atomic-write.cjs +47 -0
  17. package/adapters/shared/setup-base.sh +864 -0
  18. package/dist/adapters/extension/runtime.js +367 -24
  19. package/dist/adapters/extension/validation-process-ownership.js +10 -0
  20. package/dist/cli-commands.js +1 -0
  21. package/dist/command-contract.js +12 -0
  22. package/dist/commands/launch/extension.js +130 -19
  23. package/dist/commands/setup-base.js +24 -0
  24. package/dist/mm-harness-cli.js +28 -2
  25. package/docs/RECIPES.md +26 -1
  26. package/docs/SECURITY.md +31 -0
  27. package/library/actions/extension/analytics/consent.mjs +203 -0
  28. package/library/actions/extension/analytics/set_consent.mjs +19 -143
  29. package/library/actions/extension/perps/perps.mjs +2 -16
  30. package/library/actions/extension/perps/state.mjs +20 -0
  31. package/library/actions/extension/wallet/list_accounts.mjs +3 -25
  32. package/library/actions/extension/wallet/read_state.mjs +3 -23
  33. package/library/actions/extension/wallet/select_account.mjs +6 -33
  34. package/library/actions/extension/wallet/setup.mjs +2 -20
  35. package/library/actions/extension/wallet/state.mjs +111 -0
  36. package/library/recipes/runner/action-validation.extension.recipe.json +1 -1
  37. package/library/recipes/runner/action-validation.mobile.recipe.json +1 -1
  38. package/package.json +7 -4
  39. package/scripts/site-contrast.mjs +538 -0
  40. package/site/architecture.html +474 -0
  41. package/site/assets/progress.mjs +272 -0
  42. package/site/assets/style.css +808 -0
  43. package/site/cheatsheet.html +305 -0
  44. package/site/index.html +647 -0
  45. package/site/recipes.html +396 -0
  46. package/site/reviewers.html +374 -0
  47. package/site/tutorials/index.html +180 -0
  48. package/site/tutorials/v1.html +211 -0
  49. package/site/tutorials/v2.html +207 -0
  50. package/site/tutorials/v3.html +214 -0
  51. package/site/tutorials/v4.html +195 -0
  52. package/site/tutorials/v5.html +163 -0
  53. package/site/tutorials/v6.html +165 -0
  54. package/site/tutorials/v7.html +184 -0
@@ -0,0 +1,864 @@
1
+ #!/usr/bin/env bash
2
+ # mm-harness setup-base — bootstrap the shared default MetaMask checkout layout.
3
+ #
4
+ # npx -p @deeeed/metamask-harness mm-harness setup-base
5
+ #
6
+ # Clones the MetaMask product repos in N copies each into one standard folder
7
+ # layout and runs each repo's own dependency install:
8
+ #
9
+ # <base>/metamask-extension-1..N
10
+ # <base>/metamask-mobile-1..N
11
+ # <base>/core-1..N
12
+ #
13
+ # Scope fence: clone + dependency install, nothing else. No platform toolchains,
14
+ # no simulators, no .env files, no builds. Environment readiness is a separate
15
+ # step — `mm-harness doctor`.
16
+ #
17
+ # This file is deliberately a plain, standalone bash script: it runs on a machine
18
+ # before anything else is set up, and you should be able to read the whole thing
19
+ # top to bottom and decide whether to trust it BEFORE you run it. It sources
20
+ # nothing from the package at runtime and pulls in no shell dependencies.
21
+ set -euo pipefail
22
+
23
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
24
+ NODE_BIN="${MM_HARNESS_NODE:-node}"
25
+ PRIVATE_WRITER="$SCRIPT_DIR/private-atomic-write.cjs"
26
+
27
+ # Exit codes are stable and match the harness CLI, so another tool can branch on
28
+ # them: 0 ok · 1 a repo failed · 2 usage · 3 environment/disk refusal.
29
+ EXIT_OK=0
30
+ EXIT_RUNTIME=1
31
+ EXIT_USAGE=2
32
+ EXIT_INFRA=3
33
+
34
+ DEFAULT_BASE="$HOME/dev/metamask"
35
+ REPO_KEYS=(extension mobile core)
36
+ HARNESS_PACKAGE="@deeeed/metamask-harness"
37
+
38
+ COUNT_EXTENSION=2
39
+ COUNT_MOBILE=2
40
+ COUNT_CORE=2
41
+
42
+ BASE_DIR=""
43
+ BASE_SOURCE=""
44
+ SELECTED=()
45
+ SELECTION_SOURCE=""
46
+ DRY_RUN=0
47
+ FORCE=0
48
+ JSON_OUT=0
49
+ SKIP_HARNESS_UPDATE=0
50
+ SAW_ONLY=0
51
+ SAW_COUNTS=0
52
+ SHOW_CONFIG=0
53
+ RESET_CONFIG=0
54
+ RESULTS=()
55
+ FAILURES=0
56
+ EXIT_CODE="$EXIT_OK"
57
+ ERROR_CODE=""
58
+ ERROR_USER_ACTION=""
59
+
60
+ for arg in "$@"; do
61
+ [ "$arg" = "--json" ] && JSON_OUT=1
62
+ done
63
+
64
+ # --- config location ----------------------------------------------------------
65
+ # Same shape the harness already uses for persisted state: an explicit env
66
+ # override first, otherwise an XDG base directory plus an mm-harness/ folder.
67
+ config_file() {
68
+ if [ -n "${MM_HARNESS_SETUP_CONFIG:-}" ]; then
69
+ printf '%s' "$MM_HARNESS_SETUP_CONFIG"
70
+ return
71
+ fi
72
+ printf '%s/mm-harness/setup-base.json' "${XDG_CONFIG_HOME:-$HOME/.config}"
73
+ }
74
+
75
+ last_run_file() {
76
+ printf '%s/last-run.json' "$(dirname "$(config_file)")"
77
+ }
78
+
79
+ CONFIG_FILE="$(config_file)"
80
+
81
+ # --- UI -----------------------------------------------------------------------
82
+ # With --json, stdout carries the machine summary and nothing else, so every
83
+ # human line goes to stderr.
84
+ if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-}" != "dumb" ]; then
85
+ C_RESET=$'\033[0m'; C_BOLD=$'\033[1m'
86
+ C_RED=$'\033[31m'; C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'
87
+ C_BLUE=$'\033[34m'; C_CYAN=$'\033[36m'
88
+ else
89
+ C_RESET=; C_BOLD=; C_RED=; C_GREEN=; C_YELLOW=; C_BLUE=; C_CYAN=
90
+ fi
91
+
92
+ say() { printf '%s\n' "$*" >&2; }
93
+ ui_ok() { printf '%s[ OK ]%s %s\n' "$C_GREEN" "$C_RESET" "$*" >&2; }
94
+ ui_warn() { printf '%s[WARN]%s %s\n' "$C_YELLOW" "$C_RESET" "$*" >&2; }
95
+ ui_fail() { printf '%s[FAIL]%s %s\n' "$C_RED" "$C_RESET" "$*" >&2; }
96
+ ui_info() { printf '%s[INFO]%s %s\n' "$C_CYAN" "$C_RESET" "$*" >&2; }
97
+ ui_section() { printf '\n%s== %s ==%s\n' "$C_BOLD$C_BLUE" "$*" "$C_RESET" >&2; }
98
+ ui_next() { printf ' %sNext:%s %s\n' "$C_BOLD" "$C_RESET" "$*" >&2; }
99
+
100
+ write_summary() {
101
+ local target="$1" code="$2" message="$3" rows="" generator idx
102
+ for ((idx = 0; idx < ${#RESULTS[@]}; idx++)); do
103
+ rows="$rows${RESULTS[$idx]}"$'\n'
104
+ done
105
+ generator='
106
+ const repos = (process.env.MM_ROWS || "").split("\n").filter(Boolean).map((line) => {
107
+ const [directory, clone, install] = line.split("\t");
108
+ return { directory, clone, install };
109
+ });
110
+ process.stdout.write(JSON.stringify({
111
+ schemaVersion: 1,
112
+ command: "setup-base",
113
+ baseDir: process.env.MM_BASE || null,
114
+ configFile: process.env.MM_CONFIG,
115
+ exitCode: Number(process.env.MM_CODE),
116
+ status: Number(process.env.MM_CODE) === 0 ? "ok" : "fail",
117
+ message: process.env.MM_MESSAGE || undefined,
118
+ error: process.env.MM_ERROR_CODE ? {
119
+ code: process.env.MM_ERROR_CODE,
120
+ message: process.env.MM_MESSAGE,
121
+ userAction: process.env.MM_ERROR_USER_ACTION,
122
+ } : undefined,
123
+ repos,
124
+ finishedAt: new Date().toISOString(),
125
+ }, null, 2) + "\n");
126
+ '
127
+ if [ "$target" = "-" ]; then
128
+ MM_ROWS="$rows" MM_BASE="$BASE_DIR" MM_CONFIG="$CONFIG_FILE" \
129
+ MM_CODE="$code" MM_MESSAGE="$message" MM_ERROR_CODE="$ERROR_CODE" \
130
+ MM_ERROR_USER_ACTION="$ERROR_USER_ACTION" "$NODE_BIN" -e "$generator"
131
+ return
132
+ fi
133
+ MM_ROWS="$rows" MM_BASE="$BASE_DIR" MM_CONFIG="$CONFIG_FILE" \
134
+ MM_CODE="$code" MM_MESSAGE="$message" MM_ERROR_CODE="$ERROR_CODE" \
135
+ MM_ERROR_USER_ACTION="$ERROR_USER_ACTION" "$NODE_BIN" -e "$generator" \
136
+ | "$NODE_BIN" "$PRIVATE_WRITER" "$target"
137
+ }
138
+
139
+ finish() {
140
+ local code="$1" message="${2:-}"
141
+ EXIT_CODE="$code"
142
+ local last_run
143
+ last_run="$(last_run_file)"
144
+ write_summary "$last_run" "$code" "$message" 2>/dev/null \
145
+ || ui_warn "could not write the run summary to $last_run"
146
+ if [ "$JSON_OUT" = 1 ]; then
147
+ write_summary - "$code" "$message"
148
+ fi
149
+ exit "$code"
150
+ }
151
+
152
+ # die <exit-code> <message> <next-command...> — no failure is allowed to leave
153
+ # the caller without a concrete command to run next.
154
+ die() {
155
+ local code="$1" msg="$2"; shift 2
156
+ if [ "$code" = "$EXIT_USAGE" ]; then
157
+ case "$msg" in
158
+ "unknown option "*) ERROR_CODE="CLI_UNKNOWN_OPTION" ;;
159
+ *" needs a "*) ERROR_CODE="CLI_MISSING_OPTION_VALUE" ;;
160
+ *) ERROR_CODE="CLI_INVALID_OPTION_VALUE" ;;
161
+ esac
162
+ ERROR_USER_ACTION="${1:-mm-harness setup-base --help}"
163
+ fi
164
+ if [ "$JSON_OUT" != 1 ] || [ "$code" != "$EXIT_USAGE" ]; then
165
+ ui_fail "$msg"
166
+ local next
167
+ for next in "$@"; do ui_next "$next"; done
168
+ fi
169
+ finish "$code" "$msg"
170
+ }
171
+
172
+ # --- numbered steps -----------------------------------------------------------
173
+ STEP_N=0
174
+ STEP_TOTAL=0
175
+ step() {
176
+ STEP_N=$((STEP_N + 1))
177
+ printf '\n%s[%s/%s]%s %s%s%s\n' \
178
+ "$C_CYAN" "$STEP_N" "$STEP_TOTAL" "$C_RESET" "$C_BOLD" "$1" "$C_RESET" >&2
179
+ }
180
+
181
+ usage() {
182
+ if [ "$JSON_OUT" = 1 ]; then
183
+ cat >&2 <<USAGE
184
+ mm-harness setup-base — bootstrap the shared default MetaMask checkout layout.
185
+
186
+ Usage:
187
+ mm-harness setup-base [--dir <base>] [--counts <spec>] [--only <repos>]
188
+ [--dry-run] [--force] [--json] [--show-config]
189
+ [--reset-config] [--skip-harness-update] [--help]
190
+ USAGE
191
+ return
192
+ fi
193
+ cat <<USAGE
194
+ mm-harness setup-base — bootstrap the shared default MetaMask checkout layout.
195
+
196
+ Clones the MetaMask product repos in several copies into one standard folder
197
+ layout and runs each repo's own dependency install. Nothing else.
198
+
199
+ Usage:
200
+ mm-harness setup-base [--dir <base>] [--counts <spec>] [--only <repos>]
201
+ [--dry-run] [--force] [--json] [--show-config]
202
+ [--reset-config] [--skip-harness-update] [--help]
203
+
204
+ Flags:
205
+ --dir <base> Where the clones go.
206
+ --counts <spec> Copies per repo, e.g. extension=2,mobile=2,core=2 (min 1).
207
+ --only <repos> Comma-separated subset of: extension,mobile,core.
208
+ --dry-run Print the plan and disk estimate; change nothing.
209
+ --force Proceed despite an insufficient-disk estimate.
210
+ --json Emit the run summary as JSON on stdout (human output
211
+ goes to stderr). For scripted/parent-tool use.
212
+ --show-config Print the saved preferences and exit.
213
+ --reset-config Delete the saved preferences and exit.
214
+ --skip-harness-update Do not check whether $HARNESS_PACKAGE is current.
215
+ -h, --help Show this help and exit.
216
+
217
+ Layout produced under <base>:
218
+ metamask-extension-1..N metamask-mobile-1..N core-1..N
219
+
220
+ Base directory precedence (first match wins):
221
+ 1. --dir <base>
222
+ 2. \$MM_HARNESS_BASE_DIR
223
+ 3. saved preferences file
224
+ 4. $DEFAULT_BASE
225
+
226
+ Saved preferences:
227
+ Path: $(config_file)
228
+ (override with \$MM_HARNESS_SETUP_CONFIG)
229
+ Format: JSON — { "schemaVersion": 1, "baseDir": "<path>",
230
+ "counts": { "extension": N, "mobile": N, "core": N },
231
+ "only": ["extension", ...], "updatedAt": "<iso-8601>" }
232
+ A successful run saves the base dir and counts it used; later runs adopt them
233
+ as defaults. Inspect with --show-config, clear with --reset-config.
234
+
235
+ The last run's summary is written beside it as:
236
+ $(last_run_file)
237
+
238
+ Selecting what to install:
239
+ With no --only/--counts and no saved preferences, an interactive prompt asks
240
+ which projects and how many copies. Passing --only or --counts skips the
241
+ prompt entirely, which is how non-interactive and parent-tool runs should
242
+ invoke this.
243
+
244
+ Exit codes:
245
+ 0 success · 1 a repo failed · 2 usage error · 3 environment or disk refusal
246
+
247
+ Scope: clone + dependency install only. Toolchains, simulators, .env files and
248
+ builds are out of scope — run \`mm-harness doctor\` for environment readiness.
249
+ USAGE
250
+ }
251
+
252
+ # --- repo table (case functions, not associative arrays: macOS ships bash 3.2) -
253
+ repo_slug() {
254
+ case "$1" in
255
+ extension) printf 'metamask-extension' ;;
256
+ mobile) printf 'metamask-mobile' ;;
257
+ core) printf 'core' ;;
258
+ esac
259
+ }
260
+
261
+ # Disk estimate per clone in GB, after clone + dependency install, rounded up
262
+ # from a measurement for headroom.
263
+ # extension measured 5.1G (1.3G .git + 3.1G node_modules)
264
+ # core measured 1.6G (257M .git + 1.1G node_modules)
265
+ # mobile estimated — .git alone measures 1.8G and a working checkout
266
+ # measures 6.3G, so 8 is deliberately conservative.
267
+ # Dependencies only; a native build adds more and is out of scope here.
268
+ repo_gb() {
269
+ case "$1" in
270
+ extension) printf '6' ;;
271
+ mobile) printf '8' ;;
272
+ core) printf '2' ;;
273
+ esac
274
+ }
275
+
276
+ count_for() {
277
+ case "$1" in
278
+ extension) printf '%s' "$COUNT_EXTENSION" ;;
279
+ mobile) printf '%s' "$COUNT_MOBILE" ;;
280
+ core) printf '%s' "$COUNT_CORE" ;;
281
+ esac
282
+ }
283
+
284
+ set_count() {
285
+ case "$1" in
286
+ extension) COUNT_EXTENSION="$2" ;;
287
+ mobile) COUNT_MOBILE="$2" ;;
288
+ core) COUNT_CORE="$2" ;;
289
+ esac
290
+ }
291
+
292
+ is_repo_key() {
293
+ local k
294
+ for k in "${REPO_KEYS[@]}"; do [ "$k" = "$1" ] && return 0; done
295
+ return 1
296
+ }
297
+
298
+ # --- argument parsing ---------------------------------------------------------
299
+ # Expand a leading ~/ that reached us quoted, so --dir "~/x" never creates a
300
+ # directory literally named "~".
301
+ expand_tilde() {
302
+ # The quoted ~/ below is the literal text being matched, not a path the shell
303
+ # should expand — expanding it is exactly this function's job.
304
+ # shellcheck disable=SC2088
305
+ case "$1" in
306
+ "~/"*) printf '%s/%s' "$HOME" "${1#"~/"}" ;;
307
+ "~") printf '%s' "$HOME" ;;
308
+ *) printf '%s' "$1" ;;
309
+ esac
310
+ }
311
+
312
+ parse_counts() {
313
+ local spec="$1" pair key value
314
+ local -a pairs=()
315
+ IFS=',' read -r -a pairs <<< "$spec"
316
+ [ "${#pairs[@]}" -gt 0 ] || die "$EXIT_USAGE" "--counts got an empty value" \
317
+ "mm-harness setup-base --counts extension=2,mobile=2,core=2"
318
+ for pair in "${pairs[@]}"; do
319
+ case "$pair" in
320
+ *=*) ;;
321
+ *) die "$EXIT_USAGE" "--counts entry '$pair' is not <repo>=<n>" \
322
+ "mm-harness setup-base --counts extension=2,mobile=2,core=2" ;;
323
+ esac
324
+ key="${pair%%=*}"
325
+ value="${pair#*=}"
326
+ is_repo_key "$key" || die "$EXIT_USAGE" "--counts names unknown repo '$key'" \
327
+ "mm-harness setup-base --counts extension=2,mobile=2,core=2 # valid: ${REPO_KEYS[*]}"
328
+ case "$value" in
329
+ "" | *[!0-9]*) die "$EXIT_USAGE" "--counts value for '$key' is not a whole number: '$value'" \
330
+ "mm-harness setup-base --counts $key=2" ;;
331
+ esac
332
+ [ "$value" -ge 1 ] || die "$EXIT_USAGE" "--counts value for '$key' must be at least 1 (got $value)" \
333
+ "mm-harness setup-base --only $key # or drop '$key' from --only to skip it"
334
+ set_count "$key" "$value"
335
+ done
336
+ }
337
+
338
+ parse_only() {
339
+ local spec="$1" key
340
+ local -a keys=()
341
+ IFS=',' read -r -a keys <<< "$spec"
342
+ [ "${#keys[@]}" -gt 0 ] || die "$EXIT_USAGE" "--only got an empty value" \
343
+ "mm-harness setup-base --only extension,core"
344
+ for key in "${keys[@]}"; do
345
+ is_repo_key "$key" || die "$EXIT_USAGE" "--only names unknown repo '$key'" \
346
+ "mm-harness setup-base --only extension,core # valid: ${REPO_KEYS[*]}"
347
+ done
348
+ SELECTED=("${keys[@]}")
349
+ }
350
+
351
+ require_value() {
352
+ local flag="$1" value="${2:-}" example="$3"
353
+ case "$value" in
354
+ "" | -*) die "$EXIT_USAGE" "$flag needs a value" "$example" ;;
355
+ esac
356
+ }
357
+
358
+ FLAG_DIR=""
359
+ while [ "$#" -gt 0 ]; do
360
+ case "$1" in
361
+ --dir)
362
+ require_value "--dir" "${2:-}" "mm-harness setup-base --dir ~/dev/metamask"
363
+ FLAG_DIR="$(expand_tilde "$2")"; shift 2 ;;
364
+ --dir=*)
365
+ require_value "--dir" "${1#*=}" "mm-harness setup-base --dir ~/dev/metamask"
366
+ FLAG_DIR="$(expand_tilde "${1#*=}")"; shift ;;
367
+ --counts)
368
+ require_value "--counts" "${2:-}" \
369
+ "mm-harness setup-base --counts extension=2,mobile=2,core=2"
370
+ parse_counts "$2"; SAW_COUNTS=1; shift 2 ;;
371
+ --counts=*)
372
+ require_value "--counts" "${1#*=}" \
373
+ "mm-harness setup-base --counts extension=2,mobile=2,core=2"
374
+ parse_counts "${1#*=}"; SAW_COUNTS=1; shift ;;
375
+ --only)
376
+ require_value "--only" "${2:-}" "mm-harness setup-base --only extension,core"
377
+ parse_only "$2"; SAW_ONLY=1; shift 2 ;;
378
+ --only=*)
379
+ require_value "--only" "${1#*=}" "mm-harness setup-base --only extension,core"
380
+ parse_only "${1#*=}"; SAW_ONLY=1; shift ;;
381
+ --dry-run) DRY_RUN=1; shift ;;
382
+ --force) FORCE=1; shift ;;
383
+ --json) JSON_OUT=1; shift ;;
384
+ --show-config) SHOW_CONFIG=1; shift ;;
385
+ --reset-config) RESET_CONFIG=1; shift ;;
386
+ --skip-harness-update) SKIP_HARNESS_UPDATE=1; shift ;;
387
+ -h | --help) usage; finish "$EXIT_OK" "help" ;;
388
+ *) die "$EXIT_USAGE" "unknown option '$1'" "mm-harness setup-base --help" ;;
389
+ esac
390
+ done
391
+
392
+ # --- saved preferences --------------------------------------------------------
393
+ # node owns the JSON so quoting and escaping are correct in both directions. It
394
+ # ships with the package that ships this script, so it is always present; a
395
+ # missing node is caught by the prerequisite step with a teaching error.
396
+ config_get() {
397
+ [ -f "$CONFIG_FILE" ] && [ ! -L "$CONFIG_FILE" ] || return 1
398
+ "$NODE_BIN" -e '
399
+ const fs = require("fs");
400
+ try {
401
+ const c = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
402
+ const key = process.argv[2];
403
+ if (key === "baseDir") { if (typeof c.baseDir === "string") process.stdout.write(c.baseDir); }
404
+ else if (key === "only") { if (Array.isArray(c.only)) process.stdout.write(c.only.join(",")); }
405
+ else if (typeof c.counts?.[key] === "number") process.stdout.write(String(c.counts[key]));
406
+ } catch { process.exit(1); }
407
+ ' "$CONFIG_FILE" "$1" 2>/dev/null
408
+ }
409
+
410
+ config_save() {
411
+ MM_BASE="$BASE_DIR" MM_ONLY="$(IFS=,; printf '%s' "${SELECTED[*]}")" \
412
+ MM_EXT="$COUNT_EXTENSION" MM_MOB="$COUNT_MOBILE" MM_CORE="$COUNT_CORE" \
413
+ "$NODE_BIN" -e '
414
+ process.stdout.write(JSON.stringify({
415
+ schemaVersion: 1,
416
+ baseDir: process.env.MM_BASE,
417
+ counts: {
418
+ extension: Number(process.env.MM_EXT),
419
+ mobile: Number(process.env.MM_MOB),
420
+ core: Number(process.env.MM_CORE),
421
+ },
422
+ only: process.env.MM_ONLY ? process.env.MM_ONLY.split(",") : [],
423
+ updatedAt: new Date().toISOString(),
424
+ }, null, 2) + "\n");
425
+ ' | "$NODE_BIN" "$PRIVATE_WRITER" "$CONFIG_FILE" 2>/dev/null \
426
+ || ui_warn "could not write $CONFIG_FILE — preferences not saved."
427
+ }
428
+
429
+ if [ "$SHOW_CONFIG" = 1 ]; then
430
+ [ ! -L "$CONFIG_FILE" ] || die "$EXIT_INFRA" "$CONFIG_FILE is a symbolic link" \
431
+ "mm-harness setup-base --reset-config"
432
+ if [ -f "$CONFIG_FILE" ]; then
433
+ say "$CONFIG_FILE"
434
+ cat "$CONFIG_FILE" >&2
435
+ else
436
+ ui_info "No saved preferences yet ($CONFIG_FILE)."
437
+ ui_next "mm-harness setup-base --only core --counts core=1 # a successful run saves them"
438
+ fi
439
+ finish "$EXIT_OK" "show-config"
440
+ fi
441
+
442
+ if [ "$RESET_CONFIG" = 1 ]; then
443
+ if [ -e "$CONFIG_FILE" ] || [ -L "$CONFIG_FILE" ]; then
444
+ rm -f "$CONFIG_FILE"
445
+ ui_ok "Removed $CONFIG_FILE"
446
+ else
447
+ ui_info "Nothing to reset ($CONFIG_FILE does not exist)."
448
+ fi
449
+ finish "$EXIT_OK" "reset-config"
450
+ fi
451
+
452
+ # --- resolve the base directory (flag > env > saved > default) ----------------
453
+ if [ -n "$FLAG_DIR" ]; then
454
+ BASE_DIR="$FLAG_DIR"; BASE_SOURCE="--dir"
455
+ elif [ -n "${MM_HARNESS_BASE_DIR:-}" ]; then
456
+ BASE_DIR="$(expand_tilde "$MM_HARNESS_BASE_DIR")"; BASE_SOURCE="\$MM_HARNESS_BASE_DIR"
457
+ elif SAVED_BASE="$(config_get baseDir)" && [ -n "$SAVED_BASE" ]; then
458
+ BASE_DIR="$SAVED_BASE"; BASE_SOURCE="saved preferences"
459
+ else
460
+ BASE_DIR="$DEFAULT_BASE"; BASE_SOURCE="default"
461
+ fi
462
+
463
+ # --- resolve the selection (flags > saved > interactive) ----------------------
464
+ HAVE_SAVED_SELECTION=0
465
+ if [ "$SAW_ONLY" = 0 ] && [ -f "$CONFIG_FILE" ]; then
466
+ if SAVED_ONLY="$(config_get only)" && [ -n "$SAVED_ONLY" ]; then
467
+ parse_only "$SAVED_ONLY"
468
+ HAVE_SAVED_SELECTION=1
469
+ fi
470
+ fi
471
+ if [ "$SAW_COUNTS" = 0 ] && [ -f "$CONFIG_FILE" ]; then
472
+ for key in "${REPO_KEYS[@]}"; do
473
+ if saved_count="$(config_get "$key")" && [ -n "$saved_count" ]; then
474
+ set_count "$key" "$saved_count"
475
+ HAVE_SAVED_SELECTION=1
476
+ fi
477
+ done
478
+ fi
479
+
480
+ if [ "$SAW_ONLY" = 1 ] || [ "$SAW_COUNTS" = 1 ]; then
481
+ SELECTION_SOURCE="flags"
482
+ [ "$SAW_ONLY" = 1 ] || SELECTED=("${REPO_KEYS[@]}")
483
+ elif [ "$HAVE_SAVED_SELECTION" = 1 ]; then
484
+ SELECTION_SOURCE="saved preferences"
485
+ [ "${#SELECTED[@]}" -gt 0 ] || SELECTED=("${REPO_KEYS[@]}")
486
+ else
487
+ SELECTION_SOURCE="interactive"
488
+ fi
489
+
490
+ # A prompt needs someone able to answer it. stdin being a terminal is the signal
491
+ # that distinguishes a human at a shell from a script, a pipe, or CI. Questions
492
+ # are asked on stderr and answered on stdin, so nothing here competes with the
493
+ # --json summary on stdout.
494
+ have_tty() { [ -t 0 ]; }
495
+
496
+ if [ "$SELECTION_SOURCE" = "interactive" ] && ! have_tty; then
497
+ die "$EXIT_USAGE" "no terminal available and nothing selected to set up" \
498
+ "mm-harness setup-base --only extension,mobile,core --counts extension=2,mobile=2,core=2" \
499
+ "mm-harness setup-base --only core --counts core=1 # a smaller start" \
500
+ "mm-harness setup-base --help # every flag"
501
+ fi
502
+
503
+ # --- step plan ----------------------------------------------------------------
504
+ STEP_TOTAL=4
505
+ [ "$SKIP_HARNESS_UPDATE" = 1 ] || STEP_TOTAL=$((STEP_TOTAL + 1))
506
+ [ "$SELECTION_SOURCE" = "interactive" ] && STEP_TOTAL=$((STEP_TOTAL + 1))
507
+
508
+ # --- step: prerequisites ------------------------------------------------------
509
+ step "Prerequisites"
510
+ command -v git >/dev/null 2>&1 || die "$EXIT_INFRA" "git is not installed — nothing can be cloned" \
511
+ "xcode-select --install # macOS" \
512
+ "brew install git"
513
+ ui_ok "git $(git --version 2>/dev/null | awk '{print $3}')"
514
+
515
+ [ -x "$NODE_BIN" ] || command -v "$NODE_BIN" >/dev/null 2>&1 \
516
+ || die "$EXIT_INFRA" "node is not installed" \
517
+ "brew install node" \
518
+ "asdf install nodejs latest # or: nvm install --lts"
519
+ ui_ok "node $("$NODE_BIN" -v 2>/dev/null)"
520
+
521
+ YARN_CMD=()
522
+ if command -v yarn >/dev/null 2>&1 && yarn --version >/dev/null 2>&1; then
523
+ YARN_CMD=(yarn)
524
+ ui_ok "yarn $(yarn --version 2>/dev/null)"
525
+ elif command -v corepack >/dev/null 2>&1; then
526
+ if corepack yarn --version >/dev/null 2>&1; then
527
+ YARN_CMD=(corepack yarn)
528
+ ui_ok "corepack yarn $(corepack yarn --version 2>/dev/null)"
529
+ else
530
+ die "$EXIT_INFRA" "corepack cannot run yarn" \
531
+ "corepack enable" \
532
+ "corepack prepare yarn@stable --activate"
533
+ fi
534
+ else
535
+ die "$EXIT_INFRA" "neither yarn nor a usable corepack is on PATH" \
536
+ "npm install -g corepack && corepack enable"
537
+ fi
538
+
539
+ # --- step: harness currency ---------------------------------------------------
540
+ # This script is the first thing a new machine runs, so it is also the natural
541
+ # place to make sure the tool it hands off to is present and current.
542
+ if [ "$SKIP_HARNESS_UPDATE" = 0 ]; then
543
+ step "Harness"
544
+ if command -v mm-harness >/dev/null 2>&1; then
545
+ ui_ok "mm-harness $(mm-harness --version 2>/dev/null | head -1)"
546
+ ui_info "Update it any time with: npm install -g $HARNESS_PACKAGE@latest"
547
+ else
548
+ ui_warn "mm-harness is not on PATH."
549
+ ui_next "npm install -g $HARNESS_PACKAGE@latest"
550
+ say " (this script still works without it; you need it for the next step,"
551
+ say " \`mm-harness doctor\`.)"
552
+ fi
553
+ fi
554
+
555
+ # --- step: selection ----------------------------------------------------------
556
+ if [ "$SELECTION_SOURCE" = "interactive" ]; then
557
+ step "What to set up"
558
+ say "Which projects do you want? Enter numbers separated by commas."
559
+ say " 1) extension 2) mobile 3) core"
560
+ printf '%sProjects [default: all]:%s ' "$C_BOLD" "$C_RESET" >&2
561
+ reply=""; read -r reply || reply=""
562
+ if [ -z "$reply" ]; then
563
+ SELECTED=("${REPO_KEYS[@]}")
564
+ else
565
+ chosen=()
566
+ IFS=',' read -r -a picks <<< "$reply"
567
+ for pick in "${picks[@]}"; do
568
+ pick="$(printf '%s' "$pick" | tr -d '[:space:]')"
569
+ case "$pick" in
570
+ 1 | extension) chosen+=(extension) ;;
571
+ 2 | mobile) chosen+=(mobile) ;;
572
+ 3 | core) chosen+=(core) ;;
573
+ "") ;;
574
+ *) die "$EXIT_USAGE" "'$pick' is not one of 1, 2, or 3" \
575
+ "mm-harness setup-base --only extension,core # skip the prompt entirely" ;;
576
+ esac
577
+ done
578
+ [ "${#chosen[@]}" -gt 0 ] || die "$EXIT_USAGE" "nothing selected" \
579
+ "mm-harness setup-base --only extension,mobile,core"
580
+ SELECTED=("${chosen[@]}")
581
+ fi
582
+ for key in "${SELECTED[@]}"; do
583
+ printf '%sHow many copies of %s? [%s]:%s ' "$C_BOLD" "$key" "$(count_for "$key")" "$C_RESET" >&2
584
+ reply=""; read -r reply || reply=""
585
+ if [ -n "$reply" ]; then
586
+ case "$reply" in
587
+ "" | *[!0-9]*) die "$EXIT_USAGE" "'$reply' is not a whole number" \
588
+ "mm-harness setup-base --counts $key=2" ;;
589
+ esac
590
+ [ "$reply" -ge 1 ] || die "$EXIT_USAGE" "copies for '$key' must be at least 1" \
591
+ "mm-harness setup-base --only $key --counts $key=1"
592
+ set_count "$key" "$reply"
593
+ fi
594
+ done
595
+ fi
596
+
597
+ # --- step: plan + disk sanity -------------------------------------------------
598
+ step "Plan"
599
+ say " Base directory: $BASE_DIR ($BASE_SOURCE)"
600
+ [ "$SELECTION_SOURCE" = "saved preferences" ] \
601
+ && ui_info "Using saved preferences — override with --dir/--only/--counts, inspect with --show-config."
602
+
603
+ PLAN_DIRS=()
604
+ PLAN_KEYS=()
605
+ NEW_CLONES=0
606
+ EST_GB=0
607
+ for key in "${SELECTED[@]}"; do
608
+ slug="$(repo_slug "$key")"
609
+ count="$(count_for "$key")"
610
+ for i in $(seq 1 "$count"); do
611
+ dest="$BASE_DIR/$slug-$i"
612
+ PLAN_KEYS+=("$key")
613
+ PLAN_DIRS+=("$dest")
614
+ if [ ! -e "$dest" ]; then
615
+ NEW_CLONES=$((NEW_CLONES + 1))
616
+ EST_GB=$((EST_GB + $(repo_gb "$key")))
617
+ fi
618
+ done
619
+ say " $(printf '%-10s' "$key") $count copies -> $BASE_DIR/$slug-1..$count"
620
+ done
621
+
622
+ [ "${#PLAN_DIRS[@]}" -gt 0 ] || die "$EXIT_USAGE" "nothing selected to set up" \
623
+ "mm-harness setup-base --only extension,mobile,core"
624
+
625
+ say " $NEW_CLONES of ${#PLAN_DIRS[@]} target directories need a fresh clone (~${EST_GB}GB)."
626
+
627
+ probe="$BASE_DIR"
628
+ while [ ! -d "$probe" ] && [ "$probe" != "/" ]; do probe="$(dirname "$probe")"; done
629
+ avail_kb="$(df -Pk "$probe" 2>/dev/null | awk 'NR==2 {print $4}')"
630
+ if [ "$EST_GB" -eq 0 ]; then
631
+ ui_info "Every target directory already exists — no new clones, so no disk check needed."
632
+ elif [ -z "${avail_kb:-}" ]; then
633
+ ui_warn "Could not read free space for $probe — skipping the disk check."
634
+ else
635
+ avail_gb=$((avail_kb / 1024 / 1024))
636
+ say " Free space on $probe: ${avail_gb}GB"
637
+ if [ "$avail_gb" -lt "$EST_GB" ]; then
638
+ if [ "$FORCE" = 1 ]; then
639
+ ui_warn "Only ${avail_gb}GB free for an estimated ${EST_GB}GB — continuing because --force was given."
640
+ else
641
+ die "$EXIT_INFRA" "not enough disk space: ~${EST_GB}GB needed, ${avail_gb}GB free on $probe" \
642
+ "mm-harness setup-base --counts extension=1,mobile=1,core=1 # fewer copies" \
643
+ "mm-harness setup-base --only core # smaller subset" \
644
+ "mm-harness setup-base --force # override this check"
645
+ fi
646
+ fi
647
+ fi
648
+
649
+ if [ "$DRY_RUN" = 1 ]; then
650
+ ui_section "Dry run"
651
+ for idx in "${!PLAN_DIRS[@]}"; do
652
+ dest="${PLAN_DIRS[$idx]}"
653
+ slug="$(repo_slug "${PLAN_KEYS[$idx]}")"
654
+ if [ -e "$dest" ]; then
655
+ say " fetch + install $dest"
656
+ RESULTS+=("$(basename "$dest") planned-existing planned")
657
+ else
658
+ say " clone + install $dest (git@github.com:MetaMask/$slug.git)"
659
+ RESULTS+=("$(basename "$dest") planned-clone planned")
660
+ fi
661
+ done
662
+ ui_info "Dry run — nothing was created, fetched, or installed."
663
+ finish "$EXIT_OK" "dry-run"
664
+ fi
665
+
666
+ # --- step: clone + install ----------------------------------------------------
667
+ step "Clone + install"
668
+ mkdir -p "$BASE_DIR" 2>/dev/null || die "$EXIT_INFRA" "cannot create $BASE_DIR" \
669
+ "mkdir -p '$BASE_DIR' # then re-run, or pass a writable --dir"
670
+ [ -w "$BASE_DIR" ] || die "$EXIT_INFRA" "$BASE_DIR is not writable" \
671
+ "mm-harness setup-base --dir \"\$HOME/dev/metamask\""
672
+
673
+ # clone_repo <slug> <dest> — clone into private staging, then publish atomically.
674
+ clone_repo() {
675
+ local slug="$1" dest="$2" staging candidate
676
+ staging="$(mktemp -d "$BASE_DIR/.mm-harness-clone.XXXXXX")" || return 1
677
+ chmod 700 "$staging"
678
+ candidate="$staging/ssh"
679
+ if ! git clone "git@github.com:MetaMask/$slug.git" "$candidate" >&2; then
680
+ ui_warn "SSH clone of $slug failed — retrying over HTTPS."
681
+ candidate="$staging/https"
682
+ if ! git clone "https://github.com/MetaMask/$slug.git" "$candidate" >&2; then
683
+ rm -rf "$staging"
684
+ return 1
685
+ fi
686
+ fi
687
+
688
+ if ! "$NODE_BIN" -e '
689
+ const fs = require("fs");
690
+ const source = process.argv[1];
691
+ const destination = process.argv[2];
692
+ const sourceMode = fs.statSync(source).mode & 0o777;
693
+ let reservation;
694
+
695
+ try {
696
+ fs.mkdirSync(destination, { mode: sourceMode });
697
+ reservation = fs.lstatSync(destination);
698
+ } catch (error) {
699
+ if (error && error.code === "EEXIST") process.exit(2);
700
+ throw error;
701
+ }
702
+
703
+ try {
704
+ const current = fs.lstatSync(destination);
705
+ if (current.dev !== reservation.dev || current.ino !== reservation.ino) {
706
+ throw new Error("destination reservation changed before publication");
707
+ }
708
+ fs.renameSync(source, destination);
709
+ } catch (error) {
710
+ try {
711
+ const current = fs.lstatSync(destination);
712
+ if (current.dev === reservation.dev && current.ino === reservation.ino) {
713
+ fs.rmdirSync(destination);
714
+ }
715
+ } catch {}
716
+ throw error;
717
+ }
718
+ ' "$candidate" "$dest" 2>/dev/null; then
719
+ ui_fail "$(basename "$dest") appeared while cloning — left untouched."
720
+ rm -rf "$staging"
721
+ return 1
722
+ fi
723
+ rm -rf "$staging"
724
+ }
725
+
726
+ origin_matches() {
727
+ local slug="$1" origin="$2"
728
+ case "$origin" in
729
+ "git@github.com:MetaMask/$slug.git" | \
730
+ "ssh://git@github.com/MetaMask/$slug.git" | \
731
+ "https://github.com/MetaMask/$slug.git" | \
732
+ "https://github.com/MetaMask/$slug") return 0 ;;
733
+ *) return 1 ;;
734
+ esac
735
+ }
736
+
737
+ # install_deps <dest> — run the repo's own dependency install, following the
738
+ # lockfile it ships. Dependencies only.
739
+ install_deps() {
740
+ local dest="$1"
741
+ if [ -f "$dest/yarn.lock" ]; then
742
+ (cd "$dest" && "${YARN_CMD[@]}" install --immutable >&2)
743
+ elif [ -f "$dest/package-lock.json" ]; then
744
+ (cd "$dest" && npm ci >&2)
745
+ elif [ -f "$dest/pnpm-lock.yaml" ]; then
746
+ (cd "$dest" && pnpm install --frozen-lockfile >&2)
747
+ elif [ -f "$dest/package.json" ]; then
748
+ ui_warn "$(basename "$dest"): no lockfile — skipping install."
749
+ else
750
+ ui_warn "$(basename "$dest"): no package.json — skipping install."
751
+ fi
752
+ }
753
+
754
+ for idx in "${!PLAN_DIRS[@]}"; do
755
+ dest="${PLAN_DIRS[$idx]}"
756
+ key="${PLAN_KEYS[$idx]}"
757
+ slug="$(repo_slug "$key")"
758
+ name="$(basename "$dest")"
759
+ clone_state=""
760
+ install_state=""
761
+
762
+ if [ -L "$dest" ]; then
763
+ ui_fail "$name is a symbolic link — left untouched."
764
+ ui_next "move it aside, then re-run: mv $dest $dest.bak"
765
+ RESULTS+=("$name"$'\t'"blocked"$'\t'"skipped")
766
+ FAILURES=$((FAILURES + 1))
767
+ continue
768
+ elif [ -e "$dest" ]; then
769
+ if [ ! -d "$dest/.git" ]; then
770
+ ui_fail "$name exists but is not a git checkout — left untouched."
771
+ ui_next "move it aside, then re-run: mv $dest $dest.bak"
772
+ RESULTS+=("$name blocked skipped")
773
+ FAILURES=$((FAILURES + 1))
774
+ continue
775
+ fi
776
+ origin_url="$(git -C "$dest" remote get-url origin 2>/dev/null || true)"
777
+ if ! origin_matches "$slug" "$origin_url"; then
778
+ ui_fail "$name has origin '$origin_url', expected MetaMask/$slug — left untouched."
779
+ ui_next "point this run elsewhere: mm-harness setup-base --dir <other-base>"
780
+ RESULTS+=("$name wrong-remote skipped")
781
+ FAILURES=$((FAILURES + 1))
782
+ continue
783
+ fi
784
+ ui_info "$name already exists — fetching (no reset, no checkout change)."
785
+ if git -C "$dest" fetch --prune origin >&2; then
786
+ clone_state="existing"
787
+ else
788
+ ui_warn "$name: fetch failed — continuing with the local state."
789
+ ui_next "git -C $dest fetch --prune origin"
790
+ clone_state="existing (stale)"
791
+ fi
792
+ else
793
+ ui_info "Cloning MetaMask/$slug into $name"
794
+ if clone_repo "$slug" "$dest"; then
795
+ clone_state="cloned"
796
+ else
797
+ ui_fail "could not clone MetaMask/$slug over SSH or HTTPS."
798
+ ui_next "ssh -T git@github.com # verify your SSH key"
799
+ ui_next "gh auth login # or authenticate for HTTPS"
800
+ RESULTS+=("$name failed skipped")
801
+ FAILURES=$((FAILURES + 1))
802
+ continue
803
+ fi
804
+ fi
805
+
806
+ ui_info "$name: installing dependencies"
807
+ if install_deps "$dest"; then
808
+ install_state="installed"
809
+ ui_ok "$name ready"
810
+ else
811
+ install_state="failed"
812
+ FAILURES=$((FAILURES + 1))
813
+ ui_fail "$name: dependency install failed."
814
+ # A wrong Node version is by far the most common cause, and these repos pin
815
+ # the version they want in .nvmrc. Note the version your package manager
816
+ # runs is what counts: a version manager can hand yarn a different Node than
817
+ # this shell resolves, so the pin is named but the current version is not.
818
+ if [ -f "$dest/.nvmrc" ]; then
819
+ pinned="$(tr -d '[:space:]' < "$dest/.nvmrc")"
820
+ ui_next "$name pins Node $pinned (.nvmrc) — switch to it, then re-run"
821
+ ui_next "nvm use $pinned # or: asdf set nodejs ${pinned#v}"
822
+ fi
823
+ ui_next "cd $dest && yarn install # re-run to see the full error"
824
+ fi
825
+
826
+ RESULTS+=("$name $clone_state $install_state")
827
+ done
828
+
829
+ # --- step: summary ------------------------------------------------------------
830
+ step "Summary"
831
+ printf ' %-28s %-16s %s\n' 'Directory' 'Clone' 'Install' >&2
832
+ for row in "${RESULTS[@]}"; do
833
+ IFS=$'\t' read -r r_name r_clone r_install <<< "$row"
834
+ printf ' %-28s %-16s %s\n' "$r_name" "$r_clone" "$r_install" >&2
835
+ done
836
+ say ""
837
+ say " Base: $BASE_DIR"
838
+
839
+ EXIT_CODE="$EXIT_OK"
840
+ if [ "$FAILURES" -gt 0 ]; then
841
+ EXIT_CODE="$EXIT_RUNTIME"
842
+ ui_warn "$FAILURES of ${#PLAN_DIRS[@]} directories need attention (see the Next: lines above)."
843
+ else
844
+ ui_ok "All ${#PLAN_DIRS[@]} checkouts are cloned and installed."
845
+ fi
846
+
847
+ # Preferences persist only after a clean run, so a half-broken setup is never
848
+ # adopted as the new default.
849
+ if [ "$FAILURES" -eq 0 ]; then
850
+ config_save
851
+ ui_info "Saved preferences to $CONFIG_FILE (--show-config to inspect, --reset-config to clear)."
852
+ fi
853
+
854
+ {
855
+ printf '\n'
856
+ printf 'This script does clones and dependency installs only. It does NOT install\n'
857
+ printf 'platform toolchains, create simulators, write .env files, or run builds.\n'
858
+ printf '\n'
859
+ printf 'Next, check whether this machine can build and run what you just cloned:\n'
860
+ printf '\n'
861
+ printf ' mm-harness doctor\n'
862
+ } >&2
863
+
864
+ finish "$EXIT_CODE"