npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

loupio

v0.11.1

Published

See your whole app at once: every screen and journey, signed out and signed in, mapped automatically from the iOS Simulator (or a local web app).

Readme

Loupio

See your whole app at once. Loupio taps through your iOS app on the Simulator (or your local web app), screenshots every screen, and lays out every journey on one canvas, signed out and signed in. No more tapping through a test phone, screenshotting, and arranging it all in Miro or Figma.

The CLI is loupio (visual-plan still works as an alias). Config stays in visual-plan.config.json and ~/.visual-plan/ for now.

Self-hosted, repo-agnostic visual plan generator: a file/diff diagram, a colorized diff, and real Playwright screenshots of your local dev server, bundled into one self-contained report.html. Optionally push it to your own Railway-hosted viewer for a shareable URL.

Built as a safe alternative to third-party "visual plan" tools whose hosted mode ships your source code and screenshots to their servers. Here, screenshot capture only ever targets localhost/127.0.0.1 (enforced, not just documented), and the only thing that ever leaves your machine is the finished HTML report you explicitly push — to infrastructure you own.

Install

npx loupio --help

Needs Node 20+. iOS mapping needs Xcode with an iOS Simulator runtime. Web mode (screenshots of a localhost dev server) also needs npx playwright install chromium once.

Once a day the CLI asks the server for the latest version, sending only its own version number and platform (no id of any kind). Turn it off with VISUAL_PLAN_NO_UPDATE_CHECK=1; it is always off when CI is set.

Developing visual-plan itself

npm install && npm --prefix viewer install
npm run build:viewer && npm run build
scripts/e2e-fixture.sh     # end-to-end crawl of ios/VPFixture, both states

Development uses Node 22 (.nvmrc). CI runs on a self-hosted runner on the maintainer's Mac (scripts/setup-runner.sh; GitHub-hosted Macs bill at 10x). Releases publish from CI on a v* tag via npm Trusted Publishing (no token; the publish job runs on a GitHub-hosted Linux runner). CI runs the fast checks on every push; the full end-to-end crawl runs only by hand (npm run e2e, or "Run workflow" on GitHub), because Simulators don't finish booting under the runner's background service.

Usage

From any repo:

visual-plan build          # writes .visual-plan/report.html and canvas.html
visual-plan push           # pushes it, prints back a shareable URL

Screenshots are opt-in — drop a visual-plan.config.json in the repo root:

{ "devServerUrl": "http://localhost:3000", "paths": ["/", "/dashboard"] }

iOS apps (signed out and signed in)

visual-plan crawls an iOS app on the Simulator: it taps through every reachable screen, screenshots each distinct one, and records which tap led where. It can map the app twice, once signed out and once signed in, and shows both side by side on the canvas. It never sees or types your password.

Needs Xcode (with an iOS Simulator runtime) and Node 20+.

npx loupio init          # finds your Xcode project, builds the app, creates the signed-out Simulator
npx loupio ios login     # opens that Simulator: sign in by hand, then press Enter
npx loupio build --open  # crawls both states, opens .visual-plan/canvas.html

