rnxsim 0.1.518 → 0.1.520

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.
Files changed (69) hide show
  1. package/cli/cloud-client.ts +419 -287
  2. package/cli/cloud-dispatch.ts +2 -21
  3. package/cli/cloud-session.ts +31 -85
  4. package/cli/commands/box.ts +15 -11
  5. package/cli/commands/inspect.ts +34 -1
  6. package/cli/commands/platform.ts +184 -232
  7. package/cli/main.ts +10 -2
  8. package/cli/outbound-endpoints.ts +21 -6
  9. package/detox/index.ts +9 -1
  10. package/dist-lib/agent-daemon-client.cjs +1 -1
  11. package/dist-lib/agent-events.cjs +1 -1
  12. package/dist-lib/agent-identity.cjs +1 -1
  13. package/dist-lib/agent-sessions.cjs +1 -1
  14. package/dist-lib/attached-projects.cjs +1 -1
  15. package/dist-lib/auth/shared-session.cjs +1 -1
  16. package/dist-lib/backend-origin.cjs +1 -1
  17. package/dist-lib/beta.cjs +1 -1
  18. package/dist-lib/beta.mjs +1 -1
  19. package/dist-lib/bridge-constants.cjs +1 -1
  20. package/dist-lib/bridge-contract-input.cjs +1 -1
  21. package/dist-lib/bridge-contract-input.mjs +1 -1
  22. package/dist-lib/bridge-contract.cjs +1 -1
  23. package/dist-lib/bridge-contract.mjs +1 -1
  24. package/dist-lib/capture-contract.cjs +1 -1
  25. package/dist-lib/capture-contract.mjs +1 -1
  26. package/dist-lib/cli-constants.cjs +1 -1
  27. package/dist-lib/cloud-contract.cjs +1 -1
  28. package/dist-lib/cloud-contract.mjs +1 -1
  29. package/dist-lib/cloud.cjs +1 -1
  30. package/dist-lib/cloud.mjs +1 -1
  31. package/dist-lib/config.cjs +1 -1
  32. package/dist-lib/detox/index.cjs +5 -2
  33. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  34. package/dist-lib/home-paths.cjs +1 -1
  35. package/dist-lib/host/bridge-host.cjs +112 -39
  36. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  37. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  38. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  39. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  40. package/dist-lib/host/websocket-proxy.cjs +1 -1
  41. package/dist-lib/index.cjs +43 -117
  42. package/dist-lib/jump-to-source-babel.cjs +1 -1
  43. package/dist-lib/jump-to-source-native.cjs +1 -1
  44. package/dist-lib/menu.cjs +30 -1
  45. package/dist-lib/menu.mjs +27 -1
  46. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  47. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  48. package/dist-lib/metro-production-bundle.cjs +1 -1
  49. package/dist-lib/metro-production-bundle.mjs +1 -1
  50. package/dist-lib/metro.cjs +1 -1
  51. package/dist-lib/profiles.cjs +1 -1
  52. package/dist-lib/public-brand.cjs +1 -1
  53. package/dist-lib/react-native-host-modules.cjs +1 -1
  54. package/dist-lib/react-native-host-modules.mjs +1 -1
  55. package/dist-lib/render-mode.cjs +1 -1
  56. package/dist-lib/scripts/dev-server-scanner.cjs +41 -4
  57. package/dist-lib/sdk.cjs +1 -1
  58. package/dist-lib/sdk.mjs +1 -1
  59. package/dist-lib/skills.cjs +138 -164
  60. package/dist-lib/swift.cjs +674 -12
  61. package/dist-lib/vite.cjs +26 -3
  62. package/package.json +1 -1
  63. package/scripts/dev-server-scanner.ts +72 -5
  64. package/src/cloud-contract.ts +7 -1
  65. package/src/cloud.ts +11 -4
  66. package/src/connect.ts +1 -1
  67. package/src/menu.ts +44 -0
  68. package/src/vite-plugin-one.ts +12 -0
  69. package/src/vite-plugin-swift.ts +17 -5
@@ -11,6 +11,7 @@ import { dirname, extname, join, resolve } from 'node:path'
11
11
  import {
12
12
  embedMetroAssetFiles,
13
13
  extractAssetDescriptors,
14
+ isEmbeddedAssetDescriptor,
14
15
  metroAssetPlaceholderBytes,
15
16
  metroAssetScaleFilename,
16
17
  metroIosAssetDestRelatives,
@@ -27,44 +28,31 @@ import { inferMetroModuleIdentity } from '../../sootsim-engine/src/metro-fingerp
27
28
  import { isLoopbackHost } from '../src/backend-origin.ts'
28
29
  import {
29
30
  isRnxCloudCommandType,
31
+ isRnxCloudSimStatus,
30
32
  RNX_CLOUD_MAX_ARTIFACT_BYTES,
31
33
  RNX_CLOUD_TOKEN_SCOPES,
32
34
  rnxCloudCommandScope,
33
35
  rnxCloudScopesAllowCommand,
34
36
  type RnxCloudBoxCreateReceipt,
35
- type RnxCloudCreateReceipt,
37
+ type RnxCloudClaim,
38
+ type RnxCloudSimCreateResponse,
39
+ type RnxCloudSimInstance,
40
+ type RnxCloudSimView,
36
41
  } from '../src/cloud-contract.ts'
37
42
  import {
38
43
  RNX_METRO_MODULE_IDENTITY_VERSION,
39
44
  type RNXMetroModuleIdentityMetadata,
40
45
  } from '../src/metro-production-bundle.ts'
41
46
  import { rnxPublicBrand } from '../src/public-brand.ts'
42
- import { CHOOSE_ACCOUNT_HINT, resolveCliAuth } from './auth.ts'
47
+ import { CHOOSE_ACCOUNT_HINT } from './auth.ts'
43
48
  import {
44
49
  createCloudSession,
45
50
  normalizeCloudOrigin,
46
51
  readCloudSession,
47
- type CloudBoxSession,
48
52
  type CloudSession,
49
53
  type CloudSimSession,
50
54
  type RemotePlatform,
51
55
  } from './cloud-session.ts'
52
-
53
- export class UnsupportedRemoteHostError extends Error {
54
- readonly code = 'unsupported_host' as const
55
- readonly platform: string
56
- readonly host: string
57
-
58
- constructor(platform: string, host: string, message?: string) {
59
- super(
60
- message ??
61
- `rnx remote ${platform} does not support --${host}: rnxsim/cloud only creates iOS simulators`,
62
- )
63
- this.name = 'UnsupportedRemoteHostError'
64
- this.platform = platform
65
- this.host = host
66
- }
67
- }
68
56
  import { detectProject } from './commands/detect.ts'
69
57
  import type { StorageSnapshot } from '../../sootsim-engine/src/preview/storage-snapshot-contract.ts'
70
58
  import type {
@@ -129,7 +117,12 @@ function failureSentence(text: string): string | null {
129
117
  }
130
118
  if (!isRecord(parsed)) return null
131
119
  if (isRecord(parsed.error) && typeof parsed.error.message === 'string') {
132
- return parsed.error.message
120
+ // the code is the contract a script matches on; the message is for people.
121
+ const code =
122
+ typeof parsed.error.code === 'string' && parsed.error.code
123
+ ? ` [${parsed.error.code}]`
124
+ : ''
125
+ return `${parsed.error.message}${code}`
133
126
  }
134
127
  if (typeof parsed.message === 'string') return parsed.message
135
128
  if (typeof parsed.error === 'string') return parsed.error
@@ -693,20 +686,68 @@ export async function producePreviewArtifact(
693
686
  )
694
687
  }
695
688
 
696
- async function produceArtifact(
689
+ export interface CloudBoxArtifactProduction {
690
+ bytes: Buffer
691
+ assetsDirectory: string | null
692
+ warnings: string[]
693
+ }
694
+
695
+ /** prepares a prebuilt bundle for a Box by embedding any beside-the-bundle Metro assets. */
696
+ export async function produceCloudBoxArtifact(
697
+ bundlePath: string,
698
+ ): Promise<CloudBoxArtifactProduction> {
699
+ const prepared = await embedBundleAssets(
700
+ bundlePath,
701
+ 'rnx box create',
702
+ 'allow-placeholders',
703
+ )
704
+ const warnings: string[] = []
705
+ if (prepared.search.unconfirmed && prepared.dest) {
706
+ warnings.push(
707
+ `rnx box create read this bundle's assets from ${prepared.dest.directory} on layout alone: every asset declares a scale that directory does not hold, so not one of Metro's hashes could be reproduced. Confirm these are the images this bundle was built against.`,
708
+ )
709
+ }
710
+ if (prepared.substituted.length > 0) {
711
+ warnings.push(
712
+ `rnx box create embedded a placeholder image for ${prepared.substituted.length} asset(s) no scale of which is in ${prepared.search.searched.join(', ') || 'any asset dest directory'}: ${prepared.substituted.join(', ')}. Rebuild with \`react-native bundle --assets-dest\` to ship the real images.`,
713
+ )
714
+ }
715
+ return {
716
+ bytes: Buffer.from(prepared.code, 'utf8'),
717
+ assetsDirectory: prepared.dest?.directory ?? null,
718
+ warnings,
719
+ }
720
+ }
721
+
722
+ async function embedBundleAssets(
697
723
  bundlePath: string,
698
- maxBytes: number,
699
724
  command: string,
700
725
  assetPolicy: 'allow-placeholders' | 'require-assets',
701
- ): Promise<CloudArtifactProduction> {
726
+ ): Promise<{
727
+ source: string
728
+ code: string
729
+ search: MetroAssetSearch
730
+ dest: MetroAssetDest | null
731
+ substituted: string[]
732
+ }> {
702
733
  const source = await loadBundleSource(bundlePath)
703
- const descriptors = extractAssetDescriptors(source)
734
+ const descriptors = extractAssetDescriptors(source).filter(
735
+ (descriptor) => !isEmbeddedAssetDescriptor(descriptor),
736
+ )
737
+ if (descriptors.length === 0) {
738
+ return {
739
+ source,
740
+ code: source,
741
+ search: { dest: null, searched: [], truncated: false, unconfirmed: false },
742
+ dest: null,
743
+ substituted: [],
744
+ }
745
+ }
704
746
  // an https bundle has no local tree to search, and a bundle with no assets
705
747
  // has nothing to search for.
706
- const search: MetroAssetSearch =
707
- descriptors.length > 0 && !isRemoteBundleInput(bundlePath)
708
- ? searchMetroAssetDest(bundlePath, descriptors)
709
- : { dest: null, searched: [], truncated: false, unconfirmed: false }
748
+ const search: MetroAssetSearch = !isRemoteBundleInput(bundlePath)
749
+ ? searchMetroAssetDest(bundlePath, descriptors)
750
+ : { dest: null, searched: [], truncated: false, unconfirmed: false }
710
751
  const dest = search.dest
711
752
  // `embedMetroAssetFiles` re-parses the bundle, so its descriptors are equal
712
753
  // to the ones the search placed but not the same objects.
@@ -768,7 +809,23 @@ async function produceArtifact(
768
809
  `${command} could not read ${join(dest?.directory ?? '.', classic)} for ${variant.descriptor.name}.${variant.descriptor.type} at scale ${variant.scale}, and has no stand-in for a .${variant.descriptor.type} asset. Rebuild with \`react-native bundle --assets-dest\`.`,
769
810
  )
770
811
  })
771
- const artifact = await buildRnxCloudArtifact(embedded.code, {
812
+ return {
813
+ source,
814
+ code: embedded.code,
815
+ search,
816
+ dest,
817
+ substituted,
818
+ }
819
+ }
820
+
821
+ async function produceArtifact(
822
+ bundlePath: string,
823
+ maxBytes: number,
824
+ command: string,
825
+ assetPolicy: 'allow-placeholders' | 'require-assets',
826
+ ): Promise<CloudArtifactProduction> {
827
+ const prepared = await embedBundleAssets(bundlePath, command, assetPolicy)
828
+ const artifact = await buildRnxCloudArtifact(prepared.code, {
772
829
  inferModuleIdentity: inferCloudModuleIdentity,
773
830
  })
774
831
  const bytes = Buffer.from(artifact.code, 'utf8')
@@ -779,14 +836,14 @@ async function produceArtifact(
779
836
  )
780
837
  }
781
838
  const warnings: string[] = []
782
- if (search.unconfirmed && dest) {
839
+ if (prepared.search.unconfirmed && prepared.dest) {
783
840
  warnings.push(
784
- `${command} read this bundle's assets from ${dest.directory} on layout alone: every asset declares a scale that directory does not hold, so not one of Metro's hashes could be reproduced. Confirm these are the images this bundle was built against.`,
841
+ `${command} read this bundle's assets from ${prepared.dest.directory} on layout alone: every asset declares a scale that directory does not hold, so not one of Metro's hashes could be reproduced. Confirm these are the images this bundle was built against.`,
785
842
  )
786
843
  }
787
- if (substituted.length > 0) {
844
+ if (prepared.substituted.length > 0) {
788
845
  warnings.push(
789
- `${command} embedded a placeholder image for ${substituted.length} asset(s) no scale of which is in ${search.searched.join(', ') || 'any asset dest directory'}: ${substituted.join(', ')}. Rebuild with \`react-native bundle --assets-dest\` to ship the real images.`,
846
+ `${command} embedded a placeholder image for ${prepared.substituted.length} asset(s) no scale of which is in ${prepared.search.searched.join(', ') || 'any asset dest directory'}: ${prepared.substituted.join(', ')}. Rebuild with \`react-native bundle --assets-dest\` to ship the real images.`,
790
847
  )
791
848
  }
792
849
  // an artifact this close to the ceiling is one asset away from being refused,
@@ -800,7 +857,7 @@ async function produceArtifact(
800
857
  bytes,
801
858
  sha256: createHash('sha256').update(bytes).digest('hex'),
802
859
  receipt: artifact.receipt,
803
- assetsDirectory: dest?.directory ?? null,
860
+ assetsDirectory: prepared.dest?.directory ?? null,
804
861
  warnings,
805
862
  }
806
863
  }
@@ -864,8 +921,8 @@ function readCreateResponse(
864
921
  }
865
922
  }
866
923
 
867
- // `rnx ios --remote <bundle>` creates the remote host for the artifact and its
868
- // first simulator in one request, and the shell then targets that simulator
924
+ // "send to box" wraps the running app in a nano box with its first simulator
925
+ // in one request, and the shell then targets that simulator
869
926
  export async function createCloudBox(options: CreateCloudBoxOptions): Promise<{
870
927
  receipt: RnxCloudBoxCreateReceipt
871
928
  session: CloudSession
@@ -874,9 +931,6 @@ export async function createCloudBox(options: CreateCloudBoxOptions): Promise<{
874
931
  warnings: string[]
875
932
  }> {
876
933
  const platform = options.platform ?? 'ios'
877
- if (platform === 'android') {
878
- throw new UnsupportedRemoteHostError('android', 'nano')
879
- }
880
934
  const apiOrigin = resolveCloudOrigin(options.apiOrigin)
881
935
  const artifact = await produceCloudArtifact(options.bundlePath)
882
936
  const boxName = options.boxName?.trim()
@@ -949,23 +1003,323 @@ export async function createCloudBox(options: CreateCloudBoxOptions): Promise<{
949
1003
  }
950
1004
  }
951
1005
 
1006
+ // one customer plane: the sim-service instance API. every `rnx remote`
1007
+ // simulator is created, listed, read, and deleted here; the box stays the
1008
+ // host behind it and is never the addressed object.
1009
+
1010
+ export interface CreateRemoteSimOptions {
1011
+ bundlePath: string
1012
+ device: string
1013
+ authorization: string
1014
+ /** the account to bill when the authorization is a session token; an api key names its own */
1015
+ accountId: string | null
1016
+ platform: RemotePlatform
1017
+ apiOrigin?: string
1018
+ /** match an existing simulator for the same artifact and labels instead of creating */
1019
+ reuse?: boolean
1020
+ }
1021
+
1022
+ function readStringMap(value: unknown): Record<string, string> {
1023
+ if (!isRecord(value)) return {}
1024
+ const labels: Record<string, string> = {}
1025
+ for (const [key, label] of Object.entries(value)) {
1026
+ if (typeof label === 'string') labels[key] = label
1027
+ }
1028
+ return labels
1029
+ }
1030
+
1031
+ function readSimCreateResponse(
1032
+ value: unknown,
1033
+ expectedBytes: number,
1034
+ expectedSha256: string,
1035
+ ): RnxCloudSimCreateResponse {
1036
+ if (!isRecord(value)) throw new Error('remote simulator create returned invalid JSON')
1037
+ const artifactId = `sha256:${expectedSha256}` as const
1038
+ const artifact = isRecord(value.artifact) ? value.artifact : null
1039
+ const claim = isRecord(value.claim) ? value.claim : null
1040
+ if (
1041
+ typeof value.simId !== 'string' ||
1042
+ !value.simId ||
1043
+ !artifact ||
1044
+ artifact.id !== artifactId ||
1045
+ artifact.bytes !== expectedBytes ||
1046
+ !claim ||
1047
+ typeof claim.id !== 'string' ||
1048
+ !claim.id ||
1049
+ typeof claim.expiresAt !== 'number' ||
1050
+ typeof value.token !== 'string' ||
1051
+ !value.token ||
1052
+ typeof value.status !== 'string' ||
1053
+ !isRnxCloudSimStatus(value.status) ||
1054
+ (value.streamUrl !== null && typeof value.streamUrl !== 'string')
1055
+ ) {
1056
+ throw new Error('remote simulator create returned an invalid receipt')
1057
+ }
1058
+ return {
1059
+ simId: value.simId,
1060
+ artifact: { id: artifactId, bytes: expectedBytes },
1061
+ claim: { id: claim.id, expiresAt: claim.expiresAt },
1062
+ token: value.token,
1063
+ status: value.status,
1064
+ labels: readStringMap(value.labels),
1065
+ streamUrl: value.streamUrl,
1066
+ }
1067
+ }
1068
+
1069
+ // `rnx remote ios|android <bundle>` creates one simulator through the
1070
+ // instance API and waits for its app to answer ready. both platforms run
1071
+ // this same typed path: an android create is refused by the service with a
1072
+ // typed 400, never by a cli-side branch.
1073
+ export async function createRemoteSim(options: CreateRemoteSimOptions): Promise<{
1074
+ receipt: RnxCloudSimCreateResponse
1075
+ session: CloudSession
1076
+ artifact: RnxCloudArtifactReceipt
1077
+ assetsDirectory: string | null
1078
+ warnings: string[]
1079
+ reused: boolean
1080
+ }> {
1081
+ const apiOrigin = resolveCloudOrigin(options.apiOrigin)
1082
+ const artifact = await produceCloudArtifact(options.bundlePath)
1083
+ const query = new URLSearchParams({
1084
+ labels: `rnx.remote=1,rnx.platform=${options.platform},rnx.bundle=${artifact.sha256.slice(0, 12)}`,
1085
+ wait: 'true',
1086
+ })
1087
+ if (options.reuse) query.set('reuseIfExists', 'true')
1088
+ const headers: Record<string, string> = {
1089
+ authorization: options.authorization,
1090
+ 'content-type': 'application/javascript',
1091
+ 'x-rnx-artifact-sha256': artifact.sha256,
1092
+ 'x-rnx-platform': options.platform,
1093
+ 'x-rnx-device': options.device,
1094
+ ...(options.accountId ? { 'x-rnx-account-id': options.accountId } : null),
1095
+ }
1096
+ let response: Response
1097
+ try {
1098
+ response = await fetch(`${apiOrigin}/v1/sims?${query}`, {
1099
+ method: 'POST',
1100
+ headers,
1101
+ body: new Uint8Array(artifact.bytes),
1102
+ })
1103
+ } catch (error) {
1104
+ throw new Error(
1105
+ `${REMOTE_COMMAND} could not reach ${apiOrigin}: ${
1106
+ error instanceof Error ? error.message : String(error)
1107
+ }`,
1108
+ )
1109
+ }
1110
+ const responseText = await response.text()
1111
+ if (!response.ok) throw responseError(response.status, responseText)
1112
+ let parsed: unknown
1113
+ try {
1114
+ parsed = JSON.parse(responseText)
1115
+ } catch {
1116
+ throw new Error('remote simulator create returned unreadable JSON')
1117
+ }
1118
+ const receipt = readSimCreateResponse(parsed, artifact.bytes.length, artifact.sha256)
1119
+ const session = createCloudSession({
1120
+ simId: receipt.simId,
1121
+ apiOrigin,
1122
+ claimId: receipt.claim.id,
1123
+ token: receipt.token,
1124
+ streamUrl: receipt.streamUrl,
1125
+ platform: options.platform,
1126
+ device: options.device,
1127
+ artifact: {
1128
+ sha256: artifact.sha256,
1129
+ bytes: artifact.bytes.length,
1130
+ },
1131
+ })
1132
+ return {
1133
+ receipt,
1134
+ session,
1135
+ artifact: artifact.receipt,
1136
+ assetsDirectory: artifact.assetsDirectory,
1137
+ warnings: artifact.warnings,
1138
+ reused: response.status === 200,
1139
+ }
1140
+ }
1141
+
1142
+ function readSimView(value: unknown): RnxCloudSimView {
1143
+ if (!isRecord(value) || !isRecord(value.labels)) {
1144
+ throw new Error('remote simulator list returned an invalid simulator')
1145
+ }
1146
+ const labels: Record<string, string> = {}
1147
+ for (const [key, label] of Object.entries(value.labels)) {
1148
+ if (typeof label !== 'string') {
1149
+ throw new Error('remote simulator list returned an invalid simulator')
1150
+ }
1151
+ labels[key] = label
1152
+ }
1153
+ const artifact = isRecord(value.artifact) ? value.artifact : null
1154
+ const claimValue = isRecord(value.claim) ? value.claim : null
1155
+ let claim: RnxCloudClaim | null = null
1156
+ if (claimValue !== null) {
1157
+ if (
1158
+ typeof claimValue.id !== 'string' ||
1159
+ !claimValue.id ||
1160
+ typeof claimValue.expiresAt !== 'number'
1161
+ ) {
1162
+ throw new Error('remote simulator list returned an invalid simulator')
1163
+ }
1164
+ claim = { id: claimValue.id, expiresAt: claimValue.expiresAt }
1165
+ }
1166
+ const inactivityTimeoutMs = value.inactivityTimeoutMs
1167
+ const hardTimeoutAt = value.hardTimeoutAt
1168
+ if (
1169
+ typeof value.simId !== 'string' ||
1170
+ !value.simId ||
1171
+ typeof value.status !== 'string' ||
1172
+ !isRnxCloudSimStatus(value.status) ||
1173
+ !artifact ||
1174
+ typeof artifact.sha256 !== 'string' ||
1175
+ typeof artifact.bytes !== 'number' ||
1176
+ typeof value.createdAt !== 'number' ||
1177
+ typeof value.lastActiveAt !== 'number' ||
1178
+ (typeof inactivityTimeoutMs !== 'number' && inactivityTimeoutMs !== null) ||
1179
+ (typeof hardTimeoutAt !== 'number' && hardTimeoutAt !== null)
1180
+ ) {
1181
+ throw new Error('remote simulator list returned an invalid simulator')
1182
+ }
1183
+ return {
1184
+ simId: value.simId,
1185
+ status: value.status,
1186
+ labels,
1187
+ artifact: { sha256: artifact.sha256, bytes: artifact.bytes },
1188
+ claim,
1189
+ createdAt: value.createdAt,
1190
+ lastActiveAt: value.lastActiveAt,
1191
+ inactivityTimeoutMs,
1192
+ hardTimeoutAt,
1193
+ }
1194
+ }
1195
+
1196
+ // `rnx remote list`: the simulators this account runs that `rnx remote` made.
1197
+ export async function listRemoteSims(options: {
1198
+ apiOrigin?: string
1199
+ authorization: string
1200
+ accountId?: string | null
1201
+ }): Promise<RnxCloudSimView[]> {
1202
+ const apiOrigin = resolveCloudOrigin(options.apiOrigin)
1203
+ const selector = new URLSearchParams({ labelSelector: 'rnx.remote=1' })
1204
+ const headers: Record<string, string> = { authorization: options.authorization }
1205
+ if (options.accountId) headers['x-rnx-account-id'] = options.accountId
1206
+ let response: Response
1207
+ try {
1208
+ response = await fetch(`${apiOrigin}/v1/sims?${selector}`, { headers })
1209
+ } catch (error) {
1210
+ throw new Error(
1211
+ `${REMOTE_COMMAND} could not reach ${apiOrigin}: ${
1212
+ error instanceof Error ? error.message : String(error)
1213
+ }`,
1214
+ )
1215
+ }
1216
+ const responseText = await response.text()
1217
+ if (!response.ok) throw responseError(response.status, responseText)
1218
+ let parsed: unknown
1219
+ try {
1220
+ parsed = JSON.parse(responseText)
1221
+ } catch {
1222
+ throw new Error('remote simulator list returned unreadable JSON')
1223
+ }
1224
+ if (!isRecord(parsed) || !Array.isArray(parsed.sims)) {
1225
+ throw new Error('remote simulator list returned invalid JSON')
1226
+ }
1227
+ return parsed.sims.map(readSimView)
1228
+ }
1229
+
1230
+ // one simulator reread by id: the reattach liveness probe, and the fresh
1231
+ // watch url an api-key caller mints with it. a simulator this account no
1232
+ // longer runs reads back as missing rather than forbidden, so 404 is null
1233
+ // and anything else refused throws.
1234
+ export async function getRemoteSim(options: {
1235
+ apiOrigin?: string
1236
+ simId: string
1237
+ authorization: string
1238
+ }): Promise<RnxCloudSimInstance | null> {
1239
+ const apiOrigin = resolveCloudOrigin(options.apiOrigin)
1240
+ let response: Response
1241
+ try {
1242
+ response = await fetch(`${apiOrigin}/v1/sims/${encodeURIComponent(options.simId)}`, {
1243
+ headers: { authorization: options.authorization },
1244
+ })
1245
+ } catch (error) {
1246
+ throw new Error(
1247
+ `${REMOTE_COMMAND} could not reach ${apiOrigin}: ${
1248
+ error instanceof Error ? error.message : String(error)
1249
+ }`,
1250
+ )
1251
+ }
1252
+ const responseText = await response.text()
1253
+ if (response.status === 404) return null
1254
+ if (!response.ok) throw responseError(response.status, responseText)
1255
+ let parsed: unknown
1256
+ try {
1257
+ parsed = JSON.parse(responseText)
1258
+ } catch {
1259
+ throw new Error('remote simulator read returned unreadable JSON')
1260
+ }
1261
+ if (!isRecord(parsed) || !isRecord(parsed.sim)) {
1262
+ throw new Error('remote simulator read returned invalid JSON')
1263
+ }
1264
+ const view = readSimView(parsed.sim)
1265
+ const streamUrl = parsed.sim.streamUrl
1266
+ if (streamUrl !== null && streamUrl !== undefined && typeof streamUrl !== 'string') {
1267
+ throw new Error('remote simulator read returned an invalid watch url')
1268
+ }
1269
+ return {
1270
+ ...view,
1271
+ streamUrl: typeof streamUrl === 'string' ? streamUrl : null,
1272
+ }
1273
+ }
1274
+
1275
+ // `rnx remote stop <id>`: delete one simulator by id. missing is null so the
1276
+ // caller can name what was not there; anything else refused throws.
1277
+ export async function deleteRemoteSim(options: {
1278
+ apiOrigin?: string
1279
+ simId: string
1280
+ authorization: string
1281
+ accountId?: string | null
1282
+ }): Promise<{ simId: string; status: string } | null> {
1283
+ const apiOrigin = resolveCloudOrigin(options.apiOrigin)
1284
+ const headers: Record<string, string> = { authorization: options.authorization }
1285
+ if (options.accountId) headers['x-rnx-account-id'] = options.accountId
1286
+ let response: Response
1287
+ try {
1288
+ response = await fetch(`${apiOrigin}/v1/sims/${encodeURIComponent(options.simId)}`, {
1289
+ method: 'DELETE',
1290
+ headers,
1291
+ })
1292
+ } catch (error) {
1293
+ throw new Error(
1294
+ `${REMOTE_COMMAND} could not reach ${apiOrigin}: ${
1295
+ error instanceof Error ? error.message : String(error)
1296
+ }`,
1297
+ )
1298
+ }
1299
+ const responseText = await response.text()
1300
+ if (response.status === 404) return null
1301
+ if (!response.ok) throw responseError(response.status, responseText)
1302
+ let parsed: unknown
1303
+ try {
1304
+ parsed = JSON.parse(responseText)
1305
+ } catch {
1306
+ throw new Error('remote simulator delete returned unreadable JSON')
1307
+ }
1308
+ if (
1309
+ !isRecord(parsed) ||
1310
+ typeof parsed.simId !== 'string' ||
1311
+ typeof parsed.status !== 'string'
1312
+ ) {
1313
+ throw new Error('remote simulator delete returned invalid JSON')
1314
+ }
1315
+ return { simId: parsed.simId, status: parsed.status }
1316
+ }
1317
+
952
1318
  export function resolveCloudSessionForParsed(
953
1319
  parsed: Pick<ParsedBridgeCliArgs, 'simId' | 'simIdSource'>,
954
1320
  ): CloudSession | null {
955
1321
  const session = readCloudSession()
956
1322
  if (!session) return null
957
- // a box holds one simulator and names it by the box, so --sim names a box
958
- // rather than a sim there and this shell already knows which box it is on.
959
- if (session.service === 'box') {
960
- if (parsed.simIdSource === 'flag' && parsed.simId !== session.boxId) {
961
- throw new Error(
962
- `this shell is connected to box ${session.boxId}; ${
963
- parsed.simId ? `--sim ${parsed.simId}` : 'the requested simulator'
964
- } is not available in this shell`,
965
- )
966
- }
967
- return session
968
- }
969
1323
  if (parsed.simIdSource === 'flag' && parsed.simId !== session.simId) {
970
1324
  throw new Error(
971
1325
  `this shell is connected to remote simulator ${session.simId}; ${
@@ -1082,107 +1436,12 @@ class CloudBridge implements WsBridge {
1082
1436
  close(): void {}
1083
1437
  }
1084
1438
 
1085
- /**
1086
- * a micro box's bridge.
1087
- *
1088
- * the box holds no simulator of its own: the one it shows lives in its Box
1089
- * page, which runs the same shell the local CLI drives over the daemon bridge.
1090
- * so this transport moves the whole bridge vocabulary, admitted by caller
1091
- * scope exactly like the nano plane, and the box service is only the relay in
1092
- * the middle. that is also why there is no claim: the box token addresses the
1093
- * box, and the page answers for it.
1094
- */
1095
- class CloudBoxBridge implements WsBridge {
1096
- readonly plane = 'cloud' as const
1097
-
1098
- constructor(private readonly session: CloudBoxSession) {}
1099
-
1100
- async send(
1101
- command: { type: string; [key: string]: unknown },
1102
- options?: { timeoutMs?: number },
1103
- ): Promise<unknown> {
1104
- const id = randomUUID()
1105
- // the wire id is this request's, so the command carries none. `simId` names
1106
- // a local sim among several and a box has exactly one.
1107
- const payload: Record<string, unknown> = {}
1108
- for (const [key, value] of Object.entries(command)) {
1109
- if (key !== 'id' && key !== 'simId') payload[key] = value
1110
- }
1111
- let response: Response
1112
- try {
1113
- response = await fetch(
1114
- `${this.session.apiOrigin}/v1/boxes/${encodeURIComponent(this.session.boxId)}/commands`,
1115
- {
1116
- method: 'POST',
1117
- headers: {
1118
- authorization: `Bearer ${this.session.boxToken}`,
1119
- 'content-type': 'application/json',
1120
- },
1121
- body: JSON.stringify({ id, command: payload }),
1122
- ...(options?.timeoutMs
1123
- ? { signal: AbortSignal.timeout(options.timeoutMs) }
1124
- : {}),
1125
- },
1126
- )
1127
- } catch (error) {
1128
- throw new Error(
1129
- `could not reach box ${this.session.boxId} at ${this.session.apiOrigin}: ${
1130
- error instanceof Error ? error.message : String(error)
1131
- }`,
1132
- )
1133
- }
1134
- const text = await response.text()
1135
- if (!response.ok) throw responseError(response.status, text)
1136
- let parsed: unknown
1137
- try {
1138
- parsed = JSON.parse(text)
1139
- } catch {
1140
- throw new Error('box command returned unreadable JSON')
1141
- }
1142
- if (!isRecord(parsed) || parsed.id !== id) {
1143
- throw new Error('box command returned an invalid command receipt')
1144
- }
1145
- if (typeof parsed.error === 'string' && parsed.error) throw new Error(parsed.error)
1146
- if (!Object.hasOwn(parsed, 'result')) {
1147
- throw new Error('box command receipt has no result')
1148
- }
1149
- return parsed.result
1150
- }
1151
-
1152
- listSims(): Promise<BridgeSimInfo[]> {
1153
- return Promise.reject(new Error('a box does not expose a local simulator list'))
1154
- }
1155
-
1156
- focusSim(): Promise<never> {
1157
- return Promise.reject(new Error('a box simulator has no browser window to focus'))
1158
- }
1159
-
1160
- resolveReloadedSim(): Promise<string | null> {
1161
- // cloud sims carry no local browser host, so there is no lineage to follow
1162
- return Promise.resolve(null)
1163
- }
1164
-
1165
- closeSim(): Promise<never> {
1166
- return Promise.reject(new Error('close a box simulator with rnx box stop'))
1167
- }
1168
-
1169
- claim(): Promise<BridgeClaimResult> {
1170
- return Promise.reject(
1171
- new Error('a box simulator is held by its Box page and has nothing to claim'),
1172
- )
1173
- }
1174
-
1175
- close(): void {}
1176
- }
1177
-
1178
1439
  export function createCloudBridgeForParsed(
1179
1440
  parsed: Pick<ParsedBridgeCliArgs, 'simId' | 'simIdSource'>,
1180
1441
  ): WsBridge | null {
1181
1442
  const session = resolveCloudSessionForParsed(parsed)
1182
1443
  if (!session) return null
1183
- return session.service === 'box'
1184
- ? new CloudBoxBridge(session)
1185
- : new CloudBridge(session)
1444
+ return new CloudBridge(session)
1186
1445
  }
1187
1446
 
1188
1447
  function readClaimResponse(value: unknown): { id: string; expiresAt: number } {
@@ -1240,32 +1499,17 @@ export async function confirmCloudSimulator(session: CloudSimSession): Promise<v
1240
1499
  }
1241
1500
  }
1242
1501
 
1243
- // deletes the box, which closes every simulator it holds
1244
- export async function closeCloudBox(session: CloudSession): Promise<void> {
1245
- if (session.service === 'box') {
1246
- const auth = resolveCliAuth()
1247
- let authHeader = `Bearer ${session.boxToken}`
1248
- if (auth?.kind === 'api-key') {
1249
- authHeader = `Bearer ${auth.secret}`
1250
- } else if (auth?.kind === 'session') {
1251
- authHeader = `Bearer ${auth.token}`
1252
- }
1253
- let response: Response
1254
- try {
1255
- response = await fetch(
1256
- `${session.apiOrigin}/v1/boxes/${encodeURIComponent(session.boxId)}/stop`,
1257
- {
1258
- method: 'POST',
1259
- headers: { authorization: authHeader },
1260
- },
1261
- )
1262
- } catch (error) {
1263
- throw new Error(
1264
- `could not reach box ${session.boxId} at ${session.apiOrigin}: ${
1265
- error instanceof Error ? error.message : String(error)
1266
- }`,
1267
- )
1268
- }
1502
+ export async function closeCloudSession(session: CloudSession): Promise<void> {
1503
+ const boxId = session.boxId
1504
+ const boxToken = session.boxToken
1505
+ if (boxId && boxToken) {
1506
+ const response = await fetch(
1507
+ `${session.apiOrigin}/v1/boxes/${encodeURIComponent(boxId)}`,
1508
+ {
1509
+ method: 'DELETE',
1510
+ headers: { authorization: `Bearer ${boxToken}` },
1511
+ },
1512
+ )
1269
1513
  const text = await response.text()
1270
1514
  if (!response.ok && response.status !== 404) {
1271
1515
  throw responseError(response.status, text)
@@ -1273,10 +1517,10 @@ export async function closeCloudBox(session: CloudSession): Promise<void> {
1273
1517
  return
1274
1518
  }
1275
1519
  const response = await fetch(
1276
- `${session.apiOrigin}/v1/boxes/${encodeURIComponent(session.boxId)}`,
1520
+ `${session.apiOrigin}/v1/sims/${encodeURIComponent(session.simId)}`,
1277
1521
  {
1278
1522
  method: 'DELETE',
1279
- headers: { authorization: `Bearer ${session.boxToken}` },
1523
+ headers: { authorization: `Bearer ${session.token}` },
1280
1524
  },
1281
1525
  )
1282
1526
  const text = await response.text()
@@ -1284,115 +1528,3 @@ export async function closeCloudBox(session: CloudSession): Promise<void> {
1284
1528
  throw responseError(response.status, text)
1285
1529
  }
1286
1530
  }
1287
-
1288
- export interface AddCloudSimulatorOptions {
1289
- device: string
1290
- authorization: string
1291
- accountId: string | null
1292
- }
1293
-
1294
- export async function addCloudSimulator(
1295
- session: CloudSession,
1296
- options: AddCloudSimulatorOptions,
1297
- ): Promise<{
1298
- receipt: RnxCloudCreateReceipt
1299
- session: CloudSession
1300
- }> {
1301
- let response: Response
1302
- try {
1303
- response = await fetch(
1304
- `${session.apiOrigin}/v1/boxes/${encodeURIComponent(session.boxId)}/simulators`,
1305
- {
1306
- method: 'POST',
1307
- headers: {
1308
- authorization: `Bearer ${session.boxToken}`,
1309
- 'x-rnx-account-authorization': options.authorization,
1310
- 'content-type': 'application/json',
1311
- ...(options.accountId ? { 'x-rnx-account-id': options.accountId } : {}),
1312
- },
1313
- body: JSON.stringify({ device: options.device }),
1314
- },
1315
- )
1316
- } catch (error) {
1317
- throw new Error(
1318
- `could not reach the remote simulator at ${session.apiOrigin}: ${
1319
- error instanceof Error ? error.message : String(error)
1320
- }`,
1321
- )
1322
- }
1323
- const responseText = await response.text()
1324
- if (!response.ok) {
1325
- if (response.status === 404) {
1326
- try {
1327
- const parsed: unknown = JSON.parse(responseText)
1328
- if (
1329
- isRecord(parsed) &&
1330
- (parsed.error === 'unknown_artifact' ||
1331
- (isRecord(parsed.error) && parsed.error.code === 'unknown_artifact'))
1332
- ) {
1333
- throw new Error(
1334
- 'this remote simulator does not hold a new bundle; run `rnx close` and create again',
1335
- )
1336
- }
1337
- } catch (err) {
1338
- if (
1339
- err instanceof Error &&
1340
- err.message.startsWith('this remote simulator does not hold')
1341
- ) {
1342
- throw err
1343
- }
1344
- }
1345
- throw new Error(
1346
- 'this remote simulator does not hold a new bundle; run `rnx close` and create again',
1347
- )
1348
- }
1349
- throw responseError(response.status, responseText)
1350
- }
1351
- let parsed: unknown
1352
- try {
1353
- parsed = JSON.parse(responseText)
1354
- } catch {
1355
- throw new Error('remote simulator create returned unreadable JSON')
1356
- }
1357
- if (!isRecord(parsed) || !isRecord(parsed.simulator)) {
1358
- throw new Error('remote simulator create returned an invalid receipt')
1359
- }
1360
- const simulator = parsed.simulator
1361
- const simId = simulator.simId
1362
- const claim = isRecord(simulator.claim) ? simulator.claim : null
1363
- const token = simulator.token
1364
- const artifact = isRecord(simulator.artifact) ? simulator.artifact : null
1365
- if (
1366
- typeof simId !== 'string' ||
1367
- !simId ||
1368
- !claim ||
1369
- typeof claim.id !== 'string' ||
1370
- !claim.id ||
1371
- typeof claim.expiresAt !== 'number' ||
1372
- typeof token !== 'string' ||
1373
- !token ||
1374
- !artifact ||
1375
- typeof artifact.id !== 'string' ||
1376
- typeof artifact.bytes !== 'number'
1377
- ) {
1378
- throw new Error('remote simulator create returned an invalid receipt')
1379
- }
1380
- const receipt: RnxCloudCreateReceipt = {
1381
- simId,
1382
- artifact: { id: artifact.id as `sha256:${string}`, bytes: artifact.bytes },
1383
- claim: { id: claim.id, expiresAt: claim.expiresAt },
1384
- }
1385
- const updatedSession = createCloudSession({
1386
- ...session,
1387
- simId,
1388
- claimId: claim.id,
1389
- token,
1390
- platform: 'ios',
1391
- device: options.device,
1392
- artifact: {
1393
- sha256: artifact.id.replace(/^sha256:/, ''),
1394
- bytes: artifact.bytes,
1395
- },
1396
- })
1397
- return { receipt, session: updatedSession }
1398
- }