rnxsim 0.1.369 → 0.1.371
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/cli/auth.ts +2 -1
- package/cli/bin.ts +283 -273
- package/cli/commands/app-fonts.ts +3 -2
- package/cli/commands/assert.ts +8 -7
- package/cli/commands/auth.ts +2 -1
- package/cli/commands/box/checkout-plane.ts +109 -29
- package/cli/commands/box/cloud-box.ts +294 -0
- package/cli/commands/box/plane-share.ts +208 -0
- package/cli/commands/box/rnx-command.ts +2 -2
- package/cli/commands/box/search-command.ts +157 -0
- package/cli/commands/box.ts +269 -13
- package/cli/commands/config.ts +2 -1
- package/cli/commands/control.ts +43 -34
- package/cli/commands/daemon.ts +22 -21
- package/cli/commands/debug.ts +17 -15
- package/cli/commands/detox.ts +5 -4
- package/cli/commands/device.ts +6 -5
- package/cli/commands/electron.ts +6 -5
- package/cli/commands/flow.ts +12 -11
- package/cli/commands/inspect/find.ts +2 -1
- package/cli/commands/inspect/keyboard.ts +2 -1
- package/cli/commands/inspect/sleep.ts +2 -1
- package/cli/commands/inspect/wait-event.ts +4 -3
- package/cli/commands/inspect/wait-idle.ts +2 -1
- package/cli/commands/inspect/wait-ready.ts +3 -2
- package/cli/commands/inspect/wait-selector.ts +4 -3
- package/cli/commands/inspect/wait.ts +2 -1
- package/cli/commands/inspect.ts +82 -80
- package/cli/commands/install-desktop.ts +5 -4
- package/cli/commands/login.ts +3 -2
- package/cli/commands/mode.ts +2 -1
- package/cli/commands/record.ts +38 -37
- package/cli/commands/runtime.ts +9 -8
- package/cli/commands/screenshot-layers.ts +4 -3
- package/cli/commands/screenshot-mode.ts +2 -1
- package/cli/commands/screenshot.ts +8 -7
- package/cli/commands/screenshots-capture.ts +2 -1
- package/cli/commands/serve.ts +5 -4
- package/cli/commands/shell-boolean-mode.ts +2 -1
- package/cli/commands/skills.ts +11 -10
- package/cli/commands/telemetry.ts +2 -1
- package/cli/commands/three-mode.ts +2 -1
- package/cli/commands/timeline.ts +4 -3
- package/cli/commands/upload.ts +14 -13
- package/cli/commands/what-happened.ts +2 -1
- package/cli/outbound-endpoints.ts +37 -1
- package/cli/recording-access.ts +6 -5
- package/cli/run-rnx.ts +140 -0
- package/dist-cli/bin.js +6 -6
- package/dist-cli/chunks/{agent-OUUFWDLF.js → agent-W2D2V7WF.js} +2 -2
- package/dist-cli/chunks/{agent-wrapper-7BE7DR4R.js → agent-wrapper-GVNM2Z4S.js} +2 -2
- package/dist-cli/chunks/app-fonts-J6R4TTXD.js +4 -0
- package/dist-cli/chunks/{assert-I3CMRNS7.js → assert-YN6U4ZTB.js} +11 -11
- package/dist-cli/chunks/auth-XGYZAK2P.js +12 -0
- package/dist-cli/chunks/{beta-BHSKY4K4.js → beta-WRDMHHWJ.js} +2 -2
- package/dist-cli/chunks/{box-3CVWXVTT.js → box-ML5TFUQW.js} +617 -596
- package/dist-cli/chunks/{camera-MNK73TYV.js → camera-OBEI4FZA.js} +2 -2
- package/dist-cli/chunks/{chunk-JXARZBWS.js → chunk-2FIT64GR.js} +1 -1
- package/dist-cli/chunks/{chunk-RYGQ6LZN.js → chunk-2LH4DYPM.js} +2 -2
- package/dist-cli/chunks/chunk-2QW3JH6T.js +99 -0
- package/dist-cli/chunks/{chunk-2APXX4RN.js → chunk-2W4YBCPX.js} +2 -2
- package/dist-cli/chunks/{chunk-X3PEDNLV.js → chunk-42YOIDLR.js} +2 -2
- package/dist-cli/chunks/{chunk-6BMM6OCW.js → chunk-47MSXLWK.js} +2 -2
- package/dist-cli/chunks/chunk-4OLUFHEZ.js +74 -0
- package/dist-cli/chunks/{chunk-WUR4FXWT.js → chunk-5PPVVGLH.js} +25 -25
- package/dist-cli/chunks/{chunk-AKJYEX54.js → chunk-6AZKZ5AJ.js} +2 -2
- package/dist-cli/chunks/{chunk-2PVVIPQS.js → chunk-6FPXLSBJ.js} +1 -1
- package/dist-cli/chunks/{chunk-CCI3DZKE.js → chunk-763GBI7G.js} +2 -2
- package/dist-cli/chunks/{chunk-CZ2HCNRK.js → chunk-7ASCDKA3.js} +1 -1
- package/dist-cli/chunks/{chunk-ZNNE5MAV.js → chunk-7Q4WHJDH.js} +1 -1
- package/dist-cli/chunks/{chunk-IYPMULI7.js → chunk-7TWD4P6J.js} +2 -2
- package/dist-cli/chunks/{chunk-45X3D6CI.js → chunk-7YB46LGI.js} +1 -1
- package/dist-cli/chunks/{chunk-3454VMHD.js → chunk-ADJSGO55.js} +2 -2
- package/dist-cli/chunks/{chunk-F4OTMARL.js → chunk-AGZZWLEL.js} +2 -2
- package/dist-cli/chunks/{chunk-UDXQCEJU.js → chunk-AVIH3TAZ.js} +11 -11
- package/dist-cli/chunks/{chunk-DCPDMF27.js → chunk-B26ZJUQA.js} +2 -2
- package/dist-cli/chunks/{chunk-D6MGELKZ.js → chunk-BNS5LNEU.js} +1 -1
- package/dist-cli/chunks/{chunk-FHRG772G.js → chunk-CJQXP4XM.js} +1 -1
- package/dist-cli/chunks/{chunk-OROUEG22.js → chunk-DP6RGTNN.js} +2 -2
- package/dist-cli/chunks/{chunk-V7I4CTAX.js → chunk-DYFOLOYL.js} +2 -2
- package/dist-cli/chunks/chunk-ERBGQEJ5.js +4 -0
- package/dist-cli/chunks/{chunk-H2GB2PLY.js → chunk-ESWSXF55.js} +2 -2
- package/dist-cli/chunks/{chunk-FGGESBWD.js → chunk-FLNGSIBL.js} +2 -2
- package/dist-cli/chunks/{chunk-4SCVGBOX.js → chunk-GQGS7TRK.js} +1 -1
- package/dist-cli/chunks/{chunk-WGE2P25F.js → chunk-H4DU457S.js} +1 -1
- package/dist-cli/chunks/{chunk-TBK5UXW2.js → chunk-HFY35VAG.js} +2 -2
- package/dist-cli/chunks/{chunk-JLOOLA47.js → chunk-HV3WOWQM.js} +1 -1
- package/dist-cli/chunks/{chunk-RV76U3EI.js → chunk-HZ365EMS.js} +10 -10
- package/dist-cli/chunks/chunk-IPNDXGVA.js +11 -0
- package/dist-cli/chunks/{chunk-TAC4CEIX.js → chunk-IX7Z6ZVJ.js} +2 -2
- package/dist-cli/chunks/{chunk-UQJY6O72.js → chunk-IYU2EOIU.js} +1 -1
- package/dist-cli/chunks/chunk-J6A5W2DI.js +30 -0
- package/dist-cli/chunks/chunk-JKRMBNPO.js +28 -0
- package/dist-cli/chunks/{chunk-VGJLCFSC.js → chunk-JUTLQ3GX.js} +1 -1
- package/dist-cli/chunks/{chunk-5QFJFASW.js → chunk-KHGXEU4L.js} +1 -1
- package/dist-cli/chunks/{chunk-A56GQVNF.js → chunk-KL34TRNW.js} +1 -1
- package/dist-cli/chunks/chunk-LFANRLWK.js +7 -0
- package/dist-cli/chunks/{chunk-NPVEBXU3.js → chunk-MOIQZVRU.js} +2 -2
- package/dist-cli/chunks/chunk-MW4E5EED.js +497 -0
- package/dist-cli/chunks/{chunk-AJJCYLBC.js → chunk-N3LAMTGS.js} +2 -2
- package/dist-cli/chunks/{chunk-QE5MC4V3.js → chunk-NQ4CI2VI.js} +1 -1
- package/dist-cli/chunks/{chunk-6F3WOWFC.js → chunk-OF3U4SZO.js} +2 -2
- package/dist-cli/chunks/{chunk-DKSZZABN.js → chunk-OF5OBB67.js} +1 -1
- package/dist-cli/chunks/{chunk-MXWME7ET.js → chunk-OQPZOEEP.js} +1 -1
- package/dist-cli/chunks/{chunk-SSBZA5VZ.js → chunk-OWDJQWJ7.js} +1 -1
- package/dist-cli/chunks/{chunk-YLNSLMQM.js → chunk-PTWMX4AA.js} +2 -2
- package/dist-cli/chunks/{chunk-TTUZ5MCK.js → chunk-RYJTPZCX.js} +1 -1
- package/dist-cli/chunks/{chunk-A72MIIJI.js → chunk-S3AH2O67.js} +1 -1
- package/dist-cli/chunks/{chunk-ZW6JXEVX.js → chunk-SH2HXHR2.js} +2 -2
- package/dist-cli/chunks/{chunk-EKAEEWC2.js → chunk-SHLJYXIN.js} +1 -1
- package/dist-cli/chunks/{chunk-4G4VCT2M.js → chunk-TL22EBF5.js} +2 -2
- package/dist-cli/chunks/{chunk-LU3AHE6T.js → chunk-TWNLRDIH.js} +2 -2
- package/dist-cli/chunks/{chunk-B5NW4TT3.js → chunk-UHYAY3RU.js} +2 -2
- package/dist-cli/chunks/{chunk-N5KDDGV6.js → chunk-UPMR6Y5P.js} +2 -2
- package/dist-cli/chunks/chunk-VEFSHPSU.js +24 -0
- package/dist-cli/chunks/{chunk-YPBDPVLR.js → chunk-WCB5XPCH.js} +2 -2
- package/dist-cli/chunks/{chunk-USHV3QFZ.js → chunk-WHZE63R2.js} +2 -2
- package/dist-cli/chunks/{chunk-ZEF57HQO.js → chunk-WLRIPAGJ.js} +1 -1
- package/dist-cli/chunks/{chunk-FBMNYSSW.js → chunk-Y7SKGRAV.js} +1 -1
- package/dist-cli/chunks/chunk-Z6CQDU74.js +4 -0
- package/dist-cli/chunks/{cleanup-H4XFRMZV.js → cleanup-PTJBMC34.js} +2 -2
- package/dist-cli/chunks/cli-version-6ZBD6GKP.js +4 -0
- package/dist-cli/chunks/{compat-NKGYERLN.js → compat-FJ2HWRAG.js} +2 -2
- package/dist-cli/chunks/config-PYGH7JPN.js +13 -0
- package/dist-cli/chunks/{connect-WWWAVVCF.js → connect-AJV4PZA4.js} +2 -2
- package/dist-cli/chunks/control-667PAYX4.js +4 -0
- package/dist-cli/chunks/daemon-EIBOHZYQ.js +4 -0
- package/dist-cli/chunks/{debug-RO6GLDIM.js → debug-XF2XUDHN.js} +20 -20
- package/dist-cli/chunks/desktop-HEZYNFYW.js +31 -0
- package/dist-cli/chunks/detox-JCFZJRNE.js +52 -0
- package/dist-cli/chunks/device-KGUVZHFW.js +18 -0
- package/dist-cli/chunks/{diagnose-HDRTUPDO.js → diagnose-KVJQ3TOI.js} +2 -2
- package/dist-cli/chunks/{disk-cleanup-QP2NYN43.js → disk-cleanup-7HQXJFUX.js} +2 -2
- package/dist-cli/chunks/{do-chain-6LP6SV5J.js → do-chain-3FYJNA2N.js} +2 -2
- package/dist-cli/chunks/drivers-5S6M5TJB.js +4 -0
- package/dist-cli/chunks/{env-4XD5D2G6.js → env-BX6S2SOM.js} +2 -2
- package/dist-cli/chunks/{film-I5SIXLS5.js → film-2WZUVYFA.js} +3 -3
- package/dist-cli/chunks/flow-6BZSWNHG.js +4 -0
- package/dist-cli/chunks/help-OGJBD4NP.js +4 -0
- package/dist-cli/chunks/{hidden-runtime-alias-LJV2S4M2.js → hidden-runtime-alias-PGF55OFH.js} +2 -2
- package/dist-cli/chunks/{home-paths-RFI2QRC3.js → home-paths-EGRMTSGE.js} +2 -2
- package/dist-cli/chunks/inspect-MZVDGAN4.js +4 -0
- package/dist-cli/chunks/install-desktop-QB76DNZF.js +4 -0
- package/dist-cli/chunks/login-HRFPYPZ3.js +26 -0
- package/dist-cli/chunks/{logout-DKXZZ6HU.js → logout-LRH5HZGP.js} +2 -2
- package/dist-cli/chunks/{maestro-WKGVVWRA.js → maestro-S53T4PPE.js} +3 -3
- package/dist-cli/chunks/{maestro-generate-KCK6RK2C.js → maestro-generate-YKIVKOB3.js} +3 -3
- package/dist-cli/chunks/mode-47JKNUNS.js +26 -0
- package/dist-cli/chunks/{optional-demo-registry-EUSKBIPP.js → optional-demo-registry-ARZSPS5Y.js} +2 -2
- package/dist-cli/chunks/{perf-IMZMA5MT.js → perf-J73OKB4T.js} +2 -2
- package/dist-cli/chunks/{permissions-SLWXTLH6.js → permissions-L3FPI56H.js} +2 -2
- package/dist-cli/chunks/{platform-HQ735LAE.js → platform-YAROAHRM.js} +3 -3
- package/dist-cli/chunks/privacy-JZQCXHKN.js +4 -0
- package/dist-cli/chunks/record-QAQMGYOO.js +72 -0
- package/dist-cli/chunks/report-issue-4FVZCAFQ.js +29 -0
- package/dist-cli/chunks/reset-NYFUFZCG.js +4 -0
- package/dist-cli/chunks/resolve-target-SP22ZGFZ.js +4 -0
- package/dist-cli/chunks/runtime-WTS2QMXZ.js +4 -0
- package/dist-cli/chunks/{screenshot-command-HAJB5FHK.js → screenshot-command-Q5MGGEWY.js} +11 -11
- package/dist-cli/chunks/screenshot-layers-4JW7DRL7.js +20 -0
- package/dist-cli/chunks/screenshots-capture-X5PMO44E.js +42 -0
- package/dist-cli/chunks/{serve-TM46TKFW.js → serve-3JWJ45J3.js} +20 -20
- package/dist-cli/chunks/setup-USPDJESI.js +4 -0
- package/dist-cli/chunks/{sim-client-LQUZSO45.js → sim-client-RL6DGOJE.js} +2 -2
- package/dist-cli/chunks/skills-QNO6GDJB.js +50 -0
- package/dist-cli/chunks/{state-RUVM4Z4S.js → state-OGUCSUC3.js} +2 -2
- package/dist-cli/chunks/{storage-GQ33UTYN.js → storage-TG7KFQZ3.js} +2 -2
- package/dist-cli/chunks/store-ZYMQW2LN.js +4 -0
- package/dist-cli/chunks/telemetry-7CSLUUIW.js +4 -0
- package/dist-cli/chunks/timeline-R4G5P335.js +24 -0
- package/dist-cli/chunks/{upgrade-AZDZLUIT.js → upgrade-RDTXJ6O3.js} +2 -2
- package/dist-cli/chunks/upload-PLF2DPMS.js +4 -0
- package/dist-cli/chunks/{version-N3ALB75G.js → version-GU2NUZDS.js} +2 -2
- package/dist-cli/chunks/{web-XPHFZATA.js → web-7PUVG3GE.js} +2 -2
- package/dist-cli/chunks/welcome-4GGUV7FY.js +5 -0
- package/dist-cli/chunks/what-happened-OHAAX6F6.js +25 -0
- package/dist-cli/chunks/{ws-bridge-RUXOYOU6.js → ws-bridge-Z3O35MNF.js} +2 -2
- package/dist-lib/agent-daemon-client.cjs +1 -1
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/agent-identity.cjs +1 -1
- package/dist-lib/agent-sessions.cjs +1 -1
- package/dist-lib/attached-projects.cjs +1 -1
- package/dist-lib/auth/shared-session.cjs +1 -1
- package/dist-lib/backend-origin.cjs +1 -1
- package/dist-lib/beta.cjs +1 -1
- package/dist-lib/beta.mjs +1 -1
- package/dist-lib/bridge-constants.cjs +1 -1
- package/dist-lib/bridge-contract-input.cjs +1 -1
- package/dist-lib/bridge-contract-input.mjs +1 -1
- package/dist-lib/bridge-contract.cjs +1 -1
- package/dist-lib/bridge-contract.mjs +1 -1
- package/dist-lib/cli-constants.cjs +1 -1
- package/dist-lib/config.cjs +1 -1
- package/dist-lib/detox/index.cjs +1 -1
- package/dist-lib/dev-bundle-resolution.cjs +69 -34
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +430 -106
- package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
- package/dist-lib/host/replacement-module-handler.cjs +1 -1
- package/dist-lib/host/websocket-proxy.cjs +1 -1
- package/dist-lib/index.cjs +1 -1
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/menu.cjs +1 -1
- package/dist-lib/menu.mjs +1 -1
- package/dist-lib/metro-production-bundle.cjs +1 -1
- package/dist-lib/metro-production-bundle.mjs +1 -1
- package/dist-lib/metro.cjs +1 -1
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/public-brand.cjs +1 -1
- package/dist-lib/react-native-host-modules.cjs +2 -1
- package/dist-lib/react-native-host-modules.mjs +2 -1
- package/dist-lib/render-mode.cjs +1 -1
- package/dist-lib/scripts/dev-server-scanner.cjs +149 -93
- package/dist-lib/sdk.cjs +1 -1
- package/dist-lib/sdk.mjs +1 -1
- package/dist-lib/skills.cjs +757 -353
- package/dist-lib/vite.cjs +1 -1
- package/package.json +1 -1
- package/scripts/dev-server-scanner.ts +275 -92
- package/src/dev-bundle-resolution.ts +123 -40
- package/src/host/bridge-host.ts +11 -0
- package/src/host/plane-host.ts +203 -0
- package/src/react-native-host-modules.ts +1 -0
- package/dist-cli/chunks/app-fonts-66V7TL26.js +0 -4
- package/dist-cli/chunks/auth-3FMWUVJH.js +0 -12
- package/dist-cli/chunks/chunk-3NLB5LCN.js +0 -28
- package/dist-cli/chunks/chunk-5RWUFIZW.js +0 -24
- package/dist-cli/chunks/chunk-COXCR6UU.js +0 -7
- package/dist-cli/chunks/chunk-JH5KE2OI.js +0 -4
- package/dist-cli/chunks/chunk-ROISJKH3.js +0 -99
- package/dist-cli/chunks/chunk-SUHAD6N2.js +0 -30
- package/dist-cli/chunks/chunk-TIB3CTRX.js +0 -497
- package/dist-cli/chunks/chunk-YFZLDFZK.js +0 -74
- package/dist-cli/chunks/cli-version-MJWLDXBW.js +0 -4
- package/dist-cli/chunks/config-WK7467KI.js +0 -13
- package/dist-cli/chunks/control-ESI7IZNN.js +0 -4
- package/dist-cli/chunks/daemon-UCLO27YP.js +0 -4
- package/dist-cli/chunks/desktop-FF34Z5X4.js +0 -31
- package/dist-cli/chunks/detox-Q3CWY5UX.js +0 -52
- package/dist-cli/chunks/device-WN4ZWLES.js +0 -18
- package/dist-cli/chunks/drivers-OR2JPAXD.js +0 -4
- package/dist-cli/chunks/flow-4GWW5DYT.js +0 -4
- package/dist-cli/chunks/help-MIIWOT3C.js +0 -4
- package/dist-cli/chunks/inspect-3ZGNJENJ.js +0 -4
- package/dist-cli/chunks/install-desktop-BYOJVTLS.js +0 -4
- package/dist-cli/chunks/login-GNIFSAFE.js +0 -26
- package/dist-cli/chunks/mode-YVMX7OJY.js +0 -26
- package/dist-cli/chunks/privacy-EDC7PF7G.js +0 -4
- package/dist-cli/chunks/record-N4KTTVDI.js +0 -72
- package/dist-cli/chunks/report-issue-PP752HAD.js +0 -29
- package/dist-cli/chunks/reset-DRMKGVP4.js +0 -4
- package/dist-cli/chunks/resolve-target-7WWI32R3.js +0 -4
- package/dist-cli/chunks/runtime-EKJV2ZVI.js +0 -4
- package/dist-cli/chunks/screenshot-layers-GOCJVM2G.js +0 -20
- package/dist-cli/chunks/screenshots-capture-YYJLYA3V.js +0 -42
- package/dist-cli/chunks/setup-FVPVIYE4.js +0 -4
- package/dist-cli/chunks/skills-V5TE57FN.js +0 -50
- package/dist-cli/chunks/store-IKNBDDKM.js +0 -4
- package/dist-cli/chunks/telemetry-2ZVU6WLK.js +0 -4
- package/dist-cli/chunks/timeline-LP7DMJYT.js +0 -24
- package/dist-cli/chunks/upload-DFQK2Q7X.js +0 -4
- package/dist-cli/chunks/welcome-2NFYLWLJ.js +0 -5
- package/dist-cli/chunks/what-happened-EJ5PHKT7.js +0 -25
package/cli/commands/assert.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// 2 misuse — bad flags, unknown verb (treated as bad by bisect)
|
|
15
15
|
|
|
16
16
|
import { spawn } from 'child_process'
|
|
17
|
+
import { rnxExit } from '../run-rnx'
|
|
17
18
|
|
|
18
19
|
// READ_VERBS lists every command whose stdout we know is valid JSON under
|
|
19
20
|
// --json. keeping this explicit means `sootsim assert tap 100 200` fails
|
|
@@ -403,13 +404,13 @@ function runJq(expr: string, payload: unknown): Promise<string> {
|
|
|
403
404
|
export async function runAssert(rawArgs: string[]): Promise<void> {
|
|
404
405
|
if (rawArgs[0] === '--help' || rawArgs[0] === '-h' || rawArgs.length === 0) {
|
|
405
406
|
printHelp()
|
|
406
|
-
|
|
407
|
+
rnxExit(rawArgs.length === 0 ? 2 : 0)
|
|
407
408
|
}
|
|
408
409
|
|
|
409
410
|
const parsed = parseAssertArgs(rawArgs)
|
|
410
411
|
if ('error' in parsed) {
|
|
411
412
|
process.stderr.write(` assert: ${parsed.error}\n`)
|
|
412
|
-
|
|
413
|
+
rnxExit(2)
|
|
413
414
|
}
|
|
414
415
|
|
|
415
416
|
const { verbArgs, predicates, withinMs, negate, quiet, allowTimeout } = parsed
|
|
@@ -420,7 +421,7 @@ export async function runAssert(rawArgs: string[]): Promise<void> {
|
|
|
420
421
|
` assert: unknown or non-read verb: ${verbArgs.join(' ')}\n` +
|
|
421
422
|
` supported: ${Array.from(READ_VERBS).sort().join(', ')}\n`,
|
|
422
423
|
)
|
|
423
|
-
|
|
424
|
+
rnxExit(2)
|
|
424
425
|
}
|
|
425
426
|
|
|
426
427
|
// default assertion when none given is --exists. matches the common
|
|
@@ -452,7 +453,7 @@ export async function runAssert(rawArgs: string[]): Promise<void> {
|
|
|
452
453
|
const label = exitCode === 1 ? 'fail' : 'skip'
|
|
453
454
|
process.stderr.write(` assert: ${label} — ${lastVerbSkipReason}\n`)
|
|
454
455
|
}
|
|
455
|
-
|
|
456
|
+
rnxExit(exitCode)
|
|
456
457
|
}
|
|
457
458
|
|
|
458
459
|
let allPass = true
|
|
@@ -468,7 +469,7 @@ export async function runAssert(rawArgs: string[]): Promise<void> {
|
|
|
468
469
|
}
|
|
469
470
|
} catch (err: any) {
|
|
470
471
|
if (!quiet) process.stderr.write(` assert: skip — ${err.message}\n`)
|
|
471
|
-
|
|
472
|
+
rnxExit(125)
|
|
472
473
|
}
|
|
473
474
|
|
|
474
475
|
const pass = negate ? !allPass : allPass
|
|
@@ -478,7 +479,7 @@ export async function runAssert(rawArgs: string[]): Promise<void> {
|
|
|
478
479
|
` assert: pass${attempts > 1 ? ` (attempt ${attempts})` : ''} — ${reasons.join('; ')}\n`,
|
|
479
480
|
)
|
|
480
481
|
}
|
|
481
|
-
|
|
482
|
+
rnxExit(0)
|
|
482
483
|
}
|
|
483
484
|
|
|
484
485
|
lastFailReason = reasons.join('; ')
|
|
@@ -491,7 +492,7 @@ export async function runAssert(rawArgs: string[]): Promise<void> {
|
|
|
491
492
|
` assert: fail${attempts > 1 ? ` after ${attempts} attempt(s)` : ''} — ${lastFailReason}\n`,
|
|
492
493
|
)
|
|
493
494
|
}
|
|
494
|
-
|
|
495
|
+
rnxExit(1)
|
|
495
496
|
}
|
|
496
497
|
}
|
|
497
498
|
|
package/cli/commands/auth.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { refreshSharedDesktopAuthSession } from '../../src/auth/shared-session'
|
|
2
2
|
import { resolveCliAuth } from '../auth'
|
|
3
|
+
import { rnxExit } from '../run-rnx'
|
|
3
4
|
|
|
4
5
|
function printHelp(): void {
|
|
5
6
|
console.log(`
|
|
@@ -55,5 +56,5 @@ export async function runAuth(args: string[]): Promise<void> {
|
|
|
55
56
|
|
|
56
57
|
console.error(` unknown auth command: ${sub}`)
|
|
57
58
|
printHelp()
|
|
58
|
-
|
|
59
|
+
rnxExit(1)
|
|
59
60
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
// ProjectFilePlane over a real local checkout
|
|
2
|
-
// seam Contrast's browser
|
|
3
|
-
//
|
|
4
|
-
// represent faithfully rather than round-tripping it through a string.
|
|
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.
|
|
5
4
|
//
|
|
6
5
|
// the file set comes from `git ls-files -co --exclude-standard`, which is the
|
|
7
6
|
// project's own definition of what belongs to it. a hardcoded skip list was
|
|
@@ -10,26 +9,48 @@
|
|
|
10
9
|
// 67 ms. `index()` is a synchronous whole-project map, so the size of that set
|
|
11
10
|
// is the whole cost model.
|
|
12
11
|
|
|
13
|
-
import {
|
|
12
|
+
import { execFileSync } from 'node:child_process'
|
|
14
13
|
import fs from 'node:fs'
|
|
15
14
|
import path from 'node:path'
|
|
16
|
-
import {
|
|
17
|
-
import type { ProjectFileMeta, ProjectFilePlane } from '@contrast/harness'
|
|
18
|
-
|
|
19
|
-
const execFileAsync = promisify(execFile)
|
|
15
|
+
import type { ProjectFileMeta, ProjectFilePlane } from '@contrast/harness/plane'
|
|
20
16
|
|
|
21
17
|
export class CheckoutRequiresGitError extends Error {}
|
|
22
18
|
|
|
23
|
-
// a NUL byte is the usual "this is not text" test.
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
// time is not an option: just-bash's `cat` discards every readFile
|
|
29
|
-
// reports "No such file or directory" whatever the error says, so
|
|
30
|
-
// has to
|
|
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. HarnessProjectFs 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.
|
|
31
27
|
const BINARY_SNIFF_BYTES = 8192
|
|
32
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
|
+
|
|
33
54
|
function looksBinary(filePath: string, size: number): boolean {
|
|
34
55
|
if (size === 0) return false
|
|
35
56
|
let fd: number | undefined
|
|
@@ -45,12 +66,32 @@ function looksBinary(filePath: string, size: number): boolean {
|
|
|
45
66
|
}
|
|
46
67
|
}
|
|
47
68
|
|
|
69
|
+
// the box re-indexes before every command, so the sniff above runs against the
|
|
70
|
+
// whole project every time a user presses return. on a 14,889-file checkout
|
|
71
|
+
// that is 277-644 ms of a 430-840 ms refresh, and nearly all of it re-reads
|
|
72
|
+
// files that did not change. a file whose size and mtime both match the last
|
|
73
|
+
// look has the same first 8 KB, so remember the answer against those two.
|
|
74
|
+
// `statSync` already returns mtimeMs, and it carries sub-millisecond
|
|
75
|
+
// resolution here, so the window in which a same-size rewrite could hide is
|
|
76
|
+
// narrower than a millisecond rather than git's one-second racy-clean case.
|
|
77
|
+
interface SniffedFile {
|
|
78
|
+
size: number
|
|
79
|
+
mtimeMs: number
|
|
80
|
+
binary: boolean
|
|
81
|
+
}
|
|
82
|
+
|
|
48
83
|
export class CheckoutFilePlane implements ProjectFilePlane {
|
|
49
84
|
readonly root: string
|
|
50
85
|
private entries = new Map<string, ProjectFileMeta>()
|
|
86
|
+
private sniffed = new Map<string, SniffedFile>()
|
|
87
|
+
// the deadline above, as a number a caller can set. what it changes is how
|
|
88
|
+
// long a wedged git is waited on, never whether it is waited on, so proving
|
|
89
|
+
// the kill does not have to cost the production deadline.
|
|
90
|
+
private readonly listTimeoutMs: number
|
|
51
91
|
|
|
52
|
-
constructor(root: string) {
|
|
92
|
+
constructor(root: string, options: { listTimeoutMs?: number } = {}) {
|
|
53
93
|
this.root = path.resolve(root)
|
|
94
|
+
this.listTimeoutMs = options.listTimeoutMs ?? LIST_FILES_TIMEOUT_MS
|
|
54
95
|
}
|
|
55
96
|
|
|
56
97
|
index(): ReadonlyMap<string, ProjectFileMeta> {
|
|
@@ -63,20 +104,47 @@ export class CheckoutFilePlane implements ProjectFilePlane {
|
|
|
63
104
|
// -z so paths containing spaces or newlines survive; -co --exclude-standard
|
|
64
105
|
// is tracked plus untracked-not-ignored, which is what a person means by
|
|
65
106
|
// "the files in my project".
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
107
|
+
stdout = execFileSync('git', ['ls-files', '-co', '--exclude-standard', '-z'], {
|
|
108
|
+
cwd: this.root,
|
|
109
|
+
encoding: 'utf8',
|
|
110
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
111
|
+
timeout: this.listTimeoutMs,
|
|
112
|
+
// stating the environment is not redundant here. bun 1.3.14 resolves
|
|
113
|
+
// the binary for the synchronous form against the PATH it captured at
|
|
114
|
+
// startup and ignores a later change to `process.env.PATH`, so without
|
|
115
|
+
// this it runs a different git than every other call in the process.
|
|
116
|
+
env: process.env,
|
|
117
|
+
})
|
|
72
118
|
} catch (error) {
|
|
73
119
|
const message = error instanceof Error ? error.message : String(error)
|
|
120
|
+
// the deadline kills with SIGTERM, so that signal is what tells a bound
|
|
121
|
+
// run apart from git failing on its own terms, and it is worth naming
|
|
122
|
+
// separately: "git was killed by SIGTERM" reads as git's problem, when
|
|
123
|
+
// what happened is that it never answered.
|
|
124
|
+
const killed =
|
|
125
|
+
error instanceof Error && 'signal' in error && error.signal === 'SIGTERM'
|
|
126
|
+
throw new CheckoutRequiresGitError(
|
|
127
|
+
killed
|
|
128
|
+
? `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.`
|
|
129
|
+
: `could not list the project's files with git in ${this.root}: ${message}`,
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// a busy machine also hands back a child that exits 0 having written
|
|
134
|
+
// nothing, which docs/testing.md owns: empty child output never means an
|
|
135
|
+
// empty answer. without this the box would build an empty index and every
|
|
136
|
+
// read would report the file as missing, which is a broken project rather
|
|
137
|
+
// than a broken box, and nothing would say so.
|
|
138
|
+
if (stdout.length === 0) {
|
|
74
139
|
throw new CheckoutRequiresGitError(
|
|
75
|
-
`
|
|
140
|
+
`git listed no files in ${this.root}. either git tracks nothing there and nothing untracked is visible to it, or this machine could not read the child process's output.`,
|
|
76
141
|
)
|
|
77
142
|
}
|
|
78
143
|
|
|
79
144
|
const next = new Map<string, ProjectFileMeta>()
|
|
145
|
+
// rebuilt alongside the index rather than pruned, so a deleted file's
|
|
146
|
+
// record cannot outlive it in a box that stays open all day
|
|
147
|
+
const nextSniffed = new Map<string, SniffedFile>()
|
|
80
148
|
for (const relative of stdout.split('\0')) {
|
|
81
149
|
if (!relative) continue
|
|
82
150
|
let stat: fs.Stats
|
|
@@ -87,12 +155,22 @@ export class CheckoutFilePlane implements ProjectFilePlane {
|
|
|
87
155
|
continue
|
|
88
156
|
}
|
|
89
157
|
if (!stat.isFile()) continue
|
|
158
|
+
const seen = this.sniffed.get(relative)
|
|
159
|
+
const binary =
|
|
160
|
+
seen && seen.size === stat.size && seen.mtimeMs === stat.mtimeMs
|
|
161
|
+
? seen.binary
|
|
162
|
+
: looksBinary(path.join(this.root, relative), stat.size)
|
|
163
|
+
nextSniffed.set(relative, { size: stat.size, mtimeMs: stat.mtimeMs, binary })
|
|
90
164
|
next.set(relative, {
|
|
91
165
|
size: stat.size,
|
|
92
|
-
|
|
166
|
+
// a checkout carries no write policy of its own; if the user can write
|
|
167
|
+
// the file, so can the shell
|
|
168
|
+
readOnly: false,
|
|
169
|
+
binary,
|
|
93
170
|
})
|
|
94
171
|
}
|
|
95
172
|
this.entries = next
|
|
173
|
+
this.sniffed = nextSniffed
|
|
96
174
|
}
|
|
97
175
|
|
|
98
176
|
private absolute(planePath: string): string {
|
|
@@ -113,13 +191,15 @@ export class CheckoutFilePlane implements ProjectFilePlane {
|
|
|
113
191
|
return fs.promises.readFile(this.absolute(planePath))
|
|
114
192
|
}
|
|
115
193
|
|
|
116
|
-
async write(planePath: string, content:
|
|
194
|
+
async write(planePath: string, content: Uint8Array): Promise<void> {
|
|
117
195
|
const absolute = this.absolute(planePath)
|
|
118
196
|
await fs.promises.mkdir(path.dirname(absolute), { recursive: true })
|
|
119
|
-
await fs.promises.writeFile(absolute, content
|
|
197
|
+
await fs.promises.writeFile(absolute, content)
|
|
120
198
|
this.entries.set(planePath, {
|
|
121
|
-
size:
|
|
199
|
+
size: content.byteLength,
|
|
122
200
|
readOnly: false,
|
|
201
|
+
// the bytes are in hand, so the same NUL test costs nothing here
|
|
202
|
+
binary: content.subarray(0, BINARY_SNIFF_BYTES).includes(0),
|
|
123
203
|
})
|
|
124
204
|
}
|
|
125
205
|
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// the client half of a cloud box: create it, seed this checkout into it, and
|
|
2
|
+
// run its shell over the box's own WebSocket.
|
|
3
|
+
//
|
|
4
|
+
// the box holds the files and the shell; this process holds neither. so the
|
|
5
|
+
// REPL here sends `shell:exec` and prints what comes back, and `cwd` is
|
|
6
|
+
// whatever the box last reported rather than anything tracked locally. that is
|
|
7
|
+
// the whole difference between the two placements, and it is why the same
|
|
8
|
+
// readline loop drives both.
|
|
9
|
+
//
|
|
10
|
+
// there is no default endpoint. a cloud box has to be deployed before one can
|
|
11
|
+
// exist, and pointing at a URL that answers nothing would advertise a
|
|
12
|
+
// capability that always fails.
|
|
13
|
+
|
|
14
|
+
import WebSocket from 'ws'
|
|
15
|
+
import type { CheckoutFilePlane } from './checkout-plane'
|
|
16
|
+
import type { HarnessShellResult } from '@contrast/harness/shell'
|
|
17
|
+
|
|
18
|
+
// a checkout is 15k files on this repo, so seeding is batched by BYTES rather
|
|
19
|
+
// than by count: one request carrying every file would be a single multi-
|
|
20
|
+
// hundred-megabyte body, and one request per file would be 15k round trips.
|
|
21
|
+
const SEED_BATCH_BYTES = 4 * 1024 * 1024
|
|
22
|
+
// base64 costs 4 bytes for every 3, and a request carries the encoded size.
|
|
23
|
+
const BASE64_OVERHEAD = 4 / 3
|
|
24
|
+
|
|
25
|
+
export interface CloudBoxSession {
|
|
26
|
+
readonly cwd: string
|
|
27
|
+
exec(command: string): Promise<HarnessShellResult>
|
|
28
|
+
close(): void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class CloudBoxError extends Error {}
|
|
32
|
+
|
|
33
|
+
function endpointOf(base: string, name: string, verb: string): string {
|
|
34
|
+
return `${base.replace(/\/+$/, '')}/v1/boxes/${encodeURIComponent(name)}/${verb}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function boxFetch(
|
|
38
|
+
base: string,
|
|
39
|
+
name: string,
|
|
40
|
+
verb: string,
|
|
41
|
+
token: string | null,
|
|
42
|
+
body?: unknown,
|
|
43
|
+
): Promise<Record<string, unknown>> {
|
|
44
|
+
const headers: Record<string, string> = { 'content-type': 'application/json' }
|
|
45
|
+
if (token) headers.authorization = `Bearer ${token}`
|
|
46
|
+
let response: Response
|
|
47
|
+
try {
|
|
48
|
+
response = await fetch(endpointOf(base, name, verb), {
|
|
49
|
+
method: body === undefined ? 'GET' : 'POST',
|
|
50
|
+
headers,
|
|
51
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
52
|
+
})
|
|
53
|
+
} catch (error) {
|
|
54
|
+
throw new CloudBoxError(
|
|
55
|
+
`could not reach the box service at ${base}: ${
|
|
56
|
+
error instanceof Error ? error.message : String(error)
|
|
57
|
+
}`,
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
const text = await response.text()
|
|
61
|
+
const parsed = text ? JSON.parse(text) : {}
|
|
62
|
+
if (!response.ok) {
|
|
63
|
+
throw new CloudBoxError(
|
|
64
|
+
String(parsed.error ?? `box ${verb} failed (${response.status})`),
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
return parsed
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* create the box and copy this checkout into it.
|
|
72
|
+
*
|
|
73
|
+
* `apiKey` is the ACCOUNT's key, which the box service forwards to
|
|
74
|
+
* /v1/rnx/sessions to authenticate the caller and start the session that pays
|
|
75
|
+
* for the box. the box's own token comes back minted by the box; a caller does
|
|
76
|
+
* not choose it.
|
|
77
|
+
*/
|
|
78
|
+
export async function createCloudBox(options: {
|
|
79
|
+
endpoint: string
|
|
80
|
+
name: string
|
|
81
|
+
apiKey: string
|
|
82
|
+
plane: CheckoutFilePlane
|
|
83
|
+
onProgress?: (seeded: number, total: number) => void
|
|
84
|
+
}): Promise<{ files: number; token: string; sessionId: string }> {
|
|
85
|
+
const { endpoint, name, apiKey, plane } = options
|
|
86
|
+
const created = await boxFetch(endpoint, name, 'create', apiKey, { name })
|
|
87
|
+
const token = String(created.token ?? '')
|
|
88
|
+
if (!token) throw new CloudBoxError('the box service returned no access token')
|
|
89
|
+
|
|
90
|
+
const paths = [...plane.index().keys()].sort()
|
|
91
|
+
let batch: [string, string][] = []
|
|
92
|
+
let batchBytes = 0
|
|
93
|
+
let seeded = 0
|
|
94
|
+
|
|
95
|
+
const flush = async () => {
|
|
96
|
+
if (batch.length === 0) return
|
|
97
|
+
await boxFetch(endpoint, name, 'seed', token, { files: batch })
|
|
98
|
+
seeded += batch.length
|
|
99
|
+
options.onProgress?.(seeded, paths.length)
|
|
100
|
+
batch = []
|
|
101
|
+
batchBytes = 0
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
for (const path of paths) {
|
|
105
|
+
const bytes = await plane.readBytes(path)
|
|
106
|
+
batch.push([path, Buffer.from(bytes).toString('base64')])
|
|
107
|
+
batchBytes += bytes.byteLength * BASE64_OVERHEAD
|
|
108
|
+
if (batchBytes >= SEED_BATCH_BYTES) await flush()
|
|
109
|
+
}
|
|
110
|
+
await flush()
|
|
111
|
+
|
|
112
|
+
const status = await boxFetch(endpoint, name, 'status', token)
|
|
113
|
+
return {
|
|
114
|
+
files: Number(status.files ?? 0),
|
|
115
|
+
token,
|
|
116
|
+
sessionId: String(status.sessionId ?? ''),
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* stop a box, or stop and wipe it.
|
|
122
|
+
*
|
|
123
|
+
* takes the ACCOUNT key, not the box token: it ends the session the account is
|
|
124
|
+
* billed for, which the box deliberately cannot do on its own.
|
|
125
|
+
*/
|
|
126
|
+
export async function stopCloudBox(options: {
|
|
127
|
+
endpoint: string
|
|
128
|
+
name: string
|
|
129
|
+
apiKey: string
|
|
130
|
+
purge?: boolean
|
|
131
|
+
}): Promise<Record<string, unknown>> {
|
|
132
|
+
return boxFetch(
|
|
133
|
+
options.endpoint,
|
|
134
|
+
options.name,
|
|
135
|
+
options.purge ? 'delete' : 'stop',
|
|
136
|
+
options.apiKey,
|
|
137
|
+
{},
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* the account's running boxes, from the session meter rather than from the box
|
|
143
|
+
* service. the meter already knows what this account is running and what it
|
|
144
|
+
* cost, so a second per-account index of boxes would be a second answer to the
|
|
145
|
+
* same question, free to drift.
|
|
146
|
+
*/
|
|
147
|
+
export async function listCloudBoxes(options: {
|
|
148
|
+
apiOrigin: string
|
|
149
|
+
apiKey: string
|
|
150
|
+
}): Promise<{ id: string; name: string | null; startedAt: string; meteredMs: number }[]> {
|
|
151
|
+
const response = await fetch(
|
|
152
|
+
`${options.apiOrigin.replace(/\/+$/, '')}/v1/rnx/sessions`,
|
|
153
|
+
{
|
|
154
|
+
headers: { authorization: `Bearer ${options.apiKey}` },
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
const text = await response.text()
|
|
158
|
+
const parsed = text ? JSON.parse(text) : {}
|
|
159
|
+
if (!response.ok) {
|
|
160
|
+
throw new CloudBoxError(
|
|
161
|
+
String(parsed.message ?? parsed.error ?? 'could not list boxes'),
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
const sessions = Array.isArray(parsed.sessions) ? parsed.sessions : []
|
|
165
|
+
return sessions
|
|
166
|
+
.filter((session: { kind?: string }) => session.kind === 'box')
|
|
167
|
+
.map((session: Record<string, unknown>) => ({
|
|
168
|
+
id: String(session.id),
|
|
169
|
+
name: typeof session.name === 'string' ? session.name : null,
|
|
170
|
+
startedAt: String(session.startedAt ?? ''),
|
|
171
|
+
meteredMs: Number(session.meteredMs ?? 0),
|
|
172
|
+
}))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* bank the wall clock while a box is open.
|
|
177
|
+
*
|
|
178
|
+
* the box holds no account credential and so cannot heartbeat for itself; the
|
|
179
|
+
* client that is actually using it does. when this process dies the heartbeats
|
|
180
|
+
* stop and the site's stale sweep closes the session at the last one, which is
|
|
181
|
+
* why there is no second idle timer in the box.
|
|
182
|
+
*/
|
|
183
|
+
export function heartbeatSession(options: {
|
|
184
|
+
apiOrigin: string
|
|
185
|
+
apiKey: string
|
|
186
|
+
sessionId: string
|
|
187
|
+
everyMs?: number
|
|
188
|
+
}): { stop: () => void } {
|
|
189
|
+
const url = `${options.apiOrigin.replace(/\/+$/, '')}/v1/rnx/sessions/${encodeURIComponent(
|
|
190
|
+
options.sessionId,
|
|
191
|
+
)}`
|
|
192
|
+
const timer = setInterval(() => {
|
|
193
|
+
void fetch(url, {
|
|
194
|
+
method: 'POST',
|
|
195
|
+
headers: { authorization: `Bearer ${options.apiKey}` },
|
|
196
|
+
}).catch(() => {
|
|
197
|
+
// a missed heartbeat is not an error worth interrupting a shell for.
|
|
198
|
+
// the sweep closes the session at the last one that landed.
|
|
199
|
+
})
|
|
200
|
+
}, options.everyMs ?? 60_000)
|
|
201
|
+
// do not hold the process open just to keep billing alive
|
|
202
|
+
timer.unref?.()
|
|
203
|
+
return { stop: () => clearInterval(timer) }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** open the box's socket and return a session the REPL can drive. */
|
|
207
|
+
export async function connectCloudBox(options: {
|
|
208
|
+
endpoint: string
|
|
209
|
+
name: string
|
|
210
|
+
token: string
|
|
211
|
+
}): Promise<CloudBoxSession> {
|
|
212
|
+
const { endpoint, name, token } = options
|
|
213
|
+
const url = endpointOf(endpoint, name, 'connect').replace(/^http/, 'ws')
|
|
214
|
+
// as a subprotocol, not a header. a browser cannot set a header on a
|
|
215
|
+
// WebSocket, and the box takes the token one way rather than one way per
|
|
216
|
+
// kind of caller.
|
|
217
|
+
const socket = new WebSocket(url, [`rnx-box-token.${token}`])
|
|
218
|
+
|
|
219
|
+
// a persistent handler, installed before the handshake can fail. ws emits
|
|
220
|
+
// 'error' again after a refused upgrade, and a `once` listener consumed by
|
|
221
|
+
// the rejection leaves that second emit unhandled, which crashes the
|
|
222
|
+
// process rather than failing the command.
|
|
223
|
+
let socketError: string | null = null
|
|
224
|
+
socket.on('error', (error) => {
|
|
225
|
+
socketError = error.message
|
|
226
|
+
})
|
|
227
|
+
await new Promise<void>((resolve, reject) => {
|
|
228
|
+
socket.once('open', resolve)
|
|
229
|
+
socket.once('error', (error) => {
|
|
230
|
+
socket.close()
|
|
231
|
+
reject(error)
|
|
232
|
+
})
|
|
233
|
+
}).catch(async (error: unknown) => {
|
|
234
|
+
// ws's handshake error text is runtime-specific: node says "Unexpected
|
|
235
|
+
// server response: 401" and bun says "Expected 101 status code" with no
|
|
236
|
+
// status at all. so ask the service itself why, over plain HTTP, and
|
|
237
|
+
// report THAT. this runs only on the failure path.
|
|
238
|
+
const reason = await boxFetch(endpoint, name, 'status', token).then(
|
|
239
|
+
() => null,
|
|
240
|
+
(failure: unknown) =>
|
|
241
|
+
failure instanceof Error ? failure.message : String(failure),
|
|
242
|
+
)
|
|
243
|
+
const fallback = error instanceof Error ? error.message : String(error)
|
|
244
|
+
throw new CloudBoxError(`could not open the box shell: ${reason ?? fallback}`)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
let nextId = 0
|
|
248
|
+
const waiting = new Map<number, (reply: Record<string, unknown>) => void>()
|
|
249
|
+
socket.on('message', (data) => {
|
|
250
|
+
let parsed: unknown
|
|
251
|
+
try {
|
|
252
|
+
parsed = JSON.parse(String(data))
|
|
253
|
+
} catch {
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
if (typeof parsed !== 'object' || parsed === null) return
|
|
257
|
+
const reply = parsed as Record<string, unknown>
|
|
258
|
+
const resolve = waiting.get(Number(reply.id))
|
|
259
|
+
if (!resolve) return
|
|
260
|
+
waiting.delete(Number(reply.id))
|
|
261
|
+
resolve(reply)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
// the socket dying mid-command must fail the command rather than hang the
|
|
265
|
+
// prompt forever, so every waiter is settled when it closes.
|
|
266
|
+
let closedReason: string | null = null
|
|
267
|
+
const settleAll = (reason: string) => {
|
|
268
|
+
closedReason = reason
|
|
269
|
+
for (const [id, resolve] of waiting) resolve({ id, error: reason })
|
|
270
|
+
waiting.clear()
|
|
271
|
+
}
|
|
272
|
+
socket.on('close', () => settleAll(socketError ?? 'the box closed the connection'))
|
|
273
|
+
socket.on('error', (error) => settleAll(error.message))
|
|
274
|
+
|
|
275
|
+
let cwd = '/project'
|
|
276
|
+
return {
|
|
277
|
+
get cwd() {
|
|
278
|
+
return cwd
|
|
279
|
+
},
|
|
280
|
+
async exec(command: string): Promise<HarnessShellResult> {
|
|
281
|
+
if (closedReason) throw new CloudBoxError(closedReason)
|
|
282
|
+
const id = nextId++
|
|
283
|
+
const reply = await new Promise<Record<string, unknown>>((resolve) => {
|
|
284
|
+
waiting.set(id, resolve)
|
|
285
|
+
socket.send(JSON.stringify({ type: 'shell:exec', id, command }))
|
|
286
|
+
})
|
|
287
|
+
if (reply.error) throw new CloudBoxError(String(reply.error))
|
|
288
|
+
const result = reply.result as HarnessShellResult
|
|
289
|
+
if (result.cwd) cwd = result.cwd
|
|
290
|
+
return result
|
|
291
|
+
},
|
|
292
|
+
close: () => socket.close(),
|
|
293
|
+
}
|
|
294
|
+
}
|