How the states work:

  • init builds your app, keeps a copy of the build in ~/.visual-plan/apps/, and creates a Simulator named visual-plan · <bundle id> · signed-out with the app installed and never launched. Every signed-out crawl runs on a brand-new Simulator with a fresh install, deleted afterwards. (Not a simctl clone: on current Xcode a clone keeps using the original's app data folder, so state would leak between them.)
  • ios login creates visual-plan · <bundle id> · signed-in with a fresh install and opens it so you can sign in however your app signs in (Apple, Google, email, codes sent to your phone). The session stays saved on that Simulator. Signed-in crawls run on it directly, not on a copy, because apps that rotate refresh tokens would see an old token reused and revoke the session. If a signed-in crawl comes back looking signed out, run ios login again. Or skip ios login altogether: with a launchEnvironment for signed-in (below) and no saved signed-in Simulator, each signed-in crawl runs on a fresh Simulator like signed-out and the app signs itself in (see "Signing in automatically").
  • Use a test account. The crawler taps real buttons as that account. It looks but doesn't commit: it never taps a switch, or anything matching delete, remove, erase, reset, deactivate, log out, sign out, purchase, buy, pay, subscribe, checkout, send, submit, confirm, redeem, post, block, leave, transfer, cancel subscription or sold at the start of a word, including in an unlabelled row's inner text (the skip setting adds more patterns to that list; it can't remove any). A label of more than six words is a description, so only its first four words are checked. If a tap flips a control (a genre chip, a checkbox: its selected state or value changes while the screen stays the same), the crawler taps it again to undo it and leaves the rest of that list alone; it does the same after two rows of a list change nothing visible. If the control disappears instead (a dismissed row), it warns and never taps a control with that label again. Every tap, skip and undo is recorded in .visual-plan/ios-crawl/<state>/crawl-plan.md.

Signing in automatically

ios login needs you at the keyboard. If your app can sign itself in from a value it is launched with, signed-in crawls run unattended: no ios login, and they can run in CI (and, later, hosted crawls). Use a test account; the crawler taps real buttons as it.

The contract:

  • launchEnvironment in visual-plan.config.json maps an app variable to a host variable (see launchEnvironment under the config keys below). The values come from your shell or .visual-plan/secrets.env, never from the config, which is committed.
  • Loupio always launches the app with LOUPIO_CRAWL=1 and LOUPIO_STATE=signed-out or signed-in (or a data state's name, see "Test data per state").

npx loupio kit adds Loupio.swift to your project (add it to your app target). It gives you Loupio.value("NAME") (a launch value), Loupio.state, Loupio.isCrawling and .loupioScreen("Name") (screen keys, below). All of it is #if DEBUG inside: in a Release build value returns nil and isCrawling is false. Re-run kit after upgrading Loupio to update the file.

Your own backend (session token, e.g. a custom JWT). Add a script on your backend that signs a session token for the test user and writes it to .visual-plan/secrets.env:

APP_TEST_TOKEN=eyJhbGciOi...      # from e.g. `node scripts/mint-test-token.js [email protected]`

Map it with "launchEnvironment": { "signed-in": { "TEST_SESSION_TOKEN": "APP_TEST_TOKEN" } }, and at launch store it wherever the app normally keeps its session:

@main
struct MyApp: App {
    init() {
        #if DEBUG
        // Loupio: start the signed-in crawl already signed in as the test user.
        if Loupio.state == "signed-in", let token = Loupio.value("TEST_SESSION_TOKEN") {
            // Placeholder: replace with your own session code. Save the token where a real
            // sign-in saves it (Keychain), then mark the app signed in.
            SessionStore.shared.adopt(token: token)
        }
        #endif
    }

    var body: some Scene { WindowGroup { RootView() } }
}

Supabase Auth. Put TEST_EMAIL and TEST_PASSWORD in launchEnvironment and sign in before the first screen decides what to show:

.task {
    #if DEBUG
    if Loupio.state == "signed-in",
       let email = Loupio.value("TEST_EMAIL"), let password = Loupio.value("TEST_PASSWORD") {
        try? await supabase.auth.signIn(email: email, password: password)
    }
    #endif
}

Firebase Auth. The same, with try? await Auth.auth().signIn(withEmail: email, password: password).

Skip onboarding. Any @AppStorage / UserDefaults bool can be preset with launchArguments, no code needed: "launchArguments": { "signed-in": ["-didCompleteOnboarding", "YES"] }.

Turn analytics off while crawled, so crawls don't show up as users:

#if DEBUG
if Loupio.isCrawling { analytics.optOut() }
#endif

Safety:

  • Test account only, never a real person's.
  • Release builds never contain the hook: keep every call inside #if DEBUG.
  • To cut access, rotate or revoke the token (or the test account's password).
  • Never commit .visual-plan/secrets.env (.visual-plan/ ignores itself in git; keep it that way).

Test data per state

Some screens only exist for some data: a past event's page, a festival, an empty inbox. Add a data state for each, as an object in states:

"states": [
  "signed-out", "signed-in",
  { "name": "past-event", "basedOn": "signed-in",
    "launchArguments": ["-seedSet", "past-event"],
    "launchEnvironment": { "FIXTURE_URL": "MY_FIXTURE_URL" } }
]

A data state is crawled like its base (signed-in or signed-out) on a fresh Simulator, with the base's launch arguments and environment plus its own, and shows up as its own state in the map and the Library (build --state past-event crawls just it). Names are a-z, 0-9 and - (30 characters at most); up to 6. A data state based on signed-in needs signed-in to sign in by launchEnvironment (above): a fresh Simulator can't copy a saved sign-in. Listing only data states keeps signed-out and signed-in as the default.

The app gets LOUPIO_STATE=past-event, so a DEBUG hook can load fixtures before the first screen shows:

@main
struct MyApp: App {
    init() {
        #if DEBUG
        // Loupio: the past-event state shows the app with a past event in it.
        if Loupio.state == "past-event" {
            // Placeholder: replace with your own data layer. Load a bundled JSON fixture,
            // or point the app at seed data, e.g. Loupio.value("FIXTURE_URL").
            DataStore.shared.loadFixture(named: "past-event")
        }
        #endif
    }

    var body: some Scene { WindowGroup { RootView() } }
}

Pointing it at seed data: keep fixtures as JSON files in a DEBUG-only folder of the app target, or seed a test account on your backend (a script, as for the session token above) and pass its id or URL through launchEnvironment. The values come from your shell or .visual-plan/secrets.env, never the config.

Gated screens (build --gates)

Screens behind a stored flag (onboarding, consent, permission prompts, an @AppStorage or UserDefaults bool) can't be reached by tapping. With an app model, build --gates adds a short pass per such gate after the crawl: for each gate whose screens the crawl missed, a fresh Simulator launched like signed-in (its launchEnvironment and launch arguments; signed-out when signed-in has no launchEnvironment) plus -<key> NO (or YES, as the crawl-plan.md suggestion says). Each pass runs for gatePassSeconds (150 by default), goes two taps deep, and keeps only the screens the state's map doesn't have yet. They join that state's map, tagged with the gate (gate in ios-crawl/signed-in+gate:<key>/graph.json and the manifest), and a model screen reached this way counts as reached. Passes run up to parallel at once, after the states. build says why it skips them (no app model, no gates, no stored keys, nothing missed behind them).

Coverage levels

thorough (the default) taps everything except the never-tap words and switches above. That includes controls that open a share, invite, call or report flow (the sheet is screenshotted, nothing inside it is tapped) and controls worded like they change saved state: going, interested, RSVP, attend, favourite, follow, like, rate, mark as read, dismiss, clear, archive, resolve, hide, mute, turn on/allow notifications. Those are undone when they visibly flip, but a change the crawler can't see stays, which is why this level is meant for a test account. A control that leaves the app (a website, Maps, a "Wants to Use … to Sign In" prompt) gets one screenshot, shown as ↗ <label> (outside the app), and the crawler comes straight back. A web sign-in prompt is cancelled, never continued.

careful leaves all of those words alone as well, and treats a pushed page that shows one of them in its text as look-only. Switch with build --coverage careful, or "coverage": "careful" in the ios block (the flag wins).

AI planning (optional, --ai)

build --ai <provider> shows each new screen to a vision model before tapping it. The model names the screen, groups screens of one kind (one event page explored, not twenty), spots the same screen shown with a banner or rotating text, and labels every control: opens, changes data, leaves the app, text input, or filter/tab. Anything it calls "changes data" is never tapped, and neither are switches. One exception, in the signed-out state only: a sign-in button ("Sign in with …", "Log in", "Sign up", "Continue with …", "Create account") is tapped anyway, since a system sign-in consent is cancelled and nothing is typed (commit words, neverTap and the app model still win). At thorough coverage its "don't tap" otherwise only holds for text inputs: controls that open, filter or leave the app are tapped anyway, as is a control it didn't mention. At careful it must approve every tap, and it can only unblock a word on the lists above when it is 90%+ sure the control is a filter or plain navigation (e.g. a "Going" filter chip). If the provider can't serve the crawl at all (bad key, unknown model, quota used up) the crawl stops rather than carrying on without AI vetting; a one-off error leaves that screen look-only.

| --ai | Key (environment) | Default model | |---|---|---| | claude | ANTHROPIC_API_KEY | claude-haiku-4-5-20251001 | | openai | OPENAI_API_KEY | gpt-6-luna | | gemini | GEMINI_API_KEY (or GOOGLE_API_KEY) | gemini-3.6-flash | | openai-compatible:<model> | OPENAI_BASE_URL (+ OPENAI_API_KEY if needed) | — (Ollama, LM Studio, OpenRouter…) |

Add :model to pick another, e.g. --ai openai:gpt-6-sol. One call per new screen, except a screen the app model's actions cover (80%+ of its controls) or one of the same kind as a screen already planned: those reuse what is known. Screenshots go as JPEG, at most ios.ai.imageWidth px wide (default 512). The calls, tokens and cost are printed at the end. Screenshots and control labels go to that provider under your key, so use a test account when signed in. Try build --dry-run --ai claude first: it plans the first screen and lists what it would tap, without tapping. If the provider fails, that screen falls back to the rules.

View types (automatic)

On the Simulator, Loupio injects a small debug library (ios/LoupioProbe, built with the crawler on your Mac, never added to your project) into your app with DYLD_INSERT_LIBRARIES. When a screen appears it logs the screen's view type, ItemDetailView for a SwiftUI screen or the class name for a UIKit view controller, through the same log channel as screen keys (type:ItemDetailView). That type is the screen's exact identity, and its app model screen (model ids are type names), with no change to your app. A screen key, if you log one, wins.

It sees screens that get a view controller of their own: pushes, sheets, the app's root view, UIKit screens. A tab's root, a NavigationStack's root and a screen a view swaps in place (if signedIn { … } else { … }) log nothing and are matched as before. Turn it off with "injectIdentity": false in the ios block. Types show in graph.json (type).

Screen keys (optional, recommended)

iOS gives Loupio no screen IDs from outside your app, so by default it tells screens apart from what's on them. If your app logs a name when each screen appears, Loupio uses that name as the screen's exact identity, title and kind: twenty venue pages with one key are one screen on the map.

import os
private let loupio = Logger(subsystem: "loupio", category: "screen")

// In your existing screen-tracking helper (PostHog `screen`, Firebase
// `screen_view`, Segment `screen`), or in each screen's .onAppear:
#if DEBUG
loupio.info("\(name, privacy: .public)")   // .public, or iOS logs it as <private>
#endif

Or run npx loupio kit and add .loupioScreen("Settings") to each screen's root view (see "Signing in automatically").

Treat screen keys like analytics event names:

  • Stable and readable: VenueDetail, Inbox, Settings.
  • No IDs, numbers or user data. Keys with digits or IDs are ignored with a warning, and so are keys when more than 3 different ones show up on one screen layout: that means they carry data, not screen names.
  • Debug builds only (#if DEBUG).
  • Renamed a key? Map the old name so maps stay comparable: "screenAliases": { "VenuePage": "VenueDetail" } in the ios block of visual-plan.config.json.

Keys show in graph.json (key) and in crawl-plan.md. Screens without a key fall back to the default matching.

Loupio also scores the crawl against the keys in your Swift source: Screen keys: 12 of 19 mapped; missing: ArtistEvents, EventTickets. It looks for string literals passed to loupioScreen("…") or trackScreen("…"). If your helper has another name, set "screenKeyPattern" in the ios block: a regex with one capture group for the key, e.g. "logScreen\\(\\s*\"([^\"]+)\"".

Screen history (loupio history, loupio diff)

Every screen gets a stable id that stays the same from build to build: ios:<module>.<TypeName>[#variant], e.g. ios:antix.ArtistDetailView. The TypeName is the screen's view type, else its app model screen, else its screen key; the module is the last part of the bundle id. Instances share one id (Artist A and Artist B are one screen). #variant tells one type's modes apart: the enum case on the app model edge that opened it, or the control's label, digits dropped ("See all 162" is #see-all), when two differently labelled controls (not rows of a list) on one screen open the same type. A screen with none of these, and none the app model makes of the control that opened it, gets fp:<fingerprint>, which changes with its contents. Ids show in graph.json and the map (sid).

Each build writes .visual-plan/history/<time>-<git sha>.json (the last 50 are kept): the app's version and build from its Info.plist, the git commit, and per screen its title, state, screenshot hashes (sha1 and a 64-bit dHash), its structural control labels (not list rows, which are counted per list, nor dates, free text or its own heading) and the screens it leads to; with an app model, its screens and why each one missed was, and why each state's crawl stopped. No screenshots are kept.

npx loupio history                        # the builds, oldest first
npx loupio diff                           # the last two
npx loupio diff 20261001T0900 -1          # a run by id prefix, or -1 the newest, -2 the one before
npx loupio diff --write-aliases           # also print the renames as screenAliases; --yes writes them

diff lists screens added, removed, renamed and changed (the screenshot looks different, controls came or went, a list got its first rows or lost its last, or links came or went on a screen both crawls worked through). A screen the newer crawl didn't get to is not reached, with why (a reason from the crawl, its deadline, no gate passes this time), not removed: removed is only a screen the app model no longer has, or one its parent, worked through, no longer leads to. Screens outside the app are never removed or changed, and fp: ids are listed as unstable, never added or removed. A rename is a screen gone and one new with the same source file or route, or screenshots that look alike (90%+ of the dHash), and 60%+ of their controls in common. screenAliases apply to the type names in ids too, so after --write-aliases --yes a renamed screen is the same screen.

The viewer gets the last 10 builds (canvas.html and push): the Library marks screens new, changed or renamed since the build before, its Changes toggle shows only those, each screen's panel has a strip of its earlier builds, and the Changes tab lists everything with the controls added and removed, and, dimmed, what wasn't reached this time.

Fixing a missed screen

crawl-plan.md and the map list the doors the crawler didn't open, per screen. Three ways to get what is behind one:

Tell the crawler, for every crawl from now on. In the ios block:

"alwaysTap": ["Settings > Export my data"],
"neverTap": ["Invite friends"]

An entry is "Label" (on any screen) or "Screen > Label". Both parts match as case-insensitive "contains": the label against the control's label, the screen against the screen's title or screen key. neverTap wins over everything. alwaysTap wins over the word lists (the never-tap words included, so mind what you name), the AI planner and the "rest of this list opens the same screen" rule, but a switch is still never tapped. Up to 100 entries each.

Tap one control now, keeping the rest of the map.

npx loupio build --only "Settings > Delete account"

This loads the last crawl, replays the taps that led to that screen, taps that control whatever the word lists say (never a switch), and explores what it opens with the normal rules. New screens and edges are added to the map; nothing else is crawled again. Repeat --only for several controls, and add --state signed-in to do it in one state only. It needs a full build first (it reads .visual-plan/ios-crawl/<state>/crawl-state.json), and it skips a target with a warning if the saved taps no longer lead to that screen.

Show it the way yourself.

npx loupio capture --state signed-in

This opens that state's saved Simulator and launches the app. Tap through it by hand; Loupio only watches (it never taps, scrolls or types). Each screen you stop on for a second or two is added to the map if it isn't there yet, with an edge from the screen you came from, labelled (by hand). Press Enter in the terminal when you are done: the report and canvas are rebuilt, and it prints how many screens were added. Without --state it uses signed-in if that Simulator exists, otherwise signed-out (on the saved Simulator itself, so what you do there stays on it). --ai <provider> names the captured screens as in a build. --only can't start from a captured screen, because nobody recorded which control you tapped to get there.

A full build starts each state's map again from nothing, captured screens included: put the control in alwaysTap if you want it crawled every time.

Other commands: ios install (rebuild and reinstall the app into both Simulators, keeping the sign-in), ios clean [--all] (delete leftover crawl Simulators; --all also deletes the two saved Simulators), build --state signed-in (crawl one state only), build --coverage careful (tap less; see "Coverage levels").

If Simulators on your newest iOS runtime never finish booting, set LOUPIO_SIM_RUNTIME=18.2 (any installed version prefix) and re-run init.

init options: --scheme <name>, --app <path/to/App.app> (skip building; use an app you built yourself), --device-type "iPhone 16", --force (overwrite the config), --trust (pass -skipMacroValidation -skipPackagePluginValidation to xcodebuild; only for projects you trust, since building runs the project's own build scripts).

Config (visual-plan.config.json, written by init):

{ "ios": { "bundleId": "com.example.app", "project": "App.xcworkspace", "scheme": "App" } }

Optional keys: states (["signed-out", "signed-in"] by default, or whichever saved Simulators exist, plus data states: see "Test data per state"), gatePassSeconds (150: see "Gated screens"), launchArguments (per state, e.g. { "signed-in": ["-UITestAutoLogin", "YES"] } for a debug-only auto-login hook in CI; never put credentials here, this file is committed), maxScreens (default 40, per state), maxDepth (5), maxSeconds (900, per state), maxScrollPages (6: how far down a screen the crawler scrolls for controls below the fold), maxControlsPerScreen (60), rowsPerList (2: how many rows of a list are tapped before the rest are left alone because those rows all open the same kind of screen), skip, coverage ("thorough" or "careful"), alwaysTap / neverTap (see "Fixing a missed screen"), targetedPass (default true: with an app model, once the crawl is done and time is left, go back to each mapped parent of a model screen not reached, look at it afresh and tap the control the model says opens it, through the same safety rules, trying up to 3 mapped instances of that parent (another event's page can have controls this one's data doesn't), one per route first (an event opened from one list, then one from another list, before a second from the first); a trigger on no route's instance is "no data". It gets 15% of maxSeconds when the model has fewer than 15 non-root screens, 35% when it has more than 40, else 25%; the crawl stops there either way. build --no-targeted skips it for one run), injectIdentity (default true: see "View types"), parallel (see below), and device (legacy: crawl that Simulator UDID as-is, without saved states).

parallel ("auto", the default, or 1 to 4) is how many Simulators crawl at once, for more screens in the same time at no extra AI cost (one planner, one usage line). The states are crawled side by side, and Simulators left over split a state crawled on fresh Simulators (signed-out, or a state signed in by launchEnvironment): once its first screen is surveyed, its subtrees (each tab, or each control's screen) are shared out among fresh Simulators made like its own, and merged back into one map. A state crawled on its saved Simulator stays on that one (its sign-in can't be copied). "auto" is the smallest of 4, half the Mac's cores and one per 1.5 GB of free memory, and 1 when the Mac is busy (load average over its core count); build prints what it chose, e.g. Parallel: 3 Simulators (8 cores, 12 GB free). maxScreens and maxSeconds still hold for the state as a whole. Extra Simulators are deleted when the state is done, on errors and on Ctrl-C. With an app model, crawl-plan.md lists each model screen not reached with why (reached but not identified, with the model screens it could be, trigger skipped, parent mapped but trigger not tried, no data, gated, other state, out of time, other variant; across states, the reason is from the state the screen belongs to), plus launch arguments that flip a stored gate and deep links to try. "Other variant": map-code --static records A/B routers (a view that only picks one of two or more screens with the same parameters, e.g. from a feature flag) as variants; once one is reached, the others, and what only they open, aren't counted as reachable. When the deadline cut the crawl short with more than 5 model screens left out of time or behind an unreached parent, the crawl prints a tip to raise maxSeconds (also in crawl-plan.md). Without init, build crawls whatever is on the booted Simulator.

launchEnvironment sets environment variables in the app, per state. Each entry maps the app's variable to the name of a variable on your Mac that holds the value, so the config only ever holds names:

{ "ios": { "launchEnvironment": { "signed-in": { "APP_SESSION": "MYAPP_TEST_TOKEN" } } } }

The value comes from your shell (MYAPP_TEST_TOKEN=… loupio build) or, failing that, from .visual-plan/secrets.env (KEY=value lines, # comments, quotes optional; .visual-plan/ is git-ignored once build has run; keep the file chmod 600, Loupio warns otherwise). A missing variable stops that state with its name. Values are replaced with ‹secret› in crawl.log and in errors, and reach the app through the crawler or SIMCTL_CHILD_* variables, never a command line. Every launch (crawl, capture, ios login) also sets LOUPIO_CRAWL=1 and LOUPIO_STATE (signed-out / signed-in), so your app can tell it is being mapped. A state with a launchEnvironment needs no ios login: without a saved Simulator it is crawled on a fresh one each time; with one, that Simulator is used and the environment applied too. Recipes for your app's side: "Signing in automatically".

Output: everything goes into .visual-plan/ in your repo (it ignores itself in git): canvas.html (the interactive map, works offline), report.html, and the raw screenshots. Signed-in screenshots can show personal account data, so push asks for --yes before uploading them.

How it works: ios/ holds a generic XCUITest bundle (generated from ios/project.yml with XcodeGen) that drives any installed app through XCUIApplication(bundleIdentifier:), so nothing is added to your app's project. The bundle only executes commands (snapshot, tap, screenshot); every decision is made by the CLI (src/crawler/, rules unit-tested with npm test). build --dry-run screenshots the first screen and lists what it would tap, without tapping; each screenshot gets an element list (screens/NNN.json). Screens are told apart by nav title, selected tab, and controls outside scroll views, so a feed that loads new data does not count as a new screen. Only the first row of each list is tapped. System permission alerts are pre-granted where simctl allows and tapped through otherwise. The first run compiles the crawler (about a minute); a small app takes 5 to 10 minutes per state. ios/VPFixture is a small test app used to check the crawler end to end.

push needs one-time configuration pointing at your deployed viewer:

VISUAL_PLAN_TOKEN=<token> visual-plan configure --server-url <url>

or omit the env var to be prompted (masked input, nothing echoed). Avoid --token <token> if you can — it lands in shell history and is visible to other local users via ps aux; it still works but prints a warning.

serverUrl must be https:// (loopback exempted for local testing) — the push token is sent as a bearer header, so an http:// URL would leak it in cleartext. Saved to ~/.visual-plan/config.json (mode 600, directory mode 700, no window where either is briefly world-readable).

The Railway viewer (server/)

A small Express app: POST /plans (bearer-auth with VISUAL_PLAN_TOKEN) stores a pushed report under a random UUID; GET /plans/:id serves it back, gated by a separate per-plan token generated at push time — so a leaked report URL only exposes that one report, not the push token or every other report. Storage is a Railway volume mounted at /data (VISUAL_PLAN_DATA_DIR), so reports survive redeploys.

Deployed as Railway project visual-plan, service visual-plan-server.

Security notes

  • devServerUrl in visual-plan.config.json must resolve to localhost/127.0.0.1/::1 — anything else is rejected (SSRF guard), since that config file could come from an untrusted repo.
  • All user-controlled strings (file paths, screenshot paths, error text) are HTML-escaped before going into report.html; diagram labels are safelisted to [a-zA-Z0-9 ._-] before being embedded in the Mermaid SVG.
  • Screenshot filenames are safelisted the same way before touching the filesystem (no path traversal via crafted paths).
  • Server token comparisons use crypto.timingSafeEqual.