Skip to content

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:

Terminal
curl -fsSL https://github.com/gjsify/gjsify/releases/latest/download/install.mjs \
-o /tmp/g.mjs && gjs -m /tmp/g.mjs && rm /tmp/g.mjs

The three package runners fetch the CLI from npm and run it in place:

Terminal
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.

TaskCommands
Start a projectcreate
Build and runbuild · dev · run · test · clear · copy
Dependenciesinstall · uninstall · prune · upgrade · dlx · self-update · generate-installer
Monoreposforeach · workspace · affected
Code qualitycheck · tsc · format · lint · fix · barrels
GNOME assetsgresource · gsettings · gettext
Environmentsystem-check · info
Exploreshowcase
Debug a running appstorybook · debug · browse
Ship itship · flatpak
Publish to npmpack · publish · whoami · login · logout · trust · onboard

Scaffold a new project into a new directory.

Terminal
gjsify create my-app --template gtk-minimal
gjsify create my-app --template cli --runtime deno
gjsify create my-app --template cli --package-manager pnpm --install
gjsify create # pick template, runtime and manager interactively

On 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.

OptionDefaultDescription
[project-name]my-gjs-appDirectory to create.
-t, --template <name>promptedWhich template to scaffold from. Required when stdin is not a TTY.
-r, --runtime <rt>the host runtimeOne 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 firstMust 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, --forcefalseScaffold into a directory that already has files in it.
--installfalseRun 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: gjsgjsify; nodenpm, yarn, pnpm, gjsify; bunbun; denodeno. 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:

TemplateWhat you get
gtk-minimalA Gtk.ApplicationWindow declared in Blueprint. No Adwaita.
adw-canvas2dAdwaita app rendering through HTML Canvas 2D, Blueprint UI.
adw-webglAdwaita app with WebGL and three.js, Blueprint UI.
adw-gameAdwaita game shell on Excalibur.js, WebGL with a Canvas2D fallback.
cliCommand-line tool built on yargs.
web-server-expressHTTP server on Express.
web-server-honoHTTP 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.

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.

Terminal
gjsify build src/index.ts --outfile dist/index.js
gjsify build src/index.ts --outfile dist/index.js --no-minify # readable output
gjsify build src/index.ts --watch # rebuild on change
OptionValuesDefaultDescription
[entryPoints..]pathsbundler.input, else src/index.tsEntry points to bundle.
--appgjs | node | browser | nativescriptthe host runtime’s targetBuild 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, --outfilepathfrom package.jsonOutput file (application mode).
-d, --outdirpathfrom package.jsonOutput directory (library mode).
--minifybooltrueMinify the output. Pass --no-minify for pretty-printed code.
--globalsstringautoWhich globals to inject. See Globals.
--dialectreact-nativenoneBuild 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-rendererboolfalseResolve 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-globalslistnoneIdentifiers to drop from the auto-detected set, for false positives out of dead compat code (--exclude-globals fetch,XMLHttpRequest).
--shebangboolfalsePrepend 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, --watchboolfalseWatch 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.
--verboseboolfalsePrint detected globals and build details.
The rest of the build flags
OptionValuesDefaultDescription
--formatiife | esm | cjsautoOverride the output format.
--libraryboolfalseBuild a reusable library instead of an application.
-r, --reflectionboolfalseEnable TypeScript runtime types via Deepkit’s type compiler.
--console-shimbooltrueInject the GJS console shim, so output has no GLib prefix and ANSI colours work. --no-console-shim turns it off. GJS app builds only.
--excludeglob[][]Glob patterns to exclude from entry points and aliases.
--log-levelsilent | error | warning | info | debug | verbosewarningBundler log level.
--externalname[][]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.
--defineKEY=VALUE[][]Compile-time constants. VALUE is a JS expression, so string literals need quoting: --define VERSION='"1.2.3"'. Repeatable.
--aliasFROM=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.

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:

Terminal
gjsify build src/app.tsx --app gjs --outfile dist/app.gjs.mjs

--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:

AnswerHow
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:

Terminal
gjsify build src/cli.entry.ts --app node --outfile dist/cli.mjs \
--define '__MY_VERSION__="1.0.0"' \
--external typedoc,prettier,@inquirer/prompts,inquirer

On 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 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.

ModeUsageWhat it does
auto--globals autoDetect everything from the bundled output.
auto,<extras>--globals auto,domAuto plus explicit extras, for globals the detector cannot see.
explicit list--globals fetch,BufferExactly these, no detection.
none--globals noneInject 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:

Terminal
gjsify build src/gjs/gjs.ts -o dist/gjs.js --globals auto,dom
gjsify build src/index.ts -o dist/index.js --globals auto,matchMedia,FontFace

--verbose shows what auto found:

Terminal
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.

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, Responsefetch/register/fetch
XMLHttpRequest, XMLHttpRequestUploadfetch/register/xhr

Streams

Identifier(s)Register subpath
ReadableStream, ReadableStreamBYOBReader, ReadableStreamBYOBRequest, ReadableByteStreamController, ReadableStreamDefaultController, ReadableStreamDefaultReaderweb-streams/register/readable
WritableStreamweb-streams/register/writable
TransformStreamweb-streams/register/transform
TextEncoderStream, TextDecoderStreamweb-streams/register/text-streams
ByteLengthQueuingStrategy, CountQueuingStrategyweb-streams/register/queuing
CompressionStream, DecompressionStreamcompression-streams/register

Crypto

Identifier(s)Register subpath
cryptowebcrypto/register

Abort, messaging and events

Identifier(s)Register subpath
AbortController, AbortSignalabort-controller/register
MessageChannel, MessagePortmessage-channel/register
Event, EventTargetdom-events/register/event-target
CustomEvent, MessageEvent, ErrorEvent, CloseEvent, ProgressEventdom-events/register/custom-events
UIEvent, MouseEvent, PointerEvent, KeyboardEvent, WheelEvent, FocusEventdom-events/register/ui-events
EventSourceeventsource/register
WebSocketwebsocket/register
DOMExceptiondom-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:

Terminal
gjsify build src/index.ts --app node --outfile dist/index.node.mjs --globals auto,dom

Watch the project, rebuild on change and relaunch the app. All seven templates wire their dev script to it.

