// `rnx box` — the box environment. // // bare `rnx box` is LOCAL and mounts the current checkout at /project. cloud // create provisions through the shared client and enters the same shell unless // --json asks for a noninteractive result. connect asks the account service to // turn a Box name into an authorized reference and enters that same shell, so // this process never stores a Box token. // // every verb that acts on an existing Box takes it the same way: the id or // name on the command line, else the one `rnx box use` remembered. see // boxTarget below. import { spawn } from 'node:child_process' import path from 'node:path' import { createInterface } from 'node:readline' import { BoxClientError, createBoxClient } from '@rnx/box/client' import { createBoxHostProvider } from '@rnx/box/host' import { PROJECT_MOUNT } from '@rnx/box/shell' import { terminalBoxStack } from '@rnx/box/stack' import { BoxTemplateRegistry, prebuiltBundleBoxTemplate, sourceControlBoxTemplate, terminalBoxTemplate, } from '@rnx/box/template' import { cloudBoxConnectUrl, createCloudBoxProvider, ensureCloudBoxBrowser, heartbeatSession, resolveCloudBoxReference, } from 'rnx-cloud-box/client' import { readDefaultBox, writeDefaultBox } from '../../src/home-paths' import { rnxPublicBrand } from '../../src/public-brand' import { authHeaderOrExit, cloudAccountIdOrExit, type CliAuth } from '../auth' import { produceCloudBoxArtifact, type CloudBoxArtifactProduction } from '../cloud-client' import { findRepositoryPackageRoot } from '../setup-repository' import { resolvePlacement } from '../ws-bridge' import { BundleFilePlane } from './box/bundle-plane' import { CheckoutFilePlane, CheckoutRequiresGitError } from './box/checkout-plane' import { sharePlane } from './box/plane-share' import { createRnxCommand } from './box/rnx-command' import { createSearchCommand } from './box/search-command' import type { BoxShellSession } from '@rnx/box/client' import type { BoxTemplateResolution } from '@rnx/box/template' interface BoxCommandOptions { port?: number verbose?: boolean } // no default. a cloud box service has to be deployed before one can exist, and // defaulting to an origin that answers nothing would advertise a capability // that always fails. const ENDPOINT_ENV = 'RNX_BOX_ENDPOINT' const API_ORIGIN_ENV = 'RNX_API_ORIGIN' const BOX_TEMPLATES = new BoxTemplateRegistry([ terminalBoxTemplate, sourceControlBoxTemplate, prebuiltBundleBoxTemplate, ]) export async function runBox( args: string[], opts: BoxCommandOptions = {}, ): Promise { // a box inside a box would mount the same checkout twice and give the // user two shells writing through one filesystem. refuse at the top rather // than letting the second one start and quietly race the first. const placement = resolvePlacement() if (placement === 'local-box') { console.error( ` ${rnxPublicBrand.commandName} box: you are already in a box.\n` + ` this shell is the box — run ${rnxPublicBrand.commandName} commands directly, or \`exit\` to leave.`, ) return 1 } const subcommand = args.find((arg) => !arg.startsWith('-')) if (subcommand === 'create') return runCloudBoxCreate(args, opts) if (subcommand === 'use') return runCloudBoxUse(args) if (subcommand === 'connect') return runCloudBoxConnect(args) if (subcommand === 'send') return runBoxSend(args, opts) if (subcommand === 'open') return runCloudBoxOpen(args) if (subcommand === 'stop' || subcommand === 'delete') { return runCloudBoxStop(args, subcommand === 'delete') } if (subcommand === 'ls') return runCloudBoxList(args) if (subcommand !== undefined) { console.error( ` unknown command: ${rnxPublicBrand.commandName} box ${subcommand}\n` + ` run \`${rnxPublicBrand.commandName} box --help\` to see the box surface.`, ) return 1 } if (args.includes('--json')) { console.error( ` ${rnxPublicBrand.commandName} box --json requires a noninteractive cloud subcommand such as create.`, ) return 1 } return runLocalBox(opts) } function flag(args: string[], name: string): string | undefined { const inline = args.find((arg) => arg.startsWith(`--${name}=`)) if (inline) return inline.slice(name.length + 3) const index = args.indexOf(`--${name}`) if (index >= 0 && args[index + 1] && !args[index + 1].startsWith('-')) { return args[index + 1] } return undefined } // the account service, which owns /api/v1/rnx/sessions and the box connect // route. a login session names the origin it was issued by; an api key // carries none, so it goes where every other key-authenticated command goes. function apiOrigin(args: string[], auth: CliAuth): string { return ( flag(args, 'api-origin') ?? process.env[API_ORIGIN_ENV]?.trim() ?? (auth.kind === 'session' ? auth.origin : 'https://contrast.dev') ).replace(/\/+$/, '') } // the Box named on the command line: the second positional, since the first is // the verb, or `--name` which is the same thing spelled as a flag. function namedBox(args: string[]): string | undefined { return args.filter((arg) => !arg.startsWith('-'))[1] ?? flag(args, 'name') } /** * which Box a verb acts on. * * one order for every verb that acts on a Box that already exists: the id or * name on the command line, else the one `rnx box use` remembered, else * nothing and the reason printed here. */ function boxTarget(args: string[], label: string): string | null { const named = namedBox(args) if (named) return named const stored = readDefaultBox() if (stored) return stored.boxId const rnx = rnxPublicBrand.commandName console.error( ` ${rnx} box ${label}: which box? pass its ID or name, as \`${rnx} box ls\` prints it,\n` + ` or set one for every box command with \`${rnx} box use \`.`, ) return null } /** * `rnx box use` — the Box every other verb acts on when none is named. * * it resolves through the account service exactly as connect does, because * that is the only thing that knows which account owns a Box, and a default * pointing at somebody else's Box would fail at every later verb instead of * here. what is written down is the id and the display name; the Box's own * token is resolved again per command and never reaches disk. */ async function runCloudBoxUse(args: string[]): Promise { const rnx = rnxPublicBrand.commandName const named = namedBox(args) if (!named) { const stored = readDefaultBox() if (!stored) { console.log( ` no default box. \`${rnx} box use \` targets one, and every\n` + ` other box command then acts on it when you name none.`, ) return 0 } console.log(` ${stored.name ?? stored.boxId}`) return 0 } const { auth, header } = authHeaderOrExit('box use') const apiKey = header.replace(/^Bearer /, '') try { const reference = await resolveCloudBoxReference({ apiOrigin: apiOrigin(args, auth), apiKey, boxId: named, }) // attach for the Box's own name: the caller may have typed either an id or // a name, and the default has to be able to print the one a person reads. const box = await createBoxClient( createCloudBoxProvider({ endpoint: reference.endpoint, authority: { kind: 'attach' }, }), ).attach({ reference: { boxId: reference.boxId, accessToken: reference.token }, }) writeDefaultBox({ boxId: box.description.boxId, name: box.description.name }) console.log( ` ...box commands now target ${box.description.name ?? box.description.boxId}`, ) return 0 } catch (error) { if (error instanceof BoxClientError) { console.error(` ${rnx} box use: ${error.message}`) return 1 } throw error } } function requireEndpoint(args: string[], label: string): string | null { return cloudBoxEndpoint(args, `box ${label}`) } /** * the cloud box service this command talks to, or null with the reason * printed. exported because `rnx box send` creates a box too, and a second * spelling of where boxes live is a second place to configure. */ export function cloudBoxEndpoint(args: string[], command: string): string | null { const endpoint = flag(args, 'endpoint') ?? process.env[ENDPOINT_ENV]?.trim() if (endpoint) return endpoint console.error( ` ${rnxPublicBrand.commandName} ${command}: no cloud box service to talk to.\n` + ` set ${ENDPOINT_ENV} or pass --endpoint=.`, ) return null } /** the api origin a cloud box bills against. */ export function cloudBoxApiOrigin(args: string[], auth: CliAuth): string { return apiOrigin(args, auth) } // `rnx box send` — the CLI half of "send to box". the shell's rail button runs // the same function inside the daemon; this is the same send for an agent or a // terminal, and it prints the id and URL either way. async function runBoxSend(args: string[], opts: BoxCommandOptions): Promise { const rnx = rnxPublicBrand.commandName const { auth, header } = authHeaderOrExit('box send') const accountId = cloudAccountIdOrExit(auth) const { createBridgeFromParsed, parseBridgeCliArgs } = await import('../ws-bridge') const parsed = parseBridgeCliArgs( args.filter((arg) => arg !== 'send'), { port: opts.port, stripBooleanFlags: ['--no-auth', '--json'], stripValueFlags: ['--name', '--api-origin'], }, ) const bridge = createBridgeFromParsed(parsed) try { const { sendToBox } = await import('../send-to-box') const result = await sendToBox({ send: (command) => bridge.send(command), authorization: header, accountId, carryAuth: !args.includes('--no-auth'), boxName: flag(args, 'name') ?? null, apiOrigin: flag(args, 'api-origin'), }) if (args.includes('--json')) console.log(JSON.stringify(result)) return 0 } catch (error) { console.error( ` ${rnx} box send: ${error instanceof Error ? error.message : String(error)}`, ) return 1 } finally { bridge.close() } } async function runCloudBoxStop(args: string[], purge: boolean): Promise { const rnx = rnxPublicBrand.commandName const label = purge ? 'delete' : 'stop' const endpoint = requireEndpoint(args, label) if (!endpoint) return 1 const name = boxTarget(args, label) if (!name) return 1 const { auth, header } = authHeaderOrExit(`box ${label}`) const apiKey = header.replace(/^Bearer /, '') const accountId = cloudAccountIdOrExit(auth) const client = createBoxClient( createCloudBoxProvider({ endpoint, authority: { kind: 'account', apiOrigin: apiOrigin(args, auth), apiKey, accountId }, }), ) try { const result = purge ? await client.delete({ boxId: name }) : await client.stop({ boxId: name }) // the box the default named is gone, so the default goes with it rather // than pointing every later command at a box that stopped answering. const stored = readDefaultBox() if (stored && (stored.boxId === name || stored.name === name)) writeDefaultBox(null) console.log( purge ? ` deleted ${name}, freeing ${Number(result.freedBytes ?? 0)} bytes` : ` stopped ${name}`, ) return 0 } catch (error) { if (error instanceof BoxClientError) { console.error(` ${rnx} box ${label}: ${error.message}`) return 1 } throw error } } async function runCloudBoxList(args: string[]): Promise { const rnx = rnxPublicBrand.commandName const { auth, header } = authHeaderOrExit('box ls') const apiKey = header.replace(/^Bearer /, '') const accountId = cloudAccountIdOrExit(auth) const client = createBoxClient( createCloudBoxProvider({ endpoint: flag(args, 'endpoint') ?? process.env[ENDPOINT_ENV]?.trim() ?? '', authority: { kind: 'account', apiOrigin: apiOrigin(args, auth), apiKey, accountId }, }), ) try { const boxes = await client.list() if (boxes.length === 0) { console.log(` no cloud boxes running on this account`) return 0 } for (const box of boxes) { const minutes = Math.round(box.meteredMs / 60_000) console.log( ` ${box.name ?? box.boxId} running ${minutes}m since ${box.startedAt}`, ) } return 0 } catch (error) { if (error instanceof BoxClientError) { console.error(` ${rnx} box ls: ${error.message}`) return 1 } throw error } } async function runCloudBoxConnect(args: string[]): Promise { const rnx = rnxPublicBrand.commandName const json = args.includes('--json') const boxId = boxTarget(args, 'connect') if (!boxId) return 1 // the ACCOUNT's credential. the account service is the only thing that knows // which account a Box belongs to, so it resolves the name and hands back the // Box's own token; nothing here writes that token down. const { auth, header } = authHeaderOrExit('box connect') const apiKey = header.replace(/^Bearer /, '') const origin = apiOrigin(args, auth) let session: BoxShellSession | null = null let heartbeat: { stop: () => void } | null = null try { const reference = await resolveCloudBoxReference({ apiOrigin: origin, apiKey, boxId }) const box = await createBoxClient( createCloudBoxProvider({ endpoint: reference.endpoint, authority: { kind: 'attach' }, }), ).attach({ // no stack here on purpose: the Box reports the one it was created // with, and holding somebody else's Box to a stack this process guessed // would offer primitives it does not actually have. reference: { boxId: reference.boxId, accessToken: reference.token }, }) if (json) { // the reference without its token. a caller scripting against this gets // the Box's identity; the credential stays out of stdout and out of // whatever collects it. console.log(JSON.stringify({ endpoint: reference.endpoint, ...box.description })) return 0 } console.log(` ...connected to Box ${box.id}`) if (box.description.pageUrl) { console.log(` ...open ${box.description.pageUrl}`) } console.log(` ...accessing shell\n`) session = await box.connect() // the same reason `create` heartbeats: the box holds no account credential, // so the client using it is what keeps its session alive. const sessionId = box.description.sessionId if (!sessionId) throw new BoxClientError('the box service returned no session id') heartbeat = heartbeatSession({ apiOrigin: origin, apiKey, sessionId }) return await repl(session, '') } catch (error) { if (error instanceof BoxClientError) { console.error(` ${rnx} box connect: ${error.message}`) return 1 } throw error } finally { heartbeat?.stop() session?.close() } } /** * watch a cloud box in a browser. * * the box runs its app in one Browser Run page, and this opens the Box page, * which streams THAT instance rather than booting a second copy. so what the * person sees is what the agent is working on, and two people opening the same * box see the same screen. * * the url carries no credential: it is the box's page on the Contrast origin, * and opening it signed in resolves the box through the account service. that * makes it a link somebody can keep, paste, or reload. */ async function runCloudBoxOpen(args: string[]): Promise { const rnx = rnxPublicBrand.commandName const json = args.includes('--json') const print = args.includes('--print') const boxId = boxTarget(args, 'open') if (!boxId) return 1 // the ACCOUNT's credential, for the same reason connect takes one: the // account service is what knows which account owns this Box, and it hands // back the Box's own token rather than this process storing one. const { auth, header } = authHeaderOrExit('box open') const apiKey = header.replace(/^Bearer /, '') try { const reference = await resolveCloudBoxReference({ apiOrigin: apiOrigin(args, auth), apiKey, boxId, }) const connectUrl = cloudBoxConnectUrl(reference.endpoint, reference.boxId) // ensure first: it is what navigates the generation's tab to the Box page, // so a box whose browser lapsed gets a new generation with its page // restored before anybody is looking at it. without it the page opens onto // a box with no simulator running and nothing to stream. const browser = await ensureCloudBoxBrowser({ connectUrl, accessToken: reference.token, }) if (!browser.reused && !json && !print) { console.log(` ...started this box's browser`) } const box = await createBoxClient( createCloudBoxProvider({ endpoint: reference.endpoint, authority: { kind: 'attach' }, }), ).attach({ reference: { boxId: reference.boxId, accessToken: reference.token }, }) // the box owns this url because it is the only side that knows both the // page origin and the route. const url = box.description.pageUrl if (!url) { console.error( ` ${rnx} box open: this box service has no page origin configured, so it has no page to open.`, ) return 1 } if (json) { console.log(JSON.stringify({ url })) return 0 } if (print) { console.log(url) return 0 } return await openInBrowser(url) } catch (error) { if (error instanceof BoxClientError) { console.error(` ${rnx} box open: ${error.message}`) return 1 } throw error } } /** * hand a URL to the operating system's browser. * * argv rather than a shell string, because this URL is a bearer capability and * a shell would take it through history, expansion and quoting on the way. when * `open` cannot run it is printed instead: this process is the only place the * URL exists, so dropping it would cost the caller another mint. */ export async function openInBrowser(url: string): Promise { const child = spawn('open', [url], { stdio: 'ignore' }) const opened = await new Promise((resolve) => { child.once('error', () => resolve(false)) child.once('close', (code) => resolve(code === 0)) }) if (!opened) console.log(url) return 0 } function cloudBoxClient(options: { endpoint: string apiOrigin: string apiKey: string accountId: string | null }) { return createBoxClient( createCloudBoxProvider({ endpoint: options.endpoint, authority: { kind: 'account', apiOrigin: options.apiOrigin, apiKey: options.apiKey, accountId: options.accountId, }, }), ) } /** * create a Box that runs an app somebody already built. * * `box create --bundle` reaches this with a file from disk and `rnx box send` * with the artifact it just produced. both are the same box: the bundle is * the whole project, the provenance says so, and the page that opens it boots * that bundle instead of building anything. */ export async function createPrebuiltBundleBox(options: { bundle: Uint8Array name: string endpoint: string apiOrigin: string apiKey: string accountId: string | null }) { return cloudBoxClient(options).create({ name: options.name, stack: prebuiltBundleBoxTemplate.stack, template: BOX_TEMPLATES.resolve({ name: prebuiltBundleBoxTemplate.name }).provenance, source: new BundleFilePlane(options.bundle), }) } async function runCloudBoxCreate( args: string[], opts: BoxCommandOptions, ): Promise { const rnx = rnxPublicBrand.commandName const json = args.includes('--json') const endpoint = flag(args, 'endpoint') ?? process.env[ENDPOINT_ENV]?.trim() if (!endpoint) { console.error( ` ${rnx} box create: no cloud box service to create a box in.\n` + ` set ${ENDPOINT_ENV} or pass --endpoint=. \`${rnx} box\` runs the\n` + ` same shell locally over this checkout.`, ) return 1 } // --bundle IS the template choice: the box runs an app that is already // built, so naming a second template would ask for two different boxes. const bundlePath = flag(args, 'bundle') const namedTemplate = flag(args, 'template') if (bundlePath && namedTemplate) { console.error( ` ${rnx} box create: --bundle creates a ${prebuiltBundleBoxTemplate.name} box,\n` + ` so it cannot be combined with --template ${namedTemplate}.`, ) return 1 } const templateName = bundlePath ? prebuiltBundleBoxTemplate.name : (namedTemplate ?? sourceControlBoxTemplate.name) let template: BoxTemplateResolution try { template = BOX_TEMPLATES.resolve({ name: templateName }) } catch (error) { console.error( ` ${rnx} box create: ${error instanceof Error ? error.message : String(error)}.`, ) return 1 } // a prebuilt box holds the bundle; a source box holds the checkout it is // built from. nothing holds both, so they are not two nullable variables. let source: { bundle: Uint8Array } | { checkout: CheckoutFilePlane } let defaultName: string if (bundlePath) { // a prebuilt box carries no sources, so there is no checkout to read and // this can run anywhere, including a directory that is not a repository. const resolved = path.resolve(bundlePath) // the page boots these bytes with no asset server behind it, so the Metro // assets beside the bundle are embedded the way every other remote path // embeds them. the box service owns the size ceiling, not this producer. let artifact: CloudBoxArtifactProduction try { artifact = await produceCloudBoxArtifact(resolved) } catch (error) { console.error( ` ${rnx} box create: could not prepare the bundle at ${resolved}: ${ error instanceof Error ? error.message : String(error) }`, ) return 1 } if (artifact.bytes.byteLength === 0) { console.error(` ${rnx} box create: the bundle at ${resolved} is empty.`) return 1 } for (const warning of artifact.warnings) console.error(` ${warning}`) source = { bundle: new Uint8Array(artifact.bytes) } defaultName = path.basename(resolved).replace(/\.[^.]+$/, '') } else { const root = findRepositoryPackageRoot(process.cwd()) if (!root) { console.error( ` ${rnx} box create: no project here. a box is created from a checkout,\n` + ` so run this inside a git repository that has a package.json.`, ) return 1 } const checkout = new CheckoutFilePlane(root, { trackedOnly: true }) try { await checkout.refresh() } catch (error) { if (error instanceof CheckoutRequiresGitError) { console.error(` ${rnx} box create: ${error.message}`) return 1 } throw error } source = { checkout } defaultName = path.basename(root) } const name = flag(args, 'name') ?? defaultName // the ACCOUNT's key. the box service forwards it to /v1/rnx/sessions, which // is what authenticates this caller, names the account, and starts the // session the box is billed against. const { auth, header } = authHeaderOrExit('box create') const apiKey = header.replace(/^Bearer /, '') const accountId = cloudAccountIdOrExit(auth) if (!json) console.log(` ...creating cloud box ${name}`) const connection = { endpoint, apiOrigin: apiOrigin(args, auth), apiKey, accountId, } let session: BoxShellSession | null = null let heartbeat: { stop: () => void } | null = null try { const box = 'bundle' in source ? await createPrebuiltBundleBox({ ...connection, bundle: source.bundle, name }) : await cloudBoxClient(connection).create({ name, stack: template.stack, template: template.provenance, source: source.checkout, onProgress: opts.verbose && !json ? (seeded, total) => console.log(` ...seeded ${seeded}/${total} files`) : undefined, }) if (json) { console.log( JSON.stringify({ ...box.description, ...(box.accessToken ? { accessToken: box.accessToken } : {}), }), ) return 0 } console.log(` ...created Box ${box.id}`) console.log( 'bundle' in source ? ` ...seeded a prebuilt bundle of ${source.bundle.byteLength} bytes` : ` ...seeded ${box.description.files} project files`, ) // shown once and never stored: this process does not own a credential // store, and inventing one here would be a second place secrets live. console.log(` ...box token (shown once): ${box.accessToken}`) // last, so it is the line still on screen when the shell takes over. it // carries no credential: opening it signed in resolves the box through the // account service. if (box.description.pageUrl) { console.log(` ...open ${box.description.pageUrl}`) } console.log(` ...accessing shell\n`) session = await box.connect() // bank the wall clock while the shell is open. the box holds no account // credential, so the client that is using it is what keeps the session // alive; when this process dies the sweep closes it at the last heartbeat. const sessionId = box.description.sessionId if (!sessionId) throw new BoxClientError('the box service returned no session id') heartbeat = heartbeatSession({ apiOrigin: apiOrigin(args, auth), apiKey, sessionId, }) return await repl(session, '') } catch (error) { if (error instanceof BoxClientError) { console.error(` ${rnx} box create: ${error.message}`) return 1 } throw error } finally { heartbeat?.stop() session?.close() } } async function runLocalBox(opts: BoxCommandOptions): Promise { // findRepositoryPackageRoot answers both halves of what a box needs: the // nearest package.json, and only when it sits inside a git repository. git // is what decides which files belong to the project, so a box without it // would have to walk the whole directory tree — measured at 230k files on // this repo against 15k from git. const root = findRepositoryPackageRoot(process.cwd()) if (!root) { console.error( ` ${rnxPublicBrand.commandName} box: no project here.\n` + ` the box needs a directory inside a git repository that has a\n` + ` package.json. it asks git which files belong to the project, so it\n` + ` never walks node_modules or build output. run \`git init\` if this is\n` + ` a new project.`, ) return 1 } const plane = new CheckoutFilePlane(root) try { await plane.refresh() } catch (error) { if (error instanceof CheckoutRequiresGitError) { console.error(` ${rnxPublicBrand.commandName} box: ${error.message}`) return 1 } throw error } let session: BoxShellSession | null = null const client = createBoxClient( createBoxHostProvider({ shell: { commands: [ createRnxCommand({ root, shellCwd: () => session?.cwd ?? PROJECT_MOUNT, }), ], // just-bash's JavaScript rg reads every file body through the plane. // see search-command.ts for the numbers. builtInCommandOverrides: [createSearchCommand({ root })], }, }), ) const box = await client.create({ name: path.basename(root), stack: terminalBoxStack, source: plane, }) session = await box.connect() const relative = path.relative(root, process.cwd()) console.log(` ...starting local box`) console.log(` ...mounting ${root} at ${PROJECT_MOUNT}`) // authorize this checkout for the bridge daemon so a browser tab can reach // this box's plane. deliberately NOT awaited: a box must not pay for a // bridge that may not be there, and an older daemon never answers at all. const shared = sharePlane(root, { port: opts.port }) // two boxes over one checkout is worth SAYING, not refusing. a person with // two terminals in one project is doing something reasonable, and the plane // re-reads the file list before every command, so neither box works from a // stale one. what it does break is an agent mid-run, whose file set changes // underneath it because something else is writing the tree. that is the wave // case, and the wave's own isolation check is where a refusal belongs; this // reports the fact so that check has something to refuse on. void shared.settled.then((outcome) => { if ('declined' in outcome) { if (opts.verbose) console.log(` ...plane not shared: ${outcome.declined}`) return } if (outcome.holders > 1) { console.error( `\n ${rnxPublicBrand.commandName} box: ${outcome.holders - 1} other box${ outcome.holders > 2 ? 'es are' : ' is' } already open on this checkout.\n` + ` writes from either land on the same files. that is fine for a person\n` + ` with two terminals, and not fine for an agent, whose file set will\n` + ` change underneath it mid-run.\n`, ) return } if (opts.verbose) { console.log(` ...plane shared on the bridge (port ${outcome.port})`) } }) console.log(` ...accessing shell\n`) if (opts.verbose) { console.log(` ${plane.index().size} project files indexed\n`) } try { // the checkout is shared with editors, agents, and the user's own terminal. // BoxProjectFs arms a stale bit on each shell turn and refreshes the // plane synchronously on the first actual file access, so commands that // never touch project files pay nothing, while reads always see fresh disk state. return await repl(session, relative) } finally { session.close() shared?.close() } } // the REPL drives whatever holds the shell. locally that is an in-process // BoxShell over this checkout; for a cloud box it is a socket to the box, // which holds both the files and the shell. same loop, because the only // difference is where exec runs. async function repl( session: BoxShellSession, startRelative: string, beforeCommand?: () => Promise, ): Promise { const rl = createInterface({ input: process.stdin, output: process.stdout }) // enter where the user was standing, so `rnx box` from a subdirectory does // not silently teleport them to the project root. if (startRelative && !startRelative.startsWith('..')) { await session.exec(`cd ${JSON.stringify(startRelative)}`) } const prompt = () => { rl.setPrompt(`${session.cwd} > `) rl.prompt() } // readline delivers a pasted block, or a piped script, as a burst of `line` // events. run them one at a time in order — dropping the ones that arrive // while a command is running would silently execute only the first line of // a paste. let queue: Promise = Promise.resolve() // stdin ended: stop printing prompts, but still finish what is already // queued — piping a script in must run every line, not just the first. let inputEnded = false // the user typed `exit`: abandon anything still queued behind it. let stopped = false // the command running right now, so Ctrl-C interrupts it rather than the CLI. let interrupt: AbortController | null = null const run = async (command: string) => { if (stopped) return const controller = new AbortController() interrupt = controller try { await beforeCommand?.() const result = await session.exec(command, { signal: controller.signal, // written as it arrives rather than collected: a terminal shows a // command's output while it runs, and stderr keeps its own stream so // piping the shell's stdout somewhere still separates the two. onOutput: (stream, chunk) => { if (stream === 'stdout') process.stdout.write(chunk) else process.stderr.write(chunk) }, }) if (result.exitCode !== 0) console.log(`exit code: ${result.exitCode}`) } catch (error) { console.error(error instanceof Error ? error.message : String(error)) } finally { interrupt = null } if (!inputEnded && !stopped) prompt() } return new Promise((resolve) => { prompt() // Ctrl-C while a command is running interrupts the command. at an idle // prompt there is nothing to interrupt, so it still leaves the shell, // which is the only way out of a box that has stopped answering. rl.on('SIGINT', () => { if (interrupt) { process.stdout.write('^C\n') interrupt.abort() return } stopped = true rl.close() }) rl.on('line', (line) => { const command = line.trim() if (!command) { queue = queue.then(() => { if (!inputEnded && !stopped) prompt() }) return } if (command === 'exit' || command === 'quit') { queue = queue.then(() => { stopped = true rl.close() }) return } queue = queue.then(() => run(command)) }) rl.on('close', () => { inputEnded = true // let any already-queued command finish before the process exits. queue.then(() => { console.log() resolve(0) }) }) }) }