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.
@@ -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"