Terminal
gjsify dev # watch, rebuild and relaunch on the host runtime
gjsify dev --runtime node # build and launch the `--app node` bundle instead
gjsify dev src/main.ts --watch-dir src
gjsify dev --build-only # rebuild on every change, never launch
Argument / OptionDescription
[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-onlyRebuild 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.

Run a script from package.json, or launch a built bundle.

Terminal
gjsify run start # the `start` script from ./package.json
gjsify run build -w cli # the `build` script in the `cli` workspace
gjsify run dist/index.js # a built bundle
gjsify run ./server.mjs -- --port 8080
Argument / OptionDescription
<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-scriptTreat <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.

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://.

Terminal
gjsify build src/app.ts --app gjs --outfile dist/app.gjs.mjs
gjsify build src/app.ts --app node --outfile dist/app.node.mjs
gjsify run --runtime gjs dist/app.gjs.mjs # gjs -m
gjsify run --runtime node dist/app.node.mjs # node
gjsify run --runtime bun dist/app.node.mjs # bun, same node bundle
gjsify run --runtime deno dist/app.node.mjs # deno run -A --node-modules-dir=manual

If 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:

Terminal
gjs -m dist/index.js

With native prebuilds, export the environment first:

Terminal
eval $(gjsify info --export)
gjs -m dist/index.js

Build and run the package’s src/test.mts aggregator on GJS and Node, then aggregate the results.

Terminal
gjsify test # both runtimes
gjsify test --runtime gjs # one runtime
gjsify test --no-build # reuse existing bundles
gjsify test --rebuild # rebuild even if bundles look fresh
OptionDefaultDescription
--runtime <gjs|node|all>allWhich runtimes to build and run.
--entry <path>gjsify.test.entry, else src/test.mtsTest entry.
--outdir <path>gjsify.test.outdir, else dist/Where test.{gjs,node}.mjs is written.
--rebuildfalseAlways rebuild, even when the outputs look up to date.
--buildtrueBuild before running. --no-build skips it when bundles already exist.
--verbosefalsePrint 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:

JSON
{
"gjsify": {
"test": {
"entry": "src/test.mts",
"outdir": "dist",
"runtimes": ["gjs", "node"]
}
}
}

The entry usually aggregates @gjsify/unit suites:

src/test.mts
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.

Delete build output. A portable rm -rf for your clear scripts, so they work on every host.

Terminal
gjsify clear dist lib tsconfig.tsbuildinfo
gjsify clear "dist/*.mjs" --dry-run
OptionDefaultDescription
[paths..][]Paths to delete, relative to the current package. A missing path is fine, not an error. * and ? work in the last segment.
--dry-runfalsePrint what would be deleted and touch nothing.
-v, --verbosefalsePrint each path as it goes.

Copy files and directories into the build output. A portable mkdir -p plus cp -r.

Terminal
gjsify copy src/style.css dist/
gjsify copy "data/*.ui" data/icons dist/data/
OptionDefaultDescription
[paths..][]One or more sources followed by the destination.
--dry-runfalsePrint what would be copied and touch nothing.
-v, --verbosefalsePrint 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.

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.

KeyWhat it sets
appDefault --app target for this project.
bundlerRolldown options passed through. Most projects only set output.file, output.dir or plugins.
globalsDefault --globals value.
excludeGlobalsIdentifiers to drop from the auto-detected set.
excludeGlob patterns to exclude from entry points and aliases.
consoleShimInject the GJS console shim. Default true, and read by --app gjs builds only.
shebangtrue 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.
aliasesExtra module aliases, the config form of --alias.
loadersExtension to loader kind, for files Rolldown does not classify.
defineFromPackageJsonCompile-time constants read out of package.json.
defineFromEnvCompile-time constants read out of process.env at config-load time.
nodeScriptglobals / excludeGlobals overrides for the ad-hoc bundle gjsify run --node-script builds.
library, typescriptLibrary-mode package.json fields and TypeScript options (reflection: true turns on Deepkit).
main, binThe GJS entry (and named GJS bins) that gjsify dlx and gjsify ship read.
prebuildsDirectory holding native prebuilds.
testDefaults for gjsify test.
exampleDeclared runtimes for gjsify run --runtime and gjsify showcase.
storybookDefaults for gjsify storybook.
browse, devtoolsDefaults for gjsify browse and gjsify debug.
flatpakConfig for the gjsify flatpak commands.
shipConfig for gjsify ship. Metadata falls back to flatpak.

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.

JSON
// 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:

JSON
{
"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:

JSON
{
"gjsify": {
"bundler": {
"plugins": [
{ "name": "@gjsify/rolldown-plugin-solid" },
{ "name": "./build/my-plugin.mjs", "export": "myPlugin", "options": { "verbose": true } }
]
}
}
}
FieldDefaultWhat it does
namerequiredA 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.
exportdefaultWhich 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.

Rolldown does not classify unknown extensions, so without a loader it tries to parse them as JavaScript and fails. Map them yourself:

JSON
{
"gjsify": {
"loaders": {
".glsl": "text",
".ui": "text",
".asm": "text",
".png": "dataurl"
}
}
}
KindOutputUse it for
textexport default "<file contents>"GLSL shaders, GtkBuilder .ui XML, assembly source.
dataurlexport 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:

JSON
{ "gjsify": { "shebang": "${env:GJS_CONSOLE:-/usr/bin/env -S gjs} -m" } }

A leading #! is added if you leave it out.

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.

Terminal
gjsify install # full project install
gjsify install --immutable # CI: install strictly from gjsify-lock.json
gjsify install lodash # add lodash to dependencies
gjsify install -D vitest # add to devDependencies
gjsify install -g @gjsify/cli # global install under ~/.local/share/gjsify/global/
OptionDefaultDescription
[packages..][]Package specs. Omit for a full project install.
-g, --globalfalseInstall into ~/.local/share/gjsify/global/ and symlink bins into ~/.local/bin/.
-D, --save-devfalseSave to devDependencies.
--save-peerfalseSave to peerDependencies.
-O, --save-optionalfalseSave to optionalDependencies.
--immutablefalseInstall strictly from gjsify-lock.json, failing if it is missing or stale. Same idea as yarn --immutable or npm ci.
--refresh-lockfilefalseRe-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>nativenative 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.
--progresstrue on a TTYTTY-aware progress bar for resolve, download and extract. Off under --verbose or --quiet.
--quietfalseSilence the progress bar.
--verbosefalsePer-package install log.
--timeout <ms>1800000Overall wall-clock budget. On timeout, in-flight registry fetches abort and the install exits non-zero. 0 disables it.
--os <name>this hostResolve and install for another OS (darwin, win32, linux). The lockfile stays platform independent either way.
--cpu <arch>this hostResolve and install for another CPU architecture (x64, arm64).
--libc <glibc|musl>probedResolve for another libc family. Only meaningful with --os=linux.
--forcefalseInstall a required dependency even when its os / cpu / libc excludes the target, instead of failing with EBADPLATFORM. Incompatible optional dependencies stay skipped.
--prunetrueAfterwards, 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.

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.

Terminal
gjsify uninstall -g <pkg>
gjsify uninstall -g <pkg> --dry-run
gjsify uninstall -g <pkg1> <pkg2>
OptionDefaultDescription
<packages..>requiredOne or more package names, optionally with a version.
-g, --globalfalseRequired. Only global mode is supported today.
--dry-runfalsePrint what would be removed and touch nothing.
--verbosefalseVerbose logging.

It exits non-zero when nothing matched.

Remove installed packages this host cannot use: the ones an earlier install put there before the platform filter could skip them.

Terminal
gjsify prune -g --dry-run # what would go, and how much it frees
gjsify prune -g # remove it
gjsify prune # the same, for this project's node_modules
gjsify prune -g --os=darwin # what a darwin host could not use

The 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.

OptionDefaultDescription
-g, --globalfalsePrune the user-global prefix instead of this project’s node_modules.
--dry-runfalseReport what would be removed and touch nothing.
--verbosefalseList every package rather than the first few.
--os <name> / --cpu <arch> / --libc <name>this hostDecide 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.

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.

Terminal
gjsify upgrade # interactive: pick what to upgrade
gjsify upgrade --latest # bump everything, major bumps allowed
gjsify upgrade --minor # stay within the current major
gjsify upgrade --patch # patches only
gjsify upgrade --latest --dry-run # print the plan, write nothing
gjsify upgrade --filter '@gjsify,vite' # narrow by substring
gjsify upgrade --check # CI gate for inconsistent ranges
gjsify upgrade --align # fix them, offline
gjsify upgrade --latest --exact --filter @girs # pin at the newest release, no operator
gjsify upgrade --check --exact --filter @girs # CI gate for exactness
gjsify upgrade --align --exact --filter @girs # fix that gate, offline
OptionDefaultDescription
--latestfalseNon-interactive bulk update, major bumps allowed.
--minorfalseNon-interactive, semver-minor and patch only.
--patchfalseNon-interactive, semver-patch only.
--filter <substring>noneMatch against package names, case-insensitive. Repeatable, comma-separated values are split.
-p, --workspace <pattern>allRestrict to some workspaces. Matched against the package name and the directory path. Repeatable.
--exclude-workspace <pattern>noneSkip workspaces, for ones with deliberate dependency drift such as integration tests pinned to a specific upstream. Repeatable.
--alignfalseOffline 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.
--checkfalseCI gate: exit non-zero when any dep is declared inconsistently across workspaces. Offline. --align with the same flags is the fix.
--exactfalsePin 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-runfalsePrint the plan without writing.
-y, --yesfalseIn interactive mode, select everything without prompting.
--cwd <path>process.cwd()Project directory. From inside a workspace it walks up to the monorepo root.
--verbosefalsePrint 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.

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.

Terminal
gjsify dlx @gjsify/example-dom-canvas2d-fireworks
gjsify dlx @scope/pkg@1.2.3 # version-pinned
gjsify dlx @scope/pkg my-bin -- --opt value # pick a bin, forward args
gjsify dlx ./local/path # local dir, no install, no cache
OptionDefaultDescription
<spec>requiredname, name@version, @scope/name@spec, or a local path.
[binOrArg]noneA 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.
--reinstallfalseBypass the cache for this run. Same as --cache-max-age=0.
--frozenfalseUse the project-local gjsify-lock.json verbatim, failing if it is missing or stale. No resolver pass.
--registry <url>from .npmrcRegistry override.
--verbosefalseVerbose 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.

dlx reads a top-level gjsify object from the package’s package.json:

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.

Refresh the installed @gjsify/cli to the latest release, or to a pinned dist-tag.

Terminal
gjsify self-update # latest
gjsify self-update --check # compare only, exit 1 if outdated
gjsify self-update --force # reinstall the same version
gjsify self-update --tag next # a specific dist-tag or version
OptionDefaultDescription
--checkfalseCompare current against target without installing. Exit 0 if up to date, 1 if outdated.
--forcefalseReinstall even when the target already matches.
--tag <tag>latestnpm dist-tag or a pinned version.
--skip-depsfalseUpdate 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.
--prunetrueAfterwards, 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.

Scaffold an install.mjs for your own GJS-runnable package, so your users get the same curl … | gjs -m - install story GJSify has.

Terminal
cd my-gjs-app
gjsify 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
OptionDefaultDescription
[target]package.json#nameThe npm package the installer installs.
--bin-name <name>first key of gjsify.bin or binBin name the installer produces.
--bootstrap-url <url>GJSify’s releases/latest/download/cli.gjs.mjsWhere the bootstrap bundle comes from.
--output <file>install.mjsWhere to write it.
--forcefalseOverwrite 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.

Run a script across all, or some, workspaces. A drop-in for yarn workspaces foreach.

Terminal
gjsify foreach build # `build` everywhere
gjsify foreach -p -t build # parallel, topological order
gjsify foreach --no-private build # skip private:true workspaces
gjsify foreach --include '@gjsify/web-*' test # glob filter
gjsify foreach --exec -- npm publish --tag latest
gjsify foreach --exec -- gjsify publish --verify-timeout 5 --tag latest
OptionDefaultDescription
[script]noneScript to run. With --exec, the command to run.
[args..][]Extra arguments forwarded to each invocation.
-A, --allfalseInclude workspaces marked private: true.
-p, --parallelfalseRun in parallel, capped by --jobs.
-t, --topologicalfalseWait for each workspace’s production dependencies to finish first.
--topological-devfalseLike --topological, but also respects devDependencies. Often cyclic, so use it sparingly.
--include <glob>allInclude workspaces matching the glob. Repeatable. A pattern that matches nothing is a hard error.
--exclude <glob>noneExclude workspaces matching the glob. Repeatable.
-d, --with-dependenciesfalseAlso 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.
--privatetrueInclude private workspaces. --no-private skips them.
-j, --jobs <n>cpu countMax concurrent workspaces in --parallel mode.
--execfalseTreat <script> [args..] as an arbitrary command. Use -- <cmd> so flags reach the command. Everything after -- is forwarded verbatim, numbers included.
--cachedGJSIFY_BUILD_CACHE=1Content-hash build cache. See Build cache. Script mode only.
--shard <index>/<total>noneRun 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, --verbosefalseEcho every spawned command.

Run one script in one workspace. A drop-in for yarn workspace <name> run <script>.

Terminal
gjsify workspace @gjsify/cli build
gjsify workspace @gjsify/fetch test:gjs
gjsify workspace @gjsify/website build -d # build its deps first
ArgumentDescription
<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.
OptionDefaultDescription
-d, -t, --with-dependencies, --topologicalfalseBuild the workspace’s transitive workspace dependencies in topological order first. Deps without the script are skipped.
--include-devfalseWith -d, also walk devDependencies.
--continue-on-errorfalseWith -d, keep going after a dependency fails.
--cachedGJSIFY_BUILD_CACHE=1Content-hash build cache. See Build cache. Also applies to the deps run by -d.
-v, --verbosefalseEcho every spawned command.

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.

Terminal
gjsify foreach build -tp --cached # rebuild only what changed
GJSIFY_BUILD_CACHE=1 gjsify run build # opt a whole script chain in

The 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.

Print the workspaces a change touches, so CI can test those and skip the rest.

Terminal
gjsify affected # text list
gjsify affected --format globs # feed into --include
gjsify affected --base "$BASE_SHA" --format github-actions
gjsify foreach test --include $(gjsify affected --format globs)
OptionDefaultDescription
--base <ref>origin/mainDiff base, resolved with git rev-parse. On a pull request, use the base SHA.
--head <ref>HEADDiff head.
--format <shape>texttext, json, globs or github-actions.
--changed-from-stdinfalseSkip git diff and read a newline-separated list of repo-relative paths from stdin.
--cwd <path>discoveredWorkspace root.

The output is the seed workspaces plus everything that transitively depends on them.

Run TypeScript type checks across the workspace. The peer of format, lint and fix.

Terminal
gjsify check # workspace-wide, parallel
gjsify check --include '@gjsify/process' # one package
gjsify check --no-parallel --verbose # sequential, full output
OptionDefaultDescription
--include <glob>allOnly run in workspaces matching these globs. Repeatable.
--exclude <glob>noneSkip workspaces matching these globs. Repeatable. @girs/* is always excluded.
-p, --paralleltrueRun checks in parallel. --no-parallel for sequential.
-j, --jobs <n>os.cpus().lengthMax workers when parallel.
--verbosefalseLog 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.

Run the TypeScript compiler, with every argument passed straight through. Same job as npx tsc.

Terminal
gjsify tsc --noEmit
gjsify tsc -p tsconfig.build.json

Two 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.

Format JS and TS through oxfmt.

Terminal
gjsify format --init # write recommended .oxlintrc.json + .oxfmtrc.json
gjsify format src/ # format in place, the default
gjsify format --check src/ # CI: exit non-zero on drift, write nothing
gjsify format --no-write src/ # report drift locally without writing
OptionDefaultDescription
[paths..].Files or directories to format.
--writetrueApply changes in place. --no-write reports drift instead.
--checkfalseCI mode: report drift and stats, exit non-zero, write nothing.
--config-path <path>nearest onePath to an .oxfmtrc.json. By default it walks up from the cwd.
--initfalseWrite recommended .oxlintrc.json and .oxfmtrc.json into the cwd, skipping existing files unless --force.
--forcefalseWith --init, overwrite the existing config files.
--verbosefalseEcho 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.

.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.

Run oxlint diagnostics.

Terminal
gjsify lint # everything
gjsify lint src/ # specific paths
gjsify lint --fix # apply safe fixes
OptionDefaultDescription
[paths..].Files or directories to lint.
--fixfalseApply safe lint fixes in place.
--config-path <path>nearest one.oxlintrc.json override.
--verbosefalseEcho 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.

oxfmt --write followed by oxlint --fix.

Terminal
gjsify fix # format, then apply safe lint fixes
gjsify fix --no-write # report only
OptionDefaultDescription
[paths..].Files or directories to process.
--writetrueApply fixes. --no-write reports only.
--config-path <path>nearest one.oxlintrc.json / .oxfmtrc.json override.
--verbosefalseEcho the resolved oxc launchers and args.

Not to be confused with gjsify check (TypeScript) or gjsify system-check (system libraries).

Regenerate index.ts barrel files. A drop-in for barrelsby.

Terminal
gjsify barrels src/widgets src/utils
gjsify barrels src --check # CI: fail when a barrel is stale
OptionDefaultDescription
[paths..][]Directories to regenerate.
--ext <js|ts|none>noneExtension on the import specifiers. none is bundler-mode resolution.
-b, --base-dir <dir>cwdResolve paths against this directory.
--exclude <regex>\.test\., \.spec\., \.test-data\.File names to skip. Repeatable.
--header <text>noneHeader comment prepended to every generated file.
--semicolonfalseEmit a trailing ; on each export line.
--single-quotestrueUse ' for import specifiers. --no-single-quotes for ".
--checkfalseReport drift without writing, exit non-zero if any barrel is stale.
--verbosefalseLog each file scanned and written.

Verify that the system libraries a GJSify project needs are installed.

Terminal
gjsify system-check
gjsify system-check --json
OptionDefaultDescription
--jsonfalseEmit 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 dependencyNeeded 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
Terminal
gjsify system-check --json
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"]
}
]
}

List the native GJSify packages in node_modules and print the environment gjs needs to load their prebuilds.

Terminal
gjsify info dist/index.js
eval $(gjsify info --export)
Argument / OptionDescription
[file]Bundle path to use in the generated example command.
--exportEmit 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.

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.

Terminal
gjsify gresource data/org.example.App.data.gresource.xml \
--sourcedir data \
--target dist/org.example.App.data.gresource
OptionDefaultDescription
<xml>requiredPath to the .gresource.xml descriptor.
--sourcedir <dir>the descriptor’s directoryWhere the referenced resource files live.
-t, --target <file><xml> without .xml, next to itOutput .gresource file.
--verbosefalsePrint the underlying glib-compile-resources call.

Needs glib-compile-resources (glib2-devel on Fedora, libglib2.0-dev-bin on Debian and Ubuntu).

Compile GSettings schemas (*.gschema.xml) into a binary gschemas.compiled. It wraps glib-compile-schemas.

Terminal
gjsify gsettings data/schemas
gjsify gsettings data/schemas --targetdir dist/schemas
OptionDefaultDescription
<schemadir>requiredDirectory holding the *.gschema.xml files.
-t, --targetdir <dir><schemadir>Where to write gschemas.compiled.
--stricttrueAbort on any schema warning. --no-strict to relax.
--verbosefalsePrint the underlying glib-compile-schemas call.

Needs glib-compile-schemas (glib2-devel on Fedora, libglib2.0-dev-bin on Debian and Ubuntu).

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.

Terminal
# Runtime .mo locale tree
gjsify gettext translations dist/locale --domain org.example.App
# Merge every catalogue into an AppStream template
gjsify gettext translations dist/metainfo \
--domain org.example.App \
--format xml \
--template data/metainfo/org.example.App.metainfo.xml.in
# …or into a desktop entry
gjsify gettext translations dist/applications \
--domain org.example.App \
--format desktop \
--template data/org.example.App.desktop.in
OptionDefaultDescription
<poDir>requiredDirectory holding <lang>.po files.
<outDir>requiredOutput directory. A locale tree for --format mo, a plain directory otherwise.
--domain <id>requiredText domain or application id.
--format <kind>momo, xml or desktop.
--template <path>noneRequired 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-commentstrueFor --format xml, strip XML comments from the output.
--verbosefalsePrint 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.

List or run the curated showcase applications.

Terminal
gjsify showcase # list them
gjsify showcase three-geometry-teapot # run one
gjsify showcase --json # machine-readable list
OptionDefaultDescription
[name]noneShowcase to run. Omit to list.
--listfalseForce list mode.
--jsonfalseOutput JSON. List mode only.
--runtime <gjs|node|bun|deno>gjs when gjs is installed, else the host runtimeWhich 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.

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.

Terminal
gjsify showcase express-webserver # gjs
gjsify showcase express-webserver --runtime node # the --app node bundle, on Node.js
gjsify showcase express-webserver --runtime bun # same bundle, on Bun
gjsify showcase express-webserver --runtime deno # same bundle, on Deno

An example or showcase can declare which runtimes it supports, so --runtime validates the request up front:

package.json
"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.

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.

Terminal
gjsify storybook # discover src/**/*.story.ts and launch
gjsify storybook --stories packages # scan somewhere else
gjsify storybook --watch # rebuild and relaunch on change
OptionDefaultDescription
--stories <dir>src, or gjsify.storybook.storiesDirectory scanned recursively for *.story.ts.
--app-id <id>gjsify.storybook.applicationId, else derived from the package nameGApplication id.
--title <text>noneWindow title.
--globals <value>autoValue for gjsify build --globals. Use auto,dom for canvas or DOM stories.
--runtime <gjs|node|bun|deno>the host runtimeRuntime 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-storybookOutput bundle path.
--watchfalseRebuild and relaunch when a story file changes.
--build-onlyfalseBuild 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.

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.

Terminal
# In .mcp.json:
# { "mcpServers": { "my-app": { "command": "gjsify", "args": ["debug", "--bus-name", "org.example.App"] } } }
gjsify debug --bus-name org.example.App # generic profile
gjsify debug --profile storybook # storybook tools
gjsify debug --build-only --out dist/bridge.gjs.mjs # build once, point .mcp.json at the bundle
OptionDefaultDescription
--bus-name <name>gjsify.devtools.busNameBase, else the storybook or browser app idThe app’s D-Bus base name.
--address <addr>GJSIFY_DEVTOOLS_ADDRESS, then the address file the app publishes, then the session busPeer 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>autogeneric, 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>autoValue for gjsify build --globals.
--out <path>node_modules/.cache/gjsify-debugOutput bundle path.
--build-onlyfalseBuild 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.

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.

Terminal
gjsify browse # open page:welcome
gjsify browse https://gnome.org # open a URL
gjsify browse https://localhost:8080 --devtools # plus the MCP control plane
gjsify browse https://localhost:8080 --screenshot shot.png
OptionDefaultDescription
[url]page:welcomeInitial URL: a page:* built-in page or an https:// address.
--app-id <id>gjsify.browse.applicationId, else derived from the package nameGApplication id.
--title <text>noneWindow title.
--globals <value>auto,domValue for gjsify build --globals. WebKit and the iframe need DOM globals.
--out <path>node_modules/.cache/gjsify-browseOutput bundle path.
--devtoolsfalseEnable the MCP devtools control plane (sets GJSIFY_DEVTOOLS=1).
--inspector-port <n>noneEnable WebKit’s remote inspector protocol on this port plus the Cdp* methods. Implies --devtools.
--screenshot <path>noneOne-shot: load the URL, capture a WebKit screenshot to this path, exit. Handy in CI.
--build-onlyfalseBuild 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.

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.

Terminal
gjsify ship # this host's layout; on Linux, a .deb and an .rpm
gjsify ship linux # the same two, from a Mac or from Windows
gjsify 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 it
gjsify ship darwin --target macos-app-dmg # a .dmg, on macOS only
gjsify ship windows # a program directory and its zip
gjsify ship windows --target msi # a Windows Installer package (needs wixl or WiX v3)
gjsify ship --skip-build # package what is already built
gjsify ship --stage # produce the payload and stop
gjsify ship --from-stage ./ship/stage # pack a payload assembled elsewhere
gjsify 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.

OptionDefaultDescription
--target <fmt..>gjsify.ship.targets, else every format wrapping the target layout that needs no extra toolingFormats 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 shipOutput root, relative to the project.
--stagefalseProduce the staged payload and stop, packing nothing.
--from-stage <dir>nonePack 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>noneUsed 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-buildfalseDo not run the project’s build script first.
--arch <arch>this hostTarget architecture, in process.arch spelling. Labels the artifact and picks the runtime packages; cross-builds nothing.
--sign <identity>gjsify.ship.sign.<os>.identitySign 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>noneSubmit the signed artifact with xcrun notarytool submit --keychain-profile <p> --wait. Needs --sign, and runs on darwin only.
--verbosefalsePrint every staged file, the GI namespaces the bundle imports, and every tool invocation.

What lands under ship/:

Code
ship/stage/ the payload for one layout
ship/stage/.gjsify-ship-stage.json the closure a packing host needs when it is not this one
ship/overlay/<format>/ per-format additions, such as the licence where each format wants it
ship/flatpak/ --target flatpak only: the generated manifest, the build dir, the export repo
ship/schemas/ off Linux only: where gschemas.compiled is built before it is staged
ship/out/ the artifacts

ship/out/ is packed by reading ship/stage/ back, so what you inspect is what ships.

FormatLayoutIn the default setPacks onNeeds installed
deblinuxyesany hostnothing
rpmlinuxyesany hostnothing
flatpaklinuxnolinuxflatpak-builder, flatpak
macos-appdarwinyesany hostglib-compile-schemas
macos-app-zipdarwinyesany hostglib-compile-schemas
macos-app-dmgdarwinnodarwinhdiutil, part of macOS
windows-dirwin32yesany hostglib-compile-schemas
windows-dir-zipwin32yesany hostglib-compile-schemas
msiwin32nolinux or win32wixl 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:

Terminal
gjsify ship darwin --stage --target macos-app-dmg # here, any OS, offline
gjsify ship --from-stage ./ship/stage \
--target macos-app-dmg # there, on a Mac

Name 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.

  • Runtime dependencies come from the gi:// imports in your built bundle, mapped to the package that ships each typelib (gir1.2-gtk-4.0 on Debian, gtk4 on 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 with gjsify.ship.typelibPackages.
  • Architecture is all or noarch unless the payload contains a .so or .node. A pure-JS app really does install everywhere, and claiming amd64 would make apt refuse it on a machine it runs on. Where the payload does carry a native image, ship reads its ELF e_machine or Mach-O cputype back 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 .app and inside a Windows program directory. It execs the interpreter your bundle was built for, and ship refuses a package whose launcher and dependency disagree.
  • Localised metadata is folded in from gjsify.ship.localeDir. The compiled .mo catalogues become Name[xx]= in the .desktop entry and xml:lang in the AppStream component.
  • Metadata falls back to gjsify.flatpak, so a project that already ships a Flatpak usually needs no gjsify.ship block at all.

Packing the same build twice gives byte-identical files. How It Works explains how.

JSON
"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" }
}
}
}
KeyDefaultWhat it does
appIdgjsify.flatpak.appId, else package.json#nameReverse-DNS id. Names the desktop entry, the AppStream component, the installed icon, CFBundleIdentifier and the MSI upgrade code, so it cannot be guessed.
binaryNamepackage name, scope stripped and lowercasedPackage name and the launcher’s filename.
nametitle-cased binaryNameDisplay name. The .desktop Name=, CFBundleName, the <App>.app directory and the Windows program directory.
versionpackage.json#versionUpstream version, normalised. An .msi needs a plain major.minor.build and refuses a prerelease.
release1Package revision within one upstream version.
maintainerpackage.json#authorMaintainer:, Packager: and the MSI’s Publisher, as Name <email>. dpkg refuses a package without one.
targetsevery format wrapping the target layout that needs no extra toolingFormats built when --target is not given.
outDirshipOutput root.
bundlegjsify.main, else package.json#mainThe built bundle the launcher executes. Its whole directory is staged.
icondata/icons or data/icons/hicolorIcon file or directory. Sizes are read from the path or the filename.
schemasdataA *.gschema.xml file or a directory of them.
licenseFilefirst of LICENSE, LICENSE.md, LICENSE.txt, COPYINGLicence file to ship.
section / groupderived from categoriesdeb Section: and rpm Group:.
mimeTypes[]shared-mime-info types the app opens. Rendered into share/mime/packages/ and into the desktop entry’s MimeType=.
minGjsVersion1.86Minimum GJS the emitted dependency asks for.
minNodeVersion24Minimum 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.
localeDirnoneDirectory 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.
fontsnoneFont 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.
flatpakderivedThe Flatpak half: runtime (gnome/freedesktop), runtimeVersion, branch (stable), sdkExtensions, appendPath, finishArgs, cleanup.
signnoneDefault 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/.rpm get <dir>/usr/share/fonts</dir>, every other prefix <dir prefix="xdg">fonts</dir>, which fontconfig also expands over XDG_DATA_DIRS, the variable the launcher already exports. Nothing to call. (That expansion is not in fonts-conf(5). It is measured across eight independent fontconfig builds, 2.14.1 through 2.18.3.)
  • macOS. ATSApplicationFontsPath in the Info.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 .app yet.
  • Windows. Nothing declarative exists. pangocairo selects the win32 backend and populates from DirectWrite alone, so a fontconfig directory is inert. Pointing FONTCONFIG_FILE at 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:

