LunaPack

    Application distribution packer built with MoonBit

    packaging
    distribution
    cli
    Download zip
    Author
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    2 hours ago
    Downloads
    3

    #LunaPack

    LunaPack is an application-distribution packer written in MoonBit. It bundles an application executable together with its resources into a single distributable file, and unpacks it again on the target machine with integrity checking.

    Repository: github.com/riantr/LunaPack (mirrored to gitee.com/ren-yongxiang/lunaPack for everyday storage).

    • SHA-256 checksums for every entry (verified on unpack).
    • gzip compression per entry, with an auto mode that only compresses when it actually helps.
    • Cross-platform: runs on Windows, Linux and macOS; packages carry their install scripts with the platform they target.
    • Native and Wasm: usable as a standalone binary or through moonx.

    #Releases

    Prebuilt binaries are attached to every GitHub release:

    AssetPlatform
    lunapack-windows-x86_64.exeWindows
    lunapack-linux-x86_64Linux
    lunapack-macos-aarch64macOS (Apple Silicon)
    lunapack-wasmWebAssembly, for moonx

    The release is built by a GitHub workflow when a version tag such as v0.1.0 is pushed; the same version is published to the Mooncakes registry, so moonx riantr/LunaPack@latest resolves to it.

    The registry requires a 0.x.y version while it is in its early stage, so the first releases are numbered 0.1.0, 0.2.0 and so on.

    #Usage

    #Via moonx (published on Mooncakes)

    moonx riantr/LunaPack@latest \ --input-exe=[input-exe-path] \ --output-exe=[output-exe-path] \ --Files=[Pack-Folder]

    FlagMeaning
    --input-exethe application executable to distribute
    --output-exethe produced single-file bundle
    --Filesfolder of files to bundle with it (repeatable)

    The result is a self-extracting bundle: the LunaPack launcher with the archive appended, exactly the trick a WinRAR or 7-Zip self-extracting archive uses.

    myapp.exe = [ LunaPack launcher ] + [ archive: myapp.exe, res/app.cfg, ... ]

    Starting it extracts the payload and launches the bundled program:

    $ ./myapp.exe LunaPack: starting D:/apps/.lunapack/8e75898941d4c137/myapp.exe

    The payload is cached in .lunapack/<hash>/ next to the executable, so the second start is immediate. Delete that folder to force a fresh extraction.

    Because the launcher does the extraction, the application finds its own dependencies (.dll, .json, assets) exactly like it would in its build folder - a plain copy of the executable would not run at all.

    #The icon

    When the application carries an icon, the bundle gets it as well, so Explorer shows the application's own icon instead of the launcher's:

    $ lunapack --input-exe=./BRLabeler.exe --output-exe=./BRLabeler.exe --Files=./build bundled 392 files -> ./BRLabeler.exe (icon copied)

    The icon is read from the application executable with a small PE reader and written into the bundle with the Windows resource API, which creates the resource section the launcher does not have. All icon sizes are copied, so the bundle looks right at every zoom level.

    This is a Windows feature: an icon is a Windows resource, so on Linux and macOS the bundle keeps the icon of the launcher. Use --Files with the application's own .ico file there, or install the icon separately.

    lunapack unpack recognises the appended archive automatically, so the same file can be verified and extracted again:

    lunapack verify myapp.exe lunapack info myapp.exe lunapack unpack -i myapp.exe -o ./out

    #As a native binary

    moon build --release # -> _build/native/release/build/cmd/main/main.exe (Windows) # -> _build/native/release/build/cmd/main/main (Linux / macOS)

    lunapack pack -i <input_dir> -o <output.appkg> [options] lunapack unpack -i <package> -o <dest_dir> [--skip-scripts] lunapack verify <package> lunapack info <package>

    Flags accept both --flag=value and --flag value.

    #Options

    OptionMeaning
    --name <name>package name (default: input dir / exe name)
    --version <ver>package version (default: 0.1.0)
    --os <os>target OS tag (default: detected)
    --arch <arch>target arch tag (default: detected)
    --pre-install <file>script executed before extraction
    --post-install <file>script executed after extraction
    --compression <mode>none, gzip or auto (default: auto)

    #Example

    # Bundle an application and its resources into one self-extracting file. lunapack --input-exe=./build/myapp \ --output-exe=./dist/myapp \ --Files=./build # Or produce a plain archive instead. lunapack pack -i ./myapp -o myapp.appkg \ --name myapp --version 1.4.0 \ --pre-install setup.sh --post-install setup.sh lunapack verify myapp.appkg lunapack unpack -i myapp.appkg -o /opt/myapp

    On Windows, with the paths of a real .NET application:

    lunapack ^ --input-exe="D:/src/App/bin/Release/net10.0-windows/App.exe" ^ --output-exe="D:/dist/App.exe" ^ --Files="D:/src/App/bin/Release/net10.0-windows"

    The contents of a single --Files directory are stored at the root of the archive next to the main program, so the extracted application is directly runnable. With several --Files directories each one gets a subdirectory named after it. The main program is never stored twice, even when it also lives inside the --Files folder.

    #Publishing to Mooncakes

    moonx resolves a package from the registry, so publish the module first:

    moon login # once moon publish --dry-run # validate moon publish # publish riantr/LunaPack moonx riantr/LunaPack@latest --input-exe=... --output-exe=... --Files=...

    The module publishes with supported_targets = "native+wasm"; moonx runs the Wasm build, which reads and writes the real filesystem through the MoonBit runtime.

    #Install scripts

    The script body is stored verbatim inside the manifest together with the platform it targets. The platform is inferred from the file extension:

    ExtensionTarget platform
    .cmd, .bat, .ps1windows
    .shposix (Linux + macOS)
    anything elseany

    At unpack time the script matching the host platform runs; a script for a different platform is skipped, so one package can safely be unpacked anywhere. On Windows the script runs through cmd /c, on Linux/macOS through sh.

    The script runs with the destination directory as its working directory and receives two environment variables:

    • LUNAPACK_DEST - absolute path of the destination directory
    • LUNAPACK_PHASE - pre_install or post_install

    Use --skip-scripts to unpack without running them.

    #Package format (v2)

    +--------------------+ offset 0 | magic "APPKGD02" | 8 bytes +--------------------+ offset 8 | entry payloads | stored back to back, raw or gzip +--------------------+ | manifest (UTF-8 | | JSON) | +--------------------+ | magic "APPKGD02" | 8 bytes | manifest offset | 8 bytes, little-endian int64 | manifest length | 8 bytes, little-endian int64 +--------------------+ end of file

    The manifest records the package metadata plus one record per entry:

    { "format_version": 2, "name": "myapp", "version": "1.4.0", "os": "linux", "arch": "x86_64", "checksum_algo": "sha256", "compression": "auto", "created_at": "2026-11-03T06:55:56Z", "main_program": "myapp.exe", "pre_install": { "platform": "posix", "body": "#!/bin/sh\n..." }, "post_install": null, "entries": [ { "path": "res/app.cfg", "mode": 420, "size": 22500, "stored_size": 913, "codec": "gzip", "checksum": "0a77c24c45d0c61f906862c4706f5dd739166985e8fc5c6344dadd9220507b28", "offset": 8 } ] }

    • size is the original file size, stored_size the space it occupies in the package.
    • checksum is the SHA-256 of the original content, so it validates the data the user actually gets, not the compressed form.
    • mode holds UNIX permission bits (0o644 / 0o755); they are ignored on Windows.
    • main_program names the entry that is the application executable.

    A bundle is the same archive appended to an executable. The archive length is manifest_offset + manifest_length + 24, so the archive can be located from the tail of the file without scanning it.

    #Testing

    moon test # unit + integration tests bash scripts/test_linux.sh # end-to-end suite (Linux / WSL)

    scripts/test_linux.sh covers packing, bundling, checksums, compression, the executable bit, install-script execution, corruption detection and --skip-scripts.

    #Notes and limitations

    • main must return Unit in this MoonBit version, so the process exit code is always 0. Status is reported on stdout (VERIFY OK / VERIFY FAILED / error: ...); scripts that need an exit code should grep for those markers.
    • UNIX permission bits cannot be set on Windows, so they are only applied on POSIX hosts.
    • The checksum is computed over the original content; verify therefore has to decompress every entry.
    • The launcher stub is the LunaPack executable of the machine that built the bundle, so a bundle runs on the platform it was created on. Under moonx (Wasm) no native stub exists, so the bundle falls back to carrying the payload inside the application executable - such a bundle holds the data but does not extract itself.
    • The extraction cache lives next to the bundle (.lunapack/<hash>/). When that folder cannot be written, a temporary directory is used instead.
    • Copying the icon is Windows only, because an icon is a Windows resource.
    • Bundling writes the archive to a temporary file next to the output, so there must be enough free disk space for it.

    Entry

    pub struct Entry {
    path : String
    mode : Int
    size : Int64
    stored_size : Int64
    codec : String
    checksum : String
    offset : Int64
    }

    One file inside the package.

    Manifest

    pub struct Manifest {
    format_version : Int
    name : String
    version : String
    os : String
    arch : String
    checksum_algo : String
    compression : String
    created_at : String
    main_program : String
    pre_install : Script?
    post_install : Script?
    entries : Array[Entry]
    }

    Package metadata plus the list of entries.

    PackOptions

    pub struct PackOptions {
    name : String
    version : String
    os : String
    arch : String
    pre_install : Script?
    post_install : Script?
    compression : String
    main_program : String
    }

    Options accepted by pack.

    PeFile

    pub struct PeFile {
    data : Bytes
    sections : Array[(Int64, Int64, Int64, Int64)]
    resource_rva : Int64
    resource_size : Int64
    }

    A parsed PE image.

    Script

    pub struct Script {
    platform : String
    body : String
    }

    An install script together with the platform it targets.

    A package built on Windows can carry a Windows batch script while a package built on Linux carries a POSIX shell script; the matching one is executed at unpack time and the other is skipped.

    CHECKSUM_ALGO

    let CHECKSUM_ALGO : String

    Checksum algorithm used for every entry payload.

    FORMAT_VERSION

    let FORMAT_VERSION : Int

    Format version recorded in the manifest.

    MAGIC

    let MAGIC : String

    Magic marker at the head and inside the footer of every .appkg file. v2 = SHA-256 checksums + optional gzip compression + install scripts.

    RT_GROUP_ICON

    let RT_GROUP_ICON : Int64

    RT_ICON

    let RT_ICON : Int64

    Resource type ids used by the icon resources.

    basename

    fn basename(p : String) -> String

    The base name of a path, without any directory part.

    bundle

    async fn bundle(input_exe : String, output_exe : String, file_dirs : Array[String], opts : PackOptions) -> Unit

    Bundle input_exe together with the contents of every directory in file_dirs into the single file output_exe.

    The result is the original executable with a LunaPack archive appended, so it stays a runnable executable while carrying the extra files. Use unpack on the result to extract the payload again.

    The contents of a single --Files directory land at the root of the archive, next to the main program, so an extracted application is directly runnable. With several directories each one gets a subdirectory named after it, to keep the paths unique.

    collect_files

    async fn collect_files(root : String) -> Array[String]

    Collect every regular file under root, recursively.

    compress_if_smaller

    async fn compress_if_smaller(data : Bytes, always : Bool) -> (Bytes, String)

    Compress data only when that actually makes it smaller. Returns the payload to store plus the codec name to record in the manifest.

    copy_icon

    async fn copy_icon(source_exe : String, target_exe : String) -> Bool

    Copy the icon of source_exe into target_exe.

    The icon is a Windows resource, so this is a no-op elsewhere.

    current_exe_path

    fn current_exe_path() -> String?

    Path of the running executable, when it can be determined.

    default_arch

    fn default_arch() -> String

    Best-effort detection of the host CPU architecture. Windows exposes PROCESSOR_ARCHITECTURE; some POSIX shells export HOSTTYPE.

    dirname

    fn dirname(p : String) -> String

    The directory part of a path, or "" when the path has no directory.

    ensure_parent_dir

    async fn ensure_parent_dir(path : String) -> Unit

    Create the parent directory of path when it does not exist yet.

    extract_icon

    fn extract_icon(pe : PeFile) -> (Int64, Bytes, Array[(Int64, Bytes)])? raise

    The icon of a PE image.

    Returns the id of the group icon, the group icon itself and every icon it references, or None when the image carries no icon.

    extract_to

    async fn extract_to(pkg : String, dest : String) -> Unit

    Extract the payload of pkg into dest silently.

    Used by the self-extracting launcher, which does not want to list every file it writes.

    find_resource

    fn find_resource(pe : PeFile, rtype : Int64, name : Int64, lang : Int64) -> (Int64, Int64)? raise

    The payload of a resource, as (offset in the file, size in bytes).

    rtype and name are the numeric resource type and name, lang the language id; use 0 for the first match and a negative name to accept the first entry of that type.

    gzip_compress

    async fn gzip_compress(data : Bytes) -> Bytes

    Compress data with gzip.

    gzip_decompress

    async fn gzip_decompress(data : Bytes) -> Bytes

    Decompress gzip-compressed data.

    host_platform

    fn host_platform() -> String

    Name of the platform the program is running on.

    One of "windows", "linux", "macos" or "unknown".

    i64_to_le8

    fn i64_to_le8(v : Int64) -> Bytes

    8-byte little-endian encoding of an Int64 into Bytes.

    is_native_target

    fn is_native_target() -> Bool

    Is this build a native (directly runnable) target?

    iso8601_utc

    fn iso8601_utc(epoch_millis : UInt64) -> String

    Format epoch milliseconds as an ISO-8601 UTC timestamp, e.g. 2026-11-03T14:35:55Z. (@env.now() reports milliseconds.)

    json_to_manifest

    fn json_to_manifest(text : String) -> Manifest raise

    Parse the manifest JSON back into a Manifest.

    le8_to_i64

    fn le8_to_i64(b : Bytes, start : Int) -> Int64

    Read an 8-byte little-endian Int64 from b starting at byte start.

    magic_bytes

    fn magic_bytes() -> Bytes

    The 8 magic bytes that open the package and close the footer.

    manifest_to_json

    fn manifest_to_json(m : Manifest) -> String

    Serialize a manifest into the canonical UTF-8 JSON representation.

    new_pack_options

    fn new_pack_options(name : String, version : String, os : String, arch : String, pre_install : Script?, post_install : Script?, compression : String) -> PackOptions

    Constructor so callers in other packages can build PackOptions without relying on cross-package struct literals (which MoonBit disallows).

    new_script

    fn new_script(platform : String, body : String) -> Script

    Constructor so other packages can build a Script.

    norm_sep

    fn norm_sep(p : String) -> String

    Normalize path separators to '/' so host FS calls work uniformly on Windows (which returns '' from realpath) and inside the package format.

    pack

    async fn pack(input : String, output : String, opts : PackOptions) -> Unit

    Bundle every file under input into a .appkg file at output.

    package_tag

    fn package_tag(m : Manifest) -> String

    Short, stable identifier of a package, used to name the cache folder.

    package_tag_of

    fn package_tag_of(text : String) -> String

    Short, stable identifier for arbitrary text.

    parse_pe

    fn parse_pe(data : Bytes) -> PeFile raise

    Parse the headers of a PE image.

    probe_archive

    async fn probe_archive(path : String) -> (Int64, Manifest)?

    Locate the archive inside path, when there is one.

    Returns the offset of the archive and its manifest, or None when the file carries no LunaPack archive.

    read_at

    fn read_at(pe : PeFile, off : Int64, size : Int64) -> Bytes

    Read size bytes at off from the image.

    read_package_manifest

    async fn read_package_manifest(pkg : String) -> Manifest

    Read the manifest of a package file.

    relpath

    fn relpath(base : String, full : String) -> String

    Path of full relative to base.

    run_install_script

    async fn run_install_script(platform : String, kind : String, script : Script, dest : String) -> Int

    Run an install script of kind inside dest and return its exit status.

    platform selects the interpreter, which makes the POSIX path testable on any host.

    script_applies

    fn script_applies(script_platform : String, host : String) -> Bool

    Does a script targeting script_platform apply on host?

    "any" always applies, "posix" covers Linux and macOS.

    script_platform_of

    fn script_platform_of(path : String) -> String

    Infer the target platform of a script from its file extension.

    set_permissions

    async fn set_permissions(platform : String, path : String, mode : Int) -> Unit

    Set UNIX permission bits on path.

    Windows has no UNIX permission bits and the async runtime's chmod is not implemented there, so the call is skipped on Windows.

    sha256_hex

    fn sha256_hex(data : Bytes) -> String

    Lowercase hex SHA-256 digest of data (64 characters).

    sha256_hex_of_string

    fn sha256_hex_of_string(s : String) -> String

    Lowercase hex SHA-256 of the UTF-8 encoding of s.

    shell_for

    fn shell_for(platform : String) -> (String, Array[String], String)

    How a script must be executed on platform: (interpreter, extra arguments, script file extension).

    Windows batch scripts need cmd /c; POSIX shells need plain sh.

    string_to_utf8_bytes

    fn string_to_utf8_bytes(s : String) -> Bytes

    Encode a String into UTF-8 bytes. NOTE: MoonBit strings are UTF-16 internally, so String::to_bytes yields UTF-16 and must not be used for on-disk data. The bundled @utf8 codec gives real UTF-8.

    try_launch_self_extracting

    async fn try_launch_self_extracting() -> Bool

    Extract the payload carried by the running executable and start the bundled program.

    Returns false when the executable carries no archive, in which case the caller should behave like an ordinary command line tool.

    unpack

    async fn unpack(pkg : String, dest : String, run_scripts : Bool) -> Unit

    Extract a package into dest.

    run_scripts controls whether the embedded pre_install / post_install scripts are executed.

    utf8_bytes_to_string

    fn utf8_bytes_to_string(bs : Bytes) -> String

    Decode UTF-8 bytes into a String. NOTE: Bytes::to_string() returns the debug representation b'\x..', not the decoded text, so it must not be used here.

    verify

    async fn verify(pkg : String) -> Bool

    Check every entry of a package: magic, sizes and SHA-256 digests.

    with_defaults

    fn with_defaults(opts : PackOptions, name : String, version : String, os : String, arch : String) -> PackOptions

    Return a copy of opts with the given metadata fields replaced.

    with_main_program

    fn with_main_program(opts : PackOptions, name : String) -> PackOptions

    Return a copy of opts whose main program is name.

    Powered by MoonBit

    Site sourceReport issueModulesSkillsBuild queueStatistics

    © 2026 mooncakes.io