Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
df1704a
fix(sdk): make the git namespace usable from a caller's point of view
alitariksahin Aug 27, 2026
5423e68
feat(cli): add a non-interactive command surface
alitariksahin Aug 27, 2026
773f588
fix(cli): address review on the non-interactive surface
alitariksahin Aug 27, 2026
9383ac2
fix(cli): address second review round
alitariksahin Aug 27, 2026
47d8380
fix(cli): verify checkout, correct the 125 claim, add integration tests
alitariksahin Aug 27, 2026
a8127fc
fix(cli): do not report a failed check as an answer
alitariksahin Aug 27, 2026
ea0653b
fix(cli): confirm a detached checkout by commit, not by name
alitariksahin Aug 27, 2026
b6b6792
fix(sdk): do not lose the exit status when a stream event is split
alitariksahin Aug 27, 2026
43d7c30
Merge branch 'DX-2966' into DX-2967
alitariksahin Aug 27, 2026
2cd7d00
fix(cli): stop reporting guesses as answers, and keep output faithful
alitariksahin Aug 27, 2026
6446920
fix(cli): close the previously-missed findings
alitariksahin Aug 27, 2026
52cbad6
style(sdk): format the exec-stream tests
alitariksahin Aug 28, 2026
b537e57
Merge branch 'DX-2966' into DX-2967
alitariksahin Aug 28, 2026
c2b40ba
fix(cli): honour --json everywhere, and stop tests touching real boxes
alitariksahin Aug 28, 2026
59ea983
refactor(cli): rename expose to public-url, matching the SDK and docs
alitariksahin Aug 28, 2026
3593fc4
Merge branch 'main' of https://github.com/upstash/box into DX-2967
alitariksahin Aug 28, 2026
94c6a65
test(cli): run the zsh completion check only where zsh exists
alitariksahin Aug 28, 2026
05c7207
fix(cli): require a finish chunk, and stop advertising --json where i…
alitariksahin Aug 28, 2026
a0069f6
test(cli): point the missing-argument case at a command that exists
alitariksahin Aug 28, 2026
fbd77e2
test(sdk): run the integration agents on Haiku 4.5
alitariksahin Aug 31, 2026
156dbb9
fix(cli): send --github-token with the connection, so private clones …
alitariksahin Sep 1, 2026
77d3485
test(sdk): make the integration agent tests survive a weaker model
alitariksahin Sep 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/box-git-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@upstash/box": patch
---

Fix three things in the git namespace that made it unusable from a caller's point of view.

- `git.updateConfig()` sent its request to `/v2/box/{id}/git-config`, which the coordinator does not serve. The identity endpoint is `/v2/box/{id}/config/git`, so every call returned 404 and no git identity was ever set through the SDK.
- `git.exec()` results now carry `exit_code`. The API has always returned it; the type omitted it, so callers could not tell a failed git command (for example exit 128 when the folder is not a repository) from a successful one.
- `git.clone()` accepts `folder`, naming the directory the repository is cloned into. Unlike every other git operation, where the folder is an existing directory derived from `cd()`, clone's folder is the destination and does not exist yet, so it could not be expressed at all: `cd()` fails on a directory the clone is about to create.
18 changes: 18 additions & 0 deletions .changeset/cli-non-interactive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@upstash/box-cli": minor
Comment thread
Copilot marked this conversation as resolved.
Outdated
---

Add a non-interactive command surface, so a script, a CI job or a coding agent can drive a box without a terminal.

- `box exec` runs a command and streams its output, passing the remote exit code through. `--json` collects `{stdout, stderr, exit_code}`.
- `box files` (read, write, list, stat, mkdir, rename, remove, upload, download). `box files write <path> -` takes the content from stdin.
- `box git` (clone, status, diff, commit, checkout, push, create-pr, config, exec), with `-C/--folder` for the cloned directory.
- `box expose` for public URLs, `box run` for the agent (text on stdout, tool calls on stderr), `box status`, `box use`.
Comment thread
alitariksahin marked this conversation as resolved.
Outdated
- `box delete` and `box pause`. Deleting asks first and refuses without `--yes` when there is no terminal to ask, and clears a `.box` file that named the box it removed.
- `box create --no-repl` (implied by `--json` or by having no terminal) creates the box, pins it to the directory in a `.box` file and prints its id. New workspace flags: `--name`, `--size`, `--keep-alive`, `--init-command`, `--browser`, `--clone-repo`, `--no-use`.

