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,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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.env
|
||||||
|
fs5.log
|
||||||
|
*.log
|
||||||
@@ -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.
|
||||||
@@ -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 <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 (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
|
||||||
|
```
|
||||||
@@ -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 "$@"
|
||||||
+133
@@ -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%'
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 <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
|
||||||
|
app uninstall <pkg> 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
|
||||||
|
```
|
||||||
@@ -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 <local-path> <device-path>`,
|
||||||
|
**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 <device-path> <local-path>`,
|
||||||
|
**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 <local-dir>/ <device-dir>/`,
|
||||||
|
**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 <apk-path>`,
|
||||||
|
**Then** the app is installed and the package name is confirmed in output.
|
||||||
|
|
||||||
|
2. **Given** an installed package, **When** I run `./fs5 app uninstall <package-name>`,
|
||||||
|
**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 "<command>"`,
|
||||||
|
**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 <N>`,
|
||||||
|
**Then** the last N log lines are printed and the command exits.
|
||||||
|
|
||||||
|
3. **When** I run `./fs5 logs --filter <tag>`,
|
||||||
|
**Then** only log lines matching the tag are shown.
|
||||||
|
|
||||||
|
4. **When** I run `./fs5 logs --output <file>`,
|
||||||
|
**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+)
|
||||||
@@ -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 |
|
||||||
@@ -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 ]
|
||||||
|
}
|
||||||
@@ -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 ]
|
||||||
|
}
|
||||||
@@ -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 ]
|
||||||
|
}
|
||||||
@@ -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 ]
|
||||||
|
}
|
||||||
@@ -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"* ]]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user