# Zync

[![Latest Release](https://gitlab.com/byarocks/zync/-/badges/release.svg?key_text=zync)](https://gitlab.com/byarocks/zync/-/releases) [![Coverage Status](https://coveralls.io/repos/gitlab/byarocks/zync/badge.svg?branch=dev)](https://coveralls.io/gitlab/byarocks/zync?branch=master)

A CLI tool written in Lua to manage, build, simulate, test, flash and debug Zephyr OS apps, primarily aimed at multi-image Zephyr OS projects managed by sysbuild (bootloader + app/s) that uses native cmake + ninja build system with a declarative config.

## Index

<!-- toc -->

- [Why?](#why)
- [Features](#features)
- [Dependencies](#dependencies)
- [Installation](#installation)
  * [Using luarocks](#using-luarocks)
  * [Manual installation](#manual-installation)
- [Usage](#usage)
  * [Commands](#commands)
  * [Command arguments](#command-arguments)
  * [Zync config](#zync-config)
    + [Config interpolation (${key})](#config-interpolation-key)
  * [Typical workflow](#typical-workflow)
  * [Sysbuild projects](#sysbuild-projects)
    + [Flashing a sysbuild image](#flashing-a-sysbuild-image)
  * [Build profiles](#build-profiles)
    + [Profile-less mode](#profile-less-mode)
    + [Project configured with build profiles](#project-configured-with-build-profiles)
    + [Build output layout](#build-output-layout)
  * [Using build profiles](#using-build-profiles)
- [Example app](#example-app)
- [Upcomming features](#upcomming-features)

<!-- tocstop -->

## Why?

Because Zephyr's `west` sucks, that's why. Even though at first glance this meta tool looks exactly like west, you have the option to configure your project declaratively. I admit, when building a simple Zephyr app, you can just use pure CMake + ninja/make and configure everything in CMake. However, when building a multi-image app using sysbuild (e.g. updatehub OTA + mcuboot + app image) then you're stuck with passing a mile of args into west as you cannot configure it declaratively and cannot externally modify mcuboot's CMake config.

## Features

1. Manage the whole lifecycle of a Zephyr app - config, build, flash, sim runs
2. Supports both simple Zephyr apps and multi-image apps with sysbuild
3. Automatic Zephyr workspace detection
4. Declarative config through `zconf.lua` @ project root
5. Automatically detects `sysbuild` projects and adjusts build accordingly
6. Automatic project dir navigation (can be run from both project root or a build dir)
7. Supports multiple build profiles (can build multiple binaries for a specific app, e.g. simulation, board1, board2)

## Dependencies

1. [argparse](https://luarocks.org/modules/argparse/argparse)
2. [LuaFileSystem](https://luarocks.org/modules/hisham/luafilesystem)

## Installation

### Using luarocks
```bash
luarocks install zync
```

### Manual installation

Clone this repo and expose `lua/zync` or just include it in your project. (You will still need to install any required dependencies; see [Dependencies](#dependencies).)

## Usage

```bash
Usage: zync [-h] [-v] [-u] [-c] [-a] [-p <profile>] [<command>] ...

Zephyr build system companion.

Options:
   -h, --help            Show this help message and exit.
   -v, --version         Display zync version.
   -u, --unsafe          Unsafe zync config execution.
   -c, --clean           Clean selected profile/image build dir before running command.
   -a, --all             Run command for all configured profiles (overrides --profile/default).
          -p <profile>,  Select project profile defined in zconf.lua.
   --profile <profile>

Commands:
   boards, bo            List boards.
   build, b              Build app.
   clean, c              Clean build dir.
   conf, cf              Configure app.
   flash, f              Flash app.
   init, i               Init zync config.
   profiles, p           List profiles.
   run, r                Run app.
   runners, rn           List runners.
```

### Commands

| Command   | Summary    |
|--------------- | --------------- |
| `init, i`   | Initializes project with a `zconf.lua` zync config  |
| `clean, c`   | Cleans entire root build directory |
| `conf, cf`   | Configures project using CMake |
| `build, b`   | Builds project using Ninja |
| `flash, f` | Flashes built app image to a configured board using selected runner |
| `run, r` | Runs app simulation (useful for `native_sim` and `qemu` apps) |
| `boards, bo` | Lists available Zephyr boards |
| `runners, rn` | Lists available Zephyr runners |
| `profiles, p` | Lists configured profiles (order follows `zconf.lua`) |

You can run a clean before another command with the global `-c` flag (for example, `zync -c build`). This pre-clean targets only the selected build directory/profile.

### Command arguments

All commands share the following arguments:

| Argument | Summary |
| -------------- | --------------- |
| `--board, -b` | Zephyr OS board/target |
| `--runner, -r` | Zephyr OS runner |

These arguments are not required if configured using `zconf.lua`, although if provided, they take precedence over values configured in the zync config. Zync automatically detects board changes between build stages, meaning, if you configure the project using one board, but try to build/flash to another board, `zync` will automatically rebuild the project with the board provided.

`conf`, `build`, `flash` and `run` specific arguments:

| Argument | Summary |
| -------------- | --------------- |
| `--` | Additional arguments passed in to the build command |

`flash` and `run` specific arguments for sysbuild projects:

| Argument | Summary |
| -------------- | --------------- |
| `--list, -l` | Lists available sysbuild project images |
| `--image, -i` | Selects which image to flash/run |

If no image selected, by default, the first non-bootloader image is selected.
If no profile is selected, zync uses `default_profile` or the first profile defined in `zconf.lua`.

### Zync config

Zync can be configured declaratively using a simple `zconf.lua` config file at the project root.

Configurable values:

| Config   | Summary    |
|--------------- | --------------- |
| `board`   | Zephyr OS board/target   |
| `runner`   | Zephyr OS runner   |
| `profiles` | Table of profile configs (board/runner per profile) |
| `default_profile` | Default profile name used when none selected |
| `args` | Optional extra arguments for build phases (global + per-profile) |
| `zephyr_ws` | Zephyr OS workspace |
| `zephyr_base` | Zephyr OS base (usually `{Zephyr workspace}/zephyr`) |

Example `zconf.lua`:
```lua
return {
    board = 'nucleo_f767zi',
    runner = 'openocd',
}
```

Example `zconf.lua` with build profiles:
```lua
return {
    runner = 'openocd',

    profiles = {
        sim = {
            board = 'native_sim',
        },
        prod = {
            board = 'nucleo_f767zi',
        },
    },

    default_profile = 'sim',
}

```
See [Build profiles chapter](#build-profiles) for more info about build profiles.


Example `zconf.lua` with extra args:
```lua
return {
    --- extra args per phase (global)
    args = {
        conf = '-DHG_TEST=ON', -- cmake
        build = { '-j', '0' }, -- ninja
        flash = '--hex-file path/to/other.hex', -- west flash
        run = '-flash_mount=../../seed/sd', -- sim binary
    },

    profiles = {
        sim = {
            board = 'native_sim',
            args = {
                run = { '-flash=../../seed/flash.bin', '-wait_uart' },
            },
        },
    },
}
```

#### Config interpolation (${key})

zync supports `${key}` placeholders inside config values. Interpolation happens after zconf.lua is loaded
and after the active profile is resolved, so placeholders use the final config values for that run.
This is most commonly used inside `args` or profile values.

Supported interpolation keys (resolved from the active config):
- `board` (resolved board for the active profile)
- `runner` (resolved runner for the active profile)
- `profile` (selected profile name)
- `build_dir` (build output dir; profile-specific when profiles are enabled)
- `project_dir` (project root directory)
- `zephyr_ws` (Zephyr workspace)
- `zephyr_base` (Zephyr base, usually `{zephyr_ws}/zephyr`)
- `cwd` (current working directory used by zync)
- `is_build` (true/false)
- `sysbuild` (true/false)

Limitations and notes:
- Missing keys are left unchanged (the placeholder stays as `${key}`).
- Interpolation only applies to string values (numbers/booleans are normalized to strings when referenced).
- Cycles are not expanded further (self-references resolve to the original placeholder).
- Top-level custom keys are not allowed by the zconf schema; use interpolation inside supported fields like `args` or `profiles`.

Example (global interpolation):
```lua
return {
    board = 'nucleo_f767zi',
    runner = 'openocd',
    zephyr_ws = '/opt/zephyr',
    zephyr_base = '${zephyr_ws}/zephyr',
    args = {
        conf = {
            '-DBOARD=${board}',
            '-DZEPHYR_BASE=${zephyr_base}',
        },
        build = '--build-dir ${build_dir}',
        run = { '${project_dir}/tools', '${board}' },
    },
}
```

Example (profile-scoped interpolation):
```lua
return {
    runner = 'openocd',
    profiles = {
        sim = {
            board = 'native_sim',
            args = {
                conf = { '-DBOARD=${board}' },
                build = '--build-dir ${build_dir}',
            },
        },
        prod = {
            board = 'nucleo_f767zi',
            args = {
                conf = { '-DBOARD=${board}' },
                build = '--build-dir ${build_dir}',
            },
        },
    },
}
```

When profiles are used, interpolation inside each profile uses that profile's resolved values
(for example, `${board}` and `${build_dir}` are profile-specific). CLI overrides like `--profile`
or `--board` influence the resolved config first, then interpolation runs.

When both global and profile args are configured for a phase, zync appends them in order (global first, then profile). For example, with the config above, `zync --profile sim run` executes:
```bash
zephyr/zephyr.exe -flash_mount=../../seed/sd -flash=../../seed/flash.bin -wait_uart
```

You can also pass additional CLI args for a build phase using `--`. These are appended after the configured args:
```bash
zync run -- -flash=../../seed/flash.bin
```

For `zync run`, extra args are passed only when zync executes the built simulator binary directly. Zync looks for the binary at `zephyr/zephyr.exe` under the active build dir (or selected sysbuild image dir), runs a build first by default, and then executes the binary with the extra args. Use `--norebuild`/`-n` to skip the pre-run rebuild when running the simulator binary directly. If the binary is not found, zync falls back to `ninja run` and does not append extra args (fallback behavior remains unchanged).

Combined example (global args + profile args + CLI args):
```lua
return {
    args = {
        run = '-flash_mount=../../seed/sd',
    },
    profiles = {
        sim = {
            board = 'native_sim',
            args = {
                run = '-flash=../../seed/flash.bin' ,
            },
        },
        prod = {
            board = 'nucleo_f439zi',
            runner = 'opeocd',
        },
    },
}
```

```bash
zync --profile sim run -- --extra
# results in:
zephyr/zephyr.exe -flash_mount=../../seed/sd -flash=../../seed/flash.bin --extra
```

As you might have noticed, zync config is a Lua module, meaning you can execute Lua code when you load the file. This means you can write your own conditional config system or pre-config hooks. Code executed from the zync config module is executed in a safe sandbox environment with no access to `os`, `io`, `require`, `debug`, `package`, `dofile`, and `load`. It also guards against excessive CPU usage and infinite loops.

> 🔔 **Note:** If you absolutely need access to these Lua environment features, you can force unsafe execution using the `--unsafe`/`-u` flag, though be mindful when you do so.
>
> ⚠️ **Warning:** Even though `zconf.lua` is loaded and executed in a safe-ish sandbox environment, nothing is ever truly safe and foolproof. Do not run projects you do not trust.

### Typical workflow

Let's say you have a Zephyr app like this:

```bash
app
├── boards
│   └── ...
├── include
│   └── ...
├── src
│   └── ...
├── CMakeLists.txt
├── LICENSE
├── prj.conf
└── README.md
```

1. Initialize the project with a `zconf.lua`, either manually or use:

```bash
zync init
```

This will create a `zconf.lua` file with some default settings:

```bash
app
├── boards
│   └── ...
.
.
.
├── README.md
└── zconf.lua # <- zync config in project
```

```lua
--- zconf.lua:
--- edit these configurations at your discretion
return {
    board = 'nucleo_f767zi',
    runner = 'openocd',
}
```

2. Configure and build the app:

```bash
zync conf # <- optional step, as zync build will configure project as well if not configured
zync build
```

3. Flash app

```bash
zync flash
```

or if using one of Zephyr's simulator boards (`native_sim` or `qemu` boards) run simulation:

```bash
zync run
```

### Sysbuild projects

When building a sysbuild project the workflow looks pretty much the same:

1. Init project with zync config
2. Config + build project
3. Flash project images / run simulations

#### Flashing a sysbuild image

Sysbuild projects often consist of multiple standalone apps all built by sysbuild. Let's consider a sysbuild project consisting of the `mcuboot` bootloader and a simple app compatible with `mcuboot` (let's call it just `app`), so the project would look something like this:

```bash
app
├── boards
│   └── ...
├── keys                # keys to sign our app and mcuboot
│   └── ...
├── src
│   └── ...
├── sysbuild            # additional sysbuild image configs
│   └── mcuboot.conf    # in our case that is mcuboot
├── CMakeLists.txt      # app cmake config
├── LICENSE
├── prj.conf            # app kconfig
├── README.md
├── sysbuild.conf       # sysbuild config
└── zconf.lua           # already initialized with zync config
```

This app, when built, would produce 2 separate images:

| Image | Description |
| -------------- | --------------- |
| `app` | Our app compatible with mcuboot |
| `mcuboot` | Mcuboot bootloader image |

You can list available images with the `--list` option passed to the `flash` or `run` command:

```bash
zync flash --list
```

Output:
```bash
Available sysbuild images:

1: app
2: mcuboot
```

To select an image to flash/run use the `--image` option and pass in either the image name (e.g. `app`) or the image number (e.g. `1`):

```bash
zync flash --image app
```

or

```bash
zync flash --image 1
```

If no image selected, zync will select the first non-bootloader image available.

### Build profiles

Build profiles enable zync to build multiple binaries for a selected project app. Useful when you want to build a binary targeting a board like `native_sim` for simulations and a real target board (e.g. `nucleo_f767zi`), or multiple boards without the need to re-configure the project or change the zync config.

#### Profile-less mode

Default config generated by zync is profile-less - has no configured build profiles. This is the standard approach of an app having a target configured and build results in a binary targeting that board. If the target board config is changed or provided by the `board` option, the app binary is rebuilt for the specified new board.

#### Project configured with build profiles

Build profiles are basically multiple zync configs assembled into one `zconf.lua`. Each build profile contains its own `board` and optionally `runner` config as a standalone `zconf.lua` would.

Let's say we have an app we want to build and simulate with `native_sim` during development, but our real target is `nucleo_f767zi`. Instead of having to re-configure and re-build the app every time, we can do this:

```lua
return {
    runner = 'openocd',

    profiles = {
        prod = {
            board = 'nucleo_f767zi',
        },
        sim = {
            board = 'native_sim',
        },
    },

    default_profile = 'sim',
}
```

Now we can conveniently configure the app for both `native_sim` and `nucleo_f767zi`, run simulations, flash and debug the app
without the need to re-configure and re-build the whole app every time.

#### Build output layout

- Profile-less: `./build/`
- With build profiles: `./build/<profile>/` (e.g., `./build/sim/`, `./build/prod/`)
- When build profiles are configured, zync keeps `./build/compile_commands.json` as a symlink to the default/first profile's `compile_commands.json` for editor/LSP indexing.

### Using build profiles

Let's return to our example app from the [Typical workflow](#typical-workflow):
```bash
app
├── boards
│   └── ...
├── include
│   └── ...
├── src
│   └── ...
├── CMakeLists.txt
├── LICENSE
├── prj.conf
├── README.md
└── zconf.lua

```

Add build profiles to the app's `zconf.lua`:
```lua
return {
    --- we don't need the global board option anymore
    --- as we'll configure the board for each build profile
    -- board = 'nucleo_f767zi',

    --- global runner option applies to all build profiles
    --- you can, however, set up the runner per profile
    runner = 'openocd',

    profiles = {
        prod = {
            board = 'nucleo_f767zi',
            --- runner config in a build profile overrides
            --- the global runner config for this build profile
            runner = 'stm32cubeprogrammer'
        },
        sim = {
            --- board config local to a build profile overwrites
            --- the global board setting in the same way as the runner option
            board = 'native_sim',
        },
    },
    default_profile = 'sim',
}

```

As we'd configured `sim` to be our default build profile, we can build the app and run simulation just as before. 

To build, flash, etc using our `prod` profile we'll use the `--profile` option:
```bash
zync --profile prod flash

```

If no profile is selected and no `default_profile` is configured in the zync config, zync will automatically select the first profile configured in the zync config (in this case it would be `prod`).

To run a profile-aware command across every profile, use the `--all/-a` flag (it overrides `--profile` and the default profile):
```bash
zync --all build
```

## Example app

For a simple example Zephyr app managed by `zync` see [Zynky](https://gitlab.com/byarocks/zynky.git).

## Upcomming features
- [x] Sysbuild project flash image
- [x] Sysbuild project run image
- [x] List available boards and runners
- [x] Safe zconf loading (no os.execute, no io.popen)
- [x] Logging progress
- [x] Flag to clean build before running a command
- [x] Support for multiple build profiles - multiple app builds/binaries (for simulation + multiple board targets)
- [x] Additional args configurable for different build phases
- [x] Build all profiles
- [x] Reference config values in string config options
- [ ] Better project traversal (launch from pretty much anywhere in the project as opposed to `build` or root)
- [ ] Hooks (pre/post config load, config, build, ...)
- [ ] More zync config options
- [ ] Key generation for mcuboot bootloader and images
- [ ] Project template generation (Zephyr app, module, sysbuild project)
- [ ] SPDX BoM generation
- [ ] Zephyr workspace management
- [ ] Simulation ramdisk/flashdisk management and seeding