TypeScript
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.

--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.

darwinwin32linux
toolcodesignsigntoolnone
what it signsevery Mach-O image in the payloadevery PE image in the payloadnothing
runs onmacOSWindowsnone
project defaultgjsify.ship.sign.darwin.identitygjsify.ship.sign.win32.identityrefused

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.

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.

The Flatpak toolchain, for shipping GJS apps and CLIs to Flathub.

SubcommandWhat it does
flatpak initScaffold the Flathub asset set: manifest JSON, MetaInfo XML, .desktop (apps only), flathub.json.
flatpak checkRun appstreamcli validate --strict and flatpak-builder-lint locally.
flatpak buildWrap flatpak-builder, with --force-clean, --sandbox and --delete-build-dirs on.
flatpak depsWrap flatpak-node-generator to produce the offline npm cache.
flatpak sourcesGenerate an offline sources array from any lockfile.
flatpak ciScaffold .github/workflows/flatpak.yml.
flatpak sync-flathubPoint the Flathub tracking-repo manifest at a new tag and commit.
flatpak diffCompare local git state against that manifest and report drift.
flatpak releaseChain 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.

Generate the Flathub asset bundle from package.json#gjsify.flatpak.

Terminal
gjsify flatpak init # GTK/Adwaita desktop app
gjsify flatpak init --kind cli # CLI tool: no .desktop, console-application MetaInfo
OptionDefaultDescription
--app-id <id>gjsify.flatpak.appId, else package.json#nameReverse-DNS app id.
--kind <app|cli>appcli emits console-application MetaInfo and a flathub.json with skip-icons-check: true, and no .desktop.
--cli-onlyfalseDeprecated alias for --kind cli.
--runtime <gnome|freedesktop>gnomeRuntime family. Both kinds default to GNOME, because GJS bundles need GLib and GIO at runtime.
--runtime-version <v>50 for gnome, 24.08 for freedesktopRuntime version.
--manifest <path><app-id>.jsonManifest output path.
--metainfo <path>data/<app-id>.metainfo.xml.inMetaInfo output path.
--desktop <path>data/<app-id>.desktop.in.desktop output path. App kind only.
--flathub-json <path>flathub.jsonflathub.json output path.
--command <name>gjsify.flatpak.command, else the app idBinary name in /app/bin.
--sdk-extension <ext>noneExtra SDK extension, for example org.freedesktop.Sdk.Extension.node24. Repeatable.
--finish-arg <arg>defaultsExtra finish-arg. Repeatable.
--formattrueRun oxfmt --write on generated JS/TS when oxfmt is present. The JSON, XML and .desktop files are not reformatted. --no-format to skip.
--forcefalseOverwrite existing outputs. By default they are skipped and logged.
--verbosefalsePrint 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 forNotes
appIdbothReverse-DNS.
kindboth"app" (default) or "cli".
nameoptionalDisplay 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.namemetainfoAppStream OARS 1.1 and later require <developer id="…">.
developer.emailoptionalEmits <email> inside <developer>.
developer.nameTranslatableoptionalDefault false, which emits translate="no". Set true for descriptive names.
summarymetainfo80 characters or fewer, no trailing period.
summaryTranslatorHintoptionalEmits a <!-- TRANSLATORS: ... --> comment before <summary>.
descriptionmetainfoA string (blank lines split it into <p>), or a DescriptionBlock[] of {p, translatorHint?} paragraphs and {ul:[...], translatorHint?} lists.
license.metadatametainfoSPDX id for the metadata itself. Defaults to CC0-1.0.
license.projectmetainfoSPDX id of the software.
homepageUrlmetainfo<url type="homepage">.
bugtrackerUrl / vcsBrowserUrl / donationUrl / translateUrloptionalExtra <url> entries. translateUrl is your Weblate or Crowdin URL.
iconRemoteoptional<icon type="remote">, useful for a Flathub thumbnail before a local SVG ships.
categoriesmetainfo (app), desktopFreedesktop menu categories.
keywordsoptionalSearch keywords.
releasesmetainfo[{ version, date, description? }]. Flathub needs at least one.
screenshotsoptional (app)[{ url, caption?, captionTranslatorHint?, environment?, type? }].
brandingoptional (app){ accentLight, accentDark } hex colours.
iconoptional (app)Path to a scalable SVG. You get a warning if it is missing.
contentRatingoptionalAn OARS keyword string (default oars-1.1), or { type?, attributes? } with OARS keys mapped to none, mild, moderate or intense.
kudosoptionalFlathub quality markers such as ModernToolkit, HiDpiIcon, TouchscreenSupport, UserDocs.
provides.binariesoptionalDefaults to [command].
provides.mimetypes / provides.dbusoptionalExtra <mediatype> and <dbus> entries.
supports.controlsoptional["keyboard", "pointing", "touch", "gamepad", "tablet", "console", "vision"].
supports.internetoptional"always", "offline-only" or "first-run".
requires.displayLengthMin / recommends.displayLengthMinoptionalMinimum display length in pixels. Phone portrait is about 360, tablet about 480.
requires.controls / recommends.controlsoptionalHard and soft control requirements.
runtime / runtimeVersionoptionalRuntime family and version.
sdkExtensions / appendPathoptionalExtra SDK extensions and PATH components inside the build sandbox.
commandoptionalThe binary in /app/bin. Defaults to the app id.
finishArgsoptionalSandbox capabilities. Defaults for kind: "app" are --device=dri, --share=ipc, --socket=fallback-x11, --socket=wayland; kind: "cli" gets none.
extraModulesoptionalExtra modules prepended before the generated Meson module.
modulesoptionalReplaces 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.
flathubRepooptionalOverrides 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.

