@techierathore/techieflow 1.0.4 → 1.1.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 (148) hide show
  1. package/.claude/commands/TechieFlow/agents/flow-master.md +2 -0
  2. package/.claude/commands/TechieFlow/agents/verifier.md +2 -2
  3. package/.claude/commands/TechieFlow/tasks/_develop-mode.md +34 -0
  4. package/.claude/commands/TechieFlow/tasks/_owner-language.md +1 -0
  5. package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +4 -3
  6. package/.claude/commands/TechieFlow/tasks/_yolo-mode.md +2 -2
  7. package/.claude/commands/TechieFlow/tasks/amend-docs.md +2 -2
  8. package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +3 -3
  9. package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +5 -5
  10. package/.claude/commands/TechieFlow/tasks/develop-end-to-end.md +30 -0
  11. package/.claude/commands/TechieFlow/tasks/fix-issues.md +3 -2
  12. package/.claude/commands/TechieFlow/tasks/generate-html.md +2 -2
  13. package/.claude/commands/TechieFlow/tasks/handoff-phase.md +2 -2
  14. package/.claude/commands/TechieFlow/tasks/mockups.md +2 -2
  15. package/.claude/commands/TechieFlow/tasks/productguide.md +2 -2
  16. package/.claude/commands/TechieFlow/tasks/render-workflow-docs.md +1 -1
  17. package/.claude/commands/TechieFlow/tasks/triage-and-fix.md +2 -2
  18. package/.claude/commands/TechieFlow/tasks/triage-issues.md +2 -2
  19. package/.claude/commands/TechieFlow/tasks/verify-phase.md +12 -11
  20. package/.opencode/plugin/techieflow.js +194 -14
  21. package/.tfcore/agents/flow-master.md +2 -0
  22. package/.tfcore/agents/verifier.md +2 -2
  23. package/.tfcore/core-config.yaml +2 -1
  24. package/.tfcore/hooks/block-git.sh +4 -2
  25. package/.tfcore/hooks/guard-build.sh +5 -1
  26. package/.tfcore/hooks/guard-db.sh +112 -13
  27. package/.tfcore/hooks/guard-status-html.sh +86 -2
  28. package/.tfcore/hooks/guard-verify-deps.sh +16 -1
  29. package/.tfcore/hooks/guard-verify.sh +8 -3
  30. package/.tfcore/hooks/sweep-artifacts.sh +46 -0
  31. package/.tfcore/hooks/yolo-allow.sh +27 -0
  32. package/.tfcore/standards/blazor-zoom.cs.txt +191 -0
  33. package/.tfcore/standards/coding-standards-dotnet.md +31 -3
  34. package/.tfcore/standards/owner-words.txt +23 -0
  35. package/.tfcore/tasks/_develop-mode.md +34 -0
  36. package/.tfcore/tasks/_owner-language.md +1 -0
  37. package/.tfcore/tasks/_status-update-gate.md +4 -3
  38. package/.tfcore/tasks/_yolo-mode.md +2 -2
  39. package/.tfcore/tasks/amend-docs.md +2 -2
  40. package/.tfcore/tasks/day1-brownfield.md +3 -3
  41. package/.tfcore/tasks/day1-greenfield.md +5 -5
  42. package/.tfcore/tasks/develop-end-to-end.md +30 -0
  43. package/.tfcore/tasks/fix-issues.md +3 -2
  44. package/.tfcore/tasks/generate-html.md +2 -2
  45. package/.tfcore/tasks/handoff-phase.md +2 -2
  46. package/.tfcore/tasks/mockups.md +2 -2
  47. package/.tfcore/tasks/productguide.md +2 -2
  48. package/.tfcore/tasks/render-workflow-docs.md +1 -1
  49. package/.tfcore/tasks/triage-and-fix.md +2 -2
  50. package/.tfcore/tasks/triage-issues.md +2 -2
  51. package/.tfcore/tasks/verify-phase.md +12 -11
  52. package/.tfcore/telemetry/SCHEMA.md +10 -2
  53. package/.tfcore/telemetry/install-metrics.sh +3 -2
  54. package/.tfcore/telemetry/tf-metrics.sh +38 -2
  55. package/.tfcore/templates/stack-defaults/dotnet.md +3 -1
  56. package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +6 -0
  57. package/.tfcore/templates/v4custom/app-architecture-tmpl.md +5 -0
  58. package/.tfcore/templates/v4custom/app-brd-tmpl.md +7 -4
  59. package/.tfcore/templates/v4custom/app-checklist-tmpl.md +11 -0
  60. package/.tfcore/templates/v4custom/app-phase-brd-tmpl.md +94 -0
  61. package/.tfcore/templates/v4custom/app-phase-uidesign-tmpl.md +60 -0
  62. package/.tfcore/templates/v4custom/app-phases-tmpl.md +2 -1
  63. package/.tfcore/templates/v4custom/app-uidesign-tmpl.md +4 -2
  64. package/.tfcore/templates/v4custom/build-subagent-prompt.md +2 -2
  65. package/.tfcore/templates/v4custom/html-render-shell.md +6 -2
  66. package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
  67. package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
  68. package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
  69. package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
  70. package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
  71. package/.tfcore/utils/tf-appium.mjs +179 -0
  72. package/.tfcore/utils/tf-assets-browser.mjs +96 -0
  73. package/.tfcore/utils/tf-assets.sh +97 -12
  74. package/.tfcore/utils/tf-brd-status.py +14 -5
  75. package/.tfcore/utils/tf-build-list.py +95 -12
  76. package/.tfcore/utils/tf-build-list.sh +2 -1
  77. package/.tfcore/utils/tf-build.sh +163 -15
  78. package/.tfcore/utils/tf-checklist-edit.py +21 -3
  79. package/.tfcore/utils/tf-ci-repro.py +685 -0
  80. package/.tfcore/utils/tf-ci-repro.sh +24 -0
  81. package/.tfcore/utils/tf-day1-files.py +55 -28
  82. package/.tfcore/utils/tf-develop.sh +264 -0
  83. package/.tfcore/utils/tf-devguide-list.py +53 -24
  84. package/.tfcore/utils/tf-doc-check.py +218 -26
  85. package/.tfcore/utils/tf-doc-tests.sh +116 -0
  86. package/.tfcore/utils/tf-emit.sh +132 -45
  87. package/.tfcore/utils/tf-feedback.sh +13 -0
  88. package/.tfcore/utils/tf-fix-close.sh +87 -31
  89. package/.tfcore/utils/tf-goal.sh +12 -12
  90. package/.tfcore/utils/tf-harness.sh +9 -4
  91. package/.tfcore/utils/tf-lock.sh +29 -0
  92. package/.tfcore/utils/tf-log-miss.py +34 -8
  93. package/.tfcore/utils/tf-login.mjs +167 -0
  94. package/.tfcore/utils/tf-maccatalyst-check.sh +92 -0
  95. package/.tfcore/utils/tf-menus.mjs +401 -0
  96. package/.tfcore/utils/tf-misses-md.py +10 -4
  97. package/.tfcore/utils/tf-mockup-parity.mjs +763 -71
  98. package/.tfcore/utils/tf-mockup-parity.sh +25 -2
  99. package/.tfcore/utils/tf-owner-text.py +233 -0
  100. package/.tfcore/utils/tf-owner-text.sh +14 -0
  101. package/.tfcore/utils/tf-phase.sh +38 -4
  102. package/.tfcore/utils/tf-portable.sh +230 -0
  103. package/.tfcore/utils/tf-render-html.py +79 -5
  104. package/.tfcore/utils/tf-req-ids.py +36 -0
  105. package/.tfcore/utils/tf-selfcheck.sh +39 -6
  106. package/.tfcore/utils/tf-split-brd.py +11 -1
  107. package/.tfcore/utils/tf-stack-check.sh +3 -0
  108. package/.tfcore/utils/tf-status-evidence.sh +11 -9
  109. package/.tfcore/utils/tf-status-facts.py +160 -45
  110. package/.tfcore/utils/tf-theme.mjs +189 -0
  111. package/.tfcore/utils/tf-triage.py +74 -17
  112. package/.tfcore/utils/tf-triage.sh +2 -0
  113. package/.tfcore/utils/tf-verify-boot.sh +529 -54
  114. package/.tfcore/utils/tf-verify-emit.sh +57 -17
  115. package/.tfcore/utils/tf-verify-env.sh +33 -4
  116. package/.tfcore/utils/tf-verify-list.py +290 -24
  117. package/.tfcore/utils/tf-verify-list.sh +3 -1
  118. package/.tfcore/utils/tf-verify-native.mjs +307 -0
  119. package/.tfcore/utils/tf-verify-screens.mjs +341 -44
  120. package/.tfcore/utils/tf-verify-screens.sh +23 -5
  121. package/.tfcore/utils/tf-verify-tests.sh +310 -19
  122. package/.tfcore/utils/tf-verify-verdict.py +163 -21
  123. package/.tfcore/utils/tf-verify-zoom.mjs +193 -0
  124. package/.tfcore/utils/tf-verify-zoom.sh +29 -0
  125. package/.tfcore/utils/tf-yolo-alias.sh +84 -0
  126. package/.tfcore/utils/tf-yolo-perms.py +135 -0
  127. package/.tfcore/utils/tf-yolo-start.sh +44 -0
  128. package/.tfcore/utils/tf-yolo.sh +31 -3
  129. package/.tfcore/utils/tf_defect.py +27 -0
  130. package/.tfcore/utils/tf_feedback.py +272 -0
  131. package/.tfcore/utils/tf_roadmap.py +35 -0
  132. package/.tfcore/utils/tf_stack_check.py +93 -0
  133. package/README.md +6 -2
  134. package/docs/TechieFlow-Installation.md +5 -3
  135. package/opencode.jsonc +6 -2
  136. package/package.json +1 -1
  137. package/scripts/install.mjs +13 -2
  138. package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
  139. package/.tfcore/utils/__pycache__/tf-codex-bind.cpython-312.pyc +0 -0
  140. package/.tfcore/utils/__pycache__/tf-codex-telemetry.cpython-312.pyc +0 -0
  141. package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
  142. package/.tfcore/utils/__pycache__/tf-log-miss.cpython-312.pyc +0 -0
  143. package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
  144. package/.tfcore/utils/__pycache__/tf-mockups-locate.cpython-312.pyc +0 -0
  145. package/.tfcore/utils/__pycache__/tf-opencode-audit.cpython-312.pyc +0 -0
  146. package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
  147. package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
  148. package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
