Skip to content

Ship your app

Your app runs on your machine. This section is about getting it onto someone else’s.

Ship it
Terminal
gjsify ship
Terminal
npx @gjsify/cli ship
Terminal
bunx @gjsify/cli ship
Terminal
deno run -A npm:@gjsify/cli ship

That runs your project’s build script, collects everything the app needs into one payload under ship/stage/, and wraps that payload once per format into ship/out/. You write no manifest, no meson file and no spec file.

gjsify ship <os> says which layout to assemble. A bare gjsify ship assembles the layout of the host you are on, so name the operating system when you want a different one.

CommandWhat lands in ship/out/That target runs
gjsify ship linuxmy-app_1.2.3-1_all.deb, my-app-1.2.3-1.noarch.rpmgjs or node
gjsify ship darwinMy App.app, my-app-1.2.3-1.macos.arm64.zipnode
gjsify ship windowsMy App/, my-app-1.2.3-1.windows.x64.zipnode

Each target picks its own runtime from gjsify.ship.app.<os>, keyed linux, darwin and win32, falling back to gjsify.app. So one project can be GJS on Linux and Node where no GJS exists.

Assembling is never host-bound, so one workstation produces all three artifact sets. Packing is another matter: two of the nine formats need a particular host. See where each format can be packed.

A Linux package declares its interpreter and its GTK libraries as dependencies and takes them from the distribution. The macOS bundle and the Windows program directory carry them inside, as described below.

Neither operating system ships GTK, and neither has a GJS you could put in a download, so the darwin and windows layouts carry a Node interpreter and a GTK closure and run node only. Set that for those targets alone, and the Linux package of the same project stays on GJS:

JSON
{
"gjsify": {
"app": "gjs", // Linux keeps it
"ship": { "app": { "darwin": "node", "win32": "node" } }
}
}

Setting gjsify.app to "node" works too, and moves every target with it, including the Linux .deb from Depends: gjs to Depends: nodejs.