Every command resolves its box from `--box`, then `BOX_ID`, then the nearest `.box` file. Data goes to stdout and diagnostics to stderr, so output stays pipeable. A failure of the CLI itself exits 125, which cannot be confused with a status the remote command returned.
Comment thread
alitariksahin marked this conversation as resolved.
Outdated

Every command now reports its own failures as exit code 125 through one error boundary, rather than exiting 1 from wherever the error was noticed. The shell completion script covers the whole command surface.

Also fixes `box create` on a terminal with no flags: the agent harness was resolved before the setup wizard ran, so the wizard's own answer was rejected with "agent harness is required". `box get` now reports the box's details rather than only its id, and `/git status` in the REPL no longer reports a missing repository as a clean tree.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,6 @@ coverage/
tmp/
temp/
*.tmp

# pnpm's local content-addressable store, when one is configured in-repo
.pnpm-store/
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"build": "pnpm -r build",
"dev": "pnpm -r --parallel dev",
"test": "pnpm -r test",
"test:integration": "vitest run -c packages/sdk/vitest.integration.config.ts",
"test:integration": "pnpm -r --if-present test:integration",
"changeset": "changeset",
"ci:version": "changeset version && node packages/sdk/scripts/gen-version.mjs && node packages/cli/scripts/gen-version.mjs && pnpm install --lockfile-only",
"ci:tag": "changeset tag",
Expand Down
389 changes: 270 additions & 119 deletions packages/cli/README.md

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
"test": "vitest run",
"lint": "prettier --check . --write",
"ci:lint": "prettier --check .",
"prepublishOnly": "node scripts/gen-version.mjs && npm run build"
"prepublishOnly": "node scripts/gen-version.mjs && npm run build",
"test:integration": "vitest run -c vitest.integration.config.ts"
},
"keywords": [
"upstash",
Expand Down
204 changes: 204 additions & 0 deletions packages/cli/scripts/smoke.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
#!/usr/bin/env bash
#
# Manual smoke test for the box CLI against a real box.
#
# ./scripts/smoke.sh run every check, then delete the box
# ./scripts/smoke.sh --keep leave the box alive to poke at afterwards
# ./scripts/smoke.sh --shell skip the checks, open a shell set up to use it
#
# Needs UPSTASH_BOX_API_KEY. If it is not set, packages/sdk/.env is read.
# Creates one box, exercises the whole surface against it, and deletes it at
# the end even if a check fails.
set -uo pipefail

CLI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CLI_JS="$CLI_DIR/dist/cli.js"
KEEP=0
SHELL_ONLY=0
for arg in "$@"; do
case "$arg" in
--keep) KEEP=1 ;;
--shell) SHELL_ONLY=1 ;;
-h|--help) sed -n '2,11p' "${BASH_SOURCE[0]}"; exit 0 ;;
*) echo "unknown option: $arg" >&2; exit 2 ;;
esac
done

if [ ! -f "$CLI_JS" ]; then
echo "Build it first: cd $CLI_DIR && npm run build" >&2
exit 1
fi

if [ -z "${UPSTASH_BOX_API_KEY:-}" ] && [ -f "$CLI_DIR/../sdk/.env" ]; then
set -a; . "$CLI_DIR/../sdk/.env"; set +a
fi
unset UPSTASH_BOX_BASE_URL
if [ -z "${UPSTASH_BOX_API_KEY:-}" ]; then
echo "Set UPSTASH_BOX_API_KEY first." >&2
exit 1
fi

# The published `box` is an older build, so put this one first on PATH. Doing it
# through a shim means every command below reads exactly as it would for a user.
BIN="$(mktemp -d)/bin"
mkdir -p "$BIN"
printf '#!/bin/sh\nexec node %s "$@"\n' "$CLI_JS" > "$BIN/box"
chmod +x "$BIN/box"
export PATH="$BIN:$PATH"

WORK="$(mktemp -d)"
cd "$WORK" || exit 1

if [ "$SHELL_ONLY" = 1 ]; then
echo "box -> $(command -v box) ($(box --version))"
echo "working directory: $WORK"
echo "Try: box create --no-repl --runtime node then box exec -- uname -a"
echo "Exit the shell when done. Delete any box you create with: box delete --yes"
exec "${SHELL:-/bin/bash}"
fi

PASS=0
FAIL=0
BOX_ID=""

cleanup() {
if [ -n "$BOX_ID" ] && [ "$KEEP" = 0 ]; then
echo
echo "cleaning up $BOX_ID"
box delete --yes "$BOX_ID" >/dev/null 2>&1 || echo " could not delete $BOX_ID, remove it in the console"
elif [ -n "$BOX_ID" ]; then
echo
echo "left alive: $BOX_ID (delete with: box delete --yes $BOX_ID)"
fi
rm -rf "$WORK" "$(dirname "$BIN")"
}
trap cleanup EXIT

