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.
Files changed (138) hide show
  1. package/README.md +19 -6
  2. package/cli/app-fonts.ts +1 -1
  3. package/cli/bin.ts +118 -26
  4. package/cli/bridge-diagnostics.ts +1 -1
  5. package/cli/bridge-flow-runner.ts +9 -9
  6. package/cli/browser-evals.ts +3 -3
  7. package/cli/cli-update.ts +261 -0
  8. package/cli/cloud-client.ts +407 -0
  9. package/cli/cloud-dispatch.ts +165 -0
  10. package/cli/cloud-session.ts +156 -0
  11. package/cli/command-contract.ts +2 -2
  12. package/cli/command-registry.ts +9 -0
  13. package/cli/commands/agent-wrapper.ts +4 -4
  14. package/cli/commands/agent.ts +3 -3
  15. package/cli/commands/box/checkout-plane.ts +16 -8
  16. package/cli/commands/box/rnx-command.ts +2 -2
  17. package/cli/commands/box/search-command.ts +2 -2
  18. package/cli/commands/box.ts +123 -70
  19. package/cli/commands/config.ts +3 -2
  20. package/cli/commands/control.ts +6 -6
  21. package/cli/commands/cpu-profile.ts +2 -2
  22. package/cli/commands/daemon.ts +7 -7
  23. package/cli/commands/debug.ts +2 -2
  24. package/cli/commands/detox.ts +7 -7
  25. package/cli/commands/diagnose.ts +1 -1
  26. package/cli/commands/electron.ts +1 -1
  27. package/cli/commands/film.ts +1 -1
  28. package/cli/commands/flow.ts +4 -4
  29. package/cli/commands/inspect/core.ts +58 -7
  30. package/cli/commands/inspect/env.ts +5 -3
  31. package/cli/commands/inspect/shared.ts +2 -1
  32. package/cli/commands/inspect/wait-event.ts +1 -1
  33. package/cli/commands/inspect/wait-ready.ts +15 -5
  34. package/cli/commands/inspect.ts +31 -9
  35. package/cli/commands/install-desktop.ts +2 -2
  36. package/cli/commands/maestro.ts +3 -3
  37. package/cli/commands/permissions.ts +1 -1
  38. package/cli/commands/platform.ts +91 -0
  39. package/cli/commands/record.ts +3 -3
  40. package/cli/commands/report-issue.ts +1 -1
  41. package/cli/commands/runtime.ts +1 -1
  42. package/cli/commands/screenshot-mode.ts +1 -1
  43. package/cli/commands/screenshot.ts +25 -0
  44. package/cli/commands/serve.ts +1 -1
  45. package/cli/commands/skills.ts +1 -1
  46. package/cli/commands/timeline.ts +1 -1
  47. package/cli/commands/upgrade.ts +8 -34
  48. package/cli/commands/upload.ts +3 -3
  49. package/cli/commands/version.ts +14 -3
  50. package/cli/commands/what-happened.ts +2 -2
  51. package/cli/current-sim.ts +43 -30
  52. package/cli/desktop-companion.ts +1 -1
  53. package/cli/drivers/electron.ts +1 -1
  54. package/cli/drivers/playwright.ts +3 -3
  55. package/cli/drivers/types.ts +4 -4
  56. package/cli/flow-file.ts +3 -3
  57. package/cli/help-core.ts +18 -11
  58. package/cli/hidden-runtime-alias.ts +1 -1
  59. package/cli/hints.ts +2 -2
  60. package/cli/maestro-js.ts +2 -2
  61. package/cli/outbound-endpoints.ts +40 -6
  62. package/cli/parse-args.ts +1 -1
  63. package/cli/registry.ts +1 -1
  64. package/cli/resolve-assets.ts +2 -2
  65. package/cli/secure-file.ts +31 -0
  66. package/cli/shell-init.ts +98 -0
  67. package/cli/ws-bridge.ts +18 -8
  68. package/detox/expectations.ts +1 -1
  69. package/detox/gestures.ts +3 -3
  70. package/detox/index.ts +3 -3
  71. package/dist-lib/agent-daemon-client.cjs +1 -1
  72. package/dist-lib/agent-events.cjs +1 -1
  73. package/dist-lib/agent-identity.cjs +1 -1
  74. package/dist-lib/agent-sessions.cjs +1 -1
  75. package/dist-lib/attached-projects.cjs +1 -1
  76. package/dist-lib/auth/shared-session.cjs +1 -1
  77. package/dist-lib/backend-origin.cjs +1 -1
  78. package/dist-lib/beta.cjs +1 -1
  79. package/dist-lib/beta.mjs +1 -1
  80. package/dist-lib/bridge-constants.cjs +1 -1
  81. package/dist-lib/bridge-contract-input.cjs +15 -8
  82. package/dist-lib/bridge-contract-input.mjs +15 -8
  83. package/dist-lib/bridge-contract.cjs +1 -1
  84. package/dist-lib/bridge-contract.mjs +1 -1
  85. package/dist-lib/capture-contract.cjs +107 -0
  86. package/dist-lib/capture-contract.mjs +80 -0
  87. package/dist-lib/cli-constants.cjs +1 -1
  88. package/dist-lib/cloud-contract.cjs +32 -0
  89. package/dist-lib/cloud-contract.mjs +7 -0
  90. package/dist-lib/config.cjs +1 -1
  91. package/dist-lib/detox/index.cjs +4 -4
  92. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  93. package/dist-lib/home-paths.cjs +9 -1
  94. package/dist-lib/host/bridge-host.cjs +15 -10
  95. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  96. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  97. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  98. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  99. package/dist-lib/host/websocket-proxy.cjs +1 -1
  100. package/dist-lib/index.cjs +248 -73
  101. package/dist-lib/jump-to-source-babel.cjs +1 -1
  102. package/dist-lib/menu.cjs +1 -1
  103. package/dist-lib/menu.mjs +1 -1
  104. package/dist-lib/metro-production-bundle.cjs +1 -1
  105. package/dist-lib/metro-production-bundle.mjs +1 -1
  106. package/dist-lib/metro.cjs +1 -1
  107. package/dist-lib/profiles.cjs +1 -1
  108. package/dist-lib/public-brand.cjs +1 -1
  109. package/dist-lib/react-native-host-modules.cjs +1 -1
  110. package/dist-lib/react-native-host-modules.mjs +1 -1
  111. package/dist-lib/render-mode.cjs +1 -1
  112. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  113. package/dist-lib/sdk.cjs +44 -6
  114. package/dist-lib/sdk.mjs +44 -6
  115. package/dist-lib/skills.cjs +435 -155
  116. package/dist-lib/vite.cjs +1 -1
  117. package/package.json +20 -4
  118. package/scripts/dev-server-scanner.ts +3 -3
  119. package/skills/contrast/SKILL.md +1 -1
  120. package/skills/rnx-perf/SKILL.md +68 -52
  121. package/src/app-url.ts +2 -3
  122. package/src/bridge-contract-input.ts +17 -6
  123. package/src/bridge-contract.ts +25 -4
  124. package/src/capture-contract.ts +423 -0
  125. package/src/cloud-contract.ts +15 -0
  126. package/src/home-paths.ts +9 -3
  127. package/src/host/agent-host.ts +1 -1
  128. package/src/host/bridge-host.ts +13 -11
  129. package/src/host/plane-host.ts +2 -2
  130. package/src/index.ts +21 -0
  131. package/src/native-dev-bundle-url.ts +2 -2
  132. package/src/screenshots/orchestrate.ts +1 -1
  133. package/src/screenshots/org-deck.ts +1 -1
  134. package/src/settings-bundle.ts +1 -1
  135. package/src/sim-client.ts +4 -0
  136. package/src/vite-plugin-one.ts +2 -2
  137. package/src/vite-plugin.ts +1 -1
  138. 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
+ }
@@ -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
 
@@ -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 sootsim desktop and a
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 sootsim CLI subcommand so it survives `bun build
4
+ // shipped as an internal rnx CLI subcommand so it survives `bun build
5
5
  // --compile` bundling. callers spawn it as:
6
6
  //
7
- // sootsim agent-wrapper \
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. `sootsim agent prompt` opens,
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)
@@ -1,4 +1,4 @@
1
- // user-facing `sootsim agent` command tree.
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 `sootsim agent projects` stays fast when no
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 `sootsim agent`, `--help`,
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 '@contrast/harness/plane'
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. HarnessProjectFs owns that refusal. detecting it at write
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(root: string, options: { listTimeoutMs?: number } = {}) {
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 so paths containing spaces or newlines survive; -co --exclude-standard
105
- // is tracked plus untracked-not-ignored, which is what a person means by
106
- // "the files in my project".
107
- stdout = execFileSync('git', ['ls-files', '-co', '--exclude-standard', '-z'], {
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 git tracks nothing there and nothing untracked is visible to it, or this machine could not read the child process's output.`,
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 '@contrast/harness/shell'
12
- import { PROJECT_MOUNT } from '@contrast/harness/shell'
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 '@contrast/harness/shell'
36
- import { PROJECT_MOUNT } from '@contrast/harness/shell'
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.
@@ -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. `rnx box
4
- // create` provisions a durable cloud box. everything else in the namespace is
5
- // cloud lifecycle. the cloud side is not built yet; those verbs fail by name
6
- // and say what works instead, so nothing advertises a capability that always
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
- createHarnessShell,
13
- HarnessProjectFs,
14
- PROJECT_MOUNT,
15
- } from '@contrast/harness/shell'
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 { HarnessShellResult } from '@contrast/harness/shell'
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 = await stopCloudBox({
157
- endpoint,
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 CloudBoxError) {
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
- try {
181
- const boxes = await listCloudBoxes({
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: header.replace(/^Bearer /, ''),
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(` ${box.name ?? box.id} running ${minutes}m since ${box.startedAt}`)
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 CloudBoxError) {
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
- let session: Awaited<ReturnType<typeof connectCloudBox>> | null = null
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 created = await createCloudBox({
250
- endpoint,
274
+ const box = await client.create({
251
275
  name,
252
- apiKey,
253
- plane,
254
- onProgress: opts.verbose
255
- ? (seeded, total) => console.log(` ...seeded ${seeded}/${total} files`)
256
- : undefined,
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
- console.log(` ...seeded ${created.files} project files`)
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): ${created.token}`)
300
+ console.log(` ...box token (shown once): ${box.accessToken}`)
262
301
  console.log(` ...accessing shell\n`)
263
- session = await connectCloudBox({ endpoint, name, token: created.token })
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: created.sessionId,
311
+ sessionId,
271
312
  })
272
313
  return await repl(session, '')
273
314
  } catch (error) {
274
- if (error instanceof CloudBoxError) {
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
- const shell = createHarnessShell({
315
- projectFs: new HarnessProjectFs(plane),
316
- commands: [createRnxCommand({ root, shellCwd: () => shell.cwd })],
317
- // just-bash's JavaScript rg reads every file body through the plane.
318
- // see search-command.ts for the numbers.
319
- builtInCommandOverrides: [createSearchCommand({ root })],
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
- // HarnessProjectFs arms a stale bit on each shell turn and refreshes the
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(shell, relative)
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
- // HarnessShell over this checkout; for a cloud box it is a socket to the box,
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: BoxSession,
437
+ session: BoxShellSession,
385
438
  startRelative: string,
386
439
  beforeCommand?: () => Promise<void>,
387
440
  ): Promise<number> {