Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ test-verbose:
cargo test -- --nocapture

test-integration:
cargo test --test simulator_script -- --ignored
cargo test --test simulator_script --test luac_compile -- --ignored

lint:
cargo clippy -- -D warnings
Expand Down
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ A command-line tool for managing Lua script packages on EdgeTX radios - and for
- **Scaffold scripts** -generate boilerplate for tools, widgets, telemetry, functions, mixes, and libraries
- **Package manifests** -`edgetx.yml` defines your scripts, dependencies, file layout, and exclusions
- **Simulator** -run an EdgeTX simulator locally with live-reload, headless mode, and Lua test scripts
- **Bytecode compilation** -compile Lua to `.luac` with EdgeTX's own compiler, and build production packages or SD card archives
- **Cross-platform** -Linux, macOS, and Windows with platform-specific radio detection

## Installation
Expand Down Expand Up @@ -167,6 +168,10 @@ edgetx-cli pkg install Org/Repo --path edgetx.c480x272.yml
| `--eject` | `false` | Safely unmount and power off the radio after install |
| `--dry-run` | `false` | Show what would be installed without writing anything |
| `--dev` | `false` | Include development dependencies |
| `--pre-compile` | `false` | Compile Lua scripts and install the bytecode alongside the sources |
| `--keep-debug` | `false` | Keep debug info in the compiled bytecode (requires `--pre-compile`) |