Run the Flathub linters locally, the same ones Flathub’s PR CI runs.

Terminal
gjsify flatpak check # auto-detect the manifest
gjsify flatpak check eu.jumplink.Learn6502.json # explicit manifest
gjsify flatpak check --repo repo # also lint a built repo
OptionDefaultDescription
[manifest]autoManifest path. Defaults to <app-id>.json, or the single .json that looks like a manifest.
--metainfo <path>data/<app-id>.metainfo.xml.inMetaInfo to validate. Skipped when missing.
--repo <path>noneAlso run flatpak-builder-lint repo <path>, after a build.
--appstreamtrueRun appstreamcli validate --strict. --no-appstream skips it.
--builder-linttrueRun flatpak-builder-lint manifest. --no-builder-lint skips it.
--verbosefalseStream 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.

Build the Flatpak with flatpak-builder, then install it, export it to a repo, bundle it or tar the build directory up.

Terminal
gjsify flatpak build
gjsify flatpak build --install
gjsify flatpak build --repo repo --bundle my-app.flatpak
OptionDefaultDescription
[manifest]first manifest-shaped .json in cwdManifest path.
--build-dir <dir>flatpak-buildflatpak-builder working directory.
--installfalseAfter the build, run flatpak-builder --user --install.
--repo <dir>noneExport into this OSTree repo.
--bundle <path>noneAfter a --repo export, build a single-file bundle here.
--tarball <path>noneCreate a tarball of the build directory.
--force-cleantruePass --force-clean to flatpak-builder.
--sandboxtruePass --sandbox.
--delete-build-dirstruePass --delete-build-dirs.
--install-deps-from <remote>nonePass --install-deps-from, for example flathub.
--verbosefalsePrint the underlying invocations.