@@ -1,31 +1,54 @@
1
1
  #!/usr/bin/env bash
2
2
  # tf-verify-boot.sh — start the application for a verify, reach it, and stop it (Sitting 4c, 2026-09-06).
3
3
  #
4
- # bash .tfcore/utils/tf-verify-boot.sh start [--head web|windows|static] [--project <csproj>]
5
- # [--port N] [--config Release] [--static <dir>]
6
- # bash .tfcore/utils/tf-verify-boot.sh stop
7
- # bash .tfcore/utils/tf-verify-boot.sh status
4
+ # bash .tfcore/utils/tf-verify-boot.sh start [--head web|windows|static] [--project <csproj>] [--dry-run]
5
+ # [--port N] [--config Release] [--static <dir>] [--probe-path /healthz]
6
+ # [--environment Development] (web: ASPNETCORE_ENVIRONMENT)
7
+ # bash .tfcore/utils/tf-verify-boot.sh stop [--port N]
8
+ # bash .tfcore/utils/tf-verify-boot.sh status [--port N]
8
9
  #
9
10
  # Heads:
10
- # web a project on Microsoft.NET.Sdk.Web: started with `dotnet run --urls`, first through
11
- # the rungs tf-build.sh knows (dotnet, ~/.dotnet/dotnet, then Windows-side cmd.exe on
12
- # WSL), polled until it answers. Prints BOOTED mode=base url=http://localhost:PORT.
11
+ # web a project on Microsoft.NET.Sdk.Web: published by `tf-build.sh publish` (its rungs, one
12
+ # build at a time) into tests/.artifacts/verify/run-<port>/, and that copy is run on the
13
+ # side that built it, reading its settings from the project folder as `dotnet run` does.
14
+ # Builders side by side never serve each other's half-written build (TF-043). A standalone
15
+ # WebAssembly project is still started with `dotnet run`. Prints BOOTED mode=base url=….
13
16
  # windows a MAUI Blazor Hybrid head (UseMaui + a net*-windows target): started Windows-side with
14
17
  # WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=9222, the DevTools port
15
18
  # relayed to every interface by tf-cdp-relay.ps1, and reached from WSL over CDP. Prints
16
19
  # BOOTED mode=cdp url=http://<host>:9223. Proven on MyDiary, 2026-09-06.
17
20
  # static a folder of HTML served by python3 (the framework self-test, a mockup set).
18
- # android, ios, maccatalyst: no driver ships in this framework version. NONE with that reason;
19
- # their rows are recorded as not verified, never as static-only passes.
20
- # Without --head the script picks web when a web project exists, else windows when a MAUI project
21
- # with a windows target exists, else NONE. --project names the project when there are several.
22
- # The state goes to tests/.artifacts/verify/boot.json; the app log to tests/.artifacts/verify/app.log.
23
- # `stop` kills what `start` started and nothing else. Never asks anyone to start anything.
21
+ # maccatalyst a MAUI project with a net*-maccatalyst target, on a Mac only: built by tf-build.sh,
22
+ # its .app started here, and reached over Appium's mac2 driver at the address in
23
+ # core-config.yaml runtimeVerification.appium.maccatalyst.url (default
24
+ # http://localhost:4723; a local Appium that is down is started and later stopped).
25
+ # Prints BOOTED mode=appium url=<appium> bundle=<id>. An SDK that names an older Xcode
26
+ # is built again with -p:ValidateXcodeVersion=false, and the state says so. A Blazor Hybrid
27
+ # head is driven without control names (its data-testid is not readable on a Mac): the
28
+ # state says webview, and those rows say the name check was not measured.
29
+ # BOOTED only once a mac2 session has opened and the app shows a window of its own, not a
30
+ # system dialog; that window is saved as boot-<port>-window.png (Lekhak TF-026). Before the
31
+ # build it checks the two things a fresh macOS 27 / Xcode 27 machine lacks, and names the fix:
32
+ # Automation Mode allowed without a password, and a mac2 driver new enough for this Xcode.
33
+ # `stop` quits the app as Cmd-Q does, and kills it only when it has not quit in 15 s.
34
+ # android, ios: no driver ships in this framework version. NONE with that reason; their rows
35
+ # are recorded as not verified, never as static-only passes.
36
+ # Without --head the script picks web when a web project exists, else (on a Mac) maccatalyst when a
37
+ # MAUI project with that target exists, else windows when a MAUI project with a windows target
38
+ # exists, else NONE. --project names the project when there are several.
39
+ # The state goes to tests/.artifacts/verify/boot-<port>.json and the app log to app-<port>.log, one pair
40
+ # per app, so builders booting side by side never empty or stop each other's (TF-034); boot.json is a
41
+ # copy of the latest start's, which the verdict reads. `stop --port N` stops that app only; a bare
42
+ # `stop` stops the one boot.json names, and refuses while two or more are running. `stop` kills what
43
+ # `start` started and nothing else. Never asks anyone to start anything.
24
44
  # Exit 0 booted / stopped · 2 NONE (reason printed; kind=build-error, host or no-driver) · 3 usage.
25
45
  set -u
26
46
  HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
27
- DIR="tests/.artifacts/verify"; STATE="$DIR/boot.json"; LOG="$DIR/app.log"
47
+ source "$HERE/tf-portable.sh" # bash-4 and GNU-only spellings go through the shim (stock Mac)
48
+ DIR="tests/.artifacts/verify"; STATE="$DIR/boot.json"; LOG="$DIR/app.log"; KEYED=""
28
49
  mkdir -p "$DIR"
50
+ # once the port is known: this app's own state file and log (TF-034)
51
+ keyed() { KEYED="$DIR/boot-$1.json"; LOG="$DIR/app-$1.log"; : > "$LOG"; }
29
52
 
30
53
  PLATFORM="linux"
31
54
  U="$(uname -s 2>/dev/null || echo windows)"
