diff --git a/.changeset/cli-browser-runs-snapshots.md b/.changeset/cli-browser-runs-snapshots.md new file mode 100644 index 00000000..c46a03e9 --- /dev/null +++ b/.changeset/cli-browser-runs-snapshots.md @@ -0,0 +1,18 @@ +--- +"@upstash/box-cli": patch +--- + +Give the CLI parity with the SDK, so an agent driving a terminal can reach everything a program can. + +- `box browser open|tabs|content|screenshot|act|close|cdp-url`. The browser is the one thing in a box with no shell fallback, because it is driven through the coordinator rather than from inside the container, so `box exec` could never stand in for it. `--tab` is optional while a single tab is open and required once there are several: acting on the wrong page is worse than asking which one. `screenshot` writes to `--out` rather than stdout, which carries text a caller may pipe. +- `box status runs` and `box status logs`. A run id was previously unobtainable, so a failed run could be observed but not investigated. +- `box cancel `. `Run.cancel()` only works while holding the object the call returned, which a separate process never is, so an agent that started a long run had no way to stop it. +- `box snapshot list` and `box snapshot delete`. +- `box from-snapshot --no-repl`, matching `box create`: explicit flag, `--json`, or no terminal on either stream. Without it a script could take a snapshot and never restore one, which made listing and deleting them write-only. + +- `box schedule exec|agent|list|get|update|pause|resume|delete`. Cron on a box was reachable only from the SDK and the console. `update` sends just the fields named, because a partial update that also sent the command would clear it. +- `box skills add|remove|list`, `box config model|harness|network|init-command`, and `box resume`. +- `box code --lang js|ts|python`, with `-` reading stdin so the shell does not mangle a program on the way in. +- The rest of the browser: `goto`, `observe`, `extract`, `live-url`, and `recordings start|stop|list|get|download`. `extract` takes a flat JSON Schema file, since a Zod schema cannot travel through a command line, and refuses anything nested rather than silently dropping fields. + +`exec.session` is deliberately absent: a session is a live WebSocket with `on`/`send`/`close`, and a one-shot command has nowhere to hold it. `getPreviewUrl` and `listPreviews` are deprecated aliases for the public-URL calls, so `box public-url` already covers them. diff --git a/packages/cli/src/__tests__/commands/browser.test.ts b/packages/cli/src/__tests__/commands/browser.test.ts new file mode 100644 index 00000000..9ddd06f1 --- /dev/null +++ b/packages/cli/src/__tests__/commands/browser.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + browserOpenCommand, + browserTabsCommand, + browserContentCommand, + browserScreenshotCommand, + browserActCommand, + browserCloseCommand, + browserCdpUrlCommand, +} from "../../commands/browser.js"; +import { CliError } from "../../core/errors.js"; + +const getBox = vi.hoisted(() => vi.fn()); +vi.mock("@upstash/box", () => ({ Box: { get: getBox } })); + +vi.mock("../../core/box-ref.js", () => ({ + resolveBoxId: vi.fn(() => ({ id: "b1", source: "flag" })), + announceBox: vi.fn(), +})); + +describe("box browser", () => { + let stdout: ReturnType; + let stderr: ReturnType; + const flags = { box: "b1", token: "box_test" }; + + const written = () => stdout.mock.calls.map((c) => String(c[0])).join(""); + + /** A box whose browser namespace is backed by the given fakes. */ + const boxWith = (browser: Record) => { + getBox.mockResolvedValue({ browser }); + return browser; + }; + + beforeEach(() => { + stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + getBox.mockReset(); + }); + + afterEach(() => { + stdout.mockRestore(); + stderr.mockRestore(); + }); + + it("prints the tab id on open, so later commands can address it", async () => { + const create = vi.fn().mockResolvedValue({ id: "tab-7" }); + boxWith({ tab: { create } }); + + await browserOpenCommand("https://example.com", { ...flags }); + + expect(create).toHaveBeenCalledWith("https://example.com"); + expect(written()).toContain("tab-7"); + }); + + it("lists tabs as data, not as SDK objects", async () => { + // A real Tab holds a back-reference to the Box, so emitting one raw is a + // circular structure that JSON.stringify throws on. Model that here, or + // the projection looks unnecessary. + const fakeTab = (id: string, url: string, title: string) => { + const tab: Record = { id, url, title, content: vi.fn() }; + tab.box = { id: "b1", tabs: [tab] }; + return tab; + }; + boxWith({ + listTabs: vi + .fn() + .mockResolvedValue([ + fakeTab("tab-1", "https://a.test", "A"), + fakeTab("tab-2", "https://b.test", "B"), + ]), + }); + + await browserTabsCommand({ ...flags, json: true }); + + // A Tab instance carries methods and a back-reference to the box; emitting + // it raw would put that in --json output. + expect(JSON.parse(written())).toEqual([ + { id: "tab-1", url: "https://a.test", title: "A" }, + { id: "tab-2", url: "https://b.test", title: "B" }, + ]); + }); + + it("uses the only open tab without being told", async () => { + const content = vi.fn().mockResolvedValue({ title: "T", url: "u", text: "hello" }); + boxWith({ listTabs: vi.fn().mockResolvedValue([{ id: "tab-1", content }]) }); + + await browserContentCommand({ ...flags }); + + expect(content).toHaveBeenCalled(); + expect(written()).toContain("hello"); + }); + + it("prints the links, which the description promises", async () => { + // Without this only --json exposed a destination URL, so the default output + // did not match what the command says it reads. + const content = vi.fn().mockResolvedValue({ + title: "T", + url: "u", + text: "body", + links: [{ text: "Docs", href: "https://docs.test" }], + }); + boxWith({ listTabs: vi.fn().mockResolvedValue([{ id: "tab-1", content }]) }); + + await browserContentCommand({ ...flags }); + + expect(written()).toContain("https://docs.test"); + }); + + it("refuses to guess when several tabs are open", async () => { + boxWith({ listTabs: vi.fn().mockResolvedValue([{ id: "tab-1" }, { id: "tab-2" }]) }); + + // Acting on the wrong page is worse than asking which one. + await expect(browserContentCommand({ ...flags })).rejects.toThrow(/--tab/); + }); + + it("says what to do when nothing is open", async () => { + boxWith({ listTabs: vi.fn().mockResolvedValue([]) }); + + await expect(browserContentCommand({ ...flags })).rejects.toThrow(/browser open/); + }); + + it("addresses the named tab directly, without listing", async () => { + const listTabs = vi.fn(); + const content = vi.fn().mockResolvedValue({ title: "T", url: "u", text: "x" }); + boxWith({ listTabs, getTab: vi.fn(() => ({ id: "tab-9", content })) }); + + await browserContentCommand({ ...flags, tab: "tab-9" }); + + expect(listTabs).not.toHaveBeenCalled(); + expect(content).toHaveBeenCalled(); + }); + + describe("screenshot", () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "box-shot-")); + }); + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + it("writes the PNG to the file it was given", async () => { + const png = new Uint8Array([137, 80, 78, 71]); + boxWith({ + listTabs: vi + .fn() + .mockResolvedValue([{ id: "t", screenshot: vi.fn().mockResolvedValue(png) }]), + }); + const out = join(dir, "page.png"); + + await browserScreenshotCommand({ ...flags, out }); + + expect([...readFileSync(out)]).toEqual([137, 80, 78, 71]); + }); + + it("requires --out rather than putting bytes on stdout", async () => { + // stdout is the data channel for text; PNG bytes would corrupt a pipe. + boxWith({ listTabs: vi.fn().mockResolvedValue([{ id: "t" }]) }); + + await expect(browserScreenshotCommand({ ...flags })).rejects.toThrow(CliError); + }); + + it("decodes a base64 screenshot rather than writing the string", async () => { + const b64 = Buffer.from([1, 2, 3]).toString("base64"); + boxWith({ + listTabs: vi + .fn() + .mockResolvedValue([{ id: "t", screenshot: vi.fn().mockResolvedValue(b64) }]), + }); + const out = join(dir, "page.png"); + + await browserScreenshotCommand({ ...flags, out }); + + expect([...readFileSync(out)]).toEqual([1, 2, 3]); + }); + }); + + it("passes the instruction through to act", async () => { + const act = vi.fn().mockResolvedValue({ success: true }); + boxWith({ listTabs: vi.fn().mockResolvedValue([{ id: "t", act }]) }); + + await browserActCommand("click the login button", { ...flags }); + + expect(act).toHaveBeenCalledWith("click the login button"); + }); + + it("closes the tab", async () => { + const close = vi.fn().mockResolvedValue(undefined); + boxWith({ listTabs: vi.fn().mockResolvedValue([{ id: "tab-3", close }]) }); + + await browserCloseCommand({ ...flags }); + + expect(close).toHaveBeenCalled(); + expect(written()).toContain("tab-3"); + }); + + it("prints the CDP url on stdout and the hint on stderr", async () => { + boxWith({ cdpUrl: vi.fn().mockResolvedValue("ws://cdp.test/abc") }); + + await browserCdpUrlCommand({ ...flags }); + + // The URL is the data; the how-to is a diagnostic, so a pipe stays clean. + expect(written()).toContain("ws://cdp.test/abc"); + expect(stderr.mock.calls.map((c) => String(c[0])).join("")).toContain("connectOverCDP"); + }); +}); diff --git a/packages/cli/src/__tests__/commands/code-and-extract.test.ts b/packages/cli/src/__tests__/commands/code-and-extract.test.ts new file mode 100644 index 00000000..46e98d66 --- /dev/null +++ b/packages/cli/src/__tests__/commands/code-and-extract.test.ts @@ -0,0 +1,237 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execCodeCommand } from "../../commands/exec.js"; +import { browserExtractCommand } from "../../commands/browser.js"; +import { CliError } from "../../core/errors.js"; + +const getBox = vi.hoisted(() => vi.fn()); +vi.mock("@upstash/box", () => ({ Box: { get: getBox } })); + +vi.mock("../../core/box-ref.js", () => ({ + resolveBoxId: vi.fn(() => ({ id: "b1", source: "flag" })), + announceBox: vi.fn(), +})); + +describe("box code", () => { + let stdout: ReturnType; + let stderr: ReturnType; + const flags = { box: "b1", token: "box_test" }; + const written = () => stdout.mock.calls.map((c) => String(c[0])).join(""); + + /** A box whose exec.code resolves to a Run-shaped result. */ + const codeReturns = (run: Record) => { + const code = vi.fn().mockResolvedValue(run); + getBox.mockResolvedValue({ exec: { code } }); + return code; + }; + + beforeEach(() => { + stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + getBox.mockReset(); + process.exitCode = undefined; + }); + afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = undefined; + }); + + it("converts the timeout from seconds to milliseconds", async () => { + // --timeout 30 against a milliseconds option would kill the code in 30ms. + const code = codeReturns({ stdout: "", stderr: "", exitCode: 0 }); + + await execCodeCommand("print(1)", { ...flags, timeout: "30" }); + + expect(code).toHaveBeenCalledWith(expect.objectContaining({ timeout: 30_000 })); + }); + + it("passes a failing snippet's exit code through", async () => { + // Reporting 0 would make `box code ... && next` run the next thing. + codeReturns({ stdout: "", stderr: "Traceback", exitCode: 1 }); + + await execCodeCommand("raise SystemExit(1)", { ...flags }); + + expect(process.exitCode).toBe(1); + }); + + it("leaves the exit code alone on success", async () => { + codeReturns({ stdout: "2\n", stderr: "", exitCode: 0 }); + + await execCodeCommand("print(1+1)", { ...flags }); + + expect(process.exitCode).toBeUndefined(); + expect(written()).toContain("2"); + }); + + it("reports the real exit code under --json", async () => { + codeReturns({ stdout: "out", stderr: "err", exitCode: 3 }); + + await execCodeCommand("x", { ...flags, json: true }); + + expect(JSON.parse(written())).toEqual({ stdout: "out", stderr: "err", exit_code: 3 }); + expect(process.exitCode).toBe(3); + }); + + it("sends stderr to stderr, so stdout stays pipeable", async () => { + codeReturns({ stdout: "data", stderr: "warning", exitCode: 0 }); + + await execCodeCommand("x", { ...flags }); + + expect(written()).toContain("data"); + expect(written()).not.toContain("warning"); + expect(stderr.mock.calls.map((c) => String(c[0])).join("")).toContain("warning"); + }); + + it("rejects an unknown language rather than guessing", async () => { + codeReturns({ stdout: "", stderr: "", exitCode: 0 }); + + await expect(execCodeCommand("x", { ...flags, lang: "ruby" })).rejects.toThrow(/--lang/); + }); + + it("rejects a timeout that is not a positive number of seconds", async () => { + codeReturns({ stdout: "", stderr: "", exitCode: 0 }); + + await expect(execCodeCommand("x", { ...flags, timeout: "nope" })).rejects.toThrow(CliError); + }); +}); + +describe("browser extract schema", () => { + let dir: string; + const flags = { box: "b1", token: "box_test" }; + + beforeEach(() => { + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + vi.spyOn(process.stderr, "write").mockImplementation(() => true); + dir = mkdtempSync(join(tmpdir(), "box-schema-")); + getBox.mockReset(); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + const schemaAt = (body: unknown) => { + const path = join(dir, "schema.json"); + writeFileSync(path, JSON.stringify(body)); + return path; + }; + + const tabReturning = (result: unknown) => { + const extract = vi.fn().mockResolvedValue(result); + getBox.mockResolvedValue({ + browser: { listTabs: vi.fn().mockResolvedValue([{ id: "t", extract }]) }, + }); + return extract; + }; + + it("converts a flat schema and extracts against it", async () => { + const extract = tabReturning({ title: "Hi", count: 2 }); + const schema = schemaAt({ + type: "object", + properties: { + title: { type: "string" }, + count: { type: "number" }, + ok: { type: "boolean" }, + tags: { type: "array", items: { type: "string" } }, + }, + }); + + await browserExtractCommand("read the page", { ...flags, schema }); + + expect(extract).toHaveBeenCalledWith("read the page", expect.anything()); + }); + + it("leaves a property out of `required` optional, so a missing field parses", async () => { + // tab.extract() ends in schema.parse(), so marking everything required + // makes a page that simply lacks an optional field fail client-side. + let captured: { parse: (value: unknown) => unknown } | undefined; + const extract = vi.fn().mockImplementation((_instruction, schema) => { + captured = schema; + return Promise.resolve({ title: "Hi" }); + }); + getBox.mockResolvedValue({ + browser: { listTabs: vi.fn().mockResolvedValue([{ id: "t", extract }]) }, + }); + const schema = schemaAt({ + type: "object", + properties: { title: { type: "string" }, subtitle: { type: "string" } }, + required: ["title"], + }); + + await browserExtractCommand("read it", { ...flags, schema }); + + expect(() => captured!.parse({ title: "Hi" })).not.toThrow(); + }); + + it("keeps a property named in `required` required", async () => { + let captured: { parse: (value: unknown) => unknown } | undefined; + const extract = vi.fn().mockImplementation((_instruction, schema) => { + captured = schema; + return Promise.resolve({ title: "Hi" }); + }); + getBox.mockResolvedValue({ + browser: { listTabs: vi.fn().mockResolvedValue([{ id: "t", extract }]) }, + }); + const schema = schemaAt({ + type: "object", + properties: { title: { type: "string" } }, + required: ["title"], + }); + + await browserExtractCommand("read it", { ...flags, schema }); + + expect(() => captured!.parse({})).toThrow(); + }); + + it("refuses a nested object rather than dropping the field", async () => { + // A dropped field comes back as a missing key, which reads as "the page did + // not have it" rather than as a schema the CLI could not express. + tabReturning({}); + const schema = schemaAt({ + type: "object", + properties: { author: { type: "object" } }, + }); + + await expect(browserExtractCommand("x", { ...flags, schema })).rejects.toThrow( + /unsupported type object/, + ); + }); + + it("refuses an array of non-strings", async () => { + tabReturning({}); + const schema = schemaAt({ + type: "object", + properties: { rows: { type: "array", items: { type: "number" } } }, + }); + + await expect(browserExtractCommand("x", { ...flags, schema })).rejects.toThrow( + /arrays of string/, + ); + }); + + it("refuses a schema that is not an object", async () => { + tabReturning({}); + const schema = schemaAt({ type: "string" }); + + await expect(browserExtractCommand("x", { ...flags, schema })).rejects.toThrow(/type.*object/); + }); + + it("refuses a schema file holding null with a message, not a TypeError", async () => { + // JSON.parse("null") succeeds, so the property read below it threw a raw + // TypeError past the CLI's own validation. + tabReturning({}); + const schema = schemaAt(null); + + await expect(browserExtractCommand("x", { ...flags, schema })).rejects.toThrow(/type.*object/); + }); + + it("names the file when it cannot be read", async () => { + tabReturning({}); + + await expect( + browserExtractCommand("x", { ...flags, schema: join(dir, "missing.json") }), + ).rejects.toThrow(/Could not read the schema/); + }); +}); diff --git a/packages/cli/src/__tests__/commands/from-snapshot.test.ts b/packages/cli/src/__tests__/commands/from-snapshot.test.ts index 4eb63d3d..ec1d407d 100644 --- a/packages/cli/src/__tests__/commands/from-snapshot.test.ts +++ b/packages/cli/src/__tests__/commands/from-snapshot.test.ts @@ -20,8 +20,27 @@ vi.mock("../../auth.js", () => ({ resolveToken: vi.fn((token?: string) => token ?? "resolved-token"), })); +vi.mock("../../core/box-ref.js", () => ({ + writeBoxFile: vi.fn(() => "/tmp/.box"), +})); + +/** Run a body as though a terminal were attached, then restore. */ +async function withTty(body: () => Promise): Promise { + const inTty = process.stdin.isTTY; + const outTty = process.stdout.isTTY; + process.stdin.isTTY = true; + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + try { + await body(); + } finally { + process.stdin.isTTY = inTty; + Object.defineProperty(process.stdout, "isTTY", { value: outTty, configurable: true }); + } +} + import { Box } from "@upstash/box"; import { startRepl } from "../../repl/terminal.js"; +import { writeBoxFile } from "../../core/box-ref.js"; describe("fromSnapshotCommand", () => { let exitSpy: ReturnType; @@ -41,11 +60,13 @@ describe("fromSnapshotCommand", () => { const mockBox = { id: "box-1" }; vi.mocked(Box.fromSnapshot).mockResolvedValueOnce(mockBox as any); - await fromSnapshotCommand("snap-1", { - token: "key", - agentModel: "model", - agentHarness: "claude-code", - agentApiKey: "agent-key", + await withTty(async () => { + await fromSnapshotCommand("snap-1", { + token: "key", + agentModel: "model", + agentHarness: "claude-code", + agentApiKey: "agent-key", + }); }); expect(Box.fromSnapshot).toHaveBeenCalledWith( @@ -73,10 +94,12 @@ describe("fromSnapshotCommand", () => { const mockBox = { id: "box-2" }; vi.mocked(Box.fromSnapshot).mockResolvedValueOnce(mockBox as any); - await fromSnapshotCommand("snap-1", { - token: "key", - agentModel: "model", - agentHarness: "claude-code", + await withTty(async () => { + await fromSnapshotCommand("snap-1", { + token: "key", + agentModel: "model", + agentHarness: "claude-code", + }); }); expect(Box.fromSnapshot).toHaveBeenCalledWith( @@ -88,6 +111,56 @@ describe("fromSnapshotCommand", () => { expect(startRepl).toHaveBeenCalledWith(mockBox); }); + it("restores without a REPL when --no-repl is given", async () => { + // The reason snapshot list/delete were write-only: an agent could make a + // snapshot and never restore one, because restore always opened a REPL. + vi.mocked(Box.fromSnapshot).mockResolvedValueOnce({ id: "box-9" } as any); + + await withTty(async () => { + await fromSnapshotCommand("snap-1", { token: "key", repl: false }); + }); + + expect(startRepl).not.toHaveBeenCalled(); + expect(writeBoxFile).toHaveBeenCalledWith("box-9"); + }); + + it("goes headless with no terminal, so a script does not hang", async () => { + vi.mocked(Box.fromSnapshot).mockResolvedValueOnce({ id: "box-9" } as any); + + await fromSnapshotCommand("snap-1", { token: "key" }); + + expect(startRepl).not.toHaveBeenCalled(); + }); + + it("still reports the id when the directory cannot be written", async () => { + // The box exists and is billing by this point; swallowing its id would + // leave it running with no way to find it. + vi.mocked(Box.fromSnapshot).mockResolvedValueOnce({ id: "box-9" } as any); + vi.mocked(writeBoxFile).mockImplementationOnce(() => { + throw new Error("EROFS: read-only file system"); + }); + + const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + let written = ""; + try { + await fromSnapshotCommand("snap-1", { token: "key", repl: false }); + // mockRestore also clears mock.calls, so read them before restoring. + written = stdout.mock.calls.map((call) => String(call[0])).join(""); + } finally { + stdout.mockRestore(); + } + + expect(written).toContain("box-9"); + }); + + it("does not pin when --no-use is given", async () => { + vi.mocked(Box.fromSnapshot).mockResolvedValueOnce({ id: "box-9" } as any); + + await fromSnapshotCommand("snap-1", { token: "key", repl: false, use: false }); + + expect(writeBoxFile).not.toHaveBeenCalled(); + }); + it("errors when agentModel is set without a harness flag", async () => { await expect( fromSnapshotCommand("snap-1", { token: "key", agentModel: "model" }), diff --git a/packages/cli/src/__tests__/commands/runs-and-snapshots.test.ts b/packages/cli/src/__tests__/commands/runs-and-snapshots.test.ts new file mode 100644 index 00000000..4d857ad6 --- /dev/null +++ b/packages/cli/src/__tests__/commands/runs-and-snapshots.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { statusRunsCommand, statusLogsCommand, cancelCommand } from "../../commands/status.js"; +import { snapshotListCommand, snapshotDeleteCommand } from "../../commands/snapshot.js"; + +const getBox = vi.hoisted(() => vi.fn()); +vi.mock("@upstash/box", () => ({ Box: { get: getBox } })); + +vi.mock("../../core/box-ref.js", () => ({ + resolveBoxId: vi.fn(() => ({ id: "b1", source: "flag" })), + announceBox: vi.fn(), + findBoxFile: vi.fn(() => undefined), +})); + +describe("runs, logs, cancel and snapshots", () => { + let stdout: ReturnType; + const flags = { box: "b1", token: "box_test" }; + const written = () => stdout.mock.calls.map((c) => String(c[0])).join(""); + + beforeEach(() => { + stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + vi.spyOn(process.stderr, "write").mockImplementation(() => true); + getBox.mockReset(); + }); + afterEach(() => vi.restoreAllMocks()); + + describe("status runs", () => { + it("lists runs with the id first, since that is what cancel needs", async () => { + getBox.mockResolvedValue({ + listRuns: vi.fn().mockResolvedValue([ + { + id: "run-1", + type: "agent", + status: "completed", + duration_ms: 4200, + cost_usd: 0.0123, + }, + ]), + }); + + await statusRunsCommand({ ...flags }); + + expect(written()).toContain("run-1"); + expect(written()).toContain("agent"); + }); + + it("says so plainly when there are none", async () => { + getBox.mockResolvedValue({ listRuns: vi.fn().mockResolvedValue([]) }); + + await statusRunsCommand({ ...flags }); + + expect(written()).toMatch(/no runs/i); + }); + + it("emits the raw run objects under --json", async () => { + const runs = [{ id: "run-1", type: "shell", duration_ms: 10, cost_usd: 0 }]; + getBox.mockResolvedValue({ listRuns: vi.fn().mockResolvedValue(runs) }); + + await statusRunsCommand({ ...flags, json: true }); + + expect(JSON.parse(written())).toEqual(runs); + }); + }); + + describe("status logs", () => { + it("passes paging through only when asked", async () => { + const logs = vi.fn().mockResolvedValue([]); + getBox.mockResolvedValue({ logs }); + + await statusLogsCommand({ ...flags }); + expect(logs).toHaveBeenCalledWith({}); + + await statusLogsCommand({ ...flags, limit: "50", offset: "10" }); + expect(logs).toHaveBeenCalledWith({ limit: 50, offset: 10 }); + }); + + it("rejects --limit 0, which the SDK would drop as falsy", async () => { + // box.logs only sets limit when truthy, so 0 returned a full default + // page instead of nothing, with no sign the flag was ignored. + getBox.mockResolvedValue({ logs: vi.fn() }); + + await expect(statusLogsCommand({ ...flags, limit: "0" })).rejects.toThrow(/at least 1/); + }); + + it("still allows --offset 0", async () => { + const logs = vi.fn().mockResolvedValue([]); + getBox.mockResolvedValue({ logs }); + + await statusLogsCommand({ ...flags, offset: "0" }); + + expect(logs).toHaveBeenCalledWith({ offset: 0 }); + }); + + it("renders the timestamp as a date rather than a number", async () => { + getBox.mockResolvedValue({ + logs: vi + .fn() + .mockResolvedValue([ + { timestamp: 1767225600, level: "error", source: "agent", message: "boom" }, + ]), + }); + + await statusLogsCommand({ ...flags }); + + expect(written()).toContain("boom"); + expect(written()).toMatch(/\d{4}-\d{2}-\d{2}T/); + }); + }); + + describe("cancel", () => { + it("posts to the run's cancel endpoint", async () => { + // Pins the method and the path, since the SDK has no cancel-by-id to + // typecheck against: a wrong path here would only fail on a live box. + const request = vi.fn().mockResolvedValue({}); + getBox.mockResolvedValue({ id: "b1", _request: request }); + + await cancelCommand("run-9", { ...flags }); + + expect(request).toHaveBeenCalledWith("POST", "/v2/box/b1/runs/run-9/cancel"); + expect(written()).toContain("run-9"); + }); + }); + + describe("snapshots", () => { + it("lists snapshots id-first, so delete has something to take", async () => { + getBox.mockResolvedValue({ + listSnapshots: vi + .fn() + .mockResolvedValue([ + { id: "snap-1", name: "before-migration", status: "ready", size_bytes: 52_428_800 }, + ]), + }); + + await snapshotListCommand({ ...flags }); + + expect(written()).toContain("snap-1"); + expect(written()).toContain("50MB"); + }); + + it("reports an empty list rather than printing nothing", async () => { + getBox.mockResolvedValue({ listSnapshots: vi.fn().mockResolvedValue([]) }); + + await snapshotListCommand({ ...flags }); + + expect(written()).toMatch(/no snapshots/i); + }); + + it("deletes by id", async () => { + const deleteSnapshot = vi.fn().mockResolvedValue(undefined); + getBox.mockResolvedValue({ deleteSnapshot }); + + await snapshotDeleteCommand("snap-2", { ...flags }); + + expect(deleteSnapshot).toHaveBeenCalledWith("snap-2"); + }); + }); +}); diff --git a/packages/cli/src/__tests__/commands/schedule-config.test.ts b/packages/cli/src/__tests__/commands/schedule-config.test.ts new file mode 100644 index 00000000..74f0bf57 --- /dev/null +++ b/packages/cli/src/__tests__/commands/schedule-config.test.ts @@ -0,0 +1,253 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + scheduleExecCommand, + scheduleAgentCommand, + scheduleListCommand, + scheduleUpdateCommand, + schedulePauseCommand, + scheduleDeleteCommand, +} from "../../commands/schedule.js"; +import { + configureModelCommand, + networkPolicyCommand, + skillsAddCommand, + skillsListCommand, + initCommandSetCommand, +} from "../../commands/config.js"; +import { CliError } from "../../core/errors.js"; + +const getBox = vi.hoisted(() => vi.fn()); +vi.mock("@upstash/box", () => ({ Box: { get: getBox } })); + +vi.mock("../../core/box-ref.js", () => ({ + resolveBoxId: vi.fn(() => ({ id: "b1", source: "flag" })), + announceBox: vi.fn(), +})); + +describe("schedule and config", () => { + let stdout: ReturnType; + const flags = { box: "b1", token: "box_test" }; + const written = () => stdout.mock.calls.map((c) => String(c[0])).join(""); + + beforeEach(() => { + stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + vi.spyOn(process.stderr, "write").mockImplementation(() => true); + getBox.mockReset(); + }); + afterEach(() => vi.restoreAllMocks()); + + describe("schedule exec", () => { + it("sends the command as argv, so quoting survives", async () => { + const exec = vi.fn().mockResolvedValue({ id: "sch-1" }); + getBox.mockResolvedValue({ schedule: { exec } }); + + await scheduleExecCommand(["npm", "run", "backup"], { ...flags, cron: "0 9 * * *" }); + + expect(exec).toHaveBeenCalledWith({ cron: "0 9 * * *", command: ["npm", "run", "backup"] }); + expect(written()).toContain("sch-1"); + }); + + it("refuses without a cron, rather than creating something that never runs", async () => { + getBox.mockResolvedValue({ schedule: { exec: vi.fn() } }); + + await expect(scheduleExecCommand(["ls"], { ...flags })).rejects.toThrow(/--cron/); + }); + + it("refuses an empty command", async () => { + getBox.mockResolvedValue({ schedule: { exec: vi.fn() } }); + + await expect(scheduleExecCommand([], { ...flags, cron: "0 9 * * *" })).rejects.toThrow( + CliError, + ); + }); + }); + + describe("schedule agent", () => { + it("joins the prompt words", async () => { + const agent = vi.fn().mockResolvedValue({ id: "sch-2" }); + getBox.mockResolvedValue({ schedule: { agent } }); + + await scheduleAgentCommand(["check", "the", "logs"], { ...flags, cron: "@daily" }); + + expect(agent).toHaveBeenCalledWith({ cron: "@daily", prompt: "check the logs" }); + }); + + it("converts the timeout from seconds to the milliseconds the SDK takes", async () => { + // Every --timeout flag is documented in seconds and every SDK option is + // milliseconds. Passing the number straight through would ask for 60ms. + const agent = vi.fn().mockResolvedValue({ id: "sch-2" }); + getBox.mockResolvedValue({ schedule: { agent } }); + + await scheduleAgentCommand(["x"], { ...flags, cron: "@daily", timeout: "60" }); + + expect(agent).toHaveBeenCalledWith(expect.objectContaining({ timeout: 60_000 })); + }); + + it("forwards a webhook, which agent schedules support too", async () => { + const agent = vi.fn().mockResolvedValue({ id: "sch-2" }); + getBox.mockResolvedValue({ schedule: { agent } }); + + await scheduleAgentCommand(["x"], { + ...flags, + cron: "@daily", + webhookUrl: "https://hook.test", + }); + + expect(agent).toHaveBeenCalledWith( + expect.objectContaining({ webhookUrl: "https://hook.test" }), + ); + }); + + it("rejects a timeout that is not a positive number", async () => { + getBox.mockResolvedValue({ schedule: { agent: vi.fn() } }); + + await expect( + scheduleAgentCommand(["x"], { ...flags, cron: "@daily", timeout: "0" }), + ).rejects.toThrow(/--timeout/); + }); + }); + + it("lists schedules id-first", async () => { + getBox.mockResolvedValue({ + schedule: { + list: vi + .fn() + .mockResolvedValue([ + { id: "sch-1", type: "exec", cron: "0 9 * * *", status: "active", command: ["ls"] }, + ]), + }, + }); + + await scheduleListCommand({ ...flags }); + + expect(written()).toContain("sch-1"); + expect(written()).toContain("0 9 * * *"); + }); + + describe("schedule update", () => { + it("sends only what changed, leaving the rest alone", async () => { + const update = vi + .fn() + .mockResolvedValue({ id: "sch-1", type: "exec", cron: "@hourly", status: "active" }); + getBox.mockResolvedValue({ schedule: { update } }); + + await scheduleUpdateCommand("sch-1", [], { ...flags, cron: "@hourly" }); + + // A partial update that also sent command/prompt would clear them. + expect(update).toHaveBeenCalledWith("sch-1", { cron: "@hourly" }); + }); + + it("updates the timeout in milliseconds, and lets 0 clear it", async () => { + const update = vi + .fn() + .mockResolvedValue({ id: "sch-1", type: "prompt", cron: "@daily", status: "active" }); + getBox.mockResolvedValue({ schedule: { update } }); + + await scheduleUpdateCommand("sch-1", [], { ...flags, timeout: "45" }); + expect(update).toHaveBeenCalledWith("sch-1", { timeout: 45_000 }); + + // 0 is how the SDK clears the field, so it has to survive the conversion + // that rejects every other non-positive value. + await scheduleUpdateCommand("sch-1", [], { ...flags, timeout: "0" }); + expect(update).toHaveBeenLastCalledWith("sch-1", { timeout: 0 }); + }); + + it("refuses an update that changes nothing", async () => { + getBox.mockResolvedValue({ schedule: { update: vi.fn() } }); + + await expect(scheduleUpdateCommand("sch-1", [], { ...flags })).rejects.toThrow( + /Nothing to update/, + ); + }); + }); + + it("pauses and deletes by id", async () => { + const pause = vi.fn().mockResolvedValue(undefined); + const del = vi.fn().mockResolvedValue(undefined); + getBox.mockResolvedValue({ schedule: { pause, delete: del } }); + + await schedulePauseCommand("sch-1", { ...flags }); + await scheduleDeleteCommand("sch-2", { ...flags }); + + expect(pause).toHaveBeenCalledWith("sch-1"); + expect(del).toHaveBeenCalledWith("sch-2"); + }); + + describe("network policy", () => { + it("sends a blanket mode with no lists", async () => { + const updateNetworkPolicy = vi.fn().mockResolvedValue(undefined); + getBox.mockResolvedValue({ updateNetworkPolicy }); + + await networkPolicyCommand("deny-all", { ...flags }); + + expect(updateNetworkPolicy).toHaveBeenCalledWith({ mode: "deny-all" }); + }); + + it("refuses lists on a blanket mode, which would look like they applied", async () => { + getBox.mockResolvedValue({ updateNetworkPolicy: vi.fn() }); + + await expect( + networkPolicyCommand("allow-all", { ...flags, allowDomain: ["example.com"] }), + ).rejects.toThrow(/only apply to 'custom'/); + }); + + it("refuses custom with no lists, which would be an empty policy", async () => { + getBox.mockResolvedValue({ updateNetworkPolicy: vi.fn() }); + + await expect(networkPolicyCommand("custom", { ...flags })).rejects.toThrow(/at least one/); + }); + + it("builds a custom policy from the lists given", async () => { + const updateNetworkPolicy = vi.fn().mockResolvedValue(undefined); + getBox.mockResolvedValue({ updateNetworkPolicy }); + + await networkPolicyCommand("custom", { + ...flags, + allowDomain: ["api.example.com"], + denyCidr: ["10.0.0.0/8"], + }); + + expect(updateNetworkPolicy).toHaveBeenCalledWith({ + mode: "custom", + allowedDomains: ["api.example.com"], + deniedCidrs: ["10.0.0.0/8"], + }); + }); + + it("rejects an unknown mode", async () => { + getBox.mockResolvedValue({ updateNetworkPolicy: vi.fn() }); + + await expect(networkPolicyCommand("everything", { ...flags })).rejects.toThrow( + /mode must be/, + ); + }); + }); + + it("configures the model", async () => { + const configureModel = vi.fn().mockResolvedValue(undefined); + getBox.mockResolvedValue({ configureModel }); + + await configureModelCommand("anthropic/claude-sonnet-5", { ...flags }); + + expect(configureModel).toHaveBeenCalledWith("anthropic/claude-sonnet-5"); + }); + + it("adds and lists skills", async () => { + const add = vi.fn().mockResolvedValue(undefined); + getBox.mockResolvedValue({ + skills: { add, list: vi.fn().mockResolvedValue(["upstash-redis"]) }, + }); + + await skillsAddCommand("upstash-redis", { ...flags }); + await skillsListCommand({ ...flags }); + + expect(add).toHaveBeenCalledWith("upstash-redis"); + expect(written()).toContain("upstash-redis"); + }); + + it("refuses an empty init command", async () => { + getBox.mockResolvedValue({ setInitCommand: vi.fn() }); + + await expect(initCommandSetCommand(" ", { ...flags })).rejects.toThrow(/empty/); + }); +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 4a7e1fb3..4b7f6973 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -9,7 +9,11 @@ import { fromSnapshotCommand } from "./commands/from-snapshot.js"; import { listCommand } from "./commands/list.js"; import { getCommand } from "./commands/get.js"; import { initDemoCommand } from "./commands/init-demo.js"; -import { snapshotCommand } from "./commands/snapshot.js"; +import { + snapshotCommand, + snapshotListCommand, + snapshotDeleteCommand, +} from "./commands/snapshot.js"; import { completionCommand } from "./commands/completion.js"; import { envSetCommand, @@ -18,9 +22,54 @@ import { envSetAllCommand, } from "./commands/env.js"; import { labelAddCommand, labelRemoveCommand, labelListCommand } from "./commands/labels.js"; -import { statusCommand } from "./commands/status.js"; +import { + statusCommand, + statusRunsCommand, + statusLogsCommand, + cancelCommand, +} from "./commands/status.js"; +import { + scheduleExecCommand, + scheduleAgentCommand, + scheduleListCommand, + scheduleGetCommand, + scheduleUpdateCommand, + schedulePauseCommand, + scheduleResumeCommand, + scheduleDeleteCommand, +} from "./commands/schedule.js"; +import { + configureModelCommand, + configureHarnessCommand, + initCommandGetCommand, + initCommandSetCommand, + initCommandDeleteCommand, + networkPolicyCommand, + skillsAddCommand, + skillsRemoveCommand, + skillsListCommand, + resumeCommand, +} from "./commands/config.js"; +import { + browserGotoCommand, + browserObserveCommand, + browserExtractCommand, + browserLiveUrlCommand, + recordingStartCommand, + recordingStopCommand, + recordingListCommand, + recordingGetCommand, + recordingDownloadCommand, + browserOpenCommand, + browserTabsCommand, + browserContentCommand, + browserScreenshotCommand, + browserActCommand, + browserCloseCommand, + browserCdpUrlCommand, +} from "./commands/browser.js"; import { useCommand } from "./commands/use.js"; -import { execCommand } from "./commands/exec.js"; +import { execCommand, execCodeCommand } from "./commands/exec.js"; import { filesReadCommand, filesWriteCommand, @@ -120,6 +169,36 @@ program await runCommand(async () => statusCommand(globals(flags))); }); +// `box status` keeps its own action; runs and logs hang off it as subcommands. +const statusGroup = program.commands.find((command) => command.name() === "status")!; +statusGroup + .command("runs") + .description("List the box's runs, most recent first") + .option("--box ", "Box to act on") + .option("--json", "Emit machine-readable output") + .option("--token ", "Upstash Box API token") + .action(async (opts) => runCommand(async () => statusRunsCommand(merged(opts)))); + +statusGroup + .command("logs") + .description("Print the box's log lines") + .option("--box ", "Box to act on") + .option("--json", "Emit machine-readable output") + .option("--token ", "Upstash Box API token") + .option("--limit ", "Maximum entries to return") + .option("--offset ", "Skip this many entries") + .action(async (opts) => runCommand(async () => statusLogsCommand(merged(opts)))); + +program + .command("cancel ") + .description("Cancel a running execution (get ids from box status runs)") + .option("--box ", "Box to act on") + .option("--json", "Emit machine-readable output") + .option("--token ", "Upstash Box API token") + .action(async (runId: string, opts) => + runCommand(async () => cancelCommand(runId, merged(opts))), + ); + program .command("exec") // Variadic so the remote command survives as separate words, and Commander @@ -137,6 +216,19 @@ program ); }); +program + .command("code") + .argument("", "Inline code, or - to read stdin") + .description("Run inline code in the box (js, ts or python)") + .option("--lang ", "js, ts or python (default python)") + .option("--timeout ", "Give up after this long") + .option("--box ", "Box to act on") + .option("--json", "Emit machine-readable output") + .option("--token ", "Upstash Box API token") + .action(async (source: string, opts) => + runCommand(async () => execCodeCommand(source, merged(opts))), + ); + const files = program.command("files").description("File operations inside the box"); /** Flags every file verb accepts, declared once. */ const withCommon = (cmd: import("commander").Command) => @@ -457,11 +549,295 @@ program (val: string, prev: string[]) => [...prev, val], [] as string[], ) + .option("--no-repl", "Restore and print the id instead of opening the REPL") + .option("--no-use", "Do not write a .box file") + .option("--json", "Emit machine-readable output (implies --no-repl)") .action(async (snapshotId, opts) => - runCommand(async () => { - refuseJson(merged(opts), "box from-snapshot", "box create --no-repl --json"); - return fromSnapshotCommand(snapshotId, merged(opts)); - }), + runCommand(async () => fromSnapshotCommand(snapshotId, merged(opts))), + ); + +const schedule = program + .command("schedule") + .description("Cron schedules that run a command or an agent prompt in the box"); + +/** Flags every schedule verb accepts, declared once. */ +const withScheduleCommon = (cmd: import("commander").Command) => + cmd + .option("--box ", "Box to act on") + .option("--json", "Emit machine-readable output") + .option("--token ", "Upstash Box API token"); + +withScheduleCommon( + schedule + .command("exec") + .argument("[command...]", "Command to run on the cron; put it after --") + .description("Schedule a shell command"), +) + .option("--cron ", "Cron expression, UTC") + .option("-C, --folder ", "Working directory inside the box") + .option("--webhook-url ", "POST the result here after each run") + .action(async (parts: string[], opts) => + runCommand(async () => scheduleExecCommand(parts, merged(opts))), + ); + +withScheduleCommon( + schedule + .command("agent") + .argument("[prompt...]", "Prompt for the agent") + .description("Schedule an agent prompt"), +) + .option("--cron ", "Cron expression, UTC") + .option("-C, --folder ", "Working directory inside the box") + .option("--model ", "Model to run the prompt on") + .option("--timeout ", "Give up after this long") + .option("--webhook-url ", "POST the result here after each run") + .action(async (parts: string[], opts) => + runCommand(async () => scheduleAgentCommand(parts, merged(opts))), + ); + +withScheduleCommon(schedule.command("list").description("List schedules")).action(async (opts) => + runCommand(async () => scheduleListCommand(merged(opts))), +); + +withScheduleCommon( + schedule.command("get").argument("").description("Show one schedule"), +).action(async (id: string, opts) => runCommand(async () => scheduleGetCommand(id, merged(opts)))); + +withScheduleCommon( + schedule + .command("update") + .argument("") + .argument("[command...]", "Replacement command; put it after --") + .description("Change a schedule in place"), +) + .option("--cron ", "New cron expression") + .option("--prompt ", "New prompt") + .option("-C, --folder ", "New working directory") + .option("--model ", "New model") + .option("--timeout ", "New timeout; 0 clears it") + .option("--webhook-url ", "New webhook URL") + .action(async (id: string, parts: string[], opts) => + runCommand(async () => scheduleUpdateCommand(id, parts, merged(opts))), + ); + +withScheduleCommon( + schedule.command("pause").argument("").description("Pause a schedule"), +).action(async (id: string, opts) => + runCommand(async () => schedulePauseCommand(id, merged(opts))), +); + +withScheduleCommon( + schedule.command("resume").argument("").description("Resume a schedule"), +).action(async (id: string, opts) => + runCommand(async () => scheduleResumeCommand(id, merged(opts))), +); + +withScheduleCommon( + schedule.command("delete").argument("").description("Delete a schedule"), +).action(async (id: string, opts) => + runCommand(async () => scheduleDeleteCommand(id, merged(opts))), +); + +const skills = program.command("skills").description("Skills enabled inside the box"); + +withScheduleCommon( + skills.command("add").argument("").description("Enable a skill"), +).action(async (id: string, opts) => runCommand(async () => skillsAddCommand(id, merged(opts)))); + +withScheduleCommon( + skills.command("remove").argument("").description("Disable a skill"), +).action(async (id: string, opts) => runCommand(async () => skillsRemoveCommand(id, merged(opts)))); + +withScheduleCommon(skills.command("list").description("List enabled skills")).action(async (opts) => + runCommand(async () => skillsListCommand(merged(opts))), +); + +const config = program.command("config").description("Box configuration"); + +withScheduleCommon( + config.command("model").argument("").description("Point the agent at another model"), +).action(async (model: string, opts) => + runCommand(async () => configureModelCommand(model, merged(opts))), +); + +withScheduleCommon(config.command("harness").description("Point the box at a custom harness")) + .requiredOption("--command ", "Harness executable") + .option( + "--arg ", + "Argument passed before the prompt (repeatable)", + (val: string, prev: string[]) => [...prev, val], + [] as string[], + ) + .action(async (opts) => + runCommand(async () => + configureHarnessCommand({ ...merged(opts), args: opts.arg as string[] }), + ), + ); + +withScheduleCommon( + config + .command("network") + .argument("", "allow-all, deny-all or custom") + .description("Set the network policy"), +) + .option( + "--allow-domain ", + "Allowed domain (repeatable, custom only)", + (val: string, prev: string[]) => [...prev, val], + [] as string[], + ) + .option( + "--allow-cidr ", + "Allowed CIDR (repeatable, custom only)", + (val: string, prev: string[]) => [...prev, val], + [] as string[], + ) + .option( + "--deny-cidr ", + "Denied CIDR (repeatable, custom only)", + (val: string, prev: string[]) => [...prev, val], + [] as string[], + ) + .action(async (mode: string, opts) => + runCommand(async () => + networkPolicyCommand(mode, { + ...merged(opts), + allowDomain: opts.allowDomain as string[], + allowCidr: opts.allowCidr as string[], + denyCidr: opts.denyCidr as string[], + }), + ), + ); + +const initCommand = config.command("init-command").description("Command the box runs at startup"); + +withScheduleCommon(initCommand.command("get").description("Show the init command")).action( + async (opts) => runCommand(async () => initCommandGetCommand(merged(opts))), +); + +withScheduleCommon( + initCommand + .command("set") + .argument("", "The command, or - to read stdin") + .description("Set the init command"), +).action(async (command: string, opts) => + runCommand(async () => initCommandSetCommand(command, merged(opts))), +); + +withScheduleCommon(initCommand.command("delete").description("Remove the init command")).action( + async (opts) => runCommand(async () => initCommandDeleteCommand(merged(opts))), +); + +program + .command("resume") + .description("Resume a paused box (every other command resumes it anyway)") + .option("--box ", "Box to act on") + .option("--json", "Emit machine-readable output") + .option("--token ", "Upstash Box API token") + .action(async (opts) => runCommand(async () => resumeCommand(merged(opts)))); + +const browser = program + .command("browser") + .description("Drive the box's headless Chromium (needs box create --browser)"); + +/** Flags every browser verb accepts, declared once. */ +const withBrowserCommon = (cmd: import("commander").Command) => + cmd + .option("--box ", "Box to act on") + .option("--json", "Emit machine-readable output") + .option("--token ", "Upstash Box API token") + .option("--tab ", "Tab to act on; only needed when more than one is open"); + +withBrowserCommon( + browser.command("open").argument("").description("Open a URL and print the tab id"), +).action(async (url: string, opts) => + runCommand(async () => browserOpenCommand(url, merged(opts))), +); + +withBrowserCommon(browser.command("tabs").description("List open tabs")).action(async (opts) => + runCommand(async () => browserTabsCommand(merged(opts))), +); + +withBrowserCommon( + browser.command("content").description("Read the page title, text and links"), +).action(async (opts) => runCommand(async () => browserContentCommand(merged(opts)))); + +withBrowserCommon(browser.command("screenshot").description("Capture the page as a PNG")) + .requiredOption("-o, --out ", "Write the PNG here") + .option("--full-page", "Capture the whole scrollable page") + .action(async (opts) => runCommand(async () => browserScreenshotCommand(merged(opts)))); + +withBrowserCommon( + browser + .command("act") + .argument("") + .description('Act on the page in words, e.g. "click the login button"'), +).action(async (instruction: string, opts) => + runCommand(async () => browserActCommand(instruction, merged(opts))), +); + +withBrowserCommon(browser.command("close").description("Close a tab")).action(async (opts) => + runCommand(async () => browserCloseCommand(merged(opts))), +); + +withBrowserCommon( + browser.command("cdp-url").description("Print the CDP URL for Playwright or Puppeteer"), +).action(async (opts) => runCommand(async () => browserCdpUrlCommand(merged(opts)))); + +withBrowserCommon( + browser.command("goto").argument("").description("Navigate an open tab"), +).action(async (url: string, opts) => + runCommand(async () => browserGotoCommand(url, merged(opts))), +); + +withBrowserCommon( + browser + .command("observe") + .argument("") + .description("List the actions available on the page"), +).action(async (instruction: string, opts) => + runCommand(async () => browserObserveCommand(instruction, merged(opts))), +); + +withBrowserCommon( + browser + .command("extract") + .argument("") + .description("Pull structured data off the page against a JSON Schema"), +) + .requiredOption("--schema ", "Flat JSON Schema object describing the fields") + .action(async (instruction: string, opts) => + runCommand(async () => browserExtractCommand(instruction, merged(opts))), + ); + +withBrowserCommon( + browser.command("live-url").description("Print a URL for watching the tab live"), +).action(async (opts) => runCommand(async () => browserLiveUrlCommand(merged(opts)))); + +const recordings = browser.command("recordings").description("Browser session recordings"); + +withBrowserCommon(recordings.command("start").description("Start recording")) + .option("--max-seconds ", "Stop automatically after this long") + .action(async (opts) => runCommand(async () => recordingStartCommand(merged(opts)))); + +withBrowserCommon(recordings.command("stop").description("Stop the active recording")).action( + async (opts) => runCommand(async () => recordingStopCommand(merged(opts))), +); + +withBrowserCommon(recordings.command("list").description("List recordings")).action(async (opts) => + runCommand(async () => recordingListCommand(merged(opts))), +); + +withBrowserCommon( + recordings.command("get").argument("").description("Show one recording"), +).action(async (id: string, opts) => runCommand(async () => recordingGetCommand(id, merged(opts)))); + +withBrowserCommon( + recordings.command("download").argument("").description("Download a recording"), +) + .requiredOption("-o, --out ", "Write the video here") + .action(async (id: string, opts) => + runCommand(async () => recordingDownloadCommand(id, merged(opts))), ); program @@ -489,6 +865,27 @@ program .option("--name ", "Snapshot name") .action(async (boxId, opts) => runCommand(async () => snapshotCommand(boxId, merged(opts)))); +const snapshotGroup = program.commands.find((command) => command.name() === "snapshot")!; + +snapshotGroup + .command("list") + .description("List the box's snapshots") + .option("--box ", "Box to act on") + .option("--json", "Emit machine-readable output") + .option("--token ", "Upstash Box API token") + .action(async (opts) => runCommand(async () => snapshotListCommand(merged(opts)))); + +snapshotGroup + .command("delete") + .argument("") + .description("Delete a snapshot") + .option("--box ", "Box to act on") + .option("--json", "Emit machine-readable output") + .option("--token ", "Upstash Box API token") + .action(async (snapshotId: string, opts) => + runCommand(async () => snapshotDeleteCommand(snapshotId, merged(opts))), + ); + program .command("init-demo") .description("Scaffold a standalone demo project for @upstash/box") diff --git a/packages/cli/src/commands/browser.ts b/packages/cli/src/commands/browser.ts new file mode 100644 index 00000000..c8392818 --- /dev/null +++ b/packages/cli/src/commands/browser.ts @@ -0,0 +1,351 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { z } from "zod"; +import { Box, type Tab } from "@upstash/box"; +import { resolveBoxId, announceBox } from "../core/box-ref.js"; +import { CliError } from "../core/errors.js"; +import { emit, note, requireToken, type GlobalFlags } from "../core/io.js"; + +export type BrowserFlags = GlobalFlags & { + tab?: string; + out?: string; + fullPage?: boolean; + schema?: string; + maxSeconds?: string; +}; + +async function open(flags: BrowserFlags): Promise { + const resolved = resolveBoxId({ flag: flags.box }); + announceBox(resolved); + return Box.get(resolved.id, { apiKey: requireToken(flags.token) }); +} + +/** + * Resolve which tab to act on. + * + * A box can hold several tabs, and every page operation is addressed by one. + * With a single tab open, requiring --tab would be ceremony; with several, + * guessing would act on the wrong page. + * @param box - the box. + * @param flags - the merged flags, for --tab. + * @returns the tab to use. + * @throws CliError when there is nothing open, or the choice is ambiguous. + */ +async function resolveTab(box: Box, flags: BrowserFlags): Promise { + if (flags.tab) return box.browser.getTab(flags.tab); + + const tabs = await box.browser.listTabs(); + if (tabs.length === 0) { + throw new CliError("No open tabs. Open one with: box browser open "); + } + if (tabs.length > 1) { + throw new CliError( + `${tabs.length} tabs are open; name one with --tab . List them with: box browser tabs`, + ); + } + return tabs[0]!; +} + +/** + * Open a URL in the box's browser. + * @param url - the page to load. + * @param flags - the merged flags. + */ +export async function browserOpenCommand(url: string, flags: BrowserFlags): Promise { + const box = await open(flags); + const tab = await box.browser.tab.create(url); + emit({ tab_id: tab.id, url }, [tab.id], flags); +} + +/** + * List the open tabs. + * @param flags - the merged flags. + */ +export async function browserTabsCommand(flags: BrowserFlags): Promise { + const box = await open(flags); + const tabs = await box.browser.listTabs(); + const data = tabs.map((tab) => ({ id: tab.id, url: tab.url, title: tab.title })); + emit( + data, + tabs.length === 0 + ? ["No open tabs."] + : tabs.map((tab) => `${tab.id}\t${tab.url ?? ""}\t${tab.title ?? ""}`), + flags, + ); +} + +/** + * Read the active page: title, visible text and links. + * @param flags - the merged flags. + */ +export async function browserContentCommand(flags: BrowserFlags): Promise { + const box = await open(flags); + const tab = await resolveTab(box, flags); + const content = await tab.content(); + const links = content.links ?? []; + emit( + content, + [ + content.title, + content.url, + "", + content.text, + // The command says it reads links, so the default output has to carry + // them; without this only --json exposed a destination URL. + ...(links.length === 0 + ? [] + : ["", ...links.map((link) => `${link.text ?? ""}\t${link.href ?? ""}`)]), + ], + flags, + ); +} + +/** + * Capture the page as a PNG. + * + * The image goes to a file rather than stdout: stdout carries text that a + * caller may pipe, and PNG bytes down the same channel would corrupt it. + * @param flags - the merged flags, with --out for the destination. + */ +export async function browserScreenshotCommand(flags: BrowserFlags): Promise { + if (!flags.out) { + throw new CliError("--out is required: a PNG cannot share stdout with text output"); + } + + const box = await open(flags); + const tab = await resolveTab(box, flags); + const shot = await tab.screenshot({ ...(flags.fullPage ? { fullPage: true } : {}) }); + const bytes = typeof shot === "string" ? Buffer.from(shot, "base64") : Buffer.from(shot); + writeFileSync(flags.out, bytes); + + emit( + { path: flags.out, bytes: bytes.length }, + [`Wrote ${bytes.length} bytes to ${flags.out}`], + flags, + ); +} + +/** + * Act on the page in natural language ("click the login button"). + * @param instruction - what to do. + * @param flags - the merged flags. + */ +export async function browserActCommand(instruction: string, flags: BrowserFlags): Promise { + const box = await open(flags); + const tab = await resolveTab(box, flags); + const result = await tab.act(instruction); + emit(result, [typeof result === "string" ? result : JSON.stringify(result, undefined, 2)], flags); +} + +/** + * Close a tab. + * @param flags - the merged flags. + */ +export async function browserCloseCommand(flags: BrowserFlags): Promise { + const box = await open(flags); + const tab = await resolveTab(box, flags); + await tab.close(); + emit({ tab_id: tab.id, closed: true }, [`Closed ${tab.id}`], flags); +} + +/** + * Print the CDP URL, so Playwright or Puppeteer can drive the same browser. + * @param flags - the merged flags. + */ +export async function browserCdpUrlCommand(flags: BrowserFlags): Promise { + const box = await open(flags); + const url = await box.browser.cdpUrl(); + note("Connect with: chromium.connectOverCDP()"); + emit({ cdp_url: url }, [url], flags); +} + +/** + * Navigate an existing tab. + * @param url - where to go. + * @param flags - the merged flags. + */ +export async function browserGotoCommand(url: string, flags: BrowserFlags): Promise { + const box = await open(flags); + const tab = await resolveTab(box, flags); + const content = await tab.goto(url); + emit(content, [content.title, content.url], flags); +} + +/** + * List the actions available on the page. + * @param instruction - what to look for. + * @param flags - the merged flags. + */ +export async function browserObserveCommand( + instruction: string, + flags: BrowserFlags, +): Promise { + const box = await open(flags); + const tab = await resolveTab(box, flags); + const result = await tab.observe(instruction); + emit(result, [JSON.stringify(result, undefined, 2)], flags); +} + +/** + * Build a Zod object from a flat JSON Schema. + * + * `extract` takes a Zod schema, which cannot travel through a command line, so + * the CLI accepts the JSON Schema an agent can write to a file. Only a flat + * object of scalars and string arrays converts; anything nested is refused + * rather than silently dropped, because a field that vanishes from the schema + * comes back as a missing key rather than as an error. + * @param path - file holding the JSON Schema. + * @returns the equivalent Zod object. + */ +function schemaFromFile(path: string): z.ZodTypeAny { + let parsed: { + type?: string; + properties?: Record; + required?: string[]; + } | null; + try { + parsed = JSON.parse(readFileSync(path, "utf8")) as typeof parsed; + } catch (error) { + throw new CliError(`Could not read the schema at ${path}: ${(error as Error).message}`); + } + + // JSON.parse("null") succeeds, and reading .type off the result is a raw + // TypeError rather than the message the caller needs. + if (parsed === null || typeof parsed !== "object") { + throw new CliError('The schema must be {"type":"object","properties":{...}}'); + } + if (parsed.type !== "object" || !parsed.properties) { + throw new CliError('The schema must be {"type":"object","properties":{...}}'); + } + + // JSON Schema says a property is optional unless it is named in `required`. + // Marking everything required instead makes tab.extract() throw client-side + // the moment the page is missing a field the caller knew might be absent. + const required = new Set(parsed.required ?? []); + const shape: Record = {}; + for (const [key, prop] of Object.entries(parsed.properties)) { + switch (prop.type) { + case "string": + shape[key] = z.string(); + break; + case "number": + case "integer": + shape[key] = z.number(); + break; + case "boolean": + shape[key] = z.boolean(); + break; + case "array": + if (prop.items?.type !== "string") { + throw new CliError(`Field ${key}: only arrays of string are supported`); + } + shape[key] = z.array(z.string()); + break; + default: + throw new CliError(`Field ${key}: unsupported type ${prop.type ?? "(missing)"}`); + } + if (!required.has(key)) shape[key] = shape[key]!.optional(); + } + return z.object(shape); +} + +/** + * Pull structured data off the page against a schema. + * @param instruction - what to extract. + * @param flags - the merged flags; --schema names a JSON Schema file. + */ +export async function browserExtractCommand( + instruction: string, + flags: BrowserFlags, +): Promise { + if (!flags.schema) { + throw new CliError("--schema is required, holding a flat JSON Schema object"); + } + + const box = await open(flags); + const tab = await resolveTab(box, flags); + const result = await tab.extract(instruction, schemaFromFile(flags.schema) as never); + emit(result, [JSON.stringify(result, undefined, 2)], flags); +} + +/** + * Print the live-view URL for a tab, to watch it in a browser. + * @param flags - the merged flags. + */ +export async function browserLiveUrlCommand(flags: BrowserFlags): Promise { + const box = await open(flags); + const tab = await resolveTab(box, flags); + const url = await tab.liveViewUrl(); + emit({ live_view_url: url }, [url], flags); +} + +/** + * Start recording the browser session. + * @param flags - the merged flags. + */ +export async function recordingStartCommand(flags: BrowserFlags): Promise { + const box = await open(flags); + const seconds = flags.maxSeconds === undefined ? undefined : Number(flags.maxSeconds); + if (seconds !== undefined && (!Number.isFinite(seconds) || seconds <= 0)) { + throw new CliError("--max-seconds must be a positive number"); + } + + const handle = await box.browser.recordings.start( + seconds === undefined ? undefined : { maxDurationSeconds: seconds }, + ); + emit(handle, [handle.id ?? "recording started"], flags); +} + +/** + * Stop the active recording. + * @param flags - the merged flags. + */ +export async function recordingStopCommand(flags: BrowserFlags): Promise { + const box = await open(flags); + const result = await box.browser.recordings.stop(); + emit(result, [result?.id ?? "recording stopped"], flags); +} + +/** + * List recordings. + * @param flags - the merged flags. + */ +export async function recordingListCommand(flags: BrowserFlags): Promise { + const box = await open(flags); + const recordings = await box.browser.recordings.list(); + emit( + recordings, + recordings.length === 0 + ? ["No recordings."] + : recordings.map((rec) => `${rec.id}\t${rec.status ?? ""}`), + flags, + ); +} + +/** + * Show one recording. + * @param recordingId - which recording. + * @param flags - the merged flags. + */ +export async function recordingGetCommand(recordingId: string, flags: BrowserFlags): Promise { + const box = await open(flags); + const recording = await box.browser.recordings.get(recordingId); + emit(recording, [JSON.stringify(recording, undefined, 2)], flags); +} + +/** + * Download a recording to a file. + * @param recordingId - which recording. + * @param flags - the merged flags; --out names the destination. + */ +export async function recordingDownloadCommand( + recordingId: string, + flags: BrowserFlags, +): Promise { + if (!flags.out) { + throw new CliError("--out is required: video bytes cannot share stdout with text"); + } + + const box = await open(flags); + await box.browser.recordings.download(recordingId, { path: flags.out }); + emit({ id: recordingId, path: flags.out }, [`Downloaded to ${flags.out}`], flags); +} diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts new file mode 100644 index 00000000..1a112ff4 --- /dev/null +++ b/packages/cli/src/commands/config.ts @@ -0,0 +1,178 @@ +import { readFileSync } from "node:fs"; +import { Box, type CustomHarnessConfig, type NetworkPolicy } from "@upstash/box"; +import { announceBox, resolveBoxId } from "../core/box-ref.js"; +import { CliError } from "../core/errors.js"; +import { emit, requireToken, type GlobalFlags } from "../core/io.js"; + +export type ConfigFlags = GlobalFlags & { + command?: string; + args?: string[]; + allowDomain?: string[]; + allowCidr?: string[]; + denyCidr?: string[]; +}; + +async function open(flags: GlobalFlags): Promise { + const resolved = resolveBoxId({ flag: flags.box }); + announceBox(resolved); + return Box.get(resolved.id, { apiKey: requireToken(flags.token) }); +} + +/** + * Point the box's agent at a different model. + * @param model - the model identifier. + * @param flags - the merged flags. + */ +export async function configureModelCommand(model: string, flags: GlobalFlags): Promise { + const box = await open(flags); + await box.configureModel(model); + emit({ model }, [`Model set to ${model}`], flags); +} + +/** + * Point the box at a custom agent harness. + * @param flags - the merged flags; --command names the executable. + */ +export async function configureHarnessCommand(flags: ConfigFlags): Promise { + if (!flags.command) { + throw new CliError("--command is required"); + } + + const box = await open(flags); + const harness: CustomHarnessConfig = { + command: flags.command, + ...(flags.args && flags.args.length > 0 ? { args: flags.args } : {}), + }; + await box.configureCustomHarness(harness); + + emit(harness, [`Custom harness set to ${flags.command}`], flags); +} + +/** + * Show the box's init command. + * @param flags - the merged flags. + */ +export async function initCommandGetCommand(flags: GlobalFlags): Promise { + const box = await open(flags); + const initCommand = await box.getInitCommand(); + emit( + { init_command: initCommand ?? null }, + initCommand ? [String(initCommand)] : ["No init command set."], + flags, + ); +} + +/** + * Set the command the box runs when it starts. + * + * `-` reads the command from stdin, so a multi-line script does not have to + * survive the shell's quoting on the way in. + * @param command - the command, or `-` for stdin. + * @param flags - the merged flags. + */ +export async function initCommandSetCommand(command: string, flags: GlobalFlags): Promise { + const text = command === "-" ? readFileSync(0, "utf8") : command; + if (!text.trim()) throw new CliError("Init command is empty"); + + const box = await open(flags); + await box.setInitCommand(text); + emit({ init_command: text }, ["Init command set."], flags); +} + +/** + * Remove the box's init command. + * @param flags - the merged flags. + */ +export async function initCommandDeleteCommand(flags: GlobalFlags): Promise { + const box = await open(flags); + await box.deleteInitCommand(); + emit({ init_command: null }, ["Init command removed."], flags); +} + +/** + * Set the box's network policy. + * + * The modes are exclusive: `allow-all` and `deny-all` take no lists, and any + * list implies `custom`. Sending a list with a blanket mode would look like it + * narrowed the policy while doing nothing. + * @param mode - allow-all, deny-all, or custom. + * @param flags - the merged flags, with the allow and deny lists. + */ +export async function networkPolicyCommand(mode: string, flags: ConfigFlags): Promise { + const lists = + (flags.allowDomain?.length ?? 0) + + (flags.allowCidr?.length ?? 0) + + (flags.denyCidr?.length ?? 0); + + if (mode !== "allow-all" && mode !== "deny-all" && mode !== "custom") { + throw new CliError("mode must be one of: allow-all, deny-all, custom"); + } + if (mode !== "custom" && lists > 0) { + throw new CliError( + `--allow-domain, --allow-cidr and --deny-cidr only apply to 'custom', not '${mode}'`, + ); + } + if (mode === "custom" && lists === 0) { + throw new CliError("custom needs at least one of --allow-domain, --allow-cidr or --deny-cidr"); + } + + const policy: NetworkPolicy = + mode === "custom" + ? { + mode: "custom", + ...(flags.allowDomain?.length ? { allowedDomains: flags.allowDomain } : {}), + ...(flags.allowCidr?.length ? { allowedCidrs: flags.allowCidr } : {}), + ...(flags.denyCidr?.length ? { deniedCidrs: flags.denyCidr } : {}), + } + : { mode }; + + const box = await open(flags); + await box.updateNetworkPolicy(policy); + emit(policy, [`Network policy set to ${mode}`], flags); +} + +/** + * Add a skill to the box. + * @param skillId - the skill to enable. + * @param flags - the merged flags. + */ +export async function skillsAddCommand(skillId: string, flags: GlobalFlags): Promise { + const box = await open(flags); + await box.skills.add(skillId); + emit({ skill: skillId, enabled: true }, [`Added ${skillId}`], flags); +} + +/** + * Remove a skill from the box. + * @param skillId - the skill to disable. + * @param flags - the merged flags. + */ +export async function skillsRemoveCommand(skillId: string, flags: GlobalFlags): Promise { + const box = await open(flags); + await box.skills.remove(skillId); + emit({ skill: skillId, enabled: false }, [`Removed ${skillId}`], flags); +} + +/** + * List the box's enabled skills. + * @param flags - the merged flags. + */ +export async function skillsListCommand(flags: GlobalFlags): Promise { + const box = await open(flags); + const skills = await box.skills.list(); + emit(skills, skills.length === 0 ? ["No skills enabled."] : skills, flags); +} + +/** + * Resume a paused box. + * + * Every other command resumes on its own, so this exists for the case where + * you want the box warm before timing something. + * @param flags - the merged flags. + */ +export async function resumeCommand(flags: GlobalFlags): Promise { + const box = await open(flags); + await box.resume(); + const { status } = await box.getStatus(); + emit({ id: box.id, status }, [`${box.id} is ${status}`], flags); +} diff --git a/packages/cli/src/commands/exec.ts b/packages/cli/src/commands/exec.ts index d92b2cdf..833a3db1 100644 --- a/packages/cli/src/commands/exec.ts +++ b/packages/cli/src/commands/exec.ts @@ -1,8 +1,9 @@ +import { readFileSync } from "node:fs"; import { Box } from "@upstash/box"; import { announceBox, resolveBoxId } from "../core/box-ref.js"; import { CliError } from "../core/errors.js"; import { buildCommand, execCollect, execStream } from "../core/exec.js"; -import { emit, requireToken, type GlobalFlags } from "../core/io.js"; +import { emit, requireToken, timeoutMs, type GlobalFlags } from "../core/io.js"; export type ExecFlags = GlobalFlags & { cwd?: string }; @@ -53,3 +54,51 @@ export async function execCommand(parts: string[], flags: ExecFlags): Promise { + const langs = new Set(["js", "ts", "python"]); + const lang = flags.lang ?? "python"; + if (!langs.has(lang)) { + throw new CliError(`--lang must be one of: ${[...langs].join(", ")}`); + } + + const code = source === "-" ? readFileSync(0, "utf8") : source; + if (!code.trim()) throw new CliError("No code to run"); + + const timeout = timeoutMs(flags.timeout); + + const resolved = resolveBoxId({ flag: flags.box }); + announceBox(resolved); + const box = await Box.get(resolved.id, { apiKey: requireToken(flags.token) }); + + const run = await box.exec.code({ + code, + lang: lang as "js" | "ts" | "python", + ...(timeout === undefined ? {} : { timeout }), + }); + + // Same contract as `box exec`: the remote status passes through, so `box code + // ... && next` chains correctly and --json reports the real code. Claiming 0 + // would make a failing snippet look successful to both. + const exitCode = run.exitCode ?? 0; + if (flags.json) { + emit({ stdout: run.stdout, stderr: run.stderr, exit_code: exitCode }, "", flags); + } else { + if (run.stdout) + process.stdout.write(run.stdout.endsWith("\n") ? run.stdout : `${run.stdout}\n`); + if (run.stderr) + process.stderr.write(run.stderr.endsWith("\n") ? run.stderr : `${run.stderr}\n`); + } + if (exitCode !== 0) process.exitCode = exitCode; +} diff --git a/packages/cli/src/commands/from-snapshot.ts b/packages/cli/src/commands/from-snapshot.ts index 8648babf..7b3d7d4e 100644 --- a/packages/cli/src/commands/from-snapshot.ts +++ b/packages/cli/src/commands/from-snapshot.ts @@ -4,6 +4,8 @@ import { resolveToken } from "../auth.js"; import { resolveAgentApiKey } from "../agent-key.js"; import { startRepl } from "../repl/terminal.js"; import { CliError } from "../core/errors.js"; +import { writeBoxFile } from "../core/box-ref.js"; +import { emit, note } from "../core/io.js"; function resolveCliAgentHarness(harness: string | undefined): string | undefined { if (!harness) return undefined; @@ -35,6 +37,26 @@ interface FromSnapshotFlags { gitToken?: string; env?: string[]; label?: string[]; + /** false when --no-repl was passed. */ + repl?: boolean; + json?: boolean; + use?: boolean; +} + +/** + * Whether to restore without opening a REPL. + * + * Same rule as `box create`: an explicit --no-repl, --json, or the absence of a + * terminal on either stream. Without this the only way to restore a snapshot + * was a REPL that a script has nobody to drive, so listing and deleting + * snapshots was a write-only feature. + * @param flags - the flags as given. + * @returns true when the command should not open a REPL. + */ +function isHeadlessRestore(flags: FromSnapshotFlags): boolean { + if (flags.repl === false) return true; + if (flags.json) return true; + return !process.stdin.isTTY || !process.stdout.isTTY; } export async function fromSnapshotCommand( @@ -63,7 +85,9 @@ export async function fromSnapshotCommand( ); } - console.log("Creating box from snapshot..."); + const headless = isHeadlessRestore(flags); + if (!headless) console.log("Creating box from snapshot..."); + else note("Creating box from snapshot..."); const box = await Box.fromSnapshot(snapshotId, { apiKey, runtime: flags.runtime as Runtime, @@ -79,5 +103,25 @@ export async function fromSnapshotCommand( labels: flags.label && flags.label.length > 0 ? flags.label : undefined, }); - await startRepl(box); + if (!headless) { + await startRepl(box); + return; + } + + // Pin it, so the commands that follow need no --box. Same as headless create, + // including the catch: the box already exists and is billing, so losing its id + // to a read-only directory would leave it running and undiscoverable. + let pinned: string | undefined; + if (flags.use !== false) { + try { + pinned = writeBoxFile(box.id); + } catch (error) { + note(`Could not write a .box file: ${(error as Error).message}`); + } + } + emit( + { id: box.id, ...(pinned === undefined ? {} : { box_file: pinned }) }, + [box.id, ...(pinned === undefined ? [] : [`Pinned to ${pinned}`])], + flags, + ); } diff --git a/packages/cli/src/commands/run.ts b/packages/cli/src/commands/run.ts index 8668ed56..74cfa79f 100644 --- a/packages/cli/src/commands/run.ts +++ b/packages/cli/src/commands/run.ts @@ -2,10 +2,7 @@ import { readFileSync } from "node:fs"; import { Box } from "@upstash/box"; import { announceBox, resolveBoxId } from "../core/box-ref.js"; import { CliError } from "../core/errors.js"; -import { emit, note, requireToken, type GlobalFlags } from "../core/io.js"; - -/** Largest delay Node's timers accept before clamping. */ -const MAX_TIMER_MS = 2_147_483_647; +import { emit, note, requireToken, timeoutMs, type GlobalFlags } from "../core/io.js"; export type RunFlags = GlobalFlags & { timeout?: string; @@ -61,16 +58,7 @@ function toolLine(name: string, input: Record): string { */ export async function runCommandAction(parts: string[], flags: RunFlags): Promise { const prompt = promptFrom(parts); - const timeout = flags.timeout === undefined ? undefined : Number(flags.timeout); - if (timeout !== undefined && (!Number.isFinite(timeout) || timeout <= 0)) { - throw new CliError("--timeout must be a positive number of seconds"); - } - // The SDK arms this with setTimeout, and Node clamps a delay beyond its timer - // range to about a millisecond, so an out-of-range timeout would abort the - // run almost immediately instead of allowing more time. - if (timeout !== undefined && timeout * 1000 > MAX_TIMER_MS) { - throw new CliError(`--timeout must be at most ${Math.floor(MAX_TIMER_MS / 1000)} seconds`); - } + const timeout = timeoutMs(flags.timeout); const resolved = resolveBoxId({ flag: flags.box }); announceBox(resolved); @@ -78,7 +66,7 @@ export async function runCommandAction(parts: string[], flags: RunFlags): Promis const run = await box.agent.stream({ prompt, - ...(timeout === undefined ? {} : { timeout: timeout * 1000 }), + ...(timeout === undefined ? {} : { timeout }), }); let output = ""; diff --git a/packages/cli/src/commands/schedule.ts b/packages/cli/src/commands/schedule.ts new file mode 100644 index 00000000..4e06adc6 --- /dev/null +++ b/packages/cli/src/commands/schedule.ts @@ -0,0 +1,174 @@ +import { Box } from "@upstash/box"; +import { announceBox, resolveBoxId } from "../core/box-ref.js"; +import { CliError } from "../core/errors.js"; +import { emit, requireToken, timeoutMs, type GlobalFlags } from "../core/io.js"; + +export type ScheduleFlags = GlobalFlags & { + cron?: string; + folder?: string; + model?: string; + timeout?: string; + webhookUrl?: string; + prompt?: string; +}; + +async function open(flags: ScheduleFlags): Promise { + const resolved = resolveBoxId({ flag: flags.box }); + announceBox(resolved); + return Box.get(resolved.id, { apiKey: requireToken(flags.token) }); +} + +/** One line per schedule, id first so the other verbs have something to take. */ +function line(schedule: { + id: string; + type: string; + cron: string; + status: string; + command?: string[]; + prompt?: string; +}): string { + const what = schedule.command ? schedule.command.join(" ") : (schedule.prompt ?? ""); + return `${schedule.id}\t${schedule.status}\t${schedule.type}\t${schedule.cron}\t${what}`; +} + +/** + * Schedule a shell command on a cron. + * + * The command goes after `--`, same as `box exec`, so its own flags are not + * read as this command's. + * @param command - the argv to run in the box. + * @param flags - the merged flags; --cron is required. + */ +export async function scheduleExecCommand(command: string[], flags: ScheduleFlags): Promise { + if (!flags.cron) throw new CliError("--cron is required, e.g. --cron '0 9 * * *'"); + if (command.length === 0) { + throw new CliError("Nothing to schedule. Put the command after --"); + } + + const box = await open(flags); + const schedule = await box.schedule.exec({ + cron: flags.cron, + command, + ...(flags.folder === undefined ? {} : { folder: flags.folder }), + ...(flags.webhookUrl === undefined ? {} : { webhookUrl: flags.webhookUrl }), + }); + + emit(schedule, [schedule.id], flags); +} + +/** + * Schedule an agent prompt on a cron. + * @param prompt - the prompt words. + * @param flags - the merged flags; --cron is required. + */ +export async function scheduleAgentCommand(prompt: string[], flags: ScheduleFlags): Promise { + if (!flags.cron) throw new CliError("--cron is required, e.g. --cron '0 9 * * *'"); + const text = prompt.join(" ").trim(); + if (!text) throw new CliError("Nothing to schedule. Give the agent a prompt."); + + const box = await open(flags); + const timeout = timeoutMs(flags.timeout); + const schedule = await box.schedule.agent({ + cron: flags.cron, + prompt: text, + ...(flags.folder === undefined ? {} : { folder: flags.folder }), + ...(flags.model === undefined ? {} : { model: flags.model }), + ...(timeout === undefined ? {} : { timeout }), + ...(flags.webhookUrl === undefined ? {} : { webhookUrl: flags.webhookUrl }), + }); + + emit(schedule, [schedule.id], flags); +} + +/** + * List the box's schedules. + * @param flags - the merged flags. + */ +export async function scheduleListCommand(flags: ScheduleFlags): Promise { + const box = await open(flags); + const schedules = await box.schedule.list(); + emit(schedules, schedules.length === 0 ? ["No schedules."] : schedules.map(line), flags); +} + +/** + * Show one schedule, including its run counts. + * @param id - the schedule id. + * @param flags - the merged flags. + */ +export async function scheduleGetCommand(id: string, flags: ScheduleFlags): Promise { + const box = await open(flags); + const schedule = await box.schedule.get(id); + emit( + schedule, + [line(schedule), `runs: ${schedule.total_runs}, failures: ${schedule.total_failures}`], + flags, + ); +} + +/** + * Change a schedule in place. + * + * Only the fields named are sent, so an update that changes the cron leaves the + * command and the webhook alone. + * @param id - the schedule id. + * @param command - a replacement command, when given after `--`. + * @param flags - the merged flags. + */ +export async function scheduleUpdateCommand( + id: string, + command: string[], + flags: ScheduleFlags, +): Promise { + const timeout = timeoutMs(flags.timeout, { allowZero: true }); + const changes = { + ...(flags.cron === undefined ? {} : { cron: flags.cron }), + ...(timeout === undefined ? {} : { timeout }), + ...(command.length === 0 ? {} : { command }), + ...(flags.prompt === undefined ? {} : { prompt: flags.prompt }), + ...(flags.folder === undefined ? {} : { folder: flags.folder }), + ...(flags.model === undefined ? {} : { model: flags.model }), + ...(flags.webhookUrl === undefined ? {} : { webhookUrl: flags.webhookUrl }), + }; + if (Object.keys(changes).length === 0) { + throw new CliError( + "Nothing to update. Pass --cron, --prompt, --folder, --model, --timeout, --webhook-url, or a command after --", + ); + } + + const box = await open(flags); + const schedule = await box.schedule.update(id, changes); + emit(schedule, [line(schedule)], flags); +} + +/** + * Pause a schedule without deleting it. + * @param id - the schedule id. + * @param flags - the merged flags. + */ +export async function schedulePauseCommand(id: string, flags: ScheduleFlags): Promise { + const box = await open(flags); + await box.schedule.pause(id); + emit({ id, status: "paused" }, [`Paused ${id}`], flags); +} + +/** + * Resume a paused schedule. + * @param id - the schedule id. + * @param flags - the merged flags. + */ +export async function scheduleResumeCommand(id: string, flags: ScheduleFlags): Promise { + const box = await open(flags); + await box.schedule.resume(id); + emit({ id, status: "active" }, [`Resumed ${id}`], flags); +} + +/** + * Delete a schedule. + * @param id - the schedule id. + * @param flags - the merged flags. + */ +export async function scheduleDeleteCommand(id: string, flags: ScheduleFlags): Promise { + const box = await open(flags); + await box.schedule.delete(id); + emit({ id, deleted: true }, [`Deleted ${id}`], flags); +} diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index 0ce5da58..d02ae06d 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -1,6 +1,7 @@ import { Box } from "@upstash/box"; import { resolveToken } from "../auth.js"; -import { emit, note, type GlobalFlags } from "../core/io.js"; +import { emit, note, requireToken, type GlobalFlags } from "../core/io.js"; +import { announceBox, resolveBoxId } from "../core/box-ref.js"; import { interactiveSelect } from "../utils/interactive-select.js"; import { dim } from "../utils/ansi.js"; import { CliError } from "../core/errors.js"; @@ -56,3 +57,41 @@ export async function snapshotCommand( const snapshot = await box.snapshot({ name: snapshotName }); emit(snapshot, `Snapshot created: ${snapshot.id} (${snapshot.name})`, flags); } + +/** + * List the box's snapshots. + * @param flags - resolved global flags. + */ +export async function snapshotListCommand(flags: GlobalFlags): Promise { + const resolved = resolveBoxId({ flag: flags.box }); + announceBox(resolved); + + const box = await Box.get(resolved.id, { apiKey: requireToken(flags.token) }); + const snapshots = await box.listSnapshots(); + + emit( + snapshots, + snapshots.length === 0 + ? ["No snapshots."] + : snapshots.map( + (snap) => + `${snap.id}\t${snap.status}\t${Math.round(snap.size_bytes / 1024 / 1024)}MB\t${snap.name}`, + ), + flags, + ); +} + +/** + * Delete a snapshot. + * @param snapshotId - the snapshot to remove. + * @param flags - resolved global flags. + */ +export async function snapshotDeleteCommand(snapshotId: string, flags: GlobalFlags): Promise { + const resolved = resolveBoxId({ flag: flags.box }); + announceBox(resolved); + + const box = await Box.get(resolved.id, { apiKey: requireToken(flags.token) }); + await box.deleteSnapshot(snapshotId); + + emit({ id: snapshotId, deleted: true }, [`Deleted ${snapshotId}`], flags); +} diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index adb77042..2f67164b 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -1,6 +1,7 @@ import { Box } from "@upstash/box"; import { announceBox, findBoxFile, resolveBoxId } from "../core/box-ref.js"; import { emit, requireToken, type GlobalFlags } from "../core/io.js"; +import { CliError } from "../core/errors.js"; /** * Report which box is selected, where that came from, and what it is doing. @@ -40,3 +41,104 @@ export async function statusCommand(flags: GlobalFlags): Promise { flags, ); } + +/** + * Read a whole-number flag. + * + * `Number("nope")` is NaN, which serialises into the query string as `NaN` and + * is silently ignored upstream, so the caller gets a default page and no idea + * the flag was wrong. `--limit 0` is rejected for the same reason: the SDK only + * sends a limit when it is truthy, so zero would quietly return a full page. + * @param raw - the flag as given. + * @param name - the flag name, for the message. + * @param min - the smallest value the endpoint honours. + * @returns the parsed count. + * @throws CliError when it is not a non-negative whole number. + */ +function countFlag(raw: string, name: string, min: number): number { + const value = Number(raw); + if (!Number.isInteger(value) || value < min) { + throw new CliError( + min === 1 ? `${name} must be a whole number of at least 1` : `${name} must be a whole number`, + ); + } + return value; +} + +/** + * List the box's runs, most recent first. + * + * The REPL has had this since the beginning; without it a non-interactive + * caller can see that a run happened but not which one, so it has no id to + * cancel or to read logs for. + * @param flags - resolved global flags. + */ +export async function statusRunsCommand(flags: GlobalFlags): Promise { + const resolved = resolveBoxId({ flag: flags.box }); + announceBox(resolved); + + const box = await Box.get(resolved.id, { apiKey: requireToken(flags.token) }); + const runs = await box.listRuns(); + + emit( + runs, + runs.length === 0 + ? ["No runs yet."] + : runs.map( + (run) => + `${run.id}\t${run.type}\t${run.status ?? ""}\t${Math.round(run.duration_ms / 1000)}s\t$${run.cost_usd.toFixed(4)}`, + ), + flags, + ); +} + +/** + * Print the box's log lines. + * @param flags - resolved global flags, plus paging. + */ +export async function statusLogsCommand( + flags: GlobalFlags & { limit?: string; offset?: string }, +): Promise { + const resolved = resolveBoxId({ flag: flags.box }); + announceBox(resolved); + + const box = await Box.get(resolved.id, { apiKey: requireToken(flags.token) }); + const logs = await box.logs({ + ...(flags.limit === undefined ? {} : { limit: countFlag(flags.limit, "--limit", 1) }), + ...(flags.offset === undefined ? {} : { offset: countFlag(flags.offset, "--offset", 0) }), + }); + + emit( + logs, + logs.length === 0 + ? ["No logs."] + : logs.map( + (entry) => + `${new Date(entry.timestamp * 1000).toISOString()}\t${entry.level}\t${entry.source}\t${entry.message}`, + ), + flags, + ); +} + +/** + * Cancel a run by id. + * + * An agent that starts a long run has no other way to stop it: the object with + * `.cancel()` on it lives in the process that started the run, which has + * usually exited by the time anyone wants it stopped. + * @param runId - the run to cancel, from `box status runs`. + * @param flags - resolved global flags. + */ +export async function cancelCommand(runId: string, flags: GlobalFlags): Promise { + const resolved = resolveBoxId({ flag: flags.box }); + announceBox(resolved); + + const box = await Box.get(resolved.id, { apiKey: requireToken(flags.token) }); + // The SDK has no cancel-by-id: Run.cancel() lives on the object the original + // call returned, which this process never holds. Going through the box's own + // request method keeps the base URL, auth and timeouts in one place rather + // than rebuilding them here. + await box._request("POST", `/v2/box/${box.id}/runs/${runId}/cancel`); + + emit({ run_id: runId, cancelled: true }, [`Cancelled ${runId}`], flags); +} diff --git a/packages/cli/src/core/io.ts b/packages/cli/src/core/io.ts index 037af68f..9a2dd48a 100644 --- a/packages/cli/src/core/io.ts +++ b/packages/cli/src/core/io.ts @@ -84,3 +84,38 @@ export function requireToken(flagToken?: string): string { } return token; } + +/** Largest delay Node's timers accept before clamping. */ +const MAX_TIMER_MS = 2_147_483_647; + +/** + * Convert a `--timeout` flag from seconds to the milliseconds the SDK takes. + * + * Every timeout flag is documented in seconds and every SDK option is in + * milliseconds, so a caller that passes the number straight through asks for a + * 30ms limit when it said 30 seconds, and the work is killed before it starts. + * @param value - the raw flag, when given. + * @param options - allowZero accepts 0, which clears a value rather than setting one. + * @returns the timeout in milliseconds, or undefined when unset. + * @throws CliError when the value is not a usable number of seconds. + */ +export function timeoutMs( + value: string | undefined, + options: { allowZero?: boolean } = {}, +): number | undefined { + if (value === undefined) return undefined; + + const seconds = Number(value); + // 0 clears a schedule's timeout, which only makes sense on an update: at + // creation it would ask for a run that is out of time before it starts. + if (options.allowZero && seconds === 0) return 0; + if (!Number.isFinite(seconds) || seconds <= 0) { + throw new CliError("--timeout must be a positive number of seconds"); + } + // Node clamps anything past this to about a millisecond, which would abort + // the run immediately rather than after the long wait that was asked for. + if (seconds * 1000 > MAX_TIMER_MS) { + throw new CliError(`--timeout must be at most ${Math.floor(MAX_TIMER_MS / 1000)} seconds`); + } + return seconds * 1000; +}