Generate the Flatpak offline npm cache from a yarn.lock or package-lock.json, wrapping flatpak-node-generator.

OptionDefaultDescription
--lockfile <path>yarn.lock or package-lock.json in cwdLockfile to read.
--type <yarn|npm>from the filenameLockfile type.
--out <path>flatpak-node-sources.jsonOutput sources file.
--xdg-layouttruePass --xdg-layout, recommended for Yarn Berry and PnP.
--electron-node-headersfalsePass --electron-node-headers.
--verbosefalsePrint the underlying invocation.

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.

Terminal
gjsify flatpak sources
gjsify flatpak sources --print-module
OptionDefaultDescription
--lockfile <path>first of gjsify-lock.json, package-lock.json, yarn.lock, pnpm-lock.yaml in cwdLockfile to read.
--type <gjsify|npm|yarn|pnpm>from the filenameLockfile format.
--out <path>gjsify-sources.jsonOutput sources file.
--cache-root <dir>flatpak-gjsify-cacheDirectory 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-modulefalseAlso print a ready-to-paste manifest module snippet to stderr.

Scaffold .github/workflows/flatpak.yml around the flathub-infra container and the flatpak-builder action.

OptionDefaultDescription
--manifest <path><app-id>.jsonManifest the workflow points at.
--bundle <name><app-id>.flatpakBundle filename the action produces.
--runtime-image <image>derived from gjsify.flatpak.runtime and runtimeVersionContainer image override, for example ghcr.io/flathub-infra/flatpak-github-actions:gnome-50.
--branches <name..>mainBranches the workflow runs on push for.
--out <path>.github/workflows/flatpak.ymlOutput path.
--cache-key <key>flatpak-builder-${{ github.sha }}Override the action cache key.
--forcefalseOverwrite an existing workflow file.
--verbosefalsePrint resolved fields.

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.

