Using CLI Plugin
The build toolchain for a sigx-lynx app: a dev server, native prebuild, autolinking and device runners — driven from the command line, configured from a single TypeScript file.
Basic usage
@sigx/lynx-cli is mostly a command-line tool, not a library you import. It registers as a plugin for @sigx/cli (auto-discovered via the package's sigx-cli.plugin field), which adds the sigx subcommands to your project.
Install both packages into a Lynx app:
pnpm add -D @sigx/cli @sigx/lynx-cli@sigx/lynx-cli requires @sigx/cli ≥ 0.9 — the dev dashboard is built on that release's pane-aware shell (a tab is told the pane it must fill, and ShellHandle exposes the active tab).
A project is detected as a Lynx project when signalx.config.ts (or .js/.mjs) or lynx.config.ts exists at its root. From there, the day-to-day commands are:
sigx dev # start the dev server, pick a device
sigx prebuild # render native ios/ and android/ projects + autolink
sigx run:android # build, install and launch on Android
sigx run:ios # build, install and launch on iOS (macOS only)
sigx run:web # build, serve and live-reload in the browser
sigx build:web # deployable static export to dist/web/
sigx build # production rspeedy bundle
sigx doctor # environment health report
The JavaScript API (defineLynxConfig, runPrebuild, resolveConfig, the device-detect helpers, etc.) exists for tooling that needs to drive the same machinery programmatically, but most apps only ever touch defineLynxConfig.
Configuring your app
Everything about the native shell — name, icons, splash, permissions, deep-link scheme, native modules — lives in signalx.config.ts. Wrap the object in defineLynxConfig for full typing and editor completion. It is a pure identity helper, so it has no runtime effect beyond typing.
// signalx.config.ts
import { defineLynxConfig } from '@sigx/lynx-cli/config';
export default defineLynxConfig({
name: 'My App',
version: '1.0.0',
scheme: 'myapp',
orientation: 'portrait',
icon: 'assets/icon.png',
splash: {
image: 'assets/splash.png',
backgroundColor: '#0D9488',
dark: { backgroundColor: '#134E4A' },
},
modules: [
{ package: '@sigx/lynx-location', config: { accuracy: 'high' } },
],
iconSets: [
{ id: 'fa', source: '@sigx/lynx-icons-fa-free', styles: ['solid', 'brands'] },
{ id: 'lucide', source: '@sigx/lynx-icons-lucide' },
],
android: {
applicationId: 'com.mycompany.myapp',
versionCode: 2,
adaptiveIcon: {
foreground: 'assets/adaptive-foreground.png',
backgroundColor: '#0D9488',
monochrome: 'assets/adaptive-monochrome.png',
},
notificationIcon: 'assets/notification-icon.png',
notificationColor: '#0D9488',
features: [{ name: 'android.hardware.camera', required: false }],
},
ios: {
bundleIdentifier: 'com.mycompany.myapp',
buildNumber: '2',
icon: { light: 'assets/icon.png', dark: 'assets/icon-dark.png', tinted: 'assets/icon-tinted.png' },
supportsTablet: true,
requiresFullScreen: false,
},
});
Notes:
- Installed
@sigx/lynx-*modules are autolinked automatically — you only list a module inmoduleswhen you need to pass it native config or restrict it to one platform. UseexcludeModulesto opt out of one. - The config file is evaluated at prebuild time, so you can read
process.envfor secrets likeandroid.googleMapsApiKey. - Sensible defaults are applied for everything you omit:
version1.0.0,buildNumber1,orientationportrait, both platforms, AndroidminSdk24 /targetSdk35, iOSdeploymentTarget15.0,supportsTablettrue. Missing icon or splash images fall back to bundled placeholders. - When
applicationId/bundleIdentifierare omitted, run and dev commands fall back tocom.sigx.<name>(lowercased, non-alphanumerics stripped). - App icons take appearance variants.
ios.iconaccepts a{ light, dark, tinted }set for iOS's standard / dark / tinted home-screen modes (a string is shorthand forlight). Android'sadaptiveIcontakesforeground+backgroundColor+ an optionalmonochromelayer (themed icons), andnotificationIcon/notificationColorstyle the status-bar notification glyph.splashlikewise takes adarkvariant. - Device support.
ios.supportsTablettoggles iPad support (Xcode's device family);ios.requiresFullScreenopts the iPad out of multitasking — only then may it follow the phone's orientation lock, otherwise iPad apps must allow all four orientations.android.featuresemits<uses-feature>manifest entries ({ name, required }) to declare hardware your app uses. - Cleartext HTTP is debug-only. Prebuild emits an Android network-security config that blocks cleartext (non-HTTPS) traffic in release builds and permits it only in the debug source set, so use
https://for production endpoints.
Native config passthrough
Most native settings have a dedicated config field, but when an SDK needs a key the CLI doesn't model directly, typed escape hatches merge arbitrary values into the rendered native projects on every prebuild — so a custom key survives without post-prebuild patching:
ios.infoPlist— arbitraryInfo.plistkeys (Record<string, PlistValue>; values may be scalars, arrays, or nested dicts) merged over the generated plist (last-write-wins).ios.usesNonExemptEncryption— a boolean convenience for the near-universalITSAppUsesNonExemptEncryptionkey; setfalsefor apps using only exempt encryption (e.g. HTTPS) to clear App Store Connect's "Missing Compliance" prompt. An explicitinfoPlist.ITSAppUsesNonExemptEncryptionwins if both are set.android.applicationAttributes— arbitrary attributes merged onto the<application>element inAndroidManifest.xml(Record<string, string | boolean | number>, e.g.{ usesCleartextTraffic: false, largeHeap: true }). Theandroid:namespace prefix is added automatically; booleans/numbers are stringified. It's the<application>-attribute counterpart tomanifestMetaData(which only adds<meta-data>children).
export default defineLynxConfig({
name: 'My App',
ios: {
usesNonExemptEncryption: false,
infoPlist: { LSApplicationQueriesSchemes: ['tel', 'mailto'] },
},
android: {
applicationAttributes: { largeHeap: true },
},
});
Each of these merges with values contributed by linked native modules — modules declare the same keys in their signalx-module.json, and the app-level entries win on a name collision.
Firebase and entitlements
Two more passthroughs wire the signed-capability plumbing that remote push (and anything needing an entitlement) depends on:
android.googleServicesFile— path to a Firebasegoogle-services.json. Prebuild copies it toandroid/app/google-services.jsonon every run (so it survives the managedandroid/re-render) and applies thecom.google.gms.google-servicesGradle plugin. Point it at a gitignored path to keep credentials out of source control. This is what@sigx/lynx-notificationsneeds for FCM.ios.entitlements— arbitrary app-level code-signing entitlements (Record<string, PlistValue>), the counterpart toios.infoPlist. Setting any entitlement makes prebuild generate<App>.entitlements(Release) +<App>.debug.entitlements(Debug) and wireCODE_SIGN_ENTITLEMENTSper build config.aps-environmentis special-cased — written asproductionin the Release file anddevelopmentin the Debug file regardless of the value you pass.
export default defineLynxConfig({
name: 'My App',
android: { googleServicesFile: './firebase/google-services.json' },
ios: { entitlements: { 'aps-environment': 'development' } },
});
Native modules contribute the same two mechanisms through their signalx-module.json — android.gradlePlugins and ios.entitlements (see the manifest types). So installing @sigx/lynx-notifications adds the FCM Gradle plugin and the aps-environment entitlement automatically; you only supply the Firebase file and your iOS signing. App-level entries win over a module's on a name collision.
Build variants
A variants map lets a dev/staging/preview build install alongside production with its own app id, name and output dir, selected with --variant <name>. See the dedicated Build variants guide.
Running the dev server
sigx dev starts the rspeedy dev server, probes for a free port (default 8788), and — in an interactive terminal — renders a full-screen dev dashboard. (In a non-TTY environment it falls back to plain banner output.)
The dashboard is laid out in panes rather than one scrolling column, so status stays put while output moves:
- Status — port, build id, selected targets.
- One QR code with the LAN-IP URL for loading the bundle on a device. It is a single QR regardless of how many targets are selected; the URL is the same for all of them.
- Build and Logs as separate tabs, so a noisy device log never buries a build error.
- Device logs as a structured table — level, source and message in columns, rather than raw interleaved lines — with filters to narrow to what you're chasing.
Press z to zoom the focused pane to full screen and back, which is the fastest way to read a long stack trace without leaving the dashboard.
sigx dev # interactive target picker (TTY)
sigx dev --all # build for every connected target
sigx dev --ios # iOS only
sigx dev --android # Android only
sigx dev --last # reuse the last selected target
sigx dev --port 9000 # custom port
sigx dev --variant dev # run a build variant (see Build variants)
sigx dev --no-device-logs # don't stream device console.* output
Behavior worth knowing:
- The first
devrun with noandroid/orios/folder auto-runsprebuildfor you. - In a TTY it shows an interactive target picker and remembers your last choice (
--lastreuses it). In a non-TTY environment it behaves like--all. - Device
console.*logs stream back to your terminal; selected targets are auto-launched with the dev URL. On-device runtime exceptions (the native red-screen errors) also surface in the terminal's Logs tab — see device runtime exceptions. - An app with no
lynx.config.ts(a bundle-loader client such as sigx-lynx-go) skips the JS dev server.
Prebuilding native projects
sigx prebuild renders the managed ios/ and android/ native projects from templates, autolinks every installed @sigx/lynx-* module (generating the native registries, Podfile entries, Gradle deps, manifest permissions and so on), then runs your prebuild.post hook.
sigx prebuild # both platforms
sigx prebuild --android # Android only
sigx prebuild --ios # iOS only
sigx prebuild --clean # bypass the fingerprint fast-path, full re-render
sigx prebuild --variant dev # render the dev variant into android-dev/ + ios-dev/
sigx prebuild --embed-bundle # also bake the built JS bundle in (release pipelines)
Prebuild has a fingerprint-based fast path: it skips work when its inputs are unchanged. Editing signalx.config.ts or the post-hook script re-triggers it; --clean forces a full re-render regardless.
--embed-bundle for external release pipelines
A plain prebuild seeds a 0-byte iOS placeholder (and nothing on Android). That is deliberate: the native loaders treat a non-empty main.lynx.bundle as "load this" regardless of build configuration, so an always-embedded bundle would stop dev and sandbox builds from falling through to the dev server and DevHomeScreen.
The real bundle is therefore copied in only on explicit release intent. sigx run:ios --release and sigx run:android --release do it for you. --embed-bundle is that same step for pipelines that archive the native project themselves:
sigx build
sigx prebuild --embed-bundle --ios
xcodebuild archive … # fastlane, Xcode Cloud, gradle bundleRelease, …
It copies dist/main.lynx.bundle over the native project's bundle slot, and mirrors any async chunks from dynamic import() into the project so the production resource fetchers can serve them locally. The stale chunk subtree is removed first — hashes change every build, and leftovers would ship dead code and grow the project unboundedly.
It throws when the built bundle is missing or empty rather than no-op'ing: you asked to embed, so silently baking the placeholder again would produce an archive that looks fine and loads nothing. Run sigx build first.
Because prebuild re-renders managed native files pristine each time, a prebuild.post hook that patches them must anchor on template text and fail loudly when the anchor is missing:
// signalx.config.ts excerpt
export default defineLynxConfig({
name: 'My App',
prebuild: { post: './scripts/native-patches.mjs' },
});
The post hook's default export, if a function, is awaited with { cwd, config, platforms }.
To drive prebuild programmatically from your own tooling, call runPrebuild:
import { runPrebuild } from '@sigx/lynx-cli';
await runPrebuild({ ios: true, android: true, clean: true });
Building and running on a device
sigx run:android # debug build, install, launch
sigx run:android --release # release: bundle -> prebuild -> gradle installRelease
sigx run:ios --simulator "iPhone 15" # boot a named simulator
sigx run:ios --device "My iPhone" # a physical device (Xcode 15+ / devicectl)
sigx run:ios --release
sigx run:android --variant dev # install the dev variant alongside production
sigx build --analyze # production bundle + size summary
Platform notes:
run:ios(and any iOS build) is macOS only and errors elsewhere.- Android needs the Android SDK + JDK + Gradle;
adbis resolved fromANDROID_HOME/platform-toolsandPATH. iOS needs Xcode, CocoaPods andxcrun/devicectl. - Run
sigx doctorfirst to confirm your toolchain: it reports Node, your package manager, the Android SDK/JDK/ADB, iOS Xcode/CocoaPods and connected devices.
Code splitting
Dynamic import() works everywhere — sigx dev, release, and standalone/store
builds alike (earlier releases only split in dev). Split a heavy or rarely used
feature into its own async chunk and load it on demand:
// loaded only when the user opens the editor
button.onTap(async () => {
const { openEditor } = await import('./editor');
openEditor();
});
sigx build prints an async-chunk summary alongside the main bundle. Two things to
know before shipping:
- OTA updates carry only
main.lynx.bundle, sosigx updates:publishrefuses while async chunks are present. Host the chunks yourself (output.assetPrefix) and pass--allow-async-chunks, or skip splitting for OTA-first apps — see Publishing an update. - Standalone builds embed the chunks into the native app so they resolve offline; no extra wiring.
Running on the web
sigx run:web is the browser peer of run:android / run:ios: it builds your app for the web target and serves it locally with live reload, so you can iterate on layout and logic without a simulator or device.
sigx run:web # build, serve and open the browser (default port 8900)
sigx run:web --port 9000 # custom port
sigx run:web --no-open # don't auto-open a browser
sigx run:web --no-watch # build and serve once, no live reload
sigx run:web --host # expose on the LAN
It serves three things over one cross-origin-isolated HTTP server: a generated <lynx-view> host page, the upstream @lynx-js/web-core browser engine, and your app's own dist/ bundle. Enable the web target by adding it to your build environments:
// lynx.config.ts
import { defineConfig } from '@lynx-js/rspeedy';
import { pluginSigxLynx } from '@sigx/lynx-plugin';
export default defineConfig({
plugins: [pluginSigxLynx()],
environments: { lynx: {}, web: {} },
});
The generated host page wires the view with @sigx/lynx-web-host: installSigxWebHost derives the web-core browser-config from the <lynx-view> element's box (opt out with { viewport: false }), and the public viewportBrowserConfig(view) exposes that computation for a hand-written host page. Set browser-config inline while the page parses, before the engine upgrades <lynx-view> — SystemInfo is frozen at view construction, so later resizes aren't tracked.
Two things to know about the web target:
- Live reload is a full reload, not hot-swap — a file change rebuilds the bundle and the page re-fetches it. Lynx's HMR transport is device-oriented and stays off for the web target (matching upstream).
- Gestures work in the browser. Tap, long-press and pan map to pointer input, so
Pressable,Draggable, sheets and swipe-to-reveal all behave on web;useAnimatedStyleanimations fall back to inline styles.
Shipping it: sigx build:web
run:web is the dev server. sigx build:web produces a deployable static export in dist/web/ that any static host can serve:
sigx build:web
sigx build:web --coi # also emit the COI service-worker shim
index.html host page (no reload client)
engine/static/** the upstream @lynx-js/web-core prebuilt engine
app/** your app bundle and its static assets, async chunks included
host/sigx-host.js the @sigx/lynx-web-host page bridge
coi.js (--coi only) the COI service-worker shim
_headers, vercel.json header samples
Cross-origin isolation is required
web-core needs SharedArrayBuffer, which means the page must be cross-origin isolated: Cross-Origin-Opener-Policy: same-origin plus Cross-Origin-Embedder-Policy: require-corp. Without both, the engine will not start.
Headers are a serving concern, not a build one, so the export emits samples (_headers, vercel.json) rather than trying to guess your host:
- Hosts that can set headers (Netlify, Vercel, Cloudflare, your own nginx) should set them. Use the samples.
- Header-less hosts (GitHub Pages) can use
--coi, which registers a service worker that injects the headers client-side. The cost is one automatic reload on a visitor's first visit.
Keeping the module family in sync
The @sigx/lynx-* packages are versioned in lockstep, so the CLI ships commands to manage them as a set:
sigx add @sigx/lynx-camera # install a module (autolinks on next prebuild)
sigx remove @sigx/lynx-camera # uninstall
sigx outdated # report available updates / lockstep drift
sigx upgrade --to latest # bump the whole family together
sigx upgrade --dry-run # preview without writing
outdated exits non-zero on lockstep drift, and on an available update for the default latest check. upgrade writes exact pins by default (--caret for caret ranges) and refuses to run on a dirty git tree unless you pass --force.
How commands declare their flags
Every sigx command's flags are declared with @sigx/cli's fluent arg builders (re-exported as a from @sigx/cli/plugin, backed by @sigx/args). A command's args shape maps each flag to a typed builder, and the parsed values arrive on ctx.args:
import { a } from '@sigx/cli/plugin';
const dev = {
description: 'Start the dev server',
args: {
port: a.number().default(8788),
device: a.string().alias('d'),
deviceLogs: a.boolean().negatable(true), // --no-device-logs forces false
targets: a.rest(), // trailing positional tokens
},
run: async (ctx) => { /* ctx.args is fully typed */ },
};
Behavior worth knowing for anyone authoring a command (or a sibling plugin):
- Unknown flags error by default — a mistyped flag fails fast rather than being silently ignored.
--no-<name>negation is on by default for boolean flags (.negatable(false)to opt out).a.rest()collects trailing positional tokens asstring[]; refiners like.required(),.default(v),.multiple(), and.alias('d')set requiredness, defaults, repeat behavior, and short aliases.
This is what types the --flag columns in the command table.
See also
- Overview — what the plugin is and how it fits the family.
- Installation — project setup.
- Build variants — per-environment app identity with
--variant. - API reference — the full typed surface.
- Your first app — an end-to-end walkthrough.
