-
Notifications
You must be signed in to change notification settings - Fork 7
feat(cli): add a non-interactive command surface #231
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Changes from 3 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 5423e68
feat(cli): add a non-interactive command surface
alitariksahin 773f588
fix(cli): address review on the non-interactive surface
alitariksahin 9383ac2
fix(cli): address second review round
alitariksahin 47d8380
fix(cli): verify checkout, correct the 125 claim, add integration tests
alitariksahin a8127fc
fix(cli): do not report a failed check as an answer
alitariksahin ea0653b
fix(cli): confirm a detached checkout by commit, not by name
alitariksahin b6b6792
fix(sdk): do not lose the exit status when a stream event is split
alitariksahin 43d7c30
Merge branch 'DX-2966' into DX-2967
alitariksahin 2cd7d00
fix(cli): stop reporting guesses as answers, and keep output faithful
alitariksahin 6446920
fix(cli): close the previously-missed findings
alitariksahin 52cbad6
style(sdk): format the exec-stream tests
alitariksahin b537e57
Merge branch 'DX-2966' into DX-2967
alitariksahin c2b40ba
fix(cli): honour --json everywhere, and stop tests touching real boxes
alitariksahin 59ea983
refactor(cli): rename expose to public-url, matching the SDK and docs
alitariksahin 3593fc4
Merge branch 'main' of https://github.com/upstash/box into DX-2967
alitariksahin 94c6a65
test(cli): run the zsh completion check only where zsh exists
alitariksahin 05c7207
fix(cli): require a finish chunk, and stop advertising --json where i…
alitariksahin a0069f6
test(cli): point the missing-argument case at a command that exists
alitariksahin fbd77e2
test(sdk): run the integration agents on Haiku 4.5
alitariksahin 156dbb9
fix(cli): send --github-token with the connection, so private clones …
alitariksahin 77d3485
test(sdk): make the integration agent tests survive a weaker model
alitariksahin File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| --- | ||
| "@upstash/box-cli": minor | ||
| --- | ||
|
|
||
| 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`. | ||
|
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. | ||
|
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. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| import { execFileSync } from "node:child_process"; | ||
| import { mkdtempSync, writeFileSync } from "node:fs"; | ||
| import { tmpdir } from "node:os"; | ||
| import path from "node:path"; | ||
| import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; | ||
| import { COMPLETION_COMMANDS, completionCommand } from "../../commands/completion.js"; | ||
|
|
||
| describe("box completion", () => { | ||
| let logSpy: ReturnType<typeof vi.spyOn>; | ||
| const shell = process.env.SHELL; | ||
|
|
||
| beforeEach(() => { | ||
| logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); | ||
| }); | ||
| afterEach(() => { | ||
| logSpy.mockRestore(); | ||
| if (shell === undefined) delete process.env.SHELL; | ||
| else process.env.SHELL = shell; | ||
| }); | ||
|
|
||
| const script = () => String(logSpy.mock.calls[0]?.[0] ?? ""); | ||
|
|
||
| function write(contents: string, extension: string): string { | ||
| const file = path.join(mkdtempSync(path.join(tmpdir(), "box-completion-")), `c.${extension}`); | ||
| writeFileSync(file, contents, "utf8"); | ||
| return file; | ||
| } | ||
|
|
||
| it("offers the commands the CLI actually has", () => { | ||
| process.env.SHELL = "/bin/bash"; | ||
| completionCommand(); | ||
| // The non-interactive surface is the point of the CLI; leaving it out of | ||
| // completion is how it stays invisible. | ||
| for (const command of ["status", "exec", "files", "git", "expose", "run", "use"]) { | ||
| expect(script()).toContain(command); | ||
| } | ||
| }); | ||
|
|
||
| it("emits a script bash can parse", () => { | ||
| process.env.SHELL = "/bin/bash"; | ||
| completionCommand(); | ||
| expect(() => execFileSync("bash", ["-n", write(script(), "bash")])).not.toThrow(); | ||
| }); | ||
|
|
||
| it("emits a script zsh can parse", () => { | ||
| process.env.SHELL = "/bin/zsh"; | ||
| completionCommand(); | ||
| // An apostrophe in a description ("the box's agent") ends zsh's quoting and | ||
| // produces a script that will not load. | ||
| expect(() => execFileSync("zsh", ["-n", write(script(), "zsh")])).not.toThrow(); | ||
| }); | ||
|
|
||
| it("completes subcommands, not just the top level", () => { | ||
| process.env.SHELL = "/bin/zsh"; | ||
| completionCommand(); | ||
| expect(script()).toContain("create-pr"); | ||
| expect(script()).toContain("mkdir"); | ||
| }); | ||
|
|
||
| it("lists every command in both shells", () => { | ||
| process.env.SHELL = "/bin/bash"; | ||
| completionCommand(); | ||
| const bash = script(); | ||
| for (const command of COMPLETION_COMMANDS) expect(bash).toContain(command); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.