Terminal
gjsify flatpak sync-flathub # latest local tag
gjsify flatpak sync-flathub --version v0.6.6 --commit 1a2b3c4d
gjsify flatpak sync-flathub --version v0.6.6 --dry-run # show the plan
gjsify flatpak sync-flathub --version v0.6.6 --no-pr # clone, commit, push, no PR
OptionDefaultDescription
--version <tag>git describe --tags --abbrev=0Git tag to sync to.
--app-id <id>gjsify.flatpak.appIdUsed 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 sourceWhich modules[0].sources[] entry to update.
--prtrueOpen a PR with gh pr create after commit and push. --no-pr stops after the push.
--dry-runfalseReport the resolution, branch and commit, touching no files.
--verbosefalseEcho 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.

Compare local git state against the Flathub tracking-repo manifest before you publish.

Terminal
gjsify flatpak diff
gjsify flatpak diff --version v0.6.6
gjsify flatpak diff --against ./flathub/<app-id>.json # offline
gjsify flatpak diff --detail
OptionDefaultDescription
--version <tag>git describe --tags --abbrev=0Local version to compare.
--app-id <id>gjsify.flatpak.appIdReverse-DNS app id.
--flathub-repo <owner/name>gjsify.flatpak.flathubRepo, else flathub/<app-id>Tracking repo to fetch from.
--against <path>noneRead a local manifest instead of fetching.
--detailfalseAlso print the full Flathub source entry.
--source-index <n>first type: git sourceWhich modules[0].sources[] entry to inspect.
--verbosefalseEcho 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.

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.

