feat: add android-fs5 CLI toolset
- fs5 entrypoint with status, push, pull, app, shell, logs commands - lib/adb.sh: ADB wrapper functions with idempotency and --force guards - lib/output.sh: human + JSON output formatting via Python - 33 bats tests (6 status, 9 transfer, 7 app, 5 shell, 6 logs) — all passing - Zero shellcheck warnings - .env-based config (device serial, log path) - Spec-Driven Development artifacts in specs/001-android-fs5-management/ Device: exone GmbH FS5 (RD51QE202392, Android 9)
This commit is contained in:
@@ -0,0 +1,484 @@
|
||||
#!/usr/bin/env bash
|
||||
# fs5 — Android FS5 device management CLI
|
||||
# Device: exone GmbH FS5 (Android 9)
|
||||
# Usage: ./fs5 <command> [options]
|
||||
set -uo pipefail
|
||||
# Note: -e intentionally omitted; we handle errors explicitly for correct exit codes
|
||||
|
||||
# ── Resolve script directory ──────────────────────────────────────────────────
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# ── Load config from .env (environment variables take precedence) ─────────────
|
||||
ENV_FILE="${SCRIPT_DIR}/.env"
|
||||
if [[ -f "${ENV_FILE}" ]]; then
|
||||
# Load only variables not already set in the environment
|
||||
while IFS='=' read -r key value; do
|
||||
# Skip comments and empty lines
|
||||
[[ "${key}" =~ ^[[:space:]]*# ]] && continue
|
||||
[[ -z "${key}" ]] && continue
|
||||
key="${key// /}"
|
||||
# Only set if not already exported in environment
|
||||
if [[ -z "${!key+x}" ]]; then
|
||||
export "${key}=${value}"
|
||||
fi
|
||||
done < "${ENV_FILE}"
|
||||
fi
|
||||
|
||||
# Defaults (fallback if neither env nor .env provided)
|
||||
DEVICE_SERIAL="${DEVICE_SERIAL:-RD51QE202392}"
|
||||
LOG_FILE="${LOG_FILE:-${SCRIPT_DIR}/fs5.log}"
|
||||
ADB_TIMEOUT="${ADB_TIMEOUT:-10}"
|
||||
|
||||
# ── Load libraries ────────────────────────────────────────────────────────────
|
||||
# shellcheck source=lib/output.sh
|
||||
source "${SCRIPT_DIR}/lib/output.sh"
|
||||
# shellcheck source=lib/adb.sh
|
||||
source "${SCRIPT_DIR}/lib/adb.sh"
|
||||
|
||||
# ── Global flags ──────────────────────────────────────────────────────────────
|
||||
JSON_OUTPUT=false
|
||||
FORCE=false
|
||||
|
||||
# ── Usage ─────────────────────────────────────────────────────────────────────
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: fs5 <command> [options]
|
||||
|
||||
Commands:
|
||||
status Show device health and info
|
||||
push <src> <dst> Push file/dir to device
|
||||
pull <src> <dst> Pull file/dir from device
|
||||
app list List installed apps
|
||||
app install <apk> Install APK on device
|
||||
app uninstall <pkg> Uninstall package from device
|
||||
shell [cmd] Execute shell command on device
|
||||
logs Stream or capture logcat output
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--force Allow destructive/overwrite operations
|
||||
--help Show this help
|
||||
|
||||
Exit Codes:
|
||||
0 Success
|
||||
1 General error
|
||||
2 Device not found
|
||||
|
||||
Device: ${DEVICE_SERIAL}
|
||||
EOF
|
||||
}
|
||||
|
||||
# ── Parse global flags (modifies globals, returns remaining args via array) ───
|
||||
# Usage: parse_global_flags REMAINING_ARRAY_NAME "$@"
|
||||
parse_global_flags() {
|
||||
local -n _remaining_ref="${1}"
|
||||
shift
|
||||
_remaining_ref=()
|
||||
for arg in "$@"; do
|
||||
case "${arg}" in
|
||||
--json) JSON_OUTPUT=true ;;
|
||||
--force) FORCE=true ;;
|
||||
--help|-h) usage; exit 0 ;;
|
||||
*) _remaining_ref+=("${arg}") ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# ── Command: status ───────────────────────────────────────────────────────────
|
||||
cmd_status() {
|
||||
local show_help=false
|
||||
for arg in "$@"; do
|
||||
case "${arg}" in
|
||||
--json) JSON_OUTPUT=true ;;
|
||||
--help) show_help=true ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "${show_help}" == "true" ]]; then
|
||||
cat <<EOF
|
||||
Usage: fs5 status [--json]
|
||||
|
||||
Show device health information including model, Android version,
|
||||
battery level, storage usage, and ADB connection state.
|
||||
EOF
|
||||
return 0
|
||||
fi
|
||||
|
||||
adb_check_device
|
||||
|
||||
local model android_version battery_level battery_charging
|
||||
local storage_total storage_used storage_free storage_pct adb_state ts
|
||||
|
||||
model=$(adb_get_prop "ro.product.model")
|
||||
android_version=$(adb_get_prop "ro.build.version.release")
|
||||
battery_level=$(adb_battery_level)
|
||||
battery_charging=$(adb_battery_charging)
|
||||
adb_state=$(adb -s "${DEVICE_SERIAL}" get-state 2>/dev/null || echo "unknown")
|
||||
ts=$(date -Iseconds)
|
||||
|
||||
# Storage: total used free pct
|
||||
read -r storage_total storage_used storage_free storage_pct \
|
||||
<<< "$(adb_storage_info)"
|
||||
|
||||
if [[ "${JSON_OUTPUT}" == "true" ]]; then
|
||||
out_json \
|
||||
"serial=${DEVICE_SERIAL}" \
|
||||
"model=${model}" \
|
||||
"manufacturer=exone GmbH" \
|
||||
"android_version=${android_version}" \
|
||||
"battery_level=${battery_level}" \
|
||||
"battery_charging=${battery_charging}" \
|
||||
"storage_total_kb=${storage_total}" \
|
||||
"storage_used_kb=${storage_used}" \
|
||||
"storage_free_kb=${storage_free}" \
|
||||
"storage_percent_used=${storage_pct}" \
|
||||
"adb_state=${adb_state}" \
|
||||
"timestamp=${ts}"
|
||||
else
|
||||
echo "Device: exone GmbH ${model}"
|
||||
echo "Serial: ${DEVICE_SERIAL}"
|
||||
echo "Android: ${android_version}"
|
||||
echo "ADB State: ${adb_state}"
|
||||
echo "Battery: ${battery_level}% (charging: ${battery_charging})"
|
||||
echo "Storage: ${storage_used}K used / ${storage_total}K total (${storage_pct}%)"
|
||||
echo "Timestamp: ${ts}"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Command: push ─────────────────────────────────────────────────────────────
|
||||
cmd_push() {
|
||||
local src="" dst="" show_help=false force_flag=""
|
||||
|
||||
for arg in "$@"; do
|
||||
case "${arg}" in
|
||||
--help) show_help=true ;;
|
||||
--force) force_flag="--force" ;;
|
||||
*)
|
||||
if [[ -z "${src}" ]]; then src="${arg}"
|
||||
elif [[ -z "${dst}" ]]; then dst="${arg}"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "${FORCE}" == "true" ]]; then force_flag="--force"; fi
|
||||
|
||||
if [[ "${show_help}" == "true" ]]; then
|
||||
cat <<EOF
|
||||
Usage: fs5 push <local-path> <device-path> [--force]
|
||||
|
||||
Push a local file or directory to the device.
|
||||
Use --force to overwrite existing files on the device.
|
||||
EOF
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -z "${src}" ]] || [[ -z "${dst}" ]]; then
|
||||
out_error "Usage: fs5 push <src> <dst>"
|
||||
return 1
|
||||
fi
|
||||
|
||||
adb_check_device
|
||||
adb_push "${src}" "${dst}" "${force_flag}"
|
||||
}
|
||||
|
||||
# ── Command: pull ─────────────────────────────────────────────────────────────
|
||||
cmd_pull() {
|
||||
local src="" dst="" show_help=false force_flag=""
|
||||
|
||||
for arg in "$@"; do
|
||||
case "${arg}" in
|
||||
--help) show_help=true ;;
|
||||
--force) force_flag="--force" ;;
|
||||
*)
|
||||
if [[ -z "${src}" ]]; then src="${arg}"
|
||||
elif [[ -z "${dst}" ]]; then dst="${arg}"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "${FORCE}" == "true" ]]; then force_flag="--force"; fi
|
||||
|
||||
if [[ "${show_help}" == "true" ]]; then
|
||||
cat <<EOF
|
||||
Usage: fs5 pull <device-path> <local-path> [--force]
|
||||
|
||||
Pull a file or directory from the device to local filesystem.
|
||||
Use --force to overwrite existing local files.
|
||||
EOF
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -z "${src}" ]] || [[ -z "${dst}" ]]; then
|
||||
out_error "Usage: fs5 pull <src> <dst>"
|
||||
return 1
|
||||
fi
|
||||
|
||||
adb_check_device
|
||||
adb_pull "${src}" "${dst}" "${force_flag}"
|
||||
}
|
||||
|
||||
# ── Command: app ──────────────────────────────────────────────────────────────
|
||||
cmd_app() {
|
||||
local subcmd="${1:-}"
|
||||
shift || true
|
||||
|
||||
case "${subcmd}" in
|
||||
list) cmd_app_list "$@" ;;
|
||||
install) cmd_app_install "$@" ;;
|
||||
uninstall) cmd_app_uninstall "$@" ;;
|
||||
--help|-h)
|
||||
cat <<EOF
|
||||
Usage: fs5 app <subcommand> [options]
|
||||
|
||||
Subcommands:
|
||||
list [--all] [--json] List installed apps
|
||||
install <apk> Install APK
|
||||
uninstall <package> --force Uninstall package
|
||||
EOF
|
||||
;;
|
||||
*)
|
||||
out_error "Unknown app subcommand: '${subcmd}'. Use: list, install, uninstall"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
cmd_app_list() {
|
||||
local show_all=false show_help=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case "${arg}" in
|
||||
--all) show_all=true ;;
|
||||
--help) show_help=true ;;
|
||||
--json) JSON_OUTPUT=true ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "${show_help}" == "true" ]]; then
|
||||
echo "Usage: fs5 app list [--all] [--json]"
|
||||
return 0
|
||||
fi
|
||||
|
||||
adb_check_device
|
||||
|
||||
local pm_args="-3"
|
||||
[[ "${show_all}" == "true" ]] && pm_args=""
|
||||
|
||||
local raw_packages
|
||||
# shellcheck disable=SC2086
|
||||
raw_packages=$(adb_cmd shell pm list packages ${pm_args} 2>/dev/null | tr -d '\r')
|
||||
|
||||
if [[ "${JSON_OUTPUT}" == "true" ]]; then
|
||||
echo "${raw_packages}" | python3 -c "
|
||||
import sys, json
|
||||
packages = []
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if line.startswith('package:'):
|
||||
packages.append({'package': line[8:]})
|
||||
print(json.dumps(packages, indent=2))
|
||||
"
|
||||
else
|
||||
echo "${raw_packages}"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_app_install() {
|
||||
local apk="" show_help=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case "${arg}" in
|
||||
--help) show_help=true ;;
|
||||
*) [[ -z "${apk}" ]] && apk="${arg}" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "${show_help}" == "true" ]]; then
|
||||
echo "Usage: fs5 app install <apk-path>"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -z "${apk}" ]]; then
|
||||
out_error "Usage: fs5 app install <apk-path>"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "${apk}" ]]; then
|
||||
out_error "APK not found: ${apk}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
adb_check_device
|
||||
|
||||
local result
|
||||
result=$(adb_cmd install "${apk}" 2>&1)
|
||||
if echo "${result}" | grep -q "Success"; then
|
||||
adb_log_op "INSTALL" "${apk}"
|
||||
out_success "Installed: ${apk}"
|
||||
else
|
||||
out_error "Install failed: ${result}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_app_uninstall() {
|
||||
local pkg="" show_help=false force_flag=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case "${arg}" in
|
||||
--help) show_help=true ;;
|
||||
--force) force_flag=true ;;
|
||||
*) [[ -z "${pkg}" ]] && pkg="${arg}" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "${FORCE}" == "true" ]]; then force_flag=true; fi
|
||||
|
||||
if [[ "${show_help}" == "true" ]]; then
|
||||
echo "Usage: fs5 app uninstall <package> --force"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -z "${pkg}" ]]; then
|
||||
out_error "Usage: fs5 app uninstall <package> --force"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ "${force_flag}" != "true" ]]; then
|
||||
out_error "Uninstall requires --force flag to prevent accidental removal"
|
||||
return 1
|
||||
fi
|
||||
|
||||
adb_check_device
|
||||
|
||||
local result
|
||||
result=$(adb_cmd shell pm uninstall "${pkg}" 2>&1 | tr -d '\r')
|
||||
if [[ "${result}" == "Success" ]]; then
|
||||
adb_log_op "UNINSTALL" "${pkg}"
|
||||
out_success "Uninstalled: ${pkg}"
|
||||
else
|
||||
out_error "Uninstall failed (package may not be installed): ${pkg}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Command: shell ────────────────────────────────────────────────────────────
|
||||
cmd_shell() {
|
||||
local show_help=false
|
||||
local -a cmd_args=()
|
||||
|
||||
for arg in "$@"; do
|
||||
case "${arg}" in
|
||||
--help) show_help=true ;;
|
||||
*) cmd_args+=("${arg}") ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "${show_help}" == "true" ]]; then
|
||||
cat <<EOF
|
||||
Usage: fs5 shell [command]
|
||||
|
||||
Execute a shell command on the device. If no command is given,
|
||||
opens an interactive shell session.
|
||||
EOF
|
||||
return 0
|
||||
fi
|
||||
|
||||
adb_check_device
|
||||
|
||||
if [[ ${#cmd_args[@]} -eq 0 ]]; then
|
||||
adb_cmd shell
|
||||
else
|
||||
adb_cmd shell "${cmd_args[@]}"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Command: logs ─────────────────────────────────────────────────────────────
|
||||
cmd_logs() {
|
||||
local lines="" filter="" output_file="" show_help=false
|
||||
|
||||
local i=0
|
||||
local -a args=("$@")
|
||||
while [[ ${i} -lt ${#args[@]} ]]; do
|
||||
case "${args[${i}]}" in
|
||||
--help) show_help=true ;;
|
||||
--lines) i=$(( i + 1 )); lines="${args[${i}]}" ;;
|
||||
--filter) i=$(( i + 1 )); filter="${args[${i}]}" ;;
|
||||
--output) i=$(( i + 1 )); output_file="${args[${i}]}" ;;
|
||||
esac
|
||||
i=$(( i + 1 ))
|
||||
done
|
||||
|
||||
if [[ "${show_help}" == "true" ]]; then
|
||||
cat <<EOF
|
||||
Usage: fs5 logs [--lines N] [--filter TAG] [--output FILE]
|
||||
|
||||
Stream or capture Android logcat output.
|
||||
|
||||
Options:
|
||||
--lines N Print last N lines and exit (non-streaming)
|
||||
--filter TAG Filter output by log tag
|
||||
--output FILE Write output to file instead of stdout
|
||||
EOF
|
||||
return 0
|
||||
fi
|
||||
|
||||
adb_check_device
|
||||
|
||||
local -a logcat_args=()
|
||||
|
||||
if [[ -n "${lines}" ]]; then
|
||||
logcat_args+=("-d" "-t" "${lines}")
|
||||
fi
|
||||
|
||||
if [[ -n "${filter}" ]]; then
|
||||
logcat_args+=("${filter}:V" "*:S")
|
||||
fi
|
||||
|
||||
if [[ -n "${output_file}" ]]; then
|
||||
adb_cmd logcat "${logcat_args[@]+"${logcat_args[@]}"}" > "${output_file}"
|
||||
out_success "Logs written to: ${output_file}"
|
||||
else
|
||||
adb_cmd logcat "${logcat_args[@]+"${logcat_args[@]}"}"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Main dispatcher ───────────────────────────────────────────────────────────
|
||||
main() {
|
||||
if [[ $# -eq 0 ]]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract command (first arg)
|
||||
local command="${1}"
|
||||
shift
|
||||
|
||||
# Handle global --help before command dispatch
|
||||
if [[ "${command}" == "--help" ]] || [[ "${command}" == "-h" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Parse remaining args for global flags; remaining_args gets non-flag args
|
||||
local -a remaining_args=()
|
||||
parse_global_flags remaining_args "$@"
|
||||
|
||||
case "${command}" in
|
||||
status) cmd_status "${remaining_args[@]+"${remaining_args[@]}"}" ;;
|
||||
push) cmd_push "${remaining_args[@]+"${remaining_args[@]}"}" ;;
|
||||
pull) cmd_pull "${remaining_args[@]+"${remaining_args[@]}"}" ;;
|
||||
app) cmd_app "${remaining_args[@]+"${remaining_args[@]}"}" ;;
|
||||
shell) cmd_shell "${remaining_args[@]+"${remaining_args[@]}"}" ;;
|
||||
logs) cmd_logs "${remaining_args[@]+"${remaining_args[@]}"}" ;;
|
||||
*)
|
||||
out_error "Unknown command: ${command}"
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user