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.4 — it builds on the 0.4 plugin API (typed args via @sigx/cli's re-exported a builders, and the dashboard shell).

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          # 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: live status (port, build id, selected targets), the QR code and LAN-IP URLs for loading the bundle on a device, streaming device logs, and keyboard shortcuts / slash-commands. (In a non-TTY environment it falls back to plain banner output.)

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/

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.

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.

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#