@@ -39,29 +62,39 @@ winarg() { # a Windows path as a cmd.exe argument: bare when it has no space (a
39
62
  json_escape() { python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$1"; }
40
63
 
41
64
  write_state() { # head mode url pids rung project reason kind
42
- python3 - "$@" <<'PY'
43
- import json, sys, datetime
65
+ TF_BOOT_FILES="$STATE $KEYED" TF_BOOT_LOG="$LOG" python3 - "$@" <<'PY'
66
+ import json, os, sys, datetime
44
67
  head, mode, url, pids, rung, project, reason, kind = sys.argv[1:9]
45
- json.dump({"head": head, "mode": mode, "url": url, "pids": [int(p) for p in pids.split() if p.isdigit()],
46
- "rung": rung, "project": project, "reason": reason, "reason_kind": kind, "stopped": False,
47
- "platform": sys.argv[9] if len(sys.argv) > 9 else "", "log": "tests/.artifacts/verify/app.log",
48
- "started": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")},
49
- open("tests/.artifacts/verify/boot.json", "w"), indent=1)
68
+ s = {"head": head, "mode": mode, "url": url, "pids": [int(p) for p in pids.split() if p.isdigit()],
69
+ "rung": rung, "project": project, "reason": reason, "reason_kind": kind, "stopped": False,
70
+ "platform": sys.argv[9] if len(sys.argv) > 9 else "", "log": os.environ["TF_BOOT_LOG"],
71
+ "started": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")}
72
+ for f in os.environ["TF_BOOT_FILES"].split():
73
+ json.dump(s, open(f, "w"), indent=1)
50
74
  PY
51
75
  }
76
+ set_state() { # key value-as-JSON: one more field on this start's state files
77
+ TF_BOOT_FILES="$STATE $KEYED" python3 -c 'import json,os,sys
78
+ for p in os.environ["TF_BOOT_FILES"].split():
79
+ s=json.load(open(p)); s[sys.argv[1]]=json.loads(sys.argv[2]); json.dump(s,open(p,"w"),indent=1)' "$1" "$2"
80
+ }
52
81
 
53
82
  find_projects() { # prints csproj paths under src/, source/, the root and one level down; never tests
54
83
  find . -maxdepth 4 -name '*.csproj' -not -path '*/bin/*' -not -path '*/obj/*' -not -path '*/node_modules/*' \
55
84
  -not -path './tests/*' -not -path '*/test/*' -not -path '*Tests/*' -not -path '*.Tests/*' 2>/dev/null | sed 's#^\./##' | sort
56
85
  }
57
86
  is_web() { grep -qE 'Microsoft\.NET\.Sdk\.Web|Microsoft\.NET\.Sdk\.BlazorWebAssembly' "$1"; }
58
- is_maui_win() { grep -qiE '<UseMaui>\s*true|Microsoft\.NET\.Sdk\.Maui' "$1" && grep -qE 'net[0-9.]+-windows' "$1"; }
87
+ is_maui_win() { grep -qiE '<UseMaui>[[:space:]]*true|Microsoft\.NET\.Sdk\.Maui' "$1" && grep -qE 'net[0-9.]+-windows' "$1"; }
88
+ is_maui_mac() { grep -qiE '<UseMaui>[[:space:]]*true|Microsoft\.NET\.Sdk\.Maui' "$1" && grep -qE 'net[0-9.]+-maccatalyst' "$1"; }
59
89
 
60
90
  poll_http() { # url seconds pid-to-watch(optional) -> 0 when it answers
61
91
  local url="$1" secs="$2" pid="${3:-}" i=0 code
62
92
  while [[ $i -lt $secs ]]; do
63
93
  code="$(curl -s -o /dev/null -m 3 -w '%{http_code}' "$url" 2>/dev/null || true)"
64
- [[ "$code" =~ ^[23] ]] && return 0
94
+ # Any HTTP answer means the app is serving; this asks whether it is up, not whether the page is
95
+ # right. A Web API with nothing at / answers 404, and was polled for 120 s, called "not brought up"
96
+ # and stopped while its log said "Application started" (AppManager TF-004).
97
+ [[ "$code" =~ ^[1-5][0-9][0-9]$ ]] && return 0
65
98
  if [[ -n "$pid" ]] && ! kill -0 "$pid" 2>/dev/null; then return 1; fi
66
99
  sleep 2; i=$((i+2))
67
100
  done
@@ -71,13 +104,64 @@ WRONG_RUNG='NETSDK1178|Microsoft\.(iOS|Android|MacCatalyst|tvOS)\.Sdk|Workload I
71
104
  CODE_ERR='error (CS|RZ|BL|XC|XLS)[0-9]{3,5}|error MSB[0-9]{4}: .*(does not exist|could not be found)|error NU1'
72
105
 
73
106
  cmd="${1:-}"; shift || true
107
+ SPORT=""; [[ "${1:-}" == "--port" && "$cmd" != "start" ]] && SPORT="${2:-}"
74
108
  case "$cmd" in
75
- status) if [[ -f "$STATE" ]]; then cat "$STATE"; else echo "NONE no boot state"; fi; exit 0 ;;
109
+ status) f="$STATE"; [[ -n "$SPORT" ]] && f="$DIR/boot-$SPORT.json"
110
+ if [[ -f "$f" ]]; then cat "$f"; else echo "NONE no boot state${SPORT:+ for port $SPORT}"; fi; exit 0 ;;
76
111
  stop)
77
- [[ -f "$STATE" ]] || { echo "STOPPED nothing was started"; exit 0; }
78
- python3 - <<'PY'
79
- import json, os, signal, subprocess, time
80
- s = json.load(open("tests/.artifacts/verify/boot.json"))
112
+ if [[ -n "$SPORT" ]]; then
113
+ [[ -f "$DIR/boot-$SPORT.json" ]] || { echo "STOPPED nothing was started on port $SPORT"; exit 0; }
114
+ else
115
+ [[ -f "$STATE" ]] || { echo "STOPPED nothing was started"; exit 0; }
116
+ # a bare stop while several apps run could stop another builder's: name the port instead
117
+ live="$(python3 - "$DIR" <<'PY'
118
+ import glob, json, os, re, sys
119
+ out = []
120
+ for f in sorted(glob.glob(os.path.join(sys.argv[1], "boot-*.json"))):
121
+ try:
122
+ s = json.load(open(f))
123
+ except Exception:
124
+ continue
125
+ alive = False
126
+ for p in s.get("pids", []):
127
+ try:
128
+ os.kill(p, 0)
129
+ alive = True
130
+ except Exception:
131
+ pass
132
+ if not s.get("stopped") and alive:
133
+ out.append(re.sub(r"^boot-(.+)[.]json$", r"\1", os.path.basename(f)))
134
+ print(" ".join(out))
135
+ PY
136
+ )"
137
+ if [[ $(wc -w <<<"$live") -ge 2 ]]; then
138
+ echo "NOT-STOPPED $(( $(wc -w <<<"$live") )) apps are running (ports ${live// /, }); stop yours with: bash .tfcore/utils/tf-verify-boot.sh stop --port <n>"; exit 2
139
+ fi
140
+ fi
141
+ python3 - "$DIR" "$SPORT" <<'PY'
142
+ import json, os, re, signal, subprocess, sys, time
143
+ d, port = sys.argv[1], sys.argv[2]
144
+ main = os.path.join(d, "boot.json")
145
+ path = os.path.join(d, f"boot-{port}.json") if port else main
146
+ s = json.load(open(path))
147
+ # A Mac Catalyst head is quit the way Cmd-Q quits it. Killed, macOS offered at the next start to reopen
148
+ # the windows of a run that "quit unexpectedly", and that dialog stood in front of the first screen
149
+ # (Lekhak TF-026). It is killed below only when it has not quit within 15 s.
150
+ if s.get("bundle_id") and s.get("app_path") and not s.get("stopped"):
151
+ exe_dir = os.path.join(s["app_path"], "Contents", "MacOS") + os.sep
152
+ def mac_running():
153
+ out = subprocess.run(["ps", "-A", "-ww", "-o", "command="], capture_output=True).stdout.decode(errors="replace")
154
+ return any(exe_dir in line for line in out.splitlines())
155
+ if mac_running():
156
+ bid = s["bundle_id"].replace('"', "")
157
+ try:
158
+ subprocess.run(["osascript", "-e", f'if application id "{bid}" is running then tell application id "{bid}" to quit'], capture_output=True, timeout=30)
159
+ except Exception:
160
+ pass
161
+ for _ in range(15):
162
+ if not mac_running():
163
+ break
164
+ time.sleep(1)
81
165
  for pid in s.get("pids", []):
82
166
  for sig in (signal.SIGTERM, signal.SIGKILL):
83
167
  try:
@@ -89,23 +173,88 @@ for pid in s.get("pids", []):
89
173
  pass
90
174
  time.sleep(1)
91
175
  image = s.get("win_image") or ""
92
- if image:
93
- subprocess.run(["taskkill.exe", "/F", "/T", "/IM", image], capture_output=True)
176
+ if s.get("win_pids"):
177
+ # this boot's own processes only (Sevak TF-003: /IM stopped every copy, another agent's included)
178
+ for wp in s["win_pids"]:
179
+ subprocess.run(["taskkill.exe", "/F", "/T", "/PID", str(wp)], capture_output=True)
180
+ elif image:
181
+ # a state written before win_pids: by name, as before, unless another start of it is still up
182
+ others = []
183
+ for f in os.listdir(d):
184
+ if f.startswith("boot-") and f.endswith(".json") and os.path.join(d, f) != path:
185
+ try:
186
+ o = json.load(open(os.path.join(d, f)))
187
+ if o.get("win_image") == image and not o.get("stopped") and o.get("pids") != s.get("pids"):
188
+ others.append(f)
189
+ except Exception:
190
+ pass
191
+ if not others:
192
+ subprocess.run(["taskkill.exe", "/F", "/T", "/IM", image], capture_output=True)
94
193
  if s.get("win_port"):
95
194
  ps = f"Get-NetTCPConnection -LocalPort {s['win_port']} -State Listen -ErrorAction SilentlyContinue | ForEach-Object {{ Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }}"
96
195
  subprocess.run(["powershell.exe", "-NoProfile", "-Command", ps], capture_output=True)
196
+ # An app whose starter died is nobody's child any more, so the kills above miss it and it keeps its
197
+ # port and its files. The published copy names it: stop whatever runs from run-<port>/ (TF-043).
198
+ m = re.search(r":(\d+)$", s.get("url") or "")
199
+ run_port = port or (m.group(1) if m else "")
200
+ marks = [os.path.join(os.getcwd(), d, f"run-{run_port}") + os.sep] if run_port else []
201
+ # A Mac Catalyst head: opening an Appium session starts the app again under a new process, so the
202
+ # pid written at boot is gone and the app is not; stop whatever runs from this boot's .app.
203
+ if s.get("app_path"):
204
+ marks.append(os.path.join(s["app_path"], "Contents", "MacOS") + os.sep)
205
+ if marks:
206
+ try:
207
+ procs = [p for p in os.listdir("/proc") if p.isdigit()]
208
+ except Exception:
209
+ procs = []
210
+ cmds = [] # (pid, command line)
211
+ for p in procs:
212
+ try:
213
+ cmd = open(f"/proc/{p}/cmdline", "rb").read().replace(b"\0", b" ").decode(errors="replace")
214
+ except Exception:
215
+ continue
216
+ cmds.append((int(p), cmd))
217
+ if not procs:
218
+ # No /proc (macOS): the same list from ps, full width so the run-<port>/ path is not cut off.
219
+ try:
220
+ out = subprocess.run(["ps", "-A", "-ww", "-o", "pid=,command="], capture_output=True).stdout.decode(errors="replace")
221
+ except Exception:
222
+ out = ""
223
+ for line in out.splitlines():
224
+ pid, _, cmd = line.strip().partition(" ")
225
+ if pid.isdigit():
226
+ cmds.append((int(pid), cmd))
227
+ for pid, cmd in cmds:
228
+ if any(mk in cmd for mk in marks) and pid != os.getpid():
229
+ try:
230
+ os.kill(pid, signal.SIGKILL)
231
+ except Exception:
232
+ pass
97
233
  s["stopped"] = True
98
- json.dump(s, open("tests/.artifacts/verify/boot.json", "w"), indent=1)
234
+ json.dump(s, open(path, "w"), indent=1)
235
+ # the same app under its other name: boot.json and its own boot-<port>.json
236
+ twins = [main] if port else ([os.path.join(d, f"boot-{m.group(1)}.json")] if m else [])
237
+ for f in twins:
238
+ try:
239
+ o = json.load(open(f))
240
+ if o.get("pids") == s.get("pids"):
241
+ o["stopped"] = True
242
+ json.dump(o, open(f, "w"), indent=1)
243
+ except Exception:
244
+ pass
99
245
  print(f"STOPPED head={s.get('head')} mode={s.get('mode')} pids={s.get('pids')}")
100
246
  PY
101
247
  exit 0 ;;
102
248
  start) ;;