gjsify ship resolves the interpreter and the closure by name out of your node_modules, so they are installed on the machine that packages the app, not on the one that runs it. Each operating system has its own pair, listed on its page. @gjsify/node-gi is a dependency rather than a devDependency, because a --app node bundle keeps @gjsify/node-gi/* external and requires it at run time:

JSON
{
"devDependencies": {
"@gjsify/node-runtime-<os>-<arch>": "^0.44.0", // Node and its LICENSE
"@gjsify/gtk-runtime-<os>-<arch>": "^0.44.0" // GTK and GObject-Introspection
},
"dependencies": { "@gjsify/node-gi": "^0.44.0" }
}

When one is missing, ship names the package to install and still produces an artifact. That artifact starts only where Node and GTK are already installed, so use it as an intermediate and do not hand it to a user. For a patched or unpublished build, GJSIFY_NODE_RUNTIME names a directory holding the node binary (node.exe on Windows) and its LICENSE, and GJSIFY_GTK_RUNTIME one holding the GTK closure.

The .desktop entry and the AppStream component travel along and are never read, because neither macOS nor Windows reads them. Ship lists every such file on each run. The zips carry the operating system in their filename, macos or windows, because .zip is the one suffix two layouts share.

The same fields feed all three operating systems. Set these before the first run.

package.jsonWhat it becomesOverride
namethe package name and the launcher’s filenamegjsify.ship.binaryName
versionthe version in every artifactgjsify.ship.version
license (an SPDX id such as "MIT")a required field in .deb and .rpmgjsify.ship.license.project
author as "Name <you@example.org>"Maintainer:, Packager: and the MSI’s Publishergjsify.ship.maintainer
gjsify.main (or main)the built bundle the launcher runsgjsify.ship.bundle
gjsify.appthe interpreter the launcher execs, gjs -m or node. Ship refuses a package whose launcher and declared dependency disagreegjsify.ship.app.<os>, per target
scripts.buildthe build step ship runs firstpass --skip-build instead

Two more you have to set yourself, because nothing can derive them:

  • gjsify.ship.appId, a reverse-DNS id such as org.example.MyApp. It names the desktop entry, the AppStream component, the installed icon, the macOS CFBundleIdentifier and the MSI upgrade code. gjsify.flatpak.appId is used when it is set, and a reverse-DNS package name is used after that.
  • gjsify.ship.name, the display name. It becomes the .desktop Name=, the <App>.app directory, the Windows program directory and the Start-Menu entry. Without it ship title-cases the binary name.

Icons under data/icons/, GSettings schemas under data/, and a LICENSE, COPYING or LICENSE.md in the project root are found on their own.

Your version is rewritten on the way in, because npm and dpkg disagree about prereleases. A leading v is dropped, +buildmetadata is dropped with a warning, and 1.2.0-rc.1 becomes 1.2.0~rc.1. Both dpkg and rpm sort 1.2.0~rc.1 before 1.2.0, the way npm does, while they read 1.2.0-rc.1 as release rc.1 of version 1.2.0 and sort it after. A version neither format can spell stops the run.

Ten formats wrap three layouts. Seven of them pack on any host, offline, with no tool outside this CLI. Three do not, and ship refuses those on the wrong host rather than writing a broken file.

FormatLayoutIn the default setPacks onNeeds installed
deblinuxyesany hostnothing
rpmlinuxyesany hostnothing
flatpaklinuxnoLinuxflatpak-builder, flatpak
appimagelinuxnoLinuxappimagetool, which no distribution packages
macos-appdarwinyesany hostglib-compile-schemas
macos-app-zipdarwinyesany hostglib-compile-schemas
macos-app-dmgdarwinnomacOShdiutil, which is part of macOS
windows-dirwindowsyesany hostglib-compile-schemas
windows-dir-zipwindowsyesany hostglib-compile-schemas
msiwindowsnoLinux or Windowswixl from msitools, or WiX Toolset v3.14

appimage is the one row that embeds something this project did not write: the AppImage runtime the finished file starts with. gjsify ship passes a pinned one when your machine has it at /usr/local/share/gjsify/appimage-runtime/runtime-<arch> (or wherever GJSIFY_APPIMAGE_RUNTIME_DIR points), and says so on every pack. Without one, appimagetool downloads a runtime, so that pack needs a network, and the pack log says that too.

glib-compile-schemas ships in glib2 on Fedora and libglib2.0-bin on Debian and Ubuntu. It is a tool rather than a host, so the four formats that declare it still pack anywhere. A layout other than Linux has no install step to compile your GSettings schemas later, so ship compiles them while it assembles.

A missing tool and a wrong host are separate messages, because the fixes differ, and both fire before your build script runs. Asking for a format that wraps another operating system’s layout is an error by name, so gjsify ship darwin --target deb stops immediately.

The .dmg needs a Mac and the .msi needs wixl or WiX. --stage and --from-stage are how you produce them from a host that has neither.

Terminal
gjsify ship darwin --stage --target macos-app,macos-app-zip,macos-app-dmg
# move ship/stage/ to a Mac, then:
gjsify ship --from-stage ./stage --target macos-app-dmg

--stage writes the payload and stops, leaving ship/stage/.gjsify-ship-stage.json beside it. That file records the file modes, the pre-rendered licence overlays, the GI namespaces read out of your bundle and the build stamp, so a packing host needs nothing else. --from-stage reads no project at all. No package.json, no config, no built bundle. That is what lets one CI job assemble and another finish.

Name every format you intend to pack in the --stage run. Phase one renders one licence overlay per format, and a format the stage never saw is refused on the packing host:

Text
gjsify ship: this stage was assembled for deb, and --target names rpm.

--expect-target <os>-<arch> on the finish step refuses a stage assembled for a different matrix leg, so a job that downloaded the wrong artifact stops instead of packing it. It compares against the value the stage recorded, not against the machine you are packing on, because packing an arm64 stage on an x64 runner is a supported path.

Terminal
gjsify ship --from-stage ./stage --expect-target darwin-arm64

One check needs no flag and cannot be turned off. If the payload carries a native image whose ELF e_machine or Mach-O cputype contradicts the architecture the artifact is being labelled with, ship refuses and names the file and both architectures.

Everything lands under ship/, which --out <dir> moves.

Text
ship/
├── stage/ the payload, exactly as a user gets it
│ └── .gjsify-ship-stage.json
├── overlay/<format>/ what one format wants elsewhere, today the licence
├── schemas/ off Linux: where gschemas.compiled is built
├── flatpak/ --target flatpak: the generated manifest and repo
└── out/ the artifacts

Both packers read ship/stage/ back off disk rather than keeping it in memory, so what you inspect there is what a user installs. --verbose prints every staged file, the GI namespaces the bundle imports, and every tool a packer runs.

gjsify ship is one option of several, and they stack. The same built bundle feeds all of them.

You wantReach forGuide
A file a Linux user installs with their own package managergjsify ship linuxLinux packages
A single Flatpak file, sandboxed, with a pinned GTK on every distrogjsify ship linux --target flatpakLinux packages
One portable file for someone else’s distro, with no install and no rootgjsify ship linux --target appimageLinux packages
An application a Mac user drags into /Applicationsgjsify ship darwinmacOS app bundles
An installer a Windows user double-clicksgjsify ship windows --target msiWindows artifacts
A GUI app on Flathub, with the manifest and metadata files in your repogjsify flatpak initFlatpak: GUI app
The same for a headless command-line toolgjsify flatpak init --kind cliFlatpak: CLI tool
A curl … | gjs one-liner that installs into ~/.local, no rootgjsify generate-installerOne-line installer
A single executable file people download, chmod +x and rungjsify build --shebangSelf-executing bundle
No installation at all, run it straight from npmpublish a dlx-friendly packageRun via dlx

Choose gjsify ship when you want a user to install once and forget about it. Add gjsify flatpak init on top when you are submitting to Flathub, which wants the manifest and the AppStream files committed in your repository. The one-line installer suits early adopters while you are still iterating, and dlx suits a tool nobody wants permanently installed.

The AppImage and the Flatpak bundle are both one file with no repository behind it, and they answer different questions. An AppImage needs no installation and no root at all, and takes GJS and GTK from the machine it runs on. gjsify ship prints that list, and the file repeats it if the runtime is missing. A Flatpak carries its own runtime and needs flatpak installed to put it anywhere.