Terminal
gjsify flatpak release v0.6.6
gjsify flatpak release v0.6.6 --dry-run # show the plan
gjsify flatpak release v0.6.6 --skip-tag # the tag already exists
OptionDefaultDescription
<version>requiredRelease tag, for example v0.6.6.
--skip-initfalseSkip the flatpak init --force regeneration.
--skip-checkfalseSkip the linter step.
--skip-tagfalseSkip git tag and the push.
--push-tagtruePush the tag after creating it.
--flathub-repo <owner/name>noneOverride forwarded to sync-flathub.
--dry-runfalsePrint each step without running any of them.
--verbosefalseEcho 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.

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.

Terminal
gjsify pack # the current workspace
gjsify pack packages/infra/cli # a specific one
gjsify pack --pack-destination dist # write it somewhere else
gjsify pack --json # npm-pack-compatible metadata
OptionDefaultDescription
[path]cwdWorkspace to pack.
--pack-destination <dir>the workspaceWhere to write the tarball.
--jsonfalseEmit pack metadata as JSON on stdout.
--dry-runfalseCompute everything, write no .tgz.
--ignore-scriptsfalseSkip 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.

Pack and upload a workspace. A drop-in for npm publish, using gjsify pack, so the workspace:^ rewrite happens for you.

Terminal
gjsify publish # the current workspace
gjsify publish packages/infra/cli --tag latest
gjsify publish --access public # first publish of a scoped package
gjsify publish --access public --otp 123456 # with a 2FA code
gjsify publish --tolerate-republish # treat "already published" as success
gjsify publish --dry-run # pack only
OptionDefaultDescription
[path]cwdWorkspace to publish.
--tag <tag>latestDist-tag.
--access <kind>nonepublic or restricted. Required for the first publish of a scoped package.
--otp <code>nonenpm 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-republishfalseTreat “version already published” as success, covering both the classic 409 and the OIDC-path 403.
--tolerate-untrusted-newfalseExit 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.
--trustedautoAuthenticate 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-trustedfalseDo 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>600Seconds 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-deferfalseReport an unverified publish and exit 0 instead of 1. Only for a caller that re-checks the same set afterwards.
--provenancefalseRecorded in the payload. No signing happens yet.
--dry-runfalsePack only, do not upload.
--jsonfalseEmit 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:

