mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
0a6dbec87c
## What
Swaps the `posthog-node` transport for an OpenTelemetry Logs exporter.
Everything above the transport stays as it is - opt-out consent,
per-machine identity and fingerprint, the sanitizer allowlist, the typed
event surface, crash diagnostics. Only the ~3-method client boundary
changes.
One analytics event becomes one OTLP/HTTP log record:
| PostHog | OpenTelemetry |
| --- | --- |
| `capture({ event, distinctId, properties })` | `logger.emit({ body:
event, attributes: { distinct_id, "event.name", ...props } })` |
| event name | log-record `body` |
| `distinctId` | `distinct_id` attribute |
| event/base properties | log-record attributes |
| `client.shutdown()` | `LoggerProvider.shutdown()` (force-flush +
teardown) |
Records go to a hard-coded Software Mansion collector. The endpoint and
the authorization header are passed to the exporter explicitly in code,
and an explicit value beats the corresponding `OTEL_EXPORTER_OTLP_*`
variable - the same anti-exfiltration property the fixed PostHog host
had.
## Changes
- `packages/telemetry/src/posthog.ts` becomes `otel.ts`:
`LoggerProvider` + `BatchLogRecordProcessor` + `OTLPLogExporter`
(HTTP/JSON). Queue size 20 and a 10 s flush interval mirror the old
client; the per-export deadline is 1.5 s, kept at or below `index.ts`'s
drain budget so a stalled export can't hold a short-lived command open.
- Dependencies: drop `posthog-node`, add
`@opentelemetry/{api,api-logs,sdk-logs,exporter-logs-otlp-http,resources}`.
They inline cleanly into the esbuild bundles.
- Build-time ingest token via esbuild `define`
(`ARGENT_OTEL_INGEST_TOKEN`), same mechanism as `ARGENT_CLI_VERSION`.
Unset in a dev build leaves the client unconstructed and telemetry
inert.
- Base props: drop `$process_person_profile` (a PostHog directive,
meaningless to a collector) and rename `$session_id` to `session_id`.
Null-valued attributes are dropped, since OTel rejects them -
`cloud_agent` is null on the common path, and absence means the same
thing.
- `Telemetry.md`: the "who we disclose to" and "international transfers"
sections describe a Software Mansion-operated OTLP collector instead of
PostHog as a third-party processor.
## A hang this uncovered
`timeoutMillis` only bounds a request once its socket is connected,
because the exporter applies it with `req.setTimeout()`. Against a
collector address that drops packets rather than refusing them - a
corporate egress filter, a dead host behind a firewall - the socket sits
in the connecting state where no export deadline reaches it, and the
process stays alive for the OS connect timeout: 75 s past a `shutdown()`
that had already resolved at 1.75 s.
Pairing the export deadline with an agent-level socket timeout, armed at
socket creation so it covers connection establishment too, fixes it:
| collector state | before | after |
| --- | --- | --- |
| healthy | 0.18 s, delivered | 0.18 s, delivered |
| connection refused | 0.94 s | 1.03 s |
| blackholed (http and https) | 75.0 s | 1.6 s |
| connected but never responds | - | 1.6 s |
`posthog-node` exited in 10.6 s under the same blackhole (undici aborts
a connecting socket), so this was a regression as well as a hang. A unit
test that fails without the fix guards it.
## Found in review
Each of these was reproduced before it was changed, and each is pinned
by a test that fails without the fix.
- **The environment's OTLP headers were forwarded to the collector.**
The SDK merges `OTEL_EXPORTER_OTLP_HEADERS` / `_LOGS_HEADERS` into every
request, keeping any key the code does not set itself. Out in the world
that variable holds the developer's *own* observability credential - a
Honeycomb team key, a Dynatrace `Api-Token`, Grafana Cloud basic auth -
so any machine already running OpenTelemetry shipped that third-party
secret here on every batch, without it passing the sanitizer or
appearing in `Telemetry.md`. Reproduced by watching `x-honeycomb-team:
hcaik_…` arrive at a capture server. The variables are now cleared
across the exporter constructor (where the SDK resolves its header set)
and restored after.
- **`httpAgentOptions` silently disabled connection reuse.** Supplying
it replaces the agent the SDK would build, whose default is `keepAlive:
true`. Measured on loopback: three batches shared one socket by default,
and opened three sockets with `httpAgentOptions` - a fresh TCP+TLS
handshake per 10 s batch in the long-lived tool-server. `keepAlive` is
restated alongside the timeout.
- **`markDisabled()`'s drain had no grace period.** It raced flat
against `SHORT_FLUSH_TIMEOUT_MS` while `shutdown()` allowed `timeoutMs +
250`. Since the exporter's own deadline equals that budget, the flat
race fired at the same instant and always abandoned the batch instead of
letting it finish. Both call sites share one `raceDrain()` now.
- **Export failures reached nobody.** The batch processor hands errors
to OpenTelemetry's global handler, so `LoggerProvider.shutdown()`
resolves identically for a delivered batch and a rejected token - making
`shutdown()`'s `catch` unreachable. The SDK's diagnostics are now routed
into the existing debug channel under `ARGENT_TELEMETRY_DEBUG=1`; a run
without the flag stays silent so no host application's diag logger is
disturbed.
- **The build-time token now beats the `__ARGENT_OTEL_TOKEN_TEST` seam**
rather than losing to it, so the seam reaches unbundled source only.
Verified with esbuild across all three build modes.
- **The endpoint guard test mocked the exporter it guards.** It could
only show that the code passes a `url`; an SDK upgrade that changed the
precedence would have kept it green. `otel-endpoint-live.test.ts` drives
the real exporter against loopback servers - dropping the explicit
`url`, the explicit `authorization`, or the header clearing each fails
it.
- **The wire shape had no pins at all.** Bypassing the sanitizer
entirely, sending an explicit `null`, renaming `service.name` and
dropping `event.name` all left the suite green. Four tests now fail on
those mutations. The sanitizer bypass was unpinned on `main` too.
## Before this ships
1. ~~**Collector URL**~~ - done in 8bca962f. `OTLP_LOGS_ENDPOINT` is
`https://argent-otel.swmansion.com/v1/logs`. That host serves OTLP as of
2026-08-12: a `POST` answers the OTLP-shaped `401`
(`{"code":16,"message":"provided authorization does not match expected
scheme or token"}`), where it previously `301`d to `/v1/logs/` and
`404`d there. It is a distinct host from the test collector
(`167.235.245.1` vs `46.224.172.171`, its own certificate).
2. **Ingest token** - `ARGENT_OTEL_INGEST_TOKEN` has to be set in the
release build environment. Nothing under `.github/` sets it today, so
every published artifact resolves an empty token and stays inert -
silently: with no token the client is never constructed, so a run emits
nothing and writes no `telemetry-debug.log` even under
`ARGENT_TELEMETRY_DEBUG=1`. It must be the **production** host's token:
`argent-otel.swmansion.com` runs its own token list and rejects the test
collector's.
3. **IP geolocation** - PostHog's `disableGeoip: true` set
`$geoip_disable` on every event, which is what stopped location being
derived from the source IP. There is no in-repo equivalent for a
collector, so that suppression has to be configured collector-side to
keep `Telemetry.md`'s "what we collect" list accurate.
4. **Legal** - the privacy-notice edits and the version/date bump to
1.03 are drafted to match the new architecture, but Legal should review
and finalize them. Note the notice actually served at
`TELEMETRY_DETAILS_URL` is still Version 1.01 and names PostHog as the
processor.
## Verification
Unit and integration:
- `packages/telemetry`: 293 tests pass, including the rewritten
`index.test.ts`, `otel-endpoint.test.ts` and the new
`otel-endpoint-live.test.ts`.
- Every workspace suite green on the merged branch: argent-cli 276,
argent-installer 535, argent-mcp 78, argent 60, tools-client 178,
configuration-core 83, registry 87, tool-server 3070, update-core 31,
archive 11.
- `bundle-tools.cjs` leaves no `posthog` in any of the four bundles.
OTel is inlined into the three that emit - `cli-cmds.mjs`,
`installer.mjs`, `tool-server.cjs` - each carrying the endpoint and the
token define as string literals. `mcp-server.mjs` carries none: it is a
stdio shim over the tool-server, which is the process that reports.
- `lint`, `prettier --check`, `tsc --build`, the `tsconfig.test.json`
gate and `npm install --package-lock-only` (idempotent on Node 24) all
clean.
675 lines
29 KiB
JavaScript
675 lines
29 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, "../../..");
|
|
|
|
// esbuild entry points (source) and bundle outputs.
|
|
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");
|
|
|
|
// Shared aliases so each bundle resolves workspace deps from source. Resolving
|
|
// from source (rather than each package's compiled dist/) keeps the bundle
|
|
// independent of build order/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: the CLI version stamped into every
|
|
// event, and the OTLP ingest token used to authenticate against the collector.
|
|
// The token is read from the environment at release time (ARGENT_OTEL_INGEST_TOKEN);
|
|
// an unset/dev build defines it as "", 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),
|
|
};
|
|
|
|
// esbuild on platform:"node" defaults mainFields to ["main","module"], which
|
|
// picks UMD entries that use runtime `require("./impl/...")`. Those requires
|
|
// can't be statically resolved during bundling and fail at runtime because
|
|
// the impl/ folder isn't shipped next to the bundle. Prefer "module" so we
|
|
// pick ESM entries with static imports that get fully inlined. Affects e.g.
|
|
// jsonc-parser, which ships both UMD (main) and ESM (module).
|
|
const MAIN_FIELDS = ["module", "main"];
|
|
|
|
// Banner injected into ESM bundles so any inlined CJS dependencies that rely on
|
|
// the CJS context work: `require()`, plus `__dirname`/`__filename` (e.g.
|
|
// @argent/native-devtools-ios resolves the simulator-server binary dir relative
|
|
// to `__dirname`, which is undefined in an ESM module without this shim). The
|
|
// shimmed values point at the bundle's own location, matching how the CJS
|
|
// tool-server bundle resolves them natively.
|
|
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);",
|
|
};
|
|
|
|
// ── Asset source/destination paths ─────────────────────────────────────────
|
|
// Source layout mirrors what `scripts/download-simulator-server.sh` writes:
|
|
// platform-keyed subdirectories of bin/, each containing one simulator-server
|
|
// binary. ax-service is macOS-only (it spawns inside an iOS Simulator), so it
|
|
// only lives under darwin/.
|
|
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");
|
|
// The tcp ax-service lives in a platform-NEUTRAL bin/tcp/ (not bin/darwin/tcp/):
|
|
// it 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 are macOS-only: tvos-ax-service runs inside an
|
|
// appletvsimulator, tvos-hid-daemon runs on the host. 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` (named simulator-server.exe, not simulator-server).
|
|
const SUPPORTED_HOST_PLATFORMS = ["darwin", "linux", "linux-arm64", "win32"];
|
|
// The simulator-server file name per host key — extensionless everywhere
|
|
// except Windows, where it's a `.exe`.
|
|
const simulatorServerFileName = (platform) =>
|
|
platform === "win32" ? "simulator-server.exe" : "simulator-server";
|
|
// Generated module pinning the Perfetto version that stamps the bundled
|
|
// trace_processor.wasm. esbuild inlines it into every bundle via the
|
|
// @argent/native-devtools-android alias.
|
|
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");
|
|
|
|
// ── Asset table ─────────────────────────────────────────────────────────────
|
|
// Declarative copy plan. Each entry is copied (or its absence reported) by
|
|
// copyAsset() below. `required: true` throws on a missing source; otherwise
|
|
// copyAsset warns and skips. The per-asset required/optional decision is the
|
|
// whole point of keeping this table explicit.
|
|
//
|
|
// 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]
|
|
*/
|
|
/** @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. Best-effort: 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 — spawned inside an appletvsimulator via simctl to read
|
|
// the focus-engine accessibility tree. macOS-only, unix-socket transport.
|
|
{
|
|
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. macOS-only, unix-socket transport.
|
|
{
|
|
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 + the EngineBase decoder + LICENSE).
|
|
// wasm-trace-processor.ts resolves these via
|
|
// `path.join(__dirname, "..", "assets", "trace-processor")` — in the bundled
|
|
// tool-server.cjs that is `<pkg>/assets/trace-processor/`, i.e. this exact
|
|
// destination. Not committed; fetched + sha256-verified at pack time by
|
|
// scripts/download-trace-processor.sh, so this copy is purely local.
|
|
{
|
|
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",
|
|
},
|
|
// Android helper manifest.json. Required: helperManifest()/bundledHelperApkPath()
|
|
// read it at runtime, and the version-stamped APK filename is derived from its
|
|
// versionName (see the dedicated 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 so that tool-server's
|
|
// /preview/ endpoint can locate it via __dirname lookup. index.html AND its
|
|
// externalised theme.css must both ship — a partial copy would 404
|
|
// /preview/theme.css and serve 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",
|
|
},
|
|
// argent.tracecfg.pbtxt so the Android profiler capture step can find it at
|
|
// runtime. capture.ts resolves the path via @argent/native-devtools-android's
|
|
// `traceConfigPath()`, which does
|
|
// `path.join(__dirname, "..", "assets", "argent.tracecfg.pbtxt")` — in the
|
|
// bundled tool-server.cjs that is `<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. run-tp.ts resolves QUERY_DIR via
|
|
// `path.join(__dirname, "..", "assets", "queries")` — in the bundled
|
|
// tool-server.cjs that is `<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. Mirrors the full directory structure from
|
|
// packages/skills/skills/ (e.g. metro-debugger/SKILL.md,
|
|
// metro-debugger/references/source-maps.md, …).
|
|
{
|
|
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,
|
|
},
|
|
];
|
|
|
|
/**
|
|
* Bundle a single entry point with esbuild and log the result.
|
|
* @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 (for inlined CJS deps) and must keep
|
|
// node: builtins external; CJS bundles only externalise what the caller
|
|
// passes (e.g. `electron`, or tree-sitter's native addons — see the
|
|
// tools-server call site).
|
|
...(format === "esm"
|
|
? { banner: ESM_REQUIRE_BANNER, external: [...new Set(["node:*", ...external])] }
|
|
: external.length > 0
|
|
? { external }
|
|
: {}),
|
|
});
|
|
console.log(`✓ Bundled ${label} → ${path.relative(process.cwd(), out)}`);
|
|
}
|
|
|
|
/**
|
|
* Resolve the count shown in the success line, or null when the asset isn't
|
|
* counted (`count` takes precedence over `countExt`).
|
|
* @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") {
|
|
fs.cpSync(a.src, a.dest, { recursive: true });
|
|
} 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}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read the pinned Perfetto version from argent-private's PERFETTO_VERSION (the
|
|
* same file CI builds the engine against), falling back to the committed
|
|
* constant in bundled-meta.ts when the submodule isn't checked out (dev/source
|
|
* builds). Stamps the bundled trace_processor.wasm so the marker and the engine
|
|
* always agree.
|
|
*/
|
|
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 that stamps the
|
|
* bundled trace_processor.wasm. The version comes from argent-private's
|
|
* PERFETTO_VERSION (or the committed fallback). Since the engine is now a single
|
|
* cross-platform WASM artifact, there is no per-platform marker to emit.
|
|
*/
|
|
function generateBundledMeta() {
|
|
const version = readPerfettoVersion();
|
|
const content = `// GENERATED at pack time by packages/argent/scripts/bundle-tools.cjs.
|
|
//
|
|
// Committed with a sane fallback value so dev / source builds (and the
|
|
// submodule-less checkout) still compile; the pack step overwrites this file
|
|
// with the Perfetto version pinned in argent-private's PERFETTO_VERSION. esbuild
|
|
// inlines it into every published bundle. The trace-processor engine is now a
|
|
// single cross-platform WASM artifact (no per-platform binary), fetched +
|
|
// sha256-verified at pack time, so this version is purely informational — it
|
|
// stamps the bundled \`trace_processor.wasm\` — and JS<->engine version skew is
|
|
// structurally impossible.
|
|
|
|
export const PERFETTO_VERSION = "${version}";
|
|
`;
|
|
fs.writeFileSync(BUNDLED_META_DEST, content);
|
|
console.log(`✓ Generated bundled-meta.ts (perfetto=${version})`);
|
|
}
|
|
|
|
// Generate the bundled marker BEFORE esbuild runs, so the constants get inlined
|
|
// into tool-server.cjs / installer.mjs / etc.
|
|
generateBundledMeta();
|
|
|
|
// Purge artifact directories so stale files don't survive across builds. Derived
|
|
// from the table (every dir-kind destination, plus BIN_DIR) 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 });
|
|
}
|
|
|
|
// Copy the hand-written `argent-simulator-server` dispatcher into bin/.
|
|
// It's the file npm's `bin` field publishes; its job is to pick 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 lives outside PURGE_DIRS (it's a single file under
|
|
// assets/), so remove it explicitly so a stale manifest can't fool
|
|
// helperManifest() into pointing at an APK that's no longer present.
|
|
// The APK itself goes into BIN_DIR which is already fully purged above.
|
|
fs.rmSync(ANDROID_MANIFEST_DEST, { force: true });
|
|
|
|
// Ensure dist/ exists
|
|
fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true });
|
|
|
|
// Bundle the tools server (CJS — the dispatcher loads it via require()).
|
|
//
|
|
// tree-sitter / tree-sitter-typescript are native addons (.node) that esbuild
|
|
// cannot inline; keep them external so the bundle `require()`s them at runtime
|
|
// from the published package's own dependencies (see package.json).
|
|
//
|
|
// `electron` MUST stay external too. It is a runtime dependency of
|
|
// @swmansion/argent (npm installs it into node_modules/electron). If esbuild
|
|
// inlines electron's index.js into this bundle, electron's binary-path lookup
|
|
// runs with __dirname = <install>/dist (no path.txt there) and throws
|
|
// "Electron failed to install correctly" at eval time. That throw is swallowed
|
|
// by preview-window.ts's try/catch, so the variant-selection window silently
|
|
// never opens. Keeping it external means the runtime `require("electron")` in
|
|
// preview-window.ts resolves against the real node_modules/electron with an
|
|
// intact __dirname. (The preview-window bundle below externalises it too.)
|
|
//
|
|
// `@fails-components/webtransport` and its http3-quiche transport ship native
|
|
// addons (quiche, prebuilt .node binaries) that can't be inlined either. They
|
|
// are declared in @swmansion/argent's dependencies so npm installs them
|
|
// alongside the package; keep them external so the bundle 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). bunyan loads it defensively as
|
|
// `require('dtrace-provider' + '')` wrapped in try/catch — the `+ ''` is a
|
|
// deliberate trick to hide the module from bundlers, and a failed require just
|
|
// disables the (unused) DTrace USDT probes. esbuild constant-folds that string
|
|
// back to a literal, defeating the trick, and then chokes on dtrace-provider's
|
|
// own dynamic native binding require (`require('./src/build/'+build+'/…')`).
|
|
// Keeping it external restores bunyan's intent: the published package never
|
|
// declares dtrace-provider, so the runtime require misses and bunyan's
|
|
// try/catch nulls it out — no DTrace probes, no functional impact.
|
|
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;
|
|
// - CLI commands: the tools/run/server subcommands share the same toolchain.
|
|
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, so keep it external — the CLI bundle
|
|
// `require()`s it from the published package's optional dependency. Absent
|
|
// install → loadNodePty() returns null → lens falls back to a new window.
|
|
{ entry: CLI_ENTRY, out: CLI_OUT_FILE, label: "CLI commands", external: ["node-pty"] },
|
|
];
|
|
for (const b of ESM_BUNDLES) {
|
|
buildBundle({ ...b, format: "esm" });
|
|
}
|
|
|
|
// Copy all declared assets.
|
|
for (const a of ASSETS) {
|
|
copyAsset(a);
|
|
}
|
|
|
|
// Bundle the Electron preview-window main entrypoint. CJS, with `electron`
|
|
// externalised — the dependency is declared on @swmansion/argent so npm
|
|
// installs it alongside, and the spawn helper in tool-server's
|
|
// preview-window.ts resolves the executable via `require("electron")`.
|
|
// The bundled main.cjs lives next to the tool-server bundle so the same
|
|
// helper can find it via `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:*"],
|
|
});
|
|
|
|
// Copy simulator-server for every supported host platform that's present in
|
|
// the staging area. Require the darwin binary only when bundling ON darwin
|
|
// (the publish pipeline) — a Linux contributor running `npm run pack` locally
|
|
// 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)}`);
|
|
// Ship the screen-sharing agent resources (jar + per-ABI .so) that the
|
|
// `android_device` controller pushes to a connected phone. They sit at
|
|
// <platform>/resources/android/ next to the binary (the simulator-server
|
|
// resolves them relative to its working directory, which the spawn sets to
|
|
// its own platform dir). download-simulator-server.sh extracts them there;
|
|
// optional because physical-device support degrades gracefully without them.
|
|
const resourcesSrc = path.join(BIN_SRC_ROOT, platform, "resources");
|
|
if (fs.existsSync(resourcesSrc)) {
|
|
const resourcesDest = path.join(destDir, "resources");
|
|
fs.cpSync(resourcesSrc, resourcesDest, { recursive: true });
|
|
console.log(
|
|
`✓ Copied screen-sharing agent resources (${platform}) → ${path.relative(process.cwd(), resourcesDest)}`
|
|
);
|
|
}
|
|
} 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`);
|
|
}
|
|
}
|
|
|
|
// Copy the Android helper APK into bin/ (the only Android native artifact left —
|
|
// the trace processor now ships as WASM under assets/trace-processor/). Runs
|
|
// after the copy loop, so dropping into the already-purged-and-repopulated
|
|
// BIN_DIR is safe. Its filename is version-stamped from manifest.json's
|
|
// versionName (see bundledHelperApkPath()); the APK 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`
|
|
);
|
|
}
|