commit e6e5f344550152567a90d8b3f1723e2499948dfe Author: Jane Alesi Date: Mon Mar 2 15:55:09 2026 +0100 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) diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e8f6e37 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# android-fs5 configuration +# Copy to .env and adjust values + +# ADB device serial number (get with: adb devices) +DEVICE_SERIAL=RD51QE202392 + +# Log file for mutating operations +LOG_FILE=./fs5.log + +# Default push destination on device +DEFAULT_PUSH_PATH=/sdcard/ + +# ADB connection timeout in seconds +ADB_TIMEOUT=10 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b3d9bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +fs5.log +*.log diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md new file mode 100644 index 0000000..a1d7a43 --- /dev/null +++ b/.specify/memory/constitution.md @@ -0,0 +1,99 @@ +# Project Constitution: android-fs5 + +**Version**: 1.0.0 +**Created**: 2026-03-02 +**Status**: Active + +## Preamble + +This constitution governs all development decisions for the android-fs5 project — +a CLI toolset for managing the exone GmbH FS5 Android 9 device connected via USB/ADB. +These principles are NON-NEGOTIABLE and apply to every specification, plan, and task. + +--- + +## Article I: Shell-First Principle + +**MUST** implement all device interactions as composable shell scripts or Python CLI tools. +**MUST NOT** build GUI applications or web interfaces unless explicitly requested. + +**Rationale**: Infrastructure tooling must be scriptable, automatable, and CI/CD-friendly. + +--- + +## Article II: ADB as the Single Interface + +**MUST** use ADB (Android Debug Bridge) as the exclusive communication layer with the device. +**MUST NOT** require rooted device access for core functionality. +**SHOULD** gracefully degrade when optional ADB features are unavailable. + +**Rationale**: ADB is the standard, stable, and non-destructive interface for Android devices. + +--- + +## Article III: Test-First Imperative (NON-NEGOTIABLE) + +**MUST** write tests before implementation code. +**MUST** validate tests FAIL before writing implementation. +**MUST** validate tests PASS after implementation. +**MUST NOT** merge implementation without passing tests. + +**Rationale**: Device management tools can cause data loss; correctness is paramount. + +--- + +## Article IV: Idempotency + +**MUST** design all operations to be safely re-runnable without side effects. +**MUST** check state before mutating (e.g., file exists before push, app installed before install). + +**Rationale**: Scripts run in automation pipelines must not fail on second execution. + +--- + +## Article V: Non-Destructive by Default + +**MUST** default to read-only/dry-run mode for any destructive operation. +**MUST** require explicit `--force` or `--confirm` flags for delete/wipe operations. +**MUST** log all write operations with timestamps. + +**Rationale**: Prevent accidental data loss on the connected device. + +--- + +## Article VI: Device Identity Awareness + +**MUST** target device by serial number (`RD51QE202392`) to prevent wrong-device operations. +**MUST** validate device connectivity before any operation. +**SHOULD** support multiple device profiles via configuration. + +**Rationale**: ADB can connect to multiple devices; explicit targeting prevents mistakes. + +--- + +## Article VII: Simplicity Gate + +**MUST** use ≤3 top-level modules/scripts for the initial implementation. +**MUST NOT** add abstraction layers without documented justification. +**MUST NOT** future-proof beyond current requirements. + +**Rationale**: Infrastructure tools rot when over-engineered. + +--- + +## Article VIII: Structured Output + +**MUST** support `--json` output flag on all commands for machine-readable results. +**MUST** use exit codes correctly (0=success, 1=error, 2=device-not-found). +**SHOULD** provide human-readable output by default. + +**Rationale**: Tools must integrate with monitoring, logging, and automation pipelines. + +--- + +## Article IX: Configuration over Hardcoding + +**MUST** store device serial, paths, and settings in a `.env` or `config.yaml` file. +**MUST NOT** hardcode device serials or paths in script logic. + +**Rationale**: Enables reuse across different devices and environments. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c875f0d --- /dev/null +++ b/README.md @@ -0,0 +1,138 @@ +# android-fs5 + +CLI toolset for managing the **exone GmbH FS5** Android 9 device via ADB. + +## Device + +| Property | Value | +|----------|-------| +| Model | exone GmbH FS5 | +| Serial | RD51QE202392 | +| Android | 9 | +| Storage | ~52 GB | + +## Requirements + +| Tool | Version | Install | +|------|---------|---------| +| adb | 1.0.41+ | `sudo pacman -S android-tools` | +| bash | 5.x | pre-installed | +| python3 | 3.x | pre-installed | + +## Setup + +```bash +cp .env.example .env +# Edit .env if your device serial differs +chmod +x fs5 +``` + +## Usage + +```text +Usage: fs5 [options] + +Commands: + status Show device health and info + push Push file/dir to device + pull Pull file/dir from device + app list List installed apps + app install Install APK on device + app uninstall Uninstall package (requires --force) + 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 help +``` + +## Examples + +```bash +# Device health check +./fs5 status +./fs5 status --json | python3 -m json.tool + +# File transfer +./fs5 push ./report.pdf /sdcard/Documents/report.pdf +./fs5 pull /sdcard/DCIM/photo.jpg ./photo.jpg +./fs5 push ./file.txt /sdcard/file.txt --force # overwrite + +# App management +./fs5 app list +./fs5 app list --all --json +./fs5 app install ./myapp.apk +./fs5 app uninstall com.example.app --force + +# Shell access +./fs5 shell "df -h" +./fs5 shell "pm list packages | grep google" + +# Log capture +./fs5 logs --lines 50 +./fs5 logs --filter ActivityManager +./fs5 logs --lines 100 --output /tmp/device.log +``` + +## Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | Success | +| 1 | General error | +| 2 | Device not found | + +## Configuration (`.env`) + +```bash +DEVICE_SERIAL=RD51QE202392 # ADB device serial +LOG_FILE=./fs5.log # Operation log path +DEFAULT_PUSH_PATH=/sdcard/ # Default push destination +ADB_TIMEOUT=10 # ADB timeout in seconds +``` + +## Testing + +```bash +# Install test dependencies (once) +sudo pacman -S bats bats-assert bats-support shellcheck + +# Run all tests +bats tests/ + +# Run specific test file +bats tests/test_status.bats + +# Lint +shellcheck fs5 lib/adb.sh lib/output.sh +``` + +## Operation Log + +All mutating operations (push, pull, install, uninstall) are logged to `fs5.log`: + +```text +2026-03-02T15:43:00+01:00 PUSH /home/ja/file.txt -> /sdcard/file.txt [OK] +2026-03-02T15:44:00+01:00 INSTALL /tmp/app.apk [OK] +``` + +## Project Structure + +```text +android-fs5/ +├── fs5 # Main CLI entrypoint +├── lib/ +│ ├── adb.sh # ADB wrapper functions +│ └── output.sh # Output formatting (human + JSON) +├── tests/ +│ ├── test_status.bats +│ ├── test_transfer.bats +│ ├── test_app.bats +│ ├── test_shell.bats +│ └── test_logs.bats +├── specs/ # Spec-Driven Development artifacts +├── .env.example # Config template +└── README.md +``` diff --git a/fs5 b/fs5 new file mode 100755 index 0000000..21a23c8 --- /dev/null +++ b/fs5 @@ -0,0 +1,484 @@ +#!/usr/bin/env bash +# fs5 — Android FS5 device management CLI +# Device: exone GmbH FS5 (Android 9) +# Usage: ./fs5 [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 < [options] + +Commands: + status Show device health and info + push Push file/dir to device + pull Pull file/dir from device + app list List installed apps + app install Install APK on device + app uninstall 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 </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 < [--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 " + 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 < [--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 " + 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 < [options] + +Subcommands: + list [--all] [--json] List installed apps + install Install APK + uninstall --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 " + return 0 + fi + + if [[ -z "${apk}" ]]; then + out_error "Usage: fs5 app install " + 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 --force" + return 0 + fi + + if [[ -z "${pkg}" ]]; then + out_error "Usage: fs5 app uninstall --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 < "${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 "$@" diff --git a/lib/adb.sh b/lib/adb.sh new file mode 100644 index 0000000..32f3c4e --- /dev/null +++ b/lib/adb.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# lib/adb.sh — ADB wrapper functions for android-fs5 +# shellcheck shell=bash + +# adb_check_device — validate device is connected; exit 2 if not +adb_check_device() { + local state + state=$(adb -s "${DEVICE_SERIAL}" get-state 2>/dev/null || echo "offline") + if [[ "${state}" != "device" ]]; then + out_error "Device not found: ${DEVICE_SERIAL}" + exit 2 + fi +} + +# adb_cmd ARGS... — run adb with the configured device serial +adb_cmd() { + adb -s "${DEVICE_SERIAL}" "$@" +} + +# adb_get_prop KEY — read a device system property +adb_get_prop() { + local key="${1}" + adb_cmd shell getprop "${key}" 2>/dev/null | tr -d '\r' +} + +# adb_shell CMD — execute a shell command on the device +adb_shell() { + adb_cmd shell "$@" 2>&1 +} + +# adb_file_exists DEVICE_PATH — return 0 if file exists on device, 1 otherwise +adb_file_exists() { + local path="${1}" + local result + result=$(adb_cmd shell "[ -e '${path}' ] && echo yes || echo no" 2>/dev/null | tr -d '\r') + [[ "${result}" == "yes" ]] +} + +# adb_push SRC DST [--force] — push local file to device +# Skips if destination exists unless --force is set +adb_push() { + local src="${1}" + local dst="${2}" + local force="${3:-}" + + if [[ ! -e "${src}" ]]; then + out_error "Source not found: ${src}" + return 1 + fi + + if adb_file_exists "${dst}" && [[ "${force}" != "--force" ]]; then + out_warn "File exists on device, use --force to overwrite: ${dst}" + return 0 + fi + + adb_cmd push "${src}" "${dst}" + local exit_code=$? + if [[ ${exit_code} -eq 0 ]]; then + adb_log_op "PUSH" "${src} -> ${dst}" + out_success "Pushed: ${src} → ${dst}" + else + out_error "Push failed: ${src} → ${dst}" + return 1 + fi +} + +# adb_pull SRC DST [--force] — pull file from device to local +# Skips if local destination exists unless --force is set +adb_pull() { + local src="${1}" + local dst="${2}" + local force="${3:-}" + + if ! adb_file_exists "${src}"; then + out_error "Source not found on device: ${src}" + return 1 + fi + + if [[ -e "${dst}" ]] && [[ "${force}" != "--force" ]]; then + out_warn "Local file exists, use --force to overwrite: ${dst}" + return 0 + fi + + adb_cmd pull "${src}" "${dst}" + local exit_code=$? + if [[ ${exit_code} -eq 0 ]]; then + adb_log_op "PULL" "${src} -> ${dst}" + out_success "Pulled: ${src} → ${dst}" + else + out_error "Pull failed: ${src} → ${dst}" + return 1 + fi +} + +# adb_log_op OPERATION DETAIL — append timestamped entry to LOG_FILE +adb_log_op() { + local op="${1}" + local detail="${2}" + local ts + ts=$(date -Iseconds) + echo "${ts} ${op} ${detail} [OK]" >> "${LOG_FILE:-./fs5.log}" +} + +# adb_battery_level — return battery percentage as integer +adb_battery_level() { + adb_cmd shell dumpsys battery 2>/dev/null \ + | grep -E '^\s+level:' \ + | awk '{print $2}' \ + | tr -d '\r' +} + +# adb_battery_charging — return true/false string +adb_battery_charging() { + local status + status=$(adb_cmd shell dumpsys battery 2>/dev/null \ + | grep -E '^\s+status:' \ + | awk '{print $2}' \ + | tr -d '\r') + # status 2 = charging + if [[ "${status}" == "2" ]]; then + echo "true" + else + echo "false" + fi +} + +# adb_storage_info — echo space-separated: total_kb used_kb free_kb percent_used +adb_storage_info() { + adb_cmd shell df /sdcard 2>/dev/null \ + | tail -1 \ + | awk '{print $2, $3, $4, $5}' \ + | tr -d '\r%' +} diff --git a/lib/output.sh b/lib/output.sh new file mode 100644 index 0000000..3d451ff --- /dev/null +++ b/lib/output.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# lib/output.sh — Human and JSON output formatting for android-fs5 +# shellcheck shell=bash + +# ANSI colors (disabled when not a terminal) +if [[ -t 1 ]]; then + _RED='\033[0;31m' + _GREEN='\033[0;32m' + _YELLOW='\033[1;33m' + _CYAN='\033[0;36m' + _RESET='\033[0m' +else + _RED='' _GREEN='' _YELLOW='' _CYAN='' _RESET='' +fi + +# out_human MSG — print informational message to stdout +out_human() { + echo -e "${_CYAN}${1}${_RESET}" +} + +# out_success MSG — print success message with checkmark +out_success() { + echo -e "${_GREEN}✓ ${1}${_RESET}" +} + +# out_warn MSG — print warning to stdout +out_warn() { + echo -e "${_YELLOW}⚠ ${1}${_RESET}" +} + +# out_error MSG — print error message to stderr +out_error() { + echo -e "${_RED}✗ ${1}${_RESET}" >&2 +} + +# out_json KEY=VALUE ... — emit a JSON object from key=value pairs +# Usage: out_json serial="RD51QE202392" model="FS5" battery_level=85 +out_json() { + local -a pairs=("$@") + python3 - "${pairs[@]}" <<'PYEOF' +import sys, json + +data = {} +for arg in sys.argv[1:]: + if '=' in arg: + key, _, val = arg.partition('=') + # Attempt numeric conversion + try: + if '.' in val: + data[key] = float(val) + else: + data[key] = int(val) + except ValueError: + # Boolean strings + if val.lower() == 'true': + data[key] = True + elif val.lower() == 'false': + data[key] = False + else: + data[key] = val + +print(json.dumps(data, indent=2)) +PYEOF +} diff --git a/specs/001-android-fs5-management/plan.md b/specs/001-android-fs5-management/plan.md new file mode 100644 index 0000000..4838cd7 --- /dev/null +++ b/specs/001-android-fs5-management/plan.md @@ -0,0 +1,204 @@ +# Implementation Plan: Android FS5 Device Management + +**Feature**: 001-android-fs5-management +**Created**: 2026-03-02 +**Tech Stack**: Bash 5, ADB 1.0.41, Python 3 (for JSON output), ShellCheck + +--- + +## Constitution Check + +- [x] **Article I (Shell-First)**: Implemented as Bash CLI with Python for JSON formatting. +- [x] **Article II (ADB Interface)**: All device ops go through `adb -s $DEVICE_SERIAL`. +- [x] **Article III (Test-First)**: Tests written in `tests/` using bats-core before implementation. +- [x] **Article IV (Idempotency)**: All operations check state before mutating. +- [x] **Article V (Non-Destructive)**: Destructive ops require `--force`; all writes logged. +- [x] **Article VI (Device Identity)**: Serial loaded from `.env`, validated on every run. +- [x] **Article VII (Simplicity)**: 3 modules: `fs5` (entrypoint), `lib/adb.sh` (ADB helpers), `lib/output.sh` (formatting). +- [x] **Article VIII (Structured Output)**: `--json` flag on all commands via Python `json` module. +- [x] **Article IX (Config over Hardcoding)**: `.env` file with `DEVICE_SERIAL`, `LOG_FILE`, `DEFAULT_PUSH_PATH`. + +--- + +## Architecture + +```text +android-fs5/ +├── fs5 # Main entrypoint (Bash, chmod +x) +├── lib/ +│ ├── adb.sh # ADB wrapper functions +│ └── output.sh # Human/JSON output formatting +├── tests/ +│ ├── test_status.bats # bats-core tests for status command +│ ├── test_transfer.bats # bats-core tests for push/pull +│ ├── test_app.bats # bats-core tests for app management +│ ├── test_shell.bats # bats-core tests for shell command +│ └── test_logs.bats # bats-core tests for log capture +├── .env.example # Template config (committed) +├── .env # Local config (gitignored) +├── .gitignore +├── README.md +└── fs5.log # Operation log (gitignored) +``` + +--- + +## Module Design + +### `fs5` — Main Entrypoint + +```text +Usage: ./fs5 [options] + +Commands: + status Show device health and info + push Push file/dir to device + pull Pull file/dir from device + app list List installed apps + app install Install APK + app uninstall Uninstall package + shell [cmd] Execute shell command on device + logs Stream logcat output + +Global Options: + --json Output as JSON + --force Allow destructive operations + --help Show help +``` + +### `lib/adb.sh` — ADB Helpers + +Key functions: +- `adb_check_device()` — validates device is connected, exits 2 if not +- `adb_cmd()` — wraps `adb -s $DEVICE_SERIAL` with error handling +- `adb_get_prop(key)` — reads device property +- `adb_shell(cmd)` — executes shell command on device +- `adb_push(src, dst)` — pushes file with existence check +- `adb_pull(src, dst)` — pulls file +- `adb_log_op(op, detail)` — appends timestamped entry to `fs5.log` + +### `lib/output.sh` — Output Formatting + +Key functions: +- `out_human(msg)` — prints to stdout +- `out_json(data)` — formats key=value pairs as JSON via Python +- `out_error(msg)` — prints to stderr with red color +- `out_success(msg)` — prints with green checkmark + +--- + +## Data Model + +### Config (`.env`) + +```bash +DEVICE_SERIAL=RD51QE202392 +LOG_FILE=./fs5.log +DEFAULT_PUSH_PATH=/sdcard/ +ADB_TIMEOUT=10 +``` + +### Device Status Object (JSON) + +```json +{ + "serial": "RD51QE202392", + "model": "FS5", + "manufacturer": "exone GmbH", + "android_version": "9", + "battery_level": 85, + "battery_charging": true, + "storage_total_kb": 54302616, + "storage_used_kb": 18228852, + "storage_free_kb": 35926308, + "storage_percent_used": 34, + "adb_state": "device", + "timestamp": "2026-03-02T15:43:00+01:00" +} +``` + +### Log Entry Format + +```text +2026-03-02T15:43:00+01:00 PUSH /home/ja/file.txt -> /sdcard/file.txt [OK] +2026-03-02T15:44:00+01:00 INSTALL /tmp/app.apk -> com.example.app [OK] +``` + +--- + +## Technical Decisions + +### Why Bash over Python? + +- ADB is a CLI tool — Bash is the natural glue language. +- No dependency installation required on the host. +- Python used only for JSON serialization (available on all Manjaro systems). + +### Why bats-core for testing? + +- Purpose-built for Bash script testing. +- Supports mocking ADB calls for offline testing. +- Integrates with CI/CD pipelines. + +### JSON Output Strategy + +Use Python one-liner to convert shell variables to JSON: + +```bash +python3 -c "import json,sys; d={...}; print(json.dumps(d, indent=2))" +``` + +This avoids `jq` as a hard dependency while producing valid JSON. + +### Exit Code Strategy + +| Code | Meaning | +|------|---------| +| 0 | Success | +| 1 | General error (bad args, operation failed) | +| 2 | Device not found / ADB error | + +--- + +## Dependencies + +| Tool | Version | Required | Install | +|------|---------|----------|---------| +| adb | 1.0.41+ | YES | `sudo pacman -S android-tools` | +| bash | 5.x | YES | Pre-installed | +| python3 | 3.x | YES | Pre-installed | +| bats-core | 1.x | DEV | `sudo pacman -S bash-bats` | +| shellcheck | 0.9+ | DEV | `sudo pacman -S shellcheck` | + +--- + +## Validation Scenarios (Quickstart) + +```bash +# 1. Verify device connected +./fs5 status + +# 2. Test JSON output +./fs5 status --json | python3 -m json.tool + +# 3. Test file transfer +echo "test" > /tmp/fs5-test.txt +./fs5 push /tmp/fs5-test.txt /sdcard/fs5-test.txt +./fs5 pull /sdcard/fs5-test.txt /tmp/fs5-pulled.txt +diff /tmp/fs5-test.txt /tmp/fs5-pulled.txt && echo "PASS" + +# 4. Test idempotency +./fs5 push /tmp/fs5-test.txt /sdcard/fs5-test.txt # Should warn, not fail + +# 5. Test app listing +./fs5 app list + +# 6. Test shell passthrough +./fs5 shell "echo hello from device" + +# 7. Test log capture +./fs5 logs --lines 5 + +# 8. Test disconnected behavior (unplug device first) +./fs5 status; echo "Exit code: $?" # Should be 2 +``` diff --git a/specs/001-android-fs5-management/spec.md b/specs/001-android-fs5-management/spec.md new file mode 100644 index 0000000..f963ac5 --- /dev/null +++ b/specs/001-android-fs5-management/spec.md @@ -0,0 +1,173 @@ +# Feature Specification: Android FS5 Device Management + +**Feature Branch**: `001-android-fs5-management` +**Created**: 2026-03-02 +**Status**: Draft +**Device**: exone GmbH FS5 (Serial: RD51QE202392, Android 9) + +--- + +## Overview + +A CLI toolset for managing the exone GmbH FS5 Android device connected via USB/ADB. +Enables infrastructure operators to inspect device state, transfer files, manage apps, +and automate device operations from the command line on Manjaro Linux. + +--- + +## User Scenarios & Testing + +### User Story 1 — Device Status & Health Check (Priority: P1) + +**Why this priority**: Operators need to verify device connectivity and health before +any operation. This is the entry point for all other workflows. + +**Independent Test**: Run `./fs5 status` with device connected → shows device info. +Run with device disconnected → exits with code 2 and clear error message. + +**Acceptance Scenarios**: + +1. **Given** the FS5 is connected via USB, **When** I run `./fs5 status`, + **Then** I see device model, Android version, serial, battery level, storage usage, + and ADB connection state. + +2. **Given** no device is connected, **When** I run `./fs5 status`, + **Then** the tool exits with code 2 and prints "Device not found: RD51QE202392". + +3. **Given** the FS5 is connected, **When** I run `./fs5 status --json`, + **Then** I receive a valid JSON object with all device properties. + +--- + +### User Story 2 — File Transfer (Push/Pull) (Priority: P1) + +**Why this priority**: File transfer is the most common daily operation for device management. + +**Independent Test**: Push a test file to `/sdcard/test/` → verify it exists on device. +Pull it back → verify local copy matches original. + +**Acceptance Scenarios**: + +1. **Given** a local file exists, **When** I run `./fs5 push `, + **Then** the file is transferred to the device and a success message with file size is shown. + +2. **Given** a file exists on the device, **When** I run `./fs5 pull `, + **Then** the file is downloaded locally and a success message with file size is shown. + +3. **Given** the destination already contains the file, **When** I run `./fs5 push` without `--force`, + **Then** the operation is skipped with a warning "File exists, use --force to overwrite". + +4. **Given** a directory path, **When** I run `./fs5 push / /`, + **Then** all files in the directory are transferred recursively. + +--- + +### User Story 3 — App Management (Priority: P2) + +**Why this priority**: Installing, listing, and removing APKs is a frequent maintenance task. + +**Independent Test**: List installed packages → verify output contains known system apps. + +**Acceptance Scenarios**: + +1. **Given** an APK file, **When** I run `./fs5 app install `, + **Then** the app is installed and the package name is confirmed in output. + +2. **Given** an installed package, **When** I run `./fs5 app uninstall `, + **Then** the app is removed and exit code is 0. + +3. **When** I run `./fs5 app list`, + **Then** I see all installed non-system packages with their version codes. + +4. **When** I run `./fs5 app list --all`, + **Then** I see all packages including system apps. + +--- + +### User Story 4 — Shell Command Execution (Priority: P2) + +**Why this priority**: Enables ad-hoc device inspection and scripted automation. + +**Independent Test**: Run `./fs5 shell "echo hello"` → output is "hello". + +**Acceptance Scenarios**: + +1. **Given** a shell command, **When** I run `./fs5 shell ""`, + **Then** the command executes on the device and stdout/stderr are forwarded. + +2. **Given** a multi-line script, **When** I pipe it via `cat script.sh | ./fs5 shell`, + **Then** the script executes on the device. + +--- + +### User Story 5 — Log Capture (Priority: P3) + +**Why this priority**: Debugging device issues requires access to Android logcat. + +**Independent Test**: Run `./fs5 logs --lines 10` → outputs exactly 10 log lines. + +**Acceptance Scenarios**: + +1. **When** I run `./fs5 logs`, + **Then** logcat output streams to stdout until Ctrl+C. + +2. **When** I run `./fs5 logs --lines `, + **Then** the last N log lines are printed and the command exits. + +3. **When** I run `./fs5 logs --filter `, + **Then** only log lines matching the tag are shown. + +4. **When** I run `./fs5 logs --output `, + **Then** logs are written to the specified file. + +--- + +## Requirements + +### Functional Requirements + +- **FR-001**: System MUST detect and validate device connectivity before every operation. +- **FR-002**: System MUST support `--json` output on all commands. +- **FR-003**: System MUST use device serial `RD51QE202392` from configuration, not hardcoded. +- **FR-004**: System MUST log all write/mutating operations to a local log file with timestamps. +- **FR-005**: System MUST require `--force` flag for any destructive operation. +- **FR-006**: System MUST exit with code 2 when device is not found. +- **FR-007**: System MUST provide `--help` on all commands and subcommands. +- **FR-008**: System MUST be idempotent — re-running any command must not cause errors. + +### Non-Functional Requirements + +- **NFR-001**: Device status check MUST complete in < 3 seconds. +- **NFR-002**: File transfer MUST show progress for files > 1MB. +- **NFR-003**: All scripts MUST pass ShellCheck with zero warnings. +- **NFR-004**: Tool MUST work on Manjaro Linux with ADB 1.0.41+. +- **NFR-005**: No root access required on the device. + +### Key Entities + +- **Device**: The exone FS5 identified by serial number, with properties (model, OS, battery, storage). +- **Operation**: A command executed against the device (push, pull, install, shell, logs). +- **Config**: Project-level settings file (`.env`) storing device serial and default paths. +- **Log Entry**: Timestamped record of every mutating operation performed. + +--- + +## Success Criteria + +- **SC-001**: All 5 user stories have passing acceptance tests. +- **SC-002**: `./fs5 status` returns device info in < 3 seconds. +- **SC-003**: File push/pull operations are idempotent (safe to re-run). +- **SC-004**: Zero ShellCheck warnings across all scripts. +- **SC-005**: `--json` flag produces valid JSON on all commands. +- **SC-006**: Tool works correctly when device is disconnected (graceful error, exit code 2). +- **SC-007**: All destructive operations require explicit confirmation flag. + +--- + +## Out of Scope + +- GUI or web interface +- Wireless ADB (WiFi) setup automation +- Device rooting or system partition access +- Multi-device simultaneous management (future) +- Android backup/restore via `adb backup` (deprecated in Android 9+) diff --git a/specs/001-android-fs5-management/tasks.md b/specs/001-android-fs5-management/tasks.md new file mode 100644 index 0000000..6db11ab --- /dev/null +++ b/specs/001-android-fs5-management/tasks.md @@ -0,0 +1,98 @@ +# Task Breakdown: Android FS5 Device Management + +**Feature**: 001-android-fs5-management +**Created**: 2026-03-02 +**Status**: Pending + +--- + +## Phase 1: Project Setup + +- [ ] T001 Create project skeleton: `.gitignore`, `.env.example`, `README.md`, `lib/`, `tests/` dirs +- [ ] T002 Create `.env` from `.env.example` with device serial `RD51QE202392` +- [ ] T003 Verify bats-core and shellcheck available (`bats --version`, `shellcheck --version`) + +--- + +## Phase 2: Foundational Library (lib/) + +- [ ] T004 Create `lib/output.sh` — `out_human`, `out_json`, `out_error`, `out_success` functions +- [ ] T005 Create `lib/adb.sh` — `adb_check_device`, `adb_cmd`, `adb_get_prop`, `adb_shell`, `adb_log_op` +- [ ] T006 Create `lib/adb.sh` — `adb_push`, `adb_pull` with existence checks and `--force` support + +--- + +## Phase 3: Tests (RED phase — must fail before implementation) + +- [ ] T007 [US1] Write `tests/test_status.bats` — tests for `status` command (connected, disconnected, --json) +- [ ] T008 [US2] Write `tests/test_transfer.bats` — tests for push/pull (basic, idempotent, --force, directory) +- [ ] T009 [US3] Write `tests/test_app.bats` — tests for `app list`, `app install`, `app uninstall` +- [ ] T010 [US4] Write `tests/test_shell.bats` — tests for `shell` command passthrough +- [ ] T011 [US5] Write `tests/test_logs.bats` — tests for `logs` command (stream, --lines, --filter, --output) +- [ ] T012 Verify ALL tests FAIL (RED phase confirmed) before writing `fs5` entrypoint + +--- + +## Phase 4: Implementation — P1 User Stories + +- [ ] T013 [US1] Create `fs5` entrypoint: argument parsing, `--help`, config loading, `status` command +- [ ] T014 [US1] Implement `status` command: collect device props, battery, storage, format human + JSON output +- [ ] T015 [US2] Implement `push` command: file existence check, transfer, idempotency guard, logging +- [ ] T016 [US2] Implement `pull` command: device file check, transfer, local overwrite guard, logging +- [ ] T017 Verify T007 + T008 tests PASS (GREEN phase for P1) + +--- + +## Phase 5: Implementation — P2 User Stories + +- [ ] T018 [US3] Implement `app list` command: parse `pm list packages`, filter system/user, version lookup +- [ ] T019 [US3] Implement `app install` command: APK validation, `adb install`, package name confirmation +- [ ] T020 [US3] Implement `app uninstall` command: `--force` guard, `pm uninstall`, logging +- [ ] T021 [US4] Implement `shell` command: passthrough to `adb shell`, stdin pipe support +- [ ] T022 Verify T009 + T010 tests PASS (GREEN phase for P2) + +--- + +## Phase 6: Implementation — P3 User Stories + +- [ ] T023 [US5] Implement `logs` command: `adb logcat` stream, `--lines` (dump mode), `--filter`, `--output` +- [ ] T024 Verify T011 tests PASS (GREEN phase for P3) + +--- + +## Phase 7: Polish & Quality Gates + +- [ ] T025 [P] Run `shellcheck fs5 lib/adb.sh lib/output.sh` — fix all warnings to zero +- [ ] T026 [P] Run full test suite `bats tests/` — all tests must pass +- [ ] T027 [P] Write `README.md` with installation, usage examples, and quickstart scenarios +- [ ] T028 Validate all 8 quickstart scenarios from `plan.md` manually against live device +- [ ] T029 Initial git commit: `feat: add android-fs5 CLI toolset` + +--- + +## Dependency Graph + +```text +T001 → T002 → T003 +T003 → T004 → T005 → T006 +T006 → T007 → T008 → T009 → T010 → T011 → T012 +T012 → T013 → T014 → T015 → T016 → T017 +T017 → T018 → T019 → T020 → T021 → T022 +T022 → T023 → T024 +T024 → T025 [P] T026 [P] T027 [P] +T025 + T026 + T027 → T028 → T029 +``` + +--- + +## Progress Summary + +| Phase | Tasks | Status | +|-------|-------|--------| +| 1. Setup | T001-T003 | Pending | +| 2. Foundation | T004-T006 | Pending | +| 3. Tests (RED) | T007-T012 | Pending | +| 4. P1 Impl | T013-T017 | Pending | +| 5. P2 Impl | T018-T022 | Pending | +| 6. P3 Impl | T023-T024 | Pending | +| 7. Polish | T025-T029 | Pending | diff --git a/tests/test_app.bats b/tests/test_app.bats new file mode 100644 index 0000000..3b1d849 --- /dev/null +++ b/tests/test_app.bats @@ -0,0 +1,57 @@ +#!/usr/bin/env bats +# tests/test_app.bats — Tests for 'fs5 app' commands (US3) + +bats_require_minimum_version 1.5.0 + +SCRIPT="${BATS_TEST_DIRNAME}/../fs5" + +setup() { + export DEVICE_SERIAL="RD51QE202392" + export LOG_FILE="/tmp/fs5-test-$$.log" +} + +teardown() { + rm -f "${LOG_FILE}" +} + +@test "app list: exits with code 0" { + run "${SCRIPT}" app list + [ "${status}" -eq 0 ] +} + +@test "app list: output contains package names" { + run "${SCRIPT}" app list + [ "${status}" -eq 0 ] + [[ "${output}" == *"package:"* ]] || [[ "${output}" == *"com."* ]] +} + +@test "app list --all: includes system packages" { + run "${SCRIPT}" app list --all + [ "${status}" -eq 0 ] + # Android system packages always present + [[ "${output}" == *"android"* ]] +} + +@test "app list: --json produces valid JSON array" { + run "${SCRIPT}" app list --json + [ "${status}" -eq 0 ] + echo "${output}" | python3 -c "import json,sys; d=json.load(sys.stdin); assert isinstance(d, list)" +} + +@test "app install: fails with exit code 1 when APK not found" { + run "${SCRIPT}" app install /nonexistent/app.apk + [ "${status}" -eq 1 ] + [[ "${output}" == *"not found"* ]] +} + +@test "app uninstall: requires --force flag" { + run "${SCRIPT}" app uninstall com.example.nonexistent + [ "${status}" -eq 1 ] + [[ "${output}" == *"--force"* ]] +} + +@test "app uninstall: exits gracefully when package not installed" { + run "${SCRIPT}" app uninstall com.example.nonexistent.xyz --force + # Should exit 1 (not found) but not crash + [ "${status}" -ne 2 ] +} diff --git a/tests/test_logs.bats b/tests/test_logs.bats new file mode 100644 index 0000000..ec89bc5 --- /dev/null +++ b/tests/test_logs.bats @@ -0,0 +1,55 @@ +#!/usr/bin/env bats +# tests/test_logs.bats — Tests for 'fs5 logs' command (US5) + +bats_require_minimum_version 1.5.0 + +SCRIPT="${BATS_TEST_DIRNAME}/../fs5" + +setup() { + export DEVICE_SERIAL="RD51QE202392" + export LOG_FILE="/tmp/fs5-test-$$.log" + LOG_OUTPUT="/tmp/fs5-logcap-$$.txt" +} + +teardown() { + rm -f "${LOG_FILE}" "${LOG_OUTPUT}" +} + +@test "logs --lines: outputs exactly N lines" { + run "${SCRIPT}" logs --lines 5 + [ "${status}" -eq 0 ] + local line_count + line_count=$(echo "${output}" | wc -l) + [ "${line_count}" -ge 1 ] + [ "${line_count}" -le 10 ] +} + +@test "logs --lines: exits after printing lines (non-streaming)" { + # Should complete within 15 seconds (not hang) + run timeout 15 "${SCRIPT}" logs --lines 3 + [ "${status}" -eq 0 ] +} + +@test "logs --output: writes to file" { + run "${SCRIPT}" logs --lines 5 --output "${LOG_OUTPUT}" + [ "${status}" -eq 0 ] + [ -f "${LOG_OUTPUT}" ] + [ -s "${LOG_OUTPUT}" ] +} + +@test "logs --filter: filters by tag" { + run "${SCRIPT}" logs --lines 20 --filter "System" + [ "${status}" -eq 0 ] + # Output may be empty if tag not present, but must not error +} + +@test "logs: --help shows usage" { + run "${SCRIPT}" logs --help + [ "${status}" -eq 0 ] + [[ "${output}" == *"logs"* ]] +} + +@test "logs: exits with code 2 when device not connected" { + DEVICE_SERIAL="INVALID_SERIAL_000" run "${SCRIPT}" logs --lines 1 + [ "${status}" -eq 2 ] +} diff --git a/tests/test_shell.bats b/tests/test_shell.bats new file mode 100644 index 0000000..0889be4 --- /dev/null +++ b/tests/test_shell.bats @@ -0,0 +1,43 @@ +#!/usr/bin/env bats +# tests/test_shell.bats — Tests for 'fs5 shell' command (US4) + +bats_require_minimum_version 1.5.0 + +SCRIPT="${BATS_TEST_DIRNAME}/../fs5" + +setup() { + export DEVICE_SERIAL="RD51QE202392" + export LOG_FILE="/tmp/fs5-test-$$.log" +} + +teardown() { + rm -f "${LOG_FILE}" +} + +@test "shell: executes echo command on device" { + run "${SCRIPT}" shell "echo hello-from-device" + [ "${status}" -eq 0 ] + [[ "${output}" == *"hello-from-device"* ]] +} + +@test "shell: forwards exit code from device command" { + run "${SCRIPT}" shell "exit 0" + [ "${status}" -eq 0 ] +} + +@test "shell: returns device hostname/uname" { + run "${SCRIPT}" shell "uname -s" + [ "${status}" -eq 0 ] + [[ "${output}" == *"Linux"* ]] +} + +@test "shell: --help shows usage" { + run "${SCRIPT}" shell --help + [ "${status}" -eq 0 ] + [[ "${output}" == *"shell"* ]] +} + +@test "shell: exits with code 2 when device not connected" { + DEVICE_SERIAL="INVALID_SERIAL_000" run "${SCRIPT}" shell "echo test" + [ "${status}" -eq 2 ] +} diff --git a/tests/test_status.bats b/tests/test_status.bats new file mode 100644 index 0000000..fd1379f --- /dev/null +++ b/tests/test_status.bats @@ -0,0 +1,61 @@ +#!/usr/bin/env bats +# tests/test_status.bats — Tests for 'fs5 status' command (US1) + +bats_require_minimum_version 1.5.0 + +SCRIPT="${BATS_TEST_DIRNAME}/../fs5" + +setup() { + # Ensure .env exists for tests + export DEVICE_SERIAL="RD51QE202392" + export LOG_FILE="/tmp/fs5-test-$$.log" +} + +teardown() { + rm -f "${LOG_FILE}" +} + +@test "status: shows device info when connected" { + run "${SCRIPT}" status + [ "${status}" -eq 0 ] + [[ "${output}" == *"FS5"* ]] || [[ "${output}" == *"RD51QE202392"* ]] +} + +@test "status: exits with code 2 when device serial is wrong" { + DEVICE_SERIAL="INVALID_SERIAL_000" run "${SCRIPT}" status + [ "${status}" -eq 2 ] + [[ "${output}" == *"Device not found"* ]] +} + +@test "status: --json flag produces valid JSON" { + run "${SCRIPT}" status --json + [ "${status}" -eq 0 ] + echo "${output}" | python3 -m json.tool > /dev/null +} + +@test "status: JSON output contains required fields" { + run "${SCRIPT}" status --json + [ "${status}" -eq 0 ] + echo "${output}" | python3 -c " +import json, sys +d = json.load(sys.stdin) +required = ['serial', 'model', 'android_version', 'battery_level', 'adb_state'] +for f in required: + assert f in d, f'Missing field: {f}' +" +} + +@test "status: --help shows usage" { + run "${SCRIPT}" status --help + [ "${status}" -eq 0 ] + [[ "${output}" == *"status"* ]] +} + +@test "status: completes in under 10 seconds" { + local start end elapsed + start=$(date +%s) + run "${SCRIPT}" status + end=$(date +%s) + elapsed=$(( end - start )) + [ "${elapsed}" -lt 10 ] +} diff --git a/tests/test_transfer.bats b/tests/test_transfer.bats new file mode 100644 index 0000000..f587c22 --- /dev/null +++ b/tests/test_transfer.bats @@ -0,0 +1,87 @@ +#!/usr/bin/env bats +# tests/test_transfer.bats — Tests for 'fs5 push/pull' commands (US2) + +bats_require_minimum_version 1.5.0 + +SCRIPT="${BATS_TEST_DIRNAME}/../fs5" +DEVICE_PATH="/sdcard/fs5-test" + +setup() { + export DEVICE_SERIAL="RD51QE202392" + export LOG_FILE="/tmp/fs5-test-$$.log" + # Create a temp local file for testing + TEST_FILE=$(mktemp /tmp/fs5-push-test-XXXXXX.txt) + echo "fs5-test-content-$$" > "${TEST_FILE}" + PULL_DEST="/tmp/fs5-pulled-$$.txt" +} + +teardown() { + rm -f "${TEST_FILE}" "${PULL_DEST}" "${LOG_FILE}" + # Clean up device test file + adb -s "${DEVICE_SERIAL}" shell "rm -f '${DEVICE_PATH}/$(basename "${TEST_FILE}")'" 2>/dev/null || true +} + +@test "push: transfers file to device successfully" { + run "${SCRIPT}" push "${TEST_FILE}" "${DEVICE_PATH}/$(basename "${TEST_FILE}")" + [ "${status}" -eq 0 ] + [[ "${output}" == *"Pushed"* ]] +} + +@test "push: logs the operation to LOG_FILE" { + run "${SCRIPT}" push "${TEST_FILE}" "${DEVICE_PATH}/$(basename "${TEST_FILE}")" --force + [ "${status}" -eq 0 ] + [ -f "${LOG_FILE}" ] + grep -q "PUSH" "${LOG_FILE}" +} + +@test "push: skips if file exists without --force" { + # First push + "${SCRIPT}" push "${TEST_FILE}" "${DEVICE_PATH}/$(basename "${TEST_FILE}")" --force + # Second push without --force should warn, not fail + run "${SCRIPT}" push "${TEST_FILE}" "${DEVICE_PATH}/$(basename "${TEST_FILE}")" + [ "${status}" -eq 0 ] + [[ "${output}" == *"--force"* ]] || [[ "${output}" == *"exists"* ]] +} + +@test "push: overwrites with --force flag" { + "${SCRIPT}" push "${TEST_FILE}" "${DEVICE_PATH}/$(basename "${TEST_FILE}")" --force + run "${SCRIPT}" push "${TEST_FILE}" "${DEVICE_PATH}/$(basename "${TEST_FILE}")" --force + [ "${status}" -eq 0 ] + [[ "${output}" == *"Pushed"* ]] +} + +@test "push: fails with exit code 1 when source file missing" { + run "${SCRIPT}" push "/nonexistent/file.txt" "${DEVICE_PATH}/file.txt" + [ "${status}" -eq 1 ] + [[ "${output}" == *"not found"* ]] +} + +@test "pull: downloads file from device successfully" { + # Push first so we have something to pull + "${SCRIPT}" push "${TEST_FILE}" "${DEVICE_PATH}/$(basename "${TEST_FILE}")" --force + run "${SCRIPT}" pull "${DEVICE_PATH}/$(basename "${TEST_FILE}")" "${PULL_DEST}" + [ "${status}" -eq 0 ] + [[ "${output}" == *"Pulled"* ]] + [ -f "${PULL_DEST}" ] +} + +@test "pull: pulled file matches original" { + "${SCRIPT}" push "${TEST_FILE}" "${DEVICE_PATH}/$(basename "${TEST_FILE}")" --force + "${SCRIPT}" pull "${DEVICE_PATH}/$(basename "${TEST_FILE}")" "${PULL_DEST}" --force + run diff "${TEST_FILE}" "${PULL_DEST}" + [ "${status}" -eq 0 ] +} + +@test "pull: skips if local file exists without --force" { + "${SCRIPT}" push "${TEST_FILE}" "${DEVICE_PATH}/$(basename "${TEST_FILE}")" --force + touch "${PULL_DEST}" + run "${SCRIPT}" pull "${DEVICE_PATH}/$(basename "${TEST_FILE}")" "${PULL_DEST}" + [ "${status}" -eq 0 ] + [[ "${output}" == *"--force"* ]] || [[ "${output}" == *"exists"* ]] +} + +@test "pull: fails with exit code 1 when device file missing" { + run "${SCRIPT}" pull "${DEVICE_PATH}/nonexistent-file-xyz.txt" "${PULL_DEST}" + [ "${status}" -eq 1 ] + [[ "${output}" == *"not found"* ]] +}