# check <name> <expected> <actual>
check() {
if [ "$2" = "$3" ]; then
printf ' ok %s\n' "$1"
PASS=$((PASS + 1))
else
printf ' FAIL %s\n expected: %s\n actual: %s\n' "$1" "$2" "$3"
FAIL=$((FAIL + 1))
fi
}

# check_contains <name> <needle> <haystack>
check_contains() {
case "$3" in
*"$2"*) printf ' ok %s\n' "$1"; PASS=$((PASS + 1)) ;;
*) printf ' FAIL %s\n wanted to find: %s\n in: %s\n' "$1" "$2" "$3"
FAIL=$((FAIL + 1)) ;;
esac
}

echo "box -> $(command -v box) ($(box --version))"
echo "working directory: $WORK"
echo

echo "create"
CREATE_LOG="$WORK/create.log"
BOX_ID=$(box create --no-repl --runtime node --name smoke-test 2>"$CREATE_LOG")
if [ -z "$BOX_ID" ]; then
# Everything below needs a box, and comparing empty against empty would
# report a wall of passes for a run that did nothing. Report the error this
# attempt produced rather than making a second one, which would leak a box
# if it happened to succeed.
echo " FAIL could not create a box; the rest of the checks need one"
echo
sed 's/^/ /' "$CREATE_LOG"
echo
echo "----"
echo "0 passed, 1 failed"
exit 1
fi
check "prints one bare line on stdout, nothing else" "1" "$(printf '%s\n' "$BOX_ID" | wc -l | tr -d ' ')"
check "writes a .box pin naming it" "$BOX_ID" "$(cat .box 2>/dev/null)"
check_contains "status reports the box" "$BOX_ID" "$(box status 2>/dev/null)"

echo
echo "exec"
check "runs a command" "hello" "$(box exec -- echo hello 2>/dev/null)"
check "passes the remote exit code through" "7" "$(box exec -- 'exit 7' >/dev/null 2>&1; echo $?)"
check "succeeds with 0" "0" "$(box exec -- true >/dev/null 2>&1; echo $?)"
check "applies --cwd" "/tmp" "$(box exec -C /tmp -- pwd 2>/dev/null)"
check "keeps stdout clean when piped" "hello" "$(box exec -- echo hello 2>/dev/null | cat)"
JSON=$(box exec --json -- 'echo out; echo err >&2' 2>/dev/null)
check_contains "--json separates stdout and stderr" '"stderr": "err' "$JSON"
check "a CLI failure is 125, not a command status" "125" \
"$(box --box no-such-box-here exec -- true >/dev/null 2>&1; echo $?)"

echo
echo "files"
printf 'console.log(2 + 3)\n' | box files write app.js - >/dev/null 2>&1
check "writes from stdin and the file runs" "5" "$(box exec -- node app.js 2>/dev/null)"
check "reads raw content back" "console.log(2 + 3)" "$(box files read app.js 2>/dev/null)"
check_contains "lists a directory" "app.js" "$(box files list 2>/dev/null)"
box files mkdir -p a/b/c >/dev/null 2>&1
check_contains "mkdir -p creates the tree" "b" "$(box files list a 2>/dev/null)"
box files rename app.js renamed.js >/dev/null 2>&1
check_contains "renames" "renamed.js" "$(box files list 2>/dev/null)"
check "refuses to remove a directory without -r" "125" \
"$(box files remove a >/dev/null 2>&1; echo $?)"
check "removes a directory with -r" "0" "$(box files remove a -r >/dev/null 2>&1; echo $?)"

echo
echo "git"
box git clone https://github.com/octocat/Hello-World >/dev/null 2>&1
check_contains "clones into a directory named after the repo" "Hello-World" "$(box files list 2>/dev/null)"
box git clone https://github.com/octocat/Hello-World -C my-app >/dev/null 2>&1
check_contains "clones into an explicit -C destination" "my-app" "$(box files list 2>/dev/null)"
check "a clean tree is silent and exits 0" "0" "$(box git status -C Hello-World >/dev/null 2>&1; echo $?)"
check "the workspace root is reported as not a repository" "125" \
"$(box git status >/dev/null 2>&1; echo $?)"
check_contains "and says why" "Not a git repository" "$(box git status 2>&1)"
box git config -C Hello-World --name "Smoke Test" --email smoke@example.com >/dev/null 2>&1
check_contains "sets and reads the identity" "Smoke Test" "$(box git config -C Hello-World 2>/dev/null)"
box git checkout -C Hello-World smoke-branch >/dev/null 2>&1
check "checkout accepts git's -b spelling too" "0" \
"$(box git checkout -C Hello-World -b smoke-branch >/dev/null 2>&1; echo $?)"
printf 'from the smoke test\n' | box files write Hello-World/smoke.txt - >/dev/null 2>&1
check_contains "status shows the new file" "smoke.txt" "$(box git status -C Hello-World 2>/dev/null)"
box git exec -C Hello-World -- add -A >/dev/null 2>&1
check_contains "commits" "Committed" "$(box git commit -C Hello-World -m 'smoke commit' 2>/dev/null)"
check "git exec passes git's exit code through" "128" \
"$(box git exec -- rev-parse --abbrev-ref HEAD >/dev/null 2>&1; echo $?)"
check "and reports the branch it is on" "smoke-branch" \
"$(box git exec -C Hello-World -- rev-parse --abbrev-ref HEAD 2>/dev/null)"

echo
echo "expose"
box exec -- '( node -e "require(\"http\").createServer((_,r)=>r.end(\"alive\")).listen(3000)" > s.log 2>&1 & )' >/dev/null 2>&1
URL=$(box expose 3000 --json 2>/dev/null | sed -n 's/.*"url": "\([^"]*\)".*/\1/p')
check_contains "returns a public URL" "https://" "$URL"

# The server has to boot and the route has to propagate; how long that takes is
# not fixed, so poll instead of sleeping once and hoping.
BODY=""
for _ in 1 2 3 4 5 6 7 8 9 10; do
BODY=$(curl -s --max-time 15 "$URL" 2>/dev/null)
[ "$BODY" = "alive" ] && break
sleep 3
done
check "the detached server answers on it" "alive" "$BODY"
check_contains "lists the exposed port" "3000" "$(box expose list 2>/dev/null)"
check "deletes the public URL" "0" "$(box expose delete 3000 >/dev/null 2>&1; echo $?)"

echo
echo "lifecycle"
check_contains "get reports details, not just the id" "smoke-test" "$(box get "$BOX_ID" 2>/dev/null)"
check_contains "pause reports paused" "Paused" "$(box pause 2>/dev/null)"
check "a paused box resumes on the next command" "back" "$(box exec -- echo back 2>/dev/null)"
check "delete refuses without --yes in a script" "125" "$(box delete >/dev/null 2>&1; echo $?)"
check "the box survives that refusal" "0" "$(box exec -- true >/dev/null 2>&1; echo $?)"
DELETED=$(box delete --yes 2>/dev/null)
check_contains "delete --yes removes it" "Deleted" "$DELETED"
check "and clears the pin" "gone" "$([ -f .box ] && echo present || echo gone)"
BOX_ID=""

echo
echo "----"
echo "$PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ] || exit 1
36 changes: 29 additions & 7 deletions packages/cli/src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

```
src/
├── index.ts CLI entry point (Commander.js)
├── cli.ts CLI entry point (Commander.js)
├── auth.ts Token resolution (flag → env var)
├── agent-key.ts Agent API key resolution (flag → BoxApiKey enum)
├── output.ts Format utilities (JSON, raw)
Expand All @@ -12,19 +12,40 @@ src/
│ ├── spinner.ts Braille spinner with random messages
│ └── commands/ REPL command handlers
│ ├── run.ts Agent prompt streaming
│ ├── exec.ts Shell command execution
│ ├── files.ts File operations (read, write, list, upload, download)
│ ├── git.ts Git operations (clone, diff, create-pr)
│ ├── snapshot.ts Snapshot creation
│ ├── files.ts File operations (read, write, list, stat, mkdir,
Comment thread
alitariksahin marked this conversation as resolved.
│ │ rename, remove, upload, download)
│ ├── git.ts Git operations (clone, status, diff, commit,
│ │ checkout, push, create-pr, config, exec)
│ ├── snapshot.ts Snapshots (create, list, delete)
│ ├── expose.ts Public URLs for ports in the box
│ ├── status.ts Box state, runs and logs
│ ├── args.ts Flag/positional splitting for subcommands
│ ├── pause.ts Box pause (exits REPL)
│ └── delete.ts Box deletion (exits REPL)
├── core/ Shared plumbing for the non-interactive commands
│ ├── errors.ts CliError and the 125 exit code for a CLI failure
│ ├── box-ref.ts Box resolution (--box → BOX_ID → .box) and pinning
│ ├── io.ts emit/note (stdout vs stderr), the error boundary
│ └── exec.ts Collect and stream a command, working directory
├── commands/ CLI commands
│ ├── status.ts Selected box, where it came from, its state
│ ├── use.ts Write or remove this directory's .box
│ ├── exec.ts Run a shell command (streams; --json collects)
│ ├── files.ts File operations (read, write, list, stat, mkdir,
│ │ rename, remove, upload, download)
│ ├── git.ts Git operations (clone, status, diff, commit,
│ │ checkout, push, create-pr, config, exec)
│ ├── expose.ts Public URLs for ports in the box
│ ├── run.ts Agent prompt, streamed
│ ├── connect.ts Connect to existing box (interactive selector if TTY)
│ ├── create.ts Create new box
│ ├── create.ts Create new box (REPL, or headless with --no-repl)
│ ├── create-wizard.ts Interactive setup wizard for box create
│ ├── from-snapshot.ts Create box from snapshot
│ ├── list.ts List all boxes
│ ├── get.ts Get box details
│ ├── env.ts User-level env vars
│ ├── labels.ts Labels on a box
│ ├── snapshot.ts Create a snapshot
│ ├── init-demo.ts Scaffold demo project
│ └── completion.ts Shell completion script output
├── utils/
Expand All @@ -37,5 +58,6 @@ src/
## Key Concepts

- **`repl/client.ts`** is the library export (`@upstash/box-cli`). It exposes `BoxREPLClient` and `REPLHooks` for UI consumers.
- **`repl/terminal.ts`** is CLI-specific — it wires readline, colors, spinner, and tab completion.
- **`repl/terminal.ts`** is CLI-specific: it wires readline, colors, spinner, and tab completion.
- **`commands/` + `core/`** is the non-interactive half. Every command there is driven by arguments alone, writes data to stdout and diagnostics to stderr, and reports failure as exit code 125 so a remote command's own exit code stays unambiguous.
- Optional hooks (`onLoadingStart`, `onSuggestion`, `onCommandComplete`, `onCommandNotFound`) enable features by presence. CLI passes all hooks; UI consumers pass only what they need.
11 changes: 7 additions & 4 deletions packages/cli/src/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { CliError } from "../core/errors.js";
import { resolveToken } from "../auth.js";

describe("resolveToken", () => {
Expand All @@ -22,10 +23,12 @@ describe("resolveToken", () => {
expect(resolveToken()).toBe("env-token");
});

it("exits when no token available", () => {
resolveToken();
expect(exitSpy).toHaveBeenCalledWith(1);
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("API token required"));
it("throws when no token is available", () => {
// A CliError rather than process.exit, so it exits 125 through the same
// boundary as every other CLI failure.
expect(() => resolveToken()).toThrow(CliError);
expect(() => resolveToken()).toThrow(/API token required/);
expect(exitSpy).not.toHaveBeenCalled();
});

it("prefers flag over env var", () => {
Expand Down
49 changes: 49 additions & 0 deletions packages/cli/src/__tests__/cli-exit-codes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, it, expect } from "vitest";
import { CLI_FAILURE_EXIT_CODE } from "../core/errors.js";

const CLI = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../dist/cli.js");
const built = existsSync(CLI);

/**
* Commander reports usage errors before any action runs, so this convention
* cannot be checked by calling a command function. It has to run the binary.
*/
describe.skipIf(!built)("exit codes at the program boundary", () => {
function run(...args: string[]) {
return spawnSync(process.execPath, [CLI, ...args], {
encoding: "utf8",
env: { ...process.env, UPSTASH_BOX_API_KEY: "box_test" },
});
}

it.each([
["an unknown option", ["exec", "--no-such-flag", "--", "ls"]],
["an unknown command", ["no-such-command"]],
["an unknown subcommand", ["files", "no-such-verb"]],
["a missing required argument", ["files", "read"]],
["a missing subcommand argument", ["expose", "delete"]],
])("uses %s exits %i", (_what, args) => {
// Commander's default is 1, which is indistinguishable from a remote
// command that exited 1 — the ambiguity 125 exists to remove.
expect(run(...(args as string[])).status).toBe(CLI_FAILURE_EXIT_CODE);
});

it.each([
["--help", ["--help"]],
["--version", ["--version"]],
["subcommand help", ["files", "--help"]],
["nested subcommand help", ["git", "checkout", "--help"]],
["the help command", ["help", "files"]],
])("%s succeeds", (_what, args) => {
expect(run(...(args as string[])).status).toBe(0);
});

it("still explains what was wrong", () => {
const result = run("exec", "--no-such-flag", "--", "ls");
expect(`${result.stderr}${result.stdout}`).toContain("--no-such-flag");
});
});
Loading