Files
software-mansion__argent/packages/argent/scripts/bundle-tools.cjs
Sebastian Flajszer 1e69c90981 feat(ios-device): add physical iOS support (#944)
# Physical iOS device support

Try it now (cabled iPhone, unlocked, Developer Mode on):

```bash
argent run list-devices                                            # copy the 25-char UDID
argent run launch-app --udid <UDID> --bundleId com.apple.Preferences
```

First launch builds and signs the on-device runner: 3 to 5 minutes, keep
the phone unlocked. Every later start: seconds, from cache. No signing
setup needed if an Apple ID is signed into Xcode; the team is detected
from the keychain.

## What this adds

- Physical iPhone support, on by default: a cabled iPhone simply appears
in `list-devices`. iPhone-only: physical iPads are skipped at discovery.
- 15 tools ported to hardware. Flows replay on device.
- Zero-config signing from the keychain; `ARGENT_IOS_TEAM_ID` is an
override, nothing is persisted.
- Self-contained npm install: the runner's Swift sources ship in the
package. New package `@argent/ios-device-runner` (an XCUITest bundle
hosting an HTTP command server on the device), new tool-server backend
under `src/utils/ios-device/`.
- The one always-on cost: `list-devices` on macOS now runs physical
discovery in parallel with the other platform probes, under the existing
8s devicectl timeout; off-macOS it early-returns.

## Suggested review order

Rough estimate: half a day for the full pass.

1. `packages/ios-device-runner/PROTOCOL.md`, then `README.md` (wire
contract and reliability model, ~15 min)
2. `packages/tool-server/src/utils/ios-device/` (devicectl, usbmux,
team-detect, runner-build, runner-commands: the core)
3. `packages/tool-server/src/blueprints/ios-device-runner.ts` (service
lifecycle, crash recovery)
4. Per-tool device arms and the capability gates
(`ios-physical-device-classification.test.ts` is the ratchet)
5. Docs page, the two new skills, the Swift runner sources

## Requirements

1. macOS host with full Xcode (iOS platform installed); uses `xcrun
devicectl` and `xcodebuild`.
2. An Apple ID signed into Xcode; automatic signing mints the runner's
provisioning profile.
3. USB cable only. Wi-Fi is deliberately unsupported: the transport is
usbmux, and a Wi-Fi-only device lists as `paired`, not `connected`.
4. iPhone paired with and trusting the Mac, Developer Mode on, A12/2018
or newer (modern 8+16-hex UDID, the CoreDevice floor). Legacy 40-hex
UDIDs are not classified as iOS.
5. Signing resolves as: `ARGENT_IOS_TEAM_ID` in the tool-server's
environment (explicit override, CI) -> keychain detection (newest
identity first, memoized per server run, never persisted). One
certificate: nothing to do. Several teams: newest wins; the first tool
result names the pick, the runner-ups, and the override. No certificate:
the error walks through Xcode > Settings > Accounts and Manage
Certificates > + > Apple Development, then retry; it never names the env
var, since a team id alone cannot sign. Bundle ids derive per team
(`com.argent.runner.t<teamid>`). `ARGENT_IOS_RUNNER_PROJECT` overrides
the project path for unusual layouts.

Build artifacts cache under `~/.argent/ios-device-runner`, keyed by
runner sources, Xcode/SDK version, and signing. A team with no
registered devices self-heals: the failed generic build retries against
the connected phone, which registers it.

## How it works

1. `xcrun devicectl` (CoreDevice) is the control plane: discovery,
readiness, app lifecycle. Readiness gates on the wired transport, so an
unplugged phone is refused before any build starts.
2. A signed XCUITest runner is built lazily with the resolved team,
started via `xcodebuild test-without-building`, and parks as an HTTP
command server for 24h. On an npm install the Xcode project is the copy
shipped inside the package.
3. The listener binds to the device's loopback interface; the host
reaches it over usbmuxd with a dependency-free `node:net` client.
4. Mutating commands are journaled by `commandId`; a lost reply is
resolved by asking `status` for the journaled fate, never by replay
(at-most-once delivery).
5. Routing hangs off `resolveDevice` UDID-shape classification plus a
per-tool capability allowlist. CLI and MCP layers are untouched; outside
tool-server, docs, and the runner package the diff is failure-code
constants (registry), two agent skills plus rule updates (skills), the
pack step (packages/argent), and the e2e smoke script.

## Supported tools

| Tool | Status | Notes |
| ----------------- | --------- |
------------------------------------------------------------------------------------------------------------------------------------------------------------
|
| launch-app | Supported | Plain `devicectl` launch. System UI
(SpringBoard/Spotlight) registers a session only; no launch attempt.
Carries a note when signing is not resolvable yet. |
| restart-app | Supported | System UI bundle ids rejected before any
device contact. |
| reinstall-app | Supported | Same system UI pre-flight. |
| screenshot | Supported | Served through the runner; device-wide, needs
no registered app. (No host-side devicectl screenshot exists on any
toolchain; that dead route was removed.) |
| describe | Supported | AX tree snapshot via the runner. Snapshots the
session app's process, so another process's dialogs are not in the tree.
A backgrounded session app fails with an actionable error instead of
being silently re-fronted. Focus polling is off for this source; typing
uses a fixed settle. |
| keyboard | Supported | Types into the focused element; typing with
nothing focused is refused with "tap the text field first". `enter` and
`backspace` are the named keys (both exist as software-keyboard
characters; there is no per-keycode HID surface on hardware).
Whitespace-only keys rejected, not silently "typed". |
| gesture-tap | Supported | `clickCount > 1` dispatches as one native
multi-tap gesture, not separate taps. |
| gesture-swipe | Supported | Single-finger drag between two points. |
| gesture-custom | Supported | Two shapes only: press-hold and straight
single-finger drag. Second finger or `Move` waypoints rejected with
authoring guidance, before any runner starts. |
| button | Supported | `home`, `volumeUp`, `volumeDown`, `actionButton`
(volume buttons are simulator-unavailable in the SDK, not
device-unavailable). `power`/`appSwitch` have no public XCUIDevice API
and reject before device contact; a button the model lacks gets a typed
refusal via `hasHardwareButton`. |
| await-ui-element | Supported | Polls the same runner snapshot describe
returns; waits and taps see identical frames. Degrades to `cause:
"unreadable"` with a note on a backgrounded app, never a raw throw. |
| await-screen-idle | Supported | Same describe path; resolves
not-settled instead of throwing, so a follow-up screenshot still runs. |
| run-sequence | Supported | Outer gate only; each step pre-flights its
own capability. |
| open-url | Supported | devicectl launches the receiving app with the
URL as its launch payload. http(s) defaults to Safari; any other scheme
needs the new optional `bundleId` param (hardware cannot resolve a
scheme's handler system-wide). |
| screenshot-diff | Supported | Live captures through the runner
(full-resolution, device-wide, no registered app needed); the diff
itself runs on the host. Baselines are per device model/resolution;
`rotation` is ignored on hardware. |

Flows run through these tools; the device flow tree gets the same
`subtreeText` hoisting as every other platform, so container-scoped
`text` asserts work.

## Not supported (400 at the capability gate)

1. gesture-pinch, gesture-rotate: no two-finger coordinate API on
hardware. Pinch/rotate flow directives also fail upfront with a clear
error.
2. screen-recording-start/stop (video): simulator-only capture pipeline.
3. rotate, paste, shake, settings-permissions: simulator-only backends,
not ported (paste also hits the iOS 16+ cross-app paste permission
prompt).
4. native-devtools, debugger, profiler families: simulator-side
instrumentation (DYLD injection does not exist on hardware).
5. Physical iPads: filtered at discovery for now.

A test ratchet (`ios-physical-device-classification.test.ts`) keeps
`apple.device` declarations in sync with the ported list.

## Core contracts

1. Observation never mutates the screen: a backgrounded session app
makes `describe`/`await-*` fail with an error naming the three ways out
(screenshot, launch-app, or launch-app com.apple.springboard). Mutating
commands do re-front, and their result carries `reactivated: true`.
2. The runner listener is unauthenticated; mitigation is the
loopback-only bind.
3. `list-devices` states: `connected` (cabled now, usable) vs `paired`
(unreachable, never auto-bound). Wired transport alone decides; a
Wi-Fi-migrated CoreDevice tunnel does not count.
4. The multi-team signing pick rides the first affected tool result as a
one-shot note (the pre-existing notes channel). The `list-devices`
result shape is unchanged from main: devices and avds, nothing else.
5. Rejections are typed 400 validation errors end to end, so a batch
flow run skips one failing flow instead of aborting.

Also: the runner is a first-class device-owned service
(`stop-simulator-server` and `stop-all-simulator-servers` reap it,
detached xcodebuild child included; a crashed runner respawns on the
next call). A passcode-locked phone refuses launches; the devicectl
10002 hint also covers a pending system prompt on an unlocked phone.
Build failures surface xcodebuild's own `error:` lines with signing
hints (no registered devices, free-team app-id cap, missing
provisioning, the `errSecInternalComponent` partition-list fix). An app
XCTest reports as `.unknown` (a swipe-killed foreign app) is refused
with launch-app guidance. Status-bar pinning is skipped on hardware;
pixel-settle masks the top band.

## Testing

- Unit coverage, hardware-independent: UDID classification and
capability routing; usbmux framing and transport; devicectl list/hints
parsing incl. the silent iPhone-only filter; keychain detection against
real certificate fixtures; runner build/cache self-heals (poisoned
cache, concurrent checkout, fresh-team retry); crash/journal recovery;
the backgrounded-observation and reactivated contracts; keyboard guards
incl. the not-focused refusal; system-UI pre-flight; per-tool device
arms.
- Manual smoke on a real device: `scripts/e2e-ios-physical-device.mjs`.

## Docs and skills

1. `features/physical-ios-devices.mdx`: the user-facing walkthrough
(zero-config signing with the sign-in fallback, prerequisites,
app-scoped model, troubleshooting).
2. `fundamentals/supported-platforms.mdx`: physical iPhones in the
platform matrix (iPads not supported yet).
3. `reference/tools.mdx` and `reference/configuration.mdx`: list-devices
states and the `ARGENT_IOS_TEAM_ID` override.
4. Two skills ship with the package via `argent init`:
`argent-ios-device-setup` (setup ladder plus a troubleshooting table
keyed to real error texts) and `argent-ios-device-interact` (the
app-scoped hardware contract, supported subset with alternatives,
gesture recipes, pitfalls). The agent rule and sibling skills route to
them.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Added experimental USB-connected physical iPhone support.
* Physical iPhones now support discovery, app lifecycle actions, URL
opening, accessibility snapshots, screenshots, taps, swipes, custom
gestures, keyboard input, waits, flows, and screenshot comparisons.
* Added automatic signing-team detection with optional
`ARGENT_IOS_TEAM_ID` configuration.
* Added clear handling for paired or unavailable devices and unsupported
hardware actions.

* **Documentation**
* Added setup, troubleshooting, capability, and physical-device usage
guidance.
* Clarified that physical iPads and several simulator-only tools and
gestures remain unsupported.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-03 14:33:11 +02:00

635 lines
25 KiB
JavaScript

#!/usr/bin/env node
"use strict";
const esbuild = require("esbuild");
const fs = require("fs");
const path = require("path");
const WORKSPACE_ROOT = path.resolve(__dirname, "../../..");
const TOOLS_ENTRY = path.resolve(WORKSPACE_ROOT, "packages/tool-server/src/index.ts");
const ARCHIVE_ENTRY = path.resolve(WORKSPACE_ROOT, "packages/archive/src/index.ts");
const REGISTRY_ENTRY = path.resolve(WORKSPACE_ROOT, "packages/registry/src/index.ts");
const TELEMETRY_ENTRY = path.resolve(WORKSPACE_ROOT, "packages/telemetry/src/index.ts");
const NATIVE_DEVTOOLS_IOS_ENTRY = path.resolve(
WORKSPACE_ROOT,
"packages/native-devtools-ios/src/index.ts"
);
const NATIVE_DEVTOOLS_ANDROID_ENTRY = path.resolve(
WORKSPACE_ROOT,
"packages/native-devtools-android/src/index.ts"
);
const TOOLS_CLIENT_ENTRY = path.resolve(
WORKSPACE_ROOT,
"packages/argent-tools-client/src/index.ts"
);
const INSTALLER_ENTRY = path.resolve(WORKSPACE_ROOT, "packages/argent-installer/src/index.ts");
const MCP_ENTRY = path.resolve(WORKSPACE_ROOT, "packages/argent-mcp/src/index.ts");
const CLI_ENTRY = path.resolve(WORKSPACE_ROOT, "packages/argent-cli/src/index.ts");
const PREVIEW_WINDOW_ENTRY = path.resolve(WORKSPACE_ROOT, "packages/preview-window/src/main.ts");
const CONFIGURATION_ENTRY = path.resolve(
WORKSPACE_ROOT,
"packages/configuration-core/src/index.ts"
);
const OUT_FILE = path.resolve(__dirname, "../dist/tool-server.cjs");
const INSTALLER_OUT_FILE = path.resolve(__dirname, "../dist/installer.mjs");
const MCP_OUT_FILE = path.resolve(__dirname, "../dist/mcp-server.mjs");
const CLI_OUT_FILE = path.resolve(__dirname, "../dist/cli-cmds.mjs");
const PREVIEW_WINDOW_OUT_FILE = path.resolve(__dirname, "../dist/preview-window/main.cjs");
// Resolve workspace deps from source rather than each package's compiled dist/,
// so bundles don't depend on build order or freshness.
const ALIASES = {
"@argent/archive": ARCHIVE_ENTRY,
"@argent/registry": REGISTRY_ENTRY,
"@argent/native-devtools-ios": NATIVE_DEVTOOLS_IOS_ENTRY,
"@argent/native-devtools-android": NATIVE_DEVTOOLS_ANDROID_ENTRY,
"@argent/tools-client": TOOLS_CLIENT_ENTRY,
"@argent/installer": INSTALLER_ENTRY,
"@argent/mcp": MCP_ENTRY,
"@argent/cli": CLI_ENTRY,
"@argent/configuration-core": CONFIGURATION_ENTRY,
"@argent/telemetry": TELEMETRY_ENTRY,
};
// Build-time constants for @argent/telemetry. An unset ARGENT_OTEL_INGEST_TOKEN
// defines "", which leaves the telemetry client unconstructed and telemetry
// inert (see packages/telemetry/src/otel.ts).
const TELEMETRY_CLI_VERSION = (() => {
try {
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8"));
return String(pkg.version ?? "0.0.0");
} catch {
return "0.0.0";
}
})();
const TELEMETRY_OTEL_INGEST_TOKEN = process.env.ARGENT_OTEL_INGEST_TOKEN ?? "";
const TELEMETRY_DEFINE = {
ARGENT_CLI_VERSION: JSON.stringify(TELEMETRY_CLI_VERSION),
ARGENT_OTEL_INGEST_TOKEN: JSON.stringify(TELEMETRY_OTEL_INGEST_TOKEN),
};
// Prefer "module" over esbuild's node default ["main","module"]: UMD entries
// (e.g. jsonc-parser's) use runtime `require("./impl/...")` that can't be
// statically resolved, and impl/ isn't shipped next to the bundle.
const MAIN_FIELDS = ["module", "main"];
// Lets inlined CJS dependencies use `require()`, `__dirname` and `__filename`
// inside an ESM bundle (e.g. @argent/native-devtools-ios resolves bin/ off
// `__dirname`). The shimmed values point at the bundle's own location.
const ESM_REQUIRE_BANNER = {
js:
"import { createRequire as __createRequire } from 'node:module'; " +
"import { fileURLToPath as __fileURLToPath } from 'node:url'; " +
"import { dirname as __pathDirname } from 'node:path'; " +
"const require = __createRequire(import.meta.url); " +
"const __filename = __fileURLToPath(import.meta.url); " +
"const __dirname = __pathDirname(__filename);",
};
// Source layout mirrors what `scripts/download-simulator-server.sh` writes:
// platform-keyed subdirectories of bin/.
const BIN_SRC_ROOT = path.resolve(WORKSPACE_ROOT, "packages/native-devtools-ios/bin");
const AX_BIN_SRC = path.resolve(BIN_SRC_ROOT, "darwin/ax-service");
// Platform-NEUTRAL bin/tcp/ (not bin/darwin/tcp/): this binary is uploaded to the
// remote macOS orchestrator, so it must resolve from any host platform. Matches
// tcpBinDir() in native-devtools-ios/src/index.ts.
const AX_TCP_BIN_SRC = path.resolve(BIN_SRC_ROOT, "tcp/ax-service");
const BIN_DIR = path.resolve(__dirname, "../bin");
const AX_BIN_DEST = path.resolve(BIN_DIR, "darwin/ax-service");
const AX_TCP_BIN_DEST = path.resolve(BIN_DIR, "tcp/ax-service");
// tvOS control binaries. Both macOS-only, unix-socket only.
const TVOS_AX_BIN_SRC = path.resolve(BIN_SRC_ROOT, "darwin/tvos-ax-service");
const TVOS_HID_BIN_SRC = path.resolve(BIN_SRC_ROOT, "darwin/tvos-hid-daemon");
const TVOS_AX_BIN_DEST = path.resolve(BIN_DIR, "darwin/tvos-ax-service");
const TVOS_HID_BIN_DEST = path.resolve(BIN_DIR, "darwin/tvos-hid-daemon");
// Host platform keys (see hostPlatformKey() in @argent/native-devtools-ios):
// darwin is a universal binary; Linux ships one single-arch ELF per key;
// win32 ships a PE `.exe`.
const SUPPORTED_HOST_PLATFORMS = ["darwin", "linux", "linux-arm64", "win32"];
const simulatorServerFileName = (platform) =>
platform === "win32" ? "simulator-server.exe" : "simulator-server";
const BUNDLED_META_DEST = path.resolve(
WORKSPACE_ROOT,
"packages/native-devtools-android/src/bundled-meta.ts"
);
const PERFETTO_VERSION_FILE = path.resolve(
WORKSPACE_ROOT,
"packages/argent-private/packages/native-devtools-android/PERFETTO_VERSION"
);
const DYLIBS_SRC = path.resolve(WORKSPACE_ROOT, "packages/native-devtools-ios/dylibs");
const DYLIBS_DEST = path.resolve(__dirname, "../dylibs");
const SKILLS_SRC = path.resolve(WORKSPACE_ROOT, "packages/skills/skills");
const SKILLS_DEST = path.resolve(__dirname, "../skills");
const RULES_SRC = path.resolve(WORKSPACE_ROOT, "packages/skills/rules");
const RULES_DEST = path.resolve(__dirname, "../rules");
const AGENTS_SRC = path.resolve(WORKSPACE_ROOT, "packages/skills/agents");
const AGENTS_DEST = path.resolve(__dirname, "../agents");
const QUERIES_SRC = path.resolve(WORKSPACE_ROOT, "packages/native-devtools-android/assets/queries");
const QUERIES_DEST = path.resolve(__dirname, "../assets/queries");
const TRACE_PROCESSOR_SRC = path.resolve(
WORKSPACE_ROOT,
"packages/native-devtools-android/assets/trace-processor"
);
const TRACE_PROCESSOR_DEST = path.resolve(__dirname, "../assets/trace-processor");
const ANDROID_PKG_DIR = path.resolve(WORKSPACE_ROOT, "packages/native-devtools-android");
const ANDROID_MANIFEST_SRC = path.join(ANDROID_PKG_DIR, "assets/manifest.json");
const ANDROID_MANIFEST_DEST = path.resolve(__dirname, "../assets/manifest.json");
const ANDROID_APK_SRC_DIR = path.join(ANDROID_PKG_DIR, "bin");
const UI_SRC = path.resolve(WORKSPACE_ROOT, "packages/ui/index.html");
const UI_DEST = path.resolve(__dirname, "../dist/preview-ui/index.html");
const UI_THEME_SRC = path.resolve(WORKSPACE_ROOT, "packages/ui/theme.css");
const UI_THEME_DEST = path.resolve(__dirname, "../dist/preview-ui/theme.css");
const TRACE_TEMPLATE_SRC = path.resolve(
WORKSPACE_ROOT,
"packages/tool-server/src/utils/ios-profiler/Argent.tracetemplate"
);
const TRACE_TEMPLATE_DEST = path.resolve(__dirname, "../assets/Argent.tracetemplate");
const TRACECFG_SRC = path.resolve(
WORKSPACE_ROOT,
"packages/native-devtools-android/assets/argent.tracecfg.pbtxt"
);
const TRACECFG_DEST = path.resolve(__dirname, "../assets/argent.tracecfg.pbtxt");
const IOS_RUNNER_SRC = path.resolve(WORKSPACE_ROOT, "packages/ios-device-runner/ArgentRunner");
const IOS_RUNNER_DEST = path.resolve(__dirname, "../dist/ios-device-runner/ArgentRunner");
// Local Xcode state that must never ship: per-user schemes/breakpoints and
// SwiftPM build output. Everything else in the project tree is source the
// user-side xcodebuild needs.
const IOS_RUNNER_SKIP_DIRS = new Set(["xcuserdata", ".build", ".swiftpm"]);
// Declarative copy plan for copyAsset() below.
//
// kind "file" (copyFileSync) | "dir" (cpSync recursive)
// src/dest absolute source and destination paths
// mode optional chmod applied to the copied file
// required throw (true) vs warn (false) when src is missing
// copiedLabel text after "✓ Copied " on success (count is prefixed if set)
// missLabel subject of the "<X> not found at …" miss message
// countExt count src entries ending in this ext, shown before copiedLabel
// count custom counter (src) => number, takes precedence over countExt
// hint extra guidance appended to the throw message (required assets)
/**
* @typedef {Object} Asset
* @property {"file"|"dir"} kind
* @property {string} src
* @property {string} dest
* @property {boolean} required
* @property {string} copiedLabel
* @property {string} missLabel
* @property {number} [mode]
* @property {string} [countExt]
* @property {(src: string) => number} [count]
* @property {string} [hint]
* @property {(src: string) => boolean} [filter] dir-only: cpSync filter, false skips the entry
* @property {boolean} [clean] dir-only: rm the dest first, so a re-bundle never merges stale files
*/
/** @type {Asset[]} */
const ASSETS = [
// iOS ax-service binary (macOS-only — runs inside an iOS Simulator).
{
kind: "file",
src: AX_BIN_SRC,
dest: AX_BIN_DEST,
mode: 0o755,
required: false,
copiedLabel: "ax-service binary",
missLabel: "ax-service binary",
},
// iOS ax-service TCP variant; only present when the TCP transport was built.
{
kind: "file",
src: AX_TCP_BIN_SRC,
dest: AX_TCP_BIN_DEST,
mode: 0o755,
required: false,
copiedLabel: "ax-service (tcp) binary",
missLabel: "ax-service (tcp) binary",
},
// tvOS AX reader — `simctl spawn`d inside an appletvsimulator to read the
// focus-engine accessibility tree.
{
kind: "file",
src: TVOS_AX_BIN_SRC,
dest: TVOS_AX_BIN_DEST,
mode: 0o755,
required: false,
copiedLabel: "tvos-ax-service binary",
missLabel: "tvos-ax-service binary",
},
// tvOS HID daemon — runs on the macOS host, injects Siri-remote HID events
// into the simulator via SimulatorKit.
{
kind: "file",
src: TVOS_HID_BIN_SRC,
dest: TVOS_HID_BIN_DEST,
mode: 0o755,
required: false,
copiedLabel: "tvos-hid-daemon binary",
missLabel: "tvos-hid-daemon binary",
},
// Android host-side Perfetto trace processor: the third-party WASM engine
// (trace_processor.wasm + emscripten glue + engine.mjs decoder + LICENSE).
// wasm-trace-processor.ts resolves them at `<pkg>/assets/trace-processor/`,
// i.e. this exact destination. Not committed; fetched + sha256-verified by
// scripts/download-trace-processor.sh.
{
kind: "dir",
src: TRACE_PROCESSOR_SRC,
dest: TRACE_PROCESSOR_DEST,
required: true,
copiedLabel: "trace-processor WASM asset(s)",
missLabel: "trace-processor WASM assets directory",
hint: "Run: bash scripts/download-trace-processor.sh (fetches from argent-private-releases)",
},
// iOS native devtools dylibs so the packaged tool-server can inject them at runtime.
{
kind: "dir",
src: DYLIBS_SRC,
dest: DYLIBS_DEST,
required: false,
copiedLabel: "native dylib(s)",
missLabel: "Native devtools dylibs",
countExt: ".dylib",
},
// The physical-iOS runner's Xcode project, SOURCES not binaries: the runner
// must be signed with each user's own Apple team on each user's own Mac
// (a development profile whitelists device UDIDs, so no prebuilt can exist),
// and runner-artifact.ts resolves this exact dest relative to the bundled
// tool-server. Cleaned first so a re-bundle never merges a stale tree.
{
kind: "dir",
src: IOS_RUNNER_SRC,
dest: IOS_RUNNER_DEST,
required: true,
clean: true,
filter: (src) => !IOS_RUNNER_SKIP_DIRS.has(path.basename(src)),
copiedLabel: "iOS device-runner source file(s)",
missLabel: "iOS device-runner Xcode project",
// countExt reads one directory level; the Swift files sit two deep.
count: (src) =>
fs
.readdirSync(src, { recursive: true })
.filter((f) => typeof f === "string" && f.endsWith(".swift")).length,
},
// Android helper manifest.json: helperManifest()/bundledHelperApkPath() read it
// at runtime, and the version-stamped APK filename comes from its versionName
// (see the APK block after the copy loop).
{
kind: "file",
src: ANDROID_MANIFEST_SRC,
dest: ANDROID_MANIFEST_DEST,
required: true,
copiedLabel: "Android manifest",
missLabel: "Android manifest",
hint: "Run: bash scripts/download-native-binaries.sh (fetches from argent-private-releases)",
},
// Preview UI (@argent/ui) next to the bundled tool-server, where the /preview/
// endpoint finds it via __dirname. The externalised theme.css must ship with
// index.html — a partial copy 404s /preview/theme.css and serves an unstyled UI.
{
kind: "file",
src: UI_SRC,
dest: UI_DEST,
required: false,
copiedLabel: "preview UI",
missLabel: "Preview UI",
},
{
kind: "file",
src: UI_THEME_SRC,
dest: UI_THEME_DEST,
required: false,
copiedLabel: "preview UI theme.css",
missLabel: "Preview UI theme.css",
},
// Argent.tracetemplate so native-profiler-start (iOS) can find it at runtime.
{
kind: "file",
src: TRACE_TEMPLATE_SRC,
dest: TRACE_TEMPLATE_DEST,
required: false,
copiedLabel: "Argent.tracetemplate",
missLabel: "Argent.tracetemplate",
},
// Android profiler capture reads this via @argent/native-devtools-android's
// `traceConfigPath()`, which resolves `<pkg>/assets/argent.tracecfg.pbtxt` —
// i.e. this exact destination.
{
kind: "file",
src: TRACECFG_SRC,
dest: TRACECFG_DEST,
required: true,
copiedLabel: "argent.tracecfg.pbtxt",
missLabel: "argent.tracecfg.pbtxt",
hint: "This file is required for Android native profiling.",
},
// Android profiler SQL queries. traceProcessorQueriesDir() resolves
// `<pkg>/assets/queries/`, i.e. this exact destination.
{
kind: "dir",
src: QUERIES_SRC,
dest: QUERIES_DEST,
required: true,
copiedLabel: "SQL queries",
missLabel: "Android profiler queries directory",
countExt: ".sql",
hint: "This directory is required for native-profiler-analyze on Android.",
},
// Skills shipped on npm; the full directory structure is mirrored.
{
kind: "dir",
src: SKILLS_SRC,
dest: SKILLS_DEST,
required: false,
copiedLabel: "skill(s)",
missLabel: "Skills source",
count: (src) =>
fs.readdirSync(src, { withFileTypes: true }).filter((e) => e.isDirectory()).length,
},
// Rules shipped on npm.
{
kind: "dir",
src: RULES_SRC,
dest: RULES_DEST,
required: false,
copiedLabel: "rule(s)",
missLabel: "Rules source",
countExt: ".md",
},
// Agents shipped on npm.
{
kind: "dir",
src: AGENTS_SRC,
dest: AGENTS_DEST,
required: false,
copiedLabel: "agent(s)",
missLabel: "Agents source",
count: (src) =>
fs
.readdirSync(src, { withFileTypes: true })
.filter((e) => e.isFile() && e.name.endsWith(".md")).length,
},
];
/**
* @param {{ entry: string, out: string, format: "cjs" | "esm", label: string, external?: string[] }} opts
*/
function buildBundle({ entry, out, format, label, external = [] }) {
esbuild.buildSync({
entryPoints: [entry],
bundle: true,
platform: "node",
target: "node22",
format,
outfile: out,
alias: ALIASES,
mainFields: MAIN_FIELDS,
define: TELEMETRY_DEFINE,
// ESM bundles need the require() shim and must keep node: builtins external;
// CJS bundles externalise only what the caller passes.
...(format === "esm"
? { banner: ESM_REQUIRE_BANNER, external: [...new Set(["node:*", ...external])] }
: external.length > 0
? { external }
: {}),
});
console.log(`✓ Bundled ${label} → ${path.relative(process.cwd(), out)}`);
}
/**
* Count shown in the success line, or null when the asset isn't counted.
* @param {Asset} a
* @returns {number | null}
*/
function assetCount(a) {
if (a.count) return a.count(a.src);
if (a.countExt) {
const ext = a.countExt;
return fs.readdirSync(a.src).filter((f) => f.endsWith(ext)).length;
}
return null;
}
/**
* Copy one ASSETS entry, throwing or warning per its `required` flag.
* @param {Asset} a
*/
function copyAsset(a) {
if (!fs.existsSync(a.src)) {
if (a.required) {
throw new Error(`${a.missLabel} not found at ${a.src}.` + (a.hint ? `\n${a.hint}` : ""));
}
console.warn(`⚠ ${a.missLabel} not found at ${a.src} — skipping copy`);
return;
}
if (a.kind === "dir") {
if (a.clean) fs.rmSync(a.dest, { recursive: true, force: true });
fs.cpSync(a.src, a.dest, { recursive: true, filter: a.filter });
} else {
fs.mkdirSync(path.dirname(a.dest), { recursive: true });
fs.copyFileSync(a.src, a.dest);
}
if (a.mode != null) fs.chmodSync(a.dest, a.mode);
const rel = path.relative(process.cwd(), a.dest);
const count = assetCount(a);
if (count != null) {
console.log(`✓ Copied ${count} ${a.copiedLabel} → ${rel}`);
} else {
console.log(`✓ Copied ${a.copiedLabel} → ${rel}`);
}
}
/**
* Pinned Perfetto version from argent-private's PERFETTO_VERSION, falling back to
* the committed constant in bundled-meta.ts when the submodule isn't checked out.
*/
function readPerfettoVersion() {
try {
const v = fs.readFileSync(PERFETTO_VERSION_FILE, "utf8").trim();
if (v) return v;
} catch {
/* submodule absent — fall through to committed constant */
}
try {
const existing = fs.readFileSync(BUNDLED_META_DEST, "utf8");
const m = existing.match(/PERFETTO_VERSION\s*=\s*"([^"]+)"/);
if (m) return m[1];
} catch {
/* committed file missing — final fallback below */
}
return "v55.3";
}
/**
* Regenerate bundled-meta.ts so it pins the Perfetto version stamping the
* bundled trace_processor.wasm.
*/
function generateBundledMeta() {
const version = readPerfettoVersion();
const content = `// GENERATED by packages/argent/scripts/bundle-tools.cjs, which overwrites this
// file from argent-private's PERFETTO_VERSION; the committed value is a fallback
// so builds without that submodule still compile. Informational only — it stamps
// the bundled \`trace_processor.wasm\`, nothing resolves against it.
export const PERFETTO_VERSION = "${version}";
`;
fs.writeFileSync(BUNDLED_META_DEST, content);
console.log(`✓ Generated bundled-meta.ts (perfetto=${version})`);
}
// Must run before esbuild, so PERFETTO_VERSION is inlined into the bundles.
generateBundledMeta();
// Purge artifact directories so stale files don't survive across builds. Derived
// from the table so it can't drift.
const PURGE_DIRS = [BIN_DIR, ...ASSETS.filter((a) => a.kind === "dir").map((a) => a.dest)];
for (const dir of PURGE_DIRS) {
fs.rmSync(dir, { recursive: true, force: true });
fs.mkdirSync(dir, { recursive: true });
}
// The `argent-simulator-server` dispatcher npm's `bin` field publishes; it picks
// the right per-platform binary at invocation time. Source lives in scripts/ so
// it isn't entangled with the gitignored bundle output under bin/.
const DISPATCHER_SRC = path.resolve(__dirname, "argent-simulator-server.cjs");
const DISPATCHER_DEST = path.resolve(BIN_DIR, "argent-simulator-server.cjs");
fs.copyFileSync(DISPATCHER_SRC, DISPATCHER_DEST);
fs.chmodSync(DISPATCHER_DEST, 0o755);
// The Android manifest is a single file under assets/, outside PURGE_DIRS, so
// remove it explicitly: a stale manifest would fool helperManifest() into
// pointing at an APK that's no longer present.
fs.rmSync(ANDROID_MANIFEST_DEST, { force: true });
fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true });
// tree-sitter / tree-sitter-typescript are native addons (.node) esbuild cannot
// inline; external so the bundle `require()`s them at runtime from the published
// package's own dependencies.
//
// `electron` MUST stay external. Inlined, electron's binary-path lookup runs
// with __dirname = <install>/dist (no path.txt there) and throws "Electron
// failed to install correctly" at eval time. External keeps the runtime
// `require("electron")` in preview-window.ts resolving against the installed
// node_modules/electron with an intact __dirname.
//
// `@fails-components/webtransport` and its http3-quiche transport ship native
// addons (quiche, prebuilt .node binaries) that can't be inlined either; they
// are declared on @swmansion/argent, so external resolves them from
// node_modules/ at runtime.
//
// `dtrace-provider` MUST stay external. It is an OPTIONAL dependency of bunyan
// (the tool-server event-log logger), which hides it from bundlers with
// `require('dtrace-provider' + '')` in a try/catch — a failed require just
// disables the unused DTrace USDT probes. esbuild constant-folds that string
// back to a literal and then chokes on dtrace-provider's own dynamic native
// binding require. External restores bunyan's intent: the published package
// never declares it, so the require misses and bunyan nulls it out.
buildBundle({
entry: TOOLS_ENTRY,
out: OUT_FILE,
format: "cjs",
label: "tools server",
external: [
"tree-sitter",
"tree-sitter-typescript",
"electron",
"@fails-components/webtransport",
"@fails-components/webtransport-transport-http3-quiche",
"dtrace-provider",
],
});
// The remaining bundles are ESM so that:
// - installer: import.meta.dirname works at runtime (the dispatcher
// lazy-imports it, so its workspace dep needn't resolve in the published pkg);
// - MCP server: the @modelcontextprotocol/sdk ESM export paths resolve.
const ESM_BUNDLES = [
{ entry: INSTALLER_ENTRY, out: INSTALLER_OUT_FILE, label: "installer" },
{ entry: MCP_ENTRY, out: MCP_OUT_FILE, label: "MCP server" },
// node-pty is a native addon `argent lens` loads at runtime (the agent PTY
// proxy); esbuild can't inline a .node. Absent install → loadNodePty() returns
// null → lens falls back to a new terminal window.
{ entry: CLI_ENTRY, out: CLI_OUT_FILE, label: "CLI commands", external: ["node-pty"] },
];
for (const b of ESM_BUNDLES) {
buildBundle({ ...b, format: "esm" });
}
for (const a of ASSETS) {
copyAsset(a);
}
// `electron` stays external here too — tool-server's preview-window.ts resolves
// the executable via `require("electron")`. The bundled main.cjs lands next to
// the tool-server bundle so that same helper finds it at
// `path.join(__dirname, "preview-window", "main.cjs")`, without needing
// @argent/preview-window to be a published sibling pkg.
fs.mkdirSync(path.dirname(PREVIEW_WINDOW_OUT_FILE), { recursive: true });
buildBundle({
entry: PREVIEW_WINDOW_ENTRY,
out: PREVIEW_WINDOW_OUT_FILE,
format: "cjs",
label: "preview-window main",
external: ["electron", "node:*"],
});
// Require the darwin binary only when bundling ON darwin (the publish pipeline):
// a Linux contributor running `npm run pack` can't produce the macOS binary, so
// don't block them on its absence.
for (const platform of SUPPORTED_HOST_PLATFORMS) {
const binaryFile = simulatorServerFileName(platform);
const src = path.join(BIN_SRC_ROOT, platform, binaryFile);
const destDir = path.join(BIN_DIR, platform);
const dest = path.join(destDir, binaryFile);
if (fs.existsSync(src)) {
fs.mkdirSync(destDir, { recursive: true });
fs.copyFileSync(src, dest);
fs.chmodSync(dest, 0o755);
console.log(`✓ Copied simulator-server (${platform}) → ${path.relative(process.cwd(), dest)}`);
} else if (platform === "darwin" && process.platform === "darwin") {
throw new Error(
`simulator-server binary not found at ${src}.\n` +
`Run: bash scripts/download-simulator-server.sh`
);
} else {
console.warn(`⚠ simulator-server (${platform}) not found at ${src} — skipping`);
}
}
// Screen-sharing agent resources (host-independent jar + per-ABI .so the
// `android_device` controller pushes to a connected phone over adb): one shared
// copy at the bin root; simulatorServerRunDir() points the spawn cwd here.
// Optional: physical-device support degrades without them.
const RESOURCES_SRC = path.join(BIN_SRC_ROOT, "resources");
if (fs.existsSync(RESOURCES_SRC)) {
fs.cpSync(RESOURCES_SRC, path.join(BIN_DIR, "resources"), { recursive: true });
console.log(
`✓ Copied screen-sharing agent resources → ${path.relative(process.cwd(), BIN_DIR)}/resources`
);
} else {
console.warn(`⚠ screen-sharing agent resources not found at ${RESOURCES_SRC} — skipping`);
}
// The Android helper APK is the only Android native artifact left (the trace
// processor ships as WASM under assets/trace-processor/). Its filename is
// version-stamped from manifest.json's versionName (see bundledHelperApkPath());
// it is required at runtime, so a missing one throws.
const manifest = JSON.parse(fs.readFileSync(ANDROID_MANIFEST_SRC, "utf8"));
const apkName = `argent-android-devtools-${manifest.versionName}.apk`;
const apkSrc = path.join(ANDROID_APK_SRC_DIR, apkName);
if (fs.existsSync(apkSrc)) {
fs.copyFileSync(apkSrc, path.join(BIN_DIR, apkName));
console.log(`✓ Copied Android helper APK → ${path.relative(process.cwd(), BIN_DIR)}/${apkName}`);
} else {
throw new Error(
`Android helper APK not found at ${apkSrc}.\n` +
`Run: bash scripts/download-native-binaries.sh (fetches from argent-private-releases)\n` +
`or: bash packages/native-devtools-android/scripts/build.sh`
);
}