peachsim 0.1.527
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +640 -0
- package/cli/app-config.ts +82 -0
- package/cli/app-fonts.ts +388 -0
- package/cli/app-project.ts +59 -0
- package/cli/app-splash.ts +171 -0
- package/cli/app-state-reset.ts +26 -0
- package/cli/auth.ts +184 -0
- package/cli/bin.ts +59 -0
- package/cli/bridge-diagnostics.ts +226 -0
- package/cli/bridge-flow-runner.ts +2806 -0
- package/cli/browser-backend.ts +44 -0
- package/cli/browser-evals.ts +96 -0
- package/cli/cli-update.ts +261 -0
- package/cli/cloud-client.ts +1530 -0
- package/cli/cloud-dispatch.ts +206 -0
- package/cli/cloud-session.ts +227 -0
- package/cli/command-contract.ts +221 -0
- package/cli/command-registry.ts +304 -0
- package/cli/commands/agent-wrapper.ts +1048 -0
- package/cli/commands/agent.ts +439 -0
- package/cli/commands/app-fonts.ts +99 -0
- package/cli/commands/assert.ts +542 -0
- package/cli/commands/auth.ts +71 -0
- package/cli/commands/box/bundle-plane.ts +71 -0
- package/cli/commands/box/checkout-plane.ts +256 -0
- package/cli/commands/box/plane-share.ts +208 -0
- package/cli/commands/box/rnx-command.ts +60 -0
- package/cli/commands/box/search-command.ts +157 -0
- package/cli/commands/box.ts +925 -0
- package/cli/commands/camera.ts +266 -0
- package/cli/commands/cleanup.ts +169 -0
- package/cli/commands/compat.ts +176 -0
- package/cli/commands/config.ts +34 -0
- package/cli/commands/control.ts +2154 -0
- package/cli/commands/cpu-profile.ts +941 -0
- package/cli/commands/daemon-mac-app.ts +165 -0
- package/cli/commands/daemon.ts +800 -0
- package/cli/commands/debug.ts +751 -0
- package/cli/commands/desktop.ts +39 -0
- package/cli/commands/detect.ts +201 -0
- package/cli/commands/detox.ts +481 -0
- package/cli/commands/device.ts +134 -0
- package/cli/commands/diagnose.ts +589 -0
- package/cli/commands/do-chain.ts +408 -0
- package/cli/commands/electron.ts +94 -0
- package/cli/commands/film.ts +379 -0
- package/cli/commands/flow.ts +1240 -0
- package/cli/commands/inspect/actions.ts +516 -0
- package/cli/commands/inspect/core.ts +2086 -0
- package/cli/commands/inspect/count.ts +17 -0
- package/cli/commands/inspect/describe.ts +119 -0
- package/cli/commands/inspect/env.ts +25 -0
- package/cli/commands/inspect/find.ts +169 -0
- package/cli/commands/inspect/get-layout.ts +53 -0
- package/cli/commands/inspect/keyboard.ts +54 -0
- package/cli/commands/inspect/list.ts +175 -0
- package/cli/commands/inspect/memory.ts +215 -0
- package/cli/commands/inspect/redaction.ts +39 -0
- package/cli/commands/inspect/resolve-target.ts +77 -0
- package/cli/commands/inspect/screens.ts +78 -0
- package/cli/commands/inspect/settle.ts +32 -0
- package/cli/commands/inspect/settling.ts +197 -0
- package/cli/commands/inspect/shared.ts +259 -0
- package/cli/commands/inspect/sleep.ts +15 -0
- package/cli/commands/inspect/tree.ts +23 -0
- package/cli/commands/inspect/url.ts +17 -0
- package/cli/commands/inspect/wait-event.ts +211 -0
- package/cli/commands/inspect/wait-idle.ts +25 -0
- package/cli/commands/inspect/wait-ready.ts +87 -0
- package/cli/commands/inspect/wait-selector.ts +59 -0
- package/cli/commands/inspect/wait.ts +32 -0
- package/cli/commands/inspect.ts +4738 -0
- package/cli/commands/install-cli.ts +275 -0
- package/cli/commands/install-desktop.ts +385 -0
- package/cli/commands/login.ts +357 -0
- package/cli/commands/logout.ts +24 -0
- package/cli/commands/maestro-generate.ts +375 -0
- package/cli/commands/maestro.ts +536 -0
- package/cli/commands/mode.ts +58 -0
- package/cli/commands/no-bridge-hint.ts +80 -0
- package/cli/commands/perf.ts +67 -0
- package/cli/commands/permissions.ts +203 -0
- package/cli/commands/platform.ts +410 -0
- package/cli/commands/preview.ts +103 -0
- package/cli/commands/profile.ts +106 -0
- package/cli/commands/react.ts +353 -0
- package/cli/commands/record.ts +1435 -0
- package/cli/commands/report-issue.ts +305 -0
- package/cli/commands/reset.ts +89 -0
- package/cli/commands/runtime.ts +348 -0
- package/cli/commands/screenshot-command.ts +106 -0
- package/cli/commands/screenshot-layers.ts +144 -0
- package/cli/commands/screenshot-mode.ts +38 -0
- package/cli/commands/screenshot.ts +492 -0
- package/cli/commands/screenshots-capture.ts +608 -0
- package/cli/commands/screenshots.ts +127 -0
- package/cli/commands/serve.ts +139 -0
- package/cli/commands/setup.ts +416 -0
- package/cli/commands/shell-boolean-mode.ts +82 -0
- package/cli/commands/skills.ts +468 -0
- package/cli/commands/slides.ts +357 -0
- package/cli/commands/state.ts +94 -0
- package/cli/commands/storage.ts +58 -0
- package/cli/commands/telemetry.ts +55 -0
- package/cli/commands/test.ts +535 -0
- package/cli/commands/three-mode.ts +771 -0
- package/cli/commands/timeline.ts +121 -0
- package/cli/commands/upgrade.ts +167 -0
- package/cli/commands/upload.ts +1203 -0
- package/cli/commands/version.ts +63 -0
- package/cli/commands/welcome.ts +17 -0
- package/cli/commands/what-happened.ts +328 -0
- package/cli/current-sim.ts +217 -0
- package/cli/desktop-companion.ts +315 -0
- package/cli/drivers/electron.ts +70 -0
- package/cli/drivers/index.ts +20 -0
- package/cli/drivers/playwright-provisioning.ts +184 -0
- package/cli/drivers/playwright-sim-host.ts +537 -0
- package/cli/drivers/playwright.ts +209 -0
- package/cli/drivers/registry.ts +65 -0
- package/cli/drivers/types.ts +102 -0
- package/cli/flow-export.ts +55 -0
- package/cli/flow-file.ts +142 -0
- package/cli/flow-live-status.ts +126 -0
- package/cli/flow-session.ts +140 -0
- package/cli/help-core.ts +143 -0
- package/cli/help.ts +44 -0
- package/cli/hidden-runtime-alias.ts +19 -0
- package/cli/hints.ts +214 -0
- package/cli/inspect-notice-state.ts +114 -0
- package/cli/internal-child.ts +176 -0
- package/cli/maestro-js.ts +323 -0
- package/cli/main.ts +704 -0
- package/cli/open-url.ts +8 -0
- package/cli/outbound-endpoints.ts +830 -0
- package/cli/parent-pid.ts +204 -0
- package/cli/parse-args.ts +189 -0
- package/cli/privacy.ts +53 -0
- package/cli/prompt.ts +56 -0
- package/cli/recording-access.ts +108 -0
- package/cli/registry.ts +2 -0
- package/cli/resolve-assets.ts +63 -0
- package/cli/rnx-in-process.ts +98 -0
- package/cli/run-registry.ts +226 -0
- package/cli/run-rnx-core.ts +148 -0
- package/cli/run-rnx.ts +137 -0
- package/cli/runtime-notes.ts +66 -0
- package/cli/runtime-summary.ts +25 -0
- package/cli/secure-file.ts +31 -0
- package/cli/self-invocation.ts +58 -0
- package/cli/send-to-box.ts +109 -0
- package/cli/setup-repository.ts +191 -0
- package/cli/shell-init.ts +98 -0
- package/cli/standalone.ts +4 -0
- package/cli/telemetry.ts +84 -0
- package/cli/ui.ts +68 -0
- package/cli/ws-bridge.ts +1020 -0
- package/detox/attempt-events.cjs +23 -0
- package/detox/colors.ts +54 -0
- package/detox/config-loader.ts +135 -0
- package/detox/element-types.ts +48 -0
- package/detox/expectations.ts +534 -0
- package/detox/gestures.ts +501 -0
- package/detox/index.ts +2694 -0
- package/detox/jest-preset.cjs +29 -0
- package/detox/jest-setup-after-env.cjs +22 -0
- package/detox/matchers.ts +30 -0
- package/detox/motion-change.cjs +109 -0
- package/detox/motion-change.d.cts +35 -0
- package/detox/navigation-lifecycle.ts +290 -0
- package/detox/navigation.ts +43 -0
- package/detox/proof-frame.cjs +114 -0
- package/detox/record-block-execution.cjs +78 -0
- package/detox/screenshots/animated-color-test-rest-norngh.png +0 -0
- package/detox/screenshots/color-test-after-drag-norngh.png +0 -0
- package/detox/screenshots/color-test-rest-norngh.png +0 -0
- package/detox/screenshots/theme-blue-toggle.png +0 -0
- package/detox/screenshots/theme-blue.png +0 -0
- package/detox/screenshots/theme-red-toggle.png +0 -0
- package/detox/screenshots/theme-red.png +0 -0
- package/dist-lib/agent-daemon-client.cjs +502 -0
- package/dist-lib/agent-events.cjs +48 -0
- package/dist-lib/agent-identity.cjs +53 -0
- package/dist-lib/agent-sessions.cjs +759 -0
- package/dist-lib/attached-projects.cjs +526 -0
- package/dist-lib/auth/shared-session.cjs +208 -0
- package/dist-lib/backend-origin.cjs +88 -0
- package/dist-lib/beta.cjs +41 -0
- package/dist-lib/beta.mjs +13 -0
- package/dist-lib/bridge-constants.cjs +161 -0
- package/dist-lib/bridge-contract-input.cjs +451 -0
- package/dist-lib/bridge-contract-input.mjs +414 -0
- package/dist-lib/bridge-contract.cjs +130 -0
- package/dist-lib/bridge-contract.mjs +94 -0
- package/dist-lib/capture-contract.cjs +220 -0
- package/dist-lib/capture-contract.mjs +190 -0
- package/dist-lib/cli-constants.cjs +32 -0
- package/dist-lib/cloud-contract.cjs +194 -0
- package/dist-lib/cloud-contract.mjs +145 -0
- package/dist-lib/cloud.cjs +670 -0
- package/dist-lib/cloud.mjs +639 -0
- package/dist-lib/config.cjs +96 -0
- package/dist-lib/detox/index.cjs +3789 -0
- package/dist-lib/detox/jest-preset.cjs +29 -0
- package/dist-lib/dev-bundle-resolution.cjs +290 -0
- package/dist-lib/home-paths.cjs +638 -0
- package/dist-lib/host/bridge-host.cjs +10903 -0
- package/dist-lib/host/fetch-proxy-handler.cjs +421 -0
- package/dist-lib/host/fetch-proxy-overrides.cjs +63 -0
- package/dist-lib/host/fetch-proxy-overrides.mjs +37 -0
- package/dist-lib/host/replacement-module-handler.cjs +469 -0
- package/dist-lib/host/websocket-proxy.cjs +298 -0
- package/dist-lib/index.cjs +4268 -0
- package/dist-lib/jump-to-source-babel.cjs +93 -0
- package/dist-lib/jump-to-source-native.cjs +112 -0
- package/dist-lib/menu.cjs +646 -0
- package/dist-lib/menu.mjs +604 -0
- package/dist-lib/metro-fingerprint-registry.cjs +186 -0
- package/dist-lib/metro-fingerprint-registry.mjs +156 -0
- package/dist-lib/metro-production-bundle.cjs +329 -0
- package/dist-lib/metro-production-bundle.mjs +295 -0
- package/dist-lib/metro.cjs +904 -0
- package/dist-lib/profiles.cjs +246 -0
- package/dist-lib/public-brand.cjs +39 -0
- package/dist-lib/react-native-host-modules.cjs +51 -0
- package/dist-lib/react-native-host-modules.mjs +25 -0
- package/dist-lib/render-mode.cjs +54 -0
- package/dist-lib/scripts/dev-server-scanner.cjs +1232 -0
- package/dist-lib/sdk.cjs +2433 -0
- package/dist-lib/sdk.mjs +2336 -0
- package/dist-lib/skills.cjs +39132 -0
- package/dist-lib/swift-submit.cjs +92 -0
- package/dist-lib/swift-submit.mjs +65 -0
- package/dist-lib/swift.cjs +1646 -0
- package/dist-lib/vite.cjs +1591 -0
- package/package.json +290 -0
- package/scripts/dev-server-scanner.ts +1291 -0
- package/scripts/optional-demo-registry.ts +113 -0
- package/skills/contrast/SKILL.md +165 -0
- package/skills/rnx-debug/SKILL.md +433 -0
- package/skills/rnx-perf/SKILL.md +399 -0
- package/skills/rnx-setup/SKILL.md +172 -0
- package/skills/rnx-test/SKILL.md +321 -0
- package/skills/rnx-visual/SKILL.md +126 -0
- package/src/agent-daemon-client.ts +410 -0
- package/src/agent-events.ts +71 -0
- package/src/agent-identity.ts +23 -0
- package/src/agent-prompt.ts +71 -0
- package/src/agent-sessions.ts +580 -0
- package/src/app-url.ts +39 -0
- package/src/attached-projects.ts +539 -0
- package/src/auth/shared-session.ts +235 -0
- package/src/backend-origin.ts +70 -0
- package/src/beta.ts +19 -0
- package/src/bridge-constants.ts +175 -0
- package/src/bridge-contract-input.ts +502 -0
- package/src/bridge-contract.ts +558 -0
- package/src/browser-cache.ts +16 -0
- package/src/capture-contract.ts +587 -0
- package/src/cli-constants.ts +1 -0
- package/src/cli-version.ts +40 -0
- package/src/cloud-contract.ts +274 -0
- package/src/cloud.ts +762 -0
- package/src/codex-client.ts +234 -0
- package/src/config.ts +132 -0
- package/src/connect.ts +101 -0
- package/src/dev-bundle-resolution.ts +346 -0
- package/src/dev-server-open.ts +198 -0
- package/src/disk-cleanup.ts +540 -0
- package/src/flow-timeout-scale.ts +15 -0
- package/src/home-paths.ts +806 -0
- package/src/host/agent-host.ts +666 -0
- package/src/host/bridge-host.ts +2788 -0
- package/src/host/fetch-proxy-handler.ts +449 -0
- package/src/host/fetch-proxy-overrides.ts +51 -0
- package/src/host/open-url.ts +89 -0
- package/src/host/plane-host.ts +203 -0
- package/src/host/replacement-module-handler.ts +158 -0
- package/src/host/websocket-proxy.ts +315 -0
- package/src/index.ts +71 -0
- package/src/jump-to-source-babel.ts +135 -0
- package/src/jump-to-source-native.ts +102 -0
- package/src/menu.ts +777 -0
- package/src/metro-fingerprint-registry.ts +302 -0
- package/src/metro-plugin.ts +341 -0
- package/src/metro-production-bundle.ts +471 -0
- package/src/native-dev-bundle-url.ts +118 -0
- package/src/native-seam-manifest.ts +341 -0
- package/src/playwright-host-log.ts +16 -0
- package/src/plist.ts +27 -0
- package/src/poll-until.ts +123 -0
- package/src/profiles.ts +179 -0
- package/src/public-brand.ts +9 -0
- package/src/react-native-host-modules.ts +26 -0
- package/src/render-mode.ts +27 -0
- package/src/rolldown-plugin-swift.ts +240 -0
- package/src/runtime-assets.ts +233 -0
- package/src/runtime-delivery.ts +54 -0
- package/src/screenshots/compose.ts +422 -0
- package/src/screenshots/frame-compose.ts +514 -0
- package/src/screenshots/orchestrate.ts +256 -0
- package/src/screenshots/org-deck.ts +91 -0
- package/src/screenshots/registry.ts +58 -0
- package/src/screenshots/schema.ts +364 -0
- package/src/sdk.ts +15 -0
- package/src/settings-bundle.ts +418 -0
- package/src/shell-bridge-identity.ts +15 -0
- package/src/sim-client.ts +697 -0
- package/src/skills/builtin/a11y-review.ts +132 -0
- package/src/skills/builtin/compat-check.ts +71 -0
- package/src/skills/builtin/maestro-test.ts +121 -0
- package/src/skills/builtin/perf-profile.ts +90 -0
- package/src/skills/builtin/screenshot-all.ts +52 -0
- package/src/skills/builtin/visual-diff.ts +100 -0
- package/src/skills/registry.ts +107 -0
- package/src/skills/types.ts +41 -0
- package/src/swift-submit.ts +185 -0
- package/src/swift.ts +7 -0
- package/src/team-machine-prompt.ts +73 -0
- package/src/vite-plugin-one.ts +264 -0
- package/src/vite-plugin-swift.ts +620 -0
- package/src/vite-plugin.ts +1946 -0
- package/src/worklets-babel.ts +250 -0
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
// canonical filesystem layout under ~/.rnx/. shared by the CLI and the
|
|
2
|
+
// electron main process so every surface agrees where runtimes, the daemon
|
|
3
|
+
// lockfile, and caches live.
|
|
4
|
+
//
|
|
5
|
+
// ~/.rnx/
|
|
6
|
+
// ├── runtimes/
|
|
7
|
+
// │ ├── <version>/ unpacked dist/ of sootsim-engine
|
|
8
|
+
// │ └── active text file with the active version string (win-safe
|
|
9
|
+
// │ alternative to a symlink)
|
|
10
|
+
// ├── electron/
|
|
11
|
+
// │ └── <version>/ pinned electron binary (future: playwright-style)
|
|
12
|
+
// ├── profiles/
|
|
13
|
+
// │ └── profiles.json storage profile metadata
|
|
14
|
+
// ├── cache/
|
|
15
|
+
// │ └── rnx-runtime-<version>.tar.gz
|
|
16
|
+
// ├── daemon.json lockfile: pid, ports, active runtime, heartbeat
|
|
17
|
+
// ├── dev-bridges/ live Vite dev bridge records, keyed by bound port
|
|
18
|
+
// ├── runtime-upgraded.json one-shot notice written by the daemon's
|
|
19
|
+
// │ background updater, consumed (printed + deleted)
|
|
20
|
+
// │ by the next interactive CLI run
|
|
21
|
+
// ├── cli-update.json cached standalone CLI release check + notice state
|
|
22
|
+
// ├── automatic-cleanup-v1.json completion marker for the one-time safe
|
|
23
|
+
// │ cleanup introduced for legacy unbounded data
|
|
24
|
+
// └── config.json user prefs: update channel, cdn origin override,
|
|
25
|
+
// telemetry, engine settings, default box
|
|
26
|
+
|
|
27
|
+
import { randomUUID } from 'node:crypto'
|
|
28
|
+
import fs from 'node:fs'
|
|
29
|
+
import { homedir } from 'node:os'
|
|
30
|
+
import path from 'node:path'
|
|
31
|
+
|
|
32
|
+
export const RNX_HOME_ENV = 'RNX_HOME'
|
|
33
|
+
export const ACTIVE_RUNTIME_FILE = 'active'
|
|
34
|
+
export const DAEMON_LOCKFILE = 'daemon.json'
|
|
35
|
+
export const DEV_BRIDGES_DIR = 'dev-bridges'
|
|
36
|
+
export const CONFIG_FILE = 'config.json'
|
|
37
|
+
export const CLI_UPDATE_FILE = 'cli-update.json'
|
|
38
|
+
export const DAEMON_HEARTBEAT_STALE_MS = 30_000
|
|
39
|
+
|
|
40
|
+
export function rnxHomeDir(): string {
|
|
41
|
+
const override = process.env[RNX_HOME_ENV]
|
|
42
|
+
if (override && override.length > 0) return path.resolve(override)
|
|
43
|
+
return path.join(homedir(), '.rnx')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// detect when sootsim is running from a source checkout (the Contrast monorepo)
|
|
47
|
+
// rather than a published npm install. used to skip auto-install of the
|
|
48
|
+
// persistent launchd / systemd agent: dev shells shouldn't register an agent
|
|
49
|
+
// whose Program path points at workspace artifacts that change between
|
|
50
|
+
// sessions, and whose served engine assets are the stale prod build instead
|
|
51
|
+
// of the live `bun dev:sootsim` output.
|
|
52
|
+
//
|
|
53
|
+
// overrides:
|
|
54
|
+
// SOOTSIM_DEV=1 / SOOTSIM_DEV=0 force the answer
|
|
55
|
+
// SOOTSIM_FORCE_DAEMON_INSTALL=1 pretend prod even from a dev
|
|
56
|
+
// checkout, for exercising the
|
|
57
|
+
// install path from this repo
|
|
58
|
+
//
|
|
59
|
+
// signal: realpath of process.argv[1] lands inside a `packages/sootsim/`
|
|
60
|
+
// directory. workspace bin symlinks (`node_modules/.bin/sootsim` →
|
|
61
|
+
// `packages/sootsim/dist-cli/bin.js`) and bun-direct invocations
|
|
62
|
+
// (`bun packages/sootsim/cli/bin.ts ...`) both match; published installs
|
|
63
|
+
// resolve under `node_modules/sootsim/` instead.
|
|
64
|
+
export function isSootsimDevCheckout(): boolean {
|
|
65
|
+
if (process.env.SOOTSIM_FORCE_DAEMON_INSTALL === '1') return false
|
|
66
|
+
const env = process.env.SOOTSIM_DEV
|
|
67
|
+
if (env === '1' || env === 'true') return true
|
|
68
|
+
if (env === '0' || env === 'false') return false
|
|
69
|
+
const argv1 = process.argv[1]
|
|
70
|
+
if (!argv1) return false
|
|
71
|
+
try {
|
|
72
|
+
const real = fs.realpathSync(argv1)
|
|
73
|
+
return real.includes(`${path.sep}packages${path.sep}sootsim${path.sep}`)
|
|
74
|
+
} catch {
|
|
75
|
+
return false
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// detect the human's dev workstation by the env markers the developer sets in
|
|
80
|
+
// their shell profile (`IS_TAMAGUI_DEV=1` in ~/.zshrc). distinct from
|
|
81
|
+
// isSootsimDevCheckout, which keys on the *binary path*: a globally-installed
|
|
82
|
+
// `sootsim` binary on the dev machine would NOT match the checkout path, yet a
|
|
83
|
+
// persistent daemon registered there is just as dangerous. it runs in the
|
|
84
|
+
// background carrying the workstation's dev env (dev auth tokens, prod-pointing
|
|
85
|
+
// vars), and when the live `bun dev` stack already owns the bridge port it
|
|
86
|
+
// silently lands on a fallback port and shadows the real one with stale assets
|
|
87
|
+
// — the "I was on the daemon on port 7-something" footgun.
|
|
88
|
+
//
|
|
89
|
+
// override: SOOTSIM_FORCE_DAEMON_INSTALL=1 to run the daemon here anyway.
|
|
90
|
+
export function isDevWorkstation(): boolean {
|
|
91
|
+
if (process.env.SOOTSIM_FORCE_DAEMON_INSTALL === '1') return false
|
|
92
|
+
return process.env.IS_TAMAGUI_DEV === '1'
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// the persistent launchd / systemd daemon must never auto-install or run on a
|
|
96
|
+
// dev checkout or a dev workstation. either makes the background agent point at
|
|
97
|
+
// moving workspace artifacts / dev env and serve stale assets that shadow the
|
|
98
|
+
// live dev stack. setup and the launchd-spawned `rnx serve` boot consult
|
|
99
|
+
// this; a foreground `rnx serve` the dev starts
|
|
100
|
+
// by hand is intentional and stays allowed.
|
|
101
|
+
export function shouldSkipPersistentDaemon(): boolean {
|
|
102
|
+
return isSootsimDevCheckout() || isDevWorkstation()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function runtimesDir(): string {
|
|
106
|
+
return path.join(rnxHomeDir(), 'runtimes')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function runtimeDir(version: string): string {
|
|
110
|
+
return path.join(runtimesDir(), version)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function activeRuntimeFile(): string {
|
|
114
|
+
return path.join(runtimesDir(), ACTIVE_RUNTIME_FILE)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function electronDir(): string {
|
|
118
|
+
return path.join(rnxHomeDir(), 'electron')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function electronUserDataDir(): string {
|
|
122
|
+
return path.join(electronDir(), 'userData')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function electronVersionDir(version: string): string {
|
|
126
|
+
return path.join(electronDir(), version)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function profilesDir(): string {
|
|
130
|
+
return path.join(rnxHomeDir(), 'profiles')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// video fixtures staged for `rnx camera play`. the CLI copies the user's
|
|
134
|
+
// file in here and the daemon serves it by basename from this one directory,
|
|
135
|
+
// so the route can never be talked into reading anywhere else on disk.
|
|
136
|
+
export function cameraFixturesDir(): string {
|
|
137
|
+
return path.join(rnxHomeDir(), 'camera-fixtures')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// the launchd-managed daemon spawns ProgramArguments[0] directly, and macOS
|
|
141
|
+
// Background Task Management attributes the entry to whoever code-signed
|
|
142
|
+
// that binary. pointing launchd at bun directly makes Login Items say
|
|
143
|
+
// "software from Jarred Sumner" (bun's signer); wrapping the invocation in
|
|
144
|
+
// an ad-hoc-signed .app bundle here gives BTM a CFBundleDisplayName to
|
|
145
|
+
// read instead.
|
|
146
|
+
export function daemonAppDir(): string {
|
|
147
|
+
return path.join(rnxHomeDir(), 'daemon-app')
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function daemonAppBundlePath(): string {
|
|
151
|
+
return path.join(daemonAppDir(), 'RNX Daemon.app')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function daemonAppLauncherPath(): string {
|
|
155
|
+
return path.join(daemonAppBundlePath(), 'Contents', 'MacOS', 'rnx-daemon')
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function cacheDir(): string {
|
|
159
|
+
return path.join(rnxHomeDir(), 'cache')
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function daemonLockfilePath(): string {
|
|
163
|
+
return path.join(rnxHomeDir(), DAEMON_LOCKFILE)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function devBridgesDir(): string {
|
|
167
|
+
return path.join(rnxHomeDir(), DEV_BRIDGES_DIR)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function devBridgeLockfilePath(bridgePort: number): string {
|
|
171
|
+
if (!Number.isInteger(bridgePort) || bridgePort <= 0) {
|
|
172
|
+
throw new Error(`invalid development bridge port: ${bridgePort}`)
|
|
173
|
+
}
|
|
174
|
+
return path.join(devBridgesDir(), `${bridgePort}.json`)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function configFilePath(): string {
|
|
178
|
+
return path.join(rnxHomeDir(), CONFIG_FILE)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function cliUpdateFilePath(): string {
|
|
182
|
+
return path.join(rnxHomeDir(), CLI_UPDATE_FILE)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// --- shared rnx config ---------------------------------------------------
|
|
186
|
+
//
|
|
187
|
+
// ~/.rnx/config.json is the single user-level config file every rnx
|
|
188
|
+
// surface (cli, electron renderer, dev browser via electron ipc) agrees on.
|
|
189
|
+
//
|
|
190
|
+
// privacy preferences live in the nested settings object so the cli, desktop
|
|
191
|
+
// main process, renderer, daemon-served browser, and settings UI all read and
|
|
192
|
+
// write one persisted value per choice.
|
|
193
|
+
|
|
194
|
+
export interface SharedConfig {
|
|
195
|
+
telemetry?: boolean
|
|
196
|
+
onboardingVersion?: number
|
|
197
|
+
simulatorDriver?: 'electron' | 'playwright'
|
|
198
|
+
settings?: Record<string, unknown>
|
|
199
|
+
[key: string]: unknown
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function readSharedConfig(): SharedConfig {
|
|
203
|
+
try {
|
|
204
|
+
const raw = fs.readFileSync(configFilePath(), 'utf8')
|
|
205
|
+
const parsed = JSON.parse(raw) as SharedConfig
|
|
206
|
+
return parsed && typeof parsed === 'object' ? parsed : {}
|
|
207
|
+
} catch {
|
|
208
|
+
return {}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** merge `patch` into the shared config and atomically write to disk. nested
|
|
213
|
+
* objects (currently just `settings`) are shallow-merged so partial writes
|
|
214
|
+
* don't clobber unrelated fields. returns the new full snapshot. */
|
|
215
|
+
export function writeSharedConfig(patch: Partial<SharedConfig>): SharedConfig {
|
|
216
|
+
ensureRnxHome()
|
|
217
|
+
const current = readSharedConfig()
|
|
218
|
+
const next: SharedConfig = { ...current, ...patch }
|
|
219
|
+
if (patch.settings && typeof patch.settings === 'object') {
|
|
220
|
+
next.settings = {
|
|
221
|
+
...(current.settings && typeof current.settings === 'object'
|
|
222
|
+
? current.settings
|
|
223
|
+
: {}),
|
|
224
|
+
...patch.settings,
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const tmp = `${configFilePath()}.tmp`
|
|
228
|
+
fs.writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, 'utf8')
|
|
229
|
+
fs.renameSync(tmp, configFilePath())
|
|
230
|
+
return next
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const CURRENT_ONBOARDING_VERSION = 1
|
|
234
|
+
|
|
235
|
+
export type SimulatorDriverPreference = 'electron' | 'playwright'
|
|
236
|
+
|
|
237
|
+
export function isOnboardingComplete(): boolean {
|
|
238
|
+
return readSharedConfig().onboardingVersion === CURRENT_ONBOARDING_VERSION
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function readSimulatorDriverPreference(): SimulatorDriverPreference | null {
|
|
242
|
+
const driver = readSharedConfig().simulatorDriver
|
|
243
|
+
return driver === 'electron' || driver === 'playwright' ? driver : null
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function writeOnboardingPreferences(driver: SimulatorDriverPreference): void {
|
|
247
|
+
writeSharedConfig({
|
|
248
|
+
onboardingVersion: CURRENT_ONBOARDING_VERSION,
|
|
249
|
+
simulatorDriver: driver,
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export interface PrivacyPreferences {
|
|
254
|
+
productAnalytics: boolean
|
|
255
|
+
crashReports: boolean
|
|
256
|
+
configured: boolean
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function readPrivacyPreferences(): PrivacyPreferences {
|
|
260
|
+
const settings = readSharedConfig().settings
|
|
261
|
+
const productAnalytics = settings?.productAnalytics
|
|
262
|
+
const crashReports = settings?.crashReports
|
|
263
|
+
return {
|
|
264
|
+
productAnalytics: typeof productAnalytics === 'boolean' ? productAnalytics : false,
|
|
265
|
+
crashReports: typeof crashReports === 'boolean' ? crashReports : false,
|
|
266
|
+
configured:
|
|
267
|
+
typeof productAnalytics === 'boolean' && typeof crashReports === 'boolean',
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function writePrivacyPreferences(
|
|
272
|
+
preferences: Pick<PrivacyPreferences, 'productAnalytics' | 'crashReports'>,
|
|
273
|
+
): void {
|
|
274
|
+
writeSharedConfig({
|
|
275
|
+
settings: {
|
|
276
|
+
productAnalytics: preferences.productAnalytics,
|
|
277
|
+
crashReports: preferences.crashReports,
|
|
278
|
+
},
|
|
279
|
+
})
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// stable per-machine anonymous telemetry id. every signed-out cli used to
|
|
283
|
+
// report as the single literal distinct_id 'anonymous-cli', which merged all
|
|
284
|
+
// first-run users into one posthog person and made "a new user's login hung"
|
|
285
|
+
// impossible to see. created lazily on first use, persisted in config.json.
|
|
286
|
+
export function readOrCreateAnonymousId(): string {
|
|
287
|
+
const existing = readSharedConfig().anonymousId
|
|
288
|
+
if (typeof existing === 'string' && existing.trim()) return existing
|
|
289
|
+
const created = `anon-cli-${randomUUID()}`
|
|
290
|
+
writeSharedConfig({ anonymousId: created })
|
|
291
|
+
return created
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// --- default box ----------------------------------------------------------
|
|
295
|
+
//
|
|
296
|
+
// the box every box verb acts on when the command line names none. `rnx box
|
|
297
|
+
// use` writes it and `rnx box stop` clears it when it stops that box.
|
|
298
|
+
//
|
|
299
|
+
// the id and the display name, and nothing else. a Box is reached with a
|
|
300
|
+
// bearer token the account service hands out per attach, so writing one here
|
|
301
|
+
// would make this file a credential store; every verb resolves the token
|
|
302
|
+
// again from the id it reads back.
|
|
303
|
+
|
|
304
|
+
export interface DefaultBox {
|
|
305
|
+
boxId: string
|
|
306
|
+
name: string | null
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function readDefaultBox(): DefaultBox | null {
|
|
310
|
+
const stored = readSharedConfig().defaultBox
|
|
311
|
+
if (typeof stored !== 'object' || stored === null) return null
|
|
312
|
+
if (!('boxId' in stored) || typeof stored.boxId !== 'string' || !stored.boxId) {
|
|
313
|
+
return null
|
|
314
|
+
}
|
|
315
|
+
const name = 'name' in stored && typeof stored.name === 'string' ? stored.name : null
|
|
316
|
+
return { boxId: stored.boxId, name }
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** set the default box, or clear it with null. */
|
|
320
|
+
export function writeDefaultBox(box: DefaultBox | null): void {
|
|
321
|
+
writeSharedConfig({ defaultBox: box })
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// --- background-upgrade notice --------------------------------------------
|
|
325
|
+
//
|
|
326
|
+
// the daemon auto-updates the engine runtime in the background (hourly), so
|
|
327
|
+
// the only place its "updated to vX" line lands is the daemon's own stderr,
|
|
328
|
+
// which nobody reads. it also drops a one-shot notice file here; the next
|
|
329
|
+
// interactive CLI invocation prints it once and deletes it, so users learn
|
|
330
|
+
// their engine moved without having to run `rnx version`.
|
|
331
|
+
|
|
332
|
+
export interface RuntimeUpgradeNotice {
|
|
333
|
+
from: string | null
|
|
334
|
+
to: string
|
|
335
|
+
at: number
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function runtimeUpgradeNoticePath(): string {
|
|
339
|
+
return path.join(rnxHomeDir(), 'runtime-upgraded.json')
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function writeRuntimeUpgradeNotice(notice: RuntimeUpgradeNotice): void {
|
|
343
|
+
ensureRnxHome()
|
|
344
|
+
const tmp = `${runtimeUpgradeNoticePath()}.tmp`
|
|
345
|
+
fs.writeFileSync(tmp, `${JSON.stringify(notice)}\n`, 'utf8')
|
|
346
|
+
fs.renameSync(tmp, runtimeUpgradeNoticePath())
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** read + delete the pending upgrade notice, or null when none is pending.
|
|
350
|
+
* deleting first-read keeps the banner one-shot even across racing CLIs. */
|
|
351
|
+
export function consumeRuntimeUpgradeNotice(): RuntimeUpgradeNotice | null {
|
|
352
|
+
try {
|
|
353
|
+
const raw = fs.readFileSync(runtimeUpgradeNoticePath(), 'utf8')
|
|
354
|
+
fs.unlinkSync(runtimeUpgradeNoticePath())
|
|
355
|
+
const parsed = JSON.parse(raw) as Partial<RuntimeUpgradeNotice>
|
|
356
|
+
if (typeof parsed?.to !== 'string' || !parsed.to) return null
|
|
357
|
+
return {
|
|
358
|
+
from: typeof parsed.from === 'string' ? parsed.from : null,
|
|
359
|
+
to: parsed.to,
|
|
360
|
+
at: typeof parsed.at === 'number' ? parsed.at : 0,
|
|
361
|
+
}
|
|
362
|
+
} catch {
|
|
363
|
+
return null
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function ensureRnxHome(): void {
|
|
368
|
+
fs.mkdirSync(rnxHomeDir(), { recursive: true })
|
|
369
|
+
fs.mkdirSync(runtimesDir(), { recursive: true })
|
|
370
|
+
fs.mkdirSync(electronDir(), { recursive: true })
|
|
371
|
+
fs.mkdirSync(profilesDir(), { recursive: true })
|
|
372
|
+
fs.mkdirSync(cacheDir(), { recursive: true })
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** read the active runtime version string, or null if none is selected. */
|
|
376
|
+
export function readActiveRuntime(): string | null {
|
|
377
|
+
try {
|
|
378
|
+
const value = fs.readFileSync(activeRuntimeFile(), 'utf8').trim()
|
|
379
|
+
return value.length > 0 ? value : null
|
|
380
|
+
} catch {
|
|
381
|
+
return null
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** set the active runtime version. caller is responsible for verifying the
|
|
386
|
+
* version actually exists on disk before calling. */
|
|
387
|
+
export function writeActiveRuntime(version: string): void {
|
|
388
|
+
fs.mkdirSync(runtimesDir(), { recursive: true })
|
|
389
|
+
fs.writeFileSync(activeRuntimeFile(), `${version}\n`, 'utf8')
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// listInstalledRuntimes + compareSemver live in @contrast/runtime-delivery:
|
|
393
|
+
// they are delivery machinery (sorting the hosted catalog, deciding whether
|
|
394
|
+
// channel latest is newer than active), shared with contrast, and this module
|
|
395
|
+
// stays free of that dependency so every published `sootsim/*` export that
|
|
396
|
+
// resolves through it keeps working with no bundling requirement.
|
|
397
|
+
|
|
398
|
+
/** absolute path to the active runtime's directory, or null if none active
|
|
399
|
+
* or the active version is no longer installed. */
|
|
400
|
+
export function activeRuntimeDir(): string | null {
|
|
401
|
+
const version = readActiveRuntime()
|
|
402
|
+
if (!version) return null
|
|
403
|
+
const dir = runtimeDir(version)
|
|
404
|
+
try {
|
|
405
|
+
if (fs.statSync(dir).isDirectory()) return dir
|
|
406
|
+
} catch {}
|
|
407
|
+
return null
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// --- daemon lockfile ----------------------------------------------------
|
|
411
|
+
|
|
412
|
+
export interface DaemonLockfile {
|
|
413
|
+
/** sootsim cli/daemon version that wrote the lockfile. bumped whenever
|
|
414
|
+
* the lockfile shape changes so readers can gate on it. */
|
|
415
|
+
schema: 1
|
|
416
|
+
pid: number
|
|
417
|
+
/** platform — useful when one home dir is shared across platforms via NFS. */
|
|
418
|
+
platform: NodeJS.Platform
|
|
419
|
+
/** ws bridge port (where cli + electron open control connections). */
|
|
420
|
+
bridgePort: number
|
|
421
|
+
/** http runtime server port (where electron loads the renderer from). */
|
|
422
|
+
runtimePort: number
|
|
423
|
+
/** active runtime version at boot, or null if the daemon booted with no
|
|
424
|
+
* runtime installed. updated live when the user runs `sootsim runtime use`. */
|
|
425
|
+
activeRuntime: string | null
|
|
426
|
+
/** absolute path to the active runtime's dist directory, or null. */
|
|
427
|
+
activeRuntimeDir: string | null
|
|
428
|
+
/** repo-selected runtime versions currently served to connected sims. */
|
|
429
|
+
servedRuntimes?: string[]
|
|
430
|
+
/** epoch-ms of daemon start. */
|
|
431
|
+
startedAt: number
|
|
432
|
+
/** epoch-ms of last heartbeat. daemons update this every ~5s; readers
|
|
433
|
+
* treat the lockfile as stale if now - heartbeatAt > DAEMON_HEARTBEAT_STALE_MS. */
|
|
434
|
+
heartbeatAt: number
|
|
435
|
+
/** true while the daemon is still fetching/activating its runtime on first
|
|
436
|
+
* boot. clients (electron splash, cli) should wait for this to become
|
|
437
|
+
* false before treating the daemon as ready to serve. */
|
|
438
|
+
bootstrapping?: boolean
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export interface DevBridgeLockfile {
|
|
442
|
+
schema: 1
|
|
443
|
+
pid: number
|
|
444
|
+
platform: NodeJS.Platform
|
|
445
|
+
bridgePort: number
|
|
446
|
+
runtimePort: number
|
|
447
|
+
/** http port of the vite shell dev server that owns this bridge (e.g. 5173).
|
|
448
|
+
* optional: lockfiles written before this field existed still parse, and
|
|
449
|
+
* readers fall back to the default shell URL. this is what lets the CLI
|
|
450
|
+
* resolve the shell base URL and the bridge port from the SAME world
|
|
451
|
+
* instead of mixing dev bridge + daemon runtime (the split-brain bug). */
|
|
452
|
+
shellPort?: number
|
|
453
|
+
cwd: string
|
|
454
|
+
startedAt: number
|
|
455
|
+
heartbeatAt: number
|
|
456
|
+
source: 'vite-dev'
|
|
457
|
+
/** repo-selected runtime versions currently served to connected sims. */
|
|
458
|
+
servedRuntimes?: string[]
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const DAEMON_LOCKFILE_MAX_BYTES = 16 * 1024
|
|
462
|
+
const DAEMON_CLAIM_STALE_MS = 30_000
|
|
463
|
+
|
|
464
|
+
interface DaemonClaim {
|
|
465
|
+
pid: number
|
|
466
|
+
startedAt: number
|
|
467
|
+
createdAt: number
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export function readDaemonLockfile(): DaemonLockfile | null {
|
|
471
|
+
try {
|
|
472
|
+
// cap the read so a junk/large file on the lockfile path can't OOM
|
|
473
|
+
// the CLI when someone has been messing with ~/.rnx/.
|
|
474
|
+
const fd = fs.openSync(daemonLockfilePath(), 'r')
|
|
475
|
+
try {
|
|
476
|
+
const buf = Buffer.alloc(DAEMON_LOCKFILE_MAX_BYTES)
|
|
477
|
+
const bytesRead = fs.readSync(fd, buf, 0, DAEMON_LOCKFILE_MAX_BYTES, 0)
|
|
478
|
+
const raw = buf.subarray(0, bytesRead).toString('utf8')
|
|
479
|
+
const parsed = JSON.parse(raw) as Partial<DaemonLockfile>
|
|
480
|
+
if (
|
|
481
|
+
parsed &&
|
|
482
|
+
parsed.schema === 1 &&
|
|
483
|
+
typeof parsed.pid === 'number' &&
|
|
484
|
+
typeof parsed.bridgePort === 'number' &&
|
|
485
|
+
typeof parsed.runtimePort === 'number' &&
|
|
486
|
+
typeof parsed.startedAt === 'number' &&
|
|
487
|
+
typeof parsed.heartbeatAt === 'number' &&
|
|
488
|
+
(parsed.servedRuntimes === undefined ||
|
|
489
|
+
(Array.isArray(parsed.servedRuntimes) &&
|
|
490
|
+
parsed.servedRuntimes.every((version) => typeof version === 'string')))
|
|
491
|
+
) {
|
|
492
|
+
return parsed as DaemonLockfile
|
|
493
|
+
}
|
|
494
|
+
return null
|
|
495
|
+
} finally {
|
|
496
|
+
fs.closeSync(fd)
|
|
497
|
+
}
|
|
498
|
+
} catch {
|
|
499
|
+
return null
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function parseDevBridgeLockfile(raw: string): DevBridgeLockfile | null {
|
|
504
|
+
try {
|
|
505
|
+
const parsed = JSON.parse(raw)
|
|
506
|
+
if (
|
|
507
|
+
parsed &&
|
|
508
|
+
parsed.schema === 1 &&
|
|
509
|
+
parsed.source === 'vite-dev' &&
|
|
510
|
+
typeof parsed.pid === 'number' &&
|
|
511
|
+
typeof parsed.bridgePort === 'number' &&
|
|
512
|
+
typeof parsed.runtimePort === 'number' &&
|
|
513
|
+
typeof parsed.cwd === 'string' &&
|
|
514
|
+
typeof parsed.startedAt === 'number' &&
|
|
515
|
+
typeof parsed.heartbeatAt === 'number' &&
|
|
516
|
+
(parsed.shellPort === undefined || typeof parsed.shellPort === 'number') &&
|
|
517
|
+
(parsed.servedRuntimes === undefined ||
|
|
518
|
+
(Array.isArray(parsed.servedRuntimes) &&
|
|
519
|
+
parsed.servedRuntimes.every((version: unknown) => typeof version === 'string')))
|
|
520
|
+
) {
|
|
521
|
+
return parsed
|
|
522
|
+
}
|
|
523
|
+
return null
|
|
524
|
+
} catch {
|
|
525
|
+
return null
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export function readDevBridgeLockfiles(): DevBridgeLockfile[] {
|
|
530
|
+
let entries: fs.Dirent[]
|
|
531
|
+
try {
|
|
532
|
+
entries = fs.readdirSync(devBridgesDir(), { withFileTypes: true })
|
|
533
|
+
} catch {
|
|
534
|
+
return []
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const lockfiles: DevBridgeLockfile[] = []
|
|
538
|
+
for (const entry of entries) {
|
|
539
|
+
if (!entry.isFile() || !/^\d+\.json$/.test(entry.name)) continue
|
|
540
|
+
try {
|
|
541
|
+
const filePath = path.join(devBridgesDir(), entry.name)
|
|
542
|
+
const fd = fs.openSync(filePath, 'r')
|
|
543
|
+
try {
|
|
544
|
+
const buf = Buffer.alloc(DAEMON_LOCKFILE_MAX_BYTES)
|
|
545
|
+
const bytesRead = fs.readSync(fd, buf, 0, DAEMON_LOCKFILE_MAX_BYTES, 0)
|
|
546
|
+
const lockfile = parseDevBridgeLockfile(
|
|
547
|
+
buf.subarray(0, bytesRead).toString('utf8'),
|
|
548
|
+
)
|
|
549
|
+
if (lockfile && entry.name === `${lockfile.bridgePort}.json`) {
|
|
550
|
+
lockfiles.push(lockfile)
|
|
551
|
+
}
|
|
552
|
+
} finally {
|
|
553
|
+
fs.closeSync(fd)
|
|
554
|
+
}
|
|
555
|
+
} catch {}
|
|
556
|
+
}
|
|
557
|
+
return lockfiles.sort((left, right) => left.bridgePort - right.bridgePort)
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** true when the lockfile exists, the named pid is alive, and the heartbeat
|
|
561
|
+
* is recent. callers should reach for this before trusting any of the
|
|
562
|
+
* ports inside.
|
|
563
|
+
*
|
|
564
|
+
* pid-reuse note: `process.kill(pid, 0)` only checks that *some* process
|
|
565
|
+
* with that pid exists. after a reboot or heavy fork churn the OS can
|
|
566
|
+
* recycle the pid onto an unrelated process owned by the same user. the
|
|
567
|
+
* heartbeat freshness check catches most of that (30s stale ⇒ reject),
|
|
568
|
+
* but a stale lockfile where pid happens to be reused < 30s ago could
|
|
569
|
+
* still slip through. the consumer paths that actually connect (electron
|
|
570
|
+
* + ws-bridge client) time out quickly, so a stale lockfile degrades to
|
|
571
|
+
* "connect attempt fails" rather than corrupt state. */
|
|
572
|
+
export function isDaemonLockfileFresh(
|
|
573
|
+
lock: DaemonLockfile | null,
|
|
574
|
+
now = Date.now(),
|
|
575
|
+
): lock is DaemonLockfile {
|
|
576
|
+
if (!lock) return false
|
|
577
|
+
if (now - lock.heartbeatAt > DAEMON_HEARTBEAT_STALE_MS) return false
|
|
578
|
+
try {
|
|
579
|
+
// signal 0 just tests whether the pid exists + we have perm to signal.
|
|
580
|
+
process.kill(lock.pid, 0)
|
|
581
|
+
return true
|
|
582
|
+
} catch {
|
|
583
|
+
return false
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
export function isDevBridgeLockfileFresh(
|
|
588
|
+
lock: DevBridgeLockfile | null,
|
|
589
|
+
now = Date.now(),
|
|
590
|
+
): lock is DevBridgeLockfile {
|
|
591
|
+
if (!lock) return false
|
|
592
|
+
if (now - lock.heartbeatAt > DAEMON_HEARTBEAT_STALE_MS) return false
|
|
593
|
+
try {
|
|
594
|
+
process.kill(lock.pid, 0)
|
|
595
|
+
return true
|
|
596
|
+
} catch {
|
|
597
|
+
return false
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
export function readLiveRuntimeVersions(): string[] {
|
|
602
|
+
const versions = new Set<string>()
|
|
603
|
+
const daemon = readDaemonLockfile()
|
|
604
|
+
if (isDaemonLockfileFresh(daemon)) {
|
|
605
|
+
if (daemon.activeRuntime) versions.add(daemon.activeRuntime)
|
|
606
|
+
for (const version of daemon.servedRuntimes ?? []) versions.add(version)
|
|
607
|
+
}
|
|
608
|
+
for (const devBridge of readDevBridgeLockfiles()) {
|
|
609
|
+
if (!isDevBridgeLockfileFresh(devBridge)) continue
|
|
610
|
+
for (const version of devBridge.servedRuntimes ?? []) versions.add(version)
|
|
611
|
+
}
|
|
612
|
+
return [...versions].sort()
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
export function writeDaemonLockfile(data: DaemonLockfile): void {
|
|
616
|
+
ensureRnxHome()
|
|
617
|
+
const lockfilePath = daemonLockfilePath()
|
|
618
|
+
const existing = readDaemonLockfile()
|
|
619
|
+
if (!existing) {
|
|
620
|
+
if (createDaemonLockfileExclusive(data)) return
|
|
621
|
+
throw new Error('another rnx daemon claimed the lockfile before this write')
|
|
622
|
+
}
|
|
623
|
+
if (!sameDaemonOwner(existing, data)) {
|
|
624
|
+
throw new Error('refusing to overwrite another rnx daemon lockfile')
|
|
625
|
+
}
|
|
626
|
+
const tmp = `${lockfilePath}.${data.pid}.${randomUUID()}.tmp`
|
|
627
|
+
fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, 'utf8')
|
|
628
|
+
try {
|
|
629
|
+
const current = readDaemonLockfile()
|
|
630
|
+
if (!current || !sameDaemonOwner(current, data)) {
|
|
631
|
+
throw new Error('rnx daemon lock ownership changed during heartbeat')
|
|
632
|
+
}
|
|
633
|
+
fs.renameSync(tmp, lockfilePath)
|
|
634
|
+
} finally {
|
|
635
|
+
try {
|
|
636
|
+
fs.unlinkSync(tmp)
|
|
637
|
+
} catch {}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
export function writeDevBridgeLockfile(data: DevBridgeLockfile): void {
|
|
642
|
+
fs.mkdirSync(devBridgesDir(), { recursive: true })
|
|
643
|
+
const lockfilePath = devBridgeLockfilePath(data.bridgePort)
|
|
644
|
+
const tmp = `${lockfilePath}.${data.pid}.tmp`
|
|
645
|
+
fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, 'utf8')
|
|
646
|
+
fs.renameSync(tmp, lockfilePath)
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function sameDaemonOwner(
|
|
650
|
+
left: Pick<DaemonLockfile, 'pid' | 'startedAt'>,
|
|
651
|
+
right: Pick<DaemonLockfile, 'pid' | 'startedAt'>,
|
|
652
|
+
): boolean {
|
|
653
|
+
return left.pid === right.pid && left.startedAt === right.startedAt
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function isAlreadyExists(error: unknown): boolean {
|
|
657
|
+
return (
|
|
658
|
+
error instanceof Error && 'code' in error && Reflect.get(error, 'code') === 'EEXIST'
|
|
659
|
+
)
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function createDaemonLockfileExclusive(data: DaemonLockfile): boolean {
|
|
663
|
+
const lockfilePath = daemonLockfilePath()
|
|
664
|
+
const tmp = `${lockfilePath}.${data.pid}.${randomUUID()}.claim`
|
|
665
|
+
fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, 'utf8')
|
|
666
|
+
try {
|
|
667
|
+
fs.linkSync(tmp, lockfilePath)
|
|
668
|
+
return true
|
|
669
|
+
} catch (error) {
|
|
670
|
+
if (isAlreadyExists(error)) return false
|
|
671
|
+
throw error
|
|
672
|
+
} finally {
|
|
673
|
+
try {
|
|
674
|
+
fs.unlinkSync(tmp)
|
|
675
|
+
} catch {}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function daemonClaimPath(): string {
|
|
680
|
+
return `${daemonLockfilePath()}.claim`
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function parseDaemonClaim(raw: string): DaemonClaim | null {
|
|
684
|
+
try {
|
|
685
|
+
const value: unknown = JSON.parse(raw)
|
|
686
|
+
if (
|
|
687
|
+
value !== null &&
|
|
688
|
+
typeof value === 'object' &&
|
|
689
|
+
typeof Reflect.get(value, 'pid') === 'number' &&
|
|
690
|
+
typeof Reflect.get(value, 'startedAt') === 'number' &&
|
|
691
|
+
typeof Reflect.get(value, 'createdAt') === 'number'
|
|
692
|
+
) {
|
|
693
|
+
return {
|
|
694
|
+
pid: Reflect.get(value, 'pid'),
|
|
695
|
+
startedAt: Reflect.get(value, 'startedAt'),
|
|
696
|
+
createdAt: Reflect.get(value, 'createdAt'),
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
} catch {}
|
|
700
|
+
return null
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function readDaemonClaim(): DaemonClaim | null {
|
|
704
|
+
try {
|
|
705
|
+
return parseDaemonClaim(fs.readFileSync(daemonClaimPath(), 'utf8'))
|
|
706
|
+
} catch {
|
|
707
|
+
return null
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function isDaemonClaimFresh(claim: DaemonClaim, now = Date.now()): boolean {
|
|
712
|
+
if (now - claim.createdAt > DAEMON_CLAIM_STALE_MS) return false
|
|
713
|
+
try {
|
|
714
|
+
process.kill(claim.pid, 0)
|
|
715
|
+
return true
|
|
716
|
+
} catch {
|
|
717
|
+
return false
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function acquireDaemonClaim(data: DaemonLockfile): boolean {
|
|
722
|
+
const claim: DaemonClaim = {
|
|
723
|
+
pid: data.pid,
|
|
724
|
+
startedAt: data.startedAt,
|
|
725
|
+
createdAt: Date.now(),
|
|
726
|
+
}
|
|
727
|
+
const serialized = `${JSON.stringify(claim)}\n`
|
|
728
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
729
|
+
try {
|
|
730
|
+
fs.writeFileSync(daemonClaimPath(), serialized, { encoding: 'utf8', flag: 'wx' })
|
|
731
|
+
return true
|
|
732
|
+
} catch (error) {
|
|
733
|
+
if (!isAlreadyExists(error)) throw error
|
|
734
|
+
}
|
|
735
|
+
const existing = readDaemonClaim()
|
|
736
|
+
if (existing && sameDaemonOwner(existing, claim)) return true
|
|
737
|
+
if (existing && isDaemonClaimFresh(existing)) return false
|
|
738
|
+
let before: string
|
|
739
|
+
try {
|
|
740
|
+
before = fs.readFileSync(daemonClaimPath(), 'utf8')
|
|
741
|
+
const latest = parseDaemonClaim(before)
|
|
742
|
+
if (latest && isDaemonClaimFresh(latest)) return false
|
|
743
|
+
if (fs.readFileSync(daemonClaimPath(), 'utf8') !== before) continue
|
|
744
|
+
fs.unlinkSync(daemonClaimPath())
|
|
745
|
+
} catch {}
|
|
746
|
+
}
|
|
747
|
+
return false
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function releaseDaemonClaim(owner: Pick<DaemonLockfile, 'pid' | 'startedAt'>): void {
|
|
751
|
+
try {
|
|
752
|
+
const claim = readDaemonClaim()
|
|
753
|
+
if (!claim || !sameDaemonOwner(claim, owner)) return
|
|
754
|
+
fs.unlinkSync(daemonClaimPath())
|
|
755
|
+
} catch {}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** try to claim the lockfile atomically on daemon boot. a short-lived,
|
|
759
|
+
* process-owned election file serializes concurrent fallback-port binds;
|
|
760
|
+
* the complete lock snapshot is then installed with an exclusive hard link. */
|
|
761
|
+
export function claimDaemonLockfile(data: DaemonLockfile): boolean {
|
|
762
|
+
ensureRnxHome()
|
|
763
|
+
if (!acquireDaemonClaim(data)) return false
|
|
764
|
+
try {
|
|
765
|
+
const existing = readDaemonLockfile()
|
|
766
|
+
if (existing && sameDaemonOwner(existing, data)) {
|
|
767
|
+
writeDaemonLockfile(data)
|
|
768
|
+
return true
|
|
769
|
+
}
|
|
770
|
+
if (existing && isDaemonLockfileFresh(existing)) return false
|
|
771
|
+
if (existing) {
|
|
772
|
+
const latest = readDaemonLockfile()
|
|
773
|
+
if (!latest || !sameDaemonOwner(latest, existing)) return false
|
|
774
|
+
if (isDaemonLockfileFresh(latest)) return false
|
|
775
|
+
try {
|
|
776
|
+
fs.unlinkSync(daemonLockfilePath())
|
|
777
|
+
} catch {
|
|
778
|
+
return false
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
return createDaemonLockfileExclusive(data)
|
|
782
|
+
} finally {
|
|
783
|
+
releaseDaemonClaim(data)
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
export function removeDaemonLockfile(
|
|
788
|
+
owner: Pick<DaemonLockfile, 'pid' | 'startedAt'>,
|
|
789
|
+
): void {
|
|
790
|
+
try {
|
|
791
|
+
const lockfile = readDaemonLockfile()
|
|
792
|
+
if (!lockfile || !sameDaemonOwner(lockfile, owner)) return
|
|
793
|
+
fs.unlinkSync(daemonLockfilePath())
|
|
794
|
+
} catch {}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
export function removeDevBridgeLockfile(
|
|
798
|
+
owner: Pick<DevBridgeLockfile, 'bridgePort' | 'pid' | 'startedAt'>,
|
|
799
|
+
): void {
|
|
800
|
+
try {
|
|
801
|
+
const lockfilePath = devBridgeLockfilePath(owner.bridgePort)
|
|
802
|
+
const lockfile = parseDevBridgeLockfile(fs.readFileSync(lockfilePath, 'utf8'))
|
|
803
|
+
if (lockfile?.pid !== owner.pid || lockfile.startedAt !== owner.startedAt) return
|
|
804
|
+
fs.unlinkSync(lockfilePath)
|
|
805
|
+
} catch {}
|
|
806
|
+
}
|