Skip to content

Ship a CLI tool as a Flatpak

This guide covers packaging a headless CLI tool built with gjsify build as a Flatpak. The companion guide for GUI apps is implicit in gjsify flatpak init’s default flags; the only thing that changes for CLI tools is the runtime permissions, not the runtime itself.

If you’re shipping a GTK/Adwaita app, run gjsify flatpak init (no --cli-only) and skip to Build the bundle.

A gjsify build output is already a single self-contained file (gjs -m bundle.js) — so why add Flatpak?

  • Distro-agnostic distribution. Users on Fedora, Ubuntu, Arch, openSUSE, Debian get the same binary with the same GJS version, regardless of what their distro packages.
  • Pinned runtime. GJS 1.86 / SpiderMonkey 140 today, GJS 1.88 next year — your Flatpak keeps targeting the runtime you tested against, not whatever’s on the user’s box.
  • No system dependencies. Need glib-compile-resources, blueprint-compiler, flatpak-builder itself for nested builds? Bundle them once, ship them everywhere.
  • Sandboxed file access. Easier to reason about (and audit) what host paths a CLI can touch — useful for code-generation tools that read system GIR files but shouldn’t write outside their working dir.

1. Why the runtime stays org.gnome.Platform

Section titled “1. Why the runtime stays org.gnome.Platform”

This is the load-bearing decision and the most common gotcha: a GJS bundle still needs the GJS interpreter at runtime, not just at build time.

RuntimeHas GJS?Verdict for gjsify build output
org.gnome.Platform✅ Yes (GJS + GLib + GIO + GObject + GTK + Adwaita)Use this.
org.freedesktop.Platform❌ No GJSAvoid for gjsify build output. Bundling GJS into the manifest module graph is the entire “build a Linux distribution” rabbit hole.

Your bundle imports through gi://*, system, cairo, gettext, and the @gjsify/* polyfills route every Node/Web API call to libsoup, GLib, Gio, GdkPixbuf, etc. — all of which the GNOME runtime ships, none of which Freedesktop does.

The unused GUI libs (GTK, Adwaita) cost nothing at runtime — Flatpak shares them across applications via the OSTree repo.

gjsify flatpak init --cli-only keeps org.gnome.Platform as the runtime and only strips the GUI finish-args (--device=dri, --socket=wayland, --socket=fallback-x11).

gjsify flatpak init --cli-only reads package.json#gjsify.flatpak and writes <app-id>.json:

package.json
{
"name": "ts-for-gir",
"version": "4.0.0",
"type": "module",
"gjsify": {
"flatpak": {
"appId": "org.gjsify.TsForGir",
"runtime": "gnome",
"runtimeVersion": "50",
"sdkExtensions": [
"org.freedesktop.Sdk.Extension.node24"
],
"command": "ts-for-gir",
"finishArgs": [
"--share=network",
"--filesystem=home",
"--filesystem=/usr/share/gir-1.0:ro",
"--filesystem=/usr/share/gobject-introspection-1.0:ro"
]
}
}
}
gjsify flatpak init --cli-only

Produces org.gjsify.TsForGir.json:

{
"id": "org.gjsify.TsForGir",
"runtime": "org.gnome.Platform",
"runtime-version": "50",
"sdk": "org.gnome.Sdk",
"sdk-extensions": [
"org.freedesktop.Sdk.Extension.node24"
],
"build-options": {
"append-path": "/usr/lib/sdk/node24/bin:/app/bin"
},
"command": "ts-for-gir",
"finish-args": [
"--share=network",
"--filesystem=home",
"--filesystem=/usr/share/gir-1.0:ro",
"--filesystem=/usr/share/gobject-introspection-1.0:ro"
],
"modules": [
{
"name": "TsForGir",
"buildsystem": "meson",
"sources": [{ "type": "dir", "path": "." }]
}
]
}

For a CLI that reads the host’s GObject-introspection repository (which is what ts-for-gir does to generate types), the two --filesystem=/usr/share/gir-1.0:ro and --filesystem=/usr/share/gobject-introspection-1.0:ro mounts are essential. Without them the CLI can run, but it has nothing to read.

--share=network is only needed if your CLI hits the network (e.g. for npm-registry access). Drop it if the tool is fully offline.