Terminal
gjsify foreach --no-private --exec -- gjsify publish --tag latest --access public

Print the npm username behind your current token, with a clear message when the token is dead, missing, or the registry is unreachable.

Terminal
gjsify whoami
gjsify whoami --json
OptionDefaultDescription
--registry <url>scope-aware .npmrc lookup, else https://registry.npmjs.org/Registry to probe.
--jsonfalseEmit {username, registry}, or {error, registry}, as one line.

Log in to an npm registry and write the token to ~/.npmrc.

Terminal
gjsify login
gjsify login --scope @my-org
gjsify login --username me --otp 123456
OptionDefaultDescription
--registry <url>https://registry.npmjs.org/, or the scope’s registryRegistry to log in to.
--scope <name>noneAssociate the login with a scope, resolving that scope’s registry from .npmrc.
--username <name>promptedUsername.
--otp <code>prompted on demand2FA code.
--jsonfalseEmit {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.

Revoke the token on the registry (best effort) and remove it from ~/.npmrc.

Terminal
gjsify logout
gjsify logout --scope @my-org
OptionDefaultDescription
--registry <url>https://registry.npmjs.org/, or the scope’s registryRegistry to log out of.
--scope <name>noneLog out of a scope’s registry, resolved from .npmrc.
--jsonfalseEmit {registry, revoked, removed}.

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.

Terminal
gjsify trust # every publishable workspace
gjsify trust '@gjsify/web-*' # a subset
gjsify trust --list # report state, change nothing
gjsify trust --dry-run
OptionDefaultDescription
[packages..]all publishablePackage-name globs limiting the sweep.
--repository <owner/repo>inferred from originGitHub repo the Trusted Publisher is scoped to.
--workflow <file>release.ymlWorkflow allowed to publish. Basename only.
--environment <env>noneGitHub Actions environment the workflow must run in.
--registry <url>scope-aware .npmrc lookupRegistry override.
--otp <code>prompted on demand2FA code, sent as npm-otp.
--dry-runfalseList what would be configured.
--forcefalseRe-POST the config even for already-trusted packages.
--listfalseOnly report each package’s current trust state.
--privatefalseInclude private workspaces. They are not publishable, so this is off by default.

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.

Terminal
gjsify onboard # publish and trust whatever is missing
gjsify onboard --dry-run # report the plan
gjsify onboard --packages '*' # a monorepo with no root package.json
gjsify onboard --exclude '@acme/*' # filter the set by package name
gjsify onboard --otp 123456 # seed the shared 2FA code
gjsify onboard --json # machine-readable summary as the last stdout line
gjsify onboard --yes # non-interactive
OptionDefaultDescription
--packages <glob>root manifest workspacesDirectory 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>allInclude packages by name. Repeatable.
--exclude <glob>noneExclude packages by name. Repeatable.
--repository <owner/repo>inferred from originGitHub repo the Trusted Publisher is scoped to.
--workflow <file>release.ymlWorkflow allowed to publish via OIDC. Basename only.
--environment <env>noneGitHub Actions environment the workflow must run in.
--access <a>publicnpm access for a package this sweep publishes for the first time. An already-published package keeps the access it has.
--build / --no-build--buildRun 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 lookupRegistry override.
--otp <code>prompted once on demandThe initial shared 2FA code.
--concurrency <n>4How 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, --verbosefalseList every package in the plan, not just the rows that need work. The counts always cover all of them.
--dry-runfalseReport the plan without changing anything.
--jsonfalseEmit a summary object as the final stdout line.
--yesfalseNever 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.