CLI Reference
gjsify is the only binary a GJSify project needs. It scaffolds, builds, runs, tests, formats, packages and publishes.
Get it with the runtime you already have. The bootstrap script installs a standalone gjsify and is itself run by gjs, so it works on a machine with no Node on it:
curl -fsSL https://github.com/gjsify/gjsify/releases/latest/download/install.mjs \ -o /tmp/g.mjs && gjs -m /tmp/g.mjs && rm /tmp/g.mjsThe three package runners fetch the CLI from npm and run it in place:
npx @gjsify/cli@latest <command>bunx @gjsify/cli@latest <command>deno run -A --reload --min-dep-age=0 npm:@gjsify/cli@latest <command>See Install & Update for the details.
Keep the @latest tag. All three runners reuse a cached copy of an unpinned bin, so a plain npx @gjsify/cli … can go on serving a release from months ago. Deno adds a second rule and refuses anything published in the last 24 hours. Neither one tells you it happened. Which version do npx, bunx and deno run give you? has the measurement. Every example below writes plain gjsify. Swap in whichever launcher you use.
gjsify --help lists every command, and its last line tells you which runtime the CLI itself is on, for example Running on GJS 1.88.1 (SpiderMonkey) or Running on Node.js v24.x.y. Each command prints its own flags with gjsify <command> --help. The two pass-through commands, run and tsc, hand --help to the target they launch instead. That host runtime picks the default --app target for gjsify build and the default --runtime for gjsify run and gjsify storybook.
Commands at a glance
Section titled “Commands at a glance”| Task | Commands |
|---|---|
| Start a project | create |
| Build and run | build · dev · run · test · clear · copy |
| Dependencies | install · uninstall · prune · upgrade · dlx · self-update · generate-installer |
| Monorepos | foreach · workspace · affected |
| Code quality | check · tsc · format · lint · fix · barrels |
| GNOME assets | gresource · gsettings · gettext |
| Environment | system-check · info |
| Explore | showcase |
| Debug a running app | storybook · debug · browse |
| Ship it | ship · flatpak |
| Publish to npm | pack · publish · whoami · login · logout · trust · onboard |
Start a project
Section titled “Start a project”gjsify create
Section titled “gjsify create”Scaffold a new project into a new directory.
gjsify create my-app --template gtk-minimalgjsify create my-app --template cli --runtime denogjsify create my-app --template cli --package-manager pnpm --installgjsify create # pick template, runtime and manager interactivelyOn a terminal it asks three questions in order (template, runtime, package manager), each narrowing the next. Every one can be answered by a flag instead, which is how you drive it without a TTY. npm create @gjsify/app is the same scaffolder and takes the same flags.
| Option | Default | Description |
|---|---|---|
[project-name] | my-gjs-app | Directory to create. |
-t, --template <name> | prompted | Which template to scaffold from. Required when stdin is not a TTY. |
-r, --runtime <rt> | the host runtime | One of the runtimes the chosen template declares. Decides which package managers are on offer and which start script the next steps name. |
-p, --package-manager <pm> | the runtime’s first | Must be one the chosen runtime can install for. Required alongside --install when there is no TTY, since that default would write your node_modules and lockfile. |
-f, --force | false | Scaffold into a directory that already has files in it. |
--install | false | Run an install right after scaffolding. |
An installer has to produce the module layout its runtime resolves against, so the runtime decides which managers are offered: gjs → gjsify; node → npm, yarn, pnpm, gjsify; bun → bun; deno → deno. Where a runtime offers exactly one, nothing is asked. gjsify uses it and says so. Passing -p without -r settles the runtime too, since a pinned manager already names one (-p bun sets the project up for Bun).
The templates:
| Template | What you get |
|---|---|
gtk-minimal | A Gtk.ApplicationWindow declared in Blueprint. No Adwaita. |
adw-canvas2d | Adwaita app rendering through HTML Canvas 2D, Blueprint UI. |
adw-webgl | Adwaita app with WebGL and three.js, Blueprint UI. |
adw-game | Adwaita game shell on Excalibur.js, WebGL with a Canvas2D fallback. |
cli | Command-line tool built on yargs. |
web-server-express | HTTP server on Express. |
web-server-hono | HTTP server on Hono, fetch-style API. |
Every template ships src/, a tsconfig.json and a package.json with build, start, dev, check and clear scripts. All seven declare gjs, node, bun and deno in gjsify.example.runtimes and build both bundles (build:gjs and build:node). So -r only decides which start script the printed next steps name: start for gjs, start:node, start:bun or start:deno for the others. It does not limit what the project can do later. The GTK templates list @gjsify/node-gi as a dependency, which is what carries gi:// on the three non-GJS runtimes. Scaffolding is done by @gjsify/create-app.
Build and run
Section titled “Build and run”gjsify build
Section titled “gjsify build”Compile and bundle with Rolldown. Node.js and Web API imports are aliased to their @gjsify/* equivalents automatically, so import { readFileSync } from 'node:fs' works on GJS with no configuration.
gjsify build src/index.ts --outfile dist/index.jsgjsify build src/index.ts --outfile dist/index.js --no-minify # readable outputgjsify build src/index.ts --watch # rebuild on change| Option | Values | Default | Description |
|---|---|---|---|
[entryPoints..] | paths | bundler.input, else src/index.ts | Entry points to bundle. |
--app | gjs | node | browser | nativescript | the host runtime’s target | Build target. Under GJS you get gjs; under Node, Bun or Deno you get node. Bun and Deno consume the same node bundle, so they have no target of their own. Override here or with gjsify.app in package.json. |
-o, --outfile | path | from package.json | Output file (application mode). |
-d, --outdir | path | from package.json | Output directory (library mode). |
--minify | bool | true | Minify the output. Pass --no-minify for pretty-printed code. |
--globals | string | auto | Which globals to inject. See Globals. |
--dialect | react-native | none | Build a React Native application for a desktop target. Aliases react-native to @gjsify/react-native so your files keep the import they already have, and fails the build on a name that layer does not implement, naming the file and the line. Opt-in only, on --app gjs and --app node; also readable as gjsify.dialect in package.json. |
--gi-renderer | bool | false | Resolve gi://Ns?version=X to the target’s widget renderer instead of to an empty module, so import Adw from 'gi://Adw?version=1' is the same line on every target. --app browser answers gi://Adw and gi://Gtk out of @gjsify/adwaita-web, --app nativescript out of @gjsify/adwaita-nativescript; @girs/adw-1 reaches the same namespace. A namespace with no renderer, and a ?version= the renderer’s vocabulary was not generated against, both FAIL the build by name, and reading a widget the renderer does not ship throws naming it. Opt-in only, on those two targets. |
--exclude-globals | list | none | Identifiers to drop from the auto-detected set, for false positives out of dead compat code (--exclude-globals fetch,XMLHttpRequest). |
--shebang | bool | false | Prepend a target-appropriate shebang and chmod 755 the output: #!/usr/bin/env -S gjs -m for --app gjs, #!/usr/bin/env node for --app node. Needs a single --outfile. |
-w, --watch | bool | false | Watch sources and rebuild on change, logging each rebuild with its duration. Ctrl-C stops it cleanly. Rejected with --library, and it needs the npm rolldown engine, so run it under Node. On GJS use gjsify dev, which needs no watcher API and relaunches the app too. |
--verbose | bool | false | Print detected globals and build details. |
The rest of the build flags
| Option | Values | Default | Description |
|---|---|---|---|
--format | iife | esm | cjs | auto | Override the output format. |
--library | bool | false | Build a reusable library instead of an application. |
-r, --reflection | bool | false | Enable TypeScript runtime types via Deepkit’s type compiler. |
--console-shim | bool | true | Inject the GJS console shim, so output has no GLib prefix and ANSI colours work. --no-console-shim turns it off. GJS app builds only. |
--exclude | glob[] | [] | Glob patterns to exclude from entry points and aliases. |
--log-level | silent | error | warning | info | debug | verbose | warning | Bundler log level. |
--external | name[] | [] | Package names that stay as runtime imports instead of being bundled. Exact names only, no globs. Repeat the flag or pass a comma-separated list. Appended to the built-in externals for the target. <pkg>/register subpaths are always inlined for --app gjs, whatever you pass here. |
--define | KEY=VALUE[] | [] | Compile-time constants. VALUE is a JS expression, so string literals need quoting: --define VERSION='"1.2.3"'. Repeatable. |
--alias | FROM=TO[] | [] | Extra module aliases on top of the built-in map. Handy for stubbing heavy deps: --alias typedoc=@gjsify/empty. Repeatable. |
For --app gjs the JS target is firefox140 (SpiderMonkey 140), and gi://*, cairo, system and gettext stay external. For --app node the target is node24.
Native N-API addons on GJS. A --app gjs build routes a compiled .node addon through @gjsify/napi’s loadAddon. It intercepts the addon’s own bindings or node-gyp-build helper, or a direct .node import, and finds the binary with node-gyp-build’s probe order. So import Database from 'better-sqlite3' works after gjsify install @gjsify/napi, with no config. It does nothing when no native addon is in the graph, and it never applies to --app node, browser or nativescript.
Compile JSX and Vue templates
Section titled “Compile JSX and Vue templates”JSX and .vue are compiler input, not runtime syntax, so the build needs a plugin that knows which framework you meant. Name one under gjsify.bundler.plugins (below) and build the entry normally:
gjsify build src/app.tsx --app gjs --outfile dist/app.gjs.mjs@gjsify/rolldown-plugin-solidfor SolidJS JSX@gjsify/rolldown-plugin-vuefor Vue single-file components
--app gjs refuses a JSX entry that configures no transform, and that refusal is the point. Left unset, the transformer falls back to the automatic React runtime, so the bundle imports react/jsx-runtime. GJS resolves no bare specifier, so the build would report the miss as a warning, exit 0, and the artifact would abort at load with ImportError: Module not found: react/jsx-runtime. On a project that does have React installed it is worse: the bundle builds React elements, which a GTK host does nothing with.
Answer the question one of three ways, and the error message lists all three:
| Answer | How |
|---|---|
| Preserve the JSX for a framework compiler | "jsx": "preserve" in tsconfig or gjsify.bundler.transform.jsx, plus the plugin above. Pair with tsconfig "jsxImportSource": "@gjsify/gtk-host" for the types. |
| Use an automatic runtime you actually have | "jsx": "react-jsx" + "jsxImportSource": "<pkg exporting ./jsx-runtime>". Not @gjsify/gtk-host. Its /jsx-runtime is types only and throws when called. |
| Say the entry holds no JSX | "jsx": false, and the transformer reports the JSX itself. |
--app node and --app browser are unaffected. The React default is a legitimate answer there, and refusing would break builds for a mistake they did not make.
Bundle a third-party CLI that reads its own package.json
Section titled “Bundle a third-party CLI that reads its own package.json”Tools like typedoc and prettier read their own package.json during top-level evaluation, via something like Path.join(fileURLToPath(import.meta.url), '../../../package.json'). Once bundled, import.meta.url points at your bundle, the lookup escapes the package, and the tool crashes on startup.
On Node, keep those packages external so Node’s own resolver finds them in node_modules, and supply any build-time constants they expect with --define:
gjsify build src/cli.entry.ts --app node --outfile dist/cli.mjs \ --define '__MY_VERSION__="1.0.0"' \ --external typedoc,prettier,@inquirer/prompts,inquirerOn GJS this does not work: gjsify run has no node_modules-style runtime resolver, so an externalised package fails with ImportError: Module not found. Bundle them there instead.
Globals
Section titled “Globals”--globals auto is the default. It reads the bundled output and injects only the register modules your code actually needs, so most projects never touch this flag.
| Mode | Usage | What it does |
|---|---|---|
auto | --globals auto | Detect everything from the bundled output. |
auto,<extras> | --globals auto,dom | Auto plus explicit extras, for globals the detector cannot see. |
| explicit list | --globals fetch,Buffer | Exactly these, no detection. |
none | --globals none | Inject nothing. |
Three group names expand to sets of identifiers: node (Buffer, process, URL and friends), web (fetch, streams, crypto, events) and dom (document, Image, navigator).
The detector cannot follow value-flow indirection. Excalibur, for instance, stashes globalThis in a field and calls methods through it, so nothing named matchMedia ever appears in the bundle. Keep auto on and add the extras:
gjsify build src/gjs/gjs.ts -o dist/gjs.js --globals auto,domgjsify build src/index.ts -o dist/index.js --globals auto,matchMedia,FontFace--verbose shows what auto found:
gjsify build src/index.ts -o dist/index.js --verbose# [gjsify] --globals auto: converged after 2 iteration(s), 11 global(s):# AbortSignal, Buffer, HTMLElement, document, fetch, navigator, …How It Works describes the multi-pass machinery.
Known identifiers
Section titled “Known identifiers”Anything in this table can appear in --globals, and auto detection recognises the same set. The subpaths are granular on purpose: asking for Buffer does not drag in process or URL.
Node.js core globals
| Identifier(s) | Register subpath |
|---|---|
Buffer | @gjsify/node-globals/register/buffer |
process | @gjsify/node-globals/register/process |
setTimeout, setInterval, clearTimeout, clearInterval, setImmediate, clearImmediate | @gjsify/node-globals/register/timers |
queueMicrotask | @gjsify/node-globals/register/microtask |
structuredClone | @gjsify/node-globals/register/structured-clone |
btoa, atob | @gjsify/node-globals/register/encoding |
URL, URLSearchParams | @gjsify/node-globals/register/url |
Blob, File | @gjsify/buffer/register |
GJS provides setTimeout and setInterval natively, but their return value is a boxed GLib.Source whose finalizer can crash the process. They are listed here so the replacement, which returns numeric ids, is injected wherever timers are used.
Fetch and XHR
| Identifier(s) | Register subpath |
|---|---|
fetch, Headers, Request, Response | fetch/register/fetch |
XMLHttpRequest, XMLHttpRequestUpload | fetch/register/xhr |
Streams
| Identifier(s) | Register subpath |
|---|---|
ReadableStream, ReadableStreamBYOBReader, ReadableStreamBYOBRequest, ReadableByteStreamController, ReadableStreamDefaultController, ReadableStreamDefaultReader | web-streams/register/readable |
WritableStream | web-streams/register/writable |
TransformStream | web-streams/register/transform |
TextEncoderStream, TextDecoderStream | web-streams/register/text-streams |
ByteLengthQueuingStrategy, CountQueuingStrategy | web-streams/register/queuing |
CompressionStream, DecompressionStream | compression-streams/register |
Crypto
| Identifier(s) | Register subpath |
|---|---|
crypto | webcrypto/register |
Abort, messaging and events
| Identifier(s) | Register subpath |
|---|---|
AbortController, AbortSignal | abort-controller/register |
MessageChannel, MessagePort | message-channel/register |
Event, EventTarget | dom-events/register/event-target |
CustomEvent, MessageEvent, ErrorEvent, CloseEvent, ProgressEvent | dom-events/register/custom-events |
UIEvent, MouseEvent, PointerEvent, KeyboardEvent, WheelEvent, FocusEvent | dom-events/register/ui-events |
EventSource | eventsource/register |
WebSocket | websocket/register |
DOMException | dom-exception/register |
Performance and FormData
| Identifier(s) | Register subpath |
|---|---|
performance, PerformanceObserver | @gjsify/web-globals/register/performance |
FormData | @gjsify/web-globals/register/formdata |
WebAssembly promise APIs
| Identifier(s) | Register subpath |
|---|---|
WebAssembly (compile, instantiate, validate, compileStreaming, instantiateStreaming) | webassembly/register/promise |
DOM parsing, audio and gamepads (GJS only)
| Identifier(s) | Register subpath |
|---|---|
DOMParser | @gjsify/domparser/register |
AudioContext, webkitAudioContext, Audio, HTMLAudioElement | @gjsify/webaudio/register |
GamepadEvent | @gjsify/gamepad/register |
WebRTC, on GStreamer webrtcbin (GJS only)
| Identifier(s) | Register subpath |
|---|---|
RTCPeerConnection, RTCSessionDescription, RTCIceCandidate, RTCPeerConnectionIceEvent | @gjsify/webrtc/register/peer-connection |
RTCDataChannel, RTCDataChannelEvent | @gjsify/webrtc/register/data-channel |
RTCError, RTCErrorEvent | @gjsify/webrtc/register/error |
MediaStream, MediaStreamTrack, RTCTrackEvent | @gjsify/webrtc/register/media |
MediaDevices (navigator.mediaDevices) | @gjsify/webrtc/register/media-devices |
DOM and browser compatibility (GTK backed)
| Identifier(s) | Register subpath |
|---|---|
document, HTMLElement | @gjsify/dom-elements/register/document |
HTMLCanvasElement | @gjsify/dom-elements/register/canvas |
Image, HTMLImageElement | @gjsify/dom-elements/register/image |
MutationObserver, ResizeObserver, IntersectionObserver | @gjsify/dom-elements/register/observers |
FontFace | @gjsify/dom-elements/register/font-face |
matchMedia | @gjsify/dom-elements/register/match-media |
location | @gjsify/dom-elements/register/location |
navigator | @gjsify/dom-elements/register/navigator |
Canvas 2D, iframe and WebGL (GTK, WebKit and GLArea backed)
These are deliberately not part of the coarse dom group. Injecting one requires its package (and WebKitGTK for the iframe) to be installed, so auto detection pulls them in only when the identifier really appears in the bundle.
| Identifier(s) | Register subpath |
|---|---|
ImageData, Path2D | @gjsify/canvas2d/register |
HTMLIFrameElement | @gjsify/iframe/register |
WebGLRenderingContext, WebGL2RenderingContext | @gjsify/webgl/register |
Identifiers outside this table are ignored. If you still hit ReferenceError: X is not defined, add X as an extra: --globals auto,X.
The two GTK-backed groups are the ones an --app node build can also ask for. A plain --globals auto node build injects nothing, because Node, Bun and Deno bring their own fetch, streams, crypto and events. Name a group or an identifier explicitly and it injects the same register modules the --app gjs target would, reaching GTK through @gjsify/node-gi. That is what the Adwaita templates’ build:node script does. gtk-minimal needs none of it, because plain GTK reaches no DOM API:
gjsify build src/index.ts --app node --outfile dist/index.node.mjs --globals auto,domgjsify dev
Section titled “gjsify dev”Watch the project, rebuild on change and relaunch the app. All seven templates wire their dev script to it.
gjsify dev # watch, rebuild and relaunch on the host runtimegjsify dev --runtime node # build and launch the `--app node` bundle insteadgjsify dev src/main.ts --watch-dir srcgjsify dev --build-only # rebuild on every change, never launch| Argument / Option | Description |
|---|---|
[entry] | Entry point to build. Default: the one the build script names, e.g. src/index.ts out of build:gjs. |
--runtime <gjs|node|bun|deno> | Runtime to build for and launch on. Default: the host runtime. node, bun and deno all build the same --app node bundle. |
--script <name> | The package.json script the build flags are read from. Default: build:gjs, or build:node for node/bun/deno. |
--globals <value> | Override the build script’s --globals value. |
--outfile <path> | Override the build script’s --outfile path. |
--watch-dir <dir> | Directory watched recursively. Default: the directory of the entry point. |
--debounce <ms> | Quiet window after a change before the rebuild starts. Default: 200. |
--build-only | Rebuild on every change but never launch the app. |
What gets built is not declared twice. gjsify dev reads your own build:gjs / build:node script and layers its flags on top, so the dev loop and gjsify run build cannot drift into producing different bundles. Override one flag at a time with --globals or --outfile, pass a different entry as the positional argument, or point --script at another script to follow a different build entirely.
Why this is not gjsify build --watch. That flag drives rolldown’s watcher API, which only the npm engine exposes. On a Node-free GJS host it is not there at all. gjsify dev asks for no watcher API. It watches with fs.watch and rebuilds by re-entering the ordinary build command, so the same loop runs on gjs, node, bun and deno.
gjsify run
Section titled “gjsify run”Run a script from package.json, or launch a built bundle.
gjsify run start # the `start` script from ./package.jsongjsify run build -w cli # the `build` script in the `cli` workspacegjsify run dist/index.js # a built bundlegjsify run ./server.mjs -- --port 8080| Argument / Option | Description |
|---|---|
<target> | A script name from the current package.json, or a path to a built bundle. |
[args..] | Extra arguments forwarded to the script or to the runtime. Use -- before flags you do not want gjsify to parse. Everything after -- reaches the target as you typed it, numbers included: -- --port 8080 arrives as --port 8080, and -- --scale 1.0 arrives as 1.0 rather than 1. |
-w, --workspace <name> | Run <target> as a script in the named workspace, like npm run <script> -w <name>. Matches the package name, the workspace-relative path, or the directory basename. |
--runtime <gjs|node|bun|deno> | Launch a bundle file on this runtime. Forces file mode. |
--node-script | Treat <target> as an unbundled Node-style script that imports node: builtins, and run it on the host runtime. Under GJS the file is bundled --app gjs on the fly first, which is what lets a repo script run on a machine with no Node. Cannot be combined with --runtime or --workspace. |
A script name in package.json wins over a same-named file on disk, so gjsify run build still runs your build script even when a build/ directory exists. To force file mode, write a path (./build) or pass --runtime.
For a bundle file, gjsify run also sets GI_TYPELIB_PATH plus the host’s library-search variable (LD_LIBRARY_PATH on Linux, DYLD_LIBRARY_PATH on macOS, PATH on Windows) so native prebuilds load.
Run a bundle on gjs, node, bun or deno
Section titled “Run a bundle on gjs, node, bun or deno”Without --runtime, a bundle file follows the host runtime the CLI is on. A --app gjs bundle is the exception and always runs on gjs, because it has no node-gi shim. gjsify recognises it by its gi:// imports and its gjs shebang. gjs runs it via gjs -m. node, bun and deno all run the same --app node bundle, since Node-API is their common ABI. @gjsify/node-gi is only needed when the bundle actually uses gi://.
gjsify build src/app.ts --app gjs --outfile dist/app.gjs.mjsgjsify build src/app.ts --app node --outfile dist/app.node.mjs
gjsify run --runtime gjs dist/app.gjs.mjs # gjs -mgjsify run --runtime node dist/app.node.mjs # nodegjsify run --runtime bun dist/app.node.mjs # bun, same node bundlegjsify run --runtime deno dist/app.node.mjs # deno run -A --node-modules-dir=manualIf the package declares gjsify.example.runtimes, the requested runtime is checked against it, so an unsupported runtime fails with a clear message instead of a bundle crash.
Running a bundle without gjsify run
gjsify run is a convenience wrapper. With no native prebuilds you can call gjs yourself:
gjs -m dist/index.jsWith native prebuilds, export the environment first:
eval $(gjsify info --export)gjs -m dist/index.jsgjsify test
Section titled “gjsify test”Build and run the package’s src/test.mts aggregator on GJS and Node, then aggregate the results.
gjsify test # both runtimesgjsify test --runtime gjs # one runtimegjsify test --no-build # reuse existing bundlesgjsify test --rebuild # rebuild even if bundles look fresh| Option | Default | Description |
|---|---|---|
--runtime <gjs|node|all> | all | Which runtimes to build and run. |
--entry <path> | gjsify.test.entry, else src/test.mts | Test entry. |
--outdir <path> | gjsify.test.outdir, else dist/ | Where test.{gjs,node}.mjs is written. |
--rebuild | false | Always rebuild, even when the outputs look up to date. |
--build | true | Build before running. --no-build skips it when bundles already exist. |
--verbose | false | Print the resolved entry and outdir plus per-step timing. |
gjs and node are the only two runtimes this command drives. It builds the --app gjs and --app node bundles and runs each on its own runtime. Bun and Deno consume the same --app node bundle, so you can point them at it yourself with gjsify run --runtime, but gjsify test does not drive them.
A runtime you did not ask for explicitly is skipped when its binary is not on PATH, with a line saying so. Set defaults in package.json:
{ "gjsify": { "test": { "entry": "src/test.mts", "outdir": "dist", "runtimes": ["gjs", "node"] } }}The entry usually aggregates @gjsify/unit suites:
import { run } from '@gjsify/unit';import myFeature from './my-feature.spec.js';import other from './other.spec.js';run({ myFeature, other });You get one summary line, [gjsify test] ✅ gjs (412ms) ✅ node (88ms), and a non-zero exit whenever any build or run fails.
gjsify clear
Section titled “gjsify clear”Delete build output. A portable rm -rf for your clear scripts, so they work on every host.
gjsify clear dist lib tsconfig.tsbuildinfogjsify clear "dist/*.mjs" --dry-run| Option | Default | Description |
|---|---|---|
[paths..] | [] | Paths to delete, relative to the current package. A missing path is fine, not an error. * and ? work in the last segment. |
--dry-run | false | Print what would be deleted and touch nothing. |
-v, --verbose | false | Print each path as it goes. |
gjsify copy
Section titled “gjsify copy”Copy files and directories into the build output. A portable mkdir -p plus cp -r.
gjsify copy src/style.css dist/gjsify copy "data/*.ui" data/icons dist/data/| Option | Default | Description |
|---|---|---|
[paths..] | [] | One or more sources followed by the destination. |
--dry-run | false | Print what would be copied and touch nothing. |
-v, --verbose | false | Print each path as it goes. |
The destination is treated as a directory when it ends in /, when you pass several sources, or when a source has a wildcard. Otherwise it is the exact target path. Missing parent directories are created. * and ? work in the last segment of a source.
Configure it in package.json
Section titled “Configure it in package.json”Anything you would pass repeatedly on the command line can live in the gjsify field of package.json, or in .gjsifyrc.js / gjsify.config.mjs. CLI flags always win.
| Key | What it sets |
|---|---|
app | Default --app target for this project. |
bundler | Rolldown options passed through. Most projects only set output.file, output.dir or plugins. |
globals | Default --globals value. |
excludeGlobals | Identifiers to drop from the auto-detected set. |
exclude | Glob patterns to exclude from entry points and aliases. |
consoleShim | Inject the GJS console shim. Default true, and read by --app gjs builds only. |
shebang | true for the built target’s own line (#!/usr/bin/env -S gjs -m for --app gjs, #!/usr/bin/env node for --app node), false for none, or your own string. |
aliases | Extra module aliases, the config form of --alias. |
loaders | Extension to loader kind, for files Rolldown does not classify. |
defineFromPackageJson | Compile-time constants read out of package.json. |
defineFromEnv | Compile-time constants read out of process.env at config-load time. |
nodeScript | globals / excludeGlobals overrides for the ad-hoc bundle gjsify run --node-script builds. |
library, typescript | Library-mode package.json fields and TypeScript options (reflection: true turns on Deepkit). |
main, bin | The GJS entry (and named GJS bins) that gjsify dlx and gjsify ship read. |
prebuilds | Directory holding native prebuilds. |
test | Defaults for gjsify test. |
example | Declared runtimes for gjsify run --runtime and gjsify showcase. |
storybook | Defaults for gjsify storybook. |
browse, devtools | Defaults for gjsify browse and gjsify debug. |
flatpak | Config for the gjsify flatpak commands. |
ship | Config for gjsify ship. Metadata falls back to flatpak. |
Where define goes
Section titled “Where define goes”define belongs under bundler.transform.define, not at the top level of bundler. Rolldown reads only the nested one. If you write bundler.define, GJSify moves it for you and warns at build time. Move it yourself to silence the warning.
// works, but warns on every build{ "gjsify": { "bundler": { "define": { "__APP_ID__": "\"org.example.App\"" } } } }
// canonical{ "gjsify": { "bundler": { "transform": { "define": { "__APP_ID__": "\"org.example.App\"" } } } } }To pull a constant out of package.json or the environment instead, use the dedicated keys:
{ "gjsify": { "defineFromPackageJson": { "__PACKAGE_VERSION__": { "field": "version" } }, "defineFromEnv": { "__PREFIX__": { "env": "PREFIX", "default": "/usr" } } }}An unset variable with no default becomes the literal undefined, so you can guard with typeof __PREFIX__ === 'undefined'.
Name a bundler plugin instead of writing a config file
Section titled “Name a bundler plugin instead of writing a config file”bundler.plugins takes a list of plugin entries, so a project that needs one extra transform keeps its whole build in package.json:
{ "gjsify": { "bundler": { "plugins": [ { "name": "@gjsify/rolldown-plugin-solid" }, { "name": "./build/my-plugin.mjs", "export": "myPlugin", "options": { "verbose": true } } ] } }}| Field | Default | What it does |
|---|---|---|
name | required | A package name, or a path relative to the project. Resolution is anchored at the project root, so the project’s own node_modules wins over the CLI’s. |
export | default | Which export to call. It has to be a function returning a Rolldown plugin. |
options | {} | Passed to that function. |
plugins is an array, and every entry is an object with those fields. A bare "@gjsify/rolldown-plugin-solid" string is not the same thing and is not accepted.
A named plugin must be a real dependency of the package that configures it: dependencies, devDependencies or optionalDependencies, any of the three. In a monorepo an undeclared one resolves anyway through the hoisted root node_modules, then stops resolving the moment the package is installed from npm. That declaration is true in your tree and false everywhere else, so gjsify conformance fails on it rather than letting it ship.
Plugins run in the order listed.
Under --app gjs the CLI bundles the plugin to one self-contained ESM file before importing it, because GJS’s own ESM loader does not follow package.json#exports subpath maps. So the plugin’s whole dependency tree has to load under GJS, not only its entry.
Loading unusual file types
Section titled “Loading unusual file types”Rolldown does not classify unknown extensions, so without a loader it tries to parse them as JavaScript and fails. Map them yourself:
{ "gjsify": { "loaders": { ".glsl": "text", ".ui": "text", ".asm": "text", ".png": "dataurl" } }}| Kind | Output | Use it for |
|---|---|---|
text | export default "<file contents>" | GLSL shaders, GtkBuilder .ui XML, assembly source. |
dataurl | export default "data:<mime>;base64,<b64>" | Images for Excalibur’s ImageSource, or any API taking a data URL. |
MIME types for dataurl are inferred from the extension: .png, .jpg / .jpeg, .gif, .svg, .webp, .wasm, and application/octet-stream for everything else.
A shebang that outer build tools can fill in
Section titled “A shebang that outer build tools can fill in”shebang also accepts a string, with ${env:NAME} and ${env:NAME:-default} placeholders resolved against process.env. That is what you want when Meson or Flatpak exports the interpreter path:
{ "gjsify": { "shebang": "${env:GJS_CONSOLE:-/usr/bin/env -S gjs} -m" } }A leading #! is added if you leave it out.
Manage dependencies
Section titled “Manage dependencies”gjsify install
Section titled “gjsify install”Install npm dependencies. A drop-in for npm install and yarn install. Its default backend resolves, downloads and unpacks the tree itself, so neither Node nor the npm CLI has to be on the machine.
gjsify install # full project installgjsify install --immutable # CI: install strictly from gjsify-lock.jsongjsify install lodash # add lodash to dependenciesgjsify install -D vitest # add to devDependenciesgjsify install -g @gjsify/cli # global install under ~/.local/share/gjsify/global/| Option | Default | Description |
|---|---|---|
[packages..] | [] | Package specs. Omit for a full project install. |
-g, --global | false | Install into ~/.local/share/gjsify/global/ and symlink bins into ~/.local/bin/. |
-D, --save-dev | false | Save to devDependencies. |
--save-peer | false | Save to peerDependencies. |
-O, --save-optional | false | Save to optionalDependencies. |
--immutable | false | Install strictly from gjsify-lock.json, failing if it is missing or stale. Same idea as yarn --immutable or npm ci. |
--refresh-lockfile | false | Re-resolve every dependency to the newest version its range allows and rewrite the lockfile. Without it, versions already pinned are preserved and only new or changed deps are resolved. |
--backend <native|npm> | native | native goes through @gjsify/{semver,npm-registry,tar}. npm shells out to npm install as an escape hatch for cases the native backend does not model yet, such as Yarn PnP repos and lifecycle scripts. Wins over GJSIFY_INSTALL_BACKEND. |
--progress | true on a TTY | TTY-aware progress bar for resolve, download and extract. Off under --verbose or --quiet. |
--quiet | false | Silence the progress bar. |
--verbose | false | Per-package install log. |
--timeout <ms> | 1800000 | Overall wall-clock budget. On timeout, in-flight registry fetches abort and the install exits non-zero. 0 disables it. |
--os <name> | this host | Resolve and install for another OS (darwin, win32, linux). The lockfile stays platform independent either way. |
--cpu <arch> | this host | Resolve and install for another CPU architecture (x64, arm64). |
--libc <glibc|musl> | probed | Resolve for another libc family. Only meaningful with --os=linux. |
--force | false | Install a required dependency even when its os / cpu / libc excludes the target, instead of failing with EBADPLATFORM. Incompatible optional dependencies stay skipped. |
--prune | true | Afterwards, remove packages an earlier install left behind that this host cannot use, see gjsify prune. --no-prune disables. Skipped under --immutable, and whenever --os/--cpu/--libc is given. |
The resolver follows npm v3 and later semantics, and honours npm-style overrides and yarn-style resolutions in package.json. The lockfile is gjsify-lock.json, a path-keyed packages map at lockfileVersion 4. How It Works has more on how the tree is built.
gjsify uninstall
Section titled “gjsify uninstall”The inverse of gjsify install -g. Removes the package tree from ~/.local/share/gjsify/global/node_modules/<pkg>/ and any bin shims under ~/.local/bin/ pointing into it.
gjsify uninstall -g <pkg>gjsify uninstall -g <pkg> --dry-rungjsify uninstall -g <pkg1> <pkg2>| Option | Default | Description |
|---|---|---|
<packages..> | required | One or more package names, optionally with a version. |
-g, --global | false | Required. Only global mode is supported today. |
--dry-run | false | Print what would be removed and touch nothing. |
--verbose | false | Verbose logging. |
It exits non-zero when nothing matched.
gjsify prune
Section titled “gjsify prune”Remove installed packages this host cannot use: the ones an earlier install put there before the platform filter could skip them.
gjsify prune -g --dry-run # what would go, and how much it freesgjsify prune -g # remove itgjsify prune # the same, for this project's node_modulesgjsify prune -g --os=darwin # what a darwin host could not useThe decision is a pure manifest read of npm’s own os, cpu and libc, through the same check the installer filters with. So a pruned prefix converges on what a fresh install would have placed. A package that declares no platform is never touched, however unusable it looks. Inferring that from a package name is how a prune starts deleting things it cannot justify.
install and self-update run the same pass automatically, and --no-prune opts out. That pass uses the measured host and refuses outright when --os, --cpu or --libc is given, so an install can never delete against a target you typed. On this command those flags are honoured, because asking is not a side effect.
| Option | Default | Description |
|---|---|---|
-g, --global | false | Prune the user-global prefix instead of this project’s node_modules. |
--dry-run | false | Report what would be removed and touch nothing. |
--verbose | false | List every package rather than the first few. |
--os <name> / --cpu <arch> / --libc <name> | this host | Decide as if the host were this target. |
Removing nothing is a success, since this is idempotent housekeeping. It exits non-zero only when a removal you asked for failed. Sizes are apparent, summed from the files, so du, which counts allocated blocks, reports a different number.
gjsify upgrade
Section titled “gjsify upgrade”Check the registry for newer versions of your declared dependencies and update package.json. A drop-in for yarn upgrade-interactive and npx npm-check-updates, and workspace-aware: it walks every package.json in the monorepo, groups by dependency and flags inconsistencies.
gjsify upgrade # interactive: pick what to upgradegjsify upgrade --latest # bump everything, major bumps allowedgjsify upgrade --minor # stay within the current majorgjsify upgrade --patch # patches onlygjsify upgrade --latest --dry-run # print the plan, write nothinggjsify upgrade --filter '@gjsify,vite' # narrow by substringgjsify upgrade --check # CI gate for inconsistent rangesgjsify upgrade --align # fix them, offlinegjsify upgrade --latest --exact --filter @girs # pin at the newest release, no operatorgjsify upgrade --check --exact --filter @girs # CI gate for exactnessgjsify upgrade --align --exact --filter @girs # fix that gate, offline| Option | Default | Description |
|---|---|---|
--latest | false | Non-interactive bulk update, major bumps allowed. |
--minor | false | Non-interactive, semver-minor and patch only. |
--patch | false | Non-interactive, semver-patch only. |
--filter <substring> | none | Match against package names, case-insensitive. Repeatable, comma-separated values are split. |
-p, --workspace <pattern> | all | Restrict to some workspaces. Matched against the package name and the directory path. Repeatable. |
--exclude-workspace <pattern> | none | Skip workspaces, for ones with deliberate dependency drift such as integration tests pinned to a specific upstream. Repeatable. |
--align | false | Offline repair mode: find deps declared at several ranges and align them to the highest. With --exact, also drop the operator from declarations that already agree. No registry calls. |
--check | false | CI gate: exit non-zero when any dep is declared inconsistently across workspaces. Offline. --align with the same flags is the fix. |
--exact | false | Pin without a range operator. Writing, emits 1.2.3 instead of ^1.2.3; with --check, fails on any matched dep that carries one; with --align, repairs exactly that. Pair with --filter. A repository-wide exactness run touches every ordinary caret dep by design. |
--dry-run | false | Print the plan without writing. |
-y, --yes | false | In interactive mode, select everything without prompting. |
--cwd <path> | process.cwd() | Project directory. From inside a workspace it walks up to the monorepo root. |
--verbose | false | Print resolution details. |
workspace:, file:, link:, git:, git+, http(s):, npm:, * and latest ranges are skipped, since none of them is an external npm dependency. The range prefix is preserved: ^1.2.3 becomes ^2.0.0 and ~0.4.0 becomes ~0.5.0, unless --exact drops it. Lines the update does not touch are left byte-for-byte as they were, so a dependency bump never arrives as a diff over unrelated fields. The registry URL comes from ~/.npmrc, then <cwd>/.npmrc, with npm_config_registry overriding both, and scope-specific registries and auth tokens are honoured.
Output is a colour-coded table (red major, yellow minor, green patch, cyan prerelease). Run gjsify install afterwards to fetch the new versions.
@gjsify/* packages ship as one release train, so upgrade them together: gjsify upgrade --latest --filter @gjsify. See Versioning & Compatibility.
--check and --align answer the same two questions, so whatever the gate rejects the repair with the same flags fixes. Without --exact the question is consistency alone, and a tree where every manifest agrees on ^4.1.0 is done. --exact adds exactness, which consistency cannot answer, because those same manifests all carry an operator. So --align --exact widens to every matched declaration that has one and rewrites it at its declared version, operator dropped. It never asks the registry: ^4.1.0 becomes 4.1.0, not the newest 4.x. Use --latest --exact when you want the newest release instead. A range that names no single version (^1.x) cannot be pinned offline. --align names those deps and exits non-zero rather than reporting a repair the gate will still reject.
@girs/* is pinned exactly in this repository, and a CI step holds it that way. Consistency is not the same question. Every manifest agreeing on one caret is perfectly consistent and still resolves to whatever is newest, and a published package’s declaration is what a consumer installs against with no lockfile of ours. Since @gjsify/gtk-host consumes the @girs/<ns>/vocabulary subpath, a minor release moving it under such an install is a real hazard. So the pin is the whole version.
gjsify dlx
Section titled “gjsify dlx”Run the GJS bundle of a published package without adding it to your project, like npx or yarn dlx. It is strictly a GJS-bundle runner. It resolves the package’s GJS entry and calls gjs -m <bundle>. A package with no GJS entry fails loudly.
gjsify dlx @gjsify/example-dom-canvas2d-fireworksgjsify dlx @scope/pkg@1.2.3 # version-pinnedgjsify dlx @scope/pkg my-bin -- --opt value # pick a bin, forward argsgjsify dlx ./local/path # local dir, no install, no cache| Option | Default | Description |
|---|---|---|
<spec> | required | name, name@version, @scope/name@spec, or a local path. |
[binOrArg] | none | A bin name when gjsify.bin has several entries. Otherwise the first argument forwarded to the bundle. To pass a flag here, use --: gjsify dlx <pkg> -- --help. |
[extraArgs..] | [] | Extra args forwarded to gjs -m <bundle>. |
--cache-max-age <minutes> | 10080 (7 days) | Cache TTL. 0 bypasses the cache. |
--reinstall | false | Bypass the cache for this run. Same as --cache-max-age=0. |
--frozen | false | Use the project-local gjsify-lock.json verbatim, failing if it is missing or stale. No resolver pass. |
--registry <url> | from .npmrc | Registry override. |
--verbose | false | Verbose logging. |
Downloads are cached under $XDG_CACHE_HOME/gjsify/dlx/, keyed by the package specs and registries, and swapped in atomically, so parallel runs of the same spec are safe.
How dlx finds the GJS entry
Section titled “How dlx finds the GJS entry”dlx reads a top-level gjsify object from the package’s package.json:
{ "name": "@gjsify/example-dom-canvas2d-fireworks", "main": "dist/node.js", // optional Node entry "gjsify": { "main": "dist/gjs.js", // the GJS entry dlx runs "bin": { "fireworks": "dist/gjs.js" }, // optional: several GJS entries "prebuilds": "prebuilds" }}It picks, in order: the bin you named in gjsify.bin, the only entry in gjsify.bin, gjsify.main, then package.json#main as a fallback (with a hint to add gjsify.main). If none of those exist it fails with a fix hint. A multi-bin package with no bin chosen tells you the names to pick from.
gjsify self-update
Section titled “gjsify self-update”Refresh the installed @gjsify/cli to the latest release, or to a pinned dist-tag.
gjsify self-update # latestgjsify self-update --check # compare only, exit 1 if outdatedgjsify self-update --force # reinstall the same versiongjsify self-update --tag next # a specific dist-tag or version| Option | Default | Description |
|---|---|---|
--check | false | Compare current against target without installing. Exit 0 if up to date, 1 if outdated. |
--force | false | Reinstall even when the target already matches. |
--tag <tag> | latest | npm dist-tag or a pinned version. |
--skip-deps | false | Update only the @gjsify/cli bundle, not its runtime dependencies (rolldown, lightningcss, @gjsify/tsc, the native gi:// bridges). Faster, but it can leave those stale relative to the new bundle. |
--prune | true | Afterwards, remove packages an earlier install left behind that this host cannot use, see gjsify prune. --no-prune disables. |
It reuses the same install backend as gjsify install -g, so transitive native prebuilds, the lockfile and bin shims are handled. It only works for CLIs installed under ~/.local/share/gjsify/global/, which is where the install.mjs bootstrap and gjsify install -g put them. An npm install -g lands elsewhere, and self-update says so.
gjsify generate-installer
Section titled “gjsify generate-installer”Scaffold an install.mjs for your own GJS-runnable package, so your users get the same curl … | gjs -m - install story GJSify has.
cd my-gjs-appgjsify generate-installer
gjsify generate-installer \ --target @my-org/my-app \ --bin-name my-app \ --bootstrap-url https://example.com/cli.gjs.mjs \ --output bin/install.mjs --force| Option | Default | Description |
|---|---|---|
[target] | package.json#name | The npm package the installer installs. |
--bin-name <name> | first key of gjsify.bin or bin | Bin name the installer produces. |
--bootstrap-url <url> | GJSify’s releases/latest/download/cli.gjs.mjs | Where the bootstrap bundle comes from. |
--output <file> | install.mjs | Where to write it. |
--force | false | Overwrite an existing file. |
The generated file is a copy of GJSify’s own install.mjs with three constants substituted. Commit it. Distributing GJS apps has the full publication workflow.
Work in a monorepo
Section titled “Work in a monorepo”gjsify foreach
Section titled “gjsify foreach”Run a script across all, or some, workspaces. A drop-in for yarn workspaces foreach.
gjsify foreach build # `build` everywheregjsify foreach -p -t build # parallel, topological ordergjsify foreach --no-private build # skip private:true workspacesgjsify foreach --include '@gjsify/web-*' test # glob filtergjsify foreach --exec -- npm publish --tag latestgjsify foreach --exec -- gjsify publish --verify-timeout 5 --tag latest| Option | Default | Description |
|---|---|---|
[script] | none | Script to run. With --exec, the command to run. |
[args..] | [] | Extra arguments forwarded to each invocation. |
-A, --all | false | Include workspaces marked private: true. |
-p, --parallel | false | Run in parallel, capped by --jobs. |
-t, --topological | false | Wait for each workspace’s production dependencies to finish first. |
--topological-dev | false | Like --topological, but also respects devDependencies. Often cyclic, so use it sparingly. |
--include <glob> | all | Include workspaces matching the glob. Repeatable. A pattern that matches nothing is a hard error. |
--exclude <glob> | none | Exclude workspaces matching the glob. Repeatable. |
-d, --with-dependencies | false | Also select everything the filtered set depends on. --include only filters and --topological only orders, so neither can say “and the packages these need”. Excludes are re-applied afterwards. |
--private | true | Include private workspaces. --no-private skips them. |
-j, --jobs <n> | cpu count | Max concurrent workspaces in --parallel mode. |
--exec | false | Treat <script> [args..] as an arbitrary command. Use -- <cmd> so flags reach the command. Everything after -- is forwarded verbatim, numbers included. |
--cached | GJSIFY_BUILD_CACHE=1 | Content-hash build cache. See Build cache. Script mode only. |
--shard <index>/<total> | none | Run one deterministic slice of the matched workspaces, for example --shard 2/4, to fan a long run across parallel CI jobs. Partitioned by sorted name, so shards are disjoint and their union is the full set. Order-independent, so fine for tests and wrong for ordered builds. |
-v, --verbose | false | Echo every spawned command. |
gjsify workspace
Section titled “gjsify workspace”Run one script in one workspace. A drop-in for yarn workspace <name> run <script>.
gjsify workspace @gjsify/cli buildgjsify workspace @gjsify/fetch test:gjsgjsify workspace @gjsify/website build -d # build its deps first| Argument | Description |
|---|---|
<name> | Workspace name, matching package.json#name. |
<script> | Script to run. The yarn spelling workspace <name> run <script> also works. |
[args..] | Extra arguments forwarded to the script. |
| Option | Default | Description |
|---|---|---|
-d, -t, --with-dependencies, --topological | false | Build the workspace’s transitive workspace dependencies in topological order first. Deps without the script are skipped. |
--include-dev | false | With -d, also walk devDependencies. |
--continue-on-error | false | With -d, keep going after a dependency fails. |
--cached | GJSIFY_BUILD_CACHE=1 | Content-hash build cache. See Build cache. Also applies to the deps run by -d. |
-v, --verbose | false | Echo every spawned command. |
Build cache
Section titled “Build cache”gjsify foreach <script> and gjsify workspace <name> <script> can skip workspaces whose inputs are unchanged and restore the stored outputs instead of re-running the script. Turn that on with --cached or GJSIFY_BUILD_CACHE=1. An explicit --no-cached wins over both.
gjsify foreach build -tp --cached # rebuild only what changedGJSIFY_BUILD_CACHE=1 gjsify run build # opt a whole script chain inThe cache key is a sha256 over the script name and its arguments, a toolchain salt (the resolved @gjsify/cli, @gjsify/tsc, rolldown and typescript versions), the package’s own inputs (src/**, package.json, root tsconfig*.json, hashed by content) and the same for its full transitive workspace-dependency closure. Editing a dependency therefore re-runs every dependent.
Entries live in node_modules/.cache/gjsify/build/<pkg>/<key>/, at most two keys per package, oldest evicted. Only the conventional output directories (lib/, dist/, dist-templates/) that the script actually modified are stored, and a cache hit replaces exactly those. A package that does not define the script is never written to. It is script mode only, --exec is rejected, and any cache error falls back to running the script uncached.
gjsify affected
Section titled “gjsify affected”Print the workspaces a change touches, so CI can test those and skip the rest.
gjsify affected # text listgjsify affected --format globs # feed into --includegjsify affected --base "$BASE_SHA" --format github-actionsgjsify foreach test --include $(gjsify affected --format globs)| Option | Default | Description |
|---|---|---|
--base <ref> | origin/main | Diff base, resolved with git rev-parse. On a pull request, use the base SHA. |
--head <ref> | HEAD | Diff head. |
--format <shape> | text | text, json, globs or github-actions. |
--changed-from-stdin | false | Skip git diff and read a newline-separated list of repo-relative paths from stdin. |
--cwd <path> | discovered | Workspace root. |
The output is the seed workspaces plus everything that transitively depends on them.
Check and fix code
Section titled “Check and fix code”gjsify check
Section titled “gjsify check”Run TypeScript type checks across the workspace. The peer of format, lint and fix.
gjsify check # workspace-wide, parallelgjsify check --include '@gjsify/process' # one packagegjsify check --no-parallel --verbose # sequential, full output| Option | Default | Description |
|---|---|---|
--include <glob> | all | Only run in workspaces matching these globs. Repeatable. |
--exclude <glob> | none | Skip workspaces matching these globs. Repeatable. @girs/* is always excluded. |
-p, --parallel | true | Run checks in parallel. --no-parallel for sequential. |
-j, --jobs <n> | os.cpus().length | Max workers when parallel. |
--verbose | false | Log each per-workspace command before spawning. |
In a workspace root it walks every package that defines a check script and runs npm run check in each. Inside a single package it runs that package’s check script directly. Exit code is 1 if any check fails. With --no-parallel you get the first non-zero code. In parallel mode you get a summary of the failures.
gjsify tsc
Section titled “gjsify tsc”Run the TypeScript compiler, with every argument passed straight through. Same job as npx tsc.
gjsify tsc --noEmitgjsify tsc -p tsconfig.build.jsonTwo engines back it, and the machine picks. The @gjsify/tsc bundle runs as gjs -m <bundle> when that bundle resolves and gjs is on PATH. Otherwise gjsify spawns upstream npm typescript on Node. If neither is there it says so and exits 1, naming both fixes. It is the same thing as the gjsify-tsc bin from @gjsify/tsc. Most templates wire it into their check script.
gjsify format
Section titled “gjsify format”Format JS and TS through oxfmt.
gjsify format --init # write recommended .oxlintrc.json + .oxfmtrc.jsongjsify format src/ # format in place, the defaultgjsify format --check src/ # CI: exit non-zero on drift, write nothinggjsify format --no-write src/ # report drift locally without writing| Option | Default | Description |
|---|---|---|
[paths..] | . | Files or directories to format. |
--write | true | Apply changes in place. --no-write reports drift instead. |
--check | false | CI mode: report drift and stats, exit non-zero, write nothing. |
--config-path <path> | nearest one | Path to an .oxfmtrc.json. By default it walks up from the cwd. |
--init | false | Write recommended .oxlintrc.json and .oxfmtrc.json into the cwd, skipping existing files unless --force. |
--force | false | With --init, overwrite the existing config files. |
--verbose | false | Echo the resolved oxfmt launcher and args before spawning. |
A bare gjsify format writes. There is no flagless report mode. --check is the read-only CI mode, --no-write the read-only local one.
Under GJS, formatting runs in-process through the @gjsify/oxfmt-native bridge. Everywhere else the oxfmt npm launcher is resolved from node_modules and spawned with node. Set GJSIFY_OXFMT=npm to force the launcher, or GJSIFY_OXFMT=native to fail instead of falling back when the prebuild is missing. From inside a sub-workspace, resolution walks up to the workspace root, so a single .oxfmtrc.json there applies everywhere.
oxfmt itself formats JSON, CSS and TOML as well as JS and TS. The .oxfmtrc.json that --init writes ignores every one of those, so a GJSify project formats JS and TS only. Drop a pattern from ignorePatterns to widen it.
If oxfmt is missing you get [gjsify oxc] oxfmt not found. with gjsify install -D oxfmt as the hint, and exit 1.
What --init writes
Section titled “What --init writes”.oxfmtrc.json: 4-space indent, single quotes, semicolons, trailing commas everywhere, arrow parens always, print width 120, bracket spacing on. This matches the GJSify codebase and the GNOME Shell style guide. Generated artifacts are excluded (dist, lib, cli.gjs.mjs, test.{gjs,node}.mjs), along with Flatpak build directories, refs/, prebuilds and compiled .metainfo.xml.
.oxlintrc.json: oxlint’s correctness category as errors, plus typescript/no-non-null-assertion off (the ! operator is needed on @girs/* types), typescript/no-explicit-any and typescript/consistent-type-imports as warnings, unicorn/prefer-node-protocol as an error, and eslint/no-unused-vars as a warning. Same excludes as the formatter.
gjsify lint
Section titled “gjsify lint”Run oxlint diagnostics.
gjsify lint # everythinggjsify lint src/ # specific pathsgjsify lint --fix # apply safe fixes| Option | Default | Description |
|---|---|---|
[paths..] | . | Files or directories to lint. |
--fix | false | Apply safe lint fixes in place. |
--config-path <path> | nearest one | .oxlintrc.json override. |
--verbose | false | Echo the resolved oxlint launcher and args. |
oxlint is spawned through its Node launcher so its JavaScript plugin host is available. That host is what runs GJSify’s own plugin, @gjsify/oxlint-plugin-gjsify, wired in through jsPlugins in the workspace .oxlintrc.json. Its one rule, gjsify/register-class-order, catches static GObject metadata (GTypeName, Properties, Signals, InternalChildren, Template, CssName and their siblings) declared after a static { GObject.registerClass(…) } block. There registerClass runs before the field is assigned, and the metadata is silently ignored. The rule autofixes it by hoisting the fields above the static block. Name the rule when you need to configure or silence it. GObject classes explains the trap and the forms that avoid it.
Use gjsify fix for format plus safe lint fixes in one pass.
gjsify fix
Section titled “gjsify fix”oxfmt --write followed by oxlint --fix.
gjsify fix # format, then apply safe lint fixesgjsify fix --no-write # report only| Option | Default | Description |
|---|---|---|
[paths..] | . | Files or directories to process. |
--write | true | Apply fixes. --no-write reports only. |
--config-path <path> | nearest one | .oxlintrc.json / .oxfmtrc.json override. |
--verbose | false | Echo the resolved oxc launchers and args. |
Not to be confused with gjsify check (TypeScript) or gjsify system-check (system libraries).
gjsify barrels
Section titled “gjsify barrels”Regenerate index.ts barrel files. A drop-in for barrelsby.
gjsify barrels src/widgets src/utilsgjsify barrels src --check # CI: fail when a barrel is stale| Option | Default | Description |
|---|---|---|
[paths..] | [] | Directories to regenerate. |
--ext <js|ts|none> | none | Extension on the import specifiers. none is bundler-mode resolution. |
-b, --base-dir <dir> | cwd | Resolve paths against this directory. |
--exclude <regex> | \.test\., \.spec\., \.test-data\. | File names to skip. Repeatable. |
--header <text> | none | Header comment prepended to every generated file. |
--semicolon | false | Emit a trailing ; on each export line. |
--single-quotes | true | Use ' for import specifiers. --no-single-quotes for ". |
--check | false | Report drift without writing, exit non-zero if any barrel is stale. |
--verbose | false | Log each file scanned and written. |
gjsify system-check
Section titled “gjsify system-check”Verify that the system libraries a GJSify project needs are installed.
gjsify system-checkgjsify system-check --json| Option | Default | Description |
|---|---|---|
--json | false | Emit the results as JSON. |
It reports an install command for your detected package manager when something is missing, and exits 1 if any required dependency is absent. The required set is fixed rather than read off your project: the GNOME stack a GTK app links against, plus the gjs binary. Only the optional rows follow your dependencies. So a --app node project that reaches GTK through @gjsify/node-gi is still told to install gjs, even though it never runs it.
This used to be called gjsify check. The bare name now runs the TypeScript checks described above.
What it checks
Required. Always checked, and a miss is fatal: gjs, pkg-config, meson, plus gtk4, libadwaita-1, libsoup-3.0 and gobject-introspection-1.0. On Windows the Microsoft Visual C++ runtime is checked too, because the GTK bundle’s DLLs will not load without it.
Node.js is reported but never required. The install.mjs bootstrap is run by gjs, so “not installed” is a legitimate answer here.
Build toolchain, optional. ninja and vala for the Vala bridges, cargo for the three Rust-backed engines (@gjsify/rolldown-native, @gjsify/lightningcss-native, @gjsify/oxfmt-native). You only need these if you rebuild a prebuild from source.
Not checked: blueprint-compiler. A .blp template asks nothing of your machine — @gjsify/vite-plugin-blueprint parses and emits it in process (ADR 0053 clause 5), so there is no toolchain for this command to report on. It used to be a row here; ADR 0063 removed it rather than leave the command asking for a tool no build of yours can spend.
Library dependencies, optional. Checked only when the matching @gjsify/* package is in your project:
| System dependency | Needed by |
|---|---|
manette-0.2 | @gjsify/gamepad |
gstreamer-1.0, gstreamer-app-1.0 | @gjsify/webaudio |
gstreamer-1.0, gstreamer-webrtc-1.0 | @gjsify/webrtc-native |
webkitgtk-6.0 | @gjsify/iframe |
gdk-pixbuf-2.0 | @gjsify/dom-elements, @gjsify/canvas2d, @gjsify/canvas2d-core, @gjsify/webgl |
pango, pangocairo, cairo | @gjsify/canvas2d, @gjsify/canvas2d-core |
epoxy, plus the gwebgl npm package | @gjsify/webgl |
json-glib-1.0 | @gjsify/rolldown-native |
gnutls | @gjsify/tls-native |
libnghttp2 | @gjsify/http2-native |
JSON output
gjsify system-check --json{ "packageManager": "dnf", "deps": [ { "id": "gjs", "name": "GJS", "found": true, "version": "1.88.1", "severity": "required" }, { "id": "manette", "name": "libmanette", "found": false, "severity": "optional", "requiredBy": ["@gjsify/gamepad"] } ]}gjsify info
Section titled “gjsify info”List the native GJSify packages in node_modules and print the environment gjs needs to load their prebuilds.
gjsify info dist/index.jseval $(gjsify info --export)| Argument / Option | Description |
|---|---|
[file] | Bundle path to use in the generated example command. |
--export | Emit only shell export statements, ready for eval. |
You get GI_TYPELIB_PATH plus whichever library-search variable this host’s loader reads: LD_LIBRARY_PATH on Linux, DYLD_LIBRARY_PATH on macOS, PATH on Windows. Only the variables that apply here are emitted.
Build GNOME assets
Section titled “Build GNOME assets”gjsify gresource
Section titled “gjsify gresource”Compile a GResource XML descriptor into a binary .gresource bundle, so UI templates and assets ride along with your app without pulling in meson. It wraps glib-compile-resources.
gjsify gresource data/org.example.App.data.gresource.xml \ --sourcedir data \ --target dist/org.example.App.data.gresource| Option | Default | Description |
|---|---|---|
<xml> | required | Path to the .gresource.xml descriptor. |
--sourcedir <dir> | the descriptor’s directory | Where the referenced resource files live. |
-t, --target <file> | <xml> without .xml, next to it | Output .gresource file. |
--verbose | false | Print the underlying glib-compile-resources call. |
Needs glib-compile-resources (glib2-devel on Fedora, libglib2.0-dev-bin on Debian and Ubuntu).
gjsify gsettings
Section titled “gjsify gsettings”Compile GSettings schemas (*.gschema.xml) into a binary gschemas.compiled. It wraps glib-compile-schemas.
gjsify gsettings data/schemasgjsify gsettings data/schemas --targetdir dist/schemas| Option | Default | Description |
|---|---|---|
<schemadir> | required | Directory holding the *.gschema.xml files. |
-t, --targetdir <dir> | <schemadir> | Where to write gschemas.compiled. |
--strict | true | Abort on any schema warning. --no-strict to relax. |
--verbose | false | Print the underlying glib-compile-schemas call. |
Needs glib-compile-schemas (glib2-devel on Fedora, libglib2.0-dev-bin on Debian and Ubuntu).
gjsify gettext
Section titled “gjsify gettext”Compile gettext .po files. It wraps msgfmt with the output shapes GNOME apps need: a per-language .mo locale tree, and template substitution for a .desktop entry or an AppStream component.
# Runtime .mo locale treegjsify gettext translations dist/locale --domain org.example.App
# Merge every catalogue into an AppStream templategjsify gettext translations dist/metainfo \ --domain org.example.App \ --format xml \ --template data/metainfo/org.example.App.metainfo.xml.in
# …or into a desktop entrygjsify gettext translations dist/applications \ --domain org.example.App \ --format desktop \ --template data/org.example.App.desktop.in| Option | Default | Description |
|---|---|---|
<poDir> | required | Directory holding <lang>.po files. |
<outDir> | required | Output directory. A locale tree for --format mo, a plain directory otherwise. |
--domain <id> | required | Text domain or application id. |
--format <kind> | mo | mo, xml or desktop. |
--template <path> | none | Required for --format xml and --format desktop: the file msgfmt substitutes into. --metainfo is a deprecated alias. |
--filename <name> | <domain>.<ext> | Override the output filename. |
--remove-xml-comments | true | For --format xml, strip XML comments from the output. |
--verbose | false | Print each msgfmt call. |
Needs msgfmt (the gettext package).
--format xml and --format desktop require --template. msgfmt cannot produce either shape from .po files alone, and refuses with --desktop requires a "--template template" specification. The catalogues are merged one at a time with msgfmt --locale=<lang>, so no LINGUAS file is needed.
For --format xml, the template’s filename matters. msgfmt --xml finds its ITS rules by filename pattern, not by reading the document. gettext walks /usr/share/gettext/its/*.loc, and AppStream’s rule there pairs pattern="*.metainfo.xml" with the root element component. So an AppStream template must be named *.metainfo.xml or *.metainfo.xml.in. Named app.xml.in, the same content fails with cannot locate ITS rules for app.xml. Both metainfo.loc and metainfo.its come from the appstream package.
There is no --format json, because msgfmt has no JSON writer. Use @gjsify/vite-plugin-gettext’s po2jsonPlugin, which parses the catalogues directly.
Explore
Section titled “Explore”gjsify showcase
Section titled “gjsify showcase”List or run the curated showcase applications.
gjsify showcase # list themgjsify showcase three-geometry-teapot # run onegjsify showcase --json # machine-readable list| Option | Default | Description |
|---|---|---|
[name] | none | Showcase to run. Omit to list. |
--list | false | Force list mode. |
--json | false | Output JSON. List mode only. |
--runtime <gjs|node|bun|deno> | gjs when gjs is installed, else the host runtime | Which runtime to run the showcase on. |
Before launching a showcase on gjs, it verifies the required system libraries are installed and prints the install command for your package manager when any are missing. That check is skipped for node, bun and deno, which never touch the GJS bundle.
Run a showcase on Node, Bun or Deno
Section titled “Run a showcase on Node, Bun or Deno”The default is gjs whenever a gjs binary is available, because a showcase’s canonical artifact is its --app gjs bundle. Only on a host without gjs does the default follow the host runtime.
node, bun and deno resolve the showcase’s --app node bundle and run it there. The runtime is validated against the showcase’s gjsify.example.runtimes declaration, so a showcase that does not declare the runtime you asked for fails with a clear message rather than crashing. Most do ship one: the Adwaita storybook, the Express server and the Canvas 2D, three.js and Excalibur showcases all declare gjs, node, bun and deno.
gjsify showcase express-webserver # gjsgjsify showcase express-webserver --runtime node # the --app node bundle, on Node.jsgjsify showcase express-webserver --runtime bun # same bundle, on Bungjsify showcase express-webserver --runtime deno # same bundle, on DenoPer-example runtime declaration
Section titled “Per-example runtime declaration”An example or showcase can declare which runtimes it supports, so --runtime validates the request up front:
"gjsify": { "example": { "runtimes": ["gjs", "node", "bun", "deno"], // optional; omit to allow any "node": "dist/app.node.mjs" // optional; otherwise derived }}Leaving runtimes out is permissive. The showcases built on @gjsify/iframe (WebKit) and @gjsify/webrtc (GStreamer WebRTC) declare ["gjs"], so --runtime node errors cleanly. When node is omitted, the node bundle is derived from the GJS entry by convention: dist/<name>.gjs.js becomes dist/<name>.node.mjs.
Debug a running app
Section titled “Debug a running app”gjsify storybook
Section titled “gjsify storybook”Discover every *.story.ts in your project and launch the GTK and Adwaita component browser from @gjsify/storybook: a sidebar grouped by category, a live preview, and a generated controls panel. There is no per-project storybook application to maintain.
gjsify storybook # discover src/**/*.story.ts and launchgjsify storybook --stories packages # scan somewhere elsegjsify storybook --watch # rebuild and relaunch on change| Option | Default | Description |
|---|---|---|
--stories <dir> | src, or gjsify.storybook.stories | Directory scanned recursively for *.story.ts. |
--app-id <id> | gjsify.storybook.applicationId, else derived from the package name | GApplication id. |
--title <text> | none | Window title. |
--globals <value> | auto | Value for gjsify build --globals. Use auto,dom for canvas or DOM stories. |
--runtime <gjs|node|bun|deno> | the host runtime | Runtime to build for and launch on. node, bun and deno build the same --app node bundle and need @gjsify/node-gi installed in the project. |
--out <path> | node_modules/.cache/gjsify-storybook | Output bundle path. |
--watch | false | Rebuild and relaunch when a story file changes. |
--build-only | false | Build the bundle without launching it. |
Set defaults under package.json#gjsify.storybook (applicationId, title, stories, globals, runtime). Runtime precedence is the flag, then the config value, then the host default.
With GJSIFY_DEVTOOLS=1 the storybook host also exposes the devtools control plane on any of the four runtimes, so an agent can drive it with gjsify debug --profile storybook. See the Debugging and remote control guide.
gjsify debug
Section titled “gjsify debug”Launch an MCP bridge for a running, devtools-enabled GJSify app, talking to its org.gjsify.Devtools D-Bus control plane. An MCP client uses this as its server command. The bridge speaks JSON-RPC on stdio and translates each tool call to D-Bus. It comes from @gjsify/devtools-mcp.
# In .mcp.json:# { "mcpServers": { "my-app": { "command": "gjsify", "args": ["debug", "--bus-name", "org.example.App"] } } }
gjsify debug --bus-name org.example.App # generic profilegjsify debug --profile storybook # storybook toolsgjsify debug --build-only --out dist/bridge.gjs.mjs # build once, point .mcp.json at the bundle| Option | Default | Description |
|---|---|---|
--bus-name <name> | gjsify.devtools.busNameBase, else the storybook or browser app id | The app’s D-Bus base name. |
--address <addr> | GJSIFY_DEVTOOLS_ADDRESS, then the address file the app publishes, then the session bus | Peer D-Bus address (unix:path=…, nonce-tcp:…) instead of the session bus. This is how you reach an app on macOS or Windows, which have no session bus. |
--profile <kind> | auto | generic, storybook, browser or cdp. Auto picks storybook when @gjsify/storybook is a dependency, browser for @gjsify/devtools-browser, cdp for @gjsify/devtools-cdp, otherwise generic. |
--globals <value> | auto | Value for gjsify build --globals. |
--out <path> | node_modules/.cache/gjsify-debug | Output bundle path. |
--build-only | false | Build the bridge bundle without launching it. |
gjsify debug logs to stderr only, because stdout is the JSON-RPC channel. The bridge resolves @gjsify/devtools-mcp from your project’s node_modules. There is no --runtime here. The bridge bundle is always built --app gjs and launched with gjs, whichever runtime the CLI itself is on. The app it talks to can be on any of the four, since the two only ever meet over D-Bus. Full workflow: Debugging and remote control.
gjsify browse
Section titled “gjsify browse”Launch the minimal Adwaita web browser from @gjsify/devtools-browser, optionally at a URL. With --devtools it exposes the same org.gjsify.Devtools control plane, so an agent can navigate, screenshot the rendered page, evaluate JS, inspect elements and read the DOM, network and accessibility trees over MCP. It is meant for debugging web apps you wrote with gjsify.
gjsify browse # open page:welcomegjsify browse https://gnome.org # open a URLgjsify browse https://localhost:8080 --devtools # plus the MCP control planegjsify browse https://localhost:8080 --screenshot shot.png| Option | Default | Description |
|---|---|---|
[url] | page:welcome | Initial URL: a page:* built-in page or an https:// address. |
--app-id <id> | gjsify.browse.applicationId, else derived from the package name | GApplication id. |
--title <text> | none | Window title. |
--globals <value> | auto,dom | Value for gjsify build --globals. WebKit and the iframe need DOM globals. |
--out <path> | node_modules/.cache/gjsify-browse | Output bundle path. |
--devtools | false | Enable the MCP devtools control plane (sets GJSIFY_DEVTOOLS=1). |
--inspector-port <n> | none | Enable WebKit’s remote inspector protocol on this port plus the Cdp* methods. Implies --devtools. |
--screenshot <path> | none | One-shot: load the URL, capture a WebKit screenshot to this path, exit. Handy in CI. |
--build-only | false | Build the bundle without launching it. |
The browser is built on @gjsify/iframe, a WebKit.WebView postMessage bridge. It is always built --app gjs and launched with gjs, whichever runtime the CLI itself is on. With --inspector-port it also sets WEBKIT_INSPECTOR_HTTP_SERVER and exposes the @gjsify/devtools-cdp methods (CdpDiscoverTargets, CdpConnect, CdpSend, CdpDrainEvents) over the control plane. That is the full Runtime, DOM, CSS, Network, Console and Debugger protocol. Drive it with gjsify debug --profile browser, described in the Debugging and remote control guide.
Ship it
Section titled “Ship it”gjsify ship
Section titled “gjsify ship”Turn a built application into something a stranger can install. The payload is staged once, then wrapped per format. Full walkthroughs live under Ship your app, one page per operating system.
gjsify ship # this host's layout; on Linux, a .deb and an .rpmgjsify ship linux # the same two, from a Mac or from Windowsgjsify ship linux --target flatpak # a single-file Flatpak bundle (needs flatpak-builder)gjsify ship darwin --arch arm64 # a macOS <App>.app and a zip around itgjsify ship darwin --target macos-app-dmg # a .dmg, on macOS onlygjsify ship windows # a program directory and its zipgjsify ship windows --target msi # a Windows Installer package (needs wixl or WiX v3)gjsify ship --skip-build # package what is already builtgjsify ship --stage # produce the payload and stopgjsify ship --from-stage ./ship/stage # pack a payload assembled elsewheregjsify ship --from-stage ./stage --sign - # ad-hoc sign the payload (macOS, no certificate)The positional names the operating system whose LAYOUT to assemble: linux, darwin, windows (win32 is accepted too). It defaults to this host. Assembling is not host-bound, so any layout can be staged anywhere.
| Option | Default | Description |
|---|---|---|
--target <fmt..> | gjsify.ship.targets, else every format wrapping the target layout that needs no extra tooling | Formats to build. Comma-separated or repeated. Naming a format that wraps another layout is an error; a configured gjsify.ship.targets drops such a name with a printed note instead, because it is a project default rather than a claim about one run. |
--out <dir> | gjsify.ship.outDir, else ship | Output root, relative to the project. |
--stage | false | Produce the staged payload and stop, packing nothing. |
--from-stage <dir> | none | Pack a payload an earlier --stage run wrote. It needs no project at all, so no package.json, no config and no built bundle. |
--expect-target <os>-<arch> | none | Used with --from-stage. Refuses a stage assembled for a different matrix leg, such as linux-arm64. Compares against what the stage recorded, not against this host. |
--skip-build | false | Do not run the project’s build script first. |
--arch <arch> | this host | Target architecture, in process.arch spelling. Labels the artifact and picks the runtime packages; cross-builds nothing. |
--sign <identity> | gjsify.ship.sign.<os>.identity | Sign the payload with this identity, a NAME codesign or signtool looks the private key up by, never a certificate. - signs ad-hoc. Absent means unsigned, which is a legitimate output, and the skip is printed to stderr. darwin and win32 only. |
--notarize <profile> | none | Submit the signed artifact with xcrun notarytool submit --keychain-profile <p> --wait. Needs --sign, and runs on darwin only. |
--verbose | false | Print every staged file, the GI namespaces the bundle imports, and every tool invocation. |
What lands under ship/:
ship/stage/ the payload for one layoutship/stage/.gjsify-ship-stage.json the closure a packing host needs when it is not this oneship/overlay/<format>/ per-format additions, such as the licence where each format wants itship/flatpak/ --target flatpak only: the generated manifest, the build dir, the export repoship/schemas/ off Linux only: where gschemas.compiled is built before it is stagedship/out/ the artifactsship/out/ is packed by reading ship/stage/ back, so what you inspect is what ships.
The formats, and where each one packs
Section titled “The formats, and where each one packs”| Format | Layout | In the default set | Packs on | Needs installed |
|---|---|---|---|---|
deb | linux | yes | any host | nothing |
rpm | linux | yes | any host | nothing |
flatpak | linux | no | linux | flatpak-builder, flatpak |
macos-app | darwin | yes | any host | glib-compile-schemas |
macos-app-zip | darwin | yes | any host | glib-compile-schemas |
macos-app-dmg | darwin | no | darwin | hdiutil, part of macOS |
windows-dir | win32 | yes | any host | glib-compile-schemas |
windows-dir-zip | win32 | yes | any host | glib-compile-schemas |
msi | win32 | no | linux or win32 | wixl from msitools, or WiX Toolset v3.14 |
.deb, .rpm and both zips are written by ship itself, with no dpkg-deb, no rpmbuild and no zip. glib-compile-schemas is a tool rather than a host, so the formats that declare it still pack anywhere. A non-Linux layout has no install step, so the schemas are compiled while the tree is assembled.
Ask for a format this host cannot finish and you get a refusal naming the two-phase way across, never a broken file:
gjsify ship darwin --stage --target macos-app-dmg # here, any OS, offlinegjsify ship --from-stage ./ship/stage \ --target macos-app-dmg # there, on a MacName the format in the --stage run as well. Phase one renders one licence overlay per format, and a stage that never saw a format is refused when that format is asked for. A missing tool is a separate message from the wrong host, because the fixes differ, and both fire before your build script runs.
What it works out for you
Section titled “What it works out for you”- Runtime dependencies come from the
gi://imports in your built bundle, mapped to the package that ships each typelib (gir1.2-gtk-4.0on Debian,gtk4on Fedora). A namespace the table does not know fails the build and names itself, because an undeclared runtime dependency otherwise fails on a user’s machine after the download. Fill the gap withgjsify.ship.typelibPackages. - Architecture is
allornoarchunless the payload contains a.soor.node. A pure-JS app really does install everywhere, and claimingamd64would make apt refuse it on a machine it runs on. Where the payload does carry a native image,shipreads its ELFe_machineor Mach-Ocputypeback and refuses a label that contradicts it. - The launcher works out its own location at run time, so one payload works under
/usr, under/app, inside a.appand inside a Windows program directory. It execs the interpreter your bundle was built for, andshiprefuses a package whose launcher and dependency disagree. - Localised metadata is folded in from
gjsify.ship.localeDir. The compiled.mocatalogues becomeName[xx]=in the.desktopentry andxml:langin the AppStream component. - Metadata falls back to
gjsify.flatpak, so a project that already ships a Flatpak usually needs nogjsify.shipblock at all.
Packing the same build twice gives byte-identical files. How It Works explains how.
Configure it
Section titled “Configure it”"gjsify": { "ship": { "appId": "io.github.you.MyApp", // else gjsify.flatpak.appId, else package.json#name "binaryName": "my-app", // else the package name, scope stripped "bundle": "dist/index.gjs.js", // else gjsify.main, else package.json#main "icon": "data/icons", // a file or a directory "schemas": "data", // *.gschema.xml, named after the app id "depends": { "rpm": ["dconf"] }, // appended to the derived set "typelibPackages": { // fill a gap in the built-in table "Nautilus-3.0": { "deb": "gir1.2-nautilus-3.0", "rpm": "nautilus" } } }}| Key | Default | What it does |
|---|---|---|
appId | gjsify.flatpak.appId, else package.json#name | Reverse-DNS id. Names the desktop entry, the AppStream component, the installed icon, CFBundleIdentifier and the MSI upgrade code, so it cannot be guessed. |
binaryName | package name, scope stripped and lowercased | Package name and the launcher’s filename. |
name | title-cased binaryName | Display name. The .desktop Name=, CFBundleName, the <App>.app directory and the Windows program directory. |
version | package.json#version | Upstream version, normalised. An .msi needs a plain major.minor.build and refuses a prerelease. |
release | 1 | Package revision within one upstream version. |
maintainer | package.json#author | Maintainer:, Packager: and the MSI’s Publisher, as Name <email>. dpkg refuses a package without one. |
targets | every format wrapping the target layout that needs no extra tooling | Formats built when --target is not given. |
outDir | ship | Output root. |
bundle | gjsify.main, else package.json#main | The built bundle the launcher executes. Its whole directory is staged. |
icon | data/icons or data/icons/hicolor | Icon file or directory. Sizes are read from the path or the filename. |
schemas | data | A *.gschema.xml file or a directory of them. |
licenseFile | first of LICENSE, LICENSE.md, LICENSE.txt, COPYING | Licence file to ship. |
section / group | derived from categories | deb Section: and rpm Group:. |
mimeTypes | [] | shared-mime-info types the app opens. Rendered into share/mime/packages/ and into the desktop entry’s MimeType=. |
minGjsVersion | 1.86 | Minimum GJS the emitted dependency asks for. |
minNodeVersion | 24 | Minimum Node the emitted dependency asks for. Only used when the payload is an --app node bundle on Linux. |
depends | {} | Extra runtime dependencies per format, appended to the derived set. For things that are not typelibs. |
typelibPackages | {} | GI namespace to the package shipping its typelib. This is what unblocks an unknown namespace. |
bundledTypelibs | [] | Directories whose *.typelib and *.so the package carries itself, for GI libraries that arrive as npm prebuilds rather than distro packages. Staged into lib/<name>/gi/, with the launcher pointing GI_TYPELIB_PATH and LD_LIBRARY_PATH there. |
localeDir | none | Directory of COMPILED gettext catalogues in <lang>/LC_MESSAGES/<domain>.mo layout. Staged into share/locale/; the launcher exports GJSIFY_LOCALE_DIR. .po sources are refused, because bindtextdomain reads .mo only. |
fonts | none | Font files or a directory of them, staged into share/fonts/<appId>/. One payload path, three different readers: Linux gets a fontconfig directory, macOS an ATSApplicationFontsPath entry in the Info.plist, and Windows only a handed-over directory. The app must register them itself, see below. |
extraFiles | {} | Extra payload entries: prefix-relative destination to project-relative source. |
execArgs | [] | Arguments the launcher appends before the user’s own. |
flatpak | derived | The Flatpak half: runtime (gnome/freedesktop), runtimeVersion, branch (stable), sdkExtensions, appendPath, finishArgs, cleanup. |
sign | none | Default signing identity per OS: { "darwin": { "identity": "…" }, "win32": { "identity": "…" } }. An IDENTITY only. linux is not a valid key and is refused, because a .deb or .rpm is signed by the repository that serves it. |
Metadata keys (name, summary, description, developer, license, categories, keywords, homepageUrl, screenshots, and the rest) are shared with gjsify.flatpak and listed under flatpak init.
gjsify.ship.fonts stages your faces into share/fonts/<appId>/ on all three layouts.
What differs is who reads them, because the font backends differ rather than the
packaging:
- Linux. A fontconfig directory entry.
.deb/.rpmget<dir>/usr/share/fonts</dir>, every other prefix<dir prefix="xdg">fonts</dir>, which fontconfig also expands overXDG_DATA_DIRS, the variable the launcher already exports. Nothing to call. (That expansion is not infonts-conf(5). It is measured across eight independent fontconfig builds, 2.14.1 through 2.18.3.) - macOS.
ATSApplicationFontsPathin theInfo.plist. Declarative, and the ordering is the argument. The CoreText font map has no re-scan path, and the OS activates the faces before any of your code runs. Not verified end to end. Nothing here launches a real.appyet. - Windows. Nothing declarative exists.
pangocairoselects the win32 backend and populates from DirectWrite alone, so a fontconfig directory is inert. PointingFONTCONFIG_FILEat the staged directory moves the default font map by zero families, even when it is the only configuration present. Registering the face at runtime moves it by one, and the family then resolves for real rather than substituting.
The launcher exports GJSIFY_FONT_DIR at the staged directory on every layout,
because only it knows whether the payload became /usr, a --prefix tree, /app, a
bundle’s Contents/Resources or a Windows program directory. Reading it is your app’s
side of the handover, and @gjsify/gtk-host does it for you:
import { initFonts } from '@gjsify/gtk-host/fonts';
const fonts = initFonts();Call it once at startup and before any text is laid out. That ordering is
load-bearing rather than tidy. The fontconfig backend caches the fontset it resolved for
a description, and registering afterwards does not invalidate it. A layout that measured
the family first goes on measuring the fallback for the life of the process.
initFonts() reads GJSIFY_FONT_DIR itself, does nothing when the app ships no faces
and never throws. It returns which faces were registered, which the font map declined
and which failed. macOS declines correctly, because its bundle already activated them
before your code ran. Safe to call on all three operating systems, so there is no
platform branch to write.
Signing
Section titled “Signing”--sign takes an identity, never a certificate. codesign and signtool are both handed a string and look the private key up themselves, so gjsify ship is never given a secret and there is nothing to redact from a log.
| darwin | win32 | linux | |
|---|---|---|---|
| tool | codesign | signtool | none |
| what it signs | every Mach-O image in the payload | every PE image in the payload | nothing |
| runs on | macOS | Windows | none |
| project default | gjsify.ship.sign.darwin.identity | gjsify.ship.sign.win32.identity | refused |
With no identity the run skips, prints why on stderr, and exits 0. --sign on the --stage phase is refused, because that phase produces no artifact. --sign - signs ad-hoc and needs no Apple Developer Program membership.
--notarize <keychain-profile> is darwin-only and needs --sign. It does not staple, and no run in this repository has ever invoked it against a real Apple account. Sign your artifacts is the full picture.
Where the interpreter comes from
Section titled “Where the interpreter comes from”On Linux it is depended on, not shipped: gjs (>= 1.86), or nodejs (>= 24) on deb and nodejs(engine) >= 24 on rpm for an --app node bundle. Both floors exclude current Debian stable, and gjsify ship warns rather than lowering them. Set gjsify.ship.minGjsVersion or minNodeVersion if your bundle genuinely runs on an older one.
On macOS and Windows there is no system interpreter to depend on, so the artifact carries its own from @gjsify/node-runtime-<target>, with the GTK closure from @gjsify/gtk-runtime-<target> and the addon from @gjsify/node-gi. You declare all three yourself in the project you package. They are resolved by name out of your own node_modules at ship time, so they have to be installed there. GJSIFY_NODE_RUNTIME and GJSIFY_GTK_RUNTIME override the first two with a directory. All six names are published, at the same version as the rest of the release train, and re-measured against the registry before every release. macOS app bundles and Windows artifacts carry the copy-pasteable blocks.
That is also why the four macOS and Windows formats accept node only. There is no relocatable GJS to put inside a downloadable bundle, and there is no GJS host on Windows at all.
Which runtime a target ships is gjsify.ship.app.<os>, keyed linux, darwin and win32, falling back to gjsify.app. It is per target because the answer changes with the OS: a project can be GJS on Linux, where the distribution provides one, and Node where nothing does. One field for both questions meant that asking for a .app moved the Linux package’s Depends: with it.
gjsify flatpak
Section titled “gjsify flatpak”The Flatpak toolchain, for shipping GJS apps and CLIs to Flathub.
| Subcommand | What it does |
|---|---|
flatpak init | Scaffold the Flathub asset set: manifest JSON, MetaInfo XML, .desktop (apps only), flathub.json. |
flatpak check | Run appstreamcli validate --strict and flatpak-builder-lint locally. |
flatpak build | Wrap flatpak-builder, with --force-clean, --sandbox and --delete-build-dirs on. |
flatpak deps | Wrap flatpak-node-generator to produce the offline npm cache. |
flatpak sources | Generate an offline sources array from any lockfile. |
flatpak ci | Scaffold .github/workflows/flatpak.yml. |
flatpak sync-flathub | Point the Flathub tracking-repo manifest at a new tag and commit. |
flatpak diff | Compare local git state against that manifest and report drift. |
flatpak release | Chain init, check, tag and sync-flathub. |
End-to-end guides: Ship a GTK app as a Flatpak and Ship a CLI tool as a Flatpak.
gjsify flatpak init
Section titled “gjsify flatpak init”Generate the Flathub asset bundle from package.json#gjsify.flatpak.
gjsify flatpak init # GTK/Adwaita desktop appgjsify flatpak init --kind cli # CLI tool: no .desktop, console-application MetaInfo| Option | Default | Description |
|---|---|---|
--app-id <id> | gjsify.flatpak.appId, else package.json#name | Reverse-DNS app id. |
--kind <app|cli> | app | cli emits console-application MetaInfo and a flathub.json with skip-icons-check: true, and no .desktop. |
--cli-only | false | Deprecated alias for --kind cli. |
--runtime <gnome|freedesktop> | gnome | Runtime family. Both kinds default to GNOME, because GJS bundles need GLib and GIO at runtime. |
--runtime-version <v> | 50 for gnome, 24.08 for freedesktop | Runtime version. |
--manifest <path> | <app-id>.json | Manifest output path. |
--metainfo <path> | data/<app-id>.metainfo.xml.in | MetaInfo output path. |
--desktop <path> | data/<app-id>.desktop.in | .desktop output path. App kind only. |
--flathub-json <path> | flathub.json | flathub.json output path. |
--command <name> | gjsify.flatpak.command, else the app id | Binary name in /app/bin. |
--sdk-extension <ext> | none | Extra SDK extension, for example org.freedesktop.Sdk.Extension.node24. Repeatable. |
--finish-arg <arg> | defaults | Extra finish-arg. Repeatable. |
--format | true | Run oxfmt --write on generated JS/TS when oxfmt is present. The JSON, XML and .desktop files are not reformatted. --no-format to skip. |
--force | false | Overwrite existing outputs. By default they are skipped and logged. |
--verbose | false | Print resolved fields before writing. |
Each output is checked for existence on its own, so a hand-tuned .desktop does not block re-running init to refresh the others. Missing MetaInfo fields are reported with the exact gjsify.flatpak.<key> to set. The manifest still writes, and MetaInfo and .desktop wait until you fill the gaps.
Every gjsify.flatpak metadata key
gjsify.flatpak.<key> | Required for | Notes |
|---|---|---|
appId | both | Reverse-DNS. |
kind | both | "app" (default) or "cli". |
name | optional | Display name for <name> and .desktop Name=. Derived from package.json#name by default, so set it when the npm name is not the display name (npm learn6502 against "Learn 6502 Assembly"). |
developer.id / developer.name | metainfo | AppStream OARS 1.1 and later require <developer id="…">. |
developer.email | optional | Emits <email> inside <developer>. |
developer.nameTranslatable | optional | Default false, which emits translate="no". Set true for descriptive names. |
summary | metainfo | 80 characters or fewer, no trailing period. |
summaryTranslatorHint | optional | Emits a <!-- TRANSLATORS: ... --> comment before <summary>. |
description | metainfo | A string (blank lines split it into <p>), or a DescriptionBlock[] of {p, translatorHint?} paragraphs and {ul:[...], translatorHint?} lists. |
license.metadata | metainfo | SPDX id for the metadata itself. Defaults to CC0-1.0. |
license.project | metainfo | SPDX id of the software. |
homepageUrl | metainfo | <url type="homepage">. |
bugtrackerUrl / vcsBrowserUrl / donationUrl / translateUrl | optional | Extra <url> entries. translateUrl is your Weblate or Crowdin URL. |
iconRemote | optional | <icon type="remote">, useful for a Flathub thumbnail before a local SVG ships. |
categories | metainfo (app), desktop | Freedesktop menu categories. |
keywords | optional | Search keywords. |
releases | metainfo | [{ version, date, description? }]. Flathub needs at least one. |
screenshots | optional (app) | [{ url, caption?, captionTranslatorHint?, environment?, type? }]. |
branding | optional (app) | { accentLight, accentDark } hex colours. |
icon | optional (app) | Path to a scalable SVG. You get a warning if it is missing. |
contentRating | optional | An OARS keyword string (default oars-1.1), or { type?, attributes? } with OARS keys mapped to none, mild, moderate or intense. |
kudos | optional | Flathub quality markers such as ModernToolkit, HiDpiIcon, TouchscreenSupport, UserDocs. |
provides.binaries | optional | Defaults to [command]. |
provides.mimetypes / provides.dbus | optional | Extra <mediatype> and <dbus> entries. |
supports.controls | optional | ["keyboard", "pointing", "touch", "gamepad", "tablet", "console", "vision"]. |
supports.internet | optional | "always", "offline-only" or "first-run". |
requires.displayLengthMin / recommends.displayLengthMin | optional | Minimum display length in pixels. Phone portrait is about 360, tablet about 480. |
requires.controls / recommends.controls | optional | Hard and soft control requirements. |
runtime / runtimeVersion | optional | Runtime family and version. |
sdkExtensions / appendPath | optional | Extra SDK extensions and PATH components inside the build sandbox. |
command | optional | The binary in /app/bin. Defaults to the app id. |
finishArgs | optional | Sandbox capabilities. Defaults for kind: "app" are --device=dri, --share=ipc, --socket=fallback-x11, --socket=wayland; kind: "cli" gets none. |
extraModules | optional | Extra modules prepended before the generated Meson module. |
modules | optional | Replaces the module array outright, so neither extraModules nor the Meson default is emitted. This is what a plain JS CLI wants, since the Meson default does not apply to it. |
flathubRepo | optional | Overrides the flathub/<app-id> derivation for repos that do not follow the convention. |
Every translatable string (summary, description paragraphs and list items, screenshot captions, release notes) takes a parallel translatorHint that becomes a <!-- TRANSLATORS: ... --> comment in the generated .metainfo.xml.in. xgettext and msgfmt --xml --template forward those to the .po files, so translators see the context. Ship a GTK app as a Flatpak has a worked example.
gjsify flatpak check
Section titled “gjsify flatpak check”Run the Flathub linters locally, the same ones Flathub’s PR CI runs.
gjsify flatpak check # auto-detect the manifestgjsify flatpak check eu.jumplink.Learn6502.json # explicit manifestgjsify flatpak check --repo repo # also lint a built repo| Option | Default | Description |
|---|---|---|
[manifest] | auto | Manifest path. Defaults to <app-id>.json, or the single .json that looks like a manifest. |
--metainfo <path> | data/<app-id>.metainfo.xml.in | MetaInfo to validate. Skipped when missing. |
--repo <path> | none | Also run flatpak-builder-lint repo <path>, after a build. |
--appstream | true | Run appstreamcli validate --strict. --no-appstream skips it. |
--builder-lint | true | Run flatpak-builder-lint manifest. --no-builder-lint skips it. |
--verbose | false | Stream linter output through. |
Needs appstreamcli and flatpak-builder-lint on PATH. Both ship inside the org.flatpak.Builder Flatpak: flatpak install -y flathub org.flatpak.Builder. The command prints that hint when a binary is missing. Exit code is non-zero if any linter fails or any binary is absent.
gjsify flatpak build
Section titled “gjsify flatpak build”Build the Flatpak with flatpak-builder, then install it, export it to a repo, bundle it or tar the build directory up.
gjsify flatpak buildgjsify flatpak build --installgjsify flatpak build --repo repo --bundle my-app.flatpak| Option | Default | Description |
|---|---|---|
[manifest] | first manifest-shaped .json in cwd | Manifest path. |
--build-dir <dir> | flatpak-build | flatpak-builder working directory. |
--install | false | After the build, run flatpak-builder --user --install. |
--repo <dir> | none | Export into this OSTree repo. |
--bundle <path> | none | After a --repo export, build a single-file bundle here. |
--tarball <path> | none | Create a tarball of the build directory. |
--force-clean | true | Pass --force-clean to flatpak-builder. |
--sandbox | true | Pass --sandbox. |
--delete-build-dirs | true | Pass --delete-build-dirs. |
--install-deps-from <remote> | none | Pass --install-deps-from, for example flathub. |
--verbose | false | Print the underlying invocations. |
gjsify flatpak deps
Section titled “gjsify flatpak deps”Generate the Flatpak offline npm cache from a yarn.lock or package-lock.json, wrapping flatpak-node-generator.
| Option | Default | Description |
|---|---|---|
--lockfile <path> | yarn.lock or package-lock.json in cwd | Lockfile to read. |
--type <yarn|npm> | from the filename | Lockfile type. |
--out <path> | flatpak-node-sources.json | Output sources file. |
--xdg-layout | true | Pass --xdg-layout, recommended for Yarn Berry and PnP. |
--electron-node-headers | false | Pass --electron-node-headers. |
--verbose | false | Print the underlying invocation. |
gjsify flatpak sources
Section titled “gjsify flatpak sources”Generate an offline flatpak-builder sources array from any lockfile, so a Flathub build needs no network. Unlike deps, this one reads gjsify-lock.json too and needs no external generator.
gjsify flatpak sourcesgjsify flatpak sources --print-module| Option | Default | Description |
|---|---|---|
--lockfile <path> | first of gjsify-lock.json, package-lock.json, yarn.lock, pnpm-lock.yaml in cwd | Lockfile to read. |
--type <gjsify|npm|yarn|pnpm> | from the filename | Lockfile format. |
--out <path> | gjsify-sources.json | Output sources file. |
--cache-root <dir> | flatpak-gjsify-cache | Directory the tarballs download into. Point XDG_CACHE_HOME here in the build. Tarballs land at <cache-root>/gjsify/tarballs/v1/<algo>/<shard>/<hex>.tgz. |
--print-module | false | Also print a ready-to-paste manifest module snippet to stderr. |
gjsify flatpak ci
Section titled “gjsify flatpak ci”Scaffold .github/workflows/flatpak.yml around the flathub-infra container and the flatpak-builder action.
| Option | Default | Description |
|---|---|---|
--manifest <path> | <app-id>.json | Manifest the workflow points at. |
--bundle <name> | <app-id>.flatpak | Bundle filename the action produces. |
--runtime-image <image> | derived from gjsify.flatpak.runtime and runtimeVersion | Container image override, for example ghcr.io/flathub-infra/flatpak-github-actions:gnome-50. |
--branches <name..> | main | Branches the workflow runs on push for. |
--out <path> | .github/workflows/flatpak.yml | Output path. |
--cache-key <key> | flatpak-builder-${{ github.sha }} | Override the action cache key. |
--force | false | Overwrite an existing workflow file. |
--verbose | false | Print resolved fields. |
gjsify flatpak sync-flathub
Section titled “gjsify flatpak sync-flathub”Flathub publishes each app from its own repo, whose manifest pins your upstream tag and commit. After cutting a release, this updates that pin and opens the PR.
gjsify flatpak sync-flathub # latest local taggjsify flatpak sync-flathub --version v0.6.6 --commit 1a2b3c4dgjsify flatpak sync-flathub --version v0.6.6 --dry-run # show the plangjsify flatpak sync-flathub --version v0.6.6 --no-pr # clone, commit, push, no PR| Option | Default | Description |
|---|---|---|
--version <tag> | git describe --tags --abbrev=0 | Git tag to sync to. |
--app-id <id> | gjsify.flatpak.appId | Used to locate the manifest in the tracking repo. |
--flathub-repo <owner/name> | gjsify.flatpak.flathubRepo, else flathub/<app-id> | Tracking repo. |
--commit <sha> | git rev-list -n 1 <version> | Commit to pin. |
--branch <name> | update-to-<version> | Branch in the tracking repo. |
--source-index <n> | first type: git source | Which modules[0].sources[] entry to update. |
--pr | true | Open a PR with gh pr create after commit and push. --no-pr stops after the push. |
--dry-run | false | Report the resolution, branch and commit, touching no files. |
--verbose | false | Echo every git and gh invocation. |
It clones or updates flathub/<app-id> under $XDG_CACHE_HOME/gjsify/flathub-sync/ and edits modules[0].sources[<i>] to set tag and commit. It adds an x-checker-data block if missing, so Flathub’s update bot can pick up future releases, and it preserves the manifest’s original indentation and key order. Needs git always, and gh unless you pass --no-pr. Re-running with the same --version does nothing when the manifest is already pinned.
gjsify flatpak diff
Section titled “gjsify flatpak diff”Compare local git state against the Flathub tracking-repo manifest before you publish.
gjsify flatpak diffgjsify flatpak diff --version v0.6.6gjsify flatpak diff --against ./flathub/<app-id>.json # offlinegjsify flatpak diff --detail| Option | Default | Description |
|---|---|---|
--version <tag> | git describe --tags --abbrev=0 | Local version to compare. |
--app-id <id> | gjsify.flatpak.appId | Reverse-DNS app id. |
--flathub-repo <owner/name> | gjsify.flatpak.flathubRepo, else flathub/<app-id> | Tracking repo to fetch from. |
--against <path> | none | Read a local manifest instead of fetching. |
--detail | false | Also print the full Flathub source entry. |
--source-index <n> | first type: git source | Which modules[0].sources[] entry to inspect. |
--verbose | false | Echo the fetch URL and resolved values. |
Exit 0 when the tags match, exit 1 on drift, with the exact gjsify flatpak sync-flathub command that fixes it.
gjsify flatpak release
Section titled “gjsify flatpak release”Cut a release end to end: flatpak init to regenerate assets, flatpak check to lint, git tag and push, then flatpak sync-flathub to open the Flathub PR.
gjsify flatpak release v0.6.6gjsify flatpak release v0.6.6 --dry-run # show the plangjsify flatpak release v0.6.6 --skip-tag # the tag already exists| Option | Default | Description |
|---|---|---|
<version> | required | Release tag, for example v0.6.6. |
--skip-init | false | Skip the flatpak init --force regeneration. |
--skip-check | false | Skip the linter step. |
--skip-tag | false | Skip git tag and the push. |
--push-tag | true | Push the tag after creating it. |
--flathub-repo <owner/name> | none | Override forwarded to sync-flathub. |
--dry-run | false | Print each step without running any of them. |
--verbose | false | Echo every sub-command. |
init and check run before the tag is created, so a failure leaves you with no tag rather than a half-released one.
Publish to npm
Section titled “Publish to npm”gjsify pack
Section titled “gjsify pack”Produce an npm-compatible .tgz for a workspace. A drop-in for npm pack. workspace:^, workspace:~ and workspace:* dependencies are always rewritten to resolved version ranges, so the tarball is portable.
gjsify pack # the current workspacegjsify pack packages/infra/cli # a specific onegjsify pack --pack-destination dist # write it somewhere elsegjsify pack --json # npm-pack-compatible metadata| Option | Default | Description |
|---|---|---|
[path] | cwd | Workspace to pack. |
--pack-destination <dir> | the workspace | Where to write the tarball. |
--json | false | Emit pack metadata as JSON on stdout. |
--dry-run | false | Compute everything, write no .tgz. |
--ignore-scripts | false | Skip the prepack lifecycle script. Use it when an outer workflow already ran the scripts. |
It honours the files allowlist plus .npmignore and .gitignore with npm’s precedence, and always includes package.json, README*, LICENSE*, NOTICE*, and the main and bin entries even when files leaves them out.
gjsify publish
Section titled “gjsify publish”Pack and upload a workspace. A drop-in for npm publish, using gjsify pack, so the workspace:^ rewrite happens for you.
gjsify publish # the current workspacegjsify publish packages/infra/cli --tag latestgjsify publish --access public # first publish of a scoped packagegjsify publish --access public --otp 123456 # with a 2FA codegjsify publish --tolerate-republish # treat "already published" as successgjsify publish --dry-run # pack only| Option | Default | Description |
|---|---|---|
[path] | cwd | Workspace to publish. |
--tag <tag> | latest | Dist-tag. |
--access <kind> | none | public or restricted. Required for the first publish of a scoped package. |
--otp <code> | none | npm 2FA code, sent as the npm-otp header. If the registry answers 401 OTP-required and you did not pass one, an interactive terminal prompts once and retries; a non-TTY exits non-zero with an actionable message. |
--tolerate-republish | false | Treat “version already published” as success, covering both the classic 409 and the OIDC-path 403. |
--tolerate-untrusted-new | false | Exit 0 when OIDC token exchange says “package not found” and no fallback token is configured, which is a never-published scoped package whose Trusted Publisher is not set up yet. Without it, one un-bootstrapped package breaks a whole serialized gjsify foreach publish. |
--trusted | auto | Authenticate through npm Trusted Publishing, exchanging the GitHub Actions id-token for a short-lived npm token. Auto-detected when ACTIONS_ID_TOKEN_REQUEST_URL and _TOKEN are set and the resolved npmrc has no _authToken. Needs permissions: id-token: write in the workflow and a Trusted Publisher on npmjs.com. |
--check-trusted | false | Do the OIDC exchange, report success or failure, and exit without publishing. Useful as a bulk verifier via gjsify foreach publish --check-trusted. |
--verify-timeout <s> | 600 | Seconds to keep asking the registry for the version just published, before giving up. 0 disables the read-back, and the success line then says UNVERIFIED. |
--verify-defer | false | Report an unverified publish and exit 0 instead of 1. Only for a caller that re-checks the same set afterwards. |
--provenance | false | Recorded in the payload. No signing happens yet. |
--dry-run | false | Pack only, do not upload. |
--json | false | Emit publish metadata as JSON. |
Auth reads process.env.NPM_CONFIG_USERCONFIG first (where actions/setup-node writes the auth-token npmrc), falling back to ~/.npmrc.
A 2xx from npm is an accepted write, not a durable one, so the upload is read back. After the PUT
succeeds, gjsify publish asks the registry for that exact name@version and prints
+ name@version only once it is served. A 2xx that never resolves is its own outcome,
publish-unconfirmed, exit 1. The message then states what was PUT, what was asked and what came
back. The retry window exists because npm’s write really is eventually consistent: measured over
the 199 packages of the v0.46.0 release, 90.5% were committed before the response arrived and 9.5%
between 56 and 252 seconds after it, while one was never committed at all under a green job. A
409 already published tolerated by --tolerate-republish is read back the same way, because npm
can refuse to overwrite a version seconds before it serves it. The read-back GET carries the same
credential the upload did, so a registry that requires a token to read packuments does not turn a
good publish into a red one, and every probe carries a cache key of its own — measured against
registry.npmjs.org, cache-control: no-cache is ignored by the edge (cf-cache-status: HIT, with
an age up to the packument’s own max-age=300) and only a unique query parameter reaches origin.
The success line says what it established. A confirmed publish prints
+ name@version (verified on <registry> — N probe(s), Xs); --verify-timeout 0, the escape hatch
for a registry with no packument read path, prints
+ name@version (UNVERIFIED — read-back disabled by --verify-timeout 0) and a GitHub Actions
warning annotation beside it. A bare + name@version with no clause is a CLI older than v0.47.0,
which had no read-back at all. --json carries the same facts as verified and verification.
An unconfirmed read-back says WHICH of three things it found, because the remedies differ:
not-published (the registry has no record of the version — re-publish), recorded-not-served
(the registry records the write and its install document does not serve it yet — wait; a
re-publish is answered 409), and unknown (a 5xx, a timeout, a dropped connection: nothing was
established, in particular not that the publish failed).
Publish every workspace in one go with gjsify foreach:
gjsify foreach --no-private --exec -- gjsify publish --tag latest --access publicgjsify whoami
Section titled “gjsify whoami”Print the npm username behind your current token, with a clear message when the token is dead, missing, or the registry is unreachable.
gjsify whoamigjsify whoami --json| Option | Default | Description |
|---|---|---|
--registry <url> | scope-aware .npmrc lookup, else https://registry.npmjs.org/ | Registry to probe. |
--json | false | Emit {username, registry}, or {error, registry}, as one line. |
gjsify login
Section titled “gjsify login”Log in to an npm registry and write the token to ~/.npmrc.
gjsify logingjsify login --scope @my-orggjsify login --username me --otp 123456| Option | Default | Description |
|---|---|---|
--registry <url> | https://registry.npmjs.org/, or the scope’s registry | Registry to log in to. |
--scope <name> | none | Associate the login with a scope, resolving that scope’s registry from .npmrc. |
--username <name> | prompted | Username. |
--otp <code> | prompted on demand | 2FA code. |
--json | false | Emit {username, registry} on success. |
It prompts for the password with the input hidden. This is npm’s legacy credentials flow. The web OAuth flow is not supported.
gjsify logout
Section titled “gjsify logout”Revoke the token on the registry (best effort) and remove it from ~/.npmrc.
gjsify logoutgjsify logout --scope @my-org| Option | Default | Description |
|---|---|---|
--registry <url> | https://registry.npmjs.org/, or the scope’s registry | Registry to log out of. |
--scope <name> | none | Log out of a scope’s registry, resolved from .npmrc. |
--json | false | Emit {registry, revoked, removed}. |
gjsify trust
Section titled “gjsify trust”Configure npm Trusted Publishers (OIDC through GitHub Actions) for your publishable workspace packages, so release.yml can publish without a long-lived token. No npm binary needed, and it skips packages that are already configured.
gjsify trust # every publishable workspacegjsify trust '@gjsify/web-*' # a subsetgjsify trust --list # report state, change nothinggjsify trust --dry-run| Option | Default | Description |
|---|---|---|
[packages..] | all publishable | Package-name globs limiting the sweep. |
--repository <owner/repo> | inferred from origin | GitHub repo the Trusted Publisher is scoped to. |
--workflow <file> | release.yml | Workflow allowed to publish. Basename only. |
--environment <env> | none | GitHub Actions environment the workflow must run in. |
--registry <url> | scope-aware .npmrc lookup | Registry override. |
--otp <code> | prompted on demand | 2FA code, sent as npm-otp. |
--dry-run | false | List what would be configured. |
--force | false | Re-POST the config even for already-trusted packages. |
--list | false | Only report each package’s current trust state. |
--private | false | Include private workspaces. They are not publishable, so this is off by default. |
gjsify onboard
Section titled “gjsify onboard”Make sure every publishable package in a monorepo is both published on npm and has a Trusted Publisher configured, doing only the missing work. It folds the whole manual first-publish and trust bootstrap into one idempotent sweep.
Nothing about it is specific to a gjsify project. It works on any npm or yarn workspace, and --packages extends it to a monorepo with no workspace manifest at all: a repo whose package directories sit next to each other.
gjsify onboard # publish and trust whatever is missinggjsify onboard --dry-run # report the plangjsify onboard --packages '*' # a monorepo with no root package.jsongjsify onboard --exclude '@acme/*' # filter the set by package namegjsify onboard --otp 123456 # seed the shared 2FA codegjsify onboard --json # machine-readable summary as the last stdout linegjsify onboard --yes # non-interactive| Option | Default | Description |
|---|---|---|
--packages <glob> | root manifest workspaces | Directory glob naming package folders, resolved against the repo root. Repeatable. Merged with the root manifest’s own globs when it has any. A pattern that matches no directory is a hard error. |
--include <glob> | all | Include packages by name. Repeatable. |
--exclude <glob> | none | Exclude packages by name. Repeatable. |
--repository <owner/repo> | inferred from origin | GitHub repo the Trusted Publisher is scoped to. |
--workflow <file> | release.yml | Workflow allowed to publish via OIDC. Basename only. |
--environment <env> | none | GitHub Actions environment the workflow must run in. |
--access <a> | public | npm access for a package this sweep publishes for the first time. An already-published package keeps the access it has. |
--build / --no-build | --build | Run a to-be-published package’s build script first. Turn it off for a repo whose packages are generated artifacts. |
--registry <url> | scope-aware .npmrc lookup | Registry override. |
--otp <code> | prompted once on demand | The initial shared 2FA code. |
--concurrency <n> | 4 | How many packages to read state for in parallel. Kept small so one token does not burst npm. The first read is always serial, to prompt for the shared code once. |
-v, --verbose | false | List every package in the plan, not just the rows that need work. The counts always cover all of them. |
--dry-run | false | Report the plan without changing anything. |
--json | false | Emit a summary object as the final stdout line. |
--yes | false | Never prompt. Fail clearly if a login or an OTP is needed and not supplied. |
What it does, in order: check the token is live (running the login flow only if it is not), enumerate the publishable packages, read each package’s Trusted Publisher state concurrently, then act only on the gaps. One 2FA code is reused across every publish and trust operation, so a sweep of many packages usually asks you for a code once. Re-running when everything is already published and trusted does nothing and exits 0.
The sweep reports progress through both phases. The state-read phase ticks (read 600/703, then
590 to do, 10 already done) and every write is numbered ([123/662] trusted @acme/x). Nothing
else writes to the terminal while a 2FA prompt is open. Those messages are held and flushed once
you have answered, so a notice from a concurrent worker cannot land inside the digits you are
typing.
One 2FA code covers the whole sweep, and it is asked for once at a time. Concurrent probes share the prompt rather than each opening their own. npm codes expire on their ~30-second window, so a long sweep may ask again later. Each such expiry costs exactly one prompt.
Trusted-Publisher writes run at --write-concurrency (default 4); publishes stay serial,
because publish order is a correctness property. Raising the write concurrency buys fewer 2FA
prompts rather than raw speed. Measured against a 703-package repo, npm rate-limits the sweep at
serial pace already, so the registry sets the ceiling, not the loop. What serial cost was codes:
one lives about 30 seconds, so the sweep crossed a code boundary roughly every 38 packages.
An HTTP 429 is waited out, not reported. npm throttles a long sweep, and because it is cumulative
it lands on the tail of the list. That reads as “these packages are special” when the truth is
that the sweep asked too fast. A 429 anywhere pauses everywhere. Retrying one throttled
request in isolation leaves the rest of the sweep provoking the very limit that retry is waiting
out, which is how a real run spent its retry budget and reported trust failed (HTTP 429). Reads and writes share one cool-down, so the
sweep self-paces down to whatever npm will serve. The wait is a TIME budget (5 minutes per request), not an
attempt count. A fixed number of doubling retries is only ~30 seconds of patience, and npm’s
window is longer than that: a real 703-package sweep failed its last 73 writes inside a single
cooldown. Only throttling that outlasts the budget is reported, in the plan AND in the closing
summary. A write is throttled long after the plan has scrolled away, and 73 failed on its own
reads as 73 broken packages.
npm advertises no budget ahead of time, and there are no X-RateLimit-* headers on ordinary
responses. So the first 429 of a run prints what the registry actually said, including when it
said nothing and the delay is the CLI’s own. Re-running is safe, because the sweep is idempotent
and skips whatever already landed.
A package whose own package.json names a different repository than the one being
configured is refused, with the foreign repo and the count. A workspace of a repo is not the same
claim as a package published from it: gjsify/ts-for-gir has 703 generated @girs/* workspaces
that publish from gjsify/types, and a Trusted Publisher scoped to the wrong repository points
that package’s OIDC exchange at a workflow that never publishes it. Narrow the set with
--exclude / --include, or point --repository at the repo that does publish them. A package
that declares no repository is not evidence of a mismatch, and passes.
The first line of output names the repo root and every enumeration source with its count: root=/src/types | packages(*)=703. Read it before you let a sweep write to npm. The package list is the whole blast radius, and a total on its own cannot tell the right tree from a plausible wrong one. --json carries the same three fields (root, sources, discovered) in its summary object.