rnxsim 0.1.381 → 0.1.382
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 +19 -6
- package/cli/app-fonts.ts +1 -1
- package/cli/bin.ts +118 -26
- package/cli/bridge-diagnostics.ts +1 -1
- package/cli/bridge-flow-runner.ts +9 -9
- package/cli/browser-evals.ts +3 -3
- package/cli/cli-update.ts +261 -0
- package/cli/cloud-client.ts +407 -0
- package/cli/cloud-dispatch.ts +165 -0
- package/cli/cloud-session.ts +156 -0
- package/cli/command-contract.ts +2 -2
- package/cli/command-registry.ts +9 -0
- package/cli/commands/agent-wrapper.ts +4 -4
- package/cli/commands/agent.ts +3 -3
- package/cli/commands/box/checkout-plane.ts +16 -8
- package/cli/commands/box/rnx-command.ts +2 -2
- package/cli/commands/box/search-command.ts +2 -2
- package/cli/commands/box.ts +123 -70
- package/cli/commands/config.ts +3 -2
- package/cli/commands/control.ts +6 -6
- package/cli/commands/cpu-profile.ts +2 -2
- package/cli/commands/daemon.ts +7 -7
- package/cli/commands/debug.ts +2 -2
- package/cli/commands/detox.ts +7 -7
- package/cli/commands/diagnose.ts +1 -1
- package/cli/commands/electron.ts +1 -1
- package/cli/commands/film.ts +1 -1
- package/cli/commands/flow.ts +4 -4
- package/cli/commands/inspect/core.ts +58 -7
- package/cli/commands/inspect/env.ts +5 -3
- package/cli/commands/inspect/shared.ts +2 -1
- package/cli/commands/inspect/wait-event.ts +1 -1
- package/cli/commands/inspect/wait-ready.ts +15 -5
- package/cli/commands/inspect.ts +31 -9
- package/cli/commands/install-desktop.ts +2 -2
- package/cli/commands/maestro.ts +3 -3
- package/cli/commands/permissions.ts +1 -1
- package/cli/commands/platform.ts +91 -0
- package/cli/commands/record.ts +3 -3
- package/cli/commands/report-issue.ts +1 -1
- package/cli/commands/runtime.ts +1 -1
- package/cli/commands/screenshot-mode.ts +1 -1
- package/cli/commands/screenshot.ts +25 -0
- package/cli/commands/serve.ts +1 -1
- package/cli/commands/skills.ts +1 -1
- package/cli/commands/timeline.ts +1 -1
- package/cli/commands/upgrade.ts +8 -34
- package/cli/commands/upload.ts +3 -3
- package/cli/commands/version.ts +14 -3
- package/cli/commands/what-happened.ts +2 -2
- package/cli/current-sim.ts +43 -30
- package/cli/desktop-companion.ts +1 -1
- package/cli/drivers/electron.ts +1 -1
- package/cli/drivers/playwright.ts +3 -3
- package/cli/drivers/types.ts +4 -4
- package/cli/flow-file.ts +3 -3
- package/cli/help-core.ts +18 -11
- package/cli/hidden-runtime-alias.ts +1 -1
- package/cli/hints.ts +2 -2
- package/cli/maestro-js.ts +2 -2
- package/cli/outbound-endpoints.ts +40 -6
- package/cli/parse-args.ts +1 -1
- package/cli/registry.ts +1 -1
- package/cli/resolve-assets.ts +2 -2
- package/cli/secure-file.ts +31 -0
- package/cli/shell-init.ts +98 -0
- package/cli/ws-bridge.ts +18 -8
- package/detox/expectations.ts +1 -1
- package/detox/gestures.ts +3 -3
- package/detox/index.ts +3 -3
- 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 +15 -8
- package/dist-lib/bridge-contract-input.mjs +15 -8
- package/dist-lib/bridge-contract.cjs +1 -1
- package/dist-lib/bridge-contract.mjs +1 -1
- package/dist-lib/capture-contract.cjs +107 -0
- package/dist-lib/capture-contract.mjs +80 -0
- package/dist-lib/cli-constants.cjs +1 -1
- package/dist-lib/cloud-contract.cjs +32 -0
- package/dist-lib/cloud-contract.mjs +7 -0
- package/dist-lib/config.cjs +1 -1
- package/dist-lib/detox/index.cjs +4 -4
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +9 -1
- package/dist-lib/host/bridge-host.cjs +15 -10
- 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 +248 -73
- 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 +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 +44 -6
- package/dist-lib/sdk.mjs +44 -6
- package/dist-lib/skills.cjs +435 -155
- package/dist-lib/vite.cjs +1 -1
- package/package.json +20 -4
- package/scripts/dev-server-scanner.ts +3 -3
- package/skills/contrast/SKILL.md +1 -1
- package/skills/rnx-perf/SKILL.md +68 -52
- package/src/app-url.ts +2 -3
- package/src/bridge-contract-input.ts +17 -6
- package/src/bridge-contract.ts +25 -4
- package/src/capture-contract.ts +423 -0
- package/src/cloud-contract.ts +15 -0
- package/src/home-paths.ts +9 -3
- package/src/host/agent-host.ts +1 -1
- package/src/host/bridge-host.ts +13 -11
- package/src/host/plane-host.ts +2 -2
- package/src/index.ts +21 -0
- package/src/native-dev-bundle-url.ts +2 -2
- package/src/screenshots/orchestrate.ts +1 -1
- package/src/screenshots/org-deck.ts +1 -1
- package/src/settings-bundle.ts +1 -1
- package/src/sim-client.ts +4 -0
- package/src/vite-plugin-one.ts +2 -2
- package/src/vite-plugin.ts +1 -1
- package/cli/commands/box/cloud-box.ts +0 -294
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { fstatSync, writeSync } from 'node:fs'
|
|
2
|
+
import { isLoopbackHost } from '../src/backend-origin'
|
|
3
|
+
|
|
4
|
+
const CLOUD_SESSION_VERSION = 1 as const
|
|
5
|
+
const SHELL_UPDATE_FD_ENV = 'RNX_SHELL_UPDATE_FD'
|
|
6
|
+
const MAX_ENCODED_SESSION_BYTES = 32 * 1024
|
|
7
|
+
|
|
8
|
+
export const RNX_CLOUD_SESSION_ENV = 'RNX_CLOUD_SESSION'
|
|
9
|
+
export const RNX_SHELL_UPDATE_PREFIX = '__RNX_SHELL_UPDATE_V1__'
|
|
10
|
+
export const RNX_SHELL_UPDATE_SET_PREFIX = `${RNX_SHELL_UPDATE_PREFIX}set:`
|
|
11
|
+
export const RNX_SHELL_UPDATE_UNSET = `${RNX_SHELL_UPDATE_PREFIX}unset`
|
|
12
|
+
|
|
13
|
+
export interface CloudSession {
|
|
14
|
+
version: 1
|
|
15
|
+
simId: string
|
|
16
|
+
apiOrigin: string
|
|
17
|
+
claimId: string
|
|
18
|
+
token: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
22
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function normalizeCloudOrigin(source: string): string {
|
|
26
|
+
let parsed: URL
|
|
27
|
+
try {
|
|
28
|
+
parsed = new URL(source)
|
|
29
|
+
} catch {
|
|
30
|
+
throw new Error(`invalid RNX Cloud origin: ${source}`)
|
|
31
|
+
}
|
|
32
|
+
if (
|
|
33
|
+
(parsed.protocol !== 'https:' && parsed.protocol !== 'http:') ||
|
|
34
|
+
parsed.username ||
|
|
35
|
+
parsed.password ||
|
|
36
|
+
parsed.pathname !== '/' ||
|
|
37
|
+
parsed.search ||
|
|
38
|
+
parsed.hash
|
|
39
|
+
) {
|
|
40
|
+
throw new Error(`RNX Cloud origin must be an HTTP(S) origin: ${source}`)
|
|
41
|
+
}
|
|
42
|
+
if (parsed.protocol === 'http:' && !isLoopbackHost(parsed.hostname)) {
|
|
43
|
+
throw new Error(`RNX Cloud origin requires HTTPS outside loopback: ${source}`)
|
|
44
|
+
}
|
|
45
|
+
return parsed.origin
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createCloudSession(input: {
|
|
49
|
+
simId: string
|
|
50
|
+
apiOrigin: string
|
|
51
|
+
claimId: string
|
|
52
|
+
token: string
|
|
53
|
+
}): CloudSession {
|
|
54
|
+
const session = {
|
|
55
|
+
version: CLOUD_SESSION_VERSION,
|
|
56
|
+
simId: input.simId.trim(),
|
|
57
|
+
apiOrigin: normalizeCloudOrigin(input.apiOrigin.trim()),
|
|
58
|
+
claimId: input.claimId.trim(),
|
|
59
|
+
token: input.token,
|
|
60
|
+
} satisfies CloudSession
|
|
61
|
+
if (!session.simId || !session.claimId || !session.token) {
|
|
62
|
+
throw new Error('cloud session requires simulator, origin, claim, and token')
|
|
63
|
+
}
|
|
64
|
+
return session
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function serializeCloudSession(session: CloudSession): string {
|
|
68
|
+
return Buffer.from(JSON.stringify(createCloudSession(session)), 'utf8').toString(
|
|
69
|
+
'base64url',
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function parseCloudSession(encoded: string): CloudSession {
|
|
74
|
+
const value = encoded.trim()
|
|
75
|
+
if (
|
|
76
|
+
!value ||
|
|
77
|
+
value.length > MAX_ENCODED_SESSION_BYTES ||
|
|
78
|
+
!/^[A-Za-z0-9_-]+$/.test(value)
|
|
79
|
+
) {
|
|
80
|
+
throw new Error(`${RNX_CLOUD_SESSION_ENV} is invalid`)
|
|
81
|
+
}
|
|
82
|
+
let parsed: unknown
|
|
83
|
+
try {
|
|
84
|
+
const bytes = Buffer.from(value, 'base64url')
|
|
85
|
+
if (bytes.toString('base64url') !== value) {
|
|
86
|
+
throw new Error('non-canonical cloud session')
|
|
87
|
+
}
|
|
88
|
+
parsed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes))
|
|
89
|
+
} catch {
|
|
90
|
+
throw new Error(`${RNX_CLOUD_SESSION_ENV} is invalid`)
|
|
91
|
+
}
|
|
92
|
+
if (!isRecord(parsed) || parsed.version !== CLOUD_SESSION_VERSION) {
|
|
93
|
+
throw new Error(`${RNX_CLOUD_SESSION_ENV} is invalid`)
|
|
94
|
+
}
|
|
95
|
+
const simId = parsed.simId
|
|
96
|
+
const apiOrigin = parsed.apiOrigin
|
|
97
|
+
const claimId = parsed.claimId
|
|
98
|
+
const token = parsed.token
|
|
99
|
+
if (
|
|
100
|
+
typeof simId !== 'string' ||
|
|
101
|
+
typeof apiOrigin !== 'string' ||
|
|
102
|
+
typeof claimId !== 'string' ||
|
|
103
|
+
typeof token !== 'string'
|
|
104
|
+
) {
|
|
105
|
+
throw new Error(`${RNX_CLOUD_SESSION_ENV} is invalid`)
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
return createCloudSession({ simId, apiOrigin, claimId, token })
|
|
109
|
+
} catch {
|
|
110
|
+
throw new Error(`${RNX_CLOUD_SESSION_ENV} is invalid`)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function readCloudSession(
|
|
115
|
+
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
116
|
+
): CloudSession | null {
|
|
117
|
+
const encoded = environment[RNX_CLOUD_SESSION_ENV]
|
|
118
|
+
return encoded?.trim() ? parseCloudSession(encoded) : null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function shellUpdateFd(
|
|
122
|
+
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
123
|
+
): number | null {
|
|
124
|
+
const source = environment[SHELL_UPDATE_FD_ENV]
|
|
125
|
+
if (!source) return null
|
|
126
|
+
const descriptor = Number(source)
|
|
127
|
+
if (!Number.isSafeInteger(descriptor) || descriptor < 3) {
|
|
128
|
+
throw new Error('RNX shell integration provided an invalid update channel')
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
fstatSync(descriptor)
|
|
132
|
+
} catch {
|
|
133
|
+
throw new Error('RNX shell integration update channel is unavailable')
|
|
134
|
+
}
|
|
135
|
+
return descriptor
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function requireCloudSessionShellUpdate(): number {
|
|
139
|
+
const descriptor = shellUpdateFd()
|
|
140
|
+
if (descriptor === null) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
'RNX shell integration is required so this cloud session stays in the current shell; restart the shell after installing RNX',
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
return descriptor
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function publishCloudSessionToShell(
|
|
149
|
+
session: CloudSession | null,
|
|
150
|
+
descriptor = requireCloudSessionShellUpdate(),
|
|
151
|
+
): void {
|
|
152
|
+
const update = session
|
|
153
|
+
? `${RNX_SHELL_UPDATE_SET_PREFIX}${serializeCloudSession(session)}`
|
|
154
|
+
: RNX_SHELL_UPDATE_UNSET
|
|
155
|
+
writeSync(descriptor, `${update}\n`)
|
|
156
|
+
}
|
package/cli/command-contract.ts
CHANGED
|
@@ -67,8 +67,8 @@ export interface RnxParsedCommandInput {
|
|
|
67
67
|
|
|
68
68
|
export interface RnxPreviewInvocation extends RnxInvocationBase {
|
|
69
69
|
family: 'preview'
|
|
70
|
-
command: 'web' | 'ios'
|
|
71
|
-
platform: 'web' | 'ios'
|
|
70
|
+
command: 'web' | 'ios' | 'android'
|
|
71
|
+
platform: 'web' | 'ios' | 'android'
|
|
72
72
|
selection: 'select-or-ensure-existing'
|
|
73
73
|
}
|
|
74
74
|
|
package/cli/command-registry.ts
CHANGED
|
@@ -74,6 +74,7 @@ const NODE_COMMAND_NAMES = [
|
|
|
74
74
|
const BROWSER_FAMILIES = new Map<string, RnxBrowserCommandFamily>([
|
|
75
75
|
['web', 'preview'],
|
|
76
76
|
['ios', 'preview'],
|
|
77
|
+
['android', 'preview'],
|
|
77
78
|
['list', 'runtime'],
|
|
78
79
|
['use', 'runtime'],
|
|
79
80
|
['state', 'runtime'],
|
|
@@ -248,6 +249,14 @@ export function createRnxCommandInvocation(
|
|
|
248
249
|
platform: 'ios',
|
|
249
250
|
selection: 'select-or-ensure-existing',
|
|
250
251
|
}
|
|
252
|
+
case 'android':
|
|
253
|
+
return {
|
|
254
|
+
...common,
|
|
255
|
+
family: 'preview',
|
|
256
|
+
command: 'android',
|
|
257
|
+
platform: 'android',
|
|
258
|
+
selection: 'select-or-ensure-existing',
|
|
259
|
+
}
|
|
251
260
|
case 'describe':
|
|
252
261
|
return { ...common, family: 'tree' }
|
|
253
262
|
case 'find':
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
// agent-wrapper — long-running bridge process between
|
|
1
|
+
// agent-wrapper — long-running bridge process between rnx desktop and a
|
|
2
2
|
// local coding agent (codex / claude).
|
|
3
3
|
//
|
|
4
|
-
// shipped as an internal
|
|
4
|
+
// shipped as an internal rnx CLI subcommand so it survives `bun build
|
|
5
5
|
// --compile` bundling. callers spawn it as:
|
|
6
6
|
//
|
|
7
|
-
//
|
|
7
|
+
// rnx agent-wrapper \
|
|
8
8
|
// --session-id <id> --project-id <id> --provider codex \
|
|
9
9
|
// --cwd <path> --prompt-in <fifo> --events-out <fifo> \
|
|
10
10
|
// [--transcript <file>]
|
|
@@ -407,7 +407,7 @@ export async function runAgentWrapper(argv: string[]): Promise<number> {
|
|
|
407
407
|
}
|
|
408
408
|
|
|
409
409
|
// open prompt.in with O_RDWR so our own write end keeps the FIFO alive
|
|
410
|
-
// across external writer open/close cycles. `
|
|
410
|
+
// across external writer open/close cycles. `rnx agent prompt` opens,
|
|
411
411
|
// writes one line, closes — without a self-writer the reader would EOF
|
|
412
412
|
// after the first prompt and subsequent prompts would be lost.
|
|
413
413
|
const promptFd = openSync(promptIn, fsConstants.O_RDWR)
|
package/cli/commands/agent.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// user-facing `
|
|
1
|
+
// user-facing `rnx agent` command tree.
|
|
2
2
|
//
|
|
3
3
|
// session-lifecycle commands (start, prompt, end, watch) route through
|
|
4
4
|
// the bridge daemon — the daemon owns the single FIFO reader per
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
//
|
|
9
9
|
// disk-only inspection commands (projects, sessions, paths, transcript,
|
|
10
10
|
// attach, project show/remove) read the attached-projects JSON directly —
|
|
11
|
-
// no daemon needed, so `
|
|
11
|
+
// no daemon needed, so `rnx agent projects` stays fast when no
|
|
12
12
|
// wrapper is live.
|
|
13
13
|
|
|
14
14
|
import fs from 'node:fs'
|
|
@@ -96,7 +96,7 @@ async function withDaemon<T>(fn: (client: AgentDaemonClient) => Promise<T>): Pro
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
function printHelp(): void {
|
|
99
|
-
// delegate to the meta-driven renderer so bare `
|
|
99
|
+
// delegate to the meta-driven renderer so bare `rnx agent`, `--help`,
|
|
100
100
|
// and `unknown subcommand` paths all show the same rich help (header,
|
|
101
101
|
// options, examples) instead of a sparse hand-coded summary.
|
|
102
102
|
printCommandHelp('agent')
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { execFileSync } from 'node:child_process'
|
|
13
13
|
import fs from 'node:fs'
|
|
14
14
|
import path from 'node:path'
|
|
15
|
-
import type { ProjectFileMeta, ProjectFilePlane } from '@
|
|
15
|
+
import type { ProjectFileMeta, ProjectFilePlane } from '@rnx/box/plane'
|
|
16
16
|
|
|
17
17
|
export class CheckoutRequiresGitError extends Error {}
|
|
18
18
|
|
|
@@ -20,7 +20,7 @@ export class CheckoutRequiresGitError extends Error {}
|
|
|
20
20
|
// rather than acted on here: moving a binary file is byte-exact and perfectly
|
|
21
21
|
// fine, and so is deleting one, but a shell command that produced TEXT cannot
|
|
22
22
|
// be allowed to overwrite it, because that text is a lossy decode of the
|
|
23
|
-
// file's own bytes.
|
|
23
|
+
// file's own bytes. BoxProjectFs owns that refusal. detecting it at write
|
|
24
24
|
// time instead is not an option: just-bash's `cat` discards every readFile
|
|
25
25
|
// error and reports "No such file or directory" whatever the error says, so
|
|
26
26
|
// the fact has to be in the index where the filesystem layer can read it.
|
|
@@ -88,10 +88,15 @@ export class CheckoutFilePlane implements ProjectFilePlane {
|
|
|
88
88
|
// long a wedged git is waited on, never whether it is waited on, so proving
|
|
89
89
|
// the kill does not have to cost the production deadline.
|
|
90
90
|
private readonly listTimeoutMs: number
|
|
91
|
+
private readonly trackedOnly: boolean
|
|
91
92
|
|
|
92
|
-
constructor(
|
|
93
|
+
constructor(
|
|
94
|
+
root: string,
|
|
95
|
+
options: { listTimeoutMs?: number; trackedOnly?: boolean } = {},
|
|
96
|
+
) {
|
|
93
97
|
this.root = path.resolve(root)
|
|
94
98
|
this.listTimeoutMs = options.listTimeoutMs ?? LIST_FILES_TIMEOUT_MS
|
|
99
|
+
this.trackedOnly = options.trackedOnly ?? false
|
|
95
100
|
}
|
|
96
101
|
|
|
97
102
|
index(): ReadonlyMap<string, ProjectFileMeta> {
|
|
@@ -101,10 +106,13 @@ export class CheckoutFilePlane implements ProjectFilePlane {
|
|
|
101
106
|
async refresh(): Promise<void> {
|
|
102
107
|
let stdout: string
|
|
103
108
|
try {
|
|
104
|
-
// -z
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
|
|
109
|
+
// -z keeps paths containing spaces or newlines intact. a local Box sees
|
|
110
|
+
// tracked plus visible untracked files, while a durable cloud import is
|
|
111
|
+
// deliberately restricted to the checkout's tracked source.
|
|
112
|
+
const args = this.trackedOnly
|
|
113
|
+
? ['ls-files', '-z']
|
|
114
|
+
: ['ls-files', '-co', '--exclude-standard', '-z']
|
|
115
|
+
stdout = execFileSync('git', args, {
|
|
108
116
|
cwd: this.root,
|
|
109
117
|
encoding: 'utf8',
|
|
110
118
|
maxBuffer: 64 * 1024 * 1024,
|
|
@@ -137,7 +145,7 @@ export class CheckoutFilePlane implements ProjectFilePlane {
|
|
|
137
145
|
// than a broken box, and nothing would say so.
|
|
138
146
|
if (stdout.length === 0) {
|
|
139
147
|
throw new CheckoutRequiresGitError(
|
|
140
|
-
`git listed no files in ${this.root}. either
|
|
148
|
+
`git listed no ${this.trackedOnly ? 'tracked ' : ''}files in ${this.root}. either the checkout contains no eligible files, or this machine could not read the child process's output.`,
|
|
141
149
|
)
|
|
142
150
|
}
|
|
143
151
|
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
import { spawn } from 'node:child_process'
|
|
10
10
|
import path from 'node:path'
|
|
11
|
-
import { defineCommand, type CustomCommand } from '@
|
|
12
|
-
import { PROJECT_MOUNT } from '@
|
|
11
|
+
import { defineCommand, type CustomCommand } from '@rnx/box/shell'
|
|
12
|
+
import { PROJECT_MOUNT } from '@rnx/box/shell'
|
|
13
13
|
import { rnxPublicBrand } from '../../../src/public-brand'
|
|
14
14
|
import { RNX_PLACEMENT_ENV } from '../../ws-bridge'
|
|
15
15
|
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
// whose "no match here" had to be merged.
|
|
33
33
|
|
|
34
34
|
import { spawn } from 'node:child_process'
|
|
35
|
-
import { defineCommand, type CustomCommand } from '@
|
|
36
|
-
import { PROJECT_MOUNT } from '@
|
|
35
|
+
import { defineCommand, type CustomCommand } from '@rnx/box/shell'
|
|
36
|
+
import { PROJECT_MOUNT } from '@rnx/box/shell'
|
|
37
37
|
|
|
38
38
|
// generous next to a 414ms p95: this bounds a wedged child, it does not cut off
|
|
39
39
|
// a large search.
|
package/cli/commands/box.ts
CHANGED
|
@@ -1,48 +1,40 @@
|
|
|
1
1
|
// `rnx box` — the box environment.
|
|
2
2
|
//
|
|
3
|
-
// bare `rnx box` is LOCAL and mounts the current checkout at /project.
|
|
4
|
-
// create
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// fails.
|
|
3
|
+
// bare `rnx box` is LOCAL and mounts the current checkout at /project. cloud
|
|
4
|
+
// create provisions through the shared client and enters the same shell unless
|
|
5
|
+
// --json asks for a noninteractive result. reconnect still needs an authorized
|
|
6
|
+
// provider lookup from public box identity to the provider reference.
|
|
8
7
|
|
|
9
8
|
import path from 'node:path'
|
|
10
9
|
import { createInterface } from 'node:readline'
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
} from '@
|
|
10
|
+
import { BoxClientError, createBoxClient } from '@rnx/box/client'
|
|
11
|
+
import { createBoxHostProvider } from '@rnx/box/host'
|
|
12
|
+
import { PROJECT_MOUNT } from '@rnx/box/shell'
|
|
13
|
+
import { terminalBoxStack } from '@rnx/box/stack'
|
|
14
|
+
import { BoxTemplateRegistry, terminalBoxTemplate } from '@rnx/box/template'
|
|
15
|
+
import { createCloudBoxProvider, heartbeatSession } from 'rnx-cloud-box/client'
|
|
16
16
|
import { rnxPublicBrand } from '../../src/public-brand'
|
|
17
17
|
import { authHeaderOrExit } from '../auth'
|
|
18
18
|
import { findRepositoryPackageRoot } from '../setup-repository'
|
|
19
19
|
import { resolvePlacement } from '../ws-bridge'
|
|
20
20
|
import { CheckoutFilePlane, CheckoutRequiresGitError } from './box/checkout-plane'
|
|
21
|
-
import {
|
|
22
|
-
CloudBoxError,
|
|
23
|
-
connectCloudBox,
|
|
24
|
-
createCloudBox,
|
|
25
|
-
heartbeatSession,
|
|
26
|
-
listCloudBoxes,
|
|
27
|
-
stopCloudBox,
|
|
28
|
-
} from './box/cloud-box'
|
|
29
21
|
import { sharePlane } from './box/plane-share'
|
|
30
22
|
import { createRnxCommand } from './box/rnx-command'
|
|
31
23
|
import { createSearchCommand } from './box/search-command'
|
|
32
|
-
import type {
|
|
24
|
+
import type { BoxShellSession } from '@rnx/box/client'
|
|
25
|
+
import type { BoxTemplateResolution } from '@rnx/box/template'
|
|
33
26
|
|
|
34
27
|
interface BoxCommandOptions {
|
|
35
28
|
port?: number
|
|
36
29
|
verbose?: boolean
|
|
37
30
|
}
|
|
38
31
|
|
|
39
|
-
const CLOUD_SUBCOMMANDS = new Set(['connect'])
|
|
40
|
-
|
|
41
32
|
// no default. a cloud box service has to be deployed before one can exist, and
|
|
42
33
|
// defaulting to an origin that answers nothing would advertise a capability
|
|
43
34
|
// that always fails.
|
|
44
35
|
const ENDPOINT_ENV = 'RNX_BOX_ENDPOINT'
|
|
45
36
|
const API_ORIGIN_ENV = 'RNX_API_ORIGIN'
|
|
37
|
+
const BOX_TEMPLATES = new BoxTemplateRegistry([terminalBoxTemplate])
|
|
46
38
|
|
|
47
39
|
function usage(): void {
|
|
48
40
|
const rnx = rnxPublicBrand.commandName
|
|
@@ -53,9 +45,12 @@ function usage(): void {
|
|
|
53
45
|
${rnx} box start a local box over the current checkout
|
|
54
46
|
${rnx} box create create a cloud box and enter its shell
|
|
55
47
|
${rnx} box create --name=<n> create a named cloud box
|
|
48
|
+
${rnx} box create --template=<id>
|
|
49
|
+
create from a registered Box template
|
|
56
50
|
${rnx} box create --endpoint=<url>
|
|
57
51
|
the box service to create it in, or set
|
|
58
52
|
${ENDPOINT_ENV}
|
|
53
|
+
${rnx} box create --json create without entering the shell
|
|
59
54
|
${rnx} box ls cloud boxes running on this account
|
|
60
55
|
${rnx} box stop --name=<n> stop a cloud box and its billing
|
|
61
56
|
${rnx} box delete --name=<n> stop it and wipe its files
|
|
@@ -89,18 +84,18 @@ export async function runBox(
|
|
|
89
84
|
|
|
90
85
|
const subcommand = args.find((arg) => !arg.startsWith('-'))
|
|
91
86
|
if (subcommand === 'create') return runCloudBoxCreate(args, opts)
|
|
87
|
+
if (subcommand === 'connect') {
|
|
88
|
+
console.error(
|
|
89
|
+
` ${rnxPublicBrand.commandName} box connect: the cloud provider cannot yet resolve a Box ID or name to an authorized Box reference.\n` +
|
|
90
|
+
` the shared Box client requires that reference's access token, and the CLI does not persist Box tokens.`,
|
|
91
|
+
)
|
|
92
|
+
return 1
|
|
93
|
+
}
|
|
92
94
|
if (subcommand === 'stop' || subcommand === 'delete') {
|
|
93
95
|
return runCloudBoxStop(args, subcommand === 'delete')
|
|
94
96
|
}
|
|
95
97
|
if (subcommand === 'ls') return runCloudBoxList(args)
|
|
96
98
|
if (subcommand !== undefined) {
|
|
97
|
-
if (CLOUD_SUBCOMMANDS.has(subcommand)) {
|
|
98
|
-
console.error(
|
|
99
|
-
` ${rnxPublicBrand.commandName} box ${subcommand} needs a cloud box, which is not built yet.\n` +
|
|
100
|
-
` \`${rnxPublicBrand.commandName} box\` runs the same shell locally over this checkout.`,
|
|
101
|
-
)
|
|
102
|
-
return 1
|
|
103
|
-
}
|
|
104
99
|
console.error(
|
|
105
100
|
` unknown command: ${rnxPublicBrand.commandName} box ${subcommand}\n` +
|
|
106
101
|
` run \`${rnxPublicBrand.commandName} box --help\` to see the box surface.`,
|
|
@@ -108,6 +103,13 @@ export async function runBox(
|
|
|
108
103
|
return 1
|
|
109
104
|
}
|
|
110
105
|
|
|
106
|
+
if (args.includes('--json')) {
|
|
107
|
+
console.error(
|
|
108
|
+
` ${rnxPublicBrand.commandName} box --json requires a noninteractive cloud subcommand such as create.`,
|
|
109
|
+
)
|
|
110
|
+
return 1
|
|
111
|
+
}
|
|
112
|
+
|
|
111
113
|
return runLocalBox(opts)
|
|
112
114
|
}
|
|
113
115
|
|
|
@@ -152,13 +154,14 @@ async function runCloudBoxStop(args: string[], purge: boolean): Promise<number>
|
|
|
152
154
|
return 1
|
|
153
155
|
}
|
|
154
156
|
const { header } = authHeaderOrExit(`box ${label}`)
|
|
157
|
+
const apiKey = header.replace(/^Bearer /, '')
|
|
158
|
+
const client = createBoxClient(
|
|
159
|
+
createCloudBoxProvider({ endpoint, apiOrigin: apiOrigin(args), apiKey }),
|
|
160
|
+
)
|
|
155
161
|
try {
|
|
156
|
-
const result =
|
|
157
|
-
|
|
158
|
-
name
|
|
159
|
-
apiKey: header.replace(/^Bearer /, ''),
|
|
160
|
-
purge,
|
|
161
|
-
})
|
|
162
|
+
const result = purge
|
|
163
|
+
? await client.delete({ boxId: name })
|
|
164
|
+
: await client.stop({ boxId: name })
|
|
162
165
|
console.log(
|
|
163
166
|
purge
|
|
164
167
|
? ` deleted ${name}, freeing ${Number(result.freedBytes ?? 0)} bytes`
|
|
@@ -166,7 +169,7 @@ async function runCloudBoxStop(args: string[], purge: boolean): Promise<number>
|
|
|
166
169
|
)
|
|
167
170
|
return 0
|
|
168
171
|
} catch (error) {
|
|
169
|
-
if (error instanceof
|
|
172
|
+
if (error instanceof BoxClientError) {
|
|
170
173
|
console.error(` ${rnx} box ${label}: ${error.message}`)
|
|
171
174
|
return 1
|
|
172
175
|
}
|
|
@@ -177,22 +180,29 @@ async function runCloudBoxStop(args: string[], purge: boolean): Promise<number>
|
|
|
177
180
|
async function runCloudBoxList(args: string[]): Promise<number> {
|
|
178
181
|
const rnx = rnxPublicBrand.commandName
|
|
179
182
|
const { header } = authHeaderOrExit('box ls')
|
|
180
|
-
|
|
181
|
-
|
|
183
|
+
const apiKey = header.replace(/^Bearer /, '')
|
|
184
|
+
const client = createBoxClient(
|
|
185
|
+
createCloudBoxProvider({
|
|
186
|
+
endpoint: flag(args, 'endpoint') ?? process.env[ENDPOINT_ENV]?.trim() ?? '',
|
|
182
187
|
apiOrigin: apiOrigin(args),
|
|
183
|
-
apiKey
|
|
184
|
-
})
|
|
188
|
+
apiKey,
|
|
189
|
+
}),
|
|
190
|
+
)
|
|
191
|
+
try {
|
|
192
|
+
const boxes = await client.list()
|
|
185
193
|
if (boxes.length === 0) {
|
|
186
194
|
console.log(` no cloud boxes running on this account`)
|
|
187
195
|
return 0
|
|
188
196
|
}
|
|
189
197
|
for (const box of boxes) {
|
|
190
198
|
const minutes = Math.round(box.meteredMs / 60_000)
|
|
191
|
-
console.log(
|
|
199
|
+
console.log(
|
|
200
|
+
` ${box.name ?? box.boxId} running ${minutes}m since ${box.startedAt}`,
|
|
201
|
+
)
|
|
192
202
|
}
|
|
193
203
|
return 0
|
|
194
204
|
} catch (error) {
|
|
195
|
-
if (error instanceof
|
|
205
|
+
if (error instanceof BoxClientError) {
|
|
196
206
|
console.error(` ${rnx} box ls: ${error.message}`)
|
|
197
207
|
return 1
|
|
198
208
|
}
|
|
@@ -205,6 +215,7 @@ async function runCloudBoxCreate(
|
|
|
205
215
|
opts: BoxCommandOptions,
|
|
206
216
|
): Promise<number> {
|
|
207
217
|
const rnx = rnxPublicBrand.commandName
|
|
218
|
+
const json = args.includes('--json')
|
|
208
219
|
const endpoint = flag(args, 'endpoint') ?? process.env[ENDPOINT_ENV]?.trim()
|
|
209
220
|
if (!endpoint) {
|
|
210
221
|
console.error(
|
|
@@ -215,6 +226,17 @@ async function runCloudBoxCreate(
|
|
|
215
226
|
return 1
|
|
216
227
|
}
|
|
217
228
|
|
|
229
|
+
const templateName = flag(args, 'template') ?? terminalBoxTemplate.name
|
|
230
|
+
let template: BoxTemplateResolution
|
|
231
|
+
try {
|
|
232
|
+
template = BOX_TEMPLATES.resolve({ name: templateName })
|
|
233
|
+
} catch (error) {
|
|
234
|
+
console.error(
|
|
235
|
+
` ${rnx} box create: ${error instanceof Error ? error.message : String(error)}.`,
|
|
236
|
+
)
|
|
237
|
+
return 1
|
|
238
|
+
}
|
|
239
|
+
|
|
218
240
|
const root = findRepositoryPackageRoot(process.cwd())
|
|
219
241
|
if (!root) {
|
|
220
242
|
console.error(
|
|
@@ -224,7 +246,7 @@ async function runCloudBoxCreate(
|
|
|
224
246
|
return 1
|
|
225
247
|
}
|
|
226
248
|
|
|
227
|
-
const plane = new CheckoutFilePlane(root)
|
|
249
|
+
const plane = new CheckoutFilePlane(root, { trackedOnly: true })
|
|
228
250
|
try {
|
|
229
251
|
await plane.refresh()
|
|
230
252
|
} catch (error) {
|
|
@@ -242,36 +264,55 @@ async function runCloudBoxCreate(
|
|
|
242
264
|
const { header } = authHeaderOrExit('box create')
|
|
243
265
|
const apiKey = header.replace(/^Bearer /, '')
|
|
244
266
|
|
|
245
|
-
console.log(` ...creating cloud box ${name}`)
|
|
246
|
-
|
|
267
|
+
if (!json) console.log(` ...creating cloud box ${name}`)
|
|
268
|
+
const client = createBoxClient(
|
|
269
|
+
createCloudBoxProvider({ endpoint, apiOrigin: apiOrigin(args), apiKey }),
|
|
270
|
+
)
|
|
271
|
+
let session: BoxShellSession | null = null
|
|
247
272
|
let heartbeat: { stop: () => void } | null = null
|
|
248
273
|
try {
|
|
249
|
-
const
|
|
250
|
-
endpoint,
|
|
274
|
+
const box = await client.create({
|
|
251
275
|
name,
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
276
|
+
stack: template.stack,
|
|
277
|
+
template: template.provenance,
|
|
278
|
+
source: plane,
|
|
279
|
+
onProgress:
|
|
280
|
+
opts.verbose && !json
|
|
281
|
+
? (seeded, total) => console.log(` ...seeded ${seeded}/${total} files`)
|
|
282
|
+
: undefined,
|
|
257
283
|
})
|
|
258
|
-
|
|
284
|
+
if (json) {
|
|
285
|
+
console.log(
|
|
286
|
+
JSON.stringify({
|
|
287
|
+
...box.description,
|
|
288
|
+
...(box.accessToken ? { accessToken: box.accessToken } : {}),
|
|
289
|
+
}),
|
|
290
|
+
)
|
|
291
|
+
return 0
|
|
292
|
+
}
|
|
293
|
+
console.log(` ...created Box ${box.id}`)
|
|
294
|
+
if (box.description.stableUrl) {
|
|
295
|
+
console.log(` ...live ${box.description.stableUrl}`)
|
|
296
|
+
}
|
|
297
|
+
console.log(` ...seeded ${box.description.files} project files`)
|
|
259
298
|
// shown once and never stored: this process does not own a credential
|
|
260
299
|
// store, and inventing one here would be a second place secrets live.
|
|
261
|
-
console.log(` ...box token (shown once): ${
|
|
300
|
+
console.log(` ...box token (shown once): ${box.accessToken}`)
|
|
262
301
|
console.log(` ...accessing shell\n`)
|
|
263
|
-
session = await
|
|
302
|
+
session = await box.connect()
|
|
264
303
|
// bank the wall clock while the shell is open. the box holds no account
|
|
265
304
|
// credential, so the client that is using it is what keeps the session
|
|
266
305
|
// alive; when this process dies the sweep closes it at the last heartbeat.
|
|
306
|
+
const sessionId = box.description.sessionId
|
|
307
|
+
if (!sessionId) throw new BoxClientError('the box service returned no session id')
|
|
267
308
|
heartbeat = heartbeatSession({
|
|
268
309
|
apiOrigin: apiOrigin(args),
|
|
269
310
|
apiKey,
|
|
270
|
-
sessionId
|
|
311
|
+
sessionId,
|
|
271
312
|
})
|
|
272
313
|
return await repl(session, '')
|
|
273
314
|
} catch (error) {
|
|
274
|
-
if (error instanceof
|
|
315
|
+
if (error instanceof BoxClientError) {
|
|
275
316
|
console.error(` ${rnx} box create: ${error.message}`)
|
|
276
317
|
return 1
|
|
277
318
|
}
|
|
@@ -311,13 +352,28 @@ async function runLocalBox(opts: BoxCommandOptions): Promise<number> {
|
|
|
311
352
|
throw error
|
|
312
353
|
}
|
|
313
354
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
355
|
+
let session: BoxShellSession | null = null
|
|
356
|
+
const client = createBoxClient(
|
|
357
|
+
createBoxHostProvider({
|
|
358
|
+
shell: {
|
|
359
|
+
commands: [
|
|
360
|
+
createRnxCommand({
|
|
361
|
+
root,
|
|
362
|
+
shellCwd: () => session?.cwd ?? PROJECT_MOUNT,
|
|
363
|
+
}),
|
|
364
|
+
],
|
|
365
|
+
// just-bash's JavaScript rg reads every file body through the plane.
|
|
366
|
+
// see search-command.ts for the numbers.
|
|
367
|
+
builtInCommandOverrides: [createSearchCommand({ root })],
|
|
368
|
+
},
|
|
369
|
+
}),
|
|
370
|
+
)
|
|
371
|
+
const box = await client.create({
|
|
372
|
+
name: path.basename(root),
|
|
373
|
+
stack: terminalBoxStack,
|
|
374
|
+
source: plane,
|
|
320
375
|
})
|
|
376
|
+
session = await box.connect()
|
|
321
377
|
|
|
322
378
|
const relative = path.relative(root, process.cwd())
|
|
323
379
|
console.log(` ...starting local box`)
|
|
@@ -362,26 +418,23 @@ async function runLocalBox(opts: BoxCommandOptions): Promise<number> {
|
|
|
362
418
|
|
|
363
419
|
try {
|
|
364
420
|
// the checkout is shared with editors, agents, and the user's own terminal.
|
|
365
|
-
//
|
|
421
|
+
// BoxProjectFs arms a stale bit on each shell turn and refreshes the
|
|
366
422
|
// plane synchronously on the first actual file access, so commands that
|
|
367
423
|
// never touch project files pay nothing, while reads always see fresh disk state.
|
|
368
|
-
return await repl(
|
|
424
|
+
return await repl(session, relative)
|
|
369
425
|
} finally {
|
|
426
|
+
session.close()
|
|
370
427
|
shared?.close()
|
|
371
428
|
}
|
|
372
429
|
}
|
|
373
430
|
|
|
374
431
|
// the REPL drives whatever holds the shell. locally that is an in-process
|
|
375
|
-
//
|
|
432
|
+
// BoxShell over this checkout; for a cloud box it is a socket to the box,
|
|
376
433
|
// which holds both the files and the shell. same loop, because the only
|
|
377
434
|
// difference is where exec runs.
|
|
378
|
-
interface BoxSession {
|
|
379
|
-
readonly cwd: string
|
|
380
|
-
exec(command: string): Promise<HarnessShellResult>
|
|
381
|
-
}
|
|
382
435
|
|
|
383
436
|
async function repl(
|
|
384
|
-
session:
|
|
437
|
+
session: BoxShellSession,
|
|
385
438
|
startRelative: string,
|
|
386
439
|
beforeCommand?: () => Promise<void>,
|
|
387
440
|
): Promise<number> {
|