3. Replace the meson module if your tool ships without meson

Section titled “3. Replace the meson module if your tool ships without meson”

The gjsify flatpak init default assumes a Meson-built source tree (matches the GUI flatpak workflow). For a pure JS CLI tool, swap the module for a simple buildsystem with explicit build commands. Edit the manifest:

"modules": [
{
"name": "ts-for-gir",
"buildsystem": "simple",
"build-commands": [
"yarn install --immutable",
"gjsify build src/start.ts --outfile bin/ts-for-gir-gjs",
"install -Dm755 bin/ts-for-gir-gjs /app/bin/ts-for-gir"
],
"sources": [
{ "type": "git", "url": "https://github.com/gjsify/ts-for-gir.git", "tag": "v4.0.0" },
{ "type": "file", "path": "flatpak-node-sources.json" }
]
}
]

The second source — flatpak-node-sources.json — comes from gjsify flatpak deps (next step) and lets yarn install --immutable succeed inside Flatpak’s offline build sandbox.

4. Generate the offline node-modules cache

Section titled “4. Generate the offline node-modules cache”

Flatpak builds run with --share=network disabled by default. yarn install therefore needs a pre-populated cache. gjsify flatpak deps wraps the upstream flatpak-node-generator Python tool:

# One-time install of the wrapper:
pipx install flatpak-node-generator
# Generate the cache from your lockfile:
gjsify flatpak deps --lockfile yarn.lock --out flatpak-node-sources.json

The output is the JSON file you reference from your manifest’s sources: array.

Long-term goal: the gjsify ecosystem aims for a Node-free build chain. When gjsify install (a future Yarn replacement) and a GJS-native gjsify build exist, the Node SDK extension and flatpak-node-generator step both drop out. Tracked in STATUS.md → Node-free build chain. For now, Node 24 + flatpak-node-generator are part of the build-time (not runtime) story.

gjsify flatpak build wraps flatpak-builder with sensible defaults:

# Local install + Flathub-shaped tarball:
gjsify flatpak build org.gjsify.TsForGir.json --install --tarball org.gjsify.TsForGir.tar.gz
# Or: produce a portable single-file bundle for distribution:
gjsify flatpak build org.gjsify.TsForGir.json --repo repo --bundle org.gjsify.TsForGir.flatpak

After --install, your CLI is on PATH inside the Flatpak — try it:

flatpak run --command=ts-for-gir org.gjsify.TsForGir --version
flatpak run --command=ts-for-gir org.gjsify.TsForGir generate -g /run/host/usr/share/gir-1.0 --outdir=$HOME/types

gjsify flatpak ci scaffolds .github/workflows/flatpak.yml matching the upstream Flathub action shape:

gjsify flatpak ci --manifest org.gjsify.TsForGir.json --bundle org.gjsify.TsForGir.flatpak

The generated workflow runs on every push + PR to main, builds the manifest in the ghcr.io/flathub-infra/flatpak-github-actions:gnome-50 container, and uploads the .flatpak bundle as an artifact.

Re-running gjsify flatpak ci is idempotent: if the file already exists with byte-identical content, the command is a no-op. If you’ve hand-edited the workflow, the command refuses to overwrite without --force.

Flatpak sandboxes the user’s home dir by default. --filesystem=home (in the manifest above) opens the entire home — coarse but pragmatic for a CLI tool that may need to write generated code anywhere the user dropped them.

If your CLI only needs ~/.cache/<app-id>/ and ~/.config/<app-id>/, drop --filesystem=home and rely on Flatpak’s per-app XDG dirs:

"finish-args": [
"--share=network"
// No --filesystem=home — XDG_*_HOME is automatically per-app.
]

The CLI will see ~/.config and ~/.cache as writable per-app paths under ~/.var/app/<app-id>/.

Once the bundle works locally:

  1. Tag the release in your repo (git tag v4.0.0 && git push --tags).
  2. Update the manifest’s sources: git tag to match.
  3. Submit the manifest to Flathub by opening a PR against flathub/flathub.
  4. The Flathub bot rebuilds the bundle in their infra and publishes it to https://flathub.org/apps/org.gjsify.TsForGir.