pyreon

@pyreon/create-multiplatform

Scaffold a multiplatform Pyreon app — one src/App.tsx source that runs on web, iOS (SwiftUI), and Android (Jetpack Compose) via PMTC (the Pyreon Multi-Target Compiler).

Quick start

npm create @pyreon/multiplatform@latest my-app
# or: npx @pyreon/create-multiplatform my-app
cd my-app
npm install

npm run dev             # web (Vite dev server)
npm run build:ios       # src/App.tsx → ios/generated/App.swift (then: cd ios && xcodegen generate)
npm run build:android   # src/App.tsx → android/.../generated/App.kt (then: cd android && gradle assembleDebug)

The unscoped npx create-multiplatform does not work: npx resolves a package name, and the package is @pyreon/create-multiplatform (create-multiplatform is only its bin name). pyreon new --native runs the same scaffolder.

CLI usage

create-multiplatform <project-name> [--dir <path>] [--scheme <url-scheme>]
ArgDescription
<project-name>Required. Also the directory scaffolded into unless --dir overrides it. Must be lowercase kebab-case (starts with a letter, letters/digits/hyphens only, no consecutive or trailing hyphens, 1-50 chars) — the name propagates into the Xcode project name and the Gradle module id, both stricter than an npm package name. An invalid name fails with a suggested fix.
--dir <path> / -d <path>Scaffold into a directory other than <project-name>.
--scheme <url-scheme>The custom URL scheme the app registers for deep links. Defaults to the project's identifier (my-app → myapp). See Deep links.
--help / -hPrint usage and exit 0.
npx @pyreon/create-multiplatform my-app --dir ./apps/my-app

The target directory must not already exist and be non-empty — a .git-only directory is fine (the mkdir my-app && cd my-app && npx @pyreon/create-multiplatform . flow), but scaffolding refuses to overwrite a directory that already has real content in it.

Scaffolded scripts, beyond dev / build:ios / build:android: build (vite build), preview (Vite preview), lint (pyreon-lint ., including the opt-in portable @pyreon/lint rule group scoped to src/App.tsx — it flags a shared-source construct PMTC can't lower to native, before a build tries and fails), release:keystore (generates a self-signed Android release keystore if absent), release:android (ensures the keystore, then produces a signed, R8-minified release APK via gradle assembleRelease), and e2e:ios / e2e:android (build, install and launch with pyreon-native dev --target=<ios|android> --once, then run the Maestro flows in .maestro/).

The project also ships AGENTS.md, CLAUDE.md and .mcp.json: instructions for AI coding assistants (the commands above, the native-subset rules, where the capability matrix lives) and the Pyreon MCP server registration, matching what create-zero scaffolds for web apps.

Every generated app can be opened at a route by URL, with no setup. The scheme defaults to the project's identifier (my-app → myapp://); choose another with --scheme:

npx @pyreon/create-multiplatform my-app --scheme acme   # acme://item/42

The host files forward every inbound URL to the router: iOS through .onOpenURL (and a universal link through onContinueUserActivity), Android through the launch intent and onNewIntent (the activity is singleTop, so a link sent to the running app reaches it instead of stacking a second copy). The scheme is registered for both: CFBundleURLTypes on iOS, a VIEW/BROWSABLE intent filter on Android.

How a URL becomes a route: for a custom scheme the host is the first route segment, so acme://item/42 opens /item/42 and acme://about opens /about. For a web link the host is the domain and is ignored: https://example.com/item/42 also opens /item/42. Query and fragment are dropped, and a URL that yields nothing opens /.

Try it on a running simulator or emulator:

xcrun simctl openurl booted "myapp://item/42"
adb shell am start -a android.intent.action.VIEW -d "myapp://item/42"

Not generated: verified web links (universal links on iOS, autoVerify app links on Android). They need a domain you control, an Associated Domains entitlement and an assetlinks.json / apple-app-site-association file, so they stay a manual step. The forwarding for them is already in place; only the entitlement, the manifest filter and the hosted association file are missing.

What gets generated

A project sharing one canonical-primitive source across three targets:

my-app/
  package.json
  src/
    App.tsx                 # the ONE source (uses @pyreon/primitives)
    entry-web.tsx           # web mount entry
  index.html
  vite.config.ts
  tsconfig.json
  .pyreonlintrc.json        # enables the `portable` lint rules for src/
  .maestro/counter.yaml     # on-device E2E flow (npm run e2e:ios / e2e:android)
  AGENTS.md  CLAUDE.md  .mcp.json  README.md  .gitignore
  ios/
    project.yml             # xcodegen spec (a pre-build phase runs scripts/build-ios.sh)
    Sources/
      Main.swift            # SwiftUI @main app shell + deep-link forwarding
      ContentView.swift     # mounts the generated App()
    Info.plist
    generated/              # PMTC output — `npm run build:ios` populates this
      App.swift
  android/
    settings.gradle.kts
    build.gradle.kts
    gradle.properties
    app/
      build.gradle.kts
      proguard-rules.pro  proguard-test-rules.pro  proguard-releasetest-keep.pro
      src/main/AndroidManifest.xml
      src/main/kotlin/.../MainActivity.kt
      src/main/kotlin/.../generated/  # PMTC output
        App.kt
  scripts/
    build-ios.sh            # pyreon-native wire --ios-out + build --target=ios
    build-android.sh        # pyreon-native build --target=android --kotlin-package=… + wire --android-out
    ensure-release-keystore.sh

src/App.tsx uses the canonical @pyreon/primitives vocabulary (<Stack>, <Text>, <Button>, etc.). The web build auto-imports the DOM runtimes; the iOS and Android scripts run pyreon-native build to compile the same source to SwiftUI / Compose.

What "one source" means in practice

// src/App.tsx as scaffolded — runs on all three targets unchanged
import { signal } from '@pyreon/reactivity'
import { Stack, Text, Button } from '@pyreon/primitives'

export function App() {
  const count = signal(0)
  return (
    <Stack gap="md" align="center">
      <Text data-testid="count">{count}</Text>
      <Button data-testid="increment" onPress={() => count.set(count() + 1)}>Increment</Button>
    </Stack>
  )
}
TargetRender path
WebVite + @pyreon/runtime-dom + the real @pyreon/primitives web implementations.
iOSPMTC reads src/App.tsx, emits ios/generated/App.swift with SwiftUI VStack / HStack / Text / Button primitives. Xcode builds the project xcodegen generates from ios/project.yml.
AndroidPMTC reads src/App.tsx, emits android/.../generated/App.kt with Compose Column / Row / Text / Button. Gradle builds the resulting Compose app.

Toolchain requirements

TargetRequired
WebBun / Node 18+
iOSmacOS, Xcode 15+, xcodegen (brew install xcodegen)
AndroidJDK 17, Android SDK, and gradle on PATH (no Gradle wrapper is generated)

The web target requires no platform-specific toolchain. iOS and Android targets are opt-in — you can scaffold the project and only build the web target if you don't need native immediately.

Status

The generator is verified at the file-tree-shape level + the shared App.tsx compiling through PMTC to both native targets. End-to-end device launches (Simulator / Emulator) live in the native-device CI workflow and require local toolchains. Phases B+ of PMTC (more primitive coverage, real-app feature parity) are in active development.

See also

@pyreon/create-multiplatform