device-devtools-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/AGENTS.md +327 -0
  2. package/LICENSE +21 -0
  3. package/README.md +491 -0
  4. package/VERSION +1 -0
  5. package/bin/device-devtools-mcp.js +106 -0
  6. package/bin/devicetools +315 -0
  7. package/config.example.json +63 -0
  8. package/integrations/agent-pointer.sh +48 -0
  9. package/integrations/claude/SKILL.md +8 -0
  10. package/integrations/cursor/devicetools.mdc +8 -0
  11. package/integrations/gemini/GEMINI.md +5 -0
  12. package/integrations/mcp/README.md +184 -0
  13. package/integrations/mcp/mcp.json +10 -0
  14. package/integrations/mcp/reference.sh +119 -0
  15. package/integrations/mcp/selftest.sh +158 -0
  16. package/integrations/mcp/server.sh +343 -0
  17. package/package.json +50 -0
  18. package/scripts/android/app.sh +241 -0
  19. package/scripts/android/back.sh +73 -0
  20. package/scripts/android/controls.sh +56 -0
  21. package/scripts/android/devices.sh +77 -0
  22. package/scripts/android/doctor.sh +253 -0
  23. package/scripts/android/lib.sh +699 -0
  24. package/scripts/android/logs.sh +289 -0
  25. package/scripts/android/permission.sh +119 -0
  26. package/scripts/android/settings.sh +63 -0
  27. package/scripts/android/setup.sh +97 -0
  28. package/scripts/android/tree.awk +166 -0
  29. package/scripts/android/tree.sh +127 -0
  30. package/scripts/android/type.sh +191 -0
  31. package/scripts/common/find.sh +95 -0
  32. package/scripts/common/key.sh +65 -0
  33. package/scripts/common/lib.sh +14 -0
  34. package/scripts/common/measure.sh +170 -0
  35. package/scripts/common/open.sh +131 -0
  36. package/scripts/common/screenshot.sh +102 -0
  37. package/scripts/common/scroll.sh +177 -0
  38. package/scripts/common/snapshot.sh +69 -0
  39. package/scripts/common/swipe.sh +171 -0
  40. package/scripts/common/tap.sh +226 -0
  41. package/scripts/common/wait.sh +212 -0
  42. package/scripts/common/waypoint.sh +141 -0
  43. package/scripts/dispatch.sh +21 -0
  44. package/scripts/flow.sh +266 -0
  45. package/scripts/init.sh +101 -0
  46. package/scripts/ios/app.sh +404 -0
  47. package/scripts/ios/back.sh +95 -0
  48. package/scripts/ios/controls.sh +68 -0
  49. package/scripts/ios/devices.sh +80 -0
  50. package/scripts/ios/doctor.sh +386 -0
  51. package/scripts/ios/lib.sh +864 -0
  52. package/scripts/ios/logs.sh +272 -0
  53. package/scripts/ios/permission.sh +108 -0
  54. package/scripts/ios/settings.sh +76 -0
  55. package/scripts/ios/setup.sh +175 -0
  56. package/scripts/ios/tree.sh +128 -0
  57. package/scripts/ios/type.sh +178 -0
  58. package/scripts/lib.sh +1032 -0
  59. package/scripts/links.tsv +44 -0
  60. package/scripts/relink.sh +121 -0
  61. package/scripts/run.sh +415 -0
  62. package/scripts/selftest.sh +1709 -0
  63. package/scripts/snapshot.awk +362 -0
  64. package/scripts/verify-npm-package.js +133 -0
  65. package/tests/fixtures/ios-contacts-list.expected +52 -0
  66. package/tests/fixtures/ios-contacts-list.rows +140 -0
@@ -0,0 +1,44 @@
1
+ android/find.sh ../common/find.sh
2
+ android/key.sh ../common/key.sh
3
+ android/measure.sh ../common/measure.sh
4
+ android/open.sh ../common/open.sh
5
+ android/screenshot.sh ../common/screenshot.sh
6
+ android/scroll.sh ../common/scroll.sh
7
+ android/snapshot.sh ../common/snapshot.sh
8
+ android/swipe.sh ../common/swipe.sh
9
+ android/tap.sh ../common/tap.sh
10
+ android/wait.sh ../common/wait.sh
11
+ android/waypoint.sh ../common/waypoint.sh
12
+ app.sh dispatch.sh
13
+ back.sh dispatch.sh
14
+ controls.sh dispatch.sh
15
+ devices.sh dispatch.sh
16
+ doctor.sh dispatch.sh
17
+ find.sh dispatch.sh
18
+ ios/find.sh ../common/find.sh
19
+ ios/key.sh ../common/key.sh
20
+ ios/measure.sh ../common/measure.sh
21
+ ios/open.sh ../common/open.sh
22
+ ios/screenshot.sh ../common/screenshot.sh
23
+ ios/scroll.sh ../common/scroll.sh
24
+ ios/snapshot.sh ../common/snapshot.sh
25
+ ios/swipe.sh ../common/swipe.sh
26
+ ios/tap.sh ../common/tap.sh
27
+ ios/wait.sh ../common/wait.sh
28
+ ios/waypoint.sh ../common/waypoint.sh
29
+ key.sh dispatch.sh
30
+ logs.sh dispatch.sh
31
+ measure.sh dispatch.sh
32
+ open.sh dispatch.sh
33
+ permission.sh dispatch.sh
34
+ screenshot.sh dispatch.sh
35
+ scroll.sh dispatch.sh
36
+ settings.sh dispatch.sh
37
+ setup.sh dispatch.sh
38
+ snapshot.sh dispatch.sh
39
+ swipe.sh dispatch.sh
40
+ tap.sh dispatch.sh
41
+ tree.sh dispatch.sh
42
+ type.sh dispatch.sh
43
+ wait.sh dispatch.sh
44
+ waypoint.sh dispatch.sh
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env bash
2
+ # relink.sh — put back the symlinks a package manager threw away.
3
+ #
4
+ # relink.sh create any that are missing, report what changed
5
+ # relink.sh --check say whether the tree is complete, change nothing
6
+ # relink.sh --write regenerate scripts/links.tsv from the tree
7
+ #
8
+ # WHY THIS EXISTS AT ALL
9
+ #
10
+ # The verb surface is symlinks. `scripts/tap.sh` points at dispatch.sh, which
11
+ # resolves the platform and execs the adapter; `scripts/ios/tap.sh` points at
12
+ # `../common/tap.sh`, and the symlink's own DIRECTORY is what makes
13
+ # `source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"` reach the iOS adapter rather
14
+ # than the Android one. That is not a shortcut, it is the dispatch mechanism.
15
+ #
16
+ # `npm pack` does not carry symlinks. Measured, not assumed: a tarball of this
17
+ # repository came out with 63 files instead of 110 and ZERO symlinks — not
18
+ # dereferenced into copies, simply absent. Every verb was missing, and the MCP
19
+ # server would have answered `unknown tool` to all twenty-one of them while
20
+ # looking perfectly installed.
21
+ #
22
+ # So the links are written down and put back. scripts/links.tsv is GENERATED
23
+ # from the tree, never hand-edited, and a self-test fails if the two disagree —
24
+ # a hand-kept list is the second copy of a truth, and it is the copy that goes
25
+ # stale.
26
+ #
27
+ # WHY NOT A postinstall SCRIPT. `npm install --ignore-scripts` is common, some
28
+ # organisations enforce it, and a tool that is silently broken under a security
29
+ # setting is worse than one that is loudly broken. This runs from the entry
30
+ # point instead, where it cannot be turned off, and it is a no-op in the normal
31
+ # case — a git checkout already has its links.
32
+ #
33
+ # exit 0 the tree is complete (or was made complete)
34
+ # exit 1 usage error
35
+ # exit 2 something is in the way, or --check found the tree incomplete
36
+
37
+ set -euo pipefail
38
+
39
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
40
+ MANIFEST="$HERE/links.tsv"
41
+
42
+ MODE=create
43
+ case "${1-}" in
44
+ --check) MODE=check ;;
45
+ --write) MODE=write ;;
46
+ -h|--help) awk 'NR > 1 { if (!/^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}"; exit 0 ;;
47
+ "") ;;
48
+ *) printf 'unknown argument: %s (usage: relink.sh [--check|--write])\n' "$1" >&2; exit 1 ;;
49
+ esac
50
+
51
+ # --write regenerates the manifest from what is actually on disk, so the
52
+ # repository stays the source of truth and this file only ever transcribes it.
53
+ if [ "$MODE" = write ]; then
54
+ find "$HERE" -type l \
55
+ | sed "s|^$HERE/||" \
56
+ | sort \
57
+ | while IFS= read -r l; do
58
+ printf '%s\t%s\n' "$l" "$(readlink "$HERE/$l")"
59
+ done > "$MANIFEST.new"
60
+ mv "$MANIFEST.new" "$MANIFEST"
61
+ printf 'wrote %s — %s link(s)\n' "$MANIFEST" "$(grep -c . < "$MANIFEST" | tr -d ' ')"
62
+ exit 0
63
+ fi
64
+
65
+ [ -f "$MANIFEST" ] || {
66
+ printf 'no link manifest at %s — this checkout is incomplete\n' "$MANIFEST" >&2
67
+ exit 2
68
+ }
69
+
70
+ missing=0
71
+ made=0
72
+ while IFS=$'\t' read -r link target; do
73
+ [ -n "$link" ] || continue
74
+ full="$HERE/$link"
75
+
76
+ # Already a symlink pointing where it should: nothing to do, and the common
77
+ # case, because a git checkout carries them.
78
+ if [ -L "$full" ] && [ "$(readlink "$full")" = "$target" ]; then
79
+ continue
80
+ fi
81
+
82
+ # A REGULAR FILE IN THE WAY IS NOT SOMETHING TO REPLACE. It means somebody
83
+ # made a verb into a real script deliberately, or an installer dereferenced
84
+ # the link into a copy. Either way, silently overwriting their file to restore
85
+ # a link is a worse answer than saying what is there.
86
+ if [ -e "$full" ] && [ ! -L "$full" ]; then
87
+ printf 'in the way: %s is a regular file, not a link to %s\n' "$link" "$target" >&2
88
+ missing=$((missing + 1))
89
+ continue
90
+ fi
91
+
92
+ missing=$((missing + 1))
93
+ [ "$MODE" = check ] && continue
94
+
95
+ mkdir -p "$(dirname "$full")" 2>/dev/null || true
96
+ rm -f "$full" 2>/dev/null || true
97
+ if ln -s "$target" "$full" 2>/dev/null; then
98
+ made=$((made + 1))
99
+ else
100
+ printf 'could not create %s -> %s\n' "$link" "$target" >&2
101
+ fi
102
+ done < "$MANIFEST"
103
+
104
+ if [ "$MODE" = check ]; then
105
+ if [ "$missing" -eq 0 ]; then
106
+ printf 'OK the verb surface is complete — %s link(s)\n' "$(grep -c . < "$MANIFEST" | tr -d ' ')"
107
+ exit 0
108
+ fi
109
+ printf '%s of %s link(s) are missing — run relink.sh\n' \
110
+ "$missing" "$(grep -c . < "$MANIFEST" | tr -d ' ')" >&2
111
+ exit 2
112
+ fi
113
+
114
+ if [ "$made" -eq 0 ] && [ "$missing" -eq 0 ]; then
115
+ printf 'OK nothing to do — the verb surface is already complete\n'
116
+ elif [ "$made" -eq "$missing" ]; then
117
+ printf 'OK restored %s link(s) that the package manager dropped\n' "$made"
118
+ else
119
+ printf 'restored %s of %s missing link(s)\n' "$made" "$missing" >&2
120
+ exit 2
121
+ fi
package/scripts/run.sh ADDED
@@ -0,0 +1,415 @@
1
+ #!/usr/bin/env bash
2
+ # run.sh — several steps, one call.
3
+ #
4
+ # run.sh --steps '<json array>' [--var NAME=VALUE]... [--vars '<json object>']
5
+ # run.sh --steps @path/to/steps.json
6
+ #
7
+ # WHY THIS EXISTS
8
+ #
9
+ # Measured on a real production app: logging in with an already-activated account
10
+ # and opening Settings took twelve calls, of which six were snapshots taken only
11
+ # to see what the previous action had produced. A person does the same thing in
12
+ # about twenty seconds and has no concept of a "call" at all.
13
+ #
14
+ # --snapshot removed the reads. This removes the rest: gõ mật khẩu rồi bấm đăng
15
+ # nhập is one continuous act for a person and was four separate round trips
16
+ # here. Each round trip re-sends the whole conversation to the model, so the
17
+ # NUMBER of calls is the cost, not the size of what any one of them returns.
18
+ #
19
+ # run --steps '[
20
+ # {"tap": "label:Đăng nhập"},
21
+ # {"type": "{{ password }}"},
22
+ # {"key": "hide"},
23
+ # {"tap": "label:Xác nhận"},
24
+ # {"wait": "screen:Home"}
25
+ # ]'
26
+ #
27
+ # A STEP IS AN OBJECT WITH EXACTLY ONE VERB IN IT. The verb's value is what you
28
+ # would have typed after it; every other key becomes --key value, and a key
29
+ # whose value is true becomes a bare --key. Two positional arguments go in an
30
+ # array: {"swipe": ["label:A", "label:B"]}.
31
+ #
32
+ # WHAT IT REFUSES, AND WHY EACH REFUSAL IS THERE
33
+ #
34
+ # NO UIDS. A uid means "the seventh thing on the screen I was last shown", and
35
+ # inside a batch there is no such screen — the numbering the caller was looking
36
+ # at is two steps stale by the time step three runs. Selectors resolve against
37
+ # whatever is actually there, so they are the only spelling that can mean
38
+ # anything here.
39
+ #
40
+ # THE WHOLE LIST IS CHECKED BEFORE THE FIRST STEP RUNS. A typo in step seven
41
+ # must not be discovered after step six has already tapped something in a live session.
42
+ #
43
+ # IT STOPS AT THE FIRST FAILURE, AND SHOWS THE SCENE. A batch that reported only
44
+ # "step 3 failed" would trade one clear error for a black box: it hides the very
45
+ # screens that make it debuggable. So the transcript carries every step's own
46
+ # output, and a failure is followed by a snapshot of where it stopped.
47
+ #
48
+ # VARIABLES ARE NOT WRITTEN INTO THE STEPS. {{ name }} is filled from --var,
49
+ # then from DEVICETOOLS_VAR_<NAME> in the environment, then from .vars in the
50
+ # config. A password in a step list is a password in a shell history, in a
51
+ # process table and in a model's context; and the substituted value is masked
52
+ # back to {{ name }} in everything this prints.
53
+ #
54
+ # THERE IS NO `expect`, BECAUSE `wait` ALREADY IS ONE. An assertion is a wait
55
+ # with a short deadline: {"wait": "screen:Auth.LoginView", "timeout": 2} fails in
56
+ # two seconds if the app is not where the flow was written to start. A second
57
+ # verb meaning the same thing is a second thing to keep true.
58
+ #
59
+ # --steps S the list, as JSON, or @file to read it from one
60
+ # --var N=V one variable; repeatable
61
+ # --vars J several, as a JSON object
62
+ # --dry-run validate and print what would run, touch nothing
63
+ # --logs keep the LOG block on every step, not only the failing one
64
+ #
65
+ # LOGS ARE OFF FOR STEPS THAT WORKED, AND THAT IS ABOUT COST.
66
+ #
67
+ # Every action attaches the app's log lines since it began. Alone that is a few
68
+ # lines; in a batch of eight it was fifty-six, almost all of them
69
+ # `UIAccessibility: Attempting to send notification` — more noise than the whole
70
+ # batch produced in signal, and growing with the length of the batch, which is
71
+ # exactly the thing this verb exists to make longer. So a step that succeeded
72
+ # has its LOG block dropped and the step that FAILED keeps its own, which is the
73
+ # one anybody is reading. --logs keeps them all.
74
+ #
75
+ # exit 0 every step ran
76
+ # exit 1 usage error, or the step list is not usable
77
+ # exit * the exit code of the step that stopped it
78
+ #
79
+ # NOT A TEST RUNNER. Nothing here asserts, scores, retries or records a
80
+ # baseline. `wait` is how a step checks something, and it checks it by waiting
81
+ # for it. The file that used to be called run.sh in this repository was a test
82
+ # runner and was deleted; this is not it coming back.
83
+
84
+ source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
85
+
86
+ # The verbs a batch may contain. Not every verb: `measure` takes a uid and
87
+ # nothing else, so it cannot be spelled here at all, and `doctor`, `devices` and
88
+ # `setup` are things you run when a batch has already gone wrong.
89
+ RUN_VERBS="tap type swipe scroll back key wait expect snapshot screenshot app open logs waypoint flow"
90
+
91
+ # `expect` IS `wait` WITH A DEADLINE THAT MEANS "NOW", AND NOTHING ELSE.
92
+ #
93
+ # It is spelling, not a second implementation: the step runs wait.sh, and if it
94
+ # names no timeout it gets this one. The distinction it buys is real — a wait is
95
+ # "this will arrive", an expect is "this is already true" — and a flow's first
96
+ # step is almost always the second. On the app this was built against, logging
97
+ # in has three different routes depending on whether the device is activated, so
98
+ # a flow that does not check where it starts taps into a screen it was not
99
+ # written for.
100
+ #
101
+ # It is not a verb outside a batch and not a tool: there is still one wait.sh.
102
+ EXPECT_TIMEOUT=3
103
+
104
+ # Keys whose value is a selector, and which therefore may not carry a uid.
105
+ SEL_KEYS="target from to until"
106
+
107
+ # Verbs whose POSITIONAL argument is a selector. `type` is not one of them: its
108
+ # argument is the text to type, and "888888" is an OTP, not a uid.
109
+ SEL_VERBS="tap wait swipe expect"
110
+
111
+ STEPS=""
112
+ VARS='{}'
113
+ DRY=0
114
+ KEEP_LOGS=0
115
+
116
+ add_var() { # add_var NAME=VALUE
117
+ local kv="$1" k v
118
+ case "$kv" in *=*) ;; *) die "--var wants NAME=VALUE, got '$kv'" 1 ;; esac
119
+ k="${kv%%=*}"; v="${kv#*=}"
120
+ case "$k" in
121
+ ""|*[!A-Za-z0-9_]*) die "a variable name may only hold letters, digits and underscores — got '$k'" 1 ;;
122
+ esac
123
+ VARS="$(printf '%s' "$VARS" | jq -c --arg k "$k" --arg v "$v" '.[$k] = $v')"
124
+ }
125
+
126
+ while [ $# -gt 0 ]; do
127
+ case "$1" in
128
+ --steps)
129
+ [ $# -ge 2 ] || die "--steps needs a JSON array, or @file" 1
130
+ case "$2" in
131
+ @*) STEPS="$(cat "$(expand_path "${2#@}")" 2>/dev/null || true)"
132
+ [ -n "$STEPS" ] || die "could not read the step list at ${2#@}" 1 ;;
133
+ *) STEPS="$2" ;;
134
+ esac
135
+ shift 2 ;;
136
+ --var)
137
+ [ $# -ge 2 ] || die "--var needs NAME=VALUE" 1
138
+ add_var "$2"; shift 2 ;;
139
+ --vars)
140
+ [ $# -ge 2 ] || die "--vars needs a JSON object" 1
141
+ printf '%s' "$2" | jq -e 'type == "object"' >/dev/null 2>&1 \
142
+ || die "--vars must be a JSON object of name to value" 1
143
+ VARS="$(jq -cn --argjson a "$VARS" --argjson b "$2" '$a * $b')"
144
+ shift 2 ;;
145
+ --dry-run) DRY=1; shift ;;
146
+ --logs) KEEP_LOGS=1; shift ;;
147
+ -h|--help) awk 'NR > 1 { if (!/^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}"; exit 0 ;;
148
+ *) die "unknown argument: $1 (usage: run.sh --steps '<json>' [--var N=V] [--dry-run])" 1 ;;
149
+ esac
150
+ done
151
+
152
+ [ -n "$STEPS" ] || die "missing --steps (usage: run.sh --steps '[{\"tap\":\"label:X\"}]')" 1
153
+ load_config
154
+
155
+ # --- variables ----------------------------------------------------------------
156
+ #
157
+ # The environment and the config are read after the flags, so a --var on the
158
+ # command line wins. DEVICETOOLS_VAR_* is how the MCP server hands a secret to a
159
+ # child without it appearing in an argument list.
160
+ for name in $(env | sed -n 's/^\(DEVICETOOLS_VAR_[A-Za-z0-9_]*\)=.*/\1/p'); do
161
+ k="${name#DEVICETOOLS_VAR_}"
162
+ [ "$(printf '%s' "$VARS" | jq -r --arg k "$k" 'has($k)')" = true ] && continue
163
+ VARS="$(printf '%s' "$VARS" | jq -c --arg k "$k" --arg v "${!name}" '.[$k] = $v')"
164
+ done
165
+
166
+ cfg_vars="$(jq -c '.vars // {}' "$DT_CONFIG" 2>/dev/null || printf '{}')"
167
+ VARS="$(jq -cn --argjson a "$cfg_vars" --argjson b "$VARS" '$a * $b')"
168
+
169
+ # HANDED DOWN THROUGH THE ENVIRONMENT, NOT THROUGH AN ARGUMENT LIST.
170
+ #
171
+ # A {"flow": "login"} step runs a whole flow inside this one, and that flow
172
+ # needs the same password. Passing it as `--var password=…` would put it in the
173
+ # child's command line, where it is visible in the process table to every
174
+ # process on the machine. The environment is the channel this tool already
175
+ # documents for exactly that, and it is the one the MCP server uses.
176
+ while IFS=$'\t' read -r k v; do
177
+ [ -n "$k" ] || continue
178
+ export "DEVICETOOLS_VAR_$k=$v"
179
+ done <<< "$(printf '%s' "$VARS" | jq -r 'to_entries[] | "\(.key)\t\(.value)"')"
180
+
181
+ # --- the step list ------------------------------------------------------------
182
+
183
+ printf '%s' "$STEPS" | jq -e 'type == "array" and length > 0' >/dev/null 2>&1 \
184
+ || die "--steps must be a non-empty JSON array of step objects" 1
185
+
186
+ # Substitution first, so validation sees what will actually run: a {{ var }}
187
+ # holding "7" is a uid whichever way it was spelled.
188
+ EXPANDED="$(printf '%s' "$STEPS" | jq -c --argjson vars "$VARS" '
189
+ def subst:
190
+ if type == "string"
191
+ then reduce ($vars | to_entries[]) as $v (.;
192
+ gsub("\\{\\{ *" + $v.key + " *\\}\\}"; $v.value))
193
+ else . end;
194
+ walk(subst)
195
+ ' 2>/dev/null)" || die "the step list is not valid JSON" 1
196
+
197
+ # A LEFTOVER PLACEHOLDER IS A MISSING VARIABLE, NOT A STRING TO TYPE.
198
+ #
199
+ # Without this, a flow whose password was never supplied types the eleven
200
+ # characters "{{ password }}" into a login form and reports success,
201
+ # because something did change on the screen.
202
+ missing="$(printf '%s' "$EXPANDED" | jq -r '
203
+ [ .. | strings | match("\\{\\{ *([A-Za-z0-9_]+) *\\}\\}"; "g").captures[0].string ]
204
+ | unique | join(", ")')"
205
+ [ -z "$missing" ] \
206
+ || die "no value for: $missing — pass --var $(printf '%s' "$missing" | cut -d, -f1)=… , set DEVICETOOLS_VAR_… in the environment, or add it under .vars in $DT_CONFIG" 1
207
+
208
+ # --- validation, all of it, before anything runs ------------------------------
209
+
210
+ N="$(printf '%s' "$EXPANDED" | jq 'length')"
211
+
212
+ step_verb() { # step_verb <step-json> — the one verb key, or a refusal
213
+ local s="$1" keys hit="" k
214
+ printf '%s' "$s" | jq -e 'type == "object"' >/dev/null 2>&1 \
215
+ || { printf 'not an object'; return 1; }
216
+ keys="$(printf '%s' "$s" | jq -r 'keys_unsorted[]')"
217
+ for k in $keys; do
218
+ case " $RUN_VERBS " in *" $k "*) hit="$hit $k" ;; esac
219
+ done
220
+
221
+ # `snapshot` IS BOTH A VERB AND A FLAG, AND THE VERB LIST WAS EATING IT.
222
+ #
223
+ # {"tap": "label:X", "snapshot": true} is the single most useful step anyone
224
+ # can write — act, and get the screen back in the same call — and it was
225
+ # refused as "two verbs in one step". Every other verb takes --snapshot; the
226
+ # only reading in which `snapshot` here is a verb is when it is the ONLY verb.
227
+ set -- $hit
228
+ if [ $# -gt 1 ]; then
229
+ hit=""
230
+ for k in "$@"; do
231
+ [ "$k" = snapshot ] || hit="$hit $k"
232
+ done
233
+ set -- $hit
234
+ fi
235
+
236
+ case $# in
237
+ 0) printf 'no verb in it — one key must be one of: %s' "$RUN_VERBS"; return 1 ;;
238
+ 1) printf '%s' "$1"; return 0 ;;
239
+ *) printf 'two verbs in one step (%s) — a step does one thing' "${hit# }"; return 1 ;;
240
+ esac
241
+ }
242
+
243
+ i=0
244
+ while [ "$i" -lt "$N" ]; do
245
+ s="$(printf '%s' "$EXPANDED" | jq -c ".[$i]")"
246
+ v="$(step_verb "$s")" || die "step $((i + 1)): $v" 1
247
+
248
+ # No uids — BUT ONLY WHERE A SELECTOR IS WHAT THE VERB EXPECTS.
249
+ #
250
+ # This checked the positional argument of every verb, and `type`'s positional
251
+ # argument is the text to type. So a batch could not type an OTP, a PIN, an
252
+ # amount, a card number or a phone number — on a production app, close to every
253
+ # field there is. Substitution runs before validation, so {{ otp }} did not
254
+ # get round it either: it became 888888 and was then refused as a uid.
255
+ #
256
+ # It also meant `flow` could not hold a login, which is the one example the
257
+ # tool's own description gives for why flows exist.
258
+ #
259
+ # So the positional is only checked for the verbs whose positional IS a
260
+ # selector. The named keys below are checked everywhere, because those names
261
+ # only ever carry selectors.
262
+ case " $SEL_VERBS " in
263
+ *" $v "*) pos_check=true ;;
264
+ *) pos_check=false ;;
265
+ esac
266
+ bad="$(printf '%s' "$s" | jq -r --arg v "$v" --arg sel "$SEL_KEYS" --argjson pos "$pos_check" '
267
+ ($sel | split(" ")) as $keys
268
+ | [ (if $pos then (.[$v] | if type == "array" then .[] else . end) else empty end),
269
+ (.[$keys[]]? // empty) ]
270
+ | map(select(type == "string"))
271
+ | map(select(test("^[0-9]+$") or startswith("uid:")))
272
+ | join(", ")')"
273
+ [ -z "$bad" ] \
274
+ || die "step $((i + 1)) ($v): '$bad' is a uid, and a uid names a position on a screen that is already two steps old by the time this step runs. Use id:, label:, kind:, text: or xy:" 1
275
+
276
+ i=$((i + 1))
277
+ done
278
+
279
+ # --- masking ------------------------------------------------------------------
280
+ #
281
+ # Everything printed goes through this. The substituted value is put back as
282
+ # {{ name }}, so a transcript that ends up in a log, a ticket or a model's
283
+ # context carries the shape of the flow and not the credential.
284
+ mask() {
285
+ local s="$1" k v
286
+ while IFS=$'\t' read -r k v; do
287
+ [ -n "$v" ] || continue
288
+ # The replacement is built first. Backslash-escaping the braces inline puts
289
+ # the backslashes themselves into the output — the transcript then reads
290
+ # `tap label:\{\{ password \}\}`, which is masked and also wrong.
291
+ rep="{{ $k }}"
292
+ s="${s//"$v"/$rep}"
293
+ done <<< "$(printf '%s' "$VARS" | jq -r 'to_entries[] | "\(.key)\t\(.value)"')"
294
+ printf '%s' "$s"
295
+ }
296
+
297
+ # step_line <n> <verb> <step-json> — how the step is announced.
298
+ #
299
+ # `type` is announced as a length and never as text, the same rule the verb
300
+ # itself follows: it drives a login form and this output is read by a
301
+ # model and written to a log.
302
+ step_line() {
303
+ local n="$1" v="$2" s="$3" args
304
+ if [ "$v" = type ]; then
305
+ # The text becomes a count and never a string; the FLAGS still show, or a
306
+ # step written {"type": …, "snapshot": true} would silently look like one
307
+ # that was not.
308
+ args="$(printf '%s' "$s" | jq -r '
309
+ [ (.type | tostring | length | "(\(.) chars)") ]
310
+ + [ to_entries[] | select(.key != "type")
311
+ | if (.value | type) == "boolean"
312
+ then (if .value then "--" + .key else empty end)
313
+ else "--" + .key + " " + (.value | tostring) end ]
314
+ | join(" ")')"
315
+ else
316
+ args="$(printf '%s' "$s" | jq -r --arg v "$v" '
317
+ [ (.[$v] | if type == "array" then .[] else . end | tostring | select(. != "true")) ]
318
+ + [ to_entries[] | select(.key != $v)
319
+ | if (.value | type) == "boolean"
320
+ then (if .value then "--" + .key else empty end)
321
+ else "--" + .key + " " + (.value | tostring) end ]
322
+ | join(" ")')"
323
+ fi
324
+ args="$(mask "$args")"
325
+ # A verb with no arguments — {"snapshot": true} — must not print a trailing
326
+ # space, because the transcript is compared byte for byte by the self-test.
327
+ printf 'STEP %s/%s %s%s' "$n" "$N" "$v" "${args:+ $args}"
328
+ }
329
+
330
+ # --- running ------------------------------------------------------------------
331
+
332
+ printf 'RUN %s step%s\n' "$N" "$( [ "$N" = 1 ] || printf s )"
333
+
334
+ i=0
335
+ ran=0
336
+ while [ "$i" -lt "$N" ]; do
337
+ s="$(printf '%s' "$EXPANDED" | jq -c ".[$i]")"
338
+ v="$(step_verb "$s")" || die "step $((i + 1)): $v" 1
339
+ n=$((i + 1))
340
+
341
+ printf '%s\n' "$(step_line "$n" "$v" "$s")"
342
+
343
+
344
+ # NUL-separated, read straight into an array, so an argument is an argument
345
+ # whatever is in it. Same rule as the MCP server's argv_from, for the same
346
+ # reason: the app on the other end of this is somebody's production app, and there is no
347
+ # quoting to get wrong if nothing is ever re-parsed.
348
+ args=()
349
+ while IFS= read -r -d '' one; do args+=("$one"); done < <(
350
+ printf '%s' "$s" | jq -j --arg v "$v" '
351
+ ( .[$v]
352
+ | if type == "array" then .[] elif type == "boolean" then empty else . end
353
+ | tostring ) + "\u0000",
354
+ ( to_entries[] as $e
355
+ | select($e.key != $v)
356
+ | if ($e.value | type) == "boolean"
357
+ then (if $e.value then "--" + $e.key + "\u0000" else empty end)
358
+ else "--" + $e.key + "\u0000" + ($e.value | tostring) + "\u0000"
359
+ end )')
360
+
361
+ # A DRY RUN STILL DESCENDS INTO A NESTED BATCH.
362
+ #
363
+ # Skipping a {"flow": "login"} step would leave the composed list unchecked,
364
+ # which is most of what there is to check: whether the flow exists, whether
365
+ # its own steps are valid, and whether it includes itself. The child is run
366
+ # with --dry-run, so it touches the device exactly as little as this does.
367
+ if [ "$DRY" -eq 1 ]; then
368
+ case "$v" in
369
+ flow|run) args+=(--dry-run) ;;
370
+ *) i="$n"; ran="$n"; continue ;;
371
+ esac
372
+ fi
373
+
374
+ script="$v"
375
+ if [ "$v" = expect ]; then
376
+ script=wait
377
+ printf '%s' "$s" | jq -e 'has("timeout")' >/dev/null 2>&1 \
378
+ || args+=(--timeout "$EXPECT_TIMEOUT")
379
+ fi
380
+
381
+ rc=0
382
+ out="$("$DT_HOME/scripts/$script.sh" ${args[@]+"${args[@]}"} 2>&1)" || rc=$?
383
+
384
+ # The LOG block of a step that worked. See the note in the header.
385
+ if [ "$rc" -eq 0 ] && [ "$KEEP_LOGS" -eq 0 ]; then
386
+ out="$(printf '%s\n' "$out" | strip_log_block)"
387
+ fi
388
+ [ -z "$out" ] || printf '%s\n' "$(mask "$out")" | sed 's/^/ /'
389
+
390
+ if [ "$rc" -ne 0 ]; then
391
+ printf 'RUN FAILED at step %s of %s — %s step(s) ran, EXIT %s\n' "$n" "$N" "$ran" "$rc"
392
+ # THE SCENE, NOT JUST THE VERDICT. A batch that reports only which step
393
+ # failed has swapped a legible error for a black box: the caller can no
394
+ # longer see the intermediate screens, so it cannot tell a wrong selector
395
+ # from a screen that never arrived.
396
+ #
397
+ # Except when the step that failed was itself a batch — it has already
398
+ # printed the screen, and a nested flow three deep would otherwise fetch and
399
+ # print the same tree three times on its way back up.
400
+ case "$v" in
401
+ flow|run) ;;
402
+ *) printf '\n'; "$DT_HOME/scripts/snapshot.sh" 2>&1 | sed 's/^/ /' || true ;;
403
+ esac
404
+ exit "$rc"
405
+ fi
406
+
407
+ ran="$n"
408
+ i="$n"
409
+ done
410
+
411
+ if [ "$DRY" -eq 1 ]; then
412
+ printf 'RUN dry — %s step(s) validated, nothing was sent to the device\n' "$N"
413
+ else
414
+ printf 'RUN OK %s of %s step(s)\n' "$ran" "$N"
415
+ fi