103
- *) sed -n '2,26p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 3 ;;
249
+ *) sed -n '2,39p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 3 ;;
104
250
  esac
105
251
 
106
- HEAD=""; PROJECT=""; PORT=""; CONFIG=""; STATIC=""
252
+ HEAD=""; PROJECT=""; PORT=""; CONFIG=""; STATIC=""; PROBE="/"; DRYRUN=0; ENVNAME=""
107
253
  while [[ $# -gt 0 ]]; do
108
254
  case "$1" in
255
+ --environment) ENVNAME="${2:-}"; shift 2 ;; # the web head's ASPNETCORE_ENVIRONMENT (Lekhak TF-001)
256
+ --probe-path) PROBE="${2:-/}"; [[ "$PROBE" == /* ]] || PROBE="/$PROBE"; shift 2 ;;
257
+ --dry-run) DRYRUN=1; shift ;; # print which project and head would boot, and stop (TF-010)
109
258
  --head) HEAD="${2:-}"; shift 2 ;;
110
259
  --project) PROJECT="${2:-}"; shift 2 ;;
111
260
  --port) PORT="${2:-}"; shift 2 ;;
@@ -114,7 +263,6 @@ while [[ $# -gt 0 ]]; do
114
263
  *) echo "tf-verify-boot: unknown argument $1" >&2; exit 3 ;;
115
264
  esac
116
265
  done
117
- : > "$LOG"
118
266
 
119
267
  # ---- static ---------------------------------------------------------------------------
120
268
  if [[ "$HEAD" == "static" ]]; then
@@ -123,33 +271,72 @@ if [[ "$HEAD" == "static" ]]; then
123
271
  if curl -s -o /dev/null -m 2 "http://localhost:$PORT/" 2>/dev/null; then
124
272
  echo "NONE head=static reason=port $PORT is already in use; stop that process or pass --port"; exit 2
125
273
  fi
274
+ keyed "$PORT"
126
275
  nohup python3 -m http.server "$PORT" --directory "$STATIC" --bind 127.0.0.1 > "$LOG" 2>&1 &
127
276
  PID=$!
128
277
  if poll_http "http://localhost:$PORT/" 20 "$PID"; then
129
278
  write_state static base "http://localhost:$PORT" "$PID" "python3 http.server" "$STATIC" "" "" "$PLATFORM"
130
- echo "BOOTED head=static mode=base url=http://localhost:$PORT pid=$PID log=$LOG"; exit 0
279
+ echo "BOOTED head=static mode=base url=http://localhost:$PORT pid=$PID log=$LOG stop=\"bash .tfcore/utils/tf-verify-boot.sh stop --port $PORT\""; exit 0
131
280
  fi
132
281
  kill "$PID" 2>/dev/null; write_state static none "" "" "" "$STATIC" "http.server did not answer on $PORT" host
133
282
  echo "NONE head=static reason=http.server did not answer on $PORT (log $LOG)"; exit 2
134
283
  fi
135
284
 
136
285
  # ---- pick the project and the head ------------------------------------------------------
137
- mapfile -t ALL < <(find_projects)
138
- WEB=(); WIN=()
139
- for p in "${ALL[@]}"; do is_web "$p" && WEB+=("$p"); is_maui_win "$p" && WIN+=("$p"); done
286
+ tf_read_lines ALL < <(find_projects)
287
+ WEB=(); WIN=(); MAC=()
288
+ for p in ${ALL[@]+"${ALL[@]}"}; do is_web "$p" && WEB+=("$p"); is_maui_win "$p" && WIN+=("$p"); is_maui_mac "$p" && MAC+=("$p"); done
289
+ # a Mac Catalyst head is picked unasked only on a Mac, the one host that runs it
290
+ [[ "$PLATFORM" == "macos" ]] || MAC=()
291
+ # Several web projects: the one that serves screens. The first in sorted order booted AppManager's
292
+ # external API, which has no screens, in front of its admin site (AppManager TF-010). A project
293
+ # serves screens when its own folder holds .razor or .cshtml pages, or it references a project
294
+ # built with the Razor SDK. One such project is taken and said so; several, or none, stop with the
295
+ # candidates named, because a guess between them grades the wrong app.
296
+ serves_screens() { # csproj -> 0 when it draws pages
297
+ local d; d="$(dirname "$1")"
298
+ find "$d" -maxdepth 3 \( -name '*.razor' -o -name '*.cshtml' \) -not -path '*/bin/*' -not -path '*/obj/*' -print -quit 2>/dev/null | grep -q . && return 0
299
+ local ref
300
+ while read -r ref; do
301
+ ref="$(sed 's#\\#/#g' <<<"$ref")"
302
+ [[ -f "$d/$ref" ]] && grep -qE 'Microsoft\.NET\.Sdk\.Razor' "$d/$ref" && return 0
303
+ done < <(grep -oE '<ProjectReference[^>]*Include="[^"]+"' "$1" 2>/dev/null | sed -E 's/.*Include="([^"]+)".*/\1/')
304
+ return 1
305
+ }
306
+ pick_web() { # sets PROJECT from WEB, or prints NONE and exits
307
+ if [[ ${#WEB[@]} -le 1 ]]; then PROJECT="${WEB[0]:-}"; return; fi
308
+ local ui=()
309
+ for p in "${WEB[@]}"; do serves_screens "$p" && ui+=("$p"); done
310
+ if [[ ${#ui[@]} -eq 1 ]]; then
311
+ PROJECT="${ui[0]}"
312
+ echo "tf-verify-boot: ${#WEB[@]} web projects; $PROJECT serves screens (.razor/.cshtml pages or a Razor SDK reference), the other(s) do not — booting it; --project names another"
313
+ else
314
+ write_state web none "" "" "" "" "${#WEB[@]} web projects and no single one serving screens" host "$PLATFORM"
315
+ echo "NONE head=web reason=${#WEB[@]} web projects (${WEB[*]}) and ${#ui[@]} of them serve screens${ui[*]:+ (${ui[*]})}; name one with --project"; exit 2
316
+ fi
317
+ }
140
318
  if [[ -n "$PROJECT" ]]; then
141
319
  [[ -f "$PROJECT" ]] || { echo "NONE reason=--project $PROJECT does not exist"; exit 3; }
142
- if [[ -z "$HEAD" ]]; then is_web "$PROJECT" && HEAD=web; [[ -z "$HEAD" ]] && is_maui_win "$PROJECT" && HEAD=windows; fi
320
+ if [[ -z "$HEAD" ]]; then
321
+ is_web "$PROJECT" && HEAD=web
322
+ [[ -z "$HEAD" && "$PLATFORM" == "macos" ]] && is_maui_mac "$PROJECT" && HEAD=maccatalyst
323
+ [[ -z "$HEAD" ]] && is_maui_win "$PROJECT" && HEAD=windows
324
+ fi
143
325
  elif [[ -z "$HEAD" ]]; then
144
- if [[ ${#WEB[@]} -gt 0 ]]; then HEAD=web; PROJECT="${WEB[0]}"
326
+ if [[ ${#WEB[@]} -gt 0 ]]; then HEAD=web; pick_web
327
+ elif [[ ${#MAC[@]} -gt 0 ]]; then HEAD=maccatalyst; PROJECT="${MAC[0]}"
145
328
  elif [[ ${#WIN[@]} -gt 0 ]]; then HEAD=windows; PROJECT="${WIN[0]}"; fi
146
329
  else
147
- case "$HEAD" in web) PROJECT="${WEB[0]:-}" ;; windows) PROJECT="${WIN[0]:-}" ;; esac
330
+ case "$HEAD" in web) pick_web ;; windows) PROJECT="${WIN[0]:-}" ;; maccatalyst) PROJECT="${MAC[0]:-}" ;; esac
148
331
  fi
332
+ if [[ $DRYRUN -eq 1 ]]; then echo "PICK head=${HEAD:-none} project=${PROJECT:-none}"; exit 0; fi
149
333
  case "$HEAD" in
150
- android|ios|maccatalyst)
334
+ android|ios)
151
335
  write_state "$HEAD" none "" "" "" "${PROJECT:-}" "no driver for the $HEAD head ships in this framework version" no-driver "$PLATFORM"
152
336
  echo "NONE head=$HEAD reason=no driver for the $HEAD head ships in this framework version; its rows are recorded as not verified"; exit 2 ;;
337
+ maccatalyst)
338
+ [[ "$PLATFORM" == "macos" ]] || { write_state maccatalyst none "" "" "" "${PROJECT:-}" "a Mac Catalyst head runs only on a Mac" host "$PLATFORM"
339
+ echo "NONE head=maccatalyst kind=host reason=a Mac Catalyst head runs only on a Mac, this is $PLATFORM"; exit 2; } ;;
153
340
  web|windows) ;;
154
341
  "") write_state none none "" "" "" "" "no web project and no MAUI project with a windows target found under src/, source/ or the root" host "$PLATFORM"
155
342
  echo "NONE head=none reason=no web project (Microsoft.NET.Sdk.Web) and no MAUI project with a windows target found; name one with --project"; exit 2 ;;
@@ -160,6 +347,165 @@ PDIR="$(dirname "$PROJECT")"; PNAME="$(basename "$PROJECT" .csproj)"
160
347
  ASM="$(grep -oE '<AssemblyName>[^<]+' "$PROJECT" 2>/dev/null | head -1 | sed 's/<AssemblyName>//')"; ASM="${ASM:-$PNAME}"
161
348
  CFGARGS=(); [[ -n "$CONFIG" ]] && CFGARGS=(-c "$CONFIG")
162
349
 
350
+ # ---- maccatalyst (MAUI on this Mac, reached over Appium's mac2 driver) -----------------------
351
+ if [[ "$HEAD" == "maccatalyst" ]]; then
352
+ # A Blazor Hybrid head draws its screens in a web view, and on a Mac neither data-testid nor an
353
+ # HTML id reaches mac2 or the macOS accessibility tree (probed 2026-10-07). It is driven all the
354
+ # same, without control names (owner decision A): the state says webview, the screen check skips
355
+ # the name check, and the verdict writes it as not measured.
356
+ WEBVIEW=0; grep -q 'Microsoft\.AspNetCore\.Components\.WebView\.Maui' "$PROJECT" && WEBVIEW=1
357
+ TFM="$(grep -oE 'net[0-9.]+-maccatalyst[0-9.]*' "$PROJECT" | head -1)"
358
+ # the endpoint the app registers (an uncommented maccatalyst: entry with a url), else this Mac
359
+ AURL="$(python3 - <<'PY'
360
+ import re
361
+ try:
362
+ t = open(".tfcore/core-config.yaml", encoding="utf-8").read()
363
+ except Exception:
364
+ t = ""
365
+ m = re.search(r"(?m)^[ \t]+maccatalyst:[ \t]*(?:\{[^}\n]*url:[ \t]*([^,}\s]+)|\n[ \t]+url:[ \t]*(\S+))", t)
366
+ print((m.group(1) or m.group(2)) if m else "")
367
+ PY
368
+ )"
369
+ AURL="${AURL:-http://localhost:4723}"; AURL="${AURL%/}"
370
+ APORT="${AURL##*:}"; [[ "$APORT" =~ ^[0-9]+$ ]] || APORT=4723
371
+ keyed "$APORT"
372
+ mac_none() { # reason: a host fault found before anything was started
373
+ write_state maccatalyst none "" "" "" "$PROJECT" "$1" host "$PLATFORM"
374
+ echo "NONE head=maccatalyst kind=host reason=$1"; exit 2; }
375
+ # Lekhak TF-026: two set-up faults of a fresh macOS 27 / Xcode 27 machine, each of which stopped every
376
+ # mac2 session with an error that does not name its fix. Checked here, before a build of minutes.
377
+ # 1. mac2 turns Automation Mode on for each session; when that needs a password it never comes, and
378
+ # the session fails with "Timed out while enabling automation mode".
379
+ if command -v automationmodetool >/dev/null 2>&1 && automationmodetool 2>&1 | grep -q '^This device REQUIRES user authentication'; then
380
+ mac_none "macOS asks for a password before Automation Mode comes on, so no mac2 session can open. Run once, with an administrator password: sudo automationmodetool enable-automationmode-without-authentication"
381
+ fi
382
+ # 2. mac2 builds its WebDriverAgentMac with the Xcode on this Mac. Before 4.1.1 that build fails
383
+ # under Xcode 27 (deployment target 10.15 below its 12.0 minimum, xcodebuild exit 65). An
384
+ # `appium driver update mac2` left the driver unloadable ("Cannot find package 'appium'");
385
+ # uninstalling and installing again is what worked.
386
+ if command -v appium >/dev/null 2>&1; then
387
+ M2="$(appium driver list --installed --json 2>/dev/null | python3 -c 'import json,sys
388
+ try: print(json.load(sys.stdin).get("mac2", {}).get("version", ""))
389
+ except Exception: print("?")' 2>/dev/null)"
390
+ XMAJ="$(xcodebuild -version 2>/dev/null | sed -n 's/^Xcode \([0-9]*\).*/\1/p' | head -1)"
391
+ REINSTALL="appium driver uninstall mac2; appium driver install mac2"
392
+ if [[ -z "$M2" ]]; then
393
+ mac_none "Appium's mac2 driver is not installed. Run: appium driver install mac2"
394
+ elif [[ "$M2" != "?" && "${XMAJ:-0}" -ge 27 ]] && python3 -c 'import sys
395
+ v = tuple(int(x) for x in sys.argv[1].split("-")[0].split(".")[:3])
396
+ sys.exit(0 if v < (4, 1, 1) else 1)' "$M2"; then
397
+ mac_none "the mac2 driver $M2 cannot build its helper with Xcode $XMAJ (4.1.1 is the first that can). Run: $REINSTALL"
398
+ fi
399
+ fi
400
+ ready() { curl -s -m 3 "$AURL/status" 2>/dev/null | grep -q '"ready":true'; }
401
+ APPIUM_PID=""
402
+ if ! ready; then
403
+ # boot it yourself: a local Appium that is down is started here and stopped by `stop`
404
+ if [[ "$AURL" =~ ^http://(localhost|127\.0\.0\.1): ]] && command -v appium >/dev/null 2>&1; then
405
+ nohup appium --address 127.0.0.1 --port "$APORT" > "$DIR/appium-$APORT.log" 2>&1 < /dev/null &
406
+ APPIUM_PID=$!; i=0
407
+ while [[ $i -lt 60 ]] && ! ready; do sleep 2; i=$((i+2)); done
408
+ fi
409
+ if ! ready; then
410
+ [[ -n "$APPIUM_PID" ]] && kill "$APPIUM_PID" 2>/dev/null
411
+ write_state maccatalyst none "" "" "" "$PROJECT" "Appium did not answer at $AURL/status" host "$PLATFORM"
412
+ echo "NONE head=maccatalyst kind=host reason=Appium did not answer at $AURL/status$([[ -z "$APPIUM_PID" ]] && ! command -v appium >/dev/null 2>&1 && echo '; appium is not installed (docs/TechieFlow-Setup.md §0b step 3)')"; exit 2
413
+ fi
414
+ fi
415
+ stop_appium() { [[ -n "$APPIUM_PID" ]] && kill "$APPIUM_PID" 2>/dev/null; }
416
+ # the Mac rules first (coding-standards-dotnet.md §9): a missing scene manifest is stopped here,
417
+ # before a build that macOS 27 would end at the first window; a WARN is printed and the boot goes on
418
+ chk="$(bash "$HERE/tf-maccatalyst-check.sh" "$PROJECT" 2>&1)"; crc=$?
419
+ printf '%s\n' "$chk" >> "$LOG"; grep -E '^WARN' <<<"$chk" >&2
420
+ OSMAJOR="$(sw_vers -productVersion 2>/dev/null | cut -d. -f1)"
421
+ if [[ $crc -eq 1 && "${OSMAJOR:-0}" -ge 27 ]]; then
422
+ stop_appium; first="$(grep -m1 -E '^FAIL' <<<"$chk" | sed -E 's/^FAIL [^:]+: //')"
423
+ write_state maccatalyst none "" "" "tf-maccatalyst-check.sh" "$PROJECT" "$first" build-error "$PLATFORM"
424
+ echo "NONE head=maccatalyst kind=build-error reason=$first (bash .tfcore/utils/tf-maccatalyst-check.sh)"; exit 2
425
+ fi
426
+ [[ $crc -eq 1 ]] && grep -E '^FAIL' <<<"$chk" | sed 's/^FAIL/WARN (fatal from macOS 27)/' >&2
427
+ BARGS=(-f "$TFM" -c "${CONFIG:-Debug}")
428
+ echo "### build $PROJECT -f $TFM" >> "$LOG"
429
+ out="$(bash "$HERE/tf-build.sh" build "$PROJECT" -- "${BARGS[@]}" 2>&1)"; brc=$?
430
+ # A Mac Catalyst SDK names the one Xcode it was made for and refuses a newer one; tf-build.sh builds
431
+ # again with the check off and says so in a note line (Lekhak TF-032), which the state carries.
432
+ XNOTE="$(grep -oE 'requires Xcode [0-9.]+[.] The current version of Xcode is [0-9.]+' <<<"$(grep '^note' <<<"$out")" | head -1)"
433
+ printf '%s\n' "$out" >> "$LOG"
434
+ verdict="$(grep -E '^(PASS|FAIL|NOT-RUN)' <<<"$out" | tail -1)"
435
+ if [[ $brc -ne 0 ]]; then
436
+ stop_appium
437
+ kind=host; [[ $brc -eq 1 ]] && kind=build-error
438
+ write_state maccatalyst none "" "" "tf-build.sh build -f $TFM" "$PROJECT" "${verdict:-the build failed}" "$kind" "$PLATFORM"
439
+ echo "NONE head=maccatalyst kind=$kind reason=${verdict:-the build failed} (log $LOG)"; exit 2
440
+ fi
441
+ APP="$(ls -td "$PDIR/bin/${CONFIG:-Debug}/$TFM"/*.app "$PDIR/bin/${CONFIG:-Debug}/$TFM"/*/*.app 2>/dev/null | head -1)"
442
+ if [[ -z "$APP" ]]; then
443
+ stop_appium; write_state maccatalyst none "" "" "tf-build.sh build -f $TFM" "$PROJECT" "the build wrote no .app under $PDIR/bin/${CONFIG:-Debug}/$TFM" host "$PLATFORM"
444
+ echo "NONE head=maccatalyst kind=host reason=the build wrote no .app under $PDIR/bin/${CONFIG:-Debug}/$TFM (log $LOG)"; exit 2
445
+ fi
446
+ APP="$(cd "$APP" && pwd)"
447
+ BUNDLE="$(/usr/libexec/PlistBuddy -c 'Print CFBundleIdentifier' "$APP/Contents/Info.plist" 2>/dev/null)"
448
+ EXE="$(/usr/libexec/PlistBuddy -c 'Print CFBundleExecutable' "$APP/Contents/Info.plist" 2>/dev/null)"
449
+ if OLD="$(pgrep -f "$APP/Contents/MacOS/$EXE" | head -1)" && [[ -n "$OLD" ]]; then
450
+ stop_appium; write_state maccatalyst none "" "" "" "$PROJECT" "another copy of $EXE is already running (pid $OLD)" host "$PLATFORM"
451
+ echo "NONE head=maccatalyst kind=host reason=another copy of $EXE is already running (pid $OLD), and mac2 would attach to either; stop it first"; exit 2
452
+ fi
453
+ MARK="$DIR/.launch-$APORT"; : > "$MARK"; ROOT_LOG="$PWD/$LOG"
454
+ # Opened the way Finder opens it: macOS kills a sandboxed app started from its binary directly
455
+ # ("Killed: 9", Lekhak BlogAdmin, 2026-10-07). Its output goes to the log through open's own options.
456
+ # -ApplePersistenceIgnoreState: never offer to reopen the windows of an earlier run (Lekhak TF-026)
457
+ OPENERR="$(open -n -o "$ROOT_LOG" --stderr "$ROOT_LOG" "$APP" --args -ApplePersistenceIgnoreState YES 2>&1)"; printf '%s\n' "$OPENERR" >> "$LOG"
458
+ APP_PID=""; i=0
459
+ while [[ $i -lt 10 && -z "$APP_PID" ]]; do sleep 1; i=$((i+1)); APP_PID="$(pgrep -f "$APP/Contents/MacOS/$EXE" | head -1)"; done
460
+ i=0; while [[ -n "$APP_PID" && $i -lt 8 ]] && kill -0 "$APP_PID" 2>/dev/null; do sleep 1; i=$((i+1)); done
461
+ if [[ -z "$APP_PID" ]] || ! kill -0 "$APP_PID" 2>/dev/null; then
462
+ stop_appium
463
+ crash="$(find "$HOME/Library/Logs/DiagnosticReports" -name "$EXE-*.ips" -newer "$MARK" 2>/dev/null | head -1)"
464
+ hint=""; grep -qs NoSceneLifecycleAdoption "$crash" && hint="; macOS stops a UIKit app that has not adopted scenes: add UIApplicationSceneManifest to Platforms/MacCatalyst/Info.plist and a SceneDelegate : MauiUISceneDelegate"
465
+ # macOS refused to start it at all (Lekhak BlogAdmin, 2026-10-07: "Launch failed", POSIX 163): a
466
+ # locally signed build that asks for keychain-access-groups, which needs a provisioning profile
467
+ if grep -q 'Launch failed' <<<"$OPENERR"; then
468
+ hint="; macOS refused to start it ($(grep -oE 'Code=[0-9]+ "[^"]*"' <<<"$OPENERR" | tail -1))"
469
+ codesign -d --entitlements :- "$APP" 2>/dev/null | grep -q keychain-access-groups && hint="$hint: the build is signed locally (ad hoc) and asks for keychain-access-groups, which needs a provisioning profile; leave that entitlement out of Debug builds or set CodesignProvisioningProfile"
470
+ fi
471
+ write_state maccatalyst none "" "" "tf-build.sh build -f $TFM" "$PROJECT" "the app quit on start${crash:+ (crash report $crash)}$hint" build-error "$PLATFORM"
472
+ echo "NONE head=maccatalyst kind=build-error reason=the app quit on start${crash:+ (crash report $crash)}$hint (log $LOG)"; exit 2
473
+ fi
474
+ write_state maccatalyst appium "$AURL" "$APP_PID $APPIUM_PID" "tf-build.sh build -f $TFM" "$PROJECT" "" "" "$PLATFORM"
475
+ set_state bundle_id "$(json_escape "$BUNDLE")"
476
+ set_state app_path "$(json_escape "$APP")"
477
+ # Lekhak TF-026: a running process and an answering Appium are not a boot. A mac2 session has to open
478
+ # and find the app's own window on view, not a system dialog, before this says BOOTED.
479
+ SHOT="$PWD/$DIR/boot-$APORT-window.png"
480
+ FIRST="$(TF_A="$AURL" TF_B="$BUNDLE" TF_P="$APP" TF_S="$SHOT" node --input-type=module -e "
481
+ import { firstScreen } from '$HERE/tf-appium.mjs';
482
+ const r = await firstScreen(process.env.TF_A, { bundleId: process.env.TF_B, appPath: process.env.TF_P, shot: process.env.TF_S });
483
+ console.log(JSON.stringify(r));" 2>&1 | tail -1)"
484
+ printf '### first screen: %s\n' "$FIRST" >> "$LOG"
485
+ fs() { python3 -c 'import json,sys
486
+ try: print(json.loads(sys.argv[1]).get(sys.argv[2]) or "")
487
+ except Exception: print("")' "$FIRST" "$1"; }
488
+ if [[ "$(fs ok)" != "True" ]]; then
489
+ why="$(fs reason)"; why="${why:-$(cut -c1-200 <<<"$FIRST")}"
490
+ fix=""
491
+ case "$why" in
492
+ *[Aa]utomation\ mode*) fix="; run once, with an administrator password: sudo automationmodetool enable-automationmode-without-authentication" ;;
493
+ *"Cannot find package"*|*"Could not find a driver"*|*xcodebuild*|*"code 65"*) fix="; the mac2 driver does not load or cannot build its helper: appium driver uninstall mac2; appium driver install mac2" ;;
494
+ esac
495
+ [[ "$(fs stage)" == session && -z "$fix" ]] && fix="; a mac2 session that cannot start is usually a permission: System Settings > Privacy & Security > Accessibility (the terminal, and WebDriverAgentRunner-Runner)"
496
+ bash "${BASH_SOURCE[0]}" stop --port "$APORT" >/dev/null 2>&1
497
+ write_state maccatalyst none "" "" "tf-build.sh build -f $TFM" "$PROJECT" "the app started but its first screen was not reached: $why$fix" host "$PLATFORM"
498
+ echo "NONE head=maccatalyst kind=host reason=the app started but its first screen was not reached: $why$fix (log $LOG)"; exit 2
499
+ fi
500
+ set_state first_screen "$(json_escape "$SHOT")"
501
+ DISMISSED="$(fs dismissed)"
502
+ [[ -n "$DISMISSED" ]] && { set_state dismissed "$(json_escape "$DISMISSED")"; echo "tf-verify-boot: $DISMISSED" >&2; }
503
+ [[ -n "$XNOTE" ]] && set_state xcode_check "$(json_escape "off: $XNOTE")"
504
+ [[ $WEBVIEW -eq 1 ]] && set_state webview true
505
+ echo "BOOTED head=maccatalyst mode=appium url=$AURL bundle=$BUNDLE project=$PROJECT tfm=$TFM$([[ $WEBVIEW -eq 1 ]] && echo ' webview=yes (control names not measured)') pid=$APP_PID${APPIUM_PID:+ appium-pid=$APPIUM_PID}${XNOTE:+ xcode-check=off ($XNOTE)} window=$DIR/boot-$APORT-window.png log=$LOG stop=\"bash .tfcore/utils/tf-verify-boot.sh stop --port $APORT\""
506
+ exit 0
507
+ fi
508
+
163
509
  # ---- web ------------------------------------------------------------------------------
164
510
  if [[ "$HEAD" == "web" ]]; then
165
511
  if [[ -z "$PORT" ]]; then
@@ -171,6 +517,98 @@ if [[ "$HEAD" == "web" ]]; then
171
517
  echo "NONE head=web reason=port $PORT is already in use; stop that process or pass --port"; exit 2
172
518
  fi
173
519
  URL="http://localhost:$PORT"
520
+ keyed "$PORT"
521
+ # TF-043. `dotnet run` served the shared bin/ and obj/, which the next builder rewrites under a
522
+ # running app: on TfLens, 2026-09-12, four apps side by side, one sent its stylesheet as 200 with
523
+ # 0 bytes, one answered 500, and one ran a dll older than its own source. Each looked healthy and
524
+ # every measurement taken on it was wrong. So the app runs from a copy of its own, published by
525
+ # tf-build.sh, which lets one build run at a time; settings, secrets and files beside the project
526
+ # are read from the project folder, as `dotnet run` reads them.
527
+ if ! grep -q 'Microsoft\.NET\.Sdk\.BlazorWebAssembly' "$PROJECT"; then
528
+ ROOTDIR="$PWD"; RUN="$DIR/run-$PORT"
529
+ rm -rf "$RUN"
530
+ echo "### publish $PROJECT into $RUN" >> "$LOG"
531
+ # Debug unless --config says otherwise: `dotnet publish` alone builds Release, which is not the app
532
+ # `dotnet run` ran, and a Release publish over a Debug-built tree stamped the pages with one scope
533
+ # and the stylesheet with another (TfLens cluster E, in TF-043)
534
+ pub="$(bash "$HERE/tf-build.sh" publish "$PROJECT" -- -o "$RUN" -c "${CONFIG:-Debug}" 2>&1)"; prc=$?
535
+ printf '%s\n' "$pub" >> "$LOG"
536
+ verdict="$(grep -E '^(PASS|FAIL|NOT-RUN)' <<<"$pub" | tail -1)"
537
+ if [[ $prc -eq 1 ]]; then
538
+ first="$(grep -E 'error (CS|RZ|BL|XC|XLS|MSB|NU)[0-9]+' <<<"$pub" | head -1 | sed 's/^[[:space:]]*//' | cut -c1-160)"
539
+ write_state web none "" "" "tf-build.sh publish" "$PROJECT" "build error: ${first:-$verdict}" build-error "$PLATFORM"
540
+ echo "NONE head=web kind=build-error reason=the code does not build: ${first:-$verdict} (log $LOG)"; exit 2
541
+ elif [[ $prc -ne 0 || ! -f "$RUN/$ASM.dll" ]]; then
542
+ reason="${verdict:-the publish wrote no $ASM.dll into $RUN}"
543
+ write_state web none "" "" "tf-build.sh publish" "$PROJECT" "$reason" host "$PLATFORM"
544
+ echo "NONE head=web kind=host reason=$reason (log $LOG)"; exit 2
545
+ fi
546
+ # what `dotnet run` sets from the launch profile; the environment above all, which picks the
547
+ # settings file and whether the secrets are read
548
+ tf_read_lines LSENV < <(TF_BOOT_ENV="$ENVNAME" python3 - "$PDIR/Properties/launchSettings.json" <<'PY'
549
+ import json, os, sys
550
+ try:
551
+ profiles = json.load(open(sys.argv[1], encoding="utf-8-sig")).get("profiles", {})
552
+ except Exception:
553
+ profiles = {}
554
+ env = next((dict(p.get("environmentVariables") or {}) for p in profiles.values() if p.get("commandName") == "Project"), {})
555
+ env.setdefault("ASPNETCORE_ENVIRONMENT", "Development")
556
+ if os.environ.get("ASPNETCORE_ENVIRONMENT"):
557
+ env["ASPNETCORE_ENVIRONMENT"] = os.environ["ASPNETCORE_ENVIRONMENT"]
558
+ if os.environ.get("TF_BOOT_ENV"):
559
+ env["ASPNETCORE_ENVIRONMENT"] = os.environ["TF_BOOT_ENV"]
560
+ for k, v in env.items():
561
+ print(f"{k}={v}")
562
+ PY
563
+ )
564
+ WIN=""
565
+ case "$verdict" in
566
+ *"via cmd.exe"*|*"via winrun"*|*"via powershell.exe"*)
567
+ WIN=1; WPD="$(winarg "$(wslpath -w "$PDIR")")"; WDLL="$(winarg "$(wslpath -w "$RUN/$ASM.dll")")"
568
+ WWEB=""; [[ -d "$RUN/wwwroot" ]] && WWEB="--webroot $(winarg "$(wslpath -w "$RUN/wwwroot")")"
569
+ sets=""; for kv in ${LSENV[@]+"${LSENV[@]}"}; do sets+="set $kv&& "; done
570
+ nohup cmd.exe /c "cd /d $WPD && ${sets}dotnet $WDLL --urls $URL --contentRoot $WPD $WWEB" >> "$LOG" 2>&1 < /dev/null &
571
+ PID=$!; label="cmd.exe /c dotnet" ;;
572
+ *)
573
+ runner="dotnet"; [[ "$verdict" == *"via ~/.dotnet/dotnet"* ]] && runner="$HOME/.dotnet/dotnet"
574
+ # Lekhak TF-001. A copy run on the WSL side reads user-secrets from ~/.microsoft/usersecrets,
575
+ # while `dotnet user-secrets set` on Windows wrote them to %APPDATA%\Microsoft\UserSecrets:
576
+ # "Required configuration value(s) not set" on an app that starts under `dotnet run`. The
577
+ # secrets reader looks under $APPDATA first on any system, so when only the Windows store
578
+ # holds this project's secrets, the app is pointed at it.
579
+ SID="$(grep -oE '<UserSecretsId>[^<]+' "$PROJECT" 2>/dev/null | head -1 | sed 's/<UserSecretsId>//')"
580
+ if [[ -n "$SID" && ! -f "$HOME/.microsoft/usersecrets/$SID/secrets.json" ]] && [[ "$PLATFORM" == wsl || -n "${TF_WIN_APPDATA:-}" ]]; then
581
+ WAD="${TF_WIN_APPDATA:-}"
582
+ if [[ -z "$WAD" ]] && command -v cmd.exe >/dev/null 2>&1; then
583
+ WAD="$(cmd.exe /c 'echo %APPDATA%' 2>/dev/null | tr -d '\r')"; [[ -n "$WAD" ]] && WAD="$(wslpath -u "$WAD" 2>/dev/null)"
584
+ fi
585
+ if [[ -n "$WAD" && -f "$WAD/Microsoft/UserSecrets/$SID/secrets.json" ]]; then
586
+ LSENV+=("APPDATA=$WAD")
587
+ echo "tf-verify-boot: user-secrets $SID are in the Windows store only; the app reads them from $WAD/Microsoft/UserSecrets"
588
+ fi
589
+ fi
590
+ WEBR=(); [[ -d "$RUN/wwwroot" ]] && WEBR=(--webroot "$ROOTDIR/$RUN/wwwroot")
591
+ # the redirections belong to the whole background group and the group becomes the app: a group
592
+ # left waiting on its app kept this script's output open, so a caller reading it waited for ever
593
+ ( cd "$PDIR" || exit 1; exec env ${LSENV[@]+"${LSENV[@]}"} nohup "$runner" "$ROOTDIR/$RUN/$ASM.dll" --urls "$URL" --contentRoot "$PWD" ${WEBR[@]+"${WEBR[@]}"} ) \
594
+ >> "$ROOTDIR/$LOG" 2>&1 < /dev/null &
595
+ PID=$!; label="$([[ "$runner" == dotnet ]] && echo dotnet || echo '~/.dotnet/dotnet')" ;;
596
+ esac
597
+ if poll_http "$URL$PROBE" 120 "$PID"; then
598
+ write_state web base "$URL" "$PID" "$label (published copy)" "$PROJECT" "" "" "$PLATFORM"
599
+ set_state run_dir "$(json_escape "$RUN")"
600
+ [[ -n "$WIN" ]] && set_state win_port "$PORT"
601
+ echo "BOOTED head=web mode=base url=$URL rung=$label project=$PROJECT copy=$RUN pid=$PID log=$LOG stop=\"bash .tfcore/utils/tf-verify-boot.sh stop --port $PORT\""; exit 0
602
+ fi
603
+ pkill -P "$PID" 2>/dev/null; kill "$PID" 2>/dev/null
604
+ [[ -n "$WIN" ]] && powershell.exe -NoProfile -Command "Get-NetTCPConnection -LocalPort $PORT -State Listen -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id \$_.OwningProcess -Force -ErrorAction SilentlyContinue }" >/dev/null 2>&1
605
+ last="$(grep -vE '^[[:space:]]*$' "$LOG" | tail -3 | tr '\n' ' ' | cut -c1-240)"
606
+ # say what the log says: "no rung brought it up" sent a reader to the build when the app had started
607
+ said=""; grep -q 'Now listening on' "$LOG" && said=" although its log says it is listening"
608
+ write_state web none "" "" "$label (published copy)" "$PROJECT" "the published copy did not answer on $URL$PROBE within 120 s$said" host "$PLATFORM"
609
+ echo "NONE head=web kind=host reason=the published copy of $PROJECT did not answer on $URL$PROBE within 120 s$said; last lines: $last (log $LOG)"; exit 2
610
+ fi
611
+ # a standalone WebAssembly project has no server of its own to publish: `dotnet run` serves it
174
612
  rungs=()
175
613
  case "$PLATFORM" in
176
614
  wsl) command -v dotnet >/dev/null 2>&1 && rungs+=("dotnet"); [[ -x "$HOME/.dotnet/dotnet" ]] && rungs+=("$HOME/.dotnet/dotnet"); command -v cmd.exe >/dev/null 2>&1 && rungs+=("cmd.exe") ;;
@@ -183,23 +621,26 @@ if [[ "$HEAD" == "web" ]]; then
183
621
  case "$r" in
184
622
  cmd.exe)
185
623
  WP="$(winarg "$(wslpath -w "$PROJECT")")"
186
- nohup cmd.exe /c "dotnet run --project $WP ${CFGARGS[*]} --urls $URL" >> "$LOG" 2>&1 < /dev/null &
624
+ nohup cmd.exe /c "dotnet run --project $WP ${CFGARGS[*]:-} --urls $URL" >> "$LOG" 2>&1 < /dev/null &
187
625
  PID=$!; label="cmd.exe /c dotnet" ;;
188
626
  *)
189
- ASPNETCORE_ENVIRONMENT="${ASPNETCORE_ENVIRONMENT:-Development}" nohup "$r" run --project "$PROJECT" "${CFGARGS[@]}" --urls "$URL" >> "$LOG" 2>&1 < /dev/null &
627
+ ASPNETCORE_ENVIRONMENT="${ENVNAME:-${ASPNETCORE_ENVIRONMENT:-Development}}" nohup "$r" run --project "$PROJECT" ${CFGARGS[@]+"${CFGARGS[@]}"} --urls "$URL" >> "$LOG" 2>&1 < /dev/null &
190
628
  PID=$!; label="$r" ;;
191
629
  esac
192
630
  tried+=("$label")
193
- if poll_http "$URL/" 120 "$PID"; then
631
+ if poll_http "$URL$PROBE" 120 "$PID"; then
194
632
  WP_PORT=""; [[ "$r" == "cmd.exe" ]] && WP_PORT="$PORT"
195
633
  write_state web base "$URL" "$PID" "$label" "$PROJECT" "" "" "$PLATFORM"
196
- [[ -n "$WP_PORT" ]] && python3 -c "import json;p='$STATE';s=json.load(open(p));s['win_port']=$WP_PORT;json.dump(s,open(p,'w'),indent=1)"
197
- echo "BOOTED head=web mode=base url=$URL rung=$label project=$PROJECT pid=$PID log=$LOG"; exit 0
634
+ [[ -n "$WP_PORT" ]] && set_state win_port "$WP_PORT"
635
+ echo "BOOTED head=web mode=base url=$URL rung=$label project=$PROJECT pid=$PID log=$LOG stop=\"bash .tfcore/utils/tf-verify-boot.sh stop --port $PORT\""; exit 0
198
636
  fi
199
637
  pkill -P "$PID" 2>/dev/null; kill "$PID" 2>/dev/null
638
+ # killing cmd.exe on this side leaves the Windows-side app running and holding the build output
639
+ # (TF-034): stop whatever listens on THIS start's port, never by program name
640
+ [[ "$r" == "cmd.exe" ]] && powershell.exe -NoProfile -Command "Get-NetTCPConnection -LocalPort $PORT -State Listen -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id \$_.OwningProcess -Force -ErrorAction SilentlyContinue }" >/dev/null 2>&1
200
641
  seg="$(awk '/^### rung: /{p=0} index($0,"### rung: '"$r"'")==1{p=1} p' "$LOG")"
201
642
  if grep -qE "$CODE_ERR" <<<"$seg" && ! grep -qE "$WRONG_RUNG" <<<"$seg"; then
202
- first="$(grep -E 'error (CS|RZ|BL|XC|XLS|MSB|NU)[0-9]+' <<<"$seg" | head -1 | sed 's/^\s*//' | cut -c1-160)"
643
+ first="$(grep -E 'error (CS|RZ|BL|XC|XLS|MSB|NU)[0-9]+' <<<"$seg" | head -1 | sed 's/^[[:space:]]*//' | cut -c1-160)"
203
644
  write_state web none "" "" "$label" "$PROJECT" "build error: $first" build-error "$PLATFORM"
204
645
  echo "NONE head=web kind=build-error reason=the code does not build via $label: $first (log $LOG)"; exit 2
205
646
  fi
@@ -212,10 +653,41 @@ fi
212
653
  [[ "$PLATFORM" == "wsl" || "$PLATFORM" == "windows" ]] || { write_state windows none "" "" "" "$PROJECT" "a Windows head runs only from WSL or Windows" host "$PLATFORM"; echo "NONE head=windows reason=a Windows head runs only from WSL or Windows, this is $PLATFORM"; exit 2; }
213
654
  command -v cmd.exe >/dev/null 2>&1 || { write_state windows none "" "" "" "$PROJECT" "cmd.exe not reachable" host "$PLATFORM"; echo "NONE head=windows reason=cmd.exe is not reachable from this shell"; exit 2; }
214
655
  TFM="$(grep -oE 'net[0-9.]+-windows[0-9.]*' "$PROJECT" | head -1)"
215
- CDP_PORT=9222; RELAY_PORT="${PORT:-9223}"
656
+ RELAY_PORT="${PORT:-9223}"; keyed "$RELAY_PORT"
657
+ # Each boot drives its own embedded browser (Sevak TF-003). The DevTools port was 9222 for every
658
+ # head, so with two up the relay on 9223 listed pages of both apps and a smoke drove the other
659
+ # agent's window. The port now follows the relay port (9223 keeps 9222). A second head while one is
660
+ # up also gets its own WebView2 data folder: two copies of one app would otherwise share one browser
661
+ # process, whose single DevTools port lists both.
662
+ if [[ "$RELAY_PORT" == "9223" ]]; then CDP_PORT=9222; else CDP_PORT=$((RELAY_PORT + 20000)); fi
663
+ OTHERS="$(python3 - "$DIR" "$RELAY_PORT" <<'PY'
664
+ import glob, json, os, sys
665
+ n = 0
666
+ for f in glob.glob(os.path.join(sys.argv[1], "boot-*.json")):
667
+ if f.endswith(f"boot-{sys.argv[2]}.json"):
668
+ continue
669
+ try:
670
+ s = json.load(open(f))
671
+ except Exception:
672
+ continue
673
+ if s.get("head") == "windows" and s.get("mode") == "cdp" and not s.get("stopped"):
674
+ n += 1
675
+ print(n)
676
+ PY
677
+ )"
678
+ UDF_SET=""
679
+ if [[ "${OTHERS:-0}" -gt 0 ]]; then
680
+ UDF="$DIR/webview-$RELAY_PORT"; mkdir -p "$UDF"
681
+ UDF_SET="set WEBVIEW2_USER_DATA_FOLDER=$(wslpath -w "$UDF")&& "
682
+ echo "tf-verify-boot: $OTHERS other Windows head(s) up — this one gets DevTools port $CDP_PORT and its own WebView2 data folder ($UDF), so neither sees the other's pages" >&2
683
+ fi
216
684
  WP="$(winarg "$(wslpath -w "$PROJECT")")"; RELAY="$(wslpath -w "$HERE/tf-cdp-relay.ps1")"
217
- echo "### windows head: $PROJECT -f $TFM" >> "$LOG"
218
- nohup cmd.exe /c "set WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=$CDP_PORT&& dotnet run --project $WP -f $TFM ${CFGARGS[*]}" >> "$LOG" 2>&1 < /dev/null &
685
+ echo "### windows head: $PROJECT -f $TFM (DevTools $CDP_PORT, relay $RELAY_PORT)" >> "$LOG"
686
+ # this start's own Windows processes: the app's ids before it, so the new ones are this boot's and
687
+ # stop kills them alone, never every copy of the program (taskkill /IM stopped another agent's app)
688
+ win_ids() { powershell.exe -NoProfile -Command "Get-Process -Name '$ASM' -ErrorAction SilentlyContinue | ForEach-Object { \$_.Id }" 2>/dev/null | tr -d '\r' | grep -E '^[0-9]+$' | sort; }
689
+ BEFORE_IDS="$(win_ids)"
690
+ nohup cmd.exe /c "${UDF_SET}set WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=$CDP_PORT&& dotnet run --project $WP -f $TFM ${CFGARGS[*]:-}" >> "$LOG" 2>&1 < /dev/null &
219
691
  APP_PID=$!
220
692
  nohup powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$RELAY" -ListenPort "$RELAY_PORT" -TargetPort "$CDP_PORT" >> "$DIR/relay.log" 2>&1 < /dev/null &
221
693
  RELAY_PID=$!
@@ -230,7 +702,7 @@ while [[ $i -lt 300 && -z "$URL" ]]; do
230
702
  if ! kill -0 "$APP_PID" 2>/dev/null; then
231
703
  seg="$(cat "$LOG")"
232
704
  if grep -qE "$CODE_ERR" <<<"$seg"; then
233
- first="$(grep -E 'error (CS|RZ|BL|XC|XLS|MSB|NU)[0-9]+' <<<"$seg" | head -1 | sed 's/^\s*//' | cut -c1-160)"
705
+ first="$(grep -E 'error (CS|RZ|BL|XC|XLS|MSB|NU)[0-9]+' <<<"$seg" | head -1 | sed 's/^[[:space:]]*//' | cut -c1-160)"
234
706
  kill "$RELAY_PID" 2>/dev/null
235
707
  write_state windows none "" "" "cmd.exe /c dotnet run -f $TFM" "$PROJECT" "build error: $first" build-error "$PLATFORM"
236
708
  echo "NONE head=windows kind=build-error reason=the code does not build: $first (log $LOG)"; exit 2
@@ -238,13 +710,16 @@ while [[ $i -lt 300 && -z "$URL" ]]; do
238
710
  fi
239
711
  sleep 3; i=$((i+3))
240
712
  done
713
+ MINE="$(comm -13 <(printf '%s\n' "$BEFORE_IDS") <(win_ids) | tr '\n' ' ')"
241
714
  if [[ -z "$URL" ]]; then
242
715
  pkill -P "$APP_PID" 2>/dev/null; kill "$APP_PID" "$RELAY_PID" 2>/dev/null
243
- taskkill.exe /F /T /IM "$ASM.exe" >/dev/null 2>&1
716
+ for w in $MINE; do taskkill.exe /F /T /PID "$w" >/dev/null 2>&1; done
244
717
  write_state windows none "" "" "cmd.exe /c dotnet run -f $TFM" "$PROJECT" "the DevTools port $RELAY_PORT never answered within 300 s" host "$PLATFORM"
245
718
  echo "NONE head=windows kind=host reason=the app's DevTools port never answered on ${HOSTS[*]}:$RELAY_PORT within 300 s (log $LOG, relay $DIR/relay.log)"; exit 2
246
719
  fi
247
720
  write_state windows cdp "$URL" "$APP_PID $RELAY_PID" "cmd.exe /c dotnet run -f $TFM" "$PROJECT" "" "" "$PLATFORM"
248
- python3 -c "import json;p='$STATE';s=json.load(open(p));s['win_image']='$ASM.exe';json.dump(s,open(p,'w'),indent=1)"
721
+ set_state win_image "\"$ASM.exe\""
722
+ set_state win_pids "[$(tr ' ' '\n' <<<"$MINE" | grep -E '^[0-9]+$' | paste -sd, -)]"
723
+ set_state cdp_port "$CDP_PORT"
249
724
  echo "BOOTED head=windows mode=cdp url=$URL project=$PROJECT tfm=$TFM pids=$APP_PID,$RELAY_PID image=$ASM.exe log=$LOG"
250
725
  exit 0