**Pre-compiling:** `--pre-compile` builds the package in a local staging directory, compiles a `.luac` next to every `.lua`, and copies the finished tree to the card in one pass — nothing is compiled against the SD card itself. The radio then loads the bytecode instead of compiling on first use. A script that fails to compile fails the install before anything is written. See [`dev luac`](#dev-luac-file) for the compiler.

**Version resolution:** When no `@version` is specified, the CLI queries the remote repository for all tags and installs the latest [semver](https://semver.org/)-compatible tag (e.g. `v2.1.0`). If no semver tags exist, the default branch HEAD is used.

Expand Down Expand Up @@ -197,6 +202,8 @@ edgetx-cli pkg update --all
| `--eject` | `false` | Safely unmount radio after update |
| `--dry-run` | `false` | Show what would be updated without writing anything |
| `--dev` | `false` | Include development dependencies (overrides the stored install preference)|
| `--pre-compile` | `false` | Compile Lua scripts and install the bytecode alongside the sources |
| `--keep-debug` | `false` | Keep debug info in the compiled bytecode (requires `--pre-compile`) |

### `pkg remove <package>`

Expand Down Expand Up @@ -286,6 +293,10 @@ Checks each installed package against its remote source and reports available up
```sh
edgetx-cli pkg install . --eject
```
6. **Build a release artifact:**
```sh
edgetx-cli dev build --out dist --compress
```

### `dev init [name]`

Expand Down Expand Up @@ -340,6 +351,49 @@ edgetx-cli dev sync --src-dir ./my-project /path/to/edgetx-sdcard
| `--src-dir` | `.` | Source directory containing `edgetx.yml` |
| `--no-dev` | `false` | Exclude development dependencies |

### `dev build`

Build a production copy of a package with compiled bytecode, ready to publish to artifact storage.

By default the output is a package directory — `edgetx.yml` plus your content, with a `.luac` next to every `.lua` — which installs like any other local package. With `--compress` the output is instead a `.zip` laid out for the SD card: files sit at their install destinations, there is no manifest, and it extracts straight onto the card root.

Sources ship alongside the bytecode because the radio still discovers some script types (widgets in particular) by their `.lua`.

```sh
edgetx-cli dev build --out dist
edgetx-cli dev build --out dist --path edgetx.c480x272.yml
edgetx-cli dev build --out artifacts --compress
edgetx-cli dev build --out artifacts --compress --name my-tool-v1.2.3
```

| Flag | Default | Description |
|----------------|---------|-----------------------------------------------------------------|
| `--out` | | Output directory (required) |
| `--src-dir` | `.` | Source directory containing `edgetx.yml` |
| `--path` | | Manifest file or subdirectory to build (for packages with variants) |
| `--compress` | `false` | Create a `.zip` archive for the SD card instead of a package directory |
| `--name` | | Archive base name (default: the package id with `/` replaced by `-`) |
| `--keep-debug` | `false` | Keep debug info in the compiled bytecode |
| `--force` | `false` | Replace an existing build in the output directory |

Development-only content is always excluded. Since the manifest carries no version, use `--name` to stamp a version into release artifacts.

### `dev luac <file>`

Compile a single Lua script to EdgeTX bytecode.

```sh
edgetx-cli dev luac SCRIPTS/TOOLS/MyTool/main.lua
edgetx-cli dev luac main.lua -o build/main.luac --keep-debug
```

| Flag | Default | Description |
|----------------|---------|----------------------------------------------------|
| `--output`/`-o`| input with a `.luac` extension | Output path |
| `--keep-debug` | `false` | Keep debug info in the bytecode (stripping is the default, matching the radio) |

The compiler is EdgeTX's own [`edgetx-luac`](https://github.com/EdgeTX/edgetx/pull/7275) WebAssembly module, downloaded on first use and cached under your cache directory (`edgetx-cli/luac/`). Delete that file to pick up a newer build. Its output is byte-identical to the bytecode the radio and simulator produce themselves.

### `dev simulator`

Run the EdgeTX simulator. When run from a directory containing `edgetx.yml`, the package is automatically installed into the simulator's SD card and file changes are live-synced.
Expand Down
2 changes: 1 addition & 1 deletion docs/Manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ themes:
- `exclude` takes glob patterns to skip during copy (e.g., `["*.luac", "presets.txt"]`)
- `themes` installs to `THEMES/<name>/` on the SD card — typically a directory with `theme.yml`, `logo.png`, and resolution-specific backgrounds. Themes require a color LCD; set `package.capabilities.display.type: colorlcd`
- `source_dir` is relative to the manifest file; all `path` values are relative to the source root and must use `/` as the separator (never `\`), since they represent paths on a FAT32 SD card
- `binary: true` disables the default `*.luac` exclusion, allowing compiled bytecode to be installed
- `binary: true` disables the default `*.luac` exclusion, allowing compiled bytecode to be installed. `dev build` sets it in the manifest it emits, since a built package ships bytecode
- `dev: true` marks a content item as a development dependency - it is excluded from `pkg install` and `pkg update` unless `--dev` is passed, but included by default in `dev sync` (use `--no-dev` to exclude). A non-dev item cannot depend on a dev library

### Source vs destination
Expand Down
270 changes: 270 additions & 0 deletions src/cli/dev.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
use crate::luac::LuaCompiler;
use crate::manifest;
use crate::packages;
use crate::radio;
use crate::scaffold;
use crate::simulator;
use anyhow::{Context, Result, bail};
Expand Down Expand Up @@ -44,6 +47,55 @@ pub enum DevCommands {
Sync(SyncArgs),
/// Run the EdgeTX WASM simulator
Simulator(SimulatorArgs),
/// Compile a Lua script to EdgeTX bytecode (.luac)
Luac(LuacArgs),
/// Build a production package with compiled bytecode
Build(BuildArgs),
}

#[derive(Args)]
pub struct BuildArgs {
/// Output directory for the build
#[arg(long)]
out: PathBuf,

/// Source directory containing edgetx.yml
#[arg(long, default_value = ".")]
src_dir: PathBuf,

/// Manifest file or subdirectory to build (for packages with variants)
#[arg(long)]
path: Option<String>,

/// Create a .zip archive for the SD card instead of a package directory
#[arg(long)]
compress: bool,

/// Archive base name (default: the package id with '/' replaced by '-')
#[arg(long)]
name: Option<String>,

/// Keep debug info in the compiled bytecode
#[arg(long)]
keep_debug: bool,

/// Replace an existing build in the output directory
#[arg(long)]
force: bool,
}

#[derive(Args)]
pub struct LuacArgs {
/// Lua script to compile
file: PathBuf,

/// Output path (default: the input with a .luac extension)
#[arg(short, long)]
output: Option<PathBuf>,

/// Keep debug info in the compiled bytecode
#[arg(long)]
keep_debug: bool,
}

#[derive(Args)]
Expand Down Expand Up @@ -147,7 +199,225 @@ pub fn dispatch(command: DevCommands) -> Result<()> {
DevCommands::Scaffold(args) => run_scaffold(args),
DevCommands::Sync(args) => run_sync(args),
DevCommands::Simulator(args) => run_simulator(args),
DevCommands::Luac(args) => run_luac(args),
DevCommands::Build(args) => run_build(args),
}
}

/// Default output path for a compiled script: `main.lua` -> `main.luac`.
fn default_luac_path(input: &Path) -> PathBuf {
input.with_extension("luac")
}

fn run_luac(args: LuacArgs) -> Result<()> {
let source =
std::fs::read(&args.file).with_context(|| format!("reading {}", args.file.display()))?;

let output = args
.output
.clone()
.unwrap_or_else(|| default_luac_path(&args.file));

let mut compiler = super::load_compiler(!args.keep_debug)?;
let bytecode = compiler
.compile(&source)
.with_context(|| format!("compiling {}", args.file.display()))?;

std::fs::write(&output, &bytecode).with_context(|| format!("writing {}", output.display()))?;

println!(
" {} Compiled {} -> {} ({} bytes)",
console::style("✓").green(),
args.file.display(),
output.display(),
bytecode.len()
);

Ok(())
}

fn run_build(args: BuildArgs) -> Result<()> {
let src_dir = std::fs::canonicalize(&args.src_dir)
.with_context(|| format!("resolving source directory {}", args.src_dir.display()))?;

let sub_path = args.path.clone().unwrap_or_default();
let (m, manifest_dir) = manifest::load_with_sub_path(&src_dir, &sub_path)?;

if m.has_variants() {
let paths: Vec<&str> = m.package.variants.iter().map(|v| v.path.as_str()).collect();
bail!(
"{:?} selects between variants, so there is nothing to build directly.\n \
Build one with --path <variant>: {}",
m.package.display_name(),
paths.join(", ")
);
}

std::fs::create_dir_all(&args.out)
.with_context(|| format!("creating output directory {}", args.out.display()))?;
let out_dir = std::fs::canonicalize(&args.out)
.with_context(|| format!("resolving output directory {}", args.out.display()))?;

// A build writes into its output tree, so overlapping it with the sources
// would have the copy walking over its own output.
for root in m.source_roots(&manifest_dir) {
if out_dir.starts_with(&root) {
bail!(
"output directory {} is inside the source directory {}",
out_dir.display(),
root.display()
);
}
}

let base_name = args
.name
.clone()
.unwrap_or_else(|| packages::build::archive_base_name(&m.package.id));

// A zip is built through a staging directory next to it, which compress_dir
// consumes on success.
let (target, staging) = if args.compress {
(
out_dir.join(format!("{base_name}.zip")),
out_dir.join(&base_name),
)
} else {
(out_dir.clone(), out_dir.clone())
};

if !args.force && build_target_exists(&target)? {
bail!(
"{} already exists -- pass --force to replace it",
target.display()
);
}
if args.force {
clear_build_target(&target)?;
}
if args.compress {
clear_build_target(&staging)?;
}

println!();
println!(" {}", console::style(m.package.display_name()).bold());
println!();

let mut compiler = super::load_compiler(!args.keep_debug)?;

let bar = ProgressBar::new(packages::build::count_steps(&m, &manifest_dir, false) as u64);
bar.set_style(
ProgressStyle::with_template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
.unwrap(),
);
bar.set_message("Building");

let tick = |path: &str| {
if let Some(name) = Path::new(path).file_name() {
bar.set_message(name.to_string_lossy().to_string());
}
bar.inc(1);
};

let layout = if args.compress {
packages::build::BuildLayout::SdOverlay
} else {
packages::build::BuildLayout::Package
};

let result = packages::build::build_package(
&packages::build::BuildOptions {
manifest: &m,
manifest_dir: &manifest_dir,
out_dir: &staging,
include_dev: false,
layout,
},
&mut compiler,
&mut |p: &str| tick(p),
&mut |p: &str| tick(p),
);
bar.finish_and_clear();
let result = result?;

if args.compress {
let zip_total = radio::backup::count_all_files(&staging);
let zip_bar = ProgressBar::new(zip_total as u64);
zip_bar.set_style(
ProgressStyle::with_template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
.unwrap(),
);
zip_bar.set_message("Compressing");

radio::backup::compress_dir(&staging, &target, |rel| {
if let Some(name) = Path::new(rel).file_name() {
zip_bar.set_message(name.to_string_lossy().to_string());
}
zip_bar.inc(1);
})?;
zip_bar.finish_and_clear();
}

println!(
" {} Built {} file(s), compiled {} script(s)",
console::style("✓").green(),
result.files_copied,
result.scripts_compiled
);
println!(
" {} Output: {}",
console::style("ℹ").blue(),
target.display()
);
if args.compress {
println!(
" {} Extract the archive onto the SD card root",
console::style("ℹ").blue()
);
} else {
println!(
" {} Install it with: edgetx-cli pkg install {}",
console::style("ℹ").blue(),
target.display()
);
}
println!();

Ok(())
}

/// Whether a previous build is already sitting at `target`.
fn build_target_exists(target: &Path) -> Result<bool> {
if target.is_file() {
return Ok(true);
}
if !target.is_dir() {
return Ok(false);
}
let mut entries =
std::fs::read_dir(target).with_context(|| format!("reading {}", target.display()))?;
Ok(entries.next().is_some())
}

/// Remove a previous build so the new one cannot inherit stale files.
fn clear_build_target(target: &Path) -> Result<()> {
if target.is_dir() {
for entry in std::fs::read_dir(target)
.with_context(|| format!("reading {}", target.display()))?
.flatten()
{
let path = entry.path();
let removed = if path.is_dir() {
std::fs::remove_dir_all(&path)
} else {
std::fs::remove_file(&path)
};
removed.with_context(|| format!("removing {}", path.display()))?;
}
} else if target.is_file() {
std::fs::remove_file(target).with_context(|| format!("removing {}", target.display()))?;
}
Ok(())
}

fn run_init(args: InitArgs) -> Result<()> {
Expand Down
Loading
Loading