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,256 @@
|
|
|
1
|
+
// ProjectFilePlane over a real local checkout: the second host behind the
|
|
2
|
+
// seam Contrast's browser already implements. writes land on the user's actual
|
|
3
|
+
// disk, so nothing here converts content: the plane reads and writes bytes.
|
|
4
|
+
//
|
|
5
|
+
// the file set comes from `git ls-files -co --exclude-standard`, which is the
|
|
6
|
+
// project's own definition of what belongs to it. a hardcoded skip list was
|
|
7
|
+
// measured against this repo and is not viable: it walked 230,229 files
|
|
8
|
+
// (16.5 GB) in 4.9 s, where git returns the same project as 14,984 paths in
|
|
9
|
+
// 67 ms. `index()` is a synchronous whole-project map, so the size of that set
|
|
10
|
+
// is the whole cost model.
|
|
11
|
+
|
|
12
|
+
import { execFileSync } from 'node:child_process'
|
|
13
|
+
import fs from 'node:fs'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
import type { ProjectFileMeta, ProjectFilePlane } from '@rnx/box/plane'
|
|
16
|
+
|
|
17
|
+
export class CheckoutRequiresGitError extends Error {}
|
|
18
|
+
|
|
19
|
+
// a NUL byte is the usual "this is not text" test. it is published as metadata
|
|
20
|
+
// rather than acted on here: moving a binary file is byte-exact and perfectly
|
|
21
|
+
// fine, and so is deleting one, but a shell command that produced TEXT cannot
|
|
22
|
+
// be allowed to overwrite it, because that text is a lossy decode of the
|
|
23
|
+
// file's own bytes. BoxProjectFs owns that refusal. detecting it at write
|
|
24
|
+
// time instead is not an option: just-bash's `cat` discards every readFile
|
|
25
|
+
// error and reports "No such file or directory" whatever the error says, so
|
|
26
|
+
// the fact has to be in the index where the filesystem layer can read it.
|
|
27
|
+
const BINARY_SNIFF_BYTES = 8192
|
|
28
|
+
|
|
29
|
+
// every command a box runs re-indexes first, and the index read waits on a git
|
|
30
|
+
// subprocess. it used to await one, and that await could never finish: measured
|
|
31
|
+
// here over 961 refreshes, the call either answered within 314ms (p50 25ms) or
|
|
32
|
+
// never answered at all, once for 29 seconds while the process's own timer kept
|
|
33
|
+
// firing every second. that is not a slow git, it is a lost answer, and no
|
|
34
|
+
// budget reaches it.
|
|
35
|
+
//
|
|
36
|
+
// a deadline on the async form does not help either, which is why this is
|
|
37
|
+
// synchronous. node's `execFile` timeout kills the child and then still waits
|
|
38
|
+
// for a 'close' event to settle the promise, so the lost event takes the
|
|
39
|
+
// deadline down with it: the bound below was armed and silent through all 29
|
|
40
|
+
// of those seconds. `execFileSync` waits in the runtime instead of on a
|
|
41
|
+
// javascript event, so there is nothing to miss, and its timeout is enforced
|
|
42
|
+
// the same way.
|
|
43
|
+
//
|
|
44
|
+
// it costs nothing. the entry loop below is already synchronous stat and read,
|
|
45
|
+
// and under 24-way load the two forms were indistinguishable over 960 calls
|
|
46
|
+
// each, interleaved: p50 1529ms synchronous against 1520ms asynchronous.
|
|
47
|
+
//
|
|
48
|
+
// 10s is roughly fifteen times the slowest full refresh measured on a
|
|
49
|
+
// 14,889-file checkout (430-840 ms, of which `git ls-files` itself is 67 ms),
|
|
50
|
+
// so it cannot fire for a project that is merely large or a machine that is
|
|
51
|
+
// merely busy.
|
|
52
|
+
const LIST_FILES_TIMEOUT_MS = 10_000
|
|
53
|
+
|
|
54
|
+
// the same busy machine fails this call in two ways that are both a failed
|
|
55
|
+
// READ rather than an answer: the spawn raises, and a child exits 0 having
|
|
56
|
+
// written nothing. docs/testing.md measured the second one directly on
|
|
57
|
+
// 2026-08-27 under CPU oversubscription, where a git child exited 0 with empty
|
|
58
|
+
// stdout while the very next `/bin/echo` read back fine.
|
|
59
|
+
//
|
|
60
|
+
// the answer to a failed read is to read again. every box command re-indexes
|
|
61
|
+
// before it runs, so a single lost answer is otherwise the difference between
|
|
62
|
+
// `touch created.txt` working and the box telling the user it has no file set
|
|
63
|
+
// to serve: measured here as exit 1 in 103ms, which is what took the box
|
|
64
|
+
// conformance suite red under a load average of 47. the conditions the two
|
|
65
|
+
// tests in test/rnxBoxCheckoutPlane.test.ts hold (a git that never answers, a
|
|
66
|
+
// git that always answers with nothing) are permanent, so they still fail, and
|
|
67
|
+
// they fail naming the same thing.
|
|
68
|
+
const LIST_FILES_ATTEMPTS = 3
|
|
69
|
+
const LIST_FILES_RETRY_MS = 25
|
|
70
|
+
|
|
71
|
+
function looksBinary(filePath: string, size: number): boolean {
|
|
72
|
+
if (size === 0) return false
|
|
73
|
+
let fd: number | undefined
|
|
74
|
+
try {
|
|
75
|
+
fd = fs.openSync(filePath, 'r')
|
|
76
|
+
const buffer = Buffer.alloc(Math.min(BINARY_SNIFF_BYTES, size))
|
|
77
|
+
const read = fs.readSync(fd, buffer, 0, buffer.length, 0)
|
|
78
|
+
return buffer.subarray(0, read).includes(0)
|
|
79
|
+
} catch {
|
|
80
|
+
return false
|
|
81
|
+
} finally {
|
|
82
|
+
if (fd !== undefined) fs.closeSync(fd)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// the box re-indexes before every command, so the sniff above runs against the
|
|
87
|
+
// whole project every time a user presses return. on a 14,889-file checkout
|
|
88
|
+
// that is 277-644 ms of a 430-840 ms refresh, and nearly all of it re-reads
|
|
89
|
+
// files that did not change. a file whose size and mtime both match the last
|
|
90
|
+
// look has the same first 8 KB, so remember the answer against those two.
|
|
91
|
+
// `statSync` already returns mtimeMs, and it carries sub-millisecond
|
|
92
|
+
// resolution here, so the window in which a same-size rewrite could hide is
|
|
93
|
+
// narrower than a millisecond rather than git's one-second racy-clean case.
|
|
94
|
+
interface SniffedFile {
|
|
95
|
+
size: number
|
|
96
|
+
mtimeMs: number
|
|
97
|
+
binary: boolean
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export class CheckoutFilePlane implements ProjectFilePlane {
|
|
101
|
+
readonly root: string
|
|
102
|
+
private entries = new Map<string, ProjectFileMeta>()
|
|
103
|
+
private sniffed = new Map<string, SniffedFile>()
|
|
104
|
+
// the deadline above, as a number a caller can set. what it changes is how
|
|
105
|
+
// long a wedged git is waited on, never whether it is waited on, so proving
|
|
106
|
+
// the kill does not have to cost the production deadline.
|
|
107
|
+
private readonly listTimeoutMs: number
|
|
108
|
+
private readonly trackedOnly: boolean
|
|
109
|
+
|
|
110
|
+
constructor(
|
|
111
|
+
root: string,
|
|
112
|
+
options: { listTimeoutMs?: number; trackedOnly?: boolean } = {},
|
|
113
|
+
) {
|
|
114
|
+
this.root = path.resolve(root)
|
|
115
|
+
this.listTimeoutMs = options.listTimeoutMs ?? LIST_FILES_TIMEOUT_MS
|
|
116
|
+
this.trackedOnly = options.trackedOnly ?? false
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
index(): ReadonlyMap<string, ProjectFileMeta> {
|
|
120
|
+
return this.entries
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private listFiles(): string {
|
|
124
|
+
// -z keeps paths containing spaces or newlines intact. a local Box sees
|
|
125
|
+
// tracked plus visible untracked files, while a durable cloud import is
|
|
126
|
+
// deliberately restricted to the checkout's tracked source.
|
|
127
|
+
const args = this.trackedOnly
|
|
128
|
+
? ['ls-files', '-z']
|
|
129
|
+
: ['ls-files', '-co', '--exclude-standard', '-z']
|
|
130
|
+
const stdout = execFileSync('git', args, {
|
|
131
|
+
cwd: this.root,
|
|
132
|
+
encoding: 'utf8',
|
|
133
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
134
|
+
timeout: this.listTimeoutMs,
|
|
135
|
+
// stating the environment is not redundant here. bun 1.3.14 resolves
|
|
136
|
+
// the binary for the synchronous form against the PATH it captured at
|
|
137
|
+
// startup and ignores a later change to `process.env.PATH`, so without
|
|
138
|
+
// this it runs a different git than every other call in the process.
|
|
139
|
+
env: process.env,
|
|
140
|
+
})
|
|
141
|
+
// empty child output never means an empty answer. without this the box
|
|
142
|
+
// would build an empty index and every read would report the file as
|
|
143
|
+
// missing, which is a broken project rather than a broken box, and nothing
|
|
144
|
+
// would say so.
|
|
145
|
+
if (stdout.length === 0) {
|
|
146
|
+
throw new CheckoutRequiresGitError(
|
|
147
|
+
`git listed no ${this.trackedOnly ? 'tracked ' : ''}files in ${this.root}. either the checkout contains no eligible files, or this machine could not read the child process's output.`,
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
return stdout
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async refresh(): Promise<void> {
|
|
154
|
+
let stdout = ''
|
|
155
|
+
let lastFailure: CheckoutRequiresGitError | undefined
|
|
156
|
+
for (let attempt = 1; attempt <= LIST_FILES_ATTEMPTS; attempt++) {
|
|
157
|
+
try {
|
|
158
|
+
stdout = this.listFiles()
|
|
159
|
+
lastFailure = undefined
|
|
160
|
+
break
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (error instanceof CheckoutRequiresGitError) {
|
|
163
|
+
lastFailure = error
|
|
164
|
+
} else {
|
|
165
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
166
|
+
// the deadline kills with SIGTERM, so that signal is what tells a
|
|
167
|
+
// bound run apart from git failing on its own terms, and it is worth
|
|
168
|
+
// naming separately: "git was killed by SIGTERM" reads as git's
|
|
169
|
+
// problem, when what happened is that it never answered.
|
|
170
|
+
const killed =
|
|
171
|
+
error instanceof Error && 'signal' in error && error.signal === 'SIGTERM'
|
|
172
|
+
lastFailure = new CheckoutRequiresGitError(
|
|
173
|
+
killed
|
|
174
|
+
? `git did not list the project's files in ${this.root} within ${this.listTimeoutMs / 1000}s, so the box has no file set to serve. the checkout may be on a stalled filesystem, or this machine may be too loaded to start a subprocess.`
|
|
175
|
+
: `could not list the project's files with git in ${this.root}: ${message}`,
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
if (attempt < LIST_FILES_ATTEMPTS) {
|
|
179
|
+
await new Promise((resolve) =>
|
|
180
|
+
setTimeout(resolve, LIST_FILES_RETRY_MS * attempt),
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (lastFailure) {
|
|
186
|
+
lastFailure.message = `${lastFailure.message} (${LIST_FILES_ATTEMPTS} attempts)`
|
|
187
|
+
throw lastFailure
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const next = new Map<string, ProjectFileMeta>()
|
|
191
|
+
// rebuilt alongside the index rather than pruned, so a deleted file's
|
|
192
|
+
// record cannot outlive it in a box that stays open all day
|
|
193
|
+
const nextSniffed = new Map<string, SniffedFile>()
|
|
194
|
+
for (const relative of stdout.split('\0')) {
|
|
195
|
+
if (!relative) continue
|
|
196
|
+
let stat: fs.Stats
|
|
197
|
+
try {
|
|
198
|
+
stat = fs.statSync(path.join(this.root, relative))
|
|
199
|
+
} catch {
|
|
200
|
+
// listed but gone (a deleted-but-tracked file); it is simply not there
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
if (!stat.isFile()) continue
|
|
204
|
+
const seen = this.sniffed.get(relative)
|
|
205
|
+
const binary =
|
|
206
|
+
seen && seen.size === stat.size && seen.mtimeMs === stat.mtimeMs
|
|
207
|
+
? seen.binary
|
|
208
|
+
: looksBinary(path.join(this.root, relative), stat.size)
|
|
209
|
+
nextSniffed.set(relative, { size: stat.size, mtimeMs: stat.mtimeMs, binary })
|
|
210
|
+
next.set(relative, {
|
|
211
|
+
size: stat.size,
|
|
212
|
+
// a checkout carries no write policy of its own; if the user can write
|
|
213
|
+
// the file, so can the shell
|
|
214
|
+
readOnly: false,
|
|
215
|
+
binary,
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
this.entries = next
|
|
219
|
+
this.sniffed = nextSniffed
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private absolute(planePath: string): string {
|
|
223
|
+
const resolved = path.resolve(this.root, planePath)
|
|
224
|
+
// the shell resolves "." and ".." before calling us, but the plane owns the
|
|
225
|
+
// checkout boundary and must not depend on that.
|
|
226
|
+
if (resolved !== this.root && !resolved.startsWith(`${this.root}${path.sep}`)) {
|
|
227
|
+
throw new Error(`path escapes the project: ${planePath}`)
|
|
228
|
+
}
|
|
229
|
+
return resolved
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async read(planePath: string): Promise<string> {
|
|
233
|
+
return fs.promises.readFile(this.absolute(planePath), 'utf8')
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async readBytes(planePath: string): Promise<Uint8Array> {
|
|
237
|
+
return fs.promises.readFile(this.absolute(planePath))
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async write(planePath: string, content: Uint8Array): Promise<void> {
|
|
241
|
+
const absolute = this.absolute(planePath)
|
|
242
|
+
await fs.promises.mkdir(path.dirname(absolute), { recursive: true })
|
|
243
|
+
await fs.promises.writeFile(absolute, content)
|
|
244
|
+
this.entries.set(planePath, {
|
|
245
|
+
size: content.byteLength,
|
|
246
|
+
readOnly: false,
|
|
247
|
+
// the bytes are in hand, so the same NUL test costs nothing here
|
|
248
|
+
binary: content.subarray(0, BINARY_SNIFF_BYTES).includes(0),
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async delete(planePath: string): Promise<void> {
|
|
253
|
+
await fs.promises.rm(this.absolute(planePath), { force: true })
|
|
254
|
+
this.entries.delete(planePath)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// a local box authorizing its own checkout for browser access.
|
|
2
|
+
//
|
|
3
|
+
// the bridge daemon serves the `plane:*` envelope, but it will not open a root
|
|
4
|
+
// nobody has authorized, because a WebSocket to 127.0.0.1 is not subject to
|
|
5
|
+
// CORS and any page the user visits can open one. so the box registers its
|
|
6
|
+
// root and holds the socket for as long as it runs; when the box exits, the
|
|
7
|
+
// registration goes with it and the tab loses reach.
|
|
8
|
+
//
|
|
9
|
+
// THIS NEVER BLOCKS THE BOX. an earlier version awaited the socket before
|
|
10
|
+
// printing the prompt and cost 1450ms of start time on every box, because a
|
|
11
|
+
// daemon built before `plane:*` existed accepts the socket, ignores the
|
|
12
|
+
// message, and never replies, so the client sat on its timeout. a box that
|
|
13
|
+
// cannot share its plane is a box with one capability missing, not a box that
|
|
14
|
+
// starts slowly.
|
|
15
|
+
//
|
|
16
|
+
// IT ALSO REFUSES TO GUESS A PORT. when no lockfile claims a bridge,
|
|
17
|
+
// resolveBridgeWorld reports source 'default' and hands back
|
|
18
|
+
// DEFAULT_SOOTSIM_BRIDGE_PORT, which on a machine running a real daemon is
|
|
19
|
+
// that daemon. registering there would attach a test's or a benchmark's box to
|
|
20
|
+
// another session's daemon, which is exactly what a private RNX_HOME
|
|
21
|
+
// exists to prevent. so a guessed port is refused by name.
|
|
22
|
+
|
|
23
|
+
import { WebSocket } from 'ws'
|
|
24
|
+
import { bridgeWsUrl, resolveBridgeWorld } from '../../ws-bridge'
|
|
25
|
+
|
|
26
|
+
const REGISTER_TIMEOUT_MS = 1500
|
|
27
|
+
|
|
28
|
+
export interface PlaneShare {
|
|
29
|
+
/**
|
|
30
|
+
* resolves when the daemon has accepted the root, or with a reason it did
|
|
31
|
+
* not. `holders` is how many boxes now hold this checkout, including this
|
|
32
|
+
* one, so more than one means another box is writing the same tree.
|
|
33
|
+
*/
|
|
34
|
+
readonly settled: Promise<{ port: number; holders: number } | { declined: string }>
|
|
35
|
+
close(): void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function target(
|
|
39
|
+
explicitPort: number | undefined,
|
|
40
|
+
): { port: number } | { declined: string } {
|
|
41
|
+
if (explicitPort) return { port: explicitPort }
|
|
42
|
+
const world = resolveBridgeWorld()
|
|
43
|
+
if (world.source === 'default') {
|
|
44
|
+
return {
|
|
45
|
+
declined:
|
|
46
|
+
'no daemon or dev bridge claims a port, and the default is another session’s daemon on a shared machine',
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return { port: world.bridgePort }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* register `root` with the bridge daemon in the background. returns
|
|
54
|
+
* immediately; the box never waits on it.
|
|
55
|
+
*/
|
|
56
|
+
export function sharePlane(root: string, opts: { port?: number } = {}): PlaneShare {
|
|
57
|
+
const chosen = target(opts.port)
|
|
58
|
+
if ('declined' in chosen) {
|
|
59
|
+
return { settled: Promise.resolve(chosen), close: () => {} }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const port = chosen.port
|
|
63
|
+
let socket: WebSocket | null = null
|
|
64
|
+
let closed = false
|
|
65
|
+
|
|
66
|
+
const settled = new Promise<{ port: number; holders: number } | { declined: string }>(
|
|
67
|
+
(resolve) => {
|
|
68
|
+
let done = false
|
|
69
|
+
const finish = (
|
|
70
|
+
value: { port: number; holders: number } | { declined: string },
|
|
71
|
+
) => {
|
|
72
|
+
if (done) return
|
|
73
|
+
done = true
|
|
74
|
+
clearTimeout(timer)
|
|
75
|
+
resolve(value)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const timer = setTimeout(() => {
|
|
79
|
+
// an older daemon accepts the socket and ignores plane:* entirely, so
|
|
80
|
+
// silence is the expected shape of "not supported" rather than a fault.
|
|
81
|
+
socket?.close()
|
|
82
|
+
finish({ declined: `bridge on port ${port} did not answer plane:register` })
|
|
83
|
+
}, REGISTER_TIMEOUT_MS)
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
socket = new WebSocket(bridgeWsUrl(port))
|
|
87
|
+
} catch (error) {
|
|
88
|
+
finish({ declined: error instanceof Error ? error.message : String(error) })
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
if (closed) {
|
|
92
|
+
socket.close()
|
|
93
|
+
finish({ declined: 'box exited before the plane registered' })
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
socket.on('error', (error) => finish({ declined: error.message }))
|
|
98
|
+
socket.on('close', () => finish({ declined: `bridge on port ${port} closed` }))
|
|
99
|
+
socket.on('open', () => {
|
|
100
|
+
// node sends no Origin, so this socket is already cli-role. a browser
|
|
101
|
+
// client has to send bridge:hello to be corrected off the provisional
|
|
102
|
+
// sim role; see plans/rnx-cloud/transport-decision.md.
|
|
103
|
+
socket?.send(JSON.stringify({ type: 'plane:register', id: 'register', root }))
|
|
104
|
+
})
|
|
105
|
+
socket.on('message', (data) => {
|
|
106
|
+
let parsed: unknown
|
|
107
|
+
try {
|
|
108
|
+
parsed = JSON.parse(String(data))
|
|
109
|
+
} catch {
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
if (typeof parsed !== 'object' || parsed === null) return
|
|
113
|
+
const reply: Record<string, unknown> = { ...parsed }
|
|
114
|
+
if (reply.id !== 'register') return
|
|
115
|
+
if (reply.error) {
|
|
116
|
+
socket?.close()
|
|
117
|
+
finish({ declined: String(reply.error) })
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
// the daemon nests its payload under `result`; reading holders off the
|
|
121
|
+
// envelope silently reported 1 forever, which is the exact silence this
|
|
122
|
+
// field exists to remove.
|
|
123
|
+
const result = (reply.result ?? {}) as Record<string, unknown>
|
|
124
|
+
finish({ port, holders: Number(result.holders ?? 1) })
|
|
125
|
+
})
|
|
126
|
+
},
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
settled,
|
|
131
|
+
close: () => {
|
|
132
|
+
closed = true
|
|
133
|
+
try {
|
|
134
|
+
socket?.close()
|
|
135
|
+
} catch {}
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* How many boxes hold `root` right now, without registering anything.
|
|
142
|
+
*
|
|
143
|
+
* `plane:register` cannot answer this: registering is what makes a root
|
|
144
|
+
* reachable over a loopback socket, so a readiness probe that asked with it
|
|
145
|
+
* would both authorize the root and count itself in the answer.
|
|
146
|
+
*/
|
|
147
|
+
export async function planeStatus(
|
|
148
|
+
root: string,
|
|
149
|
+
opts: { port?: number } = {},
|
|
150
|
+
): Promise<{ holders: number; registered: boolean } | { declined: string }> {
|
|
151
|
+
const chosen = target(opts.port)
|
|
152
|
+
if ('declined' in chosen) return chosen
|
|
153
|
+
const port = chosen.port
|
|
154
|
+
|
|
155
|
+
return new Promise((resolve) => {
|
|
156
|
+
let done = false
|
|
157
|
+
let socket: WebSocket | null = null
|
|
158
|
+
const finish = (
|
|
159
|
+
value: { holders: number; registered: boolean } | { declined: string },
|
|
160
|
+
) => {
|
|
161
|
+
if (done) return
|
|
162
|
+
done = true
|
|
163
|
+
clearTimeout(timer)
|
|
164
|
+
try {
|
|
165
|
+
socket?.close()
|
|
166
|
+
} catch {}
|
|
167
|
+
resolve(value)
|
|
168
|
+
}
|
|
169
|
+
const timer = setTimeout(
|
|
170
|
+
() => finish({ declined: `bridge on port ${port} did not answer plane:status` }),
|
|
171
|
+
REGISTER_TIMEOUT_MS,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
socket = new WebSocket(bridgeWsUrl(port))
|
|
176
|
+
} catch (error) {
|
|
177
|
+
finish({ declined: error instanceof Error ? error.message : String(error) })
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
socket.on('error', (error) => finish({ declined: error.message }))
|
|
181
|
+
socket.on('close', () => finish({ declined: `bridge on port ${port} closed` }))
|
|
182
|
+
socket.on('open', () => {
|
|
183
|
+
socket?.send(JSON.stringify({ type: 'plane:status', id: 'status', root }))
|
|
184
|
+
})
|
|
185
|
+
socket.on('message', (data) => {
|
|
186
|
+
let parsed: unknown
|
|
187
|
+
try {
|
|
188
|
+
parsed = JSON.parse(String(data))
|
|
189
|
+
} catch {
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
if (typeof parsed !== 'object' || parsed === null) return
|
|
193
|
+
const reply: Record<string, unknown> = { ...parsed }
|
|
194
|
+
if (reply.id !== 'status') return
|
|
195
|
+
if (reply.error) {
|
|
196
|
+
finish({ declined: String(reply.error) })
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
// the daemon nests its payload under `result`, the same shape
|
|
200
|
+
// plane:register answers in.
|
|
201
|
+
const result = (reply.result ?? {}) as Record<string, unknown>
|
|
202
|
+
finish({
|
|
203
|
+
holders: Number(result.holders ?? 0),
|
|
204
|
+
registered: Boolean(result.registered),
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// step 4: RNX operations registered as just-bash commands.
|
|
2
|
+
//
|
|
3
|
+
// `rnx` inside a box shell is the same CLI, re-invoked as a child process with
|
|
4
|
+
// the shell's own cwd. that is deliberate — spelling the verbs a second time
|
|
5
|
+
// in shell-command form would be a second implementation of every command, and
|
|
6
|
+
// the spec is explicit that the CLI and the in-shell commands are clients of
|
|
7
|
+
// one implementation, not two.
|
|
8
|
+
|
|
9
|
+
import { spawn } from 'node:child_process'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import { defineCommand, type CustomCommand } from '@rnx/box/shell'
|
|
12
|
+
import { PROJECT_MOUNT } from '@rnx/box/shell'
|
|
13
|
+
import { rnxPublicBrand } from '../../../src/public-brand'
|
|
14
|
+
import { rnxSelfInvocation } from '../../self-invocation'
|
|
15
|
+
import { RNX_PLACEMENT_ENV } from '../../ws-bridge'
|
|
16
|
+
|
|
17
|
+
// map a shell path under /project back onto the real checkout, so a child
|
|
18
|
+
// process started from `cd src && rnx describe` sees the directory the user
|
|
19
|
+
// is actually standing in.
|
|
20
|
+
function hostCwd(root: string, shellCwd: string): string {
|
|
21
|
+
if (shellCwd === PROJECT_MOUNT) return root
|
|
22
|
+
if (!shellCwd.startsWith(`${PROJECT_MOUNT}/`)) return root
|
|
23
|
+
return path.join(root, shellCwd.slice(PROJECT_MOUNT.length + 1))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createRnxCommand(options: {
|
|
27
|
+
root: string
|
|
28
|
+
shellCwd: () => string
|
|
29
|
+
}): CustomCommand {
|
|
30
|
+
return defineCommand(rnxPublicBrand.commandName, async (args) => {
|
|
31
|
+
const { executable, prefixArgs } = rnxSelfInvocation()
|
|
32
|
+
const child = spawn(executable, [...prefixArgs, ...args], {
|
|
33
|
+
cwd: hostCwd(options.root, options.shellCwd()),
|
|
34
|
+
// mark the child as running inside a local box. this is the only thing
|
|
35
|
+
// that sets a placement — nothing infers one from cwd — and it is what
|
|
36
|
+
// lets `rnx box` refuse to nest inside itself.
|
|
37
|
+
env: { ...process.env, [RNX_PLACEMENT_ENV]: 'local-box' },
|
|
38
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
let stdout = ''
|
|
42
|
+
let stderr = ''
|
|
43
|
+
child.stdout.on('data', (chunk) => {
|
|
44
|
+
stdout += String(chunk)
|
|
45
|
+
})
|
|
46
|
+
child.stderr.on('data', (chunk) => {
|
|
47
|
+
stderr += String(chunk)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const exitCode = await new Promise<number>((resolve) => {
|
|
51
|
+
child.once('error', (error) => {
|
|
52
|
+
stderr += `${rnxPublicBrand.commandName}: ${error.message}\n`
|
|
53
|
+
resolve(1)
|
|
54
|
+
})
|
|
55
|
+
child.once('close', (code) => resolve(code ?? 0))
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
return { stdout, stderr, exitCode }
|
|
59
|
+
})
|
|
60
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// `rg` inside a local box, run by git rather than by a JavaScript matcher.
|
|
2
|
+
//
|
|
3
|
+
// just-bash implements rg in JavaScript and reads every file body through the
|
|
4
|
+
// plane, so one search of this repo is ~15,000 plane reads. MEASURED in the box
|
|
5
|
+
// shell: 14,093ms and two lines of output, which are its abort message. It does
|
|
6
|
+
// not return results at all, and searching is the most common thing an agent
|
|
7
|
+
// does, so that is the box being unusable on a real project.
|
|
8
|
+
//
|
|
9
|
+
// WHY GIT GREP AND NOT RIPGREP. The first version of this shelled out to `rg`,
|
|
10
|
+
// which turned CI red: the Linux runners have no ripgrep binary, and a box that
|
|
11
|
+
// depends on whatever happens to be installed on the host is not a box. Git is
|
|
12
|
+
// different in kind, not degree: `CheckoutFilePlane` already REFUSES to open a
|
|
13
|
+
// project that is not a git repository and runs `git ls-files` before every
|
|
14
|
+
// single command, so git is a hard, already-enforced requirement. Searching
|
|
15
|
+
// with it adds no dependency and cannot be absent where the box already runs.
|
|
16
|
+
//
|
|
17
|
+
// It also makes the file set correct BY CONSTRUCTION rather than by agreement.
|
|
18
|
+
// The box indexes `git ls-files -co --exclude-standard`, tracked AND
|
|
19
|
+
// untracked-not-ignored, and `git grep --untracked` applies those same rules
|
|
20
|
+
// from the same tool. RAN against a checkout built to expose the difference:
|
|
21
|
+
// tracked, untracked-not-ignored, a dotfile and a dotdir file all match, and
|
|
22
|
+
// gitignored trees do not. Ripgrep needed the plane's path list piped into it
|
|
23
|
+
// to get there, because plain `rg .` silently misses dotfiles and `rg --hidden`
|
|
24
|
+
// searches .git/**.
|
|
25
|
+
//
|
|
26
|
+
// MEASURED on this repo, 14,949 indexed files: 391ms p50 against ripgrep's
|
|
27
|
+
// 260ms and just-bash's 14,093ms. Giving up 130ms to remove a host dependency
|
|
28
|
+
// and an entire class of "works on my machine" is the right trade.
|
|
29
|
+
//
|
|
30
|
+
// It also removes the ARG_MAX chunking the ripgrep version needed. Git walks
|
|
31
|
+
// its own index, so there is one process and one exit code rather than several
|
|
32
|
+
// whose "no match here" had to be merged.
|
|
33
|
+
|
|
34
|
+
import { spawn } from 'node:child_process'
|
|
35
|
+
import { defineCommand, type CustomCommand } from '@rnx/box/shell'
|
|
36
|
+
import { PROJECT_MOUNT } from '@rnx/box/shell'
|
|
37
|
+
|
|
38
|
+
// generous next to a 414ms p95: this bounds a wedged child, it does not cut off
|
|
39
|
+
// a large search.
|
|
40
|
+
const SEARCH_DEADLINE_MS = 20_000
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* THE DEADLINE SETTLES THIS PROMISE ITSELF rather than only killing the child.
|
|
44
|
+
* A spawned child's answer has been observed going missing while the process
|
|
45
|
+
* stayed alive, with no one able to say which event never arrived, so nothing
|
|
46
|
+
* that waits on a child event can be trusted to settle. A timer in this process
|
|
47
|
+
* cannot be missed, so it is the authority and the kill is a side effect.
|
|
48
|
+
* See docs/testing.md.
|
|
49
|
+
*/
|
|
50
|
+
function runGitGrep(args: string[], cwd: string) {
|
|
51
|
+
return new Promise<{ stdout: string; stderr: string; exitCode: number }>((resolve) => {
|
|
52
|
+
const child = spawn('git', args, {
|
|
53
|
+
cwd,
|
|
54
|
+
env: process.env,
|
|
55
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
56
|
+
})
|
|
57
|
+
let stdout = ''
|
|
58
|
+
let stderr = ''
|
|
59
|
+
let settled = false
|
|
60
|
+
const finish = (exitCode: number) => {
|
|
61
|
+
if (settled) return
|
|
62
|
+
settled = true
|
|
63
|
+
clearTimeout(timer)
|
|
64
|
+
resolve({ stdout, stderr, exitCode })
|
|
65
|
+
}
|
|
66
|
+
const timer = setTimeout(() => {
|
|
67
|
+
try {
|
|
68
|
+
child.kill('SIGKILL')
|
|
69
|
+
} catch {}
|
|
70
|
+
stderr += `rg: search timed out after ${SEARCH_DEADLINE_MS}ms\n`
|
|
71
|
+
finish(2)
|
|
72
|
+
}, SEARCH_DEADLINE_MS)
|
|
73
|
+
|
|
74
|
+
child.stdout.on('data', (chunk) => {
|
|
75
|
+
stdout += String(chunk)
|
|
76
|
+
})
|
|
77
|
+
child.stderr.on('data', (chunk) => {
|
|
78
|
+
stderr += String(chunk)
|
|
79
|
+
})
|
|
80
|
+
child.once('error', (error) => {
|
|
81
|
+
stderr += `rg: ${error.message}\n`
|
|
82
|
+
finish(2)
|
|
83
|
+
})
|
|
84
|
+
child.once('close', (code) => finish(code ?? 0))
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// the rg flags worth honouring, mapped onto git grep's spelling. anything else
|
|
89
|
+
// is REFUSED BY NAME rather than dropped, because a search that silently
|
|
90
|
+
// ignores `-i` returns a confidently wrong answer.
|
|
91
|
+
const FLAG_MAP: Record<string, string> = {
|
|
92
|
+
'-i': '-i',
|
|
93
|
+
'--ignore-case': '-i',
|
|
94
|
+
'-l': '-l',
|
|
95
|
+
'--files-with-matches': '-l',
|
|
96
|
+
'-w': '-w',
|
|
97
|
+
'--word-regexp': '-w',
|
|
98
|
+
'-c': '-c',
|
|
99
|
+
'--count': '-c',
|
|
100
|
+
'-v': '-v',
|
|
101
|
+
'--invert-match': '-v',
|
|
102
|
+
'-F': '-F',
|
|
103
|
+
'--fixed-strings': '-F',
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function createSearchCommand(options: { root: string }): CustomCommand {
|
|
107
|
+
return defineCommand('rg', async (args) => {
|
|
108
|
+
const flags: string[] = []
|
|
109
|
+
const bare: string[] = []
|
|
110
|
+
for (const arg of args) {
|
|
111
|
+
if (!arg.startsWith('-')) {
|
|
112
|
+
bare.push(arg)
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
const mapped = FLAG_MAP[arg]
|
|
116
|
+
if (!mapped) {
|
|
117
|
+
return {
|
|
118
|
+
stdout: '',
|
|
119
|
+
stderr: `rg: this box does not support ${arg}\n`,
|
|
120
|
+
exitCode: 2,
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
flags.push(mapped)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const pattern = bare.shift()
|
|
127
|
+
if (pattern === undefined) {
|
|
128
|
+
return { stdout: '', stderr: 'rg: no pattern given\n', exitCode: 2 }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// paths are project-relative to git; strip the shell's mount prefix so
|
|
132
|
+
// `rg x /project/src` and `rg x src` mean the same thing.
|
|
133
|
+
const scopes = bare.map((p) =>
|
|
134
|
+
p.startsWith(`${PROJECT_MOUNT}/`) ? p.slice(PROJECT_MOUNT.length + 1) : p,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
// --untracked is what makes this the plane's set rather than git's tracked
|
|
138
|
+
// set. -E for extended regex, the closest of git's grammars to ripgrep's.
|
|
139
|
+
// -e so a pattern beginning with a dash is a pattern, not a flag.
|
|
140
|
+
const result = await runGitGrep(
|
|
141
|
+
[
|
|
142
|
+
'grep',
|
|
143
|
+
'--no-color',
|
|
144
|
+
'-n',
|
|
145
|
+
'-I',
|
|
146
|
+
'--untracked',
|
|
147
|
+
'-E',
|
|
148
|
+
...flags,
|
|
149
|
+
'-e',
|
|
150
|
+
pattern,
|
|
151
|
+
...(scopes.length > 0 ? ['--', ...scopes] : []),
|
|
152
|
+
],
|
|
153
|
+
options.root,
|
|
154
|
+
)
|
|
155
|
+
return result
|
|
156
|
+
})
|
|
157
|
+
}
|