diff --git a/README.md b/README.md index d6a394c..e04badd 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Sign in through the browser once per machine: upstash login --oauth ``` -The consent page lets you pick a personal or team scope and whether the login is read-only. To switch teams, run it again. Team management commands (`team create`, `team delete`, `team add-member`, `team remove-member`) need an API key login. +The consent page lets you pick a personal or team scope and whether the login is read-only. To switch teams, run it again. Team management commands (`team create`, `team delete`, `team add-member`, `team remove-member`) need an API key login. `upstash box` commands work with a browser login or a Box API key (`UPSTASH_BOX_API_KEY` or `--token`), not with a Developer API key. Or grab a Developer API key from the [Upstash Console](https://console.upstash.com/account/api) and save it with `upstash login`, or set `UPSTASH_EMAIL` and `UPSTASH_API_KEY` in your shell or a `.env` file (recommended for CI and agents). `upstash whoami` shows which credentials are in use. See the [auth docs](https://upstash.com/docs/agent-resources/cli#authentication) for env files, per-command flags, and precedence rules. @@ -56,6 +56,11 @@ upstash redis list upstash redis create --name my-db --region us-east-1 upstash redis exec --db-url $URL --db-token $TOKEN GET key +# Upstash Box (browser login or a Box API key; same commands as the `box` CLI) +upstash box list +upstash box create --name my-box +upstash box exec --box my-box -- ls + # Vector upstash vector list upstash vector create --name my-index --region us-east-1 --similarity-function COSINE --dimension-count 1536 diff --git a/package.json b/package.json index 4491786..a87e5aa 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "author": "Upstash", "license": "MIT", "dependencies": { + "@upstash/box-cli": "^0.4.0", "commander": "^13.0.0", "dotenv": "^16.4.5" }, diff --git a/src/cli.ts b/src/cli.ts index ae474dd..a3032ce 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,6 +8,7 @@ import { registerVector } from "./commands/vector/index.js"; import { registerSearch } from "./commands/search/index.js"; import { registerQStash } from "./commands/qstash/index.js"; import { registerBlob } from "./commands/blob/index.js"; +import { registerBox } from "./commands/box.js"; import { registerLogin } from "./commands/login.js"; import { registerLogout } from "./commands/logout.js"; import { registerWhoami } from "./commands/whoami.js"; @@ -52,5 +53,6 @@ registerVector(program); registerSearch(program); registerQStash(program); registerBlob(program); +registerBox(program); program.parseAsync().catch(handleError); diff --git a/src/commands/box.ts b/src/commands/box.ts new file mode 100644 index 0000000..c8cf913 --- /dev/null +++ b/src/commands/box.ts @@ -0,0 +1,34 @@ +import type { Command } from "commander"; +import { buildBoxProgram, setDefaultToken } from "@upstash/box-cli"; +import { envApiKeyAuth } from "../auth.js"; +import { readOAuth } from "../config.js"; +import { getAccessToken } from "../oauth/refresh.js"; +import { plainError } from "../output.js"; + +export const BOX_NEEDS_OAUTH_OR_BOX_KEY = + "Upstash Box commands need a browser login (`upstash login --oauth`) or a Box API key (--token or UPSTASH_BOX_API_KEY). Developer API keys are not accepted by Upstash Box."; + +// Commands that never contact the API and so need no credential. +const NO_CREDENTIAL = new Set(["completion", "use"]); + +export function registerBox(program: Command): void { + const box = buildBoxProgram() + .name("box") + .description("Upstash Box sandboxes (the same commands as the `box` CLI)"); + // The root parses positional options for the box tree; pass-through stays with `exec` itself. + box.passThroughOptions(false); + box.hook("preAction", async (_root, actionCommand) => { + if (NO_CREDENTIAL.has(actionCommand.name())) return; + const flags = actionCommand.optsWithGlobals() as { token?: string }; + if (flags.token || process.env.UPSTASH_BOX_API_KEY) return; + // A Developer API key is useless here, so a saved browser login wins even when one is set. + if (readOAuth()) { + setDefaultToken(await getAccessToken()); + return; + } + const env = envApiKeyAuth(); + const shadow = env.email || env.apiKey ? " UPSTASH_EMAIL / UPSTASH_API_KEY are set, but they cannot be used here." : ""; + throw plainError(BOX_NEEDS_OAUTH_OR_BOX_KEY + shadow); + }); + program.addCommand(box); +} diff --git a/tests/unit/box.test.ts b/tests/unit/box.test.ts new file mode 100644 index 0000000..a302e38 --- /dev/null +++ b/tests/unit/box.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Command } from "commander"; +import { writeConfig, writeOAuth, writeOAuthClient } from "../../src/config.js"; +import { BOX_NEEDS_OAUTH_OR_BOX_KEY, registerBox } from "../../src/commands/box.js"; + +const ISSUER = "https://issuer.test"; +const now = () => Math.floor(Date.now() / 1000); + +let dir: string; +const originalEnv = { ...process.env }; + +function program(): Command { + const p = new Command() + .exitOverride() + .option("--email ") + .option("--api-key ") + .configureOutput({ writeOut: () => {}, writeErr: () => {} }); + registerBox(p); + return p; +} + +async function run(argv: string[]): Promise { + const origLog = console.log; + const origError = console.error; + console.log = () => {}; + console.error = () => {}; + try { + await program().parseAsync(["node", "upstash", ...argv]); + } finally { + console.log = origLog; + console.error = origError; + } +} + +const okList = () => Promise.resolve(new Response("[]", { status: 200 })); + +function authHeaderOf(): Record { + return vi.mocked(fetch).mock.calls[0]![1]!.headers as Record; +} + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "upstash-cli-box-")); + process.env.UPSTASH_CONFIG_HOME = dir; + process.env.UPSTASH_LEGACY_CONFIG_HOME = dir; + delete process.env.UPSTASH_EMAIL; + delete process.env.UPSTASH_API_KEY; + delete process.env.UPSTASH_BOX_API_KEY; +}); + +afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = undefined; + rmSync(dir, { recursive: true, force: true }); + process.env = { ...originalEnv }; +}); + +describe("upstash box", () => { + it("hands the OAuth access token to the box commands as a Bearer credential", async () => { + writeOAuthClient({ issuer: ISSUER, client_id: "cid", redirect_uri: "http://127.0.0.1/callback", registered_at: now() }); + writeOAuth({ issuer: ISSUER, access_token: "a.b.c", refresh_token: "rt", expires_at: now() + 86400 }); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(okList); + + await run(["box", "list", "--json"]); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(authHeaderOf().Authorization).toBe("Bearer a.b.c"); + }); + + it("uses the saved browser login even when Developer API key env vars are set", async () => { + writeOAuthClient({ issuer: ISSUER, client_id: "cid", redirect_uri: "http://127.0.0.1/callback", registered_at: now() }); + writeOAuth({ issuer: ISSUER, access_token: "a.b.c", refresh_token: "rt", expires_at: now() + 86400 }); + process.env.UPSTASH_EMAIL = "env@b.com"; + process.env.UPSTASH_API_KEY = "k"; + vi.spyOn(globalThis, "fetch").mockImplementation(okList); + + await run(["box", "list", "--json"]); + expect(authHeaderOf().Authorization).toBe("Bearer a.b.c"); + }); + + it("names the shadowing env vars when there is no browser login", async () => { + process.env.UPSTASH_EMAIL = "env@b.com"; + process.env.UPSTASH_API_KEY = "k"; + await expect(run(["box", "list"])).rejects.toThrow(/UPSTASH_EMAIL \/ UPSTASH_API_KEY are set/); + }); + + it("runs commands that need no credential without any login", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + await run(["box", "completion"]); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("refuses a Developer API key login, and no login at all, with one message", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + await expect(run(["box", "list"])).rejects.toThrow(BOX_NEEDS_OAUTH_OR_BOX_KEY); + writeConfig({ email: "a@b.com", apiKey: "k" }); + await expect(run(["box", "list"])).rejects.toThrow(BOX_NEEDS_OAUTH_OR_BOX_KEY); + await expect(run(["box", "list", "--email", "a@b.com", "--api-key", "k"])).rejects.toThrow( + BOX_NEEDS_OAUTH_OR_BOX_KEY, + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("leaves a Box API key from the environment or --token to the box commands", async () => { + writeConfig({ email: "a@b.com", apiKey: "k" }); + process.env.UPSTASH_BOX_API_KEY = "abx_env"; + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(okList); + + await run(["box", "list", "--json"]); + expect(authHeaderOf()["X-Box-Api-Key"]).toBe("abx_env"); + + delete process.env.UPSTASH_BOX_API_KEY; + fetchSpy.mockClear(); + await run(["box", "list", "--json", "--token", "abx_flag"]); + expect(authHeaderOf()["X-Box-Api-Key"]).toBe("abx_flag"); + }); +});