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.
- package/AGENTS.md +327 -0
- package/LICENSE +21 -0
- package/README.md +491 -0
- package/VERSION +1 -0
- package/bin/device-devtools-mcp.js +106 -0
- package/bin/devicetools +315 -0
- package/config.example.json +63 -0
- package/integrations/agent-pointer.sh +48 -0
- package/integrations/claude/SKILL.md +8 -0
- package/integrations/cursor/devicetools.mdc +8 -0
- package/integrations/gemini/GEMINI.md +5 -0
- package/integrations/mcp/README.md +184 -0
- package/integrations/mcp/mcp.json +10 -0
- package/integrations/mcp/reference.sh +119 -0
- package/integrations/mcp/selftest.sh +158 -0
- package/integrations/mcp/server.sh +343 -0
- package/package.json +50 -0
- package/scripts/android/app.sh +241 -0
- package/scripts/android/back.sh +73 -0
- package/scripts/android/controls.sh +56 -0
- package/scripts/android/devices.sh +77 -0
- package/scripts/android/doctor.sh +253 -0
- package/scripts/android/lib.sh +699 -0
- package/scripts/android/logs.sh +289 -0
- package/scripts/android/permission.sh +119 -0
- package/scripts/android/settings.sh +63 -0
- package/scripts/android/setup.sh +97 -0
- package/scripts/android/tree.awk +166 -0
- package/scripts/android/tree.sh +127 -0
- package/scripts/android/type.sh +191 -0
- package/scripts/common/find.sh +95 -0
- package/scripts/common/key.sh +65 -0
- package/scripts/common/lib.sh +14 -0
- package/scripts/common/measure.sh +170 -0
- package/scripts/common/open.sh +131 -0
- package/scripts/common/screenshot.sh +102 -0
- package/scripts/common/scroll.sh +177 -0
- package/scripts/common/snapshot.sh +69 -0
- package/scripts/common/swipe.sh +171 -0
- package/scripts/common/tap.sh +226 -0
- package/scripts/common/wait.sh +212 -0
- package/scripts/common/waypoint.sh +141 -0
- package/scripts/dispatch.sh +21 -0
- package/scripts/flow.sh +266 -0
- package/scripts/init.sh +101 -0
- package/scripts/ios/app.sh +404 -0
- package/scripts/ios/back.sh +95 -0
- package/scripts/ios/controls.sh +68 -0
- package/scripts/ios/devices.sh +80 -0
- package/scripts/ios/doctor.sh +386 -0
- package/scripts/ios/lib.sh +864 -0
- package/scripts/ios/logs.sh +272 -0
- package/scripts/ios/permission.sh +108 -0
- package/scripts/ios/settings.sh +76 -0
- package/scripts/ios/setup.sh +175 -0
- package/scripts/ios/tree.sh +128 -0
- package/scripts/ios/type.sh +178 -0
- package/scripts/lib.sh +1032 -0
- package/scripts/links.tsv +44 -0
- package/scripts/relink.sh +121 -0
- package/scripts/run.sh +415 -0
- package/scripts/selftest.sh +1709 -0
- package/scripts/snapshot.awk +362 -0
- package/scripts/verify-npm-package.js +133 -0
- package/tests/fixtures/ios-contacts-list.expected +52 -0
- 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
|