rnxsim 0.1.441 → 0.1.443

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 (71) hide show
  1. package/cli/bin.ts +3 -1
  2. package/cli/cloud-client.ts +153 -17
  3. package/cli/cloud-dispatch.ts +15 -0
  4. package/cli/cloud-session.ts +69 -17
  5. package/cli/commands/box/bundle-plane.ts +71 -0
  6. package/cli/commands/box.ts +274 -115
  7. package/cli/commands/control.ts +1 -137
  8. package/cli/commands/flow.ts +1 -1
  9. package/cli/commands/inspect/list.ts +119 -2
  10. package/cli/commands/inspect.ts +3 -2
  11. package/cli/commands/platform.ts +95 -12
  12. package/cli/commands/reset.ts +7 -3
  13. package/cli/commands/state.ts +7 -3
  14. package/cli/flow-export.ts +55 -0
  15. package/cli/flow-session.ts +0 -47
  16. package/cli/outbound-endpoints.ts +5 -0
  17. package/cli/rnx-in-process.ts +98 -0
  18. package/cli/ws-bridge.ts +30 -0
  19. package/dist-lib/agent-daemon-client.cjs +1 -1
  20. package/dist-lib/agent-events.cjs +1 -1
  21. package/dist-lib/agent-identity.cjs +1 -1
  22. package/dist-lib/agent-sessions.cjs +1 -1
  23. package/dist-lib/attached-projects.cjs +1 -1
  24. package/dist-lib/auth/shared-session.cjs +1 -1
  25. package/dist-lib/backend-origin.cjs +1 -1
  26. package/dist-lib/beta.cjs +1 -1
  27. package/dist-lib/beta.mjs +1 -1
  28. package/dist-lib/bridge-constants.cjs +1 -1
  29. package/dist-lib/bridge-contract-input.cjs +1 -1
  30. package/dist-lib/bridge-contract-input.mjs +1 -1
  31. package/dist-lib/bridge-contract.cjs +1 -1
  32. package/dist-lib/bridge-contract.mjs +1 -1
  33. package/dist-lib/capture-contract.cjs +1 -1
  34. package/dist-lib/capture-contract.mjs +1 -1
  35. package/dist-lib/cli-constants.cjs +1 -1
  36. package/dist-lib/cloud-contract.cjs +1 -1
  37. package/dist-lib/cloud-contract.mjs +1 -1
  38. package/dist-lib/cloud.cjs +1 -1
  39. package/dist-lib/cloud.mjs +1 -1
  40. package/dist-lib/config.cjs +1 -1
  41. package/dist-lib/detox/index.cjs +69 -14
  42. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  43. package/dist-lib/home-paths.cjs +17 -1
  44. package/dist-lib/host/bridge-host.cjs +24 -13
  45. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  46. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  47. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  48. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  49. package/dist-lib/host/websocket-proxy.cjs +1 -1
  50. package/dist-lib/index.cjs +1 -3
  51. package/dist-lib/jump-to-source-babel.cjs +1 -1
  52. package/dist-lib/menu.cjs +6 -19
  53. package/dist-lib/menu.mjs +6 -19
  54. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  55. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  56. package/dist-lib/metro-production-bundle.cjs +1 -1
  57. package/dist-lib/metro-production-bundle.mjs +1 -1
  58. package/dist-lib/metro.cjs +1 -1
  59. package/dist-lib/profiles.cjs +1 -1
  60. package/dist-lib/public-brand.cjs +1 -1
  61. package/dist-lib/react-native-host-modules.cjs +1 -1
  62. package/dist-lib/react-native-host-modules.mjs +1 -1
  63. package/dist-lib/render-mode.cjs +1 -1
  64. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  65. package/dist-lib/sdk.cjs +1 -1
  66. package/dist-lib/sdk.mjs +1 -1
  67. package/dist-lib/skills.cjs +274 -195
  68. package/dist-lib/vite.cjs +1 -1
  69. package/package.json +8 -1
  70. package/src/home-paths.ts +31 -1
  71. package/src/menu.ts +4 -17
@@ -5,8 +5,13 @@
5
5
  // --json asks for a noninteractive result. connect asks the account service to
6
6
  // turn a Box name into an authorized reference and enters that same shell, so
7
7
  // this process never stores a Box token.
8
+ //
9
+ // every verb that acts on an existing Box takes it the same way: the id or
10
+ // name on the command line, else the one `rnx box use` remembered. see
11
+ // boxTarget below.
8
12
 
9
13
  import { spawn } from 'node:child_process'
14
+ import fs from 'node:fs/promises'
10
15
  import path from 'node:path'
11
16
  import { createInterface } from 'node:readline'
12
17
  import { BoxClientError, createBoxClient } from '@rnx/box/client'
@@ -15,21 +20,23 @@ import { PROJECT_MOUNT } from '@rnx/box/shell'
15
20
  import { terminalBoxStack } from '@rnx/box/stack'
16
21
  import {
17
22
  BoxTemplateRegistry,
23
+ prebuiltBundleBoxTemplate,
18
24
  sourceControlBoxTemplate,
19
25
  terminalBoxTemplate,
20
26
  } from '@rnx/box/template'
21
27
  import {
22
- cloudBoxBrowserLiveView,
23
28
  cloudBoxConnectUrl,
24
29
  createCloudBoxProvider,
25
30
  ensureCloudBoxBrowser,
26
31
  heartbeatSession,
27
32
  resolveCloudBoxReference,
28
33
  } from 'rnx-cloud-box/client'
34
+ import { readDefaultBox, writeDefaultBox } from '../../src/home-paths'
29
35
  import { rnxPublicBrand } from '../../src/public-brand'
30
36
  import { authHeaderOrExit, cloudAccountIdOrExit, type CliAuth } from '../auth'
31
37
  import { findRepositoryPackageRoot } from '../setup-repository'
32
38
  import { resolvePlacement } from '../ws-bridge'
39
+ import { BundleFilePlane } from './box/bundle-plane'
33
40
  import { CheckoutFilePlane, CheckoutRequiresGitError } from './box/checkout-plane'
34
41
  import { sharePlane } from './box/plane-share'
35
42
  import { createRnxCommand } from './box/rnx-command'
@@ -50,49 +57,13 @@ const API_ORIGIN_ENV = 'RNX_API_ORIGIN'
50
57
  const BOX_TEMPLATES = new BoxTemplateRegistry([
51
58
  terminalBoxTemplate,
52
59
  sourceControlBoxTemplate,
60
+ prebuiltBundleBoxTemplate,
53
61
  ])
54
62
 
55
- function usage(): void {
56
- const rnx = rnxPublicBrand.commandName
57
- console.log(`
58
- ${rnx} box — start a local box and enter its shell
59
-
60
- usage:
61
- ${rnx} box start a local box over the current checkout
62
- ${rnx} box create create a cloud box and enter its shell
63
- ${rnx} box create --name=<n> create a named cloud box
64
- ${rnx} box create --template=<id>
65
- create from a registered Box template
66
- ${rnx} box create --endpoint=<url>
67
- the box service to create it in, or set
68
- ${ENDPOINT_ENV}
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
77
- ${rnx} box ls cloud boxes running on this account
78
- ${rnx} box stop --name=<n> stop a cloud box and its billing
79
- ${rnx} box delete --name=<n> stop it and wipe its files
80
-
81
- a local box mounts this checkout at ${PROJECT_MOUNT}, gives you a shell with
82
- ls, cat, find, rg, sed, pipes and redirects, and registers ${rnx} itself as a
83
- shell command. writes go straight to the checkout — there is no second copy.
84
- `)
85
- }
86
-
87
63
  export async function runBox(
88
64
  args: string[],
89
65
  opts: BoxCommandOptions = {},
90
66
  ): Promise<number> {
91
- if (args.includes('--help') || args.includes('-h')) {
92
- usage()
93
- return 0
94
- }
95
-
96
67
  // a box inside a box would mount the same checkout twice and give the
97
68
  // user two shells writing through one filesystem. refuse at the top rather
98
69
  // than letting the second one start and quietly race the first.
@@ -107,6 +78,7 @@ export async function runBox(
107
78
 
108
79
  const subcommand = args.find((arg) => !arg.startsWith('-'))
109
80
  if (subcommand === 'create') return runCloudBoxCreate(args, opts)
81
+ if (subcommand === 'use') return runCloudBoxUse(args)
110
82
  if (subcommand === 'connect') return runCloudBoxConnect(args)
111
83
  if (subcommand === 'send') return runBoxSend(args, opts)
112
84
  if (subcommand === 'open') return runCloudBoxOpen(args)
@@ -153,17 +125,113 @@ function apiOrigin(args: string[], auth: CliAuth): string {
153
125
  ).replace(/\/+$/, '')
154
126
  }
155
127
 
128
+ // the Box named on the command line: the second positional, since the first is
129
+ // the verb, or `--name` which is the same thing spelled as a flag.
130
+ function namedBox(args: string[]): string | undefined {
131
+ return args.filter((arg) => !arg.startsWith('-'))[1] ?? flag(args, 'name')
132
+ }
133
+
134
+ /**
135
+ * which Box a verb acts on.
136
+ *
137
+ * one order for every verb that acts on a Box that already exists: the id or
138
+ * name on the command line, else the one `rnx box use` remembered, else
139
+ * nothing and the reason printed here.
140
+ */
141
+ function boxTarget(args: string[], label: string): string | null {
142
+ const named = namedBox(args)
143
+ if (named) return named
144
+ const stored = readDefaultBox()
145
+ if (stored) return stored.boxId
146
+ const rnx = rnxPublicBrand.commandName
147
+ console.error(
148
+ ` ${rnx} box ${label}: which box? pass its ID or name, as \`${rnx} box ls\` prints it,\n` +
149
+ ` or set one for every box command with \`${rnx} box use <id-or-name>\`.`,
150
+ )
151
+ return null
152
+ }
153
+
154
+ /**
155
+ * `rnx box use` — the Box every other verb acts on when none is named.
156
+ *
157
+ * it resolves through the account service exactly as connect does, because
158
+ * that is the only thing that knows which account owns a Box, and a default
159
+ * pointing at somebody else's Box would fail at every later verb instead of
160
+ * here. what is written down is the id and the display name; the Box's own
161
+ * token is resolved again per command and never reaches disk.
162
+ */
163
+ async function runCloudBoxUse(args: string[]): Promise<number> {
164
+ const rnx = rnxPublicBrand.commandName
165
+ const named = namedBox(args)
166
+ if (!named) {
167
+ const stored = readDefaultBox()
168
+ if (!stored) {
169
+ console.log(
170
+ ` no default box. \`${rnx} box use <id-or-name>\` targets one, and every\n` +
171
+ ` other box command then acts on it when you name none.`,
172
+ )
173
+ return 0
174
+ }
175
+ console.log(` ${stored.name ?? stored.boxId}`)
176
+ return 0
177
+ }
178
+
179
+ const { auth, header } = authHeaderOrExit('box use')
180
+ const apiKey = header.replace(/^Bearer /, '')
181
+ try {
182
+ const reference = await resolveCloudBoxReference({
183
+ apiOrigin: apiOrigin(args, auth),
184
+ apiKey,
185
+ boxId: named,
186
+ })
187
+ // attach for the Box's own name: the caller may have typed either an id or
188
+ // a name, and the default has to be able to print the one a person reads.
189
+ const box = await createBoxClient(
190
+ createCloudBoxProvider({
191
+ endpoint: reference.endpoint,
192
+ authority: { kind: 'attach' },
193
+ }),
194
+ ).attach({
195
+ reference: { boxId: reference.boxId, accessToken: reference.token },
196
+ })
197
+ writeDefaultBox({ boxId: box.description.boxId, name: box.description.name })
198
+ console.log(
199
+ ` ...box commands now target ${box.description.name ?? box.description.boxId}`,
200
+ )
201
+ return 0
202
+ } catch (error) {
203
+ if (error instanceof BoxClientError) {
204
+ console.error(` ${rnx} box use: ${error.message}`)
205
+ return 1
206
+ }
207
+ throw error
208
+ }
209
+ }
210
+
156
211
  function requireEndpoint(args: string[], label: string): string | null {
212
+ return cloudBoxEndpoint(args, `box ${label}`)
213
+ }
214
+
215
+ /**
216
+ * the cloud box service this command talks to, or null with the reason
217
+ * printed. exported because `rnx ios --remote` creates a box too, and a second
218
+ * spelling of where boxes live is a second place to configure.
219
+ */
220
+ export function cloudBoxEndpoint(args: string[], command: string): string | null {
157
221
  const endpoint = flag(args, 'endpoint') ?? process.env[ENDPOINT_ENV]?.trim()
158
222
  if (endpoint) return endpoint
159
- const rnx = rnxPublicBrand.commandName
160
223
  console.error(
161
- ` ${rnx} box ${label}: no cloud box service to talk to.\n` +
224
+ ` ${rnxPublicBrand.commandName} ${command}: no cloud box service to talk to.\n` +
162
225
  ` set ${ENDPOINT_ENV} or pass --endpoint=<url>.`,
163
226
  )
164
227
  return null
165
228
  }
166
229
 
230
+ /** the api origin a cloud box bills against. */
231
+ export function cloudBoxApiOrigin(args: string[], auth: CliAuth): string {
232
+ return apiOrigin(args, auth)
233
+ }
234
+
167
235
  // `rnx box send` — the CLI half of "send to box". the shell's rail button runs
168
236
  // the same function inside the daemon; this is the same send for an agent or a
169
237
  // terminal, and it prints the id and URL either way.
@@ -209,12 +277,8 @@ async function runCloudBoxStop(args: string[], purge: boolean): Promise<number>
209
277
  const label = purge ? 'delete' : 'stop'
210
278
  const endpoint = requireEndpoint(args, label)
211
279
  if (!endpoint) return 1
212
- const name =
213
- flag(args, 'name') ?? path.basename(findRepositoryPackageRoot(process.cwd()) ?? '')
214
- if (!name) {
215
- console.error(` ${rnx} box ${label}: which box? pass --name=<n>.`)
216
- return 1
217
- }
280
+ const name = boxTarget(args, label)
281
+ if (!name) return 1
218
282
  const { auth, header } = authHeaderOrExit(`box ${label}`)
219
283
  const apiKey = header.replace(/^Bearer /, '')
220
284
  const accountId = cloudAccountIdOrExit(auth)
@@ -228,6 +292,10 @@ async function runCloudBoxStop(args: string[], purge: boolean): Promise<number>
228
292
  const result = purge
229
293
  ? await client.delete({ boxId: name })
230
294
  : await client.stop({ boxId: name })
295
+ // the box the default named is gone, so the default goes with it rather
296
+ // than pointing every later command at a box that stopped answering.
297
+ const stored = readDefaultBox()
298
+ if (stored && (stored.boxId === name || stored.name === name)) writeDefaultBox(null)
231
299
  console.log(
232
300
  purge
233
301
  ? ` deleted ${name}, freeing ${Number(result.freedBytes ?? 0)} bytes`
@@ -279,14 +347,8 @@ async function runCloudBoxList(args: string[]): Promise<number> {
279
347
  async function runCloudBoxConnect(args: string[]): Promise<number> {
280
348
  const rnx = rnxPublicBrand.commandName
281
349
  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
- }
350
+ const boxId = boxTarget(args, 'connect')
351
+ if (!boxId) return 1
290
352
 
291
353
  // the ACCOUNT's credential. the account service is the only thing that knows
292
354
  // which account a Box belongs to, so it resolves the name and hands back the
@@ -344,23 +406,21 @@ async function runCloudBoxConnect(args: string[]): Promise<number> {
344
406
  /**
345
407
  * watch a cloud box in a browser.
346
408
  *
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.
409
+ * the box runs its app in one Browser Run page, and this opens the Box page,
410
+ * which streams THAT instance rather than booting a second copy. so what the
411
+ * person sees is what the agent is working on, and two people opening the same
412
+ * box see the same screen.
413
+ *
414
+ * the url carries no credential: it is the box's page on the Contrast origin,
415
+ * and opening it signed in resolves the box through the account service. that
416
+ * makes it a link somebody can keep, paste, or reload.
351
417
  */
352
418
  async function runCloudBoxOpen(args: string[]): Promise<number> {
353
419
  const rnx = rnxPublicBrand.commandName
354
420
  const json = args.includes('--json')
355
421
  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
- }
422
+ const boxId = boxTarget(args, 'open')
423
+ if (!boxId) return 1
364
424
 
365
425
  // the ACCOUNT's credential, for the same reason connect takes one: the
366
426
  // account service is what knows which account owns this Box, and it hands
@@ -377,7 +437,8 @@ async function runCloudBoxOpen(args: string[]): Promise<number> {
377
437
  const connectUrl = cloudBoxConnectUrl(reference.endpoint, reference.boxId)
378
438
  // ensure first: it is what navigates the generation's tab to the Box page,
379
439
  // so a box whose browser lapsed gets a new generation with its page
380
- // restored before anybody is looking at it.
440
+ // restored before anybody is looking at it. without it the page opens onto
441
+ // a box with no simulator running and nothing to stream.
381
442
  const browser = await ensureCloudBoxBrowser({
382
443
  connectUrl,
383
444
  accessToken: reference.token,
@@ -385,25 +446,32 @@ async function runCloudBoxOpen(args: string[]): Promise<number> {
385
446
  if (!browser.reused && !json && !print) {
386
447
  console.log(` ...started this box's browser`)
387
448
  }
388
- const view = await cloudBoxBrowserLiveView({
389
- connectUrl,
390
- accessToken: reference.token,
391
- mode: 'tab',
449
+ const box = await createBoxClient(
450
+ createCloudBoxProvider({
451
+ endpoint: reference.endpoint,
452
+ authority: { kind: 'attach' },
453
+ }),
454
+ ).attach({
455
+ reference: { boxId: reference.boxId, accessToken: reference.token },
392
456
  })
393
- if (json) {
394
- console.log(
395
- JSON.stringify({
396
- url: view.url,
397
- expiresAt: new Date(Date.now() + view.expiresInMs).toISOString(),
398
- }),
457
+ // the box owns this url because it is the only side that knows both the
458
+ // page origin and the route.
459
+ const url = box.description.pageUrl
460
+ if (!url) {
461
+ console.error(
462
+ ` ${rnx} box open: this box service has no page origin configured, so it has no page to open.`,
399
463
  )
464
+ return 1
465
+ }
466
+ if (json) {
467
+ console.log(JSON.stringify({ url }))
400
468
  return 0
401
469
  }
402
470
  if (print) {
403
- console.log(view.url)
471
+ console.log(url)
404
472
  return 0
405
473
  }
406
- return await openInBrowser(view.url)
474
+ return await openInBrowser(url)
407
475
  } catch (error) {
408
476
  if (error instanceof BoxClientError) {
409
477
  console.error(` ${rnx} box open: ${error.message}`)
@@ -421,7 +489,7 @@ async function runCloudBoxOpen(args: string[]): Promise<number> {
421
489
  * `open` cannot run it is printed instead: this process is the only place the
422
490
  * URL exists, so dropping it would cost the caller another mint.
423
491
  */
424
- async function openInBrowser(url: string): Promise<number> {
492
+ export async function openInBrowser(url: string): Promise<number> {
425
493
  const child = spawn('open', [url], { stdio: 'ignore' })
426
494
  const opened = await new Promise<boolean>((resolve) => {
427
495
  child.once('error', () => resolve(false))
@@ -431,6 +499,49 @@ async function openInBrowser(url: string): Promise<number> {
431
499
  return 0
432
500
  }
433
501
 
502
+ function cloudBoxClient(options: {
503
+ endpoint: string
504
+ apiOrigin: string
505
+ apiKey: string
506
+ accountId: string | null
507
+ }) {
508
+ return createBoxClient(
509
+ createCloudBoxProvider({
510
+ endpoint: options.endpoint,
511
+ authority: {
512
+ kind: 'account',
513
+ apiOrigin: options.apiOrigin,
514
+ apiKey: options.apiKey,
515
+ accountId: options.accountId,
516
+ },
517
+ }),
518
+ )
519
+ }
520
+
521
+ /**
522
+ * create a Box that runs an app somebody already built.
523
+ *
524
+ * `box create --bundle` reaches this with a file from disk and `rnx ios
525
+ * --remote` with the artifact it just produced. both are the same box: the
526
+ * bundle is the whole project, the provenance says so, and the page that opens
527
+ * it boots that bundle instead of building anything.
528
+ */
529
+ export async function createPrebuiltBundleBox(options: {
530
+ bundle: Uint8Array
531
+ name: string
532
+ endpoint: string
533
+ apiOrigin: string
534
+ apiKey: string
535
+ accountId: string | null
536
+ }) {
537
+ return cloudBoxClient(options).create({
538
+ name: options.name,
539
+ stack: prebuiltBundleBoxTemplate.stack,
540
+ template: BOX_TEMPLATES.resolve({ name: prebuiltBundleBoxTemplate.name }).provenance,
541
+ source: new BundleFilePlane(options.bundle),
542
+ })
543
+ }
544
+
434
545
  async function runCloudBoxCreate(
435
546
  args: string[],
436
547
  opts: BoxCommandOptions,
@@ -447,7 +558,20 @@ async function runCloudBoxCreate(
447
558
  return 1
448
559
  }
449
560
 
450
- const templateName = flag(args, 'template') ?? sourceControlBoxTemplate.name
561
+ // --bundle IS the template choice: the box runs an app that is already
562
+ // built, so naming a second template would ask for two different boxes.
563
+ const bundlePath = flag(args, 'bundle')
564
+ const namedTemplate = flag(args, 'template')
565
+ if (bundlePath && namedTemplate) {
566
+ console.error(
567
+ ` ${rnx} box create: --bundle creates a ${prebuiltBundleBoxTemplate.name} box,\n` +
568
+ ` so it cannot be combined with --template ${namedTemplate}.`,
569
+ )
570
+ return 1
571
+ }
572
+ const templateName = bundlePath
573
+ ? prebuiltBundleBoxTemplate.name
574
+ : (namedTemplate ?? sourceControlBoxTemplate.name)
451
575
  let template: BoxTemplateResolution
452
576
  try {
453
577
  template = BOX_TEMPLATES.resolve({ name: templateName })
@@ -458,27 +582,55 @@ async function runCloudBoxCreate(
458
582
  return 1
459
583
  }
460
584
 
461
- const root = findRepositoryPackageRoot(process.cwd())
462
- if (!root) {
463
- console.error(
464
- ` ${rnx} box create: no project here. a box is created from a checkout,\n` +
465
- ` so run this inside a git repository that has a package.json.`,
466
- )
467
- return 1
468
- }
469
-
470
- const plane = new CheckoutFilePlane(root, { trackedOnly: true })
471
- try {
472
- await plane.refresh()
473
- } catch (error) {
474
- if (error instanceof CheckoutRequiresGitError) {
475
- console.error(` ${rnx} box create: ${error.message}`)
585
+ // a prebuilt box holds the bundle; a source box holds the checkout it is
586
+ // built from. nothing holds both, so they are not two nullable variables.
587
+ let source: { bundle: Uint8Array } | { checkout: CheckoutFilePlane }
588
+ let defaultName: string
589
+ if (bundlePath) {
590
+ // a prebuilt box carries no sources, so there is no checkout to read and
591
+ // this can run anywhere, including a directory that is not a repository.
592
+ const resolved = path.resolve(bundlePath)
593
+ let read: Buffer
594
+ try {
595
+ read = await fs.readFile(resolved)
596
+ } catch (error) {
597
+ console.error(
598
+ ` ${rnx} box create: could not read the bundle at ${resolved}: ${
599
+ error instanceof Error ? error.message : String(error)
600
+ }`,
601
+ )
476
602
  return 1
477
603
  }
478
- throw error
604
+ if (read.byteLength === 0) {
605
+ console.error(` ${rnx} box create: the bundle at ${resolved} is empty.`)
606
+ return 1
607
+ }
608
+ source = { bundle: new Uint8Array(read) }
609
+ defaultName = path.basename(resolved).replace(/\.[^.]+$/, '')
610
+ } else {
611
+ const root = findRepositoryPackageRoot(process.cwd())
612
+ if (!root) {
613
+ console.error(
614
+ ` ${rnx} box create: no project here. a box is created from a checkout,\n` +
615
+ ` so run this inside a git repository that has a package.json.`,
616
+ )
617
+ return 1
618
+ }
619
+ const checkout = new CheckoutFilePlane(root, { trackedOnly: true })
620
+ try {
621
+ await checkout.refresh()
622
+ } catch (error) {
623
+ if (error instanceof CheckoutRequiresGitError) {
624
+ console.error(` ${rnx} box create: ${error.message}`)
625
+ return 1
626
+ }
627
+ throw error
628
+ }
629
+ source = { checkout }
630
+ defaultName = path.basename(root)
479
631
  }
480
632
 
481
- const name = flag(args, 'name') ?? path.basename(root)
633
+ const name = flag(args, 'name') ?? defaultName
482
634
  // the ACCOUNT's key. the box service forwards it to /v1/rnx/sessions, which
483
635
  // is what authenticates this caller, names the account, and starts the
484
636
  // session the box is billed against.
@@ -487,25 +639,28 @@ async function runCloudBoxCreate(
487
639
  const accountId = cloudAccountIdOrExit(auth)
488
640
 
489
641
  if (!json) console.log(` ...creating cloud box ${name}`)
490
- const client = createBoxClient(
491
- createCloudBoxProvider({
492
- endpoint,
493
- authority: { kind: 'account', apiOrigin: apiOrigin(args, auth), apiKey, accountId },
494
- }),
495
- )
642
+ const connection = {
643
+ endpoint,
644
+ apiOrigin: apiOrigin(args, auth),
645
+ apiKey,
646
+ accountId,
647
+ }
496
648
  let session: BoxShellSession | null = null
497
649
  let heartbeat: { stop: () => void } | null = null
498
650
  try {
499
- const box = await client.create({
500
- name,
501
- stack: template.stack,
502
- template: template.provenance,
503
- source: plane,
504
- onProgress:
505
- opts.verbose && !json
506
- ? (seeded, total) => console.log(` ...seeded ${seeded}/${total} files`)
507
- : undefined,
508
- })
651
+ const box =
652
+ 'bundle' in source
653
+ ? await createPrebuiltBundleBox({ ...connection, bundle: source.bundle, name })
654
+ : await cloudBoxClient(connection).create({
655
+ name,
656
+ stack: template.stack,
657
+ template: template.provenance,
658
+ source: source.checkout,
659
+ onProgress:
660
+ opts.verbose && !json
661
+ ? (seeded, total) => console.log(` ...seeded ${seeded}/${total} files`)
662
+ : undefined,
663
+ })
509
664
  if (json) {
510
665
  console.log(
511
666
  JSON.stringify({
@@ -516,7 +671,11 @@ async function runCloudBoxCreate(
516
671
  return 0
517
672
  }
518
673
  console.log(` ...created Box ${box.id}`)
519
- console.log(` ...seeded ${box.description.files} project files`)
674
+ console.log(
675
+ 'bundle' in source
676
+ ? ` ...seeded a prebuilt bundle of ${source.bundle.byteLength} bytes`
677
+ : ` ...seeded ${box.description.files} project files`,
678
+ )
520
679
  // shown once and never stored: this process does not own a credential
521
680
  // store, and inventing one here would be a second place secrets live.
522
681
  console.log(` ...box token (shown once): ${box.accessToken}`)