Lynx/Modules/CLI Plugin/Usage
@sigx/lynx-cli · Stable

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:

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

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

TypeScript
// 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 in modules when you need to pass it native config or restrict it to one platform. Use excludeModules to opt out of one.
  • The config file is evaluated at prebuild time, so you can read process.env for secrets like android.googleMapsApiKey.
  • Sensible defaults are applied for everything you omit: version 1.0.0, buildNumber 1, orientation portrait, both platforms, Android minSdk 24 / targetSdk 35, iOS deploymentTarget 15.0, supportsTablet true. Missing icon or splash images fall back to bundled placeholders.
  • When applicationId / bundleIdentifier are omitted, run and dev commands fall back to com.sigx.<name> (lowercased, non-alphanumerics stripped).
  • App icons take appearance variants. ios.icon accepts a { light, dark, tinted } set for iOS's standard / dark / tinted home-screen modes (a string is shorthand for light). Android's adaptiveIcon takes foreground + backgroundColor + an optional monochrome layer (themed icons), and notificationIcon / notificationColor style the status-bar notification glyph. splash likewise takes a dark variant.
  • Device support. ios.supportsTablet toggles iPad support (Xcode's device family); ios.requiresFullScreen opts the iPad out of multitasking — only then may it follow the phone's orientation lock, otherwise iPad apps must allow all four orientations. android.features emits <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 — arbitrary Info.plist keys (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-universal ITSAppUsesNonExemptEncryption key; set false for apps using only exempt encryption (e.g. HTTPS) to clear App Store Connect's "Missing Compliance" prompt. An explicit infoPlist.ITSAppUsesNonExemptEncryption wins if both are set.
  • android.applicationAttributes — arbitrary attributes merged onto the <application> element in AndroidManifest.xml (Record<string, string | boolean | number>, e.g. { usesCleartextTraffic: false, largeHeap: true }). The android: namespace prefix is added automatically; booleans/numbers are stringified. It's the <application>-attribute counterpart to manifestMetaData (which only adds <meta-data> children).
TypeScript
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 Firebase google-services.json. Prebuild copies it to android/app/google-services.json on every run (so it survives the managed android/ re-render) and applies the com.google.gms.google-services Gradle plugin. Point it at a gitignored path to keep credentials out of source control. This is what @sigx/lynx-notifications needs for FCM.
  • ios.entitlements — arbitrary app-level code-signing entitlements (Record<string, PlistValue>), the counterpart to ios.infoPlist. Setting any entitlement makes prebuild generate <App>.entitlements (Release) + <App>.debug.entitlements (Debug) and wire CODE_SIGN_ENTITLEMENTS per build config. aps-environment is special-cased — written as production in the Release file and development in the Debug file regardless of the value you pass.
TypeScript
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.jsonandroid.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.

Terminal
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 dev run with no android/ or ios/ folder auto-runs prebuild for you.
  • In a TTY it shows an interactive target picker and remembers your last choice (--last reuses 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.

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

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

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

TypeScript
import { runPrebuild } from '@sigx/lynx-cli';

await runPrebuild({ ios: true, android: true, clean: true });

Building and running on a device#

Terminal
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; adb is resolved from ANDROID_HOME/platform-tools and PATH. iOS needs Xcode, CocoaPods and xcrun/devicectl.
  • Run sigx doctor first 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:

TSX
// 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, so sigx updates:publish refuses 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.

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

TypeScript
// 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; useAnimatedStyle animations 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:

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

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

TypeScript
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 as string[]; 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#