rnxsim 0.1.432 → 0.1.434
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/README.md +20 -50
- package/cli/auth.ts +33 -3
- package/cli/cloud-client.ts +577 -99
- package/cli/cloud-dispatch.ts +5 -10
- package/cli/cloud-session.ts +5 -5
- package/cli/commands/auth.ts +13 -2
- package/cli/commands/box.ts +270 -26
- package/cli/commands/control.ts +40 -13
- package/cli/commands/device.ts +1 -1
- package/cli/commands/flow.ts +189 -43
- package/cli/commands/inspect/get-layout.ts +1 -1
- package/cli/commands/inspect.ts +8 -1
- package/cli/commands/login.ts +79 -14
- package/cli/commands/maestro-generate.ts +70 -54
- package/cli/commands/maestro.ts +69 -20
- package/cli/commands/platform.ts +30 -32
- package/cli/commands/screenshot.ts +3 -2
- package/cli/commands/upload.ts +28 -10
- package/cli/help-core.ts +11 -1
- package/cli/outbound-endpoints.ts +32 -5
- package/cli/parse-args.ts +17 -0
- package/cli/send-to-box.ts +112 -0
- package/cli/shell-init.ts +4 -4
- package/cli/ws-bridge.ts +109 -62
- 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 +17 -4
- 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/capture-contract.cjs +1 -1
- package/dist-lib/capture-contract.mjs +1 -1
- package/dist-lib/cli-constants.cjs +1 -1
- package/dist-lib/cloud-contract.cjs +1 -1
- package/dist-lib/cloud-contract.mjs +1 -1
- package/dist-lib/cloud.cjs +108 -93
- package/dist-lib/cloud.mjs +106 -91
- package/dist-lib/config.cjs +1 -1
- package/dist-lib/detox/index.cjs +1 -1
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +5324 -1371
- 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 +298 -91
- package/dist-lib/host/websocket-proxy.cjs +1 -1
- package/dist-lib/index.cjs +369 -241
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/menu.cjs +7 -1
- package/dist-lib/menu.mjs +7 -1
- package/dist-lib/metro-fingerprint-registry.cjs +1 -1
- package/dist-lib/metro-fingerprint-registry.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 +327 -299
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/public-brand.cjs +1 -1
- package/dist-lib/react-native-host-modules.cjs +1 -1
- package/dist-lib/react-native-host-modules.mjs +1 -1
- package/dist-lib/render-mode.cjs +1 -1
- package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
- package/dist-lib/sdk.cjs +1 -1
- package/dist-lib/sdk.mjs +1 -1
- package/dist-lib/skills.cjs +4849 -1199
- package/dist-lib/vite.cjs +298 -91
- package/package.json +3 -2
- package/src/auth/shared-session.ts +31 -0
- package/src/bridge-contract.ts +6 -0
- package/src/cloud.ts +83 -113
- package/src/host/bridge-host.ts +79 -0
- package/src/host/replacement-module-handler.ts +20 -96
- package/src/menu.ts +8 -0
- package/src/metro-plugin.ts +30 -113
- package/src/vite-plugin.ts +11 -2
package/cli/cloud-dispatch.ts
CHANGED
|
@@ -121,7 +121,7 @@ function refuseCloudInvocation(
|
|
|
121
121
|
): CloudBoundaryResult {
|
|
122
122
|
const verb = firstVerb(positional)
|
|
123
123
|
const name = verb ? `${command} ${verb}` : command
|
|
124
|
-
console.error(` rnx ${name} is not available for
|
|
124
|
+
console.error(` rnx ${name} is not available for a remote simulator`)
|
|
125
125
|
return { handled: true, code: 1 }
|
|
126
126
|
}
|
|
127
127
|
|
|
@@ -129,18 +129,13 @@ export async function dispatchCloudBoundary({
|
|
|
129
129
|
command,
|
|
130
130
|
args,
|
|
131
131
|
}: CloudBoundaryInvocation): Promise<CloudBoundaryResult> {
|
|
132
|
-
if (command === 'ios' &&
|
|
133
|
-
|
|
134
|
-
if (command === 'android' && (args.includes('--nano') || args.includes('--cloud'))) {
|
|
132
|
+
if (command === 'ios' && args.includes('--remote')) return { handled: false }
|
|
133
|
+
if (command === 'android' && args.includes('--remote')) {
|
|
135
134
|
console.error(
|
|
136
|
-
' rnx android --
|
|
135
|
+
' rnx android --remote is not available; remote simulators are iOS only',
|
|
137
136
|
)
|
|
138
137
|
return { handled: true, code: 1 }
|
|
139
138
|
}
|
|
140
|
-
if ((command === 'ios' || command === 'android') && args.includes('--micro')) {
|
|
141
|
-
console.error(` rnx ${command} --micro is not implemented`)
|
|
142
|
-
return { handled: true, code: 1 }
|
|
143
|
-
}
|
|
144
139
|
if (!CLOUD_TARGET_COMMANDS.has(command)) return { handled: false }
|
|
145
140
|
|
|
146
141
|
const [{ parseBridgeCliArgs }, cloudClient, cloudSession] = await Promise.all([
|
|
@@ -189,7 +184,7 @@ export async function dispatchCloudBoundary({
|
|
|
189
184
|
const descriptor = cloudSession.requireCloudSessionShellUpdate()
|
|
190
185
|
await cloudClient.closeCloudBox(session)
|
|
191
186
|
cloudSession.publishCloudSessionToShell(null, descriptor)
|
|
192
|
-
console.log(` closed:
|
|
187
|
+
console.log(` closed: remote simulator ${session.simId}`)
|
|
193
188
|
return { handled: true, code: 0 }
|
|
194
189
|
} catch (error) {
|
|
195
190
|
console.error(
|
package/cli/cloud-session.ts
CHANGED
|
@@ -10,7 +10,7 @@ export const RNX_SHELL_UPDATE_PREFIX = '__RNX_SHELL_UPDATE_V1__'
|
|
|
10
10
|
export const RNX_SHELL_UPDATE_SET_PREFIX = `${RNX_SHELL_UPDATE_PREFIX}set:`
|
|
11
11
|
export const RNX_SHELL_UPDATE_UNSET = `${RNX_SHELL_UPDATE_PREFIX}unset`
|
|
12
12
|
|
|
13
|
-
// the shell's target: one
|
|
13
|
+
// the shell's target: one remote host, addressed by the box token, and the
|
|
14
14
|
// simulator inside it that ordinary commands reach with the simulator token
|
|
15
15
|
export interface CloudSession {
|
|
16
16
|
version: 2
|
|
@@ -31,7 +31,7 @@ export function normalizeCloudOrigin(source: string): string {
|
|
|
31
31
|
try {
|
|
32
32
|
parsed = new URL(source)
|
|
33
33
|
} catch {
|
|
34
|
-
throw new Error(`invalid
|
|
34
|
+
throw new Error(`invalid remote simulator origin: ${source}`)
|
|
35
35
|
}
|
|
36
36
|
if (
|
|
37
37
|
(parsed.protocol !== 'https:' && parsed.protocol !== 'http:') ||
|
|
@@ -41,10 +41,10 @@ export function normalizeCloudOrigin(source: string): string {
|
|
|
41
41
|
parsed.search ||
|
|
42
42
|
parsed.hash
|
|
43
43
|
) {
|
|
44
|
-
throw new Error(`
|
|
44
|
+
throw new Error(`remote simulator origin must be an HTTP(S) origin: ${source}`)
|
|
45
45
|
}
|
|
46
46
|
if (parsed.protocol === 'http:' && !isLoopbackHost(parsed.hostname)) {
|
|
47
|
-
throw new Error(`
|
|
47
|
+
throw new Error(`remote simulator origin requires HTTPS outside loopback: ${source}`)
|
|
48
48
|
}
|
|
49
49
|
return parsed.origin
|
|
50
50
|
}
|
|
@@ -157,7 +157,7 @@ export function requireCloudSessionShellUpdate(): number {
|
|
|
157
157
|
const descriptor = shellUpdateFd()
|
|
158
158
|
if (descriptor === null) {
|
|
159
159
|
throw new Error(
|
|
160
|
-
'RNX shell integration is required so this
|
|
160
|
+
'RNX shell integration is required so this remote simulator stays connected to the current shell; restart the shell after installing RNX',
|
|
161
161
|
)
|
|
162
162
|
}
|
|
163
163
|
return descriptor
|
package/cli/commands/auth.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
refreshSharedDesktopAuthSession,
|
|
3
|
+
sharedDesktopAuthAccounts,
|
|
4
|
+
} from '../../src/auth/shared-session'
|
|
5
|
+
import { CHOOSE_ACCOUNT_HINT, resolveCliAuth } from '../auth'
|
|
3
6
|
import { rnxExit } from '../run-rnx'
|
|
4
7
|
|
|
5
8
|
function printHelp(): void {
|
|
@@ -36,7 +39,15 @@ async function printStatus(): Promise<number> {
|
|
|
36
39
|
|
|
37
40
|
const current = await refreshSharedDesktopAuthSession(auth.origin)
|
|
38
41
|
const user = current?.user
|
|
42
|
+
const accounts = sharedDesktopAuthAccounts(user)
|
|
43
|
+
const accountId = current?.accountId ?? auth.accountId
|
|
44
|
+
const account = accounts.find((candidate) => candidate.id === accountId)
|
|
39
45
|
console.log(` ${user?.email || user?.name || user?.id || 'signed in'}`)
|
|
46
|
+
console.log(
|
|
47
|
+
` account: ${
|
|
48
|
+
account ? `${account.name} (${account.id})` : (accountId ?? CHOOSE_ACCOUNT_HINT)
|
|
49
|
+
}`,
|
|
50
|
+
)
|
|
40
51
|
console.log(` origin: ${current?.origin ?? auth.origin}`)
|
|
41
52
|
if (current?.updatedAt) console.log(` updated: ${current.updatedAt}`)
|
|
42
53
|
return 0
|
package/cli/commands/box.ts
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
//
|
|
3
3
|
// bare `rnx box` is LOCAL and mounts the current checkout at /project. cloud
|
|
4
4
|
// create provisions through the shared client and enters the same shell unless
|
|
5
|
-
// --json asks for a noninteractive result.
|
|
6
|
-
//
|
|
5
|
+
// --json asks for a noninteractive result. connect asks the account service to
|
|
6
|
+
// turn a Box name into an authorized reference and enters that same shell, so
|
|
7
|
+
// this process never stores a Box token.
|
|
7
8
|
|
|
9
|
+
import { spawn } from 'node:child_process'
|
|
8
10
|
import path from 'node:path'
|
|
9
11
|
import { createInterface } from 'node:readline'
|
|
10
12
|
import { BoxClientError, createBoxClient } from '@rnx/box/client'
|
|
@@ -16,9 +18,16 @@ import {
|
|
|
16
18
|
sourceControlBoxTemplate,
|
|
17
19
|
terminalBoxTemplate,
|
|
18
20
|
} from '@rnx/box/template'
|
|
19
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
cloudBoxBrowserLiveView,
|
|
23
|
+
cloudBoxConnectUrl,
|
|
24
|
+
createCloudBoxProvider,
|
|
25
|
+
ensureCloudBoxBrowser,
|
|
26
|
+
heartbeatSession,
|
|
27
|
+
resolveCloudBoxReference,
|
|
28
|
+
} from 'rnx-cloud-box/client'
|
|
20
29
|
import { rnxPublicBrand } from '../../src/public-brand'
|
|
21
|
-
import { authHeaderOrExit } from '../auth'
|
|
30
|
+
import { authHeaderOrExit, cloudAccountIdOrExit, type CliAuth } from '../auth'
|
|
22
31
|
import { findRepositoryPackageRoot } from '../setup-repository'
|
|
23
32
|
import { resolvePlacement } from '../ws-bridge'
|
|
24
33
|
import { CheckoutFilePlane, CheckoutRequiresGitError } from './box/checkout-plane'
|
|
@@ -58,6 +67,13 @@ function usage(): void {
|
|
|
58
67
|
the box service to create it in, or set
|
|
59
68
|
${ENDPOINT_ENV}
|
|
60
69
|
${rnx} box create --json create without entering the shell
|
|
70
|
+
${rnx} box connect <id> enter the shell of a cloud box you own
|
|
71
|
+
${rnx} box connect <id> --json print its reference without a shell
|
|
72
|
+
${rnx} box send send the running local app to a nano box
|
|
73
|
+
${rnx} box send --name=<n> name the box it creates
|
|
74
|
+
${rnx} box send --no-auth leave the signed-in session behind
|
|
75
|
+
${rnx} box open <id> watch a cloud box in your browser
|
|
76
|
+
${rnx} box open <id> --print print the viewing URL instead of opening it
|
|
61
77
|
${rnx} box ls cloud boxes running on this account
|
|
62
78
|
${rnx} box stop --name=<n> stop a cloud box and its billing
|
|
63
79
|
${rnx} box delete --name=<n> stop it and wipe its files
|
|
@@ -91,13 +107,9 @@ export async function runBox(
|
|
|
91
107
|
|
|
92
108
|
const subcommand = args.find((arg) => !arg.startsWith('-'))
|
|
93
109
|
if (subcommand === 'create') return runCloudBoxCreate(args, opts)
|
|
94
|
-
if (subcommand === 'connect')
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
` the shared Box client requires that reference's access token, and the CLI does not persist Box tokens.`,
|
|
98
|
-
)
|
|
99
|
-
return 1
|
|
100
|
-
}
|
|
110
|
+
if (subcommand === 'connect') return runCloudBoxConnect(args)
|
|
111
|
+
if (subcommand === 'send') return runBoxSend(args, opts)
|
|
112
|
+
if (subcommand === 'open') return runCloudBoxOpen(args)
|
|
101
113
|
if (subcommand === 'stop' || subcommand === 'delete') {
|
|
102
114
|
return runCloudBoxStop(args, subcommand === 'delete')
|
|
103
115
|
}
|
|
@@ -130,12 +142,15 @@ function flag(args: string[], name: string): string | undefined {
|
|
|
130
142
|
return undefined
|
|
131
143
|
}
|
|
132
144
|
|
|
133
|
-
|
|
145
|
+
// the account service, which owns /api/v1/rnx/sessions and the box connect
|
|
146
|
+
// route. a login session names the origin it was issued by; an api key
|
|
147
|
+
// carries none, so it goes where every other key-authenticated command goes.
|
|
148
|
+
function apiOrigin(args: string[], auth: CliAuth): string {
|
|
134
149
|
return (
|
|
135
150
|
flag(args, 'api-origin') ??
|
|
136
151
|
process.env[API_ORIGIN_ENV]?.trim() ??
|
|
137
|
-
|
|
138
|
-
)
|
|
152
|
+
(auth.kind === 'session' ? auth.origin : 'https://contrast.dev')
|
|
153
|
+
).replace(/\/+$/, '')
|
|
139
154
|
}
|
|
140
155
|
|
|
141
156
|
function requireEndpoint(args: string[], label: string): string | null {
|
|
@@ -149,6 +164,46 @@ function requireEndpoint(args: string[], label: string): string | null {
|
|
|
149
164
|
return null
|
|
150
165
|
}
|
|
151
166
|
|
|
167
|
+
// `rnx box send` — the CLI half of "send to box". the shell's rail button runs
|
|
168
|
+
// the same function inside the daemon; this is the same send for an agent or a
|
|
169
|
+
// terminal, and it prints the id and URL either way.
|
|
170
|
+
async function runBoxSend(args: string[], opts: BoxCommandOptions): Promise<number> {
|
|
171
|
+
const rnx = rnxPublicBrand.commandName
|
|
172
|
+
const { auth, header } = authHeaderOrExit('box send')
|
|
173
|
+
const accountId = cloudAccountIdOrExit(auth)
|
|
174
|
+
const { createBridgeFromParsed, parseBridgeCliArgs } = await import('../ws-bridge')
|
|
175
|
+
const parsed = parseBridgeCliArgs(
|
|
176
|
+
args.filter((arg) => arg !== 'send'),
|
|
177
|
+
{
|
|
178
|
+
port: opts.port,
|
|
179
|
+
stripBooleanFlags: ['--no-auth', '--json'],
|
|
180
|
+
stripValueFlags: ['--name', '--assets', '--api-origin'],
|
|
181
|
+
},
|
|
182
|
+
)
|
|
183
|
+
const bridge = createBridgeFromParsed(parsed)
|
|
184
|
+
try {
|
|
185
|
+
const { sendToBox } = await import('../send-to-box')
|
|
186
|
+
const result = await sendToBox({
|
|
187
|
+
send: (command) => bridge.send(command),
|
|
188
|
+
authorization: header,
|
|
189
|
+
accountId,
|
|
190
|
+
carryAuth: !args.includes('--no-auth'),
|
|
191
|
+
boxName: flag(args, 'name') ?? null,
|
|
192
|
+
assetsPath: flag(args, 'assets') ?? null,
|
|
193
|
+
apiOrigin: flag(args, 'api-origin'),
|
|
194
|
+
})
|
|
195
|
+
if (args.includes('--json')) console.log(JSON.stringify(result))
|
|
196
|
+
return 0
|
|
197
|
+
} catch (error) {
|
|
198
|
+
console.error(
|
|
199
|
+
` ${rnx} box send: ${error instanceof Error ? error.message : String(error)}`,
|
|
200
|
+
)
|
|
201
|
+
return 1
|
|
202
|
+
} finally {
|
|
203
|
+
bridge.close()
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
152
207
|
async function runCloudBoxStop(args: string[], purge: boolean): Promise<number> {
|
|
153
208
|
const rnx = rnxPublicBrand.commandName
|
|
154
209
|
const label = purge ? 'delete' : 'stop'
|
|
@@ -160,12 +215,13 @@ async function runCloudBoxStop(args: string[], purge: boolean): Promise<number>
|
|
|
160
215
|
console.error(` ${rnx} box ${label}: which box? pass --name=<n>.`)
|
|
161
216
|
return 1
|
|
162
217
|
}
|
|
163
|
-
const { header } = authHeaderOrExit(`box ${label}`)
|
|
218
|
+
const { auth, header } = authHeaderOrExit(`box ${label}`)
|
|
164
219
|
const apiKey = header.replace(/^Bearer /, '')
|
|
220
|
+
const accountId = cloudAccountIdOrExit(auth)
|
|
165
221
|
const client = createBoxClient(
|
|
166
222
|
createCloudBoxProvider({
|
|
167
223
|
endpoint,
|
|
168
|
-
authority: { kind: 'account', apiOrigin: apiOrigin(args), apiKey },
|
|
224
|
+
authority: { kind: 'account', apiOrigin: apiOrigin(args, auth), apiKey, accountId },
|
|
169
225
|
}),
|
|
170
226
|
)
|
|
171
227
|
try {
|
|
@@ -189,12 +245,13 @@ async function runCloudBoxStop(args: string[], purge: boolean): Promise<number>
|
|
|
189
245
|
|
|
190
246
|
async function runCloudBoxList(args: string[]): Promise<number> {
|
|
191
247
|
const rnx = rnxPublicBrand.commandName
|
|
192
|
-
const { header } = authHeaderOrExit('box ls')
|
|
248
|
+
const { auth, header } = authHeaderOrExit('box ls')
|
|
193
249
|
const apiKey = header.replace(/^Bearer /, '')
|
|
250
|
+
const accountId = cloudAccountIdOrExit(auth)
|
|
194
251
|
const client = createBoxClient(
|
|
195
252
|
createCloudBoxProvider({
|
|
196
253
|
endpoint: flag(args, 'endpoint') ?? process.env[ENDPOINT_ENV]?.trim() ?? '',
|
|
197
|
-
authority: { kind: 'account', apiOrigin: apiOrigin(args), apiKey },
|
|
254
|
+
authority: { kind: 'account', apiOrigin: apiOrigin(args, auth), apiKey, accountId },
|
|
198
255
|
}),
|
|
199
256
|
)
|
|
200
257
|
try {
|
|
@@ -219,6 +276,161 @@ async function runCloudBoxList(args: string[]): Promise<number> {
|
|
|
219
276
|
}
|
|
220
277
|
}
|
|
221
278
|
|
|
279
|
+
async function runCloudBoxConnect(args: string[]): Promise<number> {
|
|
280
|
+
const rnx = rnxPublicBrand.commandName
|
|
281
|
+
const json = args.includes('--json')
|
|
282
|
+
// the first positional is `connect`; the second is the Box.
|
|
283
|
+
const boxId = args.filter((arg) => !arg.startsWith('-'))[1]
|
|
284
|
+
if (!boxId) {
|
|
285
|
+
console.error(
|
|
286
|
+
` ${rnx} box connect: which box? pass its ID or name, as \`${rnx} box ls\` prints it.`,
|
|
287
|
+
)
|
|
288
|
+
return 1
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// the ACCOUNT's credential. the account service is the only thing that knows
|
|
292
|
+
// which account a Box belongs to, so it resolves the name and hands back the
|
|
293
|
+
// Box's own token; nothing here writes that token down.
|
|
294
|
+
const { auth, header } = authHeaderOrExit('box connect')
|
|
295
|
+
const apiKey = header.replace(/^Bearer /, '')
|
|
296
|
+
const origin = apiOrigin(args, auth)
|
|
297
|
+
|
|
298
|
+
let session: BoxShellSession | null = null
|
|
299
|
+
let heartbeat: { stop: () => void } | null = null
|
|
300
|
+
try {
|
|
301
|
+
const reference = await resolveCloudBoxReference({ apiOrigin: origin, apiKey, boxId })
|
|
302
|
+
const box = await createBoxClient(
|
|
303
|
+
createCloudBoxProvider({
|
|
304
|
+
endpoint: reference.endpoint,
|
|
305
|
+
authority: { kind: 'attach' },
|
|
306
|
+
}),
|
|
307
|
+
).attach({
|
|
308
|
+
// no stack here on purpose: the Box reports the one it was created
|
|
309
|
+
// with, and holding somebody else's Box to a stack this process guessed
|
|
310
|
+
// would offer primitives it does not actually have.
|
|
311
|
+
reference: { boxId: reference.boxId, accessToken: reference.token },
|
|
312
|
+
})
|
|
313
|
+
if (json) {
|
|
314
|
+
// the reference without its token. a caller scripting against this gets
|
|
315
|
+
// the Box's identity; the credential stays out of stdout and out of
|
|
316
|
+
// whatever collects it.
|
|
317
|
+
console.log(JSON.stringify({ endpoint: reference.endpoint, ...box.description }))
|
|
318
|
+
return 0
|
|
319
|
+
}
|
|
320
|
+
console.log(` ...connected to Box ${box.id}`)
|
|
321
|
+
if (box.description.pageUrl) {
|
|
322
|
+
console.log(` ...open ${box.description.pageUrl}`)
|
|
323
|
+
}
|
|
324
|
+
console.log(` ...accessing shell\n`)
|
|
325
|
+
session = await box.connect()
|
|
326
|
+
// the same reason `create` heartbeats: the box holds no account credential,
|
|
327
|
+
// so the client using it is what keeps its session alive.
|
|
328
|
+
const sessionId = box.description.sessionId
|
|
329
|
+
if (!sessionId) throw new BoxClientError('the box service returned no session id')
|
|
330
|
+
heartbeat = heartbeatSession({ apiOrigin: origin, apiKey, sessionId })
|
|
331
|
+
return await repl(session, '')
|
|
332
|
+
} catch (error) {
|
|
333
|
+
if (error instanceof BoxClientError) {
|
|
334
|
+
console.error(` ${rnx} box connect: ${error.message}`)
|
|
335
|
+
return 1
|
|
336
|
+
}
|
|
337
|
+
throw error
|
|
338
|
+
} finally {
|
|
339
|
+
heartbeat?.stop()
|
|
340
|
+
session?.close()
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* watch a cloud box in a browser.
|
|
346
|
+
*
|
|
347
|
+
* the box is already running its page in a headless Browser Run tab; this
|
|
348
|
+
* attaches a viewer to that same tab rather than starting a second one. the
|
|
349
|
+
* URL it mints is a short-lived bearer capability, so it is handed straight to
|
|
350
|
+
* the browser and this output is the only place it ever exists.
|
|
351
|
+
*/
|
|
352
|
+
async function runCloudBoxOpen(args: string[]): Promise<number> {
|
|
353
|
+
const rnx = rnxPublicBrand.commandName
|
|
354
|
+
const json = args.includes('--json')
|
|
355
|
+
const print = args.includes('--print')
|
|
356
|
+
// the first positional is `open`; the second is the Box.
|
|
357
|
+
const boxId = args.filter((arg) => !arg.startsWith('-'))[1] ?? flag(args, 'name')
|
|
358
|
+
if (!boxId) {
|
|
359
|
+
console.error(
|
|
360
|
+
` ${rnx} box open: which box? pass its ID or name, as \`${rnx} box ls\` prints it.`,
|
|
361
|
+
)
|
|
362
|
+
return 1
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// the ACCOUNT's credential, for the same reason connect takes one: the
|
|
366
|
+
// account service is what knows which account owns this Box, and it hands
|
|
367
|
+
// back the Box's own token rather than this process storing one.
|
|
368
|
+
const { auth, header } = authHeaderOrExit('box open')
|
|
369
|
+
const apiKey = header.replace(/^Bearer /, '')
|
|
370
|
+
|
|
371
|
+
try {
|
|
372
|
+
const reference = await resolveCloudBoxReference({
|
|
373
|
+
apiOrigin: apiOrigin(args, auth),
|
|
374
|
+
apiKey,
|
|
375
|
+
boxId,
|
|
376
|
+
})
|
|
377
|
+
const connectUrl = cloudBoxConnectUrl(reference.endpoint, reference.boxId)
|
|
378
|
+
// ensure first: it is what navigates the generation's tab to the Box page,
|
|
379
|
+
// so a box whose browser lapsed gets a new generation with its page
|
|
380
|
+
// restored before anybody is looking at it.
|
|
381
|
+
const browser = await ensureCloudBoxBrowser({
|
|
382
|
+
connectUrl,
|
|
383
|
+
accessToken: reference.token,
|
|
384
|
+
})
|
|
385
|
+
if (!browser.reused && !json && !print) {
|
|
386
|
+
console.log(` ...started this box's browser`)
|
|
387
|
+
}
|
|
388
|
+
const view = await cloudBoxBrowserLiveView({
|
|
389
|
+
connectUrl,
|
|
390
|
+
accessToken: reference.token,
|
|
391
|
+
mode: 'tab',
|
|
392
|
+
})
|
|
393
|
+
if (json) {
|
|
394
|
+
console.log(
|
|
395
|
+
JSON.stringify({
|
|
396
|
+
url: view.url,
|
|
397
|
+
expiresAt: new Date(Date.now() + view.expiresInMs).toISOString(),
|
|
398
|
+
}),
|
|
399
|
+
)
|
|
400
|
+
return 0
|
|
401
|
+
}
|
|
402
|
+
if (print) {
|
|
403
|
+
console.log(view.url)
|
|
404
|
+
return 0
|
|
405
|
+
}
|
|
406
|
+
return await openInBrowser(view.url)
|
|
407
|
+
} catch (error) {
|
|
408
|
+
if (error instanceof BoxClientError) {
|
|
409
|
+
console.error(` ${rnx} box open: ${error.message}`)
|
|
410
|
+
return 1
|
|
411
|
+
}
|
|
412
|
+
throw error
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* hand a URL to the operating system's browser.
|
|
418
|
+
*
|
|
419
|
+
* argv rather than a shell string, because this URL is a bearer capability and
|
|
420
|
+
* a shell would take it through history, expansion and quoting on the way. when
|
|
421
|
+
* `open` cannot run it is printed instead: this process is the only place the
|
|
422
|
+
* URL exists, so dropping it would cost the caller another mint.
|
|
423
|
+
*/
|
|
424
|
+
async function openInBrowser(url: string): Promise<number> {
|
|
425
|
+
const child = spawn('open', [url], { stdio: 'ignore' })
|
|
426
|
+
const opened = await new Promise<boolean>((resolve) => {
|
|
427
|
+
child.once('error', () => resolve(false))
|
|
428
|
+
child.once('close', (code) => resolve(code === 0))
|
|
429
|
+
})
|
|
430
|
+
if (!opened) console.log(url)
|
|
431
|
+
return 0
|
|
432
|
+
}
|
|
433
|
+
|
|
222
434
|
async function runCloudBoxCreate(
|
|
223
435
|
args: string[],
|
|
224
436
|
opts: BoxCommandOptions,
|
|
@@ -270,14 +482,15 @@ async function runCloudBoxCreate(
|
|
|
270
482
|
// the ACCOUNT's key. the box service forwards it to /v1/rnx/sessions, which
|
|
271
483
|
// is what authenticates this caller, names the account, and starts the
|
|
272
484
|
// session the box is billed against.
|
|
273
|
-
const { header } = authHeaderOrExit('box create')
|
|
485
|
+
const { auth, header } = authHeaderOrExit('box create')
|
|
274
486
|
const apiKey = header.replace(/^Bearer /, '')
|
|
487
|
+
const accountId = cloudAccountIdOrExit(auth)
|
|
275
488
|
|
|
276
489
|
if (!json) console.log(` ...creating cloud box ${name}`)
|
|
277
490
|
const client = createBoxClient(
|
|
278
491
|
createCloudBoxProvider({
|
|
279
492
|
endpoint,
|
|
280
|
-
authority: { kind: 'account', apiOrigin: apiOrigin(args), apiKey },
|
|
493
|
+
authority: { kind: 'account', apiOrigin: apiOrigin(args, auth), apiKey, accountId },
|
|
281
494
|
}),
|
|
282
495
|
)
|
|
283
496
|
let session: BoxShellSession | null = null
|
|
@@ -303,13 +516,16 @@ async function runCloudBoxCreate(
|
|
|
303
516
|
return 0
|
|
304
517
|
}
|
|
305
518
|
console.log(` ...created Box ${box.id}`)
|
|
306
|
-
if (box.description.stableUrl) {
|
|
307
|
-
console.log(` ...live ${box.description.stableUrl}`)
|
|
308
|
-
}
|
|
309
519
|
console.log(` ...seeded ${box.description.files} project files`)
|
|
310
520
|
// shown once and never stored: this process does not own a credential
|
|
311
521
|
// store, and inventing one here would be a second place secrets live.
|
|
312
522
|
console.log(` ...box token (shown once): ${box.accessToken}`)
|
|
523
|
+
// last, so it is the line still on screen when the shell takes over. it
|
|
524
|
+
// carries no credential: opening it signed in resolves the box through the
|
|
525
|
+
// account service.
|
|
526
|
+
if (box.description.pageUrl) {
|
|
527
|
+
console.log(` ...open ${box.description.pageUrl}`)
|
|
528
|
+
}
|
|
313
529
|
console.log(` ...accessing shell\n`)
|
|
314
530
|
session = await box.connect()
|
|
315
531
|
// bank the wall clock while the shell is open. the box holds no account
|
|
@@ -318,7 +534,7 @@ async function runCloudBoxCreate(
|
|
|
318
534
|
const sessionId = box.description.sessionId
|
|
319
535
|
if (!sessionId) throw new BoxClientError('the box service returned no session id')
|
|
320
536
|
heartbeat = heartbeatSession({
|
|
321
|
-
apiOrigin: apiOrigin(args),
|
|
537
|
+
apiOrigin: apiOrigin(args, auth),
|
|
322
538
|
apiKey,
|
|
323
539
|
sessionId,
|
|
324
540
|
})
|
|
@@ -473,15 +689,30 @@ async function repl(
|
|
|
473
689
|
let inputEnded = false
|
|
474
690
|
// the user typed `exit`: abandon anything still queued behind it.
|
|
475
691
|
let stopped = false
|
|
692
|
+
// the command running right now, so Ctrl-C interrupts it rather than the CLI.
|
|
693
|
+
let interrupt: AbortController | null = null
|
|
476
694
|
|
|
477
695
|
const run = async (command: string) => {
|
|
478
696
|
if (stopped) return
|
|
697
|
+
const controller = new AbortController()
|
|
698
|
+
interrupt = controller
|
|
479
699
|
try {
|
|
480
700
|
await beforeCommand?.()
|
|
481
|
-
const result = await session.exec(command
|
|
482
|
-
|
|
701
|
+
const result = await session.exec(command, {
|
|
702
|
+
signal: controller.signal,
|
|
703
|
+
// written as it arrives rather than collected: a terminal shows a
|
|
704
|
+
// command's output while it runs, and stderr keeps its own stream so
|
|
705
|
+
// piping the shell's stdout somewhere still separates the two.
|
|
706
|
+
onOutput: (stream, chunk) => {
|
|
707
|
+
if (stream === 'stdout') process.stdout.write(chunk)
|
|
708
|
+
else process.stderr.write(chunk)
|
|
709
|
+
},
|
|
710
|
+
})
|
|
711
|
+
if (result.exitCode !== 0) console.log(`exit code: ${result.exitCode}`)
|
|
483
712
|
} catch (error) {
|
|
484
713
|
console.error(error instanceof Error ? error.message : String(error))
|
|
714
|
+
} finally {
|
|
715
|
+
interrupt = null
|
|
485
716
|
}
|
|
486
717
|
if (!inputEnded && !stopped) prompt()
|
|
487
718
|
}
|
|
@@ -489,6 +720,19 @@ async function repl(
|
|
|
489
720
|
return new Promise<number>((resolve) => {
|
|
490
721
|
prompt()
|
|
491
722
|
|
|
723
|
+
// Ctrl-C while a command is running interrupts the command. at an idle
|
|
724
|
+
// prompt there is nothing to interrupt, so it still leaves the shell,
|
|
725
|
+
// which is the only way out of a box that has stopped answering.
|
|
726
|
+
rl.on('SIGINT', () => {
|
|
727
|
+
if (interrupt) {
|
|
728
|
+
process.stdout.write('^C\n')
|
|
729
|
+
interrupt.abort()
|
|
730
|
+
return
|
|
731
|
+
}
|
|
732
|
+
stopped = true
|
|
733
|
+
rl.close()
|
|
734
|
+
})
|
|
735
|
+
|
|
492
736
|
rl.on('line', (line) => {
|
|
493
737
|
const command = line.trim()
|
|
494
738
|
if (!command) {
|
package/cli/commands/control.ts
CHANGED
|
@@ -62,11 +62,11 @@ import { rethrowIfExit, rnxExit } from '../run-rnx'
|
|
|
62
62
|
import { rnxSelfInvocation } from '../self-invocation'
|
|
63
63
|
import {
|
|
64
64
|
BridgeSimLockedError,
|
|
65
|
-
bridgePortForShellBaseUrl,
|
|
66
65
|
createBridge,
|
|
67
66
|
createBridgeFromParsed,
|
|
68
67
|
parseBridgeCliArgs,
|
|
69
68
|
printBridgeWorldNotice,
|
|
69
|
+
resolveBridgePortForPin,
|
|
70
70
|
resolveBridgeWorld,
|
|
71
71
|
worldForBridgePort,
|
|
72
72
|
type BridgeSimInfo,
|
|
@@ -747,6 +747,16 @@ export interface BuildShellUrlOptions {
|
|
|
747
747
|
device?: string
|
|
748
748
|
resolvedBundle?: ResolvedDevBundle
|
|
749
749
|
project?: ResolvedAppProject | null
|
|
750
|
+
// deterministic-capture mode, stamped onto the engine URL as ?proof=1. the
|
|
751
|
+
// shell worker, compositor worker and tenant worker each read it off the
|
|
752
|
+
// host page URL at boot (syncProofModeFromInitUrl) and pin everything that
|
|
753
|
+
// would otherwise differ frame to frame: the TextInput caret stops blinking
|
|
754
|
+
// and stays visible, and notifications stop auto-dismissing. capture-frame
|
|
755
|
+
// also reads the raw param and stops clipping the device corners, so a
|
|
756
|
+
// capture matches simctl's rectangular framebuffer. without it a
|
|
757
|
+
// screen holding a focused text field never reaches three identical idle
|
|
758
|
+
// frames, so the screenshot guard burns all 12 attempts and then fails.
|
|
759
|
+
proof?: boolean
|
|
750
760
|
}
|
|
751
761
|
|
|
752
762
|
function bundleUrlOf(urlStr: string): string | undefined {
|
|
@@ -771,9 +781,10 @@ export async function buildShellUrl(
|
|
|
771
781
|
const project =
|
|
772
782
|
opts.project ?? (await resolveAppProjectForBundleUrl(bundleUrlOf(resolved)))
|
|
773
783
|
const stamped = await stampAppSplash(await stampAppFonts(resolved, project), project)
|
|
774
|
-
if (!opts.device) return stamped
|
|
784
|
+
if (!opts.device && !opts.proof) return stamped
|
|
775
785
|
const url = new URL(stamped)
|
|
776
|
-
url.searchParams.set('device', opts.device)
|
|
786
|
+
if (opts.device) url.searchParams.set('device', opts.device)
|
|
787
|
+
if (opts.proof) url.searchParams.set('proof', '1')
|
|
777
788
|
return url.toString()
|
|
778
789
|
}
|
|
779
790
|
|
|
@@ -1417,6 +1428,7 @@ export async function runOpenCommand(
|
|
|
1417
1428
|
'--no-hmr',
|
|
1418
1429
|
'--no-describe',
|
|
1419
1430
|
'--quiet',
|
|
1431
|
+
'--proof',
|
|
1420
1432
|
],
|
|
1421
1433
|
stripValueFlags: [
|
|
1422
1434
|
'--base-url',
|
|
@@ -1516,14 +1528,25 @@ export async function runOpenCommand(
|
|
|
1516
1528
|
await ensureConfiguredRuntime(configuredRuntimeVersion)
|
|
1517
1529
|
}
|
|
1518
1530
|
|
|
1519
|
-
// keep the bridge port and the shell base URL in one world:
|
|
1520
|
-
//
|
|
1521
|
-
//
|
|
1522
|
-
//
|
|
1531
|
+
// keep the bridge port and the shell base URL in one world: --base-url
|
|
1532
|
+
// retargets polling at THAT world's bridge (otherwise open builds the page
|
|
1533
|
+
// against one runtime while polling the other bridge for registration and
|
|
1534
|
+
// times out after ~125s), and a pin nothing serves is refused rather than
|
|
1535
|
+
// falling back to the discovered default, which used to send
|
|
1536
|
+
// ensureBridgeForOpen below off to start a second shell.
|
|
1523
1537
|
const explicitBaseUrl = args.find((_, i) => args[i - 1] === '--base-url')
|
|
1524
|
-
if (explicitBaseUrl
|
|
1525
|
-
const
|
|
1526
|
-
|
|
1538
|
+
if (explicitBaseUrl) {
|
|
1539
|
+
const pinned = resolveBridgePortForPin({
|
|
1540
|
+
baseUrl: explicitBaseUrl,
|
|
1541
|
+
wsPort: parsed.wsPort,
|
|
1542
|
+
explicitPort: parsed.explicitPort,
|
|
1543
|
+
})
|
|
1544
|
+
if (pinned.error !== undefined) {
|
|
1545
|
+
console.error(` error: ${pinned.error}`)
|
|
1546
|
+
rnxExit(1)
|
|
1547
|
+
} else {
|
|
1548
|
+
parsed.wsPort = pinned.port
|
|
1549
|
+
}
|
|
1527
1550
|
}
|
|
1528
1551
|
|
|
1529
1552
|
parsed.wsPort = await ensureBridgeForOpen(parsed.wsPort, parsed.explicitPort)
|
|
@@ -1597,6 +1620,7 @@ export async function runOpenCommand(
|
|
|
1597
1620
|
device: deviceArg,
|
|
1598
1621
|
resolvedBundle,
|
|
1599
1622
|
project,
|
|
1623
|
+
proof: args.includes('--proof'),
|
|
1600
1624
|
}
|
|
1601
1625
|
|
|
1602
1626
|
// resolve the shell base URL from the bridge port we settled on, so the page
|
|
@@ -1762,9 +1786,12 @@ export async function runOpenCommand(
|
|
|
1762
1786
|
const result = await driver.launch({
|
|
1763
1787
|
url: openUrl,
|
|
1764
1788
|
// the shell serves the sim page but its WS bridge may have drifted off
|
|
1765
|
-
// the shell-port-implied default. inject the
|
|
1766
|
-
// page does not reconnect to a stale world.
|
|
1767
|
-
|
|
1789
|
+
// the shell-port-implied default. inject the bridge this command settled
|
|
1790
|
+
// on so the page does not reconnect to a stale world. re-deriving it from
|
|
1791
|
+
// the shell URL could name a third port, since a bridge with no lockfile
|
|
1792
|
+
// resolves back to the default shell URL and that shell may belong to a
|
|
1793
|
+
// live world of its own.
|
|
1794
|
+
bridgePort: parsed.wsPort,
|
|
1768
1795
|
headless: driverHeadless,
|
|
1769
1796
|
profileId,
|
|
1770
1797
|
ephemeralProfile,
|