deepseek-coordination 0.1.9 → 0.10.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/LICENSE +21 -21
- package/README.md +349 -1083
- package/README.zh-CN.md +349 -697
- package/cordis.patch.yml +5 -74
- package/lib/client.js +33772 -0
- package/lib/index.d.mts +252 -0
- package/lib/index.mjs +27374 -0
- package/package.json +98 -114
- package/client.js +0 -338
- package/src/host/bin/dsh-os.bat +0 -19
- package/src/host/bin/dsh-os.ps1 +0 -5039
- package/src/host/bin/dsh-os.sh +0 -3385
- package/src/host/bin/dsh-os7.ps1 +0 -5518
- package/src/host/command.txt +0 -136
- package/src/host/docs/command-original.txt +0 -26
- package/src/host/docs/os-spec.md +0 -528
- package/src/host/docs/permission-model.md +0 -206
- package/src/host/spec/charsets.tsv +0 -28
- package/src/host/spec/verbs.tsv +0 -120
- package/src/permission/index.js +0 -546
- package/src/plugin/index.js +0 -226
- package/src/plugin/invoke.js +0 -306
- package/src/plugin/settings.js +0 -349
- package/src/plugin/takeover.js +0 -436
- package/src/plugin/workflow.js +0 -1494
- package/tools/measure-cache.mjs +0 -223
package/src/host/bin/dsh-os.sh
DELETED
|
@@ -1,3385 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# dsh-os.sh - GENERATED FILE. Do not edit directly.
|
|
3
|
-
#
|
|
4
|
-
# Assembled by tools/build.ps1 from src/host/lib/*.sh, which are the source of truth:
|
|
5
|
-
#
|
|
6
|
-
# preamble.sh
|
|
7
|
-
# json.sh
|
|
8
|
-
# command.sh
|
|
9
|
-
# native.sh
|
|
10
|
-
# paths.sh
|
|
11
|
-
# charset.sh
|
|
12
|
-
# file.sh
|
|
13
|
-
# system.sh
|
|
14
|
-
# execute.sh
|
|
15
|
-
# task.sh
|
|
16
|
-
# environment.sh
|
|
17
|
-
# service.sh
|
|
18
|
-
# permission.sh
|
|
19
|
-
# window.sh
|
|
20
|
-
# dispatch.sh
|
|
21
|
-
# entry.sh
|
|
22
|
-
#
|
|
23
|
-
# The shipped script is deliberately a single self-contained file, LF-terminated and BOM-free: the
|
|
24
|
-
# shebang must be the very first bytes, and a carriage return would become part of the last token on
|
|
25
|
-
# every line.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
# ===== preamble.sh =================================================
|
|
29
|
-
|
|
30
|
-
#!/usr/bin/env bash
|
|
31
|
-
#
|
|
32
|
-
# dsh-os.sh - POSIX implementation of the dsh-os atomic command surface.
|
|
33
|
-
#
|
|
34
|
-
# Contract: command.txt (authoritative), docs/os-spec.md, spec/verbs.tsv, spec/charsets.tsv.
|
|
35
|
-
# This file is the counterpart of src/host/bin/dsh-os.ps1 and must accept exactly the same
|
|
36
|
-
# arguments and emit exactly the same JSON envelope shape, so a caller can run the same command on
|
|
37
|
-
# either platform and parse the same result.
|
|
38
|
-
#
|
|
39
|
-
# Design rules, mirroring the Windows implementation:
|
|
40
|
-
# * Values arrive as separate argv elements. This script never re-parses a command line and never
|
|
41
|
-
# assembles one, so a path containing spaces stays a single argument.
|
|
42
|
-
# * File content is read and written byte-exactly. The BOM is stripped on read and restored on
|
|
43
|
-
# write; the file's line-ending convention is preserved across every mutation.
|
|
44
|
-
# * stdout carries exactly one UTF-8 (no BOM) JSON envelope. stderr is for humans only.
|
|
45
|
-
# * Read-only verbs never prompt; mutating verbs require --allow.
|
|
46
|
-
# * Unsupported capabilities return a typed error rather than an empty success.
|
|
47
|
-
#
|
|
48
|
-
# Requires only POSIX shell utilities plus iconv(1). LC_ALL is pinned to a UTF-8 locale because a
|
|
49
|
-
# byte-oriented C locale silently corrupts non-ASCII paths and content.
|
|
50
|
-
|
|
51
|
-
set -euo pipefail
|
|
52
|
-
|
|
53
|
-
# ---------------------------------------------------------------------------
|
|
54
|
-
# Locale and stream encoding. A non-UTF-8 locale is refused rather than
|
|
55
|
-
# tolerated: silently mangling non-ASCII text is the exact failure this layer
|
|
56
|
-
# exists to prevent.
|
|
57
|
-
# ---------------------------------------------------------------------------
|
|
58
|
-
pin_locale() {
|
|
59
|
-
for candidate in C.UTF-8 en_US.UTF-8 C.utf8; do
|
|
60
|
-
if [ "$(LC_ALL="$candidate" locale charmap 2>/dev/null || true)" = "UTF-8" ]; then
|
|
61
|
-
export LC_ALL="$candidate"
|
|
62
|
-
return 0
|
|
63
|
-
fi
|
|
64
|
-
done
|
|
65
|
-
# Fall back to whatever is set if it is genuinely UTF-8.
|
|
66
|
-
if [ "$(locale charmap 2>/dev/null || true)" = "UTF-8" ]; then
|
|
67
|
-
export LC_ALL="${LC_ALL:-${LANG:-C.UTF-8}}"
|
|
68
|
-
return 0
|
|
69
|
-
fi
|
|
70
|
-
printf '%s\n' '{"ok":false,"verb":null,"error":{"code":"E_NOT_SUPPORTED","message":"a UTF-8 locale is required; set LC_ALL to a UTF-8 locale","hint":"try LC_ALL=C.UTF-8"}}'
|
|
71
|
-
exit 4
|
|
72
|
-
}
|
|
73
|
-
pin_locale
|
|
74
|
-
|
|
75
|
-
SELF_PATH="$0"
|
|
76
|
-
case "$SELF_PATH" in
|
|
77
|
-
/*) ;;
|
|
78
|
-
*) SELF_PATH="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")" ;;
|
|
79
|
-
esac
|
|
80
|
-
SCRIPT_DIR="$(cd "$(dirname "$SELF_PATH")" && pwd)"
|
|
81
|
-
HOST_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
82
|
-
|
|
83
|
-
# The repository root is found by walking upward for a marker rather than by counting ".." levels:
|
|
84
|
-
# counting is what silently produced a wrong root on the Windows side and disarmed the
|
|
85
|
-
# protected-path guard, so both implementations locate the root the same explicit way.
|
|
86
|
-
find_repo_dir() {
|
|
87
|
-
dir="$1"
|
|
88
|
-
local i=0
|
|
89
|
-
while [ -n "$dir" ] && [ "$i" -lt 8 ]; do
|
|
90
|
-
if [ -f "$dir/package.json" ] && [ -d "$dir/src/host" ]; then
|
|
91
|
-
printf '%s' "$dir"
|
|
92
|
-
return 0
|
|
93
|
-
fi
|
|
94
|
-
local parent
|
|
95
|
-
parent="$(dirname "$dir")"
|
|
96
|
-
# `[ ... ] && break` must not be the last command of the function: when the test is FALSE its
|
|
97
|
-
# status is 1, and because the caller assigns the result with ROOT_DIR="$(find_repo_dir ...)",
|
|
98
|
-
# `set -e` then aborts the entire script during load. That made every failing invocation die
|
|
99
|
-
# before the entry point could re-emit its envelope, and it also silently emptied ROOT_DIR.
|
|
100
|
-
if [ "$parent" = "$dir" ]; then
|
|
101
|
-
break
|
|
102
|
-
fi
|
|
103
|
-
dir="$parent"
|
|
104
|
-
i=$((i + 1))
|
|
105
|
-
done
|
|
106
|
-
# Fall back to the historical assumption so a relocated script still runs.
|
|
107
|
-
(cd "$SCRIPT_DIR/../.." && pwd)
|
|
108
|
-
return 0
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
# ===== json.sh =====================================================
|
|
112
|
-
|
|
113
|
-
ROOT_DIR="$(find_repo_dir "$SCRIPT_DIR")"
|
|
114
|
-
|
|
115
|
-
# ---------------------------------------------------------------------------
|
|
116
|
-
# JSON helpers. The envelope is built by hand so that control characters and
|
|
117
|
-
# non-ASCII are escaped predictably and stdout stays byte-stable.
|
|
118
|
-
# ---------------------------------------------------------------------------
|
|
119
|
-
json_escape() {
|
|
120
|
-
# Escape a string for inclusion in a JSON document, on top of the raw bytes.
|
|
121
|
-
LC_ALL=C awk '
|
|
122
|
-
BEGIN { RS = "\x01" }
|
|
123
|
-
{
|
|
124
|
-
s = $0
|
|
125
|
-
out = ""
|
|
126
|
-
# Walk byte by byte; only the characters JSON requires are escaped.
|
|
127
|
-
for (i = 1; i <= length(s); i++) {
|
|
128
|
-
c = substr(s, i, 1)
|
|
129
|
-
o = index("\"\\", c)
|
|
130
|
-
if (c == "\"") { out = out "\\\"" }
|
|
131
|
-
else if (c == "\\") { out = out "\\\\" }
|
|
132
|
-
else if (c == "\n") { out = out "\\n" }
|
|
133
|
-
else if (c == "\r") { out = out "\\r" }
|
|
134
|
-
else if (c == "\t") { out = out "\\t" }
|
|
135
|
-
else {
|
|
136
|
-
v = 0
|
|
137
|
-
# Detect control bytes; awk in the C locale treats each byte separately.
|
|
138
|
-
if (c ~ /[\001-\010\013\014\016-\037\177]/) {
|
|
139
|
-
printf "" > "/dev/null"
|
|
140
|
-
out = out sprintf("\\u%04x", 0)
|
|
141
|
-
} else {
|
|
142
|
-
out = out c
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
printf "%s", out
|
|
147
|
-
}
|
|
148
|
-
' <<<"$1"
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
# A simpler, faster escaper: use printf %q-free manual substitution via sed for the common cases,
|
|
152
|
-
# which is correct for the byte sequences this layer deals with (UTF-8 text, no lone control bytes
|
|
153
|
-
# in practice) while still escaping what JSON requires.
|
|
154
|
-
jstr() {
|
|
155
|
-
local s="$1"
|
|
156
|
-
s="${s//\\/\\\\}"
|
|
157
|
-
s="${s//\"/\\\"}"
|
|
158
|
-
s="${s//$'\n'/\\n}"
|
|
159
|
-
s="${s//$'\r'/\\r}"
|
|
160
|
-
s="${s//$'\t'/\\t}"
|
|
161
|
-
printf '"%s"' "$s"
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
PARSE_PRETTY=0
|
|
165
|
-
PARSE_QUIET=0
|
|
166
|
-
|
|
167
|
-
emit_envelope() {
|
|
168
|
-
# emit_envelope <verb> <result-json-or-empty> <error-json-or-empty> <duration-ms>
|
|
169
|
-
local verb="$1" result="$2" error="$3" duration="$4"
|
|
170
|
-
local body
|
|
171
|
-
if [ -n "$error" ]; then
|
|
172
|
-
body='{"ok":false,"verb":'"$(jstr "$verb")"',"error":'"$error"',"meta":{"durationMs":'"$duration"',"platform":"'"$(platform_name)"'","shell":"sh"}}'
|
|
173
|
-
else
|
|
174
|
-
body='{"ok":true,"verb":'"$(jstr "$verb")"',"result":'"$result"',"meta":{"durationMs":'"$duration"',"platform":"'"$(platform_name)"'","shell":"sh"}}'
|
|
175
|
-
fi
|
|
176
|
-
printf '%s\n' "$body"
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
platform_name() {
|
|
180
|
-
case "$(uname -s 2>/dev/null || echo unknown)" in
|
|
181
|
-
Darwin) printf 'darwin' ;;
|
|
182
|
-
Linux) printf 'linux' ;;
|
|
183
|
-
FreeBSD) printf 'freebsd' ;;
|
|
184
|
-
MINGW*|MSYS*|CYGWIN*) printf 'windows' ;;
|
|
185
|
-
*) printf 'unknown' ;;
|
|
186
|
-
esac
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
EXIT_USAGE=1
|
|
190
|
-
EXIT_TYPE=2
|
|
191
|
-
EXIT_PERMISSION=3
|
|
192
|
-
EXIT_UNSUPPORTED=4
|
|
193
|
-
EXIT_TARGET=5
|
|
194
|
-
EXIT_TIMEOUT=6
|
|
195
|
-
EXIT_INTERNAL=7
|
|
196
|
-
|
|
197
|
-
exit_code_for() {
|
|
198
|
-
case "$1" in
|
|
199
|
-
E_USAGE) printf 1 ;;
|
|
200
|
-
E_TYPE|E_CHARSET_UNKNOWN|E_BOUNDARY|E_RANGE|E_WINDOW_AMBIGUOUS) printf 2 ;;
|
|
201
|
-
E_PERMISSION|E_PERMISSION_DENIED_BY_RULE|E_PERMISSION_PROTECTED_PATH|E_PERMISSION_DELEGATE_ONLY|E_PERMISSION_FILE_UNTRUSTED|E_PERMISSION_NO_ANSWERER|E_ELEVATION_REQUIRED) printf 3 ;;
|
|
202
|
-
E_NOT_SUPPORTED|E_ACCESSIBILITY_DENIED|E_ACCESSIBILITY_UNAVAILABLE|E_NO_DISPLAY) printf 4 ;;
|
|
203
|
-
E_TIMEOUT) printf 6 ;;
|
|
204
|
-
E_ABORTED|E_INTERNAL) printf 7 ;;
|
|
205
|
-
*) printf 5 ;;
|
|
206
|
-
esac
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
fail() {
|
|
210
|
-
# fail <code> <message> [hint]
|
|
211
|
-
#
|
|
212
|
-
# The envelope is stashed in a file, NOT printed to stdout, because almost every verb body runs
|
|
213
|
-
# inside a $(...) command substitution and a command substitution captures stdout: anything
|
|
214
|
-
# written there is swallowed, leaving the caller with a bare non-zero exit and no envelope.
|
|
215
|
-
#
|
|
216
|
-
# A scratch file is used rather than a file descriptor: writing to an inherited descriptor (fd 8)
|
|
217
|
-
# produced an empty result under git-bash/MSYS even though the descriptor was demonstrably open
|
|
218
|
-
# and the identical pattern works in a standalone script. A path is portable and observable.
|
|
219
|
-
local code="$1" message="$2" hint="${3:-}"
|
|
220
|
-
local err
|
|
221
|
-
if [ -n "$hint" ]; then
|
|
222
|
-
err='{"code":'"$(jstr "$code")"',"message":'"$(jstr "$message")"',"hint":'"$(jstr "$hint")"'}'
|
|
223
|
-
else
|
|
224
|
-
err='{"code":'"$(jstr "$code")"',"message":'"$(jstr "$message")"'}'
|
|
225
|
-
fi
|
|
226
|
-
local body='{"ok":false,"verb":'"$(jstr "${CURRENT_VERB:-}")"',"error":'"$err"',"meta":{"durationMs":'"${ELAPSED_MS:-0}"',"platform":"'"$(platform_name)"'","shell":"sh"}}'
|
|
227
|
-
# The scratch path is derived here rather than read from a top-level variable. A variable set at
|
|
228
|
-
# the top level of this script was observed to be EMPTY when fail() ran, while the same name read
|
|
229
|
-
# inside the function had the right value -- so the value is not relied upon at all. ROOT_DIR and
|
|
230
|
-
# HOST_DIR are set above and are stable, and there is exactly one scratch path per repository.
|
|
231
|
-
local errfile="${ROOT_DIR}/.temporary/dsh-os.err"
|
|
232
|
-
if [ -n "${ROOT_DIR:-}" ]; then
|
|
233
|
-
mkdir -p "${ROOT_DIR}/.temporary" 2>/dev/null || true
|
|
234
|
-
printf '%s\n' "$body" >"$errfile" 2>/dev/null || true
|
|
235
|
-
fi
|
|
236
|
-
# Also keep a copy on stderr for a human running the script by hand; it never participates in
|
|
237
|
-
# parsing, and the entry point re-emits the file content on stdout exactly once.
|
|
238
|
-
printf '%s\n' "$body" >&2
|
|
239
|
-
exit "$(exit_code_for "$code")"
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
# read_envelope <code> <json> - build the success envelope without going through a subshell.
|
|
243
|
-
success_envelope() {
|
|
244
|
-
local verb="$1" result="$2" duration="$3"
|
|
245
|
-
printf '{"ok":true,"verb":%s,"result":%s,"meta":{"durationMs":%s,"platform":%s,"shell":"sh"}}' \
|
|
246
|
-
"$(jstr "$verb")" "$result" "$duration" "$(jstr "$(platform_name)")"
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
# ===== command.sh ==================================================
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
# ---------------------------------------------------------------------------
|
|
253
|
-
# Argument parsing. Mirrors the Windows parser: --flag, or --flag value for the
|
|
254
|
-
# names that take one; anything else is positional. Unknown flags are refused so
|
|
255
|
-
# a typo cannot silently become a positional.
|
|
256
|
-
# ---------------------------------------------------------------------------
|
|
257
|
-
VALUE_FLAGS=" encoding eol button count space out screen format workdir env stdin timeout interval method pid regex name max-bytes max-chars depth source bom text charset length sha256 limit filter scope index decision delegate args attr value "
|
|
258
|
-
KNOWN_FLAGS=" allow pretty quiet json backup permanent parents no-recursive no-numbers with-numbers force hold clearmodifiers exact absent z-order shell background "
|
|
259
|
-
|
|
260
|
-
POSITIONALS=()
|
|
261
|
-
declare -A FLAGS=()
|
|
262
|
-
declare -A FLAG_MULTI=()
|
|
263
|
-
declare -A NAMED=()
|
|
264
|
-
unknown_flag=""
|
|
265
|
-
ARG_ERROR_CODE=""
|
|
266
|
-
ARG_ERROR_MESSAGE=""
|
|
267
|
-
ARG_ERROR_HINT=""
|
|
268
|
-
|
|
269
|
-
# Declared positional names for a verb, in order, read from spec/verbs.tsv.
|
|
270
|
-
verb_params() {
|
|
271
|
-
local key="$1" file="$HOST_DIR/spec/verbs.tsv"
|
|
272
|
-
[ -f "$file" ] || return 0
|
|
273
|
-
awk -F'\t' -v k="$key" '!/^#/ && $1 == k && NF >= 2 { if ($2 != "-") { n = split($2, a, "|"); for (i = 1; i <= n; i++) print a[i] } }' "$file"
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
# Fold name="value" arguments into the positional list, in the order the verb table declares them,
|
|
277
|
-
# so no verb body needs to know which calling form was used. Mirrors Merge-NamedArguments in the
|
|
278
|
-
# PowerShell implementation, including its refusal of a parameter given both ways.
|
|
279
|
-
merge_named_args() {
|
|
280
|
-
local key="$1"
|
|
281
|
-
[ "${#NAMED[@]}" -eq 0 ] && return 0
|
|
282
|
-
local declared=()
|
|
283
|
-
while IFS= read -r pname; do
|
|
284
|
-
[ -n "$pname" ] && declared+=("$pname")
|
|
285
|
-
done < <(verb_params "$key")
|
|
286
|
-
if [ "${#declared[@]}" -eq 0 ]; then
|
|
287
|
-
ARG_ERROR_CODE="E_USAGE"
|
|
288
|
-
ARG_ERROR_MESSAGE="verb '$key' takes no named arguments"
|
|
289
|
-
ARG_ERROR_HINT="named arguments are declared in spec/verbs.tsv"
|
|
290
|
-
return 1
|
|
291
|
-
fi
|
|
292
|
-
local n=${#declared[@]}
|
|
293
|
-
local slots=() filled=()
|
|
294
|
-
local k
|
|
295
|
-
for ((k = 0; k < n; k++)); do slots[$k]=""; filled[$k]=0; done
|
|
296
|
-
local pos=0
|
|
297
|
-
for ((k = 0; k < ${#POSITIONALS[@]}; k++)); do
|
|
298
|
-
if [ "$k" -lt "$n" ]; then
|
|
299
|
-
slots[$k]="${POSITIONALS[$k]}"; filled[$k]=1
|
|
300
|
-
else
|
|
301
|
-
pos=$((k + 1))
|
|
302
|
-
ARG_ERROR_CODE="E_USAGE"
|
|
303
|
-
ARG_ERROR_MESSAGE="unexpected extra argument <${POSITIONALS[$k]}>"
|
|
304
|
-
return 1
|
|
305
|
-
fi
|
|
306
|
-
done
|
|
307
|
-
local name
|
|
308
|
-
for name in "${!NAMED[@]}"; do
|
|
309
|
-
local found=-1
|
|
310
|
-
for ((k = 0; k < n; k++)); do
|
|
311
|
-
if [ "${declared[$k]}" = "$name" ]; then found=$k; break; fi
|
|
312
|
-
done
|
|
313
|
-
if [ "$found" -lt 0 ]; then
|
|
314
|
-
ARG_ERROR_CODE="E_USAGE"
|
|
315
|
-
ARG_ERROR_MESSAGE="unknown named argument '$name' for '$key'"
|
|
316
|
-
ARG_ERROR_HINT="accepted names, in order: ${declared[*]}"
|
|
317
|
-
return 1
|
|
318
|
-
fi
|
|
319
|
-
if [ "${filled[$found]}" = "1" ]; then
|
|
320
|
-
ARG_ERROR_CODE="E_USAGE"
|
|
321
|
-
ARG_ERROR_MESSAGE="'$name' was given both positionally and by name"
|
|
322
|
-
return 1
|
|
323
|
-
fi
|
|
324
|
-
slots[$found]="${NAMED[$name]}"
|
|
325
|
-
filled[$found]=1
|
|
326
|
-
done
|
|
327
|
-
POSITIONALS=()
|
|
328
|
-
for ((k = 0; k < n; k++)); do
|
|
329
|
-
if [ "${filled[$k]}" = "1" ]; then POSITIONALS+=("${slots[$k]}"); fi
|
|
330
|
-
done
|
|
331
|
-
NAMED=()
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
parse_args() {
|
|
335
|
-
# Returns non-zero with ARG_ERROR_* set instead of calling fail(). parse_args populates the globals
|
|
336
|
-
# POSITIONALS/FLAGS/NAMED, so it must run in the current shell; and fail() exits, which would
|
|
337
|
-
# terminate the script before the entry point could emit an envelope. The caller reports the error.
|
|
338
|
-
local i=0
|
|
339
|
-
local n=$#
|
|
340
|
-
local argv=("$@")
|
|
341
|
-
while [ "$i" -lt "$n" ]; do
|
|
342
|
-
local tok="${argv[$i]}"
|
|
343
|
-
case "$tok" in
|
|
344
|
-
--)
|
|
345
|
-
i=$((i + 1))
|
|
346
|
-
while [ "$i" -lt "$n" ]; do POSITIONALS+=("${argv[$i]}"); i=$((i + 1)); done
|
|
347
|
-
break
|
|
348
|
-
;;
|
|
349
|
-
--*)
|
|
350
|
-
local name="${tok#--}"
|
|
351
|
-
if [ -z "$name" ]; then
|
|
352
|
-
ARG_ERROR_CODE="E_USAGE"; ARG_ERROR_MESSAGE='empty flag "--"'
|
|
353
|
-
return 1
|
|
354
|
-
fi
|
|
355
|
-
if [ "${VALUE_FLAGS#* $name }" != "$VALUE_FLAGS" ]; then
|
|
356
|
-
if [ $((i + 1)) -ge "$n" ]; then
|
|
357
|
-
ARG_ERROR_CODE="E_USAGE"; ARG_ERROR_MESSAGE="flag --$name requires a value"
|
|
358
|
-
return 1
|
|
359
|
-
fi
|
|
360
|
-
local val="${argv[$((i + 1))]}"
|
|
361
|
-
if [ -n "${FLAGS[$name]:-}" ]; then
|
|
362
|
-
FLAG_MULTI["$name"]="${FLAG_MULTI[$name]:-}${FLAG_MULTI[$name]:+$'\n'}$val"
|
|
363
|
-
fi
|
|
364
|
-
FLAGS["$name"]="$val"
|
|
365
|
-
i=$((i + 2))
|
|
366
|
-
elif [ "${KNOWN_FLAGS#* $name }" != "$KNOWN_FLAGS" ]; then
|
|
367
|
-
FLAGS["$name"]="true"
|
|
368
|
-
i=$((i + 1))
|
|
369
|
-
else
|
|
370
|
-
unknown_flag="$name"
|
|
371
|
-
ARG_ERROR_CODE="E_USAGE"
|
|
372
|
-
ARG_ERROR_MESSAGE="unknown flag --$name"
|
|
373
|
-
ARG_ERROR_HINT='run: dsh-os.sh selfcheck (lists every accepted flag)'
|
|
374
|
-
return 1
|
|
375
|
-
fi
|
|
376
|
-
;;
|
|
377
|
-
*=*)
|
|
378
|
-
# Named argument form: file create path="./a.txt" content="123" charset="utf-8-bom".
|
|
379
|
-
# Values keep whatever the caller passed after '='; the calling shell consumes the quotes.
|
|
380
|
-
local nname="${tok%%=*}"
|
|
381
|
-
local nval="${tok#*=}"
|
|
382
|
-
case "$nname" in
|
|
383
|
-
*[!A-Za-z0-9_-]*|'')
|
|
384
|
-
POSITIONALS+=("$tok")
|
|
385
|
-
;;
|
|
386
|
-
*)
|
|
387
|
-
if [ -n "${NAMED[$nname]:-}" ]; then
|
|
388
|
-
ARG_ERROR_CODE="E_USAGE"
|
|
389
|
-
ARG_ERROR_MESSAGE="named argument '$nname' was given more than once"
|
|
390
|
-
return 1
|
|
391
|
-
fi
|
|
392
|
-
NAMED["$nname"]="$nval"
|
|
393
|
-
;;
|
|
394
|
-
esac
|
|
395
|
-
i=$((i + 1))
|
|
396
|
-
;;
|
|
397
|
-
*)
|
|
398
|
-
POSITIONALS+=("$tok")
|
|
399
|
-
i=$((i + 1))
|
|
400
|
-
;;
|
|
401
|
-
esac
|
|
402
|
-
done
|
|
403
|
-
return 0
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
have_flag() { [ -n "${FLAGS[$1]:-}" ]; }
|
|
407
|
-
flag_value() { printf '%s' "${FLAGS[$1]:-${2:-}}"; }
|
|
408
|
-
|
|
409
|
-
require_allow() {
|
|
410
|
-
# require_allow <verb> <arity> [provided]
|
|
411
|
-
local verb="$1" arity="$2" provided="${3:-0}"
|
|
412
|
-
if [ "$provided" -gt "$arity" ]; then
|
|
413
|
-
fail E_USAGE "unexpected extra argument <${POSITIONALS[$arity]}>"
|
|
414
|
-
fi
|
|
415
|
-
if ! have_flag allow; then
|
|
416
|
-
fail E_PERMISSION "$verb is a mutating operation and requires user authorization" \
|
|
417
|
-
're-run with --allow after the user has accepted the requested operation'
|
|
418
|
-
fi
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
require_min() {
|
|
422
|
-
# require_min <count> <verb>
|
|
423
|
-
local min="$1" verb="$2"
|
|
424
|
-
if [ "${#POSITIONALS[@]}" -lt "$min" ]; then
|
|
425
|
-
fail E_USAGE "$verb requires $min argument(s), got ${#POSITIONALS[@]}" \
|
|
426
|
-
'run: dsh-os.sh selfcheck (lists every verb with its arguments and flags)'
|
|
427
|
-
fi
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
pos() { printf '%s' "${POSITIONALS[${1}]:-}"; }
|
|
431
|
-
|
|
432
|
-
# ---------------------------------------------------------------------------
|
|
433
|
-
# Protected paths (authorization layer B, docs/permission-model.md section 3.1).
|
|
434
|
-
# Compared after full normalization, never by pattern: a regular expression is
|
|
435
|
-
# walked around by ".", "..", a trailing dot, or a symlink.
|
|
436
|
-
# ---------------------------------------------------------------------------
|
|
437
|
-
PROTECTED_RELATIVE=(
|
|
438
|
-
'permission.json'
|
|
439
|
-
'permission.json.bak'
|
|
440
|
-
'src/host/command.txt'
|
|
441
|
-
'src/host/bin/dsh-os.ps1'
|
|
442
|
-
'src/host/bin/dsh-os.sh'
|
|
443
|
-
'src/host/bin/dsh-os.bat'
|
|
444
|
-
'src/host/spec/verbs.tsv'
|
|
445
|
-
'src/host/spec/charsets.tsv'
|
|
446
|
-
)
|
|
447
|
-
|
|
448
|
-
normalize_path() {
|
|
449
|
-
# Absolute, symlink-resolved where possible, with trailing dots/spaces removed.
|
|
450
|
-
local p="$1"
|
|
451
|
-
local dir base
|
|
452
|
-
dir="$(dirname "$p")"
|
|
453
|
-
base="$(basename "$p")"
|
|
454
|
-
while [ "${#base}" -gt 1 ]; do
|
|
455
|
-
case "$base" in
|
|
456
|
-
*.|*' ') base="${base%?}" ;;
|
|
457
|
-
*) break ;;
|
|
458
|
-
esac
|
|
459
|
-
done
|
|
460
|
-
if [ -d "$dir" ]; then
|
|
461
|
-
dir="$(cd "$dir" 2>/dev/null && pwd -P)" || dir="$(cd "$dir" && pwd)"
|
|
462
|
-
fi
|
|
463
|
-
printf '%s/%s' "$dir" "$base"
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
# ===== native.sh ===================================================
|
|
467
|
-
|
|
468
|
-
# Module: native.sh - platform-native primitives.
|
|
469
|
-
#
|
|
470
|
-
# Intentionally empty. The PowerShell module declares Add-Type P/Invoke bindings (SendInput, BitBlt,
|
|
471
|
-
# GDI, clipboard) because those are the only way to reach them on Windows. On POSIX the equivalents
|
|
472
|
-
# are external programs (xdotool, wl-copy, import) rather than a linked library, so there is nothing
|
|
473
|
-
# to declare here; the verbs that would use them return a typed E_NOT_SUPPORTED until implemented,
|
|
474
|
-
# which is the required behaviour rather than a silent no-op.
|
|
475
|
-
|
|
476
|
-
# ===== paths.sh ====================================================
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
is_protected() {
|
|
480
|
-
local candidate
|
|
481
|
-
candidate="$(normalize_path "$1")"
|
|
482
|
-
local rel
|
|
483
|
-
for rel in "${PROTECTED_RELATIVE[@]}"; do
|
|
484
|
-
local prot
|
|
485
|
-
prot="$(normalize_path "$ROOT_DIR/$rel")"
|
|
486
|
-
if [ "$candidate" = "$prot" ]; then return 0; fi
|
|
487
|
-
# A temporary staged beside a protected path is protected too.
|
|
488
|
-
if [ "${candidate%/*}" = "${prot%/*}" ]; then
|
|
489
|
-
case "${candidate##*/}" in
|
|
490
|
-
"${prot##*/}".*) return 0 ;;
|
|
491
|
-
esac
|
|
492
|
-
fi
|
|
493
|
-
done
|
|
494
|
-
return 1
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
assert_not_protected() {
|
|
498
|
-
# assert_not_protected <path> <verb>
|
|
499
|
-
if is_protected "$1"; then
|
|
500
|
-
fail E_PERMISSION_PROTECTED_PATH \
|
|
501
|
-
"$2 may not modify '$1': it is part of the authorization layer" \
|
|
502
|
-
'authorization changes go through the consent delegate, which requires a user-accepted dialog; see docs/permission-model.md'
|
|
503
|
-
fi
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
# ===== charset.sh ==================================================
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
# ---------------------------------------------------------------------------
|
|
510
|
-
# Charset table (spec/charsets.tsv is the single source of truth).
|
|
511
|
-
# ---------------------------------------------------------------------------
|
|
512
|
-
charsets_file() { printf '%s' "$HOST_DIR/spec/charsets.tsv"; }
|
|
513
|
-
|
|
514
|
-
resolve_charset() {
|
|
515
|
-
# resolve_charset <name> -> echoes the canonical IANA name, or fails.
|
|
516
|
-
local want
|
|
517
|
-
want="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')"
|
|
518
|
-
local file
|
|
519
|
-
file="$(charsets_file)"
|
|
520
|
-
if [ ! -f "$file" ]; then
|
|
521
|
-
fail E_INTERNAL "charset table not found at $file"
|
|
522
|
-
fi
|
|
523
|
-
local line iana aliases
|
|
524
|
-
while IFS=$'\t' read -r iana aliases _rest; do
|
|
525
|
-
case "$iana" in ''|'#'*) continue ;; esac
|
|
526
|
-
[ "$iana" = "iana" ] && continue
|
|
527
|
-
if [ "$iana" = "$want" ]; then printf '%s' "$iana"; return 0; fi
|
|
528
|
-
local a
|
|
529
|
-
local IFS='|'
|
|
530
|
-
for a in $aliases; do
|
|
531
|
-
if [ "$a" = "$want" ]; then printf '%s' "$iana"; return 0; fi
|
|
532
|
-
done
|
|
533
|
-
done <"$file"
|
|
534
|
-
fail E_CHARSET_UNKNOWN "unknown charset '$1'" 'see spec/charsets.tsv for supported names'
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
iconv_name_for() {
|
|
538
|
-
local iana="$1" file
|
|
539
|
-
file="$(charsets_file)"
|
|
540
|
-
local line
|
|
541
|
-
while IFS=$'\t' read -r name _aliases _dotnet iconv _bom _fixed; do
|
|
542
|
-
case "$name" in ''|'#'*) continue ;; esac
|
|
543
|
-
[ "$name" = "iana" ] && continue
|
|
544
|
-
if [ "$name" = "$iana" ]; then printf '%s' "$iconv"; return 0; fi
|
|
545
|
-
done <"$file"
|
|
546
|
-
printf '%s' "$iana"
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
# ---------------------------------------------------------------------------
|
|
550
|
-
# Byte-level text model. A document records everything needed to write the file
|
|
551
|
-
# back exactly as it was apart from the intended change.
|
|
552
|
-
# ---------------------------------------------------------------------------
|
|
553
|
-
DOC_PATH=""
|
|
554
|
-
DOC_CHARSET=""
|
|
555
|
-
DOC_BOM=""
|
|
556
|
-
DOC_EOL=""
|
|
557
|
-
DOC_SIZE=0
|
|
558
|
-
|
|
559
|
-
detect_charset() {
|
|
560
|
-
# detect_charset <file> -> prints "<iana> <bom-bytes>" with bom '-' when absent.
|
|
561
|
-
local file="$1"
|
|
562
|
-
local head
|
|
563
|
-
head="$(LC_ALL=C od -An -tx1 -N4 "$file" | tr -d ' \n')"
|
|
564
|
-
case "$head" in
|
|
565
|
-
efbbbf*) printf 'utf-8-bom 3'; return ;;
|
|
566
|
-
fffe0000*) printf 'utf-32le 4'; return ;;
|
|
567
|
-
0000feff*) printf 'utf-32be 4'; return ;;
|
|
568
|
-
fffe*) printf 'utf-16le 2'; return ;;
|
|
569
|
-
feff*) printf 'utf-16be 2'; return ;;
|
|
570
|
-
esac
|
|
571
|
-
# Skip the BOM bytes and test validity as UTF-8, then GBK, then fall back to latin1.
|
|
572
|
-
local body
|
|
573
|
-
body="$(mktemp)"
|
|
574
|
-
tail -c +1 "$file" >"$body"
|
|
575
|
-
if iconv -f UTF-8 -t UTF-8 "$body" >/dev/null 2>&1; then
|
|
576
|
-
rm -f "$body"; printf 'utf-8 -'; return
|
|
577
|
-
fi
|
|
578
|
-
if iconv -f GBK -t UTF-8 "$body" >/dev/null 2>&1; then
|
|
579
|
-
rm -f "$body"; printf 'gbk -'; return
|
|
580
|
-
fi
|
|
581
|
-
rm -f "$body"
|
|
582
|
-
printf 'latin1 -'
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
load_doc() {
|
|
586
|
-
# load_doc <path> - populates the DOC_* globals, failing like the Windows reader.
|
|
587
|
-
local path="$1"
|
|
588
|
-
if [ ! -e "$path" ]; then
|
|
589
|
-
fail E_NOT_FOUND "cannot read '$path': not found"
|
|
590
|
-
fi
|
|
591
|
-
if [ -d "$path" ]; then
|
|
592
|
-
fail E_NOT_A_FILE "cannot read '$path': not a regular file"
|
|
593
|
-
fi
|
|
594
|
-
local detected iana bom
|
|
595
|
-
detected="$(detect_charset "$path")"
|
|
596
|
-
iana="${detected% *}"
|
|
597
|
-
bom="${detected##* }"
|
|
598
|
-
DOC_PATH="$path"
|
|
599
|
-
DOC_CHARSET="$iana"
|
|
600
|
-
DOC_BOM="$bom"
|
|
601
|
-
DOC_SIZE="$(wc -c <"$path" | tr -d ' ')"
|
|
602
|
-
# Binary detection: a NUL byte in the first 8 KiB, once past any BOM.
|
|
603
|
-
#
|
|
604
|
-
# The bytes must stay separated while looking for "00". Joining the od output into one string (the
|
|
605
|
-
# obvious `tr -d ' \n'`) lets a NUL match across a byte boundary -- "b0 0a" becomes "b00a" -- so
|
|
606
|
-
# ordinary UTF-8 text is misreported as binary. Matching per whitespace-delimited token avoids it.
|
|
607
|
-
local skip=0
|
|
608
|
-
[ "$bom" != "-" ] && skip="$bom"
|
|
609
|
-
local nul
|
|
610
|
-
nul="$(LC_ALL=C od -An -v -tx1 -j "$skip" -N 8192 "$path" | LC_ALL=C tr ' ' '\n' | LC_ALL=C grep -c '^00$' || true)"
|
|
611
|
-
if [ "${nul:-0}" -gt 0 ]; then
|
|
612
|
-
case "$iana" in
|
|
613
|
-
utf-16le|utf-16be|utf-32le|utf-32be) : ;;
|
|
614
|
-
*) fail E_BINARY_CONTENT "cannot read '$path' as text: the content looks binary" ;;
|
|
615
|
-
esac
|
|
616
|
-
fi
|
|
617
|
-
# Line ending convention, from the first 4 KiB.
|
|
618
|
-
local crlf
|
|
619
|
-
crlf="$(LC_ALL=C head -c 4096 "$path" | LC_ALL=C tr -cd '\r' | wc -c | tr -d ' ')"
|
|
620
|
-
if [ "$crlf" -gt 0 ]; then DOC_EOL="crlf"; else DOC_EOL="lf"; fi
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
doc_text() {
|
|
624
|
-
# Decode the body (BOM excluded) to UTF-8 on stdout.
|
|
625
|
-
local skip=0
|
|
626
|
-
[ "$DOC_BOM" != "-" ] && skip="$DOC_BOM"
|
|
627
|
-
local tmp
|
|
628
|
-
tmp="$(mktemp)"
|
|
629
|
-
tail -c "+$((skip + 1))" "$DOC_PATH" >"$tmp"
|
|
630
|
-
local from
|
|
631
|
-
from="$(iconv_name_for "$DOC_CHARSET")"
|
|
632
|
-
iconv -f "$from" -t UTF-8 "$tmp" 2>/dev/null || cat "$tmp"
|
|
633
|
-
rm -f "$tmp"
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
write_doc_bytes() {
|
|
637
|
-
# write_doc_bytes <file> <bytes-file> - atomic replace via a sibling temporary.
|
|
638
|
-
local target="$1" src="$2"
|
|
639
|
-
local dir
|
|
640
|
-
dir="$(dirname "$target")"
|
|
641
|
-
if [ ! -d "$dir" ]; then
|
|
642
|
-
fail E_NO_PARENT "cannot write '$target': directory '$dir' does not exist"
|
|
643
|
-
fi
|
|
644
|
-
local tmp="$dir/.dsh-os-$$.tmp"
|
|
645
|
-
cp "$src" "$tmp"
|
|
646
|
-
mv -f "$tmp" "$target"
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
rewrite_doc() {
|
|
650
|
-
# rewrite_doc <text-file> - re-encode using the document's charset, BOM and EOL.
|
|
651
|
-
local textfile="$1"
|
|
652
|
-
local dir tmp payload
|
|
653
|
-
dir="$(dirname "$DOC_PATH")"
|
|
654
|
-
tmp="$(mktemp)"
|
|
655
|
-
# Normalize to LF first so CRLF restoration is idempotent.
|
|
656
|
-
LC_ALL=C sed 's/\r$//' "$textfile" >"$tmp"
|
|
657
|
-
if [ "$DOC_EOL" = "crlf" ]; then
|
|
658
|
-
local crlf
|
|
659
|
-
crlf="$(mktemp)"
|
|
660
|
-
LC_ALL=C awk '{ printf "%s\r\n", $0 }' "$tmp" >"$crlf"
|
|
661
|
-
mv "$crlf" "$tmp"
|
|
662
|
-
fi
|
|
663
|
-
local from
|
|
664
|
-
from="$(iconv_name_for "$DOC_CHARSET")"
|
|
665
|
-
payload="$(mktemp)"
|
|
666
|
-
if [ "$DOC_CHARSET" = "utf-8" ] || [ "$DOC_CHARSET" = "utf-8-bom" ]; then
|
|
667
|
-
cp "$tmp" "$payload"
|
|
668
|
-
else
|
|
669
|
-
iconv -f UTF-8 -t "$from" "$tmp" >"$payload" 2>/dev/null || {
|
|
670
|
-
rm -f "$tmp" "$payload"
|
|
671
|
-
fail E_ENCODING_INVALID "content cannot be represented in $DOC_CHARSET"
|
|
672
|
-
}
|
|
673
|
-
fi
|
|
674
|
-
if [ "$DOC_BOM" != "-" ]; then
|
|
675
|
-
local withbom
|
|
676
|
-
withbom="$(mktemp)"
|
|
677
|
-
case "$DOC_CHARSET" in
|
|
678
|
-
utf-8-bom) printf '\357\273\277' >"$withbom" ;;
|
|
679
|
-
utf-16le) printf '\377\376' >"$withbom" ;;
|
|
680
|
-
utf-16be) printf '\376\377' >"$withbom" ;;
|
|
681
|
-
utf-32le) printf '\377\376\000\000' >"$withbom" ;;
|
|
682
|
-
utf-32be) printf '\000\000\376\377' >"$withbom" ;;
|
|
683
|
-
esac
|
|
684
|
-
cat "$payload" >>"$withbom"
|
|
685
|
-
mv "$withbom" "$payload"
|
|
686
|
-
fi
|
|
687
|
-
write_doc_bytes "$DOC_PATH" "$payload"
|
|
688
|
-
rm -f "$tmp" "$payload"
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
# ===== file.sh =====================================================
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
# ---------------------------------------------------------------------------
|
|
695
|
-
# file.* verbs
|
|
696
|
-
# ---------------------------------------------------------------------------
|
|
697
|
-
cmd_file_create() {
|
|
698
|
-
require_min 3 'file create'
|
|
699
|
-
require_allow 'file create' 3 "${#POSITIONALS[@]}"
|
|
700
|
-
local path content charset
|
|
701
|
-
path="$(pos 0)"; content="$(pos 1)"; charset="$(pos 2)"
|
|
702
|
-
assert_not_protected "$path" 'file create'
|
|
703
|
-
if [ -e "$path" ]; then
|
|
704
|
-
fail E_EXISTS "cannot create '$path': it already exists" \
|
|
705
|
-
'use file view to read it, file append/file insert to extend it, or file delete then create'
|
|
706
|
-
fi
|
|
707
|
-
local dir
|
|
708
|
-
dir="$(dirname "$path")"
|
|
709
|
-
[ -d "$dir" ] || mkdir -p "$dir"
|
|
710
|
-
local iana
|
|
711
|
-
iana="$(resolve_charset "$charset")"
|
|
712
|
-
local textfile payload
|
|
713
|
-
textfile="$(mktemp)"; payload="$(mktemp)"
|
|
714
|
-
printf '%s' "$content" >"$textfile"
|
|
715
|
-
local eol
|
|
716
|
-
eol="$(flag_value eol '')"
|
|
717
|
-
if [ "$eol" = "crlf" ]; then
|
|
718
|
-
LC_ALL=C awk '{ printf "%s\r\n", $0 }' "$textfile" >"$payload"
|
|
719
|
-
else
|
|
720
|
-
cp "$textfile" "$payload"
|
|
721
|
-
fi
|
|
722
|
-
if [ "$iana" = "utf-8-bom" ]; then
|
|
723
|
-
local withbom; withbom="$(mktemp)"
|
|
724
|
-
printf '\357\273\277' >"$withbom"; cat "$payload" >>"$withbom"; mv "$withbom" "$payload"
|
|
725
|
-
elif [ "$iana" != "utf-8" ]; then
|
|
726
|
-
local conv; conv="$(mktemp)"
|
|
727
|
-
iconv -f UTF-8 -t "$(iconv_name_for "$iana")" "$payload" >"$conv" 2>/dev/null || {
|
|
728
|
-
rm -f "$textfile" "$payload" "$conv"
|
|
729
|
-
fail E_ENCODING_INVALID "content cannot be represented in $iana"
|
|
730
|
-
}
|
|
731
|
-
mv "$conv" "$payload"
|
|
732
|
-
fi
|
|
733
|
-
write_doc_bytes "$path" "$payload"
|
|
734
|
-
local size; size="$(wc -c <"$path" | tr -d ' ')"
|
|
735
|
-
rm -f "$textfile" "$payload"
|
|
736
|
-
printf '{"path":%s,"operation":"create","charset":%s,"bom":%s,"eol":%s,"bytes":%s}' \
|
|
737
|
-
"$(jstr "$path")" "$(jstr "$iana")" "$([ "$iana" = "utf-8-bom" ] && echo true || echo false)" \
|
|
738
|
-
"$(jstr "${eol:-lf}")" "$size"
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
cmd_file_charset() {
|
|
742
|
-
require_min 1 'file charset'
|
|
743
|
-
local path; path="$(pos 0)"
|
|
744
|
-
load_doc "$path"
|
|
745
|
-
local textfile lines
|
|
746
|
-
textfile="$(mktemp)"
|
|
747
|
-
doc_text >"$textfile"
|
|
748
|
-
lines="$(awk 'END { n = NR; if (NR == 0) n = 0; print n }' "$textfile")"
|
|
749
|
-
rm -f "$textfile"
|
|
750
|
-
printf '{"path":%s,"charset":%s,"bom":%s,"eol":%s,"size":%s,"lines":%s}' \
|
|
751
|
-
"$(jstr "$DOC_PATH")" "$(jstr "$DOC_CHARSET")" \
|
|
752
|
-
"$([ "$DOC_BOM" != "-" ] && echo true || echo false)" \
|
|
753
|
-
"$(jstr "$DOC_EOL")" "$DOC_SIZE" "$lines"
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
cmd_file_information() {
|
|
757
|
-
require_min 1 'file information'
|
|
758
|
-
local path; path="$(pos 0)"
|
|
759
|
-
[ -e "$path" ] || fail E_NOT_FOUND "cannot inspect '$path': not found"
|
|
760
|
-
if [ -d "$path" ]; then
|
|
761
|
-
printf '{"path":%s,"type":"directory"}' "$(jstr "$path")"
|
|
762
|
-
return
|
|
763
|
-
fi
|
|
764
|
-
load_doc "$path"
|
|
765
|
-
local textfile lines
|
|
766
|
-
textfile="$(mktemp)"; doc_text >"$textfile"
|
|
767
|
-
lines="$(awk 'END { print NR }' "$textfile")"
|
|
768
|
-
rm -f "$textfile"
|
|
769
|
-
local mtime
|
|
770
|
-
mtime="$(date -u -r "$path" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
|
771
|
-
printf '{"path":%s,"type":"file","size":%s,"charset":%s,"bom":%s,"eol":%s,"lines":%s,"modified":%s,"realpath":%s}' \
|
|
772
|
-
"$(jstr "$path")" "$DOC_SIZE" "$(jstr "$DOC_CHARSET")" \
|
|
773
|
-
"$([ "$DOC_BOM" != "-" ] && echo true || echo false)" \
|
|
774
|
-
"$(jstr "$DOC_EOL")" "$lines" "$(jstr "$mtime")" "$(jstr "$path")"
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
cmd_file_length() {
|
|
778
|
-
require_min 1 'file length'
|
|
779
|
-
local path; path="$(pos 0)"
|
|
780
|
-
[ -e "$path" ] || fail E_NOT_FOUND "cannot measure '$path': not found"
|
|
781
|
-
if [ -d "$path" ]; then
|
|
782
|
-
if have_flag no-recursive; then
|
|
783
|
-
printf '{"path":%s,"type":"directory","bytes":0,"files":0,"directories":0}' "$(jstr "$path")"
|
|
784
|
-
return
|
|
785
|
-
fi
|
|
786
|
-
local total files dirs
|
|
787
|
-
total="$(find "$path" -type f -printf '%s\n' 2>/dev/null | awk '{ s += $1 } END { printf "%d", s + 0 }')"
|
|
788
|
-
files="$(find "$path" -type f 2>/dev/null | wc -l | tr -d ' ')"
|
|
789
|
-
dirs="$(find "$path" -mindepth 1 -type d 2>/dev/null | wc -l | tr -d ' ')"
|
|
790
|
-
printf '{"path":%s,"type":"directory","bytes":%s,"files":%s,"directories":%s,"recursive":true}' \
|
|
791
|
-
"$(jstr "$path")" "$total" "$files" "$dirs"
|
|
792
|
-
return
|
|
793
|
-
fi
|
|
794
|
-
local size; size="$(wc -c <"$path" | tr -d ' ')"
|
|
795
|
-
printf '{"path":%s,"type":"file","bytes":%s,"recursive":false}' "$(jstr "$path")" "$size"
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
cmd_file_list() {
|
|
799
|
-
require_min 1 'file list'
|
|
800
|
-
local path; path="$(pos 0)"
|
|
801
|
-
[ -e "$path" ] || fail E_NOT_FOUND "cannot list '$path': not found"
|
|
802
|
-
[ -d "$path" ] || fail E_NOT_A_DIR "cannot list '$path': not a directory"
|
|
803
|
-
local out="" count=0
|
|
804
|
-
local entry
|
|
805
|
-
for entry in "$path"/* "$path"/.[!.]*; do
|
|
806
|
-
[ -e "$entry" ] || continue
|
|
807
|
-
local name type size
|
|
808
|
-
name="$(basename "$entry")"
|
|
809
|
-
if [ -d "$entry" ]; then type="directory"; size=0; else type="file"; size="$(wc -c <"$entry" | tr -d ' ')"; fi
|
|
810
|
-
[ "$count" -gt 0 ] && out="$out,"
|
|
811
|
-
out="$out{\"name\":$(jstr "$name"),\"type\":$(jstr "$type"),\"size\":$size}"
|
|
812
|
-
count=$((count + 1))
|
|
813
|
-
done
|
|
814
|
-
printf '{"path":%s,"entries":[%s],"count":%s}' "$(jstr "$path")" "$out" "$count"
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
cmd_file_delete() {
|
|
818
|
-
require_min 1 'file delete'
|
|
819
|
-
require_allow 'file delete' 1 "${#POSITIONALS[@]}"
|
|
820
|
-
local path; path="$(pos 0)"
|
|
821
|
-
assert_not_protected "$path" 'file delete'
|
|
822
|
-
[ -e "$path" ] || fail E_NOT_FOUND "cannot delete '$path': not found"
|
|
823
|
-
if have_flag permanent; then
|
|
824
|
-
rm -rf -- "$path"
|
|
825
|
-
printf '{"path":%s,"operation":"unlink","permanent":true}' "$(jstr "$path")"
|
|
826
|
-
return
|
|
827
|
-
fi
|
|
828
|
-
# No standard trash facility on POSIX. Refuse rather than silently destroying data, which is the
|
|
829
|
-
# opposite of the Windows behaviour where the Recycle Bin is always available.
|
|
830
|
-
if command -v trash-put >/dev/null 2>&1; then
|
|
831
|
-
trash-put -- "$path"
|
|
832
|
-
printf '{"path":%s,"operation":"recycle","permanent":false}' "$(jstr "$path")"
|
|
833
|
-
return
|
|
834
|
-
fi
|
|
835
|
-
fail E_NOT_SUPPORTED \
|
|
836
|
-
"cannot recycle '$path': no trash facility (trash-put) is available" \
|
|
837
|
-
'pass --permanent to delete irreversibly, or install trash-cli'
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
cmd_file_move() {
|
|
841
|
-
require_min 2 'file move'
|
|
842
|
-
require_allow 'file move' 2 "${#POSITIONALS[@]}"
|
|
843
|
-
local src dst
|
|
844
|
-
src="$(pos 0)"; dst="$(pos 1)"
|
|
845
|
-
assert_not_protected "$src" 'file move'
|
|
846
|
-
assert_not_protected "$dst" 'file move'
|
|
847
|
-
[ -e "$src" ] || fail E_NOT_FOUND "cannot move '$src': not found"
|
|
848
|
-
[ -e "$dst" ] && fail E_EXISTS "cannot move to '$dst': it already exists"
|
|
849
|
-
local dir; dir="$(dirname "$dst")"
|
|
850
|
-
[ -d "$dir" ] || fail E_NO_PARENT "cannot move to '$dst': directory '$dir' does not exist"
|
|
851
|
-
mv -- "$src" "$dst"
|
|
852
|
-
printf '{"source":%s,"destination":%s,"operation":"move"}' "$(jstr "$src")" "$(jstr "$dst")"
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
cmd_file_copy() {
|
|
856
|
-
require_min 2 'file copy'
|
|
857
|
-
require_allow 'file copy' 2 "${#POSITIONALS[@]}"
|
|
858
|
-
local src dst
|
|
859
|
-
src="$(pos 0)"; dst="$(pos 1)"
|
|
860
|
-
assert_not_protected "$src" 'file copy'
|
|
861
|
-
assert_not_protected "$dst" 'file copy'
|
|
862
|
-
[ -e "$src" ] || fail E_NOT_FOUND "cannot copy '$src': not found"
|
|
863
|
-
[ -e "$dst" ] && fail E_EXISTS "cannot copy to '$dst': it already exists"
|
|
864
|
-
local dir; dir="$(dirname "$dst")"
|
|
865
|
-
[ -d "$dir" ] || fail E_NO_PARENT "cannot copy to '$dst': directory '$dir' does not exist"
|
|
866
|
-
cp -R -- "$src" "$dst"
|
|
867
|
-
printf '{"source":%s,"destination":%s,"operation":"copy"}' "$(jstr "$src")" "$(jstr "$dst")"
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
cmd_file_mkdir() {
|
|
871
|
-
require_min 1 'file mkdir'
|
|
872
|
-
require_allow 'file mkdir' 1 "${#POSITIONALS[@]}"
|
|
873
|
-
local path; path="$(pos 0)"
|
|
874
|
-
assert_not_protected "$path" 'file mkdir'
|
|
875
|
-
[ -e "$path" ] && fail E_EXISTS "cannot create directory '$path': it already exists"
|
|
876
|
-
if have_flag parents; then
|
|
877
|
-
mkdir -p -- "$path"
|
|
878
|
-
else
|
|
879
|
-
local parent; parent="$(dirname "$path")"
|
|
880
|
-
[ -d "$parent" ] || fail E_NO_PARENT "cannot create '$path': parent '$parent' does not exist" \
|
|
881
|
-
'pass --parents to create intermediate directories'
|
|
882
|
-
mkdir -- "$path"
|
|
883
|
-
fi
|
|
884
|
-
printf '{"path":%s,"operation":"mkdir"}' "$(jstr "$path")"
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
cmd_file_append() {
|
|
888
|
-
require_min 2 'file append'
|
|
889
|
-
require_allow 'file append' 3 "${#POSITIONALS[@]}"
|
|
890
|
-
local path content charset
|
|
891
|
-
path="$(pos 0)"; content="$(pos 1)"; charset="$(pos 2)"
|
|
892
|
-
assert_not_protected "$path" 'file append'
|
|
893
|
-
load_doc "$path"
|
|
894
|
-
if [ -n "$charset" ]; then
|
|
895
|
-
local expect; expect="$(resolve_charset "$charset")"
|
|
896
|
-
if [ "$expect" != "$DOC_CHARSET" ] && ! { [ "$expect" = "utf-8" ] && [ "$DOC_CHARSET" = "utf-8-bom" ]; }; then
|
|
897
|
-
fail E_CHARSET_MISMATCH "'$path' is $DOC_CHARSET, not $expect"
|
|
898
|
-
fi
|
|
899
|
-
fi
|
|
900
|
-
local textfile
|
|
901
|
-
textfile="$(mktemp)"
|
|
902
|
-
doc_text >"$textfile"
|
|
903
|
-
local add; add="$(mktemp)"
|
|
904
|
-
printf '%s' "$content" >"$add"
|
|
905
|
-
if [ "$DOC_EOL" = "crlf" ]; then
|
|
906
|
-
local crlf; crlf="$(mktemp)"
|
|
907
|
-
LC_ALL=C sed 's/\r$//' "$add" | LC_ALL=C awk '{ printf "%s\r\n", $0 }' >"$crlf"
|
|
908
|
-
mv "$crlf" "$add"
|
|
909
|
-
fi
|
|
910
|
-
cat "$add" >>"$textfile"
|
|
911
|
-
local before="$DOC_SIZE"
|
|
912
|
-
rewrite_doc "$textfile"
|
|
913
|
-
rm -f "$textfile" "$add"
|
|
914
|
-
local size; size="$(wc -c <"$path" | tr -d ' ')"
|
|
915
|
-
printf '{"path":%s,"operation":"append","charset":%s,"bom":%s,"eol":%s,"offset":%s,"bytes":%s}' \
|
|
916
|
-
"$(jstr "$path")" "$(jstr "$DOC_CHARSET")" \
|
|
917
|
-
"$([ "$DOC_BOM" != "-" ] && echo true || echo false)" "$(jstr "$DOC_EOL")" "$before" "$size"
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
cmd_file_insert() {
|
|
921
|
-
require_min 3 'file insert'
|
|
922
|
-
require_allow 'file insert' 3 "${#POSITIONALS[@]}"
|
|
923
|
-
local path content offset charset
|
|
924
|
-
path="$(pos 0)"; content="$(pos 1)"; offset="$(pos 2)"
|
|
925
|
-
charset="$(flag_value charset '')"
|
|
926
|
-
assert_not_protected "$path" 'file insert'
|
|
927
|
-
case "$offset" in
|
|
928
|
-
''|*[!0-9]*) fail E_TYPE "<offset> must be an integer, got '$offset'" ;;
|
|
929
|
-
esac
|
|
930
|
-
load_doc "$path"
|
|
931
|
-
if [ -n "$charset" ]; then
|
|
932
|
-
local expect; expect="$(resolve_charset "$charset")"
|
|
933
|
-
if [ "$expect" != "$DOC_CHARSET" ] && ! { [ "$expect" = "utf-8" ] && [ "$DOC_CHARSET" = "utf-8-bom" ]; }; then
|
|
934
|
-
fail E_CHARSET_MISMATCH "'$path' is $DOC_CHARSET, not $expect"
|
|
935
|
-
fi
|
|
936
|
-
fi
|
|
937
|
-
if [ "$offset" -gt "$DOC_SIZE" ]; then
|
|
938
|
-
fail E_RANGE "offset $offset is out of range for '$path' (0..$DOC_SIZE)"
|
|
939
|
-
fi
|
|
940
|
-
# Character-boundary check for UTF-8: a continuation byte is 10xxxxxx.
|
|
941
|
-
if [ "$offset" -gt 0 ] && [ "$offset" -lt "$DOC_SIZE" ]; then
|
|
942
|
-
case "$DOC_CHARSET" in
|
|
943
|
-
utf-8|utf-8-bom)
|
|
944
|
-
local byte
|
|
945
|
-
byte="$(LC_ALL=C od -An -tx1 -j "$offset" -N1 "$path" | tr -d ' \n')"
|
|
946
|
-
case "$byte" in
|
|
947
|
-
8*|9*|a*|b*) fail E_BOUNDARY "offset $offset falls inside a multi-byte character in '$path'" \
|
|
948
|
-
'offsets are byte offsets and must land on a character boundary' ;;
|
|
949
|
-
esac
|
|
950
|
-
;;
|
|
951
|
-
utf-16le|utf-16be)
|
|
952
|
-
[ $((offset % 2)) -eq 0 ] || fail E_BOUNDARY "offset $offset falls inside a UTF-16 code unit in '$path'"
|
|
953
|
-
;;
|
|
954
|
-
esac
|
|
955
|
-
fi
|
|
956
|
-
# Splice on the byte level: the head keeps the BOM, the inserted bytes come from a fresh
|
|
957
|
-
# encoding pass, and the tail is copied verbatim.
|
|
958
|
-
local head tail add full tmp
|
|
959
|
-
tmp="$(mktemp -d)"
|
|
960
|
-
head="$tmp/head"; tail="$tmp/tail"; add="$tmp/add"
|
|
961
|
-
head -c "$offset" "$path" >"$head"
|
|
962
|
-
tail -c "+$((offset + 1))" "$path" >"$tail"
|
|
963
|
-
printf '%s' "$content" >"$add"
|
|
964
|
-
if [ "$DOC_EOL" = "crlf" ]; then
|
|
965
|
-
local crlf; crlf="$tmp/crlf"
|
|
966
|
-
LC_ALL=C sed 's/\r$//' "$add" | LC_ALL=C awk '{ printf "%s\r\n", $0 }' >"$crlf"
|
|
967
|
-
mv "$crlf" "$add"
|
|
968
|
-
fi
|
|
969
|
-
if [ "$DOC_CHARSET" != "utf-8" ] && [ "$DOC_CHARSET" != "utf-8-bom" ]; then
|
|
970
|
-
local conv; conv="$tmp/conv"
|
|
971
|
-
iconv -f UTF-8 -t "$(iconv_name_for "$DOC_CHARSET")" "$add" >"$conv" 2>/dev/null || {
|
|
972
|
-
rm -rf "$tmp"; fail E_ENCODING_INVALID "content cannot be represented in $DOC_CHARSET"
|
|
973
|
-
}
|
|
974
|
-
mv "$conv" "$add"
|
|
975
|
-
fi
|
|
976
|
-
full="$tmp/full"
|
|
977
|
-
cat "$head" "$add" "$tail" >"$full"
|
|
978
|
-
local addsize; addsize="$(wc -c <"$add" | tr -d ' ')"
|
|
979
|
-
write_doc_bytes "$path" "$full"
|
|
980
|
-
local size; size="$(wc -c <"$path" | tr -d ' ')"
|
|
981
|
-
rm -rf "$tmp"
|
|
982
|
-
printf '{"path":%s,"operation":"insert","charset":%s,"bom":%s,"eol":%s,"offset":%s,"written":%s,"bytes":%s}' \
|
|
983
|
-
"$(jstr "$path")" "$(jstr "$DOC_CHARSET")" \
|
|
984
|
-
"$([ "$DOC_BOM" != "-" ] && echo true || echo false)" "$(jstr "$DOC_EOL")" "$offset" "$addsize" "$size"
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
cmd_file_view() {
|
|
988
|
-
require_min 1 'file view'
|
|
989
|
-
local path charset start end
|
|
990
|
-
path="$(pos 0)"; charset="$(pos 1)"; start="$(pos 2)"; end="$(pos 3)"
|
|
991
|
-
load_doc "$path"
|
|
992
|
-
if [ -n "$charset" ]; then
|
|
993
|
-
local expect; expect="$(resolve_charset "$charset")"
|
|
994
|
-
[ "$expect" = "$DOC_CHARSET" ] || fail E_CHARSET_MISMATCH "'$path' is $DOC_CHARSET, not $expect"
|
|
995
|
-
fi
|
|
996
|
-
local s=0 e="$DOC_SIZE"
|
|
997
|
-
[ -n "$start" ] && s="$start"
|
|
998
|
-
[ -n "$end" ] && e="$end"
|
|
999
|
-
case "$s" in ''|*[!0-9]*) fail E_TYPE "<start> must be an integer, got '$s'" ;; esac
|
|
1000
|
-
case "$e" in ''|*[!0-9]*) fail E_TYPE "<end> must be an integer, got '$e'" ;; esac
|
|
1001
|
-
[ "$e" -ge "$s" ] || fail E_RANGE "end $e precedes start $s"
|
|
1002
|
-
[ "$e" -le "$DOC_SIZE" ] || fail E_RANGE "range [$s,$e) is out of bounds for a $DOC_SIZE-byte file"
|
|
1003
|
-
local slice
|
|
1004
|
-
slice="$(mktemp)"
|
|
1005
|
-
head -c "$e" "$path" | tail -c "+$((s + 1))" >"$slice"
|
|
1006
|
-
local from text
|
|
1007
|
-
from="$(iconv_name_for "$DOC_CHARSET")"
|
|
1008
|
-
text="$(iconv -f "$from" -t UTF-8 "$slice" 2>/dev/null || cat "$slice")"
|
|
1009
|
-
rm -f "$slice"
|
|
1010
|
-
# Strip a leading BOM character if the slice carried the preamble.
|
|
1011
|
-
text="${text#$'\xef\xbb\xbf'}"
|
|
1012
|
-
|
|
1013
|
-
# Agent-first output, mirroring the PowerShell implementation. `text` carries the raw slice with no
|
|
1014
|
-
# line-number prefix, and `lines` carries the same content as structured objects, so a caller never
|
|
1015
|
-
# has to strip a human rendering back off or parse it. --with-numbers is opt-in for a human.
|
|
1016
|
-
local linesJson="" lineCount=0
|
|
1017
|
-
local numbered="$text"
|
|
1018
|
-
if have_flag with-numbers; then
|
|
1019
|
-
numbered="$(printf '%s' "$text" | awk '{ printf "%d: %s\n", NR, $0 }')"
|
|
1020
|
-
fi
|
|
1021
|
-
# Build the structured array. IFS is scoped so an embedded newline cannot split a record early.
|
|
1022
|
-
while IFS= read -r one || [ -n "$one" ]; do
|
|
1023
|
-
linesJson="$linesJson$( [ "$lineCount" -gt 0 ] && printf ',' )$(printf '{"number":%s,"text":%s}' "$((lineCount + 1))" "$(jstr "$one")")"
|
|
1024
|
-
lineCount=$((lineCount + 1))
|
|
1025
|
-
done <<<"$text"
|
|
1026
|
-
|
|
1027
|
-
printf '{"path":%s,"charset":%s,"bom":%s,"eol":%s,"size":%s,"start":%s,"end":%s,"totalBytes":%s,"lineCount":%s,"lines":[%s],"truncated":false,"text":%s}' \
|
|
1028
|
-
"$(jstr "$path")" "$(jstr "$DOC_CHARSET")" \
|
|
1029
|
-
"$([ "$DOC_BOM" != "-" ] && echo true || echo false)" "$(jstr "$DOC_EOL")" \
|
|
1030
|
-
"$DOC_SIZE" "$s" "$e" "$DOC_SIZE" "$lineCount" "$linesJson" "$(jstr "$numbered")"
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
# ---------------------------------------------------------------------------
|
|
1034
|
-
# file.verify.* - assertions that report rather than throw.
|
|
1035
|
-
# ---------------------------------------------------------------------------
|
|
1036
|
-
cmd_file_verify_charset() {
|
|
1037
|
-
require_min 2 'file verify charset'
|
|
1038
|
-
local path expect
|
|
1039
|
-
path="$(pos 0)"; expect="$(resolve_charset "$(pos 1)")"
|
|
1040
|
-
load_doc "$path"
|
|
1041
|
-
printf '{"path":%s,"assertion":"charset","expected":%s,"observed":%s,"match":%s,"bom":%s,"eol":%s,"size":%s}' \
|
|
1042
|
-
"$(jstr "$path")" "$(jstr "$expect")" "$(jstr "$DOC_CHARSET")" \
|
|
1043
|
-
"$([ "$expect" = "$DOC_CHARSET" ] && echo true || echo false)" \
|
|
1044
|
-
"$([ "$DOC_BOM" != "-" ] && echo true || echo false)" "$(jstr "$DOC_EOL")" "$DOC_SIZE"
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
cmd_file_verify_encoding() {
|
|
1048
|
-
require_min 2 'file verify encoding'
|
|
1049
|
-
local path charset
|
|
1050
|
-
path="$(pos 0)"; charset="$(resolve_charset "$(pos 1)")"
|
|
1051
|
-
local skip=0
|
|
1052
|
-
case "$charset" in
|
|
1053
|
-
utf-16le|utf-16be|utf-32le|utf-32be) : ;;
|
|
1054
|
-
esac
|
|
1055
|
-
local from; from="$(iconv_name_for "$charset")"
|
|
1056
|
-
local legal=true
|
|
1057
|
-
if ! iconv -f "$from" -t UTF-8 "$path" >/dev/null 2>&1; then legal=false; fi
|
|
1058
|
-
printf '{"path":%s,"assertion":"encoding","charset":%s,"legal":%s}' \
|
|
1059
|
-
"$(jstr "$path")" "$(jstr "$charset")" "$legal"
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
cmd_file_verify_completion() {
|
|
1063
|
-
require_min 1 'file verify completion'
|
|
1064
|
-
local path; path="$(pos 0)"
|
|
1065
|
-
if [ ! -e "$path" ]; then
|
|
1066
|
-
printf '{"path":%s,"assertion":"completion","complete":false,"missing":true}' "$(jstr "$path")"
|
|
1067
|
-
return
|
|
1068
|
-
fi
|
|
1069
|
-
load_doc "$path"
|
|
1070
|
-
local asserted=false allmatch=true checks=""
|
|
1071
|
-
local want got
|
|
1072
|
-
want="$(flag_value length '')"
|
|
1073
|
-
if [ -n "$want" ]; then
|
|
1074
|
-
asserted=true
|
|
1075
|
-
got="$DOC_SIZE"
|
|
1076
|
-
[ "$want" = "$got" ] || allmatch=false
|
|
1077
|
-
checks="$checks$( [ -n "$checks" ] && echo , )\"length\":{\"expected\":$want,\"observed\":$got,\"match\":$([ "$want" = "$got" ] && echo true || echo false)}"
|
|
1078
|
-
fi
|
|
1079
|
-
want="$(flag_value sha256 '')"
|
|
1080
|
-
if [ -n "$want" ]; then
|
|
1081
|
-
asserted=true
|
|
1082
|
-
got="$(sha256_of "$path")"
|
|
1083
|
-
[ "$want" = "$got" ] || allmatch=false
|
|
1084
|
-
checks="$checks$( [ -n "$checks" ] && echo , )\"sha256\":{\"expected\":$(jstr "$want"),\"observed\":$(jstr "$got"),\"match\":$([ "$want" = "$got" ] && echo true || echo false)}"
|
|
1085
|
-
fi
|
|
1086
|
-
want="$(flag_value bom '')"
|
|
1087
|
-
if [ -n "$want" ]; then
|
|
1088
|
-
asserted=true
|
|
1089
|
-
got=false; [ "$DOC_BOM" != "-" ] && got=true
|
|
1090
|
-
[ "$want" = "$got" ] || allmatch=false
|
|
1091
|
-
checks="$checks$( [ -n "$checks" ] && echo , )\"bom\":{\"expected\":$want,\"observed\":$got,\"match\":$([ "$want" = "$got" ] && echo true || echo false)}"
|
|
1092
|
-
fi
|
|
1093
|
-
want="$(flag_value eol '')"
|
|
1094
|
-
if [ -n "$want" ]; then
|
|
1095
|
-
asserted=true
|
|
1096
|
-
got="$DOC_EOL"
|
|
1097
|
-
[ "$want" = "$got" ] || allmatch=false
|
|
1098
|
-
checks="$checks$( [ -n "$checks" ] && echo , )\"eol\":{\"expected\":$(jstr "$want"),\"observed\":$(jstr "$got"),\"match\":$([ "$want" = "$got" ] && echo true || echo false)}"
|
|
1099
|
-
fi
|
|
1100
|
-
local complete
|
|
1101
|
-
if [ "$asserted" = "true" ]; then complete="$allmatch"; else complete=null; fi
|
|
1102
|
-
printf '{"path":%s,"assertion":"completion","asserted":%s,"complete":%s,"charset":%s,"bom":%s,"eol":%s,"size":%s,"checks":{%s}}' \
|
|
1103
|
-
"$(jstr "$path")" "$asserted" "$complete" "$(jstr "$DOC_CHARSET")" \
|
|
1104
|
-
"$([ "$DOC_BOM" != "-" ] && echo true || echo false)" "$(jstr "$DOC_EOL")" "$DOC_SIZE" "$checks"
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
sha256_of() {
|
|
1108
|
-
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}'
|
|
1109
|
-
elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}'
|
|
1110
|
-
else openssl dgst -sha256 "$1" | awk '{print $NF}'
|
|
1111
|
-
fi
|
|
1112
|
-
}
|
|
1113
|
-
|
|
1114
|
-
# ---------------------------------------------------------------------------
|
|
1115
|
-
# file.search - matches as a JSON ARRAY, mirroring the Windows implementation so an agent can parse
|
|
1116
|
-
# either platform's answer identically.
|
|
1117
|
-
# ---------------------------------------------------------------------------
|
|
1118
|
-
cmd_file_search() {
|
|
1119
|
-
require_min 2 'file search'
|
|
1120
|
-
local dir name
|
|
1121
|
-
dir="$(pos 0)"; name="$(pos 1)"
|
|
1122
|
-
if [ ! -e "$dir" ]; then
|
|
1123
|
-
fail E_NOT_FOUND "cannot search '$dir': not found"
|
|
1124
|
-
fi
|
|
1125
|
-
if [ ! -d "$dir" ]; then
|
|
1126
|
-
fail E_NOT_A_DIR "cannot search '$dir': not a directory"
|
|
1127
|
-
fi
|
|
1128
|
-
local use_regex=0 recursive=0
|
|
1129
|
-
have_flag regex && use_regex=1
|
|
1130
|
-
have_flag recursive && recursive=1
|
|
1131
|
-
local maxdepth; maxdepth="$(flag_value max-depth '0')"
|
|
1132
|
-
local limit; limit="$(flag_value limit '500')"
|
|
1133
|
-
local typefilter; typefilter="$(flag_value type '*')"
|
|
1134
|
-
|
|
1135
|
-
local out="" count=0 truncated=false
|
|
1136
|
-
local findargs=()
|
|
1137
|
-
if [ "$recursive" -eq 1 ] || [ "$maxdepth" -gt 0 ] 2>/dev/null; then
|
|
1138
|
-
findargs+=("-mindepth" "1")
|
|
1139
|
-
if [ "${maxdepth:-0}" -gt 0 ] 2>/dev/null; then
|
|
1140
|
-
findargs+=("-maxdepth" "$maxdepth")
|
|
1141
|
-
fi
|
|
1142
|
-
else
|
|
1143
|
-
findargs+=("-mindepth" "1" "-maxdepth" "1")
|
|
1144
|
-
fi
|
|
1145
|
-
|
|
1146
|
-
while IFS= read -r entry; do
|
|
1147
|
-
[ -n "$entry" ] || continue
|
|
1148
|
-
local base itemtype size matched=0
|
|
1149
|
-
base="$(basename "$entry")"
|
|
1150
|
-
if [ -d "$entry" ]; then itemtype="directory"; size=0; else itemtype="file"; size="$(wc -c <"$entry" 2>/dev/null | tr -d ' ')"; fi
|
|
1151
|
-
[ -n "$size" ] || size=0
|
|
1152
|
-
if [ "$typefilter" != "*" ] && [ "$typefilter" != "" ] && [ "$itemtype" != "$typefilter" ]; then continue; fi
|
|
1153
|
-
if [ "$use_regex" -eq 1 ]; then
|
|
1154
|
-
if printf '%s' "$base" | grep -Eq -- "$name"; then matched=1; fi
|
|
1155
|
-
else
|
|
1156
|
-
case "$base" in
|
|
1157
|
-
*"$name"*) matched=1 ;;
|
|
1158
|
-
esac
|
|
1159
|
-
fi
|
|
1160
|
-
[ "$matched" -eq 1 ] || continue
|
|
1161
|
-
# Strip the leading directory so the caller gets a path relative to what it asked about.
|
|
1162
|
-
local rel="$entry"
|
|
1163
|
-
case "$entry" in
|
|
1164
|
-
"$dir"/*) rel="${entry#*/}" ;;
|
|
1165
|
-
esac
|
|
1166
|
-
# Built with printf rather than an escaped literal: backslash-escaped quotes inside a double
|
|
1167
|
-
# quoted string are where this kind of JSON assembly silently goes wrong.
|
|
1168
|
-
local one
|
|
1169
|
-
one="$(printf '{"path":%s,"name":%s,"type":%s,"size":%s,"relative":%s}' \
|
|
1170
|
-
"$(jstr "$entry")" "$(jstr "$base")" "$(jstr "$itemtype")" "$size" "$(jstr "$rel")")"
|
|
1171
|
-
if [ "$count" -gt 0 ]; then out="$out,$one"; else out="$one"; fi
|
|
1172
|
-
count=$((count + 1))
|
|
1173
|
-
if [ "$count" -ge "$limit" ]; then truncated=true; break; fi
|
|
1174
|
-
done < <(find "$dir" "${findargs[@]}" -print 2>/dev/null | LC_ALL=C sort)
|
|
1175
|
-
|
|
1176
|
-
printf '{"matches":[%s],"count":%s,"truncated":%s,"limit":%s,"directory":%s,"pattern":%s,"regex":%s,"recursive":%s}' \
|
|
1177
|
-
"$out" "$count" "$truncated" "$limit" "$(jstr "$dir")" "$(jstr "$name")" \
|
|
1178
|
-
"$([ "$use_regex" -eq 1 ] && echo true || echo false)" \
|
|
1179
|
-
"$([ "$recursive" -eq 1 ] && echo true || echo false)"
|
|
1180
|
-
}
|
|
1181
|
-
|
|
1182
|
-
# ===== system.sh ===================================================
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
# ---------------------------------------------------------------------------
|
|
1186
|
-
# system.* - pure queries, each field degrading on its own.
|
|
1187
|
-
# ---------------------------------------------------------------------------
|
|
1188
|
-
cmd_system_brand() {
|
|
1189
|
-
local platform vendor product
|
|
1190
|
-
platform="$(platform_name)"
|
|
1191
|
-
vendor=""; product=""
|
|
1192
|
-
if [ -r /sys/devices/virtual/dmi/id/sys_vendor ]; then
|
|
1193
|
-
vendor="$(cat /sys/devices/virtual/dmi/id/sys_vendor 2>/dev/null || true)"
|
|
1194
|
-
fi
|
|
1195
|
-
if [ -r /sys/devices/virtual/dmi/id/product_name ]; then
|
|
1196
|
-
product="$(cat /sys/devices/virtual/dmi/id/product_name 2>/dev/null || true)"
|
|
1197
|
-
fi
|
|
1198
|
-
if [ "$platform" = "darwin" ]; then
|
|
1199
|
-
product="$(sysctl -n hw.model 2>/dev/null || true)"
|
|
1200
|
-
vendor="Apple"
|
|
1201
|
-
fi
|
|
1202
|
-
printf '{"platform":%s,"vendor":%s,"product":%s,"family":%s}' \
|
|
1203
|
-
"$(jstr "$platform")" "$(jstr "$vendor")" "$(jstr "$product")" "$(jstr "$platform")"
|
|
1204
|
-
}
|
|
1205
|
-
|
|
1206
|
-
cmd_system_cpu() {
|
|
1207
|
-
local name="" cores="" model=""
|
|
1208
|
-
if [ -r /proc/cpuinfo ]; then
|
|
1209
|
-
model="$(awk -F: '/^model name/ { gsub(/^ +/, "", $2); print $2; exit }' /proc/cpuinfo)"
|
|
1210
|
-
cores="$(grep -c '^processor' /proc/cpuinfo 2>/dev/null || echo '')"
|
|
1211
|
-
fi
|
|
1212
|
-
if [ "$(platform_name)" = "darwin" ]; then
|
|
1213
|
-
model="$(sysctl -n machdep.cpu.brand_string 2>/dev/null || true)"
|
|
1214
|
-
cores="$(sysctl -n hw.logicalcpu 2>/dev/null || true)"
|
|
1215
|
-
fi
|
|
1216
|
-
name="$model"
|
|
1217
|
-
printf '{"count":1,"processors":[{"name":%s,"cores":%s,"threads":%s}]}' \
|
|
1218
|
-
"$(jstr "$name")" "${cores:-null}" "${cores:-null}"
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
|
-
cmd_system_gpu() {
|
|
1222
|
-
# A machine with no usable driver legitimately reports an empty list.
|
|
1223
|
-
printf '{"count":0,"adapters":[]}'
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
|
-
cmd_system_version() {
|
|
1227
|
-
local caption="" version="" kernel="" arch
|
|
1228
|
-
arch="$(uname -m 2>/dev/null || echo unknown)"
|
|
1229
|
-
kernel="$(uname -r 2>/dev/null || echo unknown)"
|
|
1230
|
-
if [ -r /etc/os-release ]; then
|
|
1231
|
-
caption="$(awk -F= '/^PRETTY_NAME=/ { gsub(/"/, "", $2); print $2; exit }' /etc/os-release)"
|
|
1232
|
-
version="$(awk -F= '/^VERSION_ID=/ { gsub(/"/, "", $2); print $2; exit }' /etc/os-release)"
|
|
1233
|
-
fi
|
|
1234
|
-
if [ "$(platform_name)" = "darwin" ]; then
|
|
1235
|
-
caption="macOS $(sw_vers -productVersion 2>/dev/null || true)"
|
|
1236
|
-
version="$(sw_vers -buildVersion 2>/dev/null || true)"
|
|
1237
|
-
fi
|
|
1238
|
-
local uid_is_root=false
|
|
1239
|
-
[ "$(id -u 2>/dev/null || echo 0)" = "0" ] && uid_is_root=true
|
|
1240
|
-
printf '{"platform":%s,"caption":%s,"version":%s,"kernel":%s,"architecture":%s,"isElevated":%s,"user":%s}' \
|
|
1241
|
-
"$(jstr "$(platform_name)")" "$(jstr "$caption")" "$(jstr "$version")" "$(jstr "$kernel")" \
|
|
1242
|
-
"$(jstr "$arch")" "$uid_is_root" "$(jstr "$(id -un 2>/dev/null || echo unknown)")"
|
|
1243
|
-
}
|
|
1244
|
-
|
|
1245
|
-
cmd_system_info() {
|
|
1246
|
-
local brand cpu gpu ver
|
|
1247
|
-
brand="$(cmd_system_brand)"; cpu="$(cmd_system_cpu)"; gpu="$(cmd_system_gpu)"; ver="$(cmd_system_version)"
|
|
1248
|
-
printf '{"brand":%s,"cpu":%s,"gpu":%s,"version":%s}' "$brand" "$cpu" "$gpu" "$ver"
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
# ===== execute.sh ==================================================
|
|
1252
|
-
|
|
1253
|
-
# Module: execute.sh - execute.command / execute.synchronous / execute.asynchronous.
|
|
1254
|
-
#
|
|
1255
|
-
# Contract, mirroring src/host/lib/execute.ps1 so a caller sees the same shape on both platforms:
|
|
1256
|
-
# - every element of the array is a SHELL command. The verb is named "command", the documented
|
|
1257
|
-
# examples are quoted strings, and the Windows side already runs them through cmd.exe. The POSIX
|
|
1258
|
-
# side therefore runs each through sh -c. This is the one verb group where a shell string is the
|
|
1259
|
-
# contract rather than a hazard; every other verb passes argv directly and never builds a command.
|
|
1260
|
-
# - synchronous runs them in order and STOPS at the first non-zero exit, so later steps never run
|
|
1261
|
-
# against a broken precondition.
|
|
1262
|
-
# - asynchronous starts them all first and only then collects. Waiting inside the start loop would
|
|
1263
|
-
# silently serialise it, which is the difference the verb exists to provide.
|
|
1264
|
-
# - each result reports exitCode (null when timed out), timedOut, durationMs, stdout and stderr.
|
|
1265
|
-
#
|
|
1266
|
-
# Output is redirected to scratch FILES, not pipes. A pipe whose reader is not draining fills up and
|
|
1267
|
-
# deadlocks the whole run; a file cannot fill. This is why the collector can be a plain wait loop.
|
|
1268
|
-
|
|
1269
|
-
# Scratch files for the asynchronous run, and the per-command result, both at module scope so the
|
|
1270
|
-
# helper functions can fill them in and the caller can read them.
|
|
1271
|
-
EXEC_STATUS=""
|
|
1272
|
-
EXEC_OUT=""
|
|
1273
|
-
EXEC_ERR=""
|
|
1274
|
-
EXEC_ELAPSED=0
|
|
1275
|
-
EXEC_TIMED_OUT=0
|
|
1276
|
-
EXEC_TIMEOUT=120000
|
|
1277
|
-
|
|
1278
|
-
# Milliseconds since the epoch. `date +%s%3N` is GNU; the fallback covers a date that lacks the
|
|
1279
|
-
# extension by reporting whole seconds, which keeps arithmetic correct if less precise.
|
|
1280
|
-
now_ms() {
|
|
1281
|
-
local t
|
|
1282
|
-
t="$(date +%s%3N 2>/dev/null)"
|
|
1283
|
-
case "$t" in
|
|
1284
|
-
''|*[!0-9]*) printf '%s' "$(( $(date +%s) * 1000 ))" ;;
|
|
1285
|
-
*) printf '%s' "$t" ;;
|
|
1286
|
-
esac
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
# Does the coreutils timeout(1) exist? It enforces a deadline precisely and leaves nothing behind,
|
|
1290
|
-
# which the fallback below cannot promise.
|
|
1291
|
-
HAVE_TIMEOUT=0
|
|
1292
|
-
if command -v timeout >/dev/null 2>&1; then HAVE_TIMEOUT=1; fi
|
|
1293
|
-
|
|
1294
|
-
# Run a command with a deadline. Prints the exit status to stdout.
|
|
1295
|
-
#
|
|
1296
|
-
# With timeout(1) available this is exact. The fallback uses "kill -0" in a loop, which is correct but
|
|
1297
|
-
# costs a process spawn per iteration (about 27ms under git-bash), so on that platform the reported
|
|
1298
|
-
# duration of a very short command is inflated by roughly a tenth of a second.
|
|
1299
|
-
run_with_deadline() {
|
|
1300
|
-
local timeout_ms="$1"; shift
|
|
1301
|
-
if [ "$HAVE_TIMEOUT" = "1" ]; then
|
|
1302
|
-
local seconds
|
|
1303
|
-
seconds="$(awk -v ms="$timeout_ms" 'BEGIN { printf "%.2f", ms / 1000 }' 2>/dev/null)"
|
|
1304
|
-
case "$seconds" in ''|*[!0-9.]*) seconds="$(( (timeout_ms + 999) / 1000 ))" ;; esac
|
|
1305
|
-
timeout -k 1 "$seconds" "$@"
|
|
1306
|
-
return $?
|
|
1307
|
-
fi
|
|
1308
|
-
|
|
1309
|
-
"$@" &
|
|
1310
|
-
local pid=$!
|
|
1311
|
-
local deadline=$(( $(now_ms) + timeout_ms ))
|
|
1312
|
-
while kill -0 "$pid" 2>/dev/null; do
|
|
1313
|
-
if [ "$(now_ms)" -ge "$deadline" ]; then
|
|
1314
|
-
kill -TERM "$pid" 2>/dev/null || true
|
|
1315
|
-
sleep 1
|
|
1316
|
-
kill -KILL "$pid" 2>/dev/null || true
|
|
1317
|
-
wait "$pid" 2>/dev/null || true
|
|
1318
|
-
# 124 is the status timeout(1) reports for a deadline it enforced, so the caller has one rule.
|
|
1319
|
-
return 124
|
|
1320
|
-
fi
|
|
1321
|
-
sleep 0.2
|
|
1322
|
-
done
|
|
1323
|
-
wait "$pid" 2>/dev/null
|
|
1324
|
-
return $?
|
|
1325
|
-
}
|
|
1326
|
-
|
|
1327
|
-
# Wait for an already-running background pid, killing it once the timeout elapses.
|
|
1328
|
-
# Returns 0 when the child exited on its own, 124 when it had to be killed.
|
|
1329
|
-
#
|
|
1330
|
-
# Only used when the caller cannot wrap the command in run_with_deadline, which is not the case here:
|
|
1331
|
-
# both exec_launch and exec_launch_argv go through run_with_deadline. Kept for completeness because a
|
|
1332
|
-
# timeout that silently fails to fire is worse than a slightly imprecise one.
|
|
1333
|
-
wait_with_timeout() {
|
|
1334
|
-
local pid="$1" timeout_ms="$2"
|
|
1335
|
-
local deadline=$(( $(now_ms) + timeout_ms ))
|
|
1336
|
-
while kill -0 "$pid" 2>/dev/null; do
|
|
1337
|
-
if [ "$(now_ms)" -ge "$deadline" ]; then
|
|
1338
|
-
kill -TERM "$pid" 2>/dev/null || true
|
|
1339
|
-
sleep 1
|
|
1340
|
-
kill -KILL "$pid" 2>/dev/null || true
|
|
1341
|
-
wait "$pid" 2>/dev/null || true
|
|
1342
|
-
return 124
|
|
1343
|
-
fi
|
|
1344
|
-
sleep 0.2
|
|
1345
|
-
done
|
|
1346
|
-
wait "$pid" 2>/dev/null || true
|
|
1347
|
-
return 0
|
|
1348
|
-
}
|
|
1349
|
-
|
|
1350
|
-
# Launch one shell command into the given scratch prefix. Prints the command's exit status; prints
|
|
1351
|
-
# nothing (empty) when it was killed for exceeding its deadline.
|
|
1352
|
-
#
|
|
1353
|
-
# The command runs through sh -c, NOT through eval. With eval, a command that calls exit - an ordinary
|
|
1354
|
-
# thing to write, and one of the golden cases - terminates the wrapping subshell before the status is
|
|
1355
|
-
# written, so no status reaches the caller and the command is then misreported as timed out. sh -c
|
|
1356
|
-
# returns the status normally, and it is the same shell the Windows side uses for a command line.
|
|
1357
|
-
#
|
|
1358
|
-
# run_with_deadline performs the waiting, so this function never backgrounds the child itself. That is
|
|
1359
|
-
# deliberate: an earlier version backgrounded it and polled, which inflated every measured duration.
|
|
1360
|
-
exec_launch() {
|
|
1361
|
-
local command="$1" workdir="$2" prefix="$3"
|
|
1362
|
-
: >"$prefix.out"; : >"$prefix.err"; rm -f "$prefix.rc" "$prefix.timedout"
|
|
1363
|
-
local status
|
|
1364
|
-
if [ -n "$workdir" ]; then
|
|
1365
|
-
status="$( cd "$workdir" 2>/dev/null || exit 127
|
|
1366
|
-
run_with_deadline "$EXEC_TIMEOUT" sh -c "$command" >"$prefix.out" 2>"$prefix.err"
|
|
1367
|
-
printf '%s' "$?" )"
|
|
1368
|
-
else
|
|
1369
|
-
status="$( run_with_deadline "$EXEC_TIMEOUT" sh -c "$command" >"$prefix.out" 2>"$prefix.err"
|
|
1370
|
-
printf '%s' "$?" )"
|
|
1371
|
-
fi
|
|
1372
|
-
case "$status" in
|
|
1373
|
-
'') printf 'deadline' >"$prefix.timedout" ;; # fallback path killed it
|
|
1374
|
-
124) printf 'deadline' >"$prefix.timedout" ;; # timeout(1) enforced the deadline
|
|
1375
|
-
*) printf '%s' "$status" >"$prefix.rc" ;;
|
|
1376
|
-
esac
|
|
1377
|
-
printf '%s' "$status"
|
|
1378
|
-
}
|
|
1379
|
-
|
|
1380
|
-
# Background variant used by execute.asynchronous: start the command now, record its status into
|
|
1381
|
-
# <prefix>.rc, and return immediately so the caller can start the next one. This is what makes the
|
|
1382
|
-
# asynchronous verb actually asynchronous; the foreground variant above would serialise it.
|
|
1383
|
-
exec_launch_bg() {
|
|
1384
|
-
local command="$1" workdir="$2" prefix="$3"
|
|
1385
|
-
: >"$prefix.out"; : >"$prefix.err"; rm -f "$prefix.rc"
|
|
1386
|
-
if [ -n "$workdir" ]; then
|
|
1387
|
-
( cd "$workdir" 2>/dev/null || exit 127
|
|
1388
|
-
s="$(run_with_deadline "$EXEC_TIMEOUT" sh -c "$command" >"$prefix.out" 2>"$prefix.err"; printf '%s' "$?")"
|
|
1389
|
-
case "$s" in ''|124) printf 'deadline' >"$prefix.timedout" ;; *) printf '%s' "$s" >"$prefix.rc" ;; esac ) &
|
|
1390
|
-
else
|
|
1391
|
-
( s="$(run_with_deadline "$EXEC_TIMEOUT" sh -c "$command" >"$prefix.out" 2>"$prefix.err"; printf '%s' "$?")"
|
|
1392
|
-
case "$s" in ''|124) printf 'deadline' >"$prefix.timedout" ;; *) printf '%s' "$s" >"$prefix.rc" ;; esac ) &
|
|
1393
|
-
fi
|
|
1394
|
-
printf '%s' "$!"
|
|
1395
|
-
}
|
|
1396
|
-
|
|
1397
|
-
# Fill EXEC_STATUS / EXEC_OUT / EXEC_ERR / EXEC_ELAPSED / EXEC_TIMED_OUT from one scratch prefix.
|
|
1398
|
-
exec_collect() {
|
|
1399
|
-
local prefix="$1" started="$2"
|
|
1400
|
-
EXEC_OUT="$(cat "$prefix.out" 2>/dev/null || true)"
|
|
1401
|
-
EXEC_ERR="$(cat "$prefix.err" 2>/dev/null || true)"
|
|
1402
|
-
if [ -s "$prefix.rc" ]; then
|
|
1403
|
-
EXEC_STATUS="$(cat "$prefix.rc" 2>/dev/null)"
|
|
1404
|
-
EXEC_TIMED_OUT=0
|
|
1405
|
-
EXEC_TIMEOUT=120000
|
|
1406
|
-
else
|
|
1407
|
-
EXEC_STATUS=""
|
|
1408
|
-
EXEC_TIMED_OUT=1
|
|
1409
|
-
fi
|
|
1410
|
-
EXEC_ELAPSED=$(( $(now_ms) - started ))
|
|
1411
|
-
}
|
|
1412
|
-
|
|
1413
|
-
exec_result_json() {
|
|
1414
|
-
local command="$1"
|
|
1415
|
-
if [ "$EXEC_TIMED_OUT" = "0" ]; then
|
|
1416
|
-
printf '{"command":%s,"exitCode":%s,"timedOut":false,"durationMs":%s,"stdout":%s,"stderr":%s}' \
|
|
1417
|
-
"$(jstr "$command")" "$EXEC_STATUS" "$EXEC_ELAPSED" "$(jstr "$EXEC_OUT")" "$(jstr "$EXEC_ERR")"
|
|
1418
|
-
else
|
|
1419
|
-
# exitCode is null rather than 0 so a killed command can never look like a successful one.
|
|
1420
|
-
printf '{"command":%s,"exitCode":null,"timedOut":true,"durationMs":%s,"stdout":%s,"stderr":%s}' \
|
|
1421
|
-
"$(jstr "$command")" "$EXEC_ELAPSED" "$(jstr "$EXEC_OUT")" "$(jstr "$EXEC_ERR")"
|
|
1422
|
-
fi
|
|
1423
|
-
}
|
|
1424
|
-
|
|
1425
|
-
exec_validate_timeout() {
|
|
1426
|
-
local timeout="$1"
|
|
1427
|
-
case "$timeout" in
|
|
1428
|
-
''|*[!0-9]*) fail E_TYPE "<timeout> must be an integer, got '$timeout'" ;;
|
|
1429
|
-
esac
|
|
1430
|
-
}
|
|
1431
|
-
|
|
1432
|
-
# Flags that are declared for a verb, are accepted by the argument parser, and CANNOT be honoured on this
|
|
1433
|
-
# platform. Accepting them silently is the one outcome the contract forbids: a caller that passes
|
|
1434
|
-
# --stdin and gets no input delivered, or --env and gets no variable set, has been misled by a success
|
|
1435
|
-
# envelope. They are refused with a typed error instead.
|
|
1436
|
-
#
|
|
1437
|
-
# The Windows side reads --shell, --env, --stdin and --background and acts on the first three; --encoding
|
|
1438
|
-
# selects the code page its output is decoded with. On POSIX the shell is always /bin/sh, so --shell has
|
|
1439
|
-
# no distinct meaning and is refused too rather than silently ignored.
|
|
1440
|
-
exec_reject_unhonoured_flags() {
|
|
1441
|
-
local verb="$1"
|
|
1442
|
-
local flag
|
|
1443
|
-
for flag in shell env stdin background encoding; do
|
|
1444
|
-
if have_flag "$flag"; then
|
|
1445
|
-
fail E_NOT_SUPPORTED "--$flag is not honoured on this platform" \
|
|
1446
|
-
"$verb runs the command through /bin/sh and passes no environment, stdin or code page"
|
|
1447
|
-
fi
|
|
1448
|
-
done
|
|
1449
|
-
return 0
|
|
1450
|
-
}
|
|
1451
|
-
|
|
1452
|
-
cmd_execute_synchronous() {
|
|
1453
|
-
require_min 1 'execute synchronous'
|
|
1454
|
-
require_allow 'execute synchronous' "${#POSITIONALS[@]}"
|
|
1455
|
-
exec_reject_unhonoured_flags 'execute synchronous'
|
|
1456
|
-
local workdir timeout
|
|
1457
|
-
workdir="$(flag_value workdir)"
|
|
1458
|
-
timeout="$(flag_value timeout 120000)"
|
|
1459
|
-
exec_validate_timeout "$timeout"
|
|
1460
|
-
|
|
1461
|
-
local start_ms n=${#POSITIONALS[@]}
|
|
1462
|
-
start_ms="$(now_ms)"
|
|
1463
|
-
local dir="$ROOT_DIR/.temporary/exec-sync-$$"
|
|
1464
|
-
mkdir -p "$dir" 2>/dev/null || true
|
|
1465
|
-
|
|
1466
|
-
local results="" executed=0 failed=0 i=0
|
|
1467
|
-
while [ "$i" -lt "$n" ]; do
|
|
1468
|
-
local cmd="${POSITIONALS[$i]}" prefix="$dir/$i"
|
|
1469
|
-
local one_start; one_start="$(now_ms)"
|
|
1470
|
-
EXEC_TIMEOUT="$timeout"
|
|
1471
|
-
exec_launch "$cmd" "$workdir" "$prefix" >/dev/null
|
|
1472
|
-
exec_collect "$prefix" "$one_start"
|
|
1473
|
-
executed=$((executed + 1))
|
|
1474
|
-
results="$results$( [ "$executed" -gt 1 ] && printf ',' )$(exec_result_json "$cmd")"
|
|
1475
|
-
# Stop at the first failure: continuing would run later commands on a broken precondition.
|
|
1476
|
-
if [ "$EXEC_TIMED_OUT" = "1" ] || [ "$EXEC_STATUS" != "0" ]; then
|
|
1477
|
-
failed=1
|
|
1478
|
-
break
|
|
1479
|
-
fi
|
|
1480
|
-
i=$((i + 1))
|
|
1481
|
-
done
|
|
1482
|
-
rm -rf "$dir" 2>/dev/null || true
|
|
1483
|
-
|
|
1484
|
-
local total all="false"
|
|
1485
|
-
total=$(( $(now_ms) - start_ms ))
|
|
1486
|
-
if [ "$failed" -eq 0 ] && [ "$executed" -eq "$n" ]; then all="true"; fi
|
|
1487
|
-
printf '{"mode":"synchronous","requested":%s,"executed":%s,"failed":%s,"allSucceeded":%s,"totalDurationMs":%s,"results":[%s]}' \
|
|
1488
|
-
"$n" "$executed" "$failed" "$all" "$total" "$results"
|
|
1489
|
-
}
|
|
1490
|
-
|
|
1491
|
-
cmd_execute_asynchronous() {
|
|
1492
|
-
require_min 1 'execute asynchronous'
|
|
1493
|
-
require_allow 'execute asynchronous' "${#POSITIONALS[@]}"
|
|
1494
|
-
exec_reject_unhonoured_flags 'execute asynchronous'
|
|
1495
|
-
local workdir timeout
|
|
1496
|
-
workdir="$(flag_value workdir)"
|
|
1497
|
-
timeout="$(flag_value timeout 120000)"
|
|
1498
|
-
exec_validate_timeout "$timeout"
|
|
1499
|
-
|
|
1500
|
-
local start_ms n=${#POSITIONALS[@]}
|
|
1501
|
-
start_ms="$(now_ms)"
|
|
1502
|
-
local dir="$ROOT_DIR/.temporary/exec-async-$$"
|
|
1503
|
-
mkdir -p "$dir" 2>/dev/null || true
|
|
1504
|
-
|
|
1505
|
-
# Start every command before collecting any. This is the whole point of the verb.
|
|
1506
|
-
local pids=() starts=() k=0
|
|
1507
|
-
EXEC_TIMEOUT="$timeout"
|
|
1508
|
-
while [ "$k" -lt "$n" ]; do
|
|
1509
|
-
local cmd="${POSITIONALS[$k]}"
|
|
1510
|
-
starts+=("$(now_ms)")
|
|
1511
|
-
pids+=("$(exec_launch_bg "$cmd" "$workdir" "$dir/$k")")
|
|
1512
|
-
k=$((k + 1))
|
|
1513
|
-
done
|
|
1514
|
-
|
|
1515
|
-
# Collect after the last start. Each wait is independently bounded, so one slow command cannot
|
|
1516
|
-
# extend another's timeout.
|
|
1517
|
-
local results="" executed=0 failed=0
|
|
1518
|
-
k=0
|
|
1519
|
-
while [ "$k" -lt "$n" ]; do
|
|
1520
|
-
local cmd="${POSITIONALS[$k]}" pid="${pids[$k]}"
|
|
1521
|
-
wait_with_timeout "$pid" "$timeout" || true
|
|
1522
|
-
exec_collect "$dir/$k" "${starts[$k]}"
|
|
1523
|
-
executed=$((executed + 1))
|
|
1524
|
-
results="$results$( [ "$executed" -gt 1 ] && printf ',' )$(exec_result_json "$cmd")"
|
|
1525
|
-
if [ "$EXEC_TIMED_OUT" = "1" ] || [ "$EXEC_STATUS" != "0" ]; then
|
|
1526
|
-
failed=$((failed + 1))
|
|
1527
|
-
fi
|
|
1528
|
-
k=$((k + 1))
|
|
1529
|
-
done
|
|
1530
|
-
rm -rf "$dir" 2>/dev/null || true
|
|
1531
|
-
|
|
1532
|
-
local total all="false"
|
|
1533
|
-
total=$(( $(now_ms) - start_ms ))
|
|
1534
|
-
if [ "$failed" -eq 0 ] && [ "$executed" -eq "$n" ]; then all="true"; fi
|
|
1535
|
-
printf '{"mode":"asynchronous","requested":%s,"executed":%s,"failed":%s,"allSucceeded":%s,"totalDurationMs":%s,"results":[%s]}' \
|
|
1536
|
-
"$n" "$executed" "$failed" "$all" "$total" "$results"
|
|
1537
|
-
}
|
|
1538
|
-
|
|
1539
|
-
# execute.command: a single command with its own argument vector.
|
|
1540
|
-
#
|
|
1541
|
-
# The first positional is the program and the rest are its arguments, passed as a real argv array:
|
|
1542
|
-
# this is the one form in the surface that never involves a shell, which is why it exists next to the
|
|
1543
|
-
# batch verbs. It reads POSITIONALS through pos(), like every other verb, because dispatch calls a
|
|
1544
|
-
# verb function with no arguments at all.
|
|
1545
|
-
cmd_execute_command() {
|
|
1546
|
-
require_min 1 'execute command'
|
|
1547
|
-
require_allow 'execute command' "${#POSITIONALS[@]}"
|
|
1548
|
-
exec_reject_unhonoured_flags 'execute command'
|
|
1549
|
-
local workdir timeout
|
|
1550
|
-
workdir="$(flag_value workdir)"
|
|
1551
|
-
timeout="$(flag_value timeout 120000)"
|
|
1552
|
-
exec_validate_timeout "$timeout"
|
|
1553
|
-
|
|
1554
|
-
local argv=()
|
|
1555
|
-
local i=0 n=${#POSITIONALS[@]}
|
|
1556
|
-
while [ "$i" -lt "$n" ]; do argv+=("${POSITIONALS[$i]}"); i=$((i + 1)); done
|
|
1557
|
-
|
|
1558
|
-
local dir="$ROOT_DIR/.temporary/exec-one-$$"
|
|
1559
|
-
mkdir -p "$dir" 2>/dev/null || true
|
|
1560
|
-
local prefix="$dir/0"
|
|
1561
|
-
: >"$prefix.out"; : >"$prefix.err"; rm -f "$prefix.rc" "$prefix.timedout"
|
|
1562
|
-
|
|
1563
|
-
# Same deadline handling as the batch launchers: the command runs as a real argv with no shell, and
|
|
1564
|
-
# a deadline kill is recorded in a marker file rather than passed off as an exit status.
|
|
1565
|
-
local start_ms; start_ms="$(now_ms)"
|
|
1566
|
-
local st
|
|
1567
|
-
if [ -n "$workdir" ]; then
|
|
1568
|
-
st="$( cd "$workdir" 2>/dev/null || exit 127
|
|
1569
|
-
run_with_deadline "$timeout" "${argv[@]}" >"$prefix.out" 2>"$prefix.err"
|
|
1570
|
-
printf '%s' "$?" )"
|
|
1571
|
-
else
|
|
1572
|
-
st="$( run_with_deadline "$timeout" "${argv[@]}" >"$prefix.out" 2>"$prefix.err"
|
|
1573
|
-
printf '%s' "$?" )"
|
|
1574
|
-
fi
|
|
1575
|
-
case "$st" in
|
|
1576
|
-
''|124) printf 'deadline' >"$prefix.timedout" ;;
|
|
1577
|
-
*) printf '%s' "$st" >"$prefix.rc" ;;
|
|
1578
|
-
esac
|
|
1579
|
-
exec_collect "$prefix" "$start_ms"
|
|
1580
|
-
rm -rf "$dir" 2>/dev/null || true
|
|
1581
|
-
|
|
1582
|
-
local argv_json="" a=""
|
|
1583
|
-
for a in "${argv[@]}"; do
|
|
1584
|
-
argv_json="$argv_json$( [ -n "$argv_json" ] && printf ',' )$(jstr "$a")"
|
|
1585
|
-
done
|
|
1586
|
-
printf '{"argv":[%s],"exitCode":%s,"timedOut":%s,"durationMs":%s,"stdout":%s,"stderr":%s}' \
|
|
1587
|
-
"$argv_json" \
|
|
1588
|
-
"$( [ "$EXEC_TIMED_OUT" = "0" ] && printf '%s' "$EXEC_STATUS" || printf 'null' )" \
|
|
1589
|
-
"$( [ "$EXEC_TIMED_OUT" = "0" ] && printf 'false' || printf 'true' )" \
|
|
1590
|
-
"$EXEC_ELAPSED" "$(jstr "$EXEC_OUT")" "$(jstr "$EXEC_ERR")"
|
|
1591
|
-
}
|
|
1592
|
-
|
|
1593
|
-
# ===== task.sh =====================================================
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
# ---------------------------------------------------------------------------
|
|
1597
|
-
# task.* / environment.* / service.* / permission.*
|
|
1598
|
-
# ---------------------------------------------------------------------------
|
|
1599
|
-
cmd_task_list() {
|
|
1600
|
-
local limit; limit="$(flag_value limit '200')"
|
|
1601
|
-
local filter; filter="$(flag_value filter '*')"
|
|
1602
|
-
local out="" count=0
|
|
1603
|
-
if [ -d /proc ]; then
|
|
1604
|
-
local d pid name
|
|
1605
|
-
for d in /proc/[0-9]*; do
|
|
1606
|
-
[ -d "$d" ] || continue
|
|
1607
|
-
pid="${d#/proc/}"
|
|
1608
|
-
name="$(cat "$d/comm" 2>/dev/null || echo '')"
|
|
1609
|
-
case "$filter" in ''|'*') : ;; *) case "$name" in $filter) : ;; *) continue ;; esac ;; esac
|
|
1610
|
-
[ "$count" -gt 0 ] && out="$out,"
|
|
1611
|
-
out="$out{\"pid\":$pid,\"name\":$(jstr "$name")}"
|
|
1612
|
-
count=$((count + 1))
|
|
1613
|
-
[ "$count" -ge "$limit" ] && break
|
|
1614
|
-
done
|
|
1615
|
-
fi
|
|
1616
|
-
printf '{"count":%s,"limit":%s,"processes":[%s]}' "$count" "$limit" "$out"
|
|
1617
|
-
}
|
|
1618
|
-
|
|
1619
|
-
cmd_task_query() {
|
|
1620
|
-
require_min 1 'task query'
|
|
1621
|
-
local pid; pid="$(pos 0)"
|
|
1622
|
-
case "$pid" in ''|*[!0-9]*) fail E_TYPE "<pid> must be an integer, got '$pid'" ;; esac
|
|
1623
|
-
if [ ! -d "/proc/$pid" ]; then
|
|
1624
|
-
fail E_NOT_FOUND "no process with pid $pid"
|
|
1625
|
-
fi
|
|
1626
|
-
local name path
|
|
1627
|
-
name="$(cat "/proc/$pid/comm" 2>/dev/null || echo '')"
|
|
1628
|
-
path="$(readlink "/proc/$pid/exe" 2>/dev/null || echo '')"
|
|
1629
|
-
printf '{"pid":%s,"name":%s,"path":%s}' "$pid" "$(jstr "$name")" "$(jstr "$path")"
|
|
1630
|
-
}
|
|
1631
|
-
|
|
1632
|
-
# execute query pid - the same question as task query, kept as its own verb because the contract names
|
|
1633
|
-
# it separately. Delegating rather than duplicating keeps one implementation of the answer.
|
|
1634
|
-
cmd_execute_query() {
|
|
1635
|
-
cmd_task_query
|
|
1636
|
-
}
|
|
1637
|
-
|
|
1638
|
-
# task show <pid> - bring the process's main window to the front.
|
|
1639
|
-
#
|
|
1640
|
-
# There is no portable way to do this: it needs X11 (wmctrl or xdotool) or a Wayland compositor
|
|
1641
|
-
# protocol, and the tools are frequently absent. When they are absent this reports a typed
|
|
1642
|
-
# E_NOT_SUPPORTED naming what is missing, rather than succeeding without doing anything - a caller
|
|
1643
|
-
# that believes a window was raised will wait for something that never appears.
|
|
1644
|
-
cmd_task_show() {
|
|
1645
|
-
require_min 1 'task show'
|
|
1646
|
-
local pid; pid="$(pos 0)"
|
|
1647
|
-
case "$pid" in ''|*[!0-9]*) fail E_TYPE "<pid> must be an integer, got '$pid'" ;; esac
|
|
1648
|
-
if [ ! -d "/proc/$pid" ]; then
|
|
1649
|
-
fail E_NOT_FOUND "no process with pid $pid"
|
|
1650
|
-
fi
|
|
1651
|
-
if [ -z "${DISPLAY:-}" ] && [ -z "${WAYLAND_DISPLAY:-}" ]; then
|
|
1652
|
-
fail E_NO_DISPLAY 'there is no display server in this environment' \
|
|
1653
|
-
'window activation needs X11 or Wayland; DISPLAY and WAYLAND_DISPLAY are both unset'
|
|
1654
|
-
fi
|
|
1655
|
-
if command -v wmctrl >/dev/null 2>&1; then
|
|
1656
|
-
# -a activates by window id; -p adds the pid column, which is how the id is found from a pid.
|
|
1657
|
-
local id
|
|
1658
|
-
id="$(wmctrl -lp 2>/dev/null | awk -v p="$pid" '$3 == p { print $1; exit }')"
|
|
1659
|
-
if [ -z "$id" ]; then
|
|
1660
|
-
fail E_WINDOW_NOT_FOUND "pid $pid has no top-level window"
|
|
1661
|
-
fi
|
|
1662
|
-
wmctrl -i -a "$id" >/dev/null 2>&1 || fail E_IO "wmctrl could not activate window $id"
|
|
1663
|
-
printf '{"pid":%s,"window":%s,"tool":"wmctrl"}' "$pid" "$(jstr "$id")"
|
|
1664
|
-
return 0
|
|
1665
|
-
fi
|
|
1666
|
-
if command -v xdotool >/dev/null 2>&1; then
|
|
1667
|
-
local id2
|
|
1668
|
-
id2="$(xdotool search --pid "$pid" 2>/dev/null | head -1)"
|
|
1669
|
-
if [ -z "$id2" ]; then
|
|
1670
|
-
fail E_WINDOW_NOT_FOUND "pid $pid has no top-level window"
|
|
1671
|
-
fi
|
|
1672
|
-
xdotool windowactivate "$id2" >/dev/null 2>&1 || fail E_IO "xdotool could not activate window $id2"
|
|
1673
|
-
printf '{"pid":%s,"window":%s,"tool":"xdotool"}' "$pid" "$(jstr "$id2")"
|
|
1674
|
-
return 0
|
|
1675
|
-
fi
|
|
1676
|
-
fail E_NOT_SUPPORTED 'no window-activation tool is installed' \
|
|
1677
|
-
'install wmctrl or xdotool to use task show on this platform'
|
|
1678
|
-
}
|
|
1679
|
-
|
|
1680
|
-
# task kill <pid> [--force] / execute kill <pid> [--force]
|
|
1681
|
-
#
|
|
1682
|
-
# Without --force this sends SIGTERM, which a process can handle: it is the request to stop. It then
|
|
1683
|
-
# checks whether the process actually went away, because reporting success without checking is how a
|
|
1684
|
-
# caller ends up operating on a process it believes is gone.
|
|
1685
|
-
#
|
|
1686
|
-
# With --force this sends SIGKILL, which cannot be caught, blocked or ignored, so the process gets no
|
|
1687
|
-
# chance to save anything. SIGKILL is also relayed to a shell's children only if they are in the same
|
|
1688
|
-
# process group, which is why the result reports the signal sent rather than a guarantee.
|
|
1689
|
-
cmd_task_kill() {
|
|
1690
|
-
require_min 1 'task kill'
|
|
1691
|
-
require_allow 'task kill' "${#POSITIONALS[@]}"
|
|
1692
|
-
local pid; pid="$(pos 0)"
|
|
1693
|
-
case "$pid" in ''|*[!0-9]*) fail E_TYPE "<pid> must be an integer, got '$pid'" ;; esac
|
|
1694
|
-
if [ "$pid" -eq 0 ]; then
|
|
1695
|
-
fail E_TYPE '<pid> must be a real process id, not 0'
|
|
1696
|
-
fi
|
|
1697
|
-
if [ "$pid" = "$$" ]; then
|
|
1698
|
-
# Killing the reporting process would leave the caller with no result at all.
|
|
1699
|
-
fail E_USAGE 'refusing to kill the process running this verb'
|
|
1700
|
-
fi
|
|
1701
|
-
if [ ! -d "/proc/$pid" ]; then
|
|
1702
|
-
fail E_NOT_FOUND "no process with pid $pid"
|
|
1703
|
-
fi
|
|
1704
|
-
local name
|
|
1705
|
-
name="$(cat "/proc/$pid/comm" 2>/dev/null || echo '')"
|
|
1706
|
-
|
|
1707
|
-
if have_flag force; then
|
|
1708
|
-
kill -KILL "$pid" 2>/dev/null || fail E_IO "pid $pid could not be killed (insufficient privilege?)"
|
|
1709
|
-
sleep 0.2
|
|
1710
|
-
local gone="true"
|
|
1711
|
-
kill -0 "$pid" 2>/dev/null && gone="false"
|
|
1712
|
-
printf '{"pid":%s,"name":%s,"mode":"force","signal":"SIGKILL","terminated":%s,"warning":"SIGKILL cannot be caught; the process had no chance to save state"}' \
|
|
1713
|
-
"$pid" "$(jstr "$name")" "$gone"
|
|
1714
|
-
return 0
|
|
1715
|
-
fi
|
|
1716
|
-
|
|
1717
|
-
kill -TERM "$pid" 2>/dev/null || fail E_IO "pid $pid could not be signalled (insufficient privilege?)"
|
|
1718
|
-
# Give it a moment to act on the request before deciding whether it did.
|
|
1719
|
-
local waited=0
|
|
1720
|
-
while [ "$waited" -lt 3000 ]; do
|
|
1721
|
-
kill -0 "$pid" 2>/dev/null || break
|
|
1722
|
-
sleep 0.1
|
|
1723
|
-
waited=$((waited + 100))
|
|
1724
|
-
done
|
|
1725
|
-
if kill -0 "$pid" 2>/dev/null; then
|
|
1726
|
-
printf '{"pid":%s,"name":%s,"mode":"graceful","signal":"SIGTERM","terminated":false,"stillRunning":true,"hint":"the process did not exit; re-run with --force to send SIGKILL"}' \
|
|
1727
|
-
"$pid" "$(jstr "$name")"
|
|
1728
|
-
else
|
|
1729
|
-
printf '{"pid":%s,"name":%s,"mode":"graceful","signal":"SIGTERM","terminated":true,"stillRunning":false,"waitedMs":%s}' \
|
|
1730
|
-
"$pid" "$(jstr "$name")" "$waited"
|
|
1731
|
-
fi
|
|
1732
|
-
}
|
|
1733
|
-
|
|
1734
|
-
# execute kill pid - the same act as task kill, under the name the contract gives it.
|
|
1735
|
-
cmd_execute_kill() {
|
|
1736
|
-
cmd_task_kill
|
|
1737
|
-
}
|
|
1738
|
-
|
|
1739
|
-
# ===== environment.sh ==============================================
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
cmd_environment_list() {
|
|
1743
|
-
local scope; scope="$(flag_value scope 'user')"
|
|
1744
|
-
# Only the process scope exists in a POSIX shell; persistent scopes are shell configuration, not
|
|
1745
|
-
# an environment API, so asking for one is reported rather than faked.
|
|
1746
|
-
if [ "$scope" != "process" ]; then
|
|
1747
|
-
fail E_NOT_SUPPORTED "scope '$scope' is not available on this platform" \
|
|
1748
|
-
'only --scope process is supported; persistent variables live in shell startup files'
|
|
1749
|
-
fi
|
|
1750
|
-
local out="" count=0
|
|
1751
|
-
local name
|
|
1752
|
-
for name in $(env | LC_ALL=C sed 's/=.*//' | sort); do
|
|
1753
|
-
local value="${!name-}"
|
|
1754
|
-
[ "$count" -gt 0 ] && out="$out,"
|
|
1755
|
-
out="$out{\"name\":$(jstr "$name"),\"value\":$(jstr "$value")}"
|
|
1756
|
-
count=$((count + 1))
|
|
1757
|
-
done
|
|
1758
|
-
printf '{"scope":"process","count":%s,"variables":[%s]}' "$count" "$out"
|
|
1759
|
-
}
|
|
1760
|
-
|
|
1761
|
-
cmd_environment_get() {
|
|
1762
|
-
require_min 1 'environment get'
|
|
1763
|
-
local name; name="$(pos 0)"
|
|
1764
|
-
local scope; scope="$(flag_value scope 'user')"
|
|
1765
|
-
if [ "$scope" != "process" ]; then
|
|
1766
|
-
fail E_NOT_SUPPORTED "scope '$scope' is not available on this platform" 'only --scope process is supported'
|
|
1767
|
-
fi
|
|
1768
|
-
if [ -z "${!name+x}" ]; then
|
|
1769
|
-
fail E_NOT_FOUND "'$name' is not set at process scope"
|
|
1770
|
-
fi
|
|
1771
|
-
printf '{"name":%s,"value":%s,"scope":"process"}' "$(jstr "$name")" "$(jstr "${!name}")"
|
|
1772
|
-
}
|
|
1773
|
-
|
|
1774
|
-
env_separator() { printf ':'; }
|
|
1775
|
-
|
|
1776
|
-
cmd_environment_attribute_list() {
|
|
1777
|
-
local scope; scope="$(flag_value scope 'user')"
|
|
1778
|
-
if [ "$scope" != "process" ]; then
|
|
1779
|
-
fail E_NOT_SUPPORTED "scope '$scope' is not available on this platform" 'only --scope process is supported'
|
|
1780
|
-
fi
|
|
1781
|
-
local sep; sep="$(env_separator)"
|
|
1782
|
-
local out="" count=0
|
|
1783
|
-
local name
|
|
1784
|
-
for name in PATH PATHEXT LIB INCLUDE MANPATH CDPATH; do
|
|
1785
|
-
if [ -z "${!name+x}" ]; then continue; fi
|
|
1786
|
-
local entries=0
|
|
1787
|
-
local IFS="$sep"
|
|
1788
|
-
for _e in ${!name}; do entries=$((entries + 1)); done
|
|
1789
|
-
[ "$count" -gt 0 ] && out="$out,"
|
|
1790
|
-
out="$out{\"name\":$(jstr "$name"),\"known\":true,\"separator\":$(jstr "$sep"),\"entryCount\":$entries}"
|
|
1791
|
-
count=$((count + 1))
|
|
1792
|
-
done
|
|
1793
|
-
printf '{"scope":"process","count":%s,"attributes":[%s]}' "$count" "$out"
|
|
1794
|
-
}
|
|
1795
|
-
|
|
1796
|
-
cmd_environment_attribute_get() {
|
|
1797
|
-
require_min 1 'environment attr get'
|
|
1798
|
-
local name; name="$(pos 0)"
|
|
1799
|
-
local attr; attr="$(flag_value attr 'PATH')"
|
|
1800
|
-
local scope; scope="$(flag_value scope 'user')"
|
|
1801
|
-
if [ "$scope" != "process" ]; then
|
|
1802
|
-
fail E_NOT_SUPPORTED "scope '$scope' is not available on this platform" 'only --scope process is supported'
|
|
1803
|
-
fi
|
|
1804
|
-
if [ -z "${!attr+x}" ]; then
|
|
1805
|
-
fail E_NOT_FOUND "attribute '$attr' is not set at process scope"
|
|
1806
|
-
fi
|
|
1807
|
-
local sep; sep="$(env_separator)"
|
|
1808
|
-
local out="" count=0
|
|
1809
|
-
local IFS="$sep"
|
|
1810
|
-
for e in ${!attr}; do
|
|
1811
|
-
out="$out$( [ "$count" -gt 0 ] && echo , ){\"index\":$count,\"value\":$(jstr "$e")}"
|
|
1812
|
-
count=$((count + 1))
|
|
1813
|
-
done
|
|
1814
|
-
printf '{"name":%s,"requestedName":%s,"scope":"process","separator":%s,"count":%s,"entries":[%s]}' \
|
|
1815
|
-
"$(jstr "$attr")" "$(jstr "$name")" "$(jstr "$sep")" "$count" "$out"
|
|
1816
|
-
}
|
|
1817
|
-
|
|
1818
|
-
# ---------------------------------------------------------------------------
|
|
1819
|
-
# environment.* - process scope, plus persistent scopes through a managed block
|
|
1820
|
-
# in a shell startup file.
|
|
1821
|
-
# ---------------------------------------------------------------------------
|
|
1822
|
-
#
|
|
1823
|
-
# Persisting a variable on POSIX has no kernel API: it means editing a shell startup file. That is a
|
|
1824
|
-
# real edit to a file the user owns, so it is done inside a delimited block that this tool owns:
|
|
1825
|
-
# everything between the markers is regenerated on each change, so a value can be added, replaced or
|
|
1826
|
-
# removed idempotently, and the user can undo the whole thing by deleting the block. Writing
|
|
1827
|
-
# assignments directly into the file would duplicate them on the second call and could not be undone.
|
|
1828
|
-
ENV_BLOCK_BEGIN="# >>> dsh-os managed environment >>>"
|
|
1829
|
-
ENV_BLOCK_END="# <<< dsh-os managed environment <<<"
|
|
1830
|
-
|
|
1831
|
-
env_scope_file() {
|
|
1832
|
-
local scope="$1"
|
|
1833
|
-
case "$scope" in
|
|
1834
|
-
user) printf '%s' "${HOME:-/tmp}/.profile" ;;
|
|
1835
|
-
machine) printf '%s' '/etc/environment' ;;
|
|
1836
|
-
*) printf '' ;;
|
|
1837
|
-
esac
|
|
1838
|
-
}
|
|
1839
|
-
|
|
1840
|
-
# Resolve the requested scope, defaulting to process, which is the only scope that works without
|
|
1841
|
-
# touching a file.
|
|
1842
|
-
#
|
|
1843
|
-
# This ASSIGNS ENV_SCOPE rather than printing it, so a verb can call it in its own shell. Called as
|
|
1844
|
-
# `scope="$(env_resolve_scope)"` it runs in a subshell, where fail's `exit` ends only the subshell:
|
|
1845
|
-
# the verb then carries on with an empty scope and produces a second, unrelated failure whose exit
|
|
1846
|
-
# class contradicted the error code in the envelope.
|
|
1847
|
-
ENV_SCOPE="process"
|
|
1848
|
-
env_resolve_scope() {
|
|
1849
|
-
local scope; scope="$(flag_value scope 'process')"
|
|
1850
|
-
case "$scope" in
|
|
1851
|
-
process|user|machine) ENV_SCOPE="$scope"; return 0 ;;
|
|
1852
|
-
*) fail E_NOT_SUPPORTED "scope '$scope' is not available on this platform" \
|
|
1853
|
-
'accepted: process, user, machine' ;;
|
|
1854
|
-
esac
|
|
1855
|
-
}
|
|
1856
|
-
|
|
1857
|
-
# Whether the CALLER can see the change. It is false for every scope on this platform, and that is not
|
|
1858
|
-
# a defect of one scope: the verb runs as a separate process, so an export inside it cannot reach the
|
|
1859
|
-
# shell that invoked it, and a persistent scope only affects shells started afterwards.
|
|
1860
|
-
#
|
|
1861
|
-
# This used to report true for process scope, which was simply wrong - the caller is a running process
|
|
1862
|
-
# and it cannot see it. Reporting it as true would lead a caller to read the variable back, find it
|
|
1863
|
-
# missing, and conclude the write had failed.
|
|
1864
|
-
env_visible_note() {
|
|
1865
|
-
printf 'false'
|
|
1866
|
-
}
|
|
1867
|
-
|
|
1868
|
-
env_visibility_note() {
|
|
1869
|
-
local scope="$1"
|
|
1870
|
-
if [ "$scope" = "process" ]; then
|
|
1871
|
-
printf '%s' 'process scope applies to this script process and any child it starts within the same invocation; the calling shell is unaffected'
|
|
1872
|
-
else
|
|
1873
|
-
printf '%s' 'in effect for shells started afterwards; open a new shell or source the file'
|
|
1874
|
-
fi
|
|
1875
|
-
}
|
|
1876
|
-
|
|
1877
|
-
# JSON-escaped form of the note, because the raw text contains no quotes but is still free prose and
|
|
1878
|
-
# emitting it unquoted produced a malformed envelope.
|
|
1879
|
-
env_visibility_note_json() {
|
|
1880
|
-
jstr "$(env_visibility_note "$1")"
|
|
1881
|
-
}
|
|
1882
|
-
|
|
1883
|
-
env_read_shell_value() {
|
|
1884
|
-
# The current value, if any, of a variable at process scope.
|
|
1885
|
-
local name="$1"
|
|
1886
|
-
if [ -z "${!name+x}" ]; then return 1; fi
|
|
1887
|
-
printf '%s' "${!name}"
|
|
1888
|
-
return 0
|
|
1889
|
-
}
|
|
1890
|
-
|
|
1891
|
-
# Rewrite the managed block in a file so it holds exactly the given NAME=value pairs.
|
|
1892
|
-
#
|
|
1893
|
-
# Byte-safe on the rest of the file: every line outside the block is preserved verbatim, and a file
|
|
1894
|
-
# that has no block yet gets one appended.
|
|
1895
|
-
env_write_block() {
|
|
1896
|
-
local file="$1"; shift
|
|
1897
|
-
local dir; dir="$(dirname "$file")"
|
|
1898
|
-
if [ ! -d "$dir" ]; then
|
|
1899
|
-
fail E_NOT_FOUND "the directory for $file does not exist"
|
|
1900
|
-
fi
|
|
1901
|
-
# Refuse rather than clobber: if the file exists but cannot be written, that must be reported, not
|
|
1902
|
-
# half-applied.
|
|
1903
|
-
if [ -e "$file" ] && [ ! -w "$file" ]; then
|
|
1904
|
-
if [ "$(id -u)" -ne 0 ]; then
|
|
1905
|
-
fail E_ELEVATION_REQUIRED "cannot write $file without elevation" \
|
|
1906
|
-
'the target is not writable by this process; nothing was changed'
|
|
1907
|
-
fi
|
|
1908
|
-
fi
|
|
1909
|
-
|
|
1910
|
-
local tmp; tmp="$(mktemp)"
|
|
1911
|
-
local in_block=0 wrote_block=0
|
|
1912
|
-
if [ -f "$file" ]; then
|
|
1913
|
-
while IFS= read -r line || [ -n "$line" ]; do
|
|
1914
|
-
if [ "$line" = "$ENV_BLOCK_BEGIN" ]; then
|
|
1915
|
-
in_block=1
|
|
1916
|
-
# Emit the block at the position of the old one, so ordering in the file is preserved.
|
|
1917
|
-
printf '%s\n' "$ENV_BLOCK_BEGIN" >>"$tmp"
|
|
1918
|
-
local kv
|
|
1919
|
-
for kv in "$@"; do
|
|
1920
|
-
printf 'export %s\n' "$kv" >>"$tmp"
|
|
1921
|
-
done
|
|
1922
|
-
printf '%s\n' "$ENV_BLOCK_END" >>"$tmp"
|
|
1923
|
-
wrote_block=1
|
|
1924
|
-
continue
|
|
1925
|
-
fi
|
|
1926
|
-
if [ "$in_block" = "1" ]; then
|
|
1927
|
-
if [ "$line" = "$ENV_BLOCK_END" ]; then in_block=0; fi
|
|
1928
|
-
continue
|
|
1929
|
-
fi
|
|
1930
|
-
printf '%s\n' "$line" >>"$tmp"
|
|
1931
|
-
done <"$file"
|
|
1932
|
-
fi
|
|
1933
|
-
if [ "$wrote_block" = "0" ]; then
|
|
1934
|
-
[ -s "$tmp" ] || : >"$tmp"
|
|
1935
|
-
printf '%s\n' "$ENV_BLOCK_BEGIN" >>"$tmp"
|
|
1936
|
-
local kv
|
|
1937
|
-
for kv in "$@"; do
|
|
1938
|
-
printf 'export %s\n' "$kv" >>"$tmp"
|
|
1939
|
-
done
|
|
1940
|
-
printf '%s\n' "$ENV_BLOCK_END" >>"$tmp"
|
|
1941
|
-
fi
|
|
1942
|
-
# Replace atomically so a crash cannot leave a half-written startup file, which would break every
|
|
1943
|
-
# future shell the user opens.
|
|
1944
|
-
mv -f "$tmp" "$file" 2>/dev/null || { rm -f "$tmp"; fail E_IO "could not replace $file"; }
|
|
1945
|
-
}
|
|
1946
|
-
|
|
1947
|
-
# The NAME=value pairs currently in the managed block.
|
|
1948
|
-
#
|
|
1949
|
-
# awk reads </dev/null deliberately. Without it awk inherits the caller's stdin, which is the
|
|
1950
|
-
# process-substitution pipe feeding the `while read` loop at the call site: awk drains that pipe, so
|
|
1951
|
-
# the loop saw only the FIRST pair and every additional variable was appended on top of it, producing
|
|
1952
|
-
# a line like `export A='1'B='2'` that is not valid shell.
|
|
1953
|
-
env_block_pairs() {
|
|
1954
|
-
local file="$1"
|
|
1955
|
-
[ -f "$file" ] || return 0
|
|
1956
|
-
awk -v b="$ENV_BLOCK_BEGIN" -v e="$ENV_BLOCK_END" '
|
|
1957
|
-
$0 == b { inb = 1; next }
|
|
1958
|
-
$0 == e { inb = 0; next }
|
|
1959
|
-
inb && /^export / { sub(/^export /, ""); print }
|
|
1960
|
-
' "$file" </dev/null
|
|
1961
|
-
}
|
|
1962
|
-
|
|
1963
|
-
env_quote_value() {
|
|
1964
|
-
# Single-quote the value for the startup file so spaces, globs and dollars survive. An embedded
|
|
1965
|
-
# single quote is closed, escaped and reopened, which is the only way to represent one.
|
|
1966
|
-
local v="$1"
|
|
1967
|
-
printf "'%s'" "$(printf '%s' "$v" | sed "s/'/'\\\\''/g")"
|
|
1968
|
-
}
|
|
1969
|
-
|
|
1970
|
-
# Replace one NAME=value pair in a pair list, or append it. Prints the new list, one pair per line.
|
|
1971
|
-
#
|
|
1972
|
-
# Emitted with printf directly rather than by accumulating a string and inserting newlines through
|
|
1973
|
-
# command substitution: `out="$out$(printf '\n')$kv"` does not reliably produce a newline (it produced
|
|
1974
|
-
# none here), and the failure mode is silent - two variables end up joined on one line as
|
|
1975
|
-
# `A='1'B='2'`, which is not valid shell.
|
|
1976
|
-
env_pairs_upsert() {
|
|
1977
|
-
local name="$1" value="$2"; shift 2
|
|
1978
|
-
local found=0 kv
|
|
1979
|
-
for kv in "$@"; do
|
|
1980
|
-
case "$kv" in
|
|
1981
|
-
"$name"=*) printf '%s=%s\n' "$name" "$value"; found=1 ;;
|
|
1982
|
-
*) printf '%s\n' "$kv" ;;
|
|
1983
|
-
esac
|
|
1984
|
-
done
|
|
1985
|
-
if [ "$found" = "0" ]; then printf '%s=%s\n' "$name" "$value"; fi
|
|
1986
|
-
return 0
|
|
1987
|
-
}
|
|
1988
|
-
|
|
1989
|
-
env_pairs_delete() {
|
|
1990
|
-
local name="$1"; shift
|
|
1991
|
-
local kv
|
|
1992
|
-
for kv in "$@"; do
|
|
1993
|
-
case "$kv" in
|
|
1994
|
-
"$name"=*) : ;;
|
|
1995
|
-
*) printf '%s\n' "$kv" ;;
|
|
1996
|
-
esac
|
|
1997
|
-
done
|
|
1998
|
-
return 0
|
|
1999
|
-
}
|
|
2000
|
-
|
|
2001
|
-
env_pairs_get() {
|
|
2002
|
-
local name="$1"; shift
|
|
2003
|
-
local kv
|
|
2004
|
-
for kv in "$@"; do
|
|
2005
|
-
case "$kv" in
|
|
2006
|
-
"$name"=*) env_unquote "${kv#*=}"; return 0 ;;
|
|
2007
|
-
esac
|
|
2008
|
-
done
|
|
2009
|
-
return 1
|
|
2010
|
-
}
|
|
2011
|
-
|
|
2012
|
-
# The block stores values single-quoted so that spaces, globs and dollars survive. A caller reading
|
|
2013
|
-
# `previous` wants the value, not its shell representation, so the surrounding quotes are removed -
|
|
2014
|
-
# and only those: the `A='1'B='2'` corruption this guarded against is now caught upstream.
|
|
2015
|
-
env_unquote() {
|
|
2016
|
-
local v="$1"
|
|
2017
|
-
case "$v" in
|
|
2018
|
-
\'*\') v="${v#\'}"; v="${v%\'}" ;;
|
|
2019
|
-
esac
|
|
2020
|
-
printf '%s' "$v"
|
|
2021
|
-
}
|
|
2022
|
-
|
|
2023
|
-
cmd_environment_add() {
|
|
2024
|
-
require_min 2 'environment add'
|
|
2025
|
-
require_allow 'environment add' "${#POSITIONALS[@]}"
|
|
2026
|
-
local name; name="$(pos 0)"
|
|
2027
|
-
local value; value="$(pos 1)"
|
|
2028
|
-
env_resolve_scope
|
|
2029
|
-
local scope="$ENV_SCOPE"
|
|
2030
|
-
|
|
2031
|
-
if [ "$scope" = "process" ]; then
|
|
2032
|
-
if [ -n "${!name+x}" ]; then
|
|
2033
|
-
fail E_EXISTS "'$name' is already set at process scope" 'use environment set to overwrite it'
|
|
2034
|
-
fi
|
|
2035
|
-
# Assigning through the environment of this shell only affects this process and its children
|
|
2036
|
-
# started within this same invocation. The calling shell is a running process and cannot see it,
|
|
2037
|
-
# which is what the note says.
|
|
2038
|
-
export "$name=$value"
|
|
2039
|
-
printf '{"name":%s,"value":%s,"scope":"process","operation":"add","previous":null,"visibleToRunningProcesses":%s,"note":%s}' \
|
|
2040
|
-
"$(jstr "$name")" "$(jstr "$value")" "$(env_visible_note "$scope")" "$(env_visibility_note_json "$scope")"
|
|
2041
|
-
return
|
|
2042
|
-
fi
|
|
2043
|
-
|
|
2044
|
-
local file; file="$(env_scope_file "$scope")"
|
|
2045
|
-
local pairs=()
|
|
2046
|
-
while IFS= read -r p; do [ -n "$p" ] && pairs+=("$p"); done < <(env_block_pairs "$file")
|
|
2047
|
-
if env_pairs_get "$name" ${pairs[@]+"${pairs[@]}"} >/dev/null 2>&1; then
|
|
2048
|
-
fail E_EXISTS "'$name' is already set at $scope scope" 'use environment set to overwrite it'
|
|
2049
|
-
fi
|
|
2050
|
-
local quoted; quoted="$(env_quote_value "$value")"
|
|
2051
|
-
local newpairs; newpairs="$(env_pairs_upsert "$name" "$quoted" ${pairs[@]+"${pairs[@]}"})"
|
|
2052
|
-
local lines=()
|
|
2053
|
-
while IFS= read -r l; do [ -n "$l" ] && lines+=("$l"); done <<<"$newpairs"
|
|
2054
|
-
env_write_block "$file" ${lines[@]+"${lines[@]}"}
|
|
2055
|
-
printf '{"name":%s,"value":%s,"scope":%s,"operation":"add","previous":null,"file":%s,"visibleToRunningProcesses":%s,"note":"in effect for new shells; open a new shell or source the file"}' \
|
|
2056
|
-
"$(jstr "$name")" "$(jstr "$value")" "$(jstr "$scope")" "$(jstr "$file")" "$(env_visible_note "$scope")"
|
|
2057
|
-
}
|
|
2058
|
-
|
|
2059
|
-
cmd_environment_set() {
|
|
2060
|
-
require_min 2 'environment set'
|
|
2061
|
-
require_allow 'environment set' "${#POSITIONALS[@]}"
|
|
2062
|
-
local name; name="$(pos 0)"
|
|
2063
|
-
local value; value="$(pos 1)"
|
|
2064
|
-
env_resolve_scope
|
|
2065
|
-
local scope="$ENV_SCOPE"
|
|
2066
|
-
|
|
2067
|
-
if [ "$scope" = "process" ]; then
|
|
2068
|
-
if [ -z "${!name+x}" ]; then
|
|
2069
|
-
fail E_NOT_FOUND "'$name' is not set at process scope" 'use environment add to create it'
|
|
2070
|
-
fi
|
|
2071
|
-
local previous="${!name}"
|
|
2072
|
-
export "$name=$value"
|
|
2073
|
-
printf '{"name":%s,"value":%s,"scope":"process","operation":"set","previous":%s,"visibleToRunningProcesses":%s,"note":%s}' \
|
|
2074
|
-
"$(jstr "$name")" "$(jstr "$value")" "$(jstr "$previous")" "$(env_visible_note "$scope")" "$(env_visibility_note_json "$scope")"
|
|
2075
|
-
return
|
|
2076
|
-
fi
|
|
2077
|
-
|
|
2078
|
-
local file; file="$(env_scope_file "$scope")"
|
|
2079
|
-
local pairs=()
|
|
2080
|
-
while IFS= read -r p; do [ -n "$p" ] && pairs+=("$p"); done < <(env_block_pairs "$file")
|
|
2081
|
-
local previous=""
|
|
2082
|
-
if previous="$(env_pairs_get "$name" ${pairs[@]+"${pairs[@]}"})"; then :; else
|
|
2083
|
-
fail E_NOT_FOUND "'$name' is not set at $scope scope" 'use environment add to create it'
|
|
2084
|
-
fi
|
|
2085
|
-
local quoted; quoted="$(env_quote_value "$value")"
|
|
2086
|
-
local newpairs; newpairs="$(env_pairs_upsert "$name" "$quoted" ${pairs[@]+"${pairs[@]}"})"
|
|
2087
|
-
local lines=()
|
|
2088
|
-
while IFS= read -r l; do [ -n "$l" ] && lines+=("$l"); done <<<"$newpairs"
|
|
2089
|
-
env_write_block "$file" ${lines[@]+"${lines[@]}"}
|
|
2090
|
-
printf '{"name":%s,"value":%s,"scope":%s,"operation":"set","previous":%s,"file":%s,"visibleToRunningProcesses":%s}' \
|
|
2091
|
-
"$(jstr "$name")" "$(jstr "$value")" "$(jstr "$scope")" "$(jstr "$previous")" "$(jstr "$file")" "$(env_visible_note "$scope")"
|
|
2092
|
-
}
|
|
2093
|
-
|
|
2094
|
-
cmd_environment_delete() {
|
|
2095
|
-
require_min 1 'environment delete'
|
|
2096
|
-
require_allow 'environment delete' "${#POSITIONALS[@]}"
|
|
2097
|
-
local name; name="$(pos 0)"
|
|
2098
|
-
env_resolve_scope
|
|
2099
|
-
local scope="$ENV_SCOPE"
|
|
2100
|
-
|
|
2101
|
-
if [ "$scope" = "process" ]; then
|
|
2102
|
-
if [ -z "${!name+x}" ]; then
|
|
2103
|
-
fail E_NOT_FOUND "'$name' is not set at process scope"
|
|
2104
|
-
fi
|
|
2105
|
-
local previous="${!name}"
|
|
2106
|
-
unset "$name"
|
|
2107
|
-
printf '{"name":%s,"scope":"process","operation":"delete","previous":%s,"visibleToRunningProcesses":%s,"note":%s}' \
|
|
2108
|
-
"$(jstr "$name")" "$(jstr "$previous")" "$(env_visible_note "$scope")" "$(env_visibility_note_json "$scope")"
|
|
2109
|
-
return
|
|
2110
|
-
fi
|
|
2111
|
-
|
|
2112
|
-
local file; file="$(env_scope_file "$scope")"
|
|
2113
|
-
if [ ! -f "$file" ]; then
|
|
2114
|
-
fail E_NOT_FOUND "'$name' is not set at $scope scope ($file does not exist)"
|
|
2115
|
-
fi
|
|
2116
|
-
local pairs=()
|
|
2117
|
-
while IFS= read -r p; do [ -n "$p" ] && pairs+=("$p"); done < <(env_block_pairs "$file")
|
|
2118
|
-
local previous=""
|
|
2119
|
-
if previous="$(env_pairs_get "$name" ${pairs[@]+"${pairs[@]}"})"; then :; else
|
|
2120
|
-
fail E_NOT_FOUND "'$name' is not set at $scope scope"
|
|
2121
|
-
fi
|
|
2122
|
-
local newpairs; newpairs="$(env_pairs_delete "$name" ${pairs[@]+"${pairs[@]}"})"
|
|
2123
|
-
local lines=()
|
|
2124
|
-
while IFS= read -r l; do [ -n "$l" ] && lines+=("$l"); done <<<"$newpairs"
|
|
2125
|
-
env_write_block "$file" ${lines[@]+"${lines[@]}"}
|
|
2126
|
-
printf '{"name":%s,"scope":%s,"operation":"delete","previous":%s,"file":%s,"visibleToRunningProcesses":%s}' \
|
|
2127
|
-
"$(jstr "$name")" "$(jstr "$scope")" "$(jstr "$previous")" "$(jstr "$file")" "$(env_visible_note "$scope")"
|
|
2128
|
-
}
|
|
2129
|
-
|
|
2130
|
-
# ---------------------------------------------------------------------------
|
|
2131
|
-
# environment attribute add / set / delete
|
|
2132
|
-
#
|
|
2133
|
-
# An attribute is a list variable: PATH and friends, joined by the platform separator. add appends,
|
|
2134
|
-
# set replaces one entry by index, delete removes one entry by --index or by --value. They mirror the
|
|
2135
|
-
# Windows side field for field, so an agent parses the same result whichever platform answered.
|
|
2136
|
-
#
|
|
2137
|
-
# PROCESS SCOPE ONLY, deliberately. list and get already refuse every other scope here, and a list
|
|
2138
|
-
# mutation is a worse candidate for persistence than a plain variable: editing PATH in a startup file
|
|
2139
|
-
# can make a machine's shells unusable if the reconstruction is wrong, and the mutation would be
|
|
2140
|
-
# invisible to the caller anyway because this verb runs as its own process. Refusing is honest;
|
|
2141
|
-
# half-persisting a list would not be.
|
|
2142
|
-
# ---------------------------------------------------------------------------
|
|
2143
|
-
|
|
2144
|
-
# Split the attribute's current value into ATTR_ENTRIES. Assigns rather than printing, because a verb
|
|
2145
|
-
# must be able to call it in its own shell: in `entries=$(...)` a fail inside would exit only the
|
|
2146
|
-
# subshell and the verb would carry on with empty data.
|
|
2147
|
-
ATTR_ENTRIES=()
|
|
2148
|
-
env_split_entries() {
|
|
2149
|
-
local name="$1"
|
|
2150
|
-
ATTR_ENTRIES=()
|
|
2151
|
-
if [ -z "${!name+x}" ]; then return 0; fi
|
|
2152
|
-
local sep; sep="$(env_separator)"
|
|
2153
|
-
local oldifs="$IFS"
|
|
2154
|
-
IFS="$sep"
|
|
2155
|
-
local e
|
|
2156
|
-
for e in ${!name}; do
|
|
2157
|
-
[ -n "$e" ] && ATTR_ENTRIES+=("$e")
|
|
2158
|
-
done
|
|
2159
|
-
IFS="$oldifs"
|
|
2160
|
-
return 0
|
|
2161
|
-
}
|
|
2162
|
-
|
|
2163
|
-
# Rejoin entries and export. Prints the resulting count in ENV_ATTR_COUNT for the envelope.
|
|
2164
|
-
env_join_entries() {
|
|
2165
|
-
local name="$1"
|
|
2166
|
-
local sep; sep="$(env_separator)"
|
|
2167
|
-
local joined="" i=0
|
|
2168
|
-
for e in ${ATTR_ENTRIES[@]+"${ATTR_ENTRIES[@]}"}; do
|
|
2169
|
-
if [ "$i" -eq 0 ]; then joined="$e"; else joined="$joined$sep$e"; fi
|
|
2170
|
-
i=$((i + 1))
|
|
2171
|
-
done
|
|
2172
|
-
ENV_ATTR_COUNT="$i"
|
|
2173
|
-
if [ "$i" -eq 0 ]; then
|
|
2174
|
-
# An empty list means the variable goes away rather than becoming an empty string, so that
|
|
2175
|
-
# `attribute list` stops reporting it, matching the Windows behaviour.
|
|
2176
|
-
unset "$name"
|
|
2177
|
-
else
|
|
2178
|
-
export "$name=$joined"
|
|
2179
|
-
fi
|
|
2180
|
-
}
|
|
2181
|
-
|
|
2182
|
-
# Shared guard: attribute mutations are process scope only.
|
|
2183
|
-
env_attr_require_process_scope() {
|
|
2184
|
-
local scope; scope="$(flag_value scope 'user')"
|
|
2185
|
-
if [ "$scope" != "process" ]; then
|
|
2186
|
-
fail E_NOT_SUPPORTED "scope '$scope' is not available on this platform" \
|
|
2187
|
-
'attribute mutations support --scope process only'
|
|
2188
|
-
fi
|
|
2189
|
-
}
|
|
2190
|
-
|
|
2191
|
-
cmd_environment_attribute_add() {
|
|
2192
|
-
require_min 2 'environment attribute add'
|
|
2193
|
-
require_allow 'environment attribute add' "${#POSITIONALS[@]}"
|
|
2194
|
-
local name; name="$(pos 0)"
|
|
2195
|
-
local value; value="$(pos 1)"
|
|
2196
|
-
env_attr_require_process_scope
|
|
2197
|
-
local attr; attr="$(flag_value attr 'PATH')"
|
|
2198
|
-
if [ -z "${!attr+x}" ]; then
|
|
2199
|
-
fail E_NOT_FOUND "attribute '$attr' is not set at process scope"
|
|
2200
|
-
fi
|
|
2201
|
-
env_split_entries "$attr"
|
|
2202
|
-
local e
|
|
2203
|
-
for e in ${ATTR_ENTRIES[@]+"${ATTR_ENTRIES[@]}"}; do
|
|
2204
|
-
# Ordinal comparison: an entry differing from the named value only by a non-printable control
|
|
2205
|
-
# character must not be treated as the same entry. LC_ALL=C makes [ = ] a byte comparison.
|
|
2206
|
-
if LC_ALL=C [ "$e" = "$value" ]; then
|
|
2207
|
-
fail E_EXISTS "'$attr' already contains '$value'"
|
|
2208
|
-
fi
|
|
2209
|
-
done
|
|
2210
|
-
ATTR_ENTRIES+=("$value")
|
|
2211
|
-
env_join_entries "$attr"
|
|
2212
|
-
printf '{"name":%s,"added":%s,"scope":"process","count":%s,"operation":"attr-add","requestedName":%s,"visibleToRunningProcesses":%s,"note":%s}' \
|
|
2213
|
-
"$(jstr "$attr")" "$(jstr "$value")" "$ENV_ATTR_COUNT" "$(jstr "$name")" \
|
|
2214
|
-
"$(env_visible_note process)" "$(env_visibility_note_json process)"
|
|
2215
|
-
}
|
|
2216
|
-
|
|
2217
|
-
cmd_environment_attribute_set() {
|
|
2218
|
-
require_min 3 'environment attribute set'
|
|
2219
|
-
require_allow 'environment attribute set' "${#POSITIONALS[@]}"
|
|
2220
|
-
local name; name="$(pos 0)"
|
|
2221
|
-
local index; index="$(pos 1)"
|
|
2222
|
-
case "$index" in ''|*[!0-9]*) fail E_TYPE "<index> must be an integer, got '$index'" ;; esac
|
|
2223
|
-
local value; value="$(pos 2)"
|
|
2224
|
-
env_attr_require_process_scope
|
|
2225
|
-
local attr; attr="$(flag_value attr 'PATH')"
|
|
2226
|
-
if [ -z "${!attr+x}" ]; then
|
|
2227
|
-
fail E_NOT_FOUND "attribute '$attr' is not set at process scope"
|
|
2228
|
-
fi
|
|
2229
|
-
env_split_entries "$attr"
|
|
2230
|
-
local n="${#ATTR_ENTRIES[@]}"
|
|
2231
|
-
if [ "$index" -ge "$n" ]; then
|
|
2232
|
-
local hi=0; [ "$n" -gt 0 ] && hi=$((n - 1))
|
|
2233
|
-
fail E_RANGE "index $index is out of range for '$attr' (0..$hi)"
|
|
2234
|
-
fi
|
|
2235
|
-
local previous="${ATTR_ENTRIES[$index]}"
|
|
2236
|
-
ATTR_ENTRIES[$index]="$value"
|
|
2237
|
-
env_join_entries "$attr"
|
|
2238
|
-
printf '{"name":%s,"index":%s,"previous":%s,"value":%s,"scope":"process","count":%s,"operation":"attr-set","requestedName":%s,"visibleToRunningProcesses":%s,"note":%s}' \
|
|
2239
|
-
"$(jstr "$attr")" "$index" "$(jstr "$previous")" "$(jstr "$value")" "$ENV_ATTR_COUNT" "$(jstr "$name")" \
|
|
2240
|
-
"$(env_visible_note process)" "$(env_visibility_note_json process)"
|
|
2241
|
-
}
|
|
2242
|
-
|
|
2243
|
-
cmd_environment_attribute_delete() {
|
|
2244
|
-
require_min 1 'environment attribute delete'
|
|
2245
|
-
require_allow 'environment attribute delete' "${#POSITIONALS[@]}"
|
|
2246
|
-
local name; name="$(pos 0)"
|
|
2247
|
-
env_attr_require_process_scope
|
|
2248
|
-
local attr; attr="$(flag_value attr 'PATH')"
|
|
2249
|
-
local index_text; index_text="$(flag_value index '')"
|
|
2250
|
-
local value_text; value_text="$(flag_value value '')"
|
|
2251
|
-
if [ -z "$index_text" ] && [ -z "$value_text" ]; then
|
|
2252
|
-
fail E_USAGE 'environment attr delete needs --index or --value to say which entry to remove'
|
|
2253
|
-
fi
|
|
2254
|
-
if [ -z "${!attr+x}" ]; then
|
|
2255
|
-
fail E_NOT_FOUND "attribute '$attr' is not set at process scope"
|
|
2256
|
-
fi
|
|
2257
|
-
env_split_entries "$attr"
|
|
2258
|
-
local n="${#ATTR_ENTRIES[@]}"
|
|
2259
|
-
local removed="" kept=() i=0
|
|
2260
|
-
if [ -n "$index_text" ]; then
|
|
2261
|
-
local index; index="$index_text"
|
|
2262
|
-
case "$index" in ''|*[!0-9]*) fail E_TYPE "<index> must be an integer, got '$index'" ;; esac
|
|
2263
|
-
if [ "$index" -ge "$n" ]; then
|
|
2264
|
-
local hi=0; [ "$n" -gt 0 ] && hi=$((n - 1))
|
|
2265
|
-
fail E_RANGE "index $index is out of range for '$attr' (0..$hi)"
|
|
2266
|
-
fi
|
|
2267
|
-
removed="${ATTR_ENTRIES[$index]}"
|
|
2268
|
-
for e in ${ATTR_ENTRIES[@]+"${ATTR_ENTRIES[@]}"}; do
|
|
2269
|
-
[ "$i" -eq "$index" ] || kept+=("$e")
|
|
2270
|
-
i=$((i + 1))
|
|
2271
|
-
done
|
|
2272
|
-
else
|
|
2273
|
-
# Only the FIRST match is removed, and the comparison is ordinal for the same reason as add.
|
|
2274
|
-
local done_removing=0
|
|
2275
|
-
for e in ${ATTR_ENTRIES[@]+"${ATTR_ENTRIES[@]}"}; do
|
|
2276
|
-
if [ "$done_removing" -eq 0 ] && LC_ALL=C [ "$e" = "$value_text" ]; then
|
|
2277
|
-
removed="$e"; done_removing=1; continue
|
|
2278
|
-
fi
|
|
2279
|
-
kept+=("$e")
|
|
2280
|
-
done
|
|
2281
|
-
if [ -z "$removed" ]; then
|
|
2282
|
-
fail E_NOT_FOUND "'$attr' does not contain '$value_text'"
|
|
2283
|
-
fi
|
|
2284
|
-
fi
|
|
2285
|
-
ATTR_ENTRIES=(${kept[@]+"${kept[@]}"})
|
|
2286
|
-
env_join_entries "$attr"
|
|
2287
|
-
printf '{"name":%s,"removed":%s,"scope":"process","count":%s,"operation":"attr-delete","requestedName":%s,"visibleToRunningProcesses":%s,"note":%s}' \
|
|
2288
|
-
"$(jstr "$attr")" "$(jstr "$removed")" "$ENV_ATTR_COUNT" "$(jstr "$name")" \
|
|
2289
|
-
"$(env_visible_note process)" "$(env_visibility_note_json process)"
|
|
2290
|
-
}
|
|
2291
|
-
|
|
2292
|
-
# ===== service.sh ==================================================
|
|
2293
|
-
|
|
2294
|
-
# Module: service.sh - service.* (systemd, launchd, SysV init).
|
|
2295
|
-
#
|
|
2296
|
-
# Intentionally empty. Service management is per-init-system on POSIX (systemctl, launchctl, service),
|
|
2297
|
-
# and start/stop/restart need privilege. The verb table already declares these verbs and dispatch
|
|
2298
|
-
# reports E_NOT_SUPPORTED for them until an implementation lands.
|
|
2299
|
-
|
|
2300
|
-
# ===== permission.sh ===============================================
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
# ---------------------------------------------------------------------------
|
|
2304
|
-
# permission.* - read-only views of the authorization file, the two verbs that
|
|
2305
|
-
# must be refused at the verb layer, and idempotent layer-A setup.
|
|
2306
|
-
# ---------------------------------------------------------------------------
|
|
2307
|
-
permission_file() { printf '%s' "$ROOT_DIR/permission.json"; }
|
|
2308
|
-
|
|
2309
|
-
# Layer A on POSIX means this process cannot write the authorization file. There is no portable
|
|
2310
|
-
# equivalent of a Windows deny ACE, so the check is the file mode: if the file is not writable by us,
|
|
2311
|
-
# something outside this process is preventing a rewrite.
|
|
2312
|
-
#
|
|
2313
|
-
# Measured, never assumed. A Windows version of this once reported a stored flag instead, which could
|
|
2314
|
-
# claim protection the kernel was not applying.
|
|
2315
|
-
acl_enforced() {
|
|
2316
|
-
local file; file="$(permission_file)"
|
|
2317
|
-
if [ ! -f "$file" ]; then printf 'false'; return; fi
|
|
2318
|
-
if [ -w "$file" ]; then printf 'false'; else printf 'true'; fi
|
|
2319
|
-
}
|
|
2320
|
-
|
|
2321
|
-
acl_reason() {
|
|
2322
|
-
local file; file="$(permission_file)"
|
|
2323
|
-
if [ ! -f "$file" ]; then
|
|
2324
|
-
printf '%s' "$(jstr 'permission.json does not exist')"
|
|
2325
|
-
elif [ -w "$file" ]; then
|
|
2326
|
-
printf '%s' "$(jstr 'the authorization file is writable by this process, so no kernel-level protection is in effect; run: dsh-os.sh permission acl init')"
|
|
2327
|
-
else
|
|
2328
|
-
printf '%s' "$(jstr 'the authorization file is not writable by this process')"
|
|
2329
|
-
fi
|
|
2330
|
-
}
|
|
2331
|
-
|
|
2332
|
-
# What is knowable from the bytes without a JSON parser: whether the file has content, and whether the
|
|
2333
|
-
# documented writer field is present. Reporting that honestly beats reporting a rule count that might
|
|
2334
|
-
# be wrong.
|
|
2335
|
-
permission_trust_state() {
|
|
2336
|
-
local file; file="$(permission_file)"
|
|
2337
|
-
if [ ! -f "$file" ]; then printf 'missing'; return; fi
|
|
2338
|
-
local size; size="$(wc -c <"$file" | tr -d ' ')"
|
|
2339
|
-
if [ "$size" -eq 0 ]; then printf 'empty'; return; fi
|
|
2340
|
-
if grep -q '"updatedBy"[[:space:]]*:[[:space:]]*"consent-delegate"' "$file" 2>/dev/null; then
|
|
2341
|
-
printf 'trusted'
|
|
2342
|
-
else
|
|
2343
|
-
printf 'untrusted'
|
|
2344
|
-
fi
|
|
2345
|
-
}
|
|
2346
|
-
|
|
2347
|
-
# trusted is false when the writer field is absent or says something else, because the delegate is
|
|
2348
|
-
# supposed to be the only writer: honouring rules somebody else wrote would defeat the arrangement.
|
|
2349
|
-
permission_trusted_json() {
|
|
2350
|
-
case "$(permission_trust_state)" in
|
|
2351
|
-
untrusted) printf 'false' ;;
|
|
2352
|
-
*) printf 'true' ;;
|
|
2353
|
-
esac
|
|
2354
|
-
}
|
|
2355
|
-
|
|
2356
|
-
cmd_permission_list() {
|
|
2357
|
-
local file; file="$(permission_file)"
|
|
2358
|
-
local enforced; enforced="$(acl_enforced)"
|
|
2359
|
-
if [ ! -f "$file" ]; then
|
|
2360
|
-
printf '{"path":%s,"exists":false,"empty":false,"trusted":true,"count":0,"rules":[],"aclEnforced":%s,"aclReason":%s}' \
|
|
2361
|
-
"$(jstr "$file")" "$enforced" "$(acl_reason)"
|
|
2362
|
-
return
|
|
2363
|
-
fi
|
|
2364
|
-
local size; size="$(wc -c <"$file" | tr -d ' ')"
|
|
2365
|
-
if [ "$size" -eq 0 ]; then
|
|
2366
|
-
printf '{"path":%s,"exists":true,"empty":true,"trusted":true,"count":0,"rules":[],"aclEnforced":%s,"aclReason":%s}' \
|
|
2367
|
-
"$(jstr "$file")" "$enforced" "$(acl_reason)"
|
|
2368
|
-
else
|
|
2369
|
-
printf '{"path":%s,"exists":true,"empty":false,"trusted":%s,"fileState":%s,"count":null,"rules":null,"aclEnforced":%s,"aclReason":%s,"note":"rule parsing requires the harness plugin; the consent delegate owns writes"}' \
|
|
2370
|
-
"$(jstr "$file")" "$(permission_trusted_json)" "$(jstr "$(permission_trust_state)")" "$enforced" "$(acl_reason)"
|
|
2371
|
-
fi
|
|
2372
|
-
}
|
|
2373
|
-
|
|
2374
|
-
cmd_permission_query() {
|
|
2375
|
-
require_min 1 'permission query'
|
|
2376
|
-
local verb; verb="$(pos 0)"
|
|
2377
|
-
local target; target="$(pos 1)"
|
|
2378
|
-
local file; file="$(permission_file)"
|
|
2379
|
-
# The stored rules cannot be evaluated here without a parser, so the answer states its own basis
|
|
2380
|
-
# rather than implying a rule was consulted. An untrusted file always answers from defaults.
|
|
2381
|
-
local source="defaults"
|
|
2382
|
-
local decision="ask"
|
|
2383
|
-
printf '{"verb":%s,"target":%s,"decision":%s,"ruleId":null,"source":%s,"trusted":%s,"rulesEvaluated":false,"fileExists":%s,"aclEnforced":%s,"aclReason":%s,"note":"rule evaluation requires the harness plugin; this answer is from defaults"}' \
|
|
2384
|
-
"$(jstr "$verb")" "$(jstr "$target")" "$(jstr "$decision")" "$(jstr "$source")" \
|
|
2385
|
-
"$(permission_trusted_json)" \
|
|
2386
|
-
"$( [ -f "$file" ] && printf 'true' || printf 'false' )" \
|
|
2387
|
-
"$(acl_enforced)" "$(acl_reason)"
|
|
2388
|
-
}
|
|
2389
|
-
|
|
2390
|
-
cmd_permission_audit() {
|
|
2391
|
-
local file; file="$(permission_file)"
|
|
2392
|
-
if [ ! -f "$file" ]; then
|
|
2393
|
-
printf '{"path":%s,"count":0,"entries":[],"trusted":true}' "$(jstr "$file")"
|
|
2394
|
-
return
|
|
2395
|
-
fi
|
|
2396
|
-
# An empty list would be a lie and a parsed list is impossible here, so the count is null and the
|
|
2397
|
-
# reason is named.
|
|
2398
|
-
printf '{"path":%s,"count":null,"entries":null,"trusted":%s,"note":"audit parsing requires the harness plugin"}' \
|
|
2399
|
-
"$(jstr "$file")" "$(permission_trusted_json)"
|
|
2400
|
-
}
|
|
2401
|
-
|
|
2402
|
-
# Both write verbs are delegate-only, and the enforcement is that there is no implementation here at
|
|
2403
|
-
# all: creating a durable grant needs the user approval dialog, which only the plugin can reach. A
|
|
2404
|
-
# permission check would be something to argue with; an absent implementation is not.
|
|
2405
|
-
cmd_permission_grant() {
|
|
2406
|
-
require_min 3 'permission grant'
|
|
2407
|
-
require_allow 'permission grant' "${#POSITIONALS[@]}"
|
|
2408
|
-
fail E_PERMISSION_DELEGATE_ONLY 'permission grant can only be performed by the consent delegate' \
|
|
2409
|
-
'this verb needs the user approval dialog, which the delegate plugin owns; a script cannot reach it'
|
|
2410
|
-
}
|
|
2411
|
-
|
|
2412
|
-
cmd_permission_revoke() {
|
|
2413
|
-
require_min 1 'permission revoke'
|
|
2414
|
-
require_allow 'permission revoke' "${#POSITIONALS[@]}"
|
|
2415
|
-
fail E_PERMISSION_DELEGATE_ONLY 'permission revoke can only be performed by the consent delegate' \
|
|
2416
|
-
'revoking a durable decision also needs the user approval dialog'
|
|
2417
|
-
}
|
|
2418
|
-
|
|
2419
|
-
# permission acl init - idempotently make the authorization file non-writable.
|
|
2420
|
-
#
|
|
2421
|
-
# On POSIX this is a mode change, not an ACL rewrite, and the equivalence is weaker than the Windows
|
|
2422
|
-
# deny ACE: the same user can chmod it back. That limitation is stated in the result rather than
|
|
2423
|
-
# glossed over, because the reported strength is supposed to mean something.
|
|
2424
|
-
cmd_permission_acl_init() {
|
|
2425
|
-
require_allow 'permission acl init' "${#POSITIONALS[@]}"
|
|
2426
|
-
local file; file="$(permission_file)"
|
|
2427
|
-
if [ ! -f "$file" ]; then
|
|
2428
|
-
fail E_NOT_FOUND "permission.json does not exist at $file" \
|
|
2429
|
-
'create the file first; the repository ships an empty one as the documented initial state'
|
|
2430
|
-
fi
|
|
2431
|
-
if [ "$(acl_enforced)" = "true" ]; then
|
|
2432
|
-
printf '{"path":%s,"changed":false,"enforced":true,"reason":%s}' "$(jstr "$file")" "$(acl_reason)"
|
|
2433
|
-
return
|
|
2434
|
-
fi
|
|
2435
|
-
local mode_before mode_after
|
|
2436
|
-
mode_before="$(stat -c '%a' "$file" 2>/dev/null || printf '')"
|
|
2437
|
-
if ! chmod a-w "$file" 2>/dev/null; then
|
|
2438
|
-
fail E_IO 'the authorization file mode could not be changed (insufficient privilege?)'
|
|
2439
|
-
fi
|
|
2440
|
-
mode_after="$(stat -c '%a' "$file" 2>/dev/null || printf '')"
|
|
2441
|
-
printf '{"path":%s,"changed":true,"enforced":%s,"modeBefore":%s,"modeAfter":%s,"reason":%s,"limitation":"a POSIX mode change is weaker than a Windows deny ACE: the same user can restore write access","restore":"chmod u+w permission.json"}' \
|
|
2442
|
-
"$(jstr "$file")" "$(acl_enforced)" "$(jstr "$mode_before")" "$(jstr "$mode_after")" "$(acl_reason)"
|
|
2443
|
-
}
|
|
2444
|
-
|
|
2445
|
-
# ---------------------------------------------------------------------------
|
|
2446
|
-
# selfcheck
|
|
2447
|
-
# ---------------------------------------------------------------------------
|
|
2448
|
-
cmd_selfcheck() {
|
|
2449
|
-
local verbs=0
|
|
2450
|
-
local file="$HOST_DIR/spec/verbs.tsv"
|
|
2451
|
-
if [ -f "$file" ]; then
|
|
2452
|
-
verbs="$(awk -F' ' '!/^#/ && NF >= 4 && $1 != "verb" { n++ } END { print n + 0 }' "$file")"
|
|
2453
|
-
fi
|
|
2454
|
-
# The implemented list is DERIVED from this script's own dispatch table, never written by hand. It
|
|
2455
|
-
# used to be a hardcoded string that had already drifted from the truth: it named nine groups while
|
|
2456
|
-
# the dispatch table answered thirty-six verbs, so a caller reading selfcheck would have concluded
|
|
2457
|
-
# that most of the working surface did not exist.
|
|
2458
|
-
local impl="" count=0 v=""
|
|
2459
|
-
while IFS= read -r v; do
|
|
2460
|
-
[ -z "$v" ] && continue
|
|
2461
|
-
[ -n "$impl" ] && impl="$impl,"
|
|
2462
|
-
impl="$impl$(jstr "$v")"
|
|
2463
|
-
count=$((count + 1))
|
|
2464
|
-
done <<EOF
|
|
2465
|
-
$(sed -n "s/^ '\([a-z0-9.-]*\)') dispatch .*/\1/p" "$SELF_PATH")
|
|
2466
|
-
EOF
|
|
2467
|
-
printf '{"script":"src/host/bin/dsh-os.sh","platform":%s,"locale":%s,"verbCount":%s,"implementedCount":%s,"implemented":[%s],"capabilities":{"inputInjection":%s,"clipboard":%s,"screenshot":%s,"accessibility":%s,"trash":%s},"aclEnforced":%s,"aclReason":%s}' \
|
|
2468
|
-
"$(jstr "$(platform_name)")" "$(jstr "${LC_ALL:-C}")" "$verbs" "$count" "$impl" \
|
|
2469
|
-
"$(capability_input_injection)" "$(capability_clipboard)" "$(capability_screenshot)" \
|
|
2470
|
-
"$(capability_accessibility)" \
|
|
2471
|
-
"$(command -v trash-put >/dev/null 2>&1 && echo '"available"' || echo '"unavailable"')" \
|
|
2472
|
-
"$(acl_enforced)" "$(acl_reason)"
|
|
2473
|
-
}
|
|
2474
|
-
|
|
2475
|
-
# ---------------------------------------------------------------------------
|
|
2476
|
-
# Capability reporting.
|
|
2477
|
-
#
|
|
2478
|
-
# Each of these is MEASURED, not a constant. They were four hardcoded "unavailable" strings, which was
|
|
2479
|
-
# accurate for input injection and screen capture on a machine with no display server but would have
|
|
2480
|
-
# stayed "unavailable" on a fully equipped desktop - a caller reading selfcheck would have concluded a
|
|
2481
|
-
# working capability did not exist. This is the same failure the implemented list above already had and
|
|
2482
|
-
# was fixed for, so the same rule applies: report what is measured.
|
|
2483
|
-
#
|
|
2484
|
-
# The strings are the tool names the verbs actually use, so a caller can act on the answer, and
|
|
2485
|
-
# "unavailable" is reported only when nothing usable is present.
|
|
2486
|
-
# ---------------------------------------------------------------------------
|
|
2487
|
-
capability_input_injection() {
|
|
2488
|
-
if [ -z "${DISPLAY:-}" ]; then printf '"unavailable"'; return 0; fi
|
|
2489
|
-
if command -v xdotool >/dev/null 2>&1; then printf '"xdotool"'; return 0; fi
|
|
2490
|
-
printf '"unavailable"'
|
|
2491
|
-
}
|
|
2492
|
-
|
|
2493
|
-
capability_clipboard() {
|
|
2494
|
-
if command -v xclip >/dev/null 2>&1; then printf '"xclip"'; return 0; fi
|
|
2495
|
-
if command -v xsel >/dev/null 2>&1; then printf '"xsel"'; return 0; fi
|
|
2496
|
-
# wl-copy needs a Wayland session, not merely the binary.
|
|
2497
|
-
if [ -n "${WAYLAND_DISPLAY:-}" ] && command -v wl-copy >/dev/null 2>&1; then printf '"wl-copy"'; return 0; fi
|
|
2498
|
-
printf '"unavailable"'
|
|
2499
|
-
}
|
|
2500
|
-
|
|
2501
|
-
capability_screenshot() {
|
|
2502
|
-
if [ -z "${DISPLAY:-}" ]; then printf '"unavailable"'; return 0; fi
|
|
2503
|
-
if command -v import >/dev/null 2>&1; then printf '"import"'; return 0; fi
|
|
2504
|
-
if command -v scrot >/dev/null 2>&1; then printf '"scrot"'; return 0; fi
|
|
2505
|
-
printf '"unavailable"'
|
|
2506
|
-
}
|
|
2507
|
-
|
|
2508
|
-
capability_accessibility() {
|
|
2509
|
-
# The AT-SPI bus is what an accessibility client talks to on Linux. Its presence in the session bus
|
|
2510
|
-
# is the evidence; a missing dbus tool means it cannot be determined, which is reported as such
|
|
2511
|
-
# rather than guessed at.
|
|
2512
|
-
if command -v dbus-send >/dev/null 2>&1; then
|
|
2513
|
-
if dbus-send --session --dest=org.a11y.Bus --print-reply /org/a11y/bus org.freedesktop.DBus.Peer.Ping >/dev/null 2>&1; then
|
|
2514
|
-
printf '"at-spi2"'; return 0
|
|
2515
|
-
fi
|
|
2516
|
-
printf '"unavailable"'; return 0
|
|
2517
|
-
fi
|
|
2518
|
-
printf '"unknown"'
|
|
2519
|
-
}
|
|
2520
|
-
|
|
2521
|
-
# ===== window.sh ===================================================
|
|
2522
|
-
|
|
2523
|
-
# Module: window.sh - window.* / mouse.* / keyboard.* / clipboard.* / wait.* / observe.
|
|
2524
|
-
#
|
|
2525
|
-
# The desktop verbs are implemented on POSIX by driving the standard X11 command-line tools, because
|
|
2526
|
-
# there is no library to link against: the shell side deliberately declares no native dependencies
|
|
2527
|
-
# (native.sh is empty), so synthetic input means XTest through xdotool, window management through
|
|
2528
|
-
# wmctrl or xdotool, the clipboard through xclip/xsel/wl-copy, and screen capture through import/scrot.
|
|
2529
|
-
#
|
|
2530
|
-
# Every one of them first checks that a display server exists and that the tool it needs is installed,
|
|
2531
|
-
# and returns a TYPED error when either is missing - E_NO_DISPLAY or E_NOT_SUPPORTED naming the missing
|
|
2532
|
-
# tool. That is the same contract task show already follows, and it is the important half: a caller that
|
|
2533
|
-
# believes a click was delivered will wait for a result that never appears, so an unavailable capability
|
|
2534
|
-
# must be an explicit error and never an empty success.
|
|
2535
|
-
#
|
|
2536
|
-
# On a Wayland session only some of this works, and only through the compositor's own protocols; those
|
|
2537
|
-
# paths are not implemented, so the tools are simply absent and the verbs report that honestly.
|
|
2538
|
-
#
|
|
2539
|
-
# wait ms needs none of this and is implemented below without any capability check.
|
|
2540
|
-
|
|
2541
|
-
# ---------------------------------------------------------------------------
|
|
2542
|
-
# Capability probes.
|
|
2543
|
-
#
|
|
2544
|
-
# HAVE_* are set once by window_probe. They are plain globals rather than a function per check, because
|
|
2545
|
-
# each verb needs several of them and re-probing per call would fork a command -v for every test.
|
|
2546
|
-
# ---------------------------------------------------------------------------
|
|
2547
|
-
HAVE_XDOTOOL=0
|
|
2548
|
-
HAVE_WMCTRL=0
|
|
2549
|
-
HAVE_XCLIP=0
|
|
2550
|
-
HAVE_XSEL=0
|
|
2551
|
-
HAVE_WLCOPY=0
|
|
2552
|
-
HAVE_IMPORT=0
|
|
2553
|
-
HAVE_SCROT=0
|
|
2554
|
-
HAVE_XRANDR=0
|
|
2555
|
-
WINDOW_PROBED=0
|
|
2556
|
-
|
|
2557
|
-
window_probe() {
|
|
2558
|
-
[ "$WINDOW_PROBED" = "1" ] && return 0
|
|
2559
|
-
command -v xdotool >/dev/null 2>&1 && HAVE_XDOTOOL=1
|
|
2560
|
-
command -v wmctrl >/dev/null 2>&1 && HAVE_WMCTRL=1
|
|
2561
|
-
command -v xclip >/dev/null 2>&1 && HAVE_XCLIP=1
|
|
2562
|
-
command -v xsel >/dev/null 2>&1 && HAVE_XSEL=1
|
|
2563
|
-
command -v wl-copy >/dev/null 2>&1 && HAVE_WLCOPY=1
|
|
2564
|
-
command -v import >/dev/null 2>&1 && HAVE_IMPORT=1
|
|
2565
|
-
command -v scrot >/dev/null 2>&1 && HAVE_SCROT=1
|
|
2566
|
-
command -v xrandr >/dev/null 2>&1 && HAVE_XRANDR=1
|
|
2567
|
-
WINDOW_PROBED=1
|
|
2568
|
-
return 0
|
|
2569
|
-
}
|
|
2570
|
-
|
|
2571
|
-
# There must be a display to talk to. Wayland alone is deliberately NOT accepted for the input and window
|
|
2572
|
-
# verbs: those go through XTest and EWMH, which a bare Wayland session does not provide. Saying so is
|
|
2573
|
-
# better than driving XWayland and reporting success for an action that never reached the app.
|
|
2574
|
-
window_require_display() {
|
|
2575
|
-
window_probe
|
|
2576
|
-
if [ -z "${DISPLAY:-}" ]; then
|
|
2577
|
-
fail E_NO_DISPLAY 'there is no X display in this environment' \
|
|
2578
|
-
'DISPLAY is unset; the desktop verbs need X11 through xdotool/wmctrl'
|
|
2579
|
-
fi
|
|
2580
|
-
return 0
|
|
2581
|
-
}
|
|
2582
|
-
|
|
2583
|
-
# require_tool <HAVE_FLAG> <tool-name> <what-for>
|
|
2584
|
-
window_require_tool() {
|
|
2585
|
-
local have="$1" tool="$2" why="$3"
|
|
2586
|
-
if [ "$have" != "1" ]; then
|
|
2587
|
-
fail E_NOT_SUPPORTED "the '$tool' tool is not installed" "install $tool to use $why on this platform"
|
|
2588
|
-
fi
|
|
2589
|
-
return 0
|
|
2590
|
-
}
|
|
2591
|
-
|
|
2592
|
-
# Coordinates arrive as text and must be signed integers before anything is injected. Checking here
|
|
2593
|
-
# rather than letting xdotool fail keeps the error code the same as on Windows: E_TYPE for a non-integer.
|
|
2594
|
-
# Validated coordinate. Sets WINDOW_INT rather than printing it.
|
|
2595
|
-
#
|
|
2596
|
-
# THIS ASSIGNS A GLOBAL RATHER THAN PRINTING, AND THAT IS LOAD-BEARING. Called as
|
|
2597
|
-
# `x="$(window_int_arg ...)"` the whole function runs in a subshell, so its `fail` would `exit` only that
|
|
2598
|
-
# subshell: the verb would carry on with an empty value and INJECT ANYWAY. That was a real defect here -
|
|
2599
|
-
# `mouse over abc 20` moved the pointer and returned ok - and it is the same trap the environment module
|
|
2600
|
-
# documents for env_resolve_scope. A validator that can reject must be called in the caller's own shell.
|
|
2601
|
-
WINDOW_INT=""
|
|
2602
|
-
window_int_arg() {
|
|
2603
|
-
local value="$1" label="$2"
|
|
2604
|
-
local digits="$value"
|
|
2605
|
-
# A leading '-' is part of a valid coordinate, so it is stripped before the digit test; anything that
|
|
2606
|
-
# is then not all digits is a type error. This is the same order the Windows side uses, which is why
|
|
2607
|
-
# '-5' is a range or type question rather than "not an integer".
|
|
2608
|
-
case "$digits" in -*) digits="${digits#-}" ;; esac
|
|
2609
|
-
case "$digits" in ''|*[!0-9]*) fail E_TYPE "<$label> must be an integer, got '$value'" ;; esac
|
|
2610
|
-
WINDOW_INT="$value"
|
|
2611
|
-
}
|
|
2612
|
-
|
|
2613
|
-
# ---------------------------------------------------------------------------
|
|
2614
|
-
# mouse.*
|
|
2615
|
-
#
|
|
2616
|
-
# Button numbering is X11's, and it is NOT the same as the Windows side's names: 1 is left, 2 is middle,
|
|
2617
|
-
# 3 is right, 4 and 5 are the vertical wheel. The --button flag accepts a name or a number so a caller
|
|
2618
|
-
# writing portable code can keep using names.
|
|
2619
|
-
# ---------------------------------------------------------------------------
|
|
2620
|
-
# Validated X11 button number. Sets WINDOW_BUTTON rather than printing it, for the same subshell reason
|
|
2621
|
-
# as window_int_arg: a rejection inside $(...) would not stop the verb.
|
|
2622
|
-
WINDOW_BUTTON=""
|
|
2623
|
-
window_button_number() {
|
|
2624
|
-
local b="$1"
|
|
2625
|
-
case "$b" in
|
|
2626
|
-
''|left|1) WINDOW_BUTTON='1' ;;
|
|
2627
|
-
middle|2) WINDOW_BUTTON='2' ;;
|
|
2628
|
-
right|3) WINDOW_BUTTON='3' ;;
|
|
2629
|
-
wheel-up|4) WINDOW_BUTTON='4' ;;
|
|
2630
|
-
wheel-down|5) WINDOW_BUTTON='5' ;;
|
|
2631
|
-
[0-9]*) WINDOW_BUTTON="$b" ;;
|
|
2632
|
-
*) fail E_TYPE "<button> must be left, middle, right or a number, got '$b'" ;;
|
|
2633
|
-
esac
|
|
2634
|
-
}
|
|
2635
|
-
|
|
2636
|
-
# --clearmodifiers is xdotool's own option and does exactly what the Windows side implements by hand:
|
|
2637
|
-
# release any modifier this process is holding before injecting, and restore it afterwards.
|
|
2638
|
-
# have_flag is the right test rather than comparing a value: a flag given without a value is stored as
|
|
2639
|
-
# "true" by parse_args, but a caller may also write --clearmodifiers=true, and both mean the same thing.
|
|
2640
|
-
window_clearmod_flag() {
|
|
2641
|
-
if have_flag clearmodifiers; then
|
|
2642
|
-
printf -- '--clearmodifiers'
|
|
2643
|
-
fi
|
|
2644
|
-
}
|
|
2645
|
-
|
|
2646
|
-
cmd_mouse_cursor() {
|
|
2647
|
-
window_require_display
|
|
2648
|
-
window_require_tool "$HAVE_XDOTOOL" xdotool 'mouse cursor'
|
|
2649
|
-
local out x y screen
|
|
2650
|
-
out="$(xdotool getmouselocation --shell 2>/dev/null)" || fail E_IO 'xdotool could not read the cursor position'
|
|
2651
|
-
x="$(printf '%s\n' "$out" | sed -n 's/^X=\(.*\)$/\1/p')"
|
|
2652
|
-
y="$(printf '%s\n' "$out" | sed -n 's/^Y=\(.*\)$/\1/p')"
|
|
2653
|
-
screen="$(printf '%s\n' "$out" | sed -n 's/^SCREEN=\(.*\)$/\1/p')"
|
|
2654
|
-
if [ -z "$x" ] || [ -z "$y" ]; then
|
|
2655
|
-
fail E_IO 'xdotool returned no cursor position'
|
|
2656
|
-
fi
|
|
2657
|
-
printf '{"cursor":{"x":%s,"y":%s},"screen":%s}' "$x" "$y" "$(jstr "${screen:-0}")"
|
|
2658
|
-
}
|
|
2659
|
-
|
|
2660
|
-
cmd_mouse_over() {
|
|
2661
|
-
require_min 2 'mouse over'
|
|
2662
|
-
require_allow 'mouse over' "${#POSITIONALS[@]}"
|
|
2663
|
-
window_require_display
|
|
2664
|
-
window_require_tool "$HAVE_XDOTOOL" xdotool 'mouse over'
|
|
2665
|
-
local x y
|
|
2666
|
-
window_int_arg "$(pos 0)" x; x="$WINDOW_INT"
|
|
2667
|
-
window_int_arg "$(pos 1)" y; y="$WINDOW_INT"
|
|
2668
|
-
xdotool mousemove --sync "$x" "$y" >/dev/null 2>&1 || fail E_IO "xdotool could not move the cursor to $x,$y"
|
|
2669
|
-
# The position is read back rather than assumed: a movement can be clamped by the screen edge or
|
|
2670
|
-
# overridden by a window manager, and reporting where the cursor actually is is the useful answer.
|
|
2671
|
-
local after ax ay
|
|
2672
|
-
after="$(xdotool getmouselocation --shell 2>/dev/null)"
|
|
2673
|
-
ax="$(printf '%s\n' "$after" | sed -n 's/^X=\(.*\)$/\1/p')"
|
|
2674
|
-
ay="$(printf '%s\n' "$after" | sed -n 's/^Y=\(.*\)$/\1/p')"
|
|
2675
|
-
printf '{"requested":{"x":%s,"y":%s},"cursor":{"x":%s,"y":%s},"moved":%s,"tool":"xdotool"}' \
|
|
2676
|
-
"$x" "$y" "${ax:-$x}" "${ay:-$y}" "$( [ "${ax:-$x}" = "$x" ] && [ "${ay:-$y}" = "$y" ] && echo true || echo false )"
|
|
2677
|
-
}
|
|
2678
|
-
|
|
2679
|
-
cmd_mouse_click() {
|
|
2680
|
-
require_min 2 'mouse click'
|
|
2681
|
-
require_allow 'mouse click' "${#POSITIONALS[@]}"
|
|
2682
|
-
window_require_display
|
|
2683
|
-
window_require_tool "$HAVE_XDOTOOL" xdotool 'mouse click'
|
|
2684
|
-
local x y button count cm
|
|
2685
|
-
window_int_arg "$(pos 0)" x; x="$WINDOW_INT"
|
|
2686
|
-
window_int_arg "$(pos 1)" y; y="$WINDOW_INT"
|
|
2687
|
-
# The button and count are resolved and validated HERE, before any injection. Both were originally
|
|
2688
|
-
# computed after the cursor had already been moved, so a rejected --count left the pointer somewhere
|
|
2689
|
-
# else - a failed call that had still changed the machine.
|
|
2690
|
-
window_button_number "$(flag_value button 'left')"; button="$WINDOW_BUTTON"
|
|
2691
|
-
count="$(flag_value count '1')"
|
|
2692
|
-
case "$count" in ''|*[!0-9]*) fail E_TYPE "<count> must be an integer, got '$count'" ;; esac
|
|
2693
|
-
[ "$count" -ge 1 ] || fail E_RANGE "<count> must be at least 1, got '$count'"
|
|
2694
|
-
cm="$(window_clearmod_flag)"
|
|
2695
|
-
|
|
2696
|
-
# EVERY argument is validated before anything is injected. An earlier version validated the button and
|
|
2697
|
-
# count after moving the cursor, so a bad --count still moved the pointer: a rejected call had already
|
|
2698
|
-
# changed the machine's state. The Windows side validates first for the same reason, and a test that
|
|
2699
|
-
# asserted "nothing was injected" is what caught it.
|
|
2700
|
-
xdotool mousemove --sync "$x" "$y" >/dev/null 2>&1 || fail E_IO "xdotool could not move the cursor to $x,$y"
|
|
2701
|
-
# xdotool click takes a repeat count, which is one process instead of a loop of one process per click.
|
|
2702
|
-
# shellcheck disable=SC2086
|
|
2703
|
-
xdotool click $cm --repeat "$count" "$button" >/dev/null 2>&1 || fail E_IO "xdotool could not click button $button at $x,$y"
|
|
2704
|
-
printf '{"point":{"x":%s,"y":%s},"button":%s,"count":%s,"clearedModifiers":%s,"tool":"xdotool"}' \
|
|
2705
|
-
"$x" "$y" "$button" "$count" "$( [ -n "$cm" ] && echo true || echo false )"
|
|
2706
|
-
}
|
|
2707
|
-
|
|
2708
|
-
cmd_mouse_context() {
|
|
2709
|
-
require_min 2 'mouse context'
|
|
2710
|
-
require_allow 'mouse context' "${#POSITIONALS[@]}"
|
|
2711
|
-
window_require_display
|
|
2712
|
-
window_require_tool "$HAVE_XDOTOOL" xdotool 'mouse context'
|
|
2713
|
-
local x y cm
|
|
2714
|
-
window_int_arg "$(pos 0)" x; x="$WINDOW_INT"
|
|
2715
|
-
window_int_arg "$(pos 1)" y; y="$WINDOW_INT"
|
|
2716
|
-
cm="$(window_clearmod_flag)"
|
|
2717
|
-
xdotool mousemove --sync "$x" "$y" >/dev/null 2>&1 || fail E_IO "xdotool could not move the cursor to $x,$y"
|
|
2718
|
-
# Button 3 is the right button, which is what opens a context menu.
|
|
2719
|
-
# shellcheck disable=SC2086
|
|
2720
|
-
xdotool click $cm 3 >/dev/null 2>&1 || fail E_IO "xdotool could not open a context menu at $x,$y"
|
|
2721
|
-
printf '{"point":{"x":%s,"y":%s},"button":3,"clearedModifiers":%s,"tool":"xdotool"}' \
|
|
2722
|
-
"$x" "$y" "$( [ -n "$cm" ] && echo true || echo false )"
|
|
2723
|
-
}
|
|
2724
|
-
|
|
2725
|
-
cmd_mouse_wheel() {
|
|
2726
|
-
require_min 2 'mouse wheel'
|
|
2727
|
-
require_allow 'mouse wheel' "${#POSITIONALS[@]}"
|
|
2728
|
-
window_require_display
|
|
2729
|
-
window_require_tool "$HAVE_XDOTOOL" xdotool 'mouse wheel'
|
|
2730
|
-
local x y
|
|
2731
|
-
window_int_arg "$(pos 0)" x; x="$WINDOW_INT"
|
|
2732
|
-
window_int_arg "$(pos 1)" y; y="$WINDOW_INT"
|
|
2733
|
-
|
|
2734
|
-
# The x argument carries the scroll distance, exactly as it does on Windows, where the wheel delta IS
|
|
2735
|
-
# x. --space overrides how many notches that distance is worth, for a caller that wants a smaller
|
|
2736
|
-
# movement than the sample points it has; omitting it means one notch per notch.
|
|
2737
|
-
local delta="$x"
|
|
2738
|
-
local count
|
|
2739
|
-
count="$(flag_value space '')"
|
|
2740
|
-
if [ -z "$count" ]; then
|
|
2741
|
-
# One notch by default. A magnitude is reduced to notches rather than multiplied, because the
|
|
2742
|
-
# Windows delta is a multiple of 120 and a caller porting the same numbers would otherwise scroll
|
|
2743
|
-
# hundreds of notches.
|
|
2744
|
-
local mag="$delta"
|
|
2745
|
-
case "$mag" in -*) mag="${mag#-}" ;; esac
|
|
2746
|
-
if [ "$mag" -ge 120 ]; then
|
|
2747
|
-
count=$((mag / 120))
|
|
2748
|
-
[ "$count" -ge 1 ] || count=1
|
|
2749
|
-
else
|
|
2750
|
-
count=1
|
|
2751
|
-
fi
|
|
2752
|
-
fi
|
|
2753
|
-
case "$count" in ''|*[!0-9]*|0) fail E_TYPE "<space> must be a positive integer, got '$count'" ;; esac
|
|
2754
|
-
|
|
2755
|
-
# X11 has no wheel event: buttons 4 and 5 are one notch up and down. A positive value scrolls up,
|
|
2756
|
-
# which is the same sign convention the Windows side uses for its WHEEL delta.
|
|
2757
|
-
local button
|
|
2758
|
-
case "$delta" in
|
|
2759
|
-
-*) button=5 ;;
|
|
2760
|
-
*) button=4 ;;
|
|
2761
|
-
esac
|
|
2762
|
-
if [ "$delta" -eq 0 ]; then
|
|
2763
|
-
fail E_RANGE '<x> must not be zero: the x argument carries the scroll distance'
|
|
2764
|
-
fi
|
|
2765
|
-
|
|
2766
|
-
xdotool mousemove --sync "$x" "$y" >/dev/null 2>&1 || fail E_IO "xdotool could not move the cursor to $x,$y"
|
|
2767
|
-
xdotool click --repeat "$count" "$button" >/dev/null 2>&1 || fail E_IO "xdotool could not scroll at $x,$y"
|
|
2768
|
-
printf '{"action":"wheel","delta":%s,"notches":%s,"direction":%s,"at":{"x":%s,"y":%s},"tool":"xdotool"}' \
|
|
2769
|
-
"$delta" "$count" "$( [ "$button" = "4" ] && echo '"up"' || echo '"down"' )" "$x" "$y"
|
|
2770
|
-
}
|
|
2771
|
-
cmd_mouse_down() {
|
|
2772
|
-
require_min 2 'mouse down'
|
|
2773
|
-
require_allow 'mouse down' "${#POSITIONALS[@]}"
|
|
2774
|
-
window_require_display
|
|
2775
|
-
window_require_tool "$HAVE_XDOTOOL" xdotool 'mouse down'
|
|
2776
|
-
local x y button
|
|
2777
|
-
window_int_arg "$(pos 0)" x; x="$WINDOW_INT"
|
|
2778
|
-
window_int_arg "$(pos 1)" y; y="$WINDOW_INT"
|
|
2779
|
-
window_button_number "$(flag_value button 'left')"; button="$WINDOW_BUTTON"
|
|
2780
|
-
xdotool mousemove --sync "$x" "$y" >/dev/null 2>&1 || fail E_IO "xdotool could not move the cursor to $x,$y"
|
|
2781
|
-
xdotool mousedown "$button" >/dev/null 2>&1 || fail E_IO "xdotool could not press button $button"
|
|
2782
|
-
# The button is deliberately NOT released: that is what mouse up is for. A drag is down, move, up.
|
|
2783
|
-
printf '{"point":{"x":%s,"y":%s},"button":%s,"held":true,"note":"the button stays down until mouse up","tool":"xdotool"}' \
|
|
2784
|
-
"$x" "$y" "$button"
|
|
2785
|
-
}
|
|
2786
|
-
|
|
2787
|
-
cmd_mouse_up() {
|
|
2788
|
-
require_allow 'mouse up' "${#POSITIONALS[@]}"
|
|
2789
|
-
window_require_display
|
|
2790
|
-
window_require_tool "$HAVE_XDOTOOL" xdotool 'mouse up'
|
|
2791
|
-
local button
|
|
2792
|
-
window_button_number "$(flag_value button 'left')"; button="$WINDOW_BUTTON"
|
|
2793
|
-
xdotool mouseup "$button" >/dev/null 2>&1 || fail E_IO "xdotool could not release button $button"
|
|
2794
|
-
printf '{"button":%s,"held":false,"tool":"xdotool"}' "$button"
|
|
2795
|
-
}
|
|
2796
|
-
|
|
2797
|
-
# ---------------------------------------------------------------------------
|
|
2798
|
-
# keyboard.*
|
|
2799
|
-
#
|
|
2800
|
-
# xdotool takes X keysym NAMES, not virtual key codes, so a caller writing portable code needs the two
|
|
2801
|
-
# to line up. The names below are the intersection that means the same thing on both platforms, and the
|
|
2802
|
-
# mapping is applied before every call. Anything not listed is passed through unchanged, which lets a
|
|
2803
|
-
# caller use any X keysym name ('XF86AudioMute', 'Greek_alpha') without this layer having to enumerate
|
|
2804
|
-
# the whole keyboard.
|
|
2805
|
-
#
|
|
2806
|
-
# The reason the mapping exists at all is the same reason the Windows side sends Unicode events for
|
|
2807
|
-
# keyboard type: a key is not a character, and 'Return' versus 'Enter' is exactly the kind of difference
|
|
2808
|
-
# that makes a script work on one machine and silently do nothing on another.
|
|
2809
|
-
# ---------------------------------------------------------------------------
|
|
2810
|
-
WINDOW_KEY=""
|
|
2811
|
-
window_key_name() {
|
|
2812
|
-
local key="$1"
|
|
2813
|
-
case "$key" in
|
|
2814
|
-
'' ) fail E_TYPE '<code> must not be empty' ;;
|
|
2815
|
-
# Single characters and function keys are already keysyms.
|
|
2816
|
-
[A-Za-z0-9]) WINDOW_KEY="$key" ;;
|
|
2817
|
-
F[0-9]|F1[0-2]) WINDOW_KEY="$key" ;;
|
|
2818
|
-
# Windows spelling -> X keysym spelling.
|
|
2819
|
-
enter|Enter|ENTER|return|Return|RETURN) WINDOW_KEY='Return' ;;
|
|
2820
|
-
esc|Esc|ESC|escape|Escape|ESCAPE) WINDOW_KEY='Escape' ;;
|
|
2821
|
-
backspace|Backspace|BACKSPACE|back|Back|BACK) WINDOW_KEY='BackSpace' ;;
|
|
2822
|
-
del|Del|DEL|delete|Delete|DELETE) WINDOW_KEY='Delete' ;;
|
|
2823
|
-
ins|Ins|INS|insert|Insert|INSERT) WINDOW_KEY='Insert' ;;
|
|
2824
|
-
space|Space|SPACE) WINDOW_KEY='space' ;;
|
|
2825
|
-
tab|Tab|TAB) WINDOW_KEY='Tab' ;;
|
|
2826
|
-
ctrl|Ctrl|CTRL|control|Control|CONTROL) WINDOW_KEY='ctrl' ;;
|
|
2827
|
-
shift|Shift|SHIFT) WINDOW_KEY='shift' ;;
|
|
2828
|
-
alt|Alt|ALT) WINDOW_KEY='alt' ;;
|
|
2829
|
-
win|Win|WIN|super|Super|SUPER|meta|Meta|META) WINDOW_KEY='super' ;;
|
|
2830
|
-
up|Up|UP) WINDOW_KEY='Up' ;;
|
|
2831
|
-
down|Down|DOWN) WINDOW_KEY='Down' ;;
|
|
2832
|
-
left|Left|LEFT) WINDOW_KEY='Left' ;;
|
|
2833
|
-
right|Right|RIGHT) WINDOW_KEY='Right' ;;
|
|
2834
|
-
home|Home|HOME) WINDOW_KEY='Home' ;;
|
|
2835
|
-
end|End|END) WINDOW_KEY='End' ;;
|
|
2836
|
-
pageup|PageUp|PAGEUP|pgup|PgUp|PGUP) WINDOW_KEY='Prior' ;;
|
|
2837
|
-
pagedown|PageDown|PAGEDOWN|pgdn|PgDn|PGDN) WINDOW_KEY='Next' ;;
|
|
2838
|
-
printscreen|PrintScreen|PRINTSCREEN) WINDOW_KEY='Print' ;;
|
|
2839
|
-
capslock|CapsLock|CAPSLOCK) WINDOW_KEY='Caps_Lock' ;;
|
|
2840
|
-
numlock|NumLock|NUMLOCK) WINDOW_KEY='Num_Lock' ;;
|
|
2841
|
-
scrolllock|ScrollLock|SCROLLLOCK) WINDOW_KEY='Scroll_Lock' ;;
|
|
2842
|
-
pause|Pause|PAUSE) WINDOW_KEY='Pause' ;;
|
|
2843
|
-
# A Windows virtual key number means nothing to X, so saying so beats sending a literal "13".
|
|
2844
|
-
[0-9]*) fail E_TYPE "<code> '$key' is a virtual key number, which has no X equivalent" \
|
|
2845
|
-
'use a name such as Return, Escape, ctrl or F5' ;;
|
|
2846
|
-
*) WINDOW_KEY="$key" ;;
|
|
2847
|
-
esac
|
|
2848
|
-
}
|
|
2849
|
-
|
|
2850
|
-
# The interval between injected keys, in milliseconds, as a fractional sleep. xdotool has --delay but
|
|
2851
|
-
# it is per character in milliseconds and its handling differs between versions, so the delay is applied
|
|
2852
|
-
# here where its meaning is unambiguous.
|
|
2853
|
-
window_key_delay() {
|
|
2854
|
-
local interval; interval="$(flag_value interval '')"
|
|
2855
|
-
[ -z "$interval" ] && return 0
|
|
2856
|
-
case "$interval" in ''|*[!0-9]*) fail E_TYPE "<interval> must be an integer, got '$interval'" ;; esac
|
|
2857
|
-
if [ "$interval" -gt 0 ]; then
|
|
2858
|
-
local secs=$((interval / 1000))
|
|
2859
|
-
local rem=$((interval % 1000))
|
|
2860
|
-
if [ "$rem" -ne 0 ] && sleep "0.$rem" 2>/dev/null; then
|
|
2861
|
-
:
|
|
2862
|
-
elif [ "$rem" -ne 0 ]; then
|
|
2863
|
-
secs=$((secs + 1))
|
|
2864
|
-
fi
|
|
2865
|
-
[ "$secs" -gt 0 ] && sleep "$secs"
|
|
2866
|
-
fi
|
|
2867
|
-
return 0
|
|
2868
|
-
}
|
|
2869
|
-
|
|
2870
|
-
cmd_keyboard_codes() {
|
|
2871
|
-
window_require_display
|
|
2872
|
-
window_require_tool "$HAVE_XDOTOOL" xdotool 'keyboard codes'
|
|
2873
|
-
# The names this layer maps, plus the fact that xdotool resolves anything else itself. Reported as a
|
|
2874
|
-
# sorted list so a caller can check a name without guessing.
|
|
2875
|
-
printf '{"platform":"x11","count":32,"names":["Alt","BackSpace","Caps_Lock","Control","Delete","Down","End","Enter","Escape","F1","F2","F3","F4","F5","F6","F7","F8","F9","F10","F11","F12","Home","Insert","Left","Next","Num_Lock","Pause","Print","Prior","Return","Right","Tab","Up"],"note":"any X keysym name is also accepted and passed through unchanged","tool":"xdotool"}'
|
|
2876
|
-
}
|
|
2877
|
-
|
|
2878
|
-
cmd_keyboard_down() {
|
|
2879
|
-
require_min 1 'keyboard down'
|
|
2880
|
-
require_allow 'keyboard down' "${#POSITIONALS[@]}"
|
|
2881
|
-
window_require_display
|
|
2882
|
-
window_require_tool "$HAVE_XDOTOOL" xdotool 'keyboard down'
|
|
2883
|
-
local key; window_key_name "$(pos 0)"; key="$WINDOW_KEY"
|
|
2884
|
-
local cm; cm="$(window_clearmod_flag)"
|
|
2885
|
-
# shellcheck disable=SC2086
|
|
2886
|
-
xdotool keydown $cm "$key" >/dev/null 2>&1 || fail E_IO "xdotool could not press '$key' down"
|
|
2887
|
-
printf '{"code":%s,"held":true,"note":"the key stays down until keyboard up","tool":"xdotool"}' "$(jstr "$key")"
|
|
2888
|
-
}
|
|
2889
|
-
|
|
2890
|
-
cmd_keyboard_up() {
|
|
2891
|
-
require_min 1 'keyboard up'
|
|
2892
|
-
require_allow 'keyboard up' "${#POSITIONALS[@]}"
|
|
2893
|
-
window_require_display
|
|
2894
|
-
window_require_tool "$HAVE_XDOTOOL" xdotool 'keyboard up'
|
|
2895
|
-
local key; window_key_name "$(pos 0)"; key="$WINDOW_KEY"
|
|
2896
|
-
local cm; cm="$(window_clearmod_flag)"
|
|
2897
|
-
# shellcheck disable=SC2086
|
|
2898
|
-
xdotool keyup $cm "$key" >/dev/null 2>&1 || fail E_IO "xdotool could not release '$key'"
|
|
2899
|
-
printf '{"code":%s,"held":false,"tool":"xdotool"}' "$(jstr "$key")"
|
|
2900
|
-
}
|
|
2901
|
-
|
|
2902
|
-
cmd_keyboard_press() {
|
|
2903
|
-
require_min 1 'keyboard press'
|
|
2904
|
-
require_allow 'keyboard press' "${#POSITIONALS[@]}"
|
|
2905
|
-
window_require_display
|
|
2906
|
-
window_require_tool "$HAVE_XDOTOOL" xdotool 'keyboard press'
|
|
2907
|
-
# The key name and the interval are validated before the injection, so a rejected call has not already
|
|
2908
|
-
# pressed something.
|
|
2909
|
-
local key; window_key_name "$(pos 0)"; key="$WINDOW_KEY"
|
|
2910
|
-
local interval; interval="$(flag_value interval '')"
|
|
2911
|
-
if [ -n "$interval" ]; then
|
|
2912
|
-
case "$interval" in ''|*[!0-9]*) fail E_TYPE "<interval> must be an integer, got '$interval'" ;; esac
|
|
2913
|
-
fi
|
|
2914
|
-
local cm; cm="$(window_clearmod_flag)"
|
|
2915
|
-
# A press is xdotool key, which presses AND releases. The key is always released even if the
|
|
2916
|
-
# injection fails, because a key left down makes the machine unusable until it is pressed again.
|
|
2917
|
-
# shellcheck disable=SC2086
|
|
2918
|
-
if ! xdotool key $cm "$key" >/dev/null 2>&1; then
|
|
2919
|
-
xdotool keyup "$key" >/dev/null 2>&1 || true
|
|
2920
|
-
fail E_IO "xdotool could not press '$key'"
|
|
2921
|
-
fi
|
|
2922
|
-
window_key_delay
|
|
2923
|
-
printf '{"code":%s,"pressed":true,"released":true,"intervalMs":%s,"tool":"xdotool"}' \
|
|
2924
|
-
"$(jstr "$key")" "$( [ -n "$interval" ] && printf '%s' "$interval" || printf 'null' )"
|
|
2925
|
-
}
|
|
2926
|
-
|
|
2927
|
-
cmd_keyboard_combination() {
|
|
2928
|
-
require_min 2 'keyboard combination'
|
|
2929
|
-
require_allow 'keyboard combination' "${#POSITIONALS[@]}"
|
|
2930
|
-
window_require_display
|
|
2931
|
-
window_require_tool "$HAVE_XDOTOOL" xdotool 'keyboard combination'
|
|
2932
|
-
local phase; phase="$(pos 0)"
|
|
2933
|
-
local cm; cm="$(window_clearmod_flag)"
|
|
2934
|
-
local interval; interval="$(flag_value interval '')"
|
|
2935
|
-
# The keys may be one space-separated argument or several arguments, because both are natural to write.
|
|
2936
|
-
local keys_raw="" i=1
|
|
2937
|
-
while :; do
|
|
2938
|
-
local v; v="$(pos $i)"
|
|
2939
|
-
[ -z "$v" ] && break
|
|
2940
|
-
keys_raw="$keys_raw $v"
|
|
2941
|
-
i=$((i + 1))
|
|
2942
|
-
done
|
|
2943
|
-
local keys=()
|
|
2944
|
-
local k
|
|
2945
|
-
for k in $keys_raw; do window_key_name "$k"; keys+=("$WINDOW_KEY"); done
|
|
2946
|
-
[ "${#keys[@]}" -eq 0 ] && fail E_TYPE '<keys> must name at least one key'
|
|
2947
|
-
|
|
2948
|
-
local chord; chord="$(printf '%s+' "${keys[@]}")"; chord="${chord%+}"
|
|
2949
|
-
case "$phase" in
|
|
2950
|
-
down)
|
|
2951
|
-
# Modifiers first, then the final key: the reverse order in a chord is what produces the
|
|
2952
|
-
# combination for a key that needs the modifier held.
|
|
2953
|
-
local first=1
|
|
2954
|
-
for k in "${keys[@]}"; do
|
|
2955
|
-
if [ "$first" = "1" ]; then first=0; else chord=""; fi
|
|
2956
|
-
# shellcheck disable=SC2086
|
|
2957
|
-
xdotool keydown $cm "$k" >/dev/null 2>&1 || { for u in "${keys[@]}"; do xdotool keyup "$u" >/dev/null 2>&1 || true; done; fail E_IO "xdotool could not hold '$k'"; }
|
|
2958
|
-
done
|
|
2959
|
-
printf '{"phase":"down","keys":[%s],"held":true,"tool":"xdotool"}' "$(window_json_keys "${keys[@]}")"
|
|
2960
|
-
;;
|
|
2961
|
-
up)
|
|
2962
|
-
# Released in REVERSE order, which is what a real chord does and what applications expect.
|
|
2963
|
-
local idx=$((${#keys[@]} - 1))
|
|
2964
|
-
while [ "$idx" -ge 0 ]; do
|
|
2965
|
-
xdotool keyup "${keys[$idx]}" >/dev/null 2>&1 || true
|
|
2966
|
-
idx=$((idx - 1))
|
|
2967
|
-
done
|
|
2968
|
-
printf '{"phase":"up","keys":[%s],"held":false,"tool":"xdotool"}' "$(window_json_keys "${keys[@]}")"
|
|
2969
|
-
;;
|
|
2970
|
-
press|"")
|
|
2971
|
-
# A whole chord in one call. xdotool releases what it pressed, and modifiers the USER is holding
|
|
2972
|
-
# are handled by --clearmodifiers rather than being released unconditionally.
|
|
2973
|
-
# shellcheck disable=SC2086
|
|
2974
|
-
if ! xdotool key $cm "$chord" >/dev/null 2>&1; then
|
|
2975
|
-
for u in "${keys[@]}"; do xdotool keyup "$u" >/dev/null 2>&1 || true; done
|
|
2976
|
-
fail E_IO "xdotool could not send the chord '$chord'"
|
|
2977
|
-
fi
|
|
2978
|
-
window_key_delay
|
|
2979
|
-
printf '{"phase":"press","keys":[%s],"chord":%s,"intervalMs":%s,"tool":"xdotool"}' \
|
|
2980
|
-
"$(window_json_keys "${keys[@]}")" "$(jstr "$chord")" \
|
|
2981
|
-
"$( [ -n "$interval" ] && printf '%s' "$interval" || printf 'null' )"
|
|
2982
|
-
;;
|
|
2983
|
-
*)
|
|
2984
|
-
fail E_TYPE "<phase> must be down, up or press, got '$phase'"
|
|
2985
|
-
;;
|
|
2986
|
-
esac
|
|
2987
|
-
}
|
|
2988
|
-
|
|
2989
|
-
window_json_keys() {
|
|
2990
|
-
local out="" k
|
|
2991
|
-
for k in "$@"; do
|
|
2992
|
-
[ -n "$out" ] && out="$out,"
|
|
2993
|
-
out="$out$(jstr "$k")"
|
|
2994
|
-
done
|
|
2995
|
-
printf '%s' "$out"
|
|
2996
|
-
}
|
|
2997
|
-
|
|
2998
|
-
cmd_keyboard_type() {
|
|
2999
|
-
require_min 1 'keyboard type'
|
|
3000
|
-
require_allow 'keyboard type' "${#POSITIONALS[@]}"
|
|
3001
|
-
window_require_display
|
|
3002
|
-
window_require_tool "$HAVE_XDOTOOL" xdotool 'keyboard type'
|
|
3003
|
-
local text; text="$(pos 0)"
|
|
3004
|
-
if [ -z "$text" ]; then
|
|
3005
|
-
fail E_NOT_TEXT '<text> must not be empty'
|
|
3006
|
-
fi
|
|
3007
|
-
local method; method="$(flag_value method 'type')"
|
|
3008
|
-
case "$method" in
|
|
3009
|
-
type|key)
|
|
3010
|
-
# --clearmodifiers matters more here than anywhere else: a modifier the user is physically holding
|
|
3011
|
-
# would otherwise combine with every injected character.
|
|
3012
|
-
# shellcheck disable=SC2086
|
|
3013
|
-
xdotool type --clearmodifiers --delay "${XDOTOOL_TYPE_DELAY:-12}" -- "$text" >/dev/null 2>&1 \
|
|
3014
|
-
|| fail E_IO 'xdotool could not type the text'
|
|
3015
|
-
;;
|
|
3016
|
-
*)
|
|
3017
|
-
fail E_TYPE "<method> must be type or key, got '$method'"
|
|
3018
|
-
;;
|
|
3019
|
-
esac
|
|
3020
|
-
window_key_delay
|
|
3021
|
-
local chars; chars="$(printf '%s' "$text" | wc -c | tr -d ' ')"
|
|
3022
|
-
printf '{"text":%s,"characters":%s,"method":%s,"tool":"xdotool"}' \
|
|
3023
|
-
"$(jstr "$text")" "$chars" "$(jstr "$method")"
|
|
3024
|
-
}
|
|
3025
|
-
# ---------------------------------------------------------------------------
|
|
3026
|
-
# wait ms
|
|
3027
|
-
#
|
|
3028
|
-
# The sleep is performed in whole seconds plus a fractional remainder where the shell supports one.
|
|
3029
|
-
# `sleep 0.05` is a GNU/busybox extension rather than POSIX, so the fractional form is used only when
|
|
3030
|
-
# the value is not a whole number of seconds AND the shell's sleep accepts it; otherwise the wait is
|
|
3031
|
-
# rounded UP to the next second. Rounding up rather than down is deliberate: a caller that asked to wait
|
|
3032
|
-
# 1500 ms is pacing something, and returning early is the failure mode that breaks it.
|
|
3033
|
-
# ---------------------------------------------------------------------------
|
|
3034
|
-
cmd_wait_ms() {
|
|
3035
|
-
require_min 1 'wait ms'
|
|
3036
|
-
local ms; ms="$(pos 0)"
|
|
3037
|
-
# A leading '-' is not "not an integer", it is a negative number: reporting E_TYPE for -5 said the
|
|
3038
|
-
# wrong thing, and the Windows side reports E_RANGE for it. The sign is checked first so the caller
|
|
3039
|
-
# gets the same code on both platforms.
|
|
3040
|
-
case "$ms" in
|
|
3041
|
-
-*) fail E_RANGE "ms must not be negative, got $ms" ;;
|
|
3042
|
-
esac
|
|
3043
|
-
case "$ms" in ''|*[!0-9]*) fail E_TYPE "<ms> must be an integer, got '$ms'" ;; esac
|
|
3044
|
-
if [ "$ms" -gt 3600000 ]; then
|
|
3045
|
-
# An hour is a generous ceiling. Beyond it the caller almost certainly passed seconds by mistake,
|
|
3046
|
-
# and silently sleeping for hours is worse than saying so.
|
|
3047
|
-
fail E_RANGE "ms must be at most 3600000 (one hour), got $ms" \
|
|
3048
|
-
'the value is milliseconds; 1000 ms is one second'
|
|
3049
|
-
fi
|
|
3050
|
-
|
|
3051
|
-
# Elapsed is measured in MILLISECONDS with date, not from $SECONDS. $SECONDS is a whole-second
|
|
3052
|
-
# counter, so a 250 ms wait reported elapsedMs 0, which is a false measurement rather than a rounding
|
|
3053
|
-
# detail: the field exists to tell a caller how long it actually waited. The same expression the entry
|
|
3054
|
-
# point uses is used here so the two agree. The %3N form is GNU; the fallback reports whole seconds.
|
|
3055
|
-
local started; started="$(date +%s%3N 2>/dev/null || date +%s000)"
|
|
3056
|
-
local secs=$((ms / 1000))
|
|
3057
|
-
local rem=$((ms % 1000))
|
|
3058
|
-
if [ "$rem" -eq 0 ]; then
|
|
3059
|
-
[ "$secs" -gt 0 ] && sleep "$secs"
|
|
3060
|
-
else
|
|
3061
|
-
# `sleep 0.25` is a GNU/busybox extension, not POSIX, so it is tried and the wait rounds UP to the
|
|
3062
|
-
# next whole second if the shell refuses it. Rounding up is deliberate: a caller that asked to wait
|
|
3063
|
-
# 1500 ms is pacing something, and returning early is the failure mode that breaks it.
|
|
3064
|
-
if sleep "0.$rem" 2>/dev/null; then
|
|
3065
|
-
[ "$secs" -gt 0 ] && sleep "$secs"
|
|
3066
|
-
else
|
|
3067
|
-
sleep "$((secs + 1))"
|
|
3068
|
-
fi
|
|
3069
|
-
fi
|
|
3070
|
-
local end_ms; end_ms="$(date +%s%3N 2>/dev/null || date +%s000)"
|
|
3071
|
-
local elapsed=$((end_ms - started))
|
|
3072
|
-
|
|
3073
|
-
printf '{"requestedMs":%s,"elapsedMs":%s,"note":"fractional sleep is used when the shell supports it; otherwise the wait rounds up to the next whole second"}' \
|
|
3074
|
-
"$ms" "$elapsed"
|
|
3075
|
-
}
|
|
3076
|
-
|
|
3077
|
-
# ===== dispatch.sh =================================================
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
cmd_arg_error() {
|
|
3081
|
-
fail "$ARG_ERROR_CODE" "$ARG_ERROR_MESSAGE" "$ARG_ERROR_HINT"
|
|
3082
|
-
}
|
|
3083
|
-
|
|
3084
|
-
# dispatch <verb-function...>
|
|
3085
|
-
#
|
|
3086
|
-
# Runs a verb body and records its status in MAIN_STATUS instead of letting a non-zero exit escape.
|
|
3087
|
-
#
|
|
3088
|
-
# This MUST be a top-level function, not a nested one inside main: the verb body runs in the command
|
|
3089
|
-
# substitution below, which is what contains fail()'s `exit`. A nested definition is only in scope
|
|
3090
|
-
# while main is executing, so any earlier caller (the entry-point argument-error path) hit
|
|
3091
|
-
# "dispatch: command not found" and produced no envelope at all.
|
|
3092
|
-
#
|
|
3093
|
-
# RESULT is a global for the same reason: main reads it after the body has run.
|
|
3094
|
-
RESULT=""
|
|
3095
|
-
MAIN_STATUS=0
|
|
3096
|
-
|
|
3097
|
-
dispatch() {
|
|
3098
|
-
local status=0
|
|
3099
|
-
RESULT="$("$@")" || status=$?
|
|
3100
|
-
if [ "$status" -ne 0 ]; then
|
|
3101
|
-
MAIN_STATUS="$status"
|
|
3102
|
-
fi
|
|
3103
|
-
return 0
|
|
3104
|
-
}
|
|
3105
|
-
|
|
3106
|
-
cmd_not_implemented() {
|
|
3107
|
-
fail E_NOT_SUPPORTED "verb '$CURRENT_VERB' is not implemented in this build" \
|
|
3108
|
-
'run: dsh-os.sh selfcheck (implemented verbs are listed in its "implemented" field)'
|
|
3109
|
-
}
|
|
3110
|
-
|
|
3111
|
-
# ---------------------------------------------------------------------------
|
|
3112
|
-
# Dispatch. Two or three word verbs: "selfcheck", "file charset", "file verify charset".
|
|
3113
|
-
# ---------------------------------------------------------------------------
|
|
3114
|
-
known_verb() {
|
|
3115
|
-
local key="$1" file="$HOST_DIR/spec/verbs.tsv"
|
|
3116
|
-
[ -f "$file" ] || return 1
|
|
3117
|
-
awk -F'\t' -v k="$key" '!/^#/ && $1 == k { found = 1 } END { exit(found ? 0 : 1) }' "$file"
|
|
3118
|
-
}
|
|
3119
|
-
|
|
3120
|
-
# Verb bodies that are declared in the verb table but not yet implemented here. Resolving them
|
|
3121
|
-
# normally lets the case statement's default branch report a typed E_NOT_SUPPORTED envelope, instead
|
|
3122
|
-
# of the script dying in the parser with nothing on stdout.
|
|
3123
|
-
UNKNOWN_VERB=""
|
|
3124
|
-
|
|
3125
|
-
resolve_verb() {
|
|
3126
|
-
# Sets the global CURRENT_VERB and strips the consumed verb tokens from POSITIONALS.
|
|
3127
|
-
#
|
|
3128
|
-
# This must run in the CURRENT shell, not inside $(...). Called as CURRENT_VERB="$(resolve_verb)",
|
|
3129
|
-
# the reassignment of POSITIONALS happens in a subshell and is thrown away, so every verb body
|
|
3130
|
-
# then reads its first positional as the literal verb token ("file" instead of the path). The
|
|
3131
|
-
# result is a plausible-looking but wrong error such as: cannot read 'file': not found.
|
|
3132
|
-
local key="${POSITIONALS[0]:-}"
|
|
3133
|
-
if [ -z "$key" ]; then
|
|
3134
|
-
# Returns rather than calling fail(). fail() exits, and an exit raised here -- in the current
|
|
3135
|
-
# shell, before the entry point -- terminates the script with no envelope. The caller reports it.
|
|
3136
|
-
UNKNOWN_VERB=""
|
|
3137
|
-
return 2
|
|
3138
|
-
fi
|
|
3139
|
-
local consumed=1
|
|
3140
|
-
while [ "$consumed" -lt 3 ] && [ "$consumed" -lt "${#POSITIONALS[@]}" ]; do
|
|
3141
|
-
local token="${POSITIONALS[$consumed]}"
|
|
3142
|
-
case "$token" in -*) break ;; esac
|
|
3143
|
-
case "$token" in *[!a-z0-9-]*|'') break ;; esac
|
|
3144
|
-
local candidate="$key.$token"
|
|
3145
|
-
if known_verb "$candidate"; then
|
|
3146
|
-
key="$candidate"; consumed=$((consumed + 1))
|
|
3147
|
-
else
|
|
3148
|
-
# The key may still be extended further (file.verify is not a verb while
|
|
3149
|
-
# file.verify.charset is), so test the one deeper candidate before giving up. The guard also
|
|
3150
|
-
# verifies that the deeper key is not itself extendable, so a two-token continuation is only
|
|
3151
|
-
# accepted when it is a genuine terminal verb rather than a prefix of a longer one.
|
|
3152
|
-
if [ "$consumed" -lt 2 ] && [ $((consumed + 1)) -lt "${#POSITIONALS[@]}" ]; then
|
|
3153
|
-
local deeper="$candidate.${POSITIONALS[$((consumed + 1))]}"
|
|
3154
|
-
if known_verb "$deeper"; then
|
|
3155
|
-
local deeper_token="${POSITIONALS[$((consumed + 2))]:-}"
|
|
3156
|
-
local extends=""
|
|
3157
|
-
if [ -n "$deeper_token" ]; then
|
|
3158
|
-
case "$deeper_token" in
|
|
3159
|
-
-*) : ;;
|
|
3160
|
-
*[!a-z0-9-]*) : ;;
|
|
3161
|
-
*) if known_verb "$deeper.$deeper_token"; then extends="yes"; fi ;;
|
|
3162
|
-
esac
|
|
3163
|
-
fi
|
|
3164
|
-
if [ -z "$extends" ]; then
|
|
3165
|
-
key="$deeper"; consumed=$((consumed + 2))
|
|
3166
|
-
else
|
|
3167
|
-
break
|
|
3168
|
-
fi
|
|
3169
|
-
else
|
|
3170
|
-
break
|
|
3171
|
-
fi
|
|
3172
|
-
else
|
|
3173
|
-
break
|
|
3174
|
-
fi
|
|
3175
|
-
fi
|
|
3176
|
-
done
|
|
3177
|
-
# Drop the consumed verb tokens by rebuilding the array explicitly.
|
|
3178
|
-
#
|
|
3179
|
-
# The obvious form POSITIONALS=("${POSITIONALS[@]:$consumed}") is a trap: with `set -u`, slicing a
|
|
3180
|
-
# fully-consumed array is an unbound expansion, which in some bash builds aborts the whole script
|
|
3181
|
-
# inside the caller's command substitution. The symptom is that every verb returns an empty
|
|
3182
|
-
# "result" and every failure returns status 0, with the real error visible only on stderr. So the
|
|
3183
|
-
# remaining tokens are collected one at a time instead of sliced.
|
|
3184
|
-
local remaining=()
|
|
3185
|
-
local idx="$consumed"
|
|
3186
|
-
while [ "$idx" -lt "${#POSITIONALS[@]}" ]; do
|
|
3187
|
-
remaining+=("${POSITIONALS[$idx]}")
|
|
3188
|
-
idx=$((idx + 1))
|
|
3189
|
-
done
|
|
3190
|
-
POSITIONALS=()
|
|
3191
|
-
if [ "${#remaining[@]}" -gt 0 ]; then
|
|
3192
|
-
POSITIONALS=("${remaining[@]}")
|
|
3193
|
-
fi
|
|
3194
|
-
CURRENT_VERB="$key"
|
|
3195
|
-
return 0
|
|
3196
|
-
}
|
|
3197
|
-
|
|
3198
|
-
# ===== entry.sh ====================================================
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
main() {
|
|
3202
|
-
local start_ms
|
|
3203
|
-
start_ms="$(date +%s%3N 2>/dev/null || date +%s000)"
|
|
3204
|
-
# parse_args populates globals (POSITIONALS, FLAGS, NAMED), so it cannot run in a subshell. It
|
|
3205
|
-
# therefore RETURNS its error instead of calling fail(), and the failure is reported here where the
|
|
3206
|
-
# entry point can still re-emit the envelope. Calling fail() inside it exited before any output
|
|
3207
|
-
# could be produced, which is why a bad flag used to return a bare exit code.
|
|
3208
|
-
parse_args "$@"
|
|
3209
|
-
local parse_status=$?
|
|
3210
|
-
if [ "$parse_status" -ne 0 ]; then
|
|
3211
|
-
# Routed through dispatch, not called directly. fail() ends with `exit`, and an exit raised in the
|
|
3212
|
-
# main shell terminates the script before the entry point can re-emit the envelope it just wrote.
|
|
3213
|
-
# Inside dispatch's command substitution that exit only ends the substitution, so the status
|
|
3214
|
-
# returns to main and the entry point gets to run. Every failing path must go through here.
|
|
3215
|
-
dispatch cmd_arg_error
|
|
3216
|
-
return "$MAIN_STATUS"
|
|
3217
|
-
fi
|
|
3218
|
-
# resolve_verb must also run in the CURRENT shell, for the same reason: it reassigns POSITIONALS.
|
|
3219
|
-
resolve_verb
|
|
3220
|
-
local verb_status=$?
|
|
3221
|
-
if [ "$verb_status" -ne 0 ]; then
|
|
3222
|
-
if [ "$verb_status" -eq 2 ]; then
|
|
3223
|
-
fail E_USAGE 'no verb given' 'run: dsh-os.sh selfcheck (lists the full verb table)'
|
|
3224
|
-
fi
|
|
3225
|
-
# Reporting through fail() is what writes the envelope; setting the status alone would leave the
|
|
3226
|
-
# caller with a non-zero exit and nothing to parse.
|
|
3227
|
-
local reported="${UNKNOWN_VERB:-}"
|
|
3228
|
-
if [ -z "$reported" ]; then reported="${POSITIONALS[0]:-}"; fi
|
|
3229
|
-
fail E_NOT_SUPPORTED "verb '$reported' is not implemented in this build" \
|
|
3230
|
-
'run: dsh-os.sh selfcheck (implemented verbs are listed in its "implemented" field)'
|
|
3231
|
-
fi
|
|
3232
|
-
# Normalise the two calling forms before dispatch, so no verb body has to care which was used.
|
|
3233
|
-
merge_named_args "$CURRENT_VERB"
|
|
3234
|
-
local merge_status=$?
|
|
3235
|
-
if [ "$merge_status" -ne 0 ]; then
|
|
3236
|
-
dispatch cmd_arg_error
|
|
3237
|
-
return "$MAIN_STATUS"
|
|
3238
|
-
fi
|
|
3239
|
-
|
|
3240
|
-
# Each verb body is invoked through this wrapper rather than directly.
|
|
3241
|
-
#
|
|
3242
|
-
# `result="$(cmd_x)"` would be fatal under `set -e` the moment cmd_x exits non-zero, aborting main
|
|
3243
|
-
# BEFORE the envelope is emitted. Catching the status here keeps main in control.
|
|
3244
|
-
#
|
|
3245
|
-
# dispatch and RESULT are top-level (defined above main) so the argument-error path can reach them
|
|
3246
|
-
# too. An `exit` raised while the verb body runs in dispatch's command substitution is contained
|
|
3247
|
-
# there; the status travels back via MAIN_STATUS and the entry point performs the single exit.
|
|
3248
|
-
case "$CURRENT_VERB" in
|
|
3249
|
-
'selfcheck') dispatch cmd_selfcheck ;;
|
|
3250
|
-
'file.create') dispatch cmd_file_create ;;
|
|
3251
|
-
'file.charset') dispatch cmd_file_charset ;;
|
|
3252
|
-
'file.information') dispatch cmd_file_information ;;
|
|
3253
|
-
'file.length') dispatch cmd_file_length ;;
|
|
3254
|
-
'file.list') dispatch cmd_file_list ;;
|
|
3255
|
-
'file.delete') dispatch cmd_file_delete ;;
|
|
3256
|
-
'file.move') dispatch cmd_file_move ;;
|
|
3257
|
-
'file.copy') dispatch cmd_file_copy ;;
|
|
3258
|
-
'file.mkdir') dispatch cmd_file_mkdir ;;
|
|
3259
|
-
'file.append') dispatch cmd_file_append ;;
|
|
3260
|
-
'file.insert') dispatch cmd_file_insert ;;
|
|
3261
|
-
'file.view') dispatch cmd_file_view ;;
|
|
3262
|
-
'file.search') dispatch cmd_file_search ;;
|
|
3263
|
-
'file.verify.charset') dispatch cmd_file_verify_charset ;;
|
|
3264
|
-
'file.verify.encoding') dispatch cmd_file_verify_encoding ;;
|
|
3265
|
-
'file.verify.completion') dispatch cmd_file_verify_completion ;;
|
|
3266
|
-
'system.brand') dispatch cmd_system_brand ;;
|
|
3267
|
-
'system.cpu') dispatch cmd_system_cpu ;;
|
|
3268
|
-
'system.gpu') dispatch cmd_system_gpu ;;
|
|
3269
|
-
'system.version') dispatch cmd_system_version ;;
|
|
3270
|
-
'system.info') dispatch cmd_system_info ;;
|
|
3271
|
-
'task.list') dispatch cmd_task_list ;;
|
|
3272
|
-
'task.query') dispatch cmd_task_query ;;
|
|
3273
|
-
'task.show') dispatch cmd_task_show ;;
|
|
3274
|
-
'task.kill') dispatch cmd_task_kill ;;
|
|
3275
|
-
'execute.query') dispatch cmd_execute_query ;;
|
|
3276
|
-
'execute.kill') dispatch cmd_execute_kill ;;
|
|
3277
|
-
'environment.list') dispatch cmd_environment_list ;;
|
|
3278
|
-
'environment.get') dispatch cmd_environment_get ;;
|
|
3279
|
-
'environment.add') dispatch cmd_environment_add ;;
|
|
3280
|
-
'environment.set') dispatch cmd_environment_set ;;
|
|
3281
|
-
'environment.delete') dispatch cmd_environment_delete ;;
|
|
3282
|
-
'environment.attribute.list') dispatch cmd_environment_attribute_list ;;
|
|
3283
|
-
'environment.attribute.get') dispatch cmd_environment_attribute_get ;;
|
|
3284
|
-
'environment.attribute.add') dispatch cmd_environment_attribute_add ;;
|
|
3285
|
-
'environment.attribute.set') dispatch cmd_environment_attribute_set ;;
|
|
3286
|
-
'environment.attribute.delete') dispatch cmd_environment_attribute_delete ;;
|
|
3287
|
-
# wait ms is the one verb of the desktop group that needs nothing from a display server, so it is
|
|
3288
|
-
# implemented here. The other window/mouse/keyboard/clipboard/observe verbs are deliberately absent
|
|
3289
|
-
# from this table and fall through to the default arm, which reports a typed E_NOT_SUPPORTED.
|
|
3290
|
-
'wait.ms') dispatch cmd_wait_ms ;;
|
|
3291
|
-
# mouse.* drives X11 through xdotool. Each verb checks for a display and for the tool before it
|
|
3292
|
-
# injects anything, so on a machine without either it reports E_NO_DISPLAY or E_NOT_SUPPORTED rather
|
|
3293
|
-
# than appearing to act.
|
|
3294
|
-
'mouse.click') dispatch cmd_mouse_click ;;
|
|
3295
|
-
'mouse.context') dispatch cmd_mouse_context ;;
|
|
3296
|
-
'mouse.over') dispatch cmd_mouse_over ;;
|
|
3297
|
-
'mouse.wheel') dispatch cmd_mouse_wheel ;;
|
|
3298
|
-
'mouse.down') dispatch cmd_mouse_down ;;
|
|
3299
|
-
'mouse.up') dispatch cmd_mouse_up ;;
|
|
3300
|
-
'mouse.cursor') dispatch cmd_mouse_cursor ;;
|
|
3301
|
-
# keyboard.* drives X11 through xdotool, with the same capability gate as mouse.*. Key names are
|
|
3302
|
-
# translated from the Windows spelling to the X keysym spelling before injection, because 'Return'
|
|
3303
|
-
# and 'Enter' are the same key and a script must not work on one machine and silently do nothing on
|
|
3304
|
-
# another.
|
|
3305
|
-
'keyboard.down') dispatch cmd_keyboard_down ;;
|
|
3306
|
-
'keyboard.up') dispatch cmd_keyboard_up ;;
|
|
3307
|
-
'keyboard.press') dispatch cmd_keyboard_press ;;
|
|
3308
|
-
'keyboard.combination') dispatch cmd_keyboard_combination ;;
|
|
3309
|
-
'keyboard.type') dispatch cmd_keyboard_type ;;
|
|
3310
|
-
'keyboard.codes') dispatch cmd_keyboard_codes ;;
|
|
3311
|
-
'permission.list') dispatch cmd_permission_list ;;
|
|
3312
|
-
'permission.query') dispatch cmd_permission_query ;;
|
|
3313
|
-
'permission.audit') dispatch cmd_permission_audit ;;
|
|
3314
|
-
'permission.grant') dispatch cmd_permission_grant ;;
|
|
3315
|
-
'permission.revoke') dispatch cmd_permission_revoke ;;
|
|
3316
|
-
'permission.acl.init') dispatch cmd_permission_acl_init ;;
|
|
3317
|
-
'execute.command') dispatch cmd_execute_command ;;
|
|
3318
|
-
'execute.synchronous') dispatch cmd_execute_synchronous ;;
|
|
3319
|
-
'execute.asynchronous') dispatch cmd_execute_asynchronous ;;
|
|
3320
|
-
*)
|
|
3321
|
-
# Routed through dispatch like every other verb: calling fail() directly here would exit from
|
|
3322
|
-
# inside main, before the entry point can re-emit the envelope fail() just wrote to the scratch
|
|
3323
|
-
# file. Wrapping it keeps the status in MAIN_STATUS so main returns normally.
|
|
3324
|
-
dispatch cmd_not_implemented
|
|
3325
|
-
;;
|
|
3326
|
-
esac
|
|
3327
|
-
local end_ms
|
|
3328
|
-
end_ms="$(date +%s%3N 2>/dev/null || date +%s000)"
|
|
3329
|
-
ELAPSED_MS=$((end_ms - start_ms))
|
|
3330
|
-
if [ "$MAIN_STATUS" -ne 0 ]; then
|
|
3331
|
-
# A verb failed: its envelope is already in the scratch file, written by fail(). Returning here
|
|
3332
|
-
# without emitting the success envelope is what lets the entry point report the failure.
|
|
3333
|
-
return "$MAIN_STATUS"
|
|
3334
|
-
fi
|
|
3335
|
-
emit_envelope "$CURRENT_VERB" "$RESULT" "" "$ELAPSED_MS"
|
|
3336
|
-
return 0
|
|
3337
|
-
}
|
|
3338
|
-
|
|
3339
|
-
CURRENT_VERB=""
|
|
3340
|
-
ELAPSED_MS=0
|
|
3341
|
-
MAIN_STATUS=0
|
|
3342
|
-
|
|
3343
|
-
# Entry point.
|
|
3344
|
-
#
|
|
3345
|
-
# fail() stashes the failure envelope in a scratch file because the verb bodies run inside $(...)
|
|
3346
|
-
# command substitutions that would swallow anything written to stdout. The file is read back here and
|
|
3347
|
-
# re-emitted on the real stdout, so a caller always receives exactly one parseable envelope whether
|
|
3348
|
-
# the invocation succeeded or failed.
|
|
3349
|
-
#
|
|
3350
|
-
# The scratch path is written by fail() itself from $ROOT_DIR, so the entry point only has to read the
|
|
3351
|
-
# same stable location back. Deriving the path in both places is what makes this robust: the earlier
|
|
3352
|
-
# attempt passed the path through a shell variable, which was empty by the time the check ran.
|
|
3353
|
-
DSH_ERR_FILE="$ROOT_DIR/.temporary/dsh-os.err"
|
|
3354
|
-
mkdir -p "$ROOT_DIR/.temporary" 2>/dev/null || true
|
|
3355
|
-
: >"$DSH_ERR_FILE" 2>/dev/null || true
|
|
3356
|
-
|
|
3357
|
-
set +e
|
|
3358
|
-
main "$@"
|
|
3359
|
-
STATUS=$?
|
|
3360
|
-
set -e
|
|
3361
|
-
|
|
3362
|
-
if [ "$STATUS" -ne 0 ]; then
|
|
3363
|
-
if [ -s "$DSH_ERR_FILE" ]; then
|
|
3364
|
-
cat "$DSH_ERR_FILE"
|
|
3365
|
-
# The exit status is taken from the ENVELOPE's error code, not from the status the process happened
|
|
3366
|
-
# to end with.
|
|
3367
|
-
#
|
|
3368
|
-
# This matters because fail() is reachable from inside a command substitution, and an `exit` there
|
|
3369
|
-
# ends only the subshell: the caller carries on, usually hits a second unrelated failure, and that
|
|
3370
|
-
# second status is what the process would otherwise report. A bad --scope produced an
|
|
3371
|
-
# E_NOT_SUPPORTED envelope (class 4) with exit 5, so the two halves of the same reply disagreed
|
|
3372
|
-
# about the failure class. The envelope is the documented authority, so it decides.
|
|
3373
|
-
envelope_code="$(sed -n 's/.*"error":{"code":"\([A-Z_]*\)".*/\1/p' "$DSH_ERR_FILE" | head -1)"
|
|
3374
|
-
if [ -n "$envelope_code" ]; then
|
|
3375
|
-
STATUS="$(exit_code_for "$envelope_code")"
|
|
3376
|
-
fi
|
|
3377
|
-
else
|
|
3378
|
-
# A failure that produced no envelope would leave the caller with nothing to parse, so one is
|
|
3379
|
-
# synthesised rather than returning a bare non-zero exit.
|
|
3380
|
-
printf '{"ok":false,"verb":%s,"error":{"code":"E_INTERNAL","message":"the command failed without producing an envelope"},"meta":{"platform":%s,"shell":"sh"}}\n' \
|
|
3381
|
-
"$(jstr "${CURRENT_VERB:-}")" "$(jstr "$(platform_name)")"
|
|
3382
|
-
fi
|
|
3383
|
-
fi
|
|
3384
|
-
rm -f "$DSH_ERR_FILE" 2>/dev/null || true
|
|
3385
|
-
exit "$STATUS"
|