From 2f065b093147d5e4b6b9838b0188e75c27de1f3d Mon Sep 17 00:00:00 2001 From: Mattias Lundell Date: Fri, 12 Jun 2026 10:01:58 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(apps):=20opper=20apps=20=E2=80=94=20de?= =?UTF-8?q?ploy=20and=20manage=20agents=20as=20managed=20apps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `opper apps` command group against the /v3/apps surface (Opper Apps — agent source in, running app out; the platform UI is a viewer, the CLI is the creation path): opper apps list | get | delete opper apps create [--name n] [--dir .|--repo [--ref r]] [--config json] opper apps redeploy --dir . opper apps logs # SSE tail opper apps run --input "…" opper apps secrets list|set|delete [KEY] [VALUE] - create accepts a local directory (tarred with the system tar, excluding .git/node_modules/__pycache__/.venv) or a git URL that is shallow-cloned and deployed; --name is optional when the source's opper.yaml manifest declares one. - OpperApi gains postMultipart (FormData upload) and streamGet (SSE-over-GET for logs), sharing the existing SSE parser. - New INVALID_ARGUMENT error code (exit 8) for bad local input. Verified live against the evroc deployment: list/get/run/secrets all round-trip; `apps run hermes` answered through the new command. Co-Authored-By: Claude Fable 5 --- src/api/client.ts | 25 +++ src/cli/apps.ts | 132 ++++++++++++++++ src/cli/help.ts | 2 +- src/commands/apps.ts | 315 +++++++++++++++++++++++++++++++++++++ src/errors.ts | 2 + src/index.ts | 2 + src/util/git-clone.ts | 38 +++++ src/util/tarball.ts | 50 ++++++ test/commands/apps.test.ts | 213 +++++++++++++++++++++++++ 9 files changed, 778 insertions(+), 1 deletion(-) create mode 100644 src/cli/apps.ts create mode 100644 src/commands/apps.ts create mode 100644 src/util/git-clone.ts create mode 100644 src/util/tarball.ts create mode 100644 test/commands/apps.test.ts diff --git a/src/api/client.ts b/src/api/client.ts index c3924c5..04acb72 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -40,6 +40,17 @@ export class OpperApi { return this.parseJson(res); } + async postMultipart(path: string, form: FormData): Promise { + const url = this.buildUrl(path); + // No Content-Type header — fetch derives the multipart boundary. + const res = await this.fetch(url, { + method: "POST", + headers: this.headers(), + body: form, + }); + return this.parseJson(res); + } + async del(path: string): Promise { const url = this.buildUrl(path); const res = await this.fetch(url, { method: "DELETE", headers: this.headers() }); @@ -57,6 +68,20 @@ export class OpperApi { }), body: JSON.stringify(body), }); + yield* this.readSse(res); + } + + /** SSE over GET — long-lived server push streams (e.g. app logs). */ + async *streamGet(path: string): AsyncIterable { + const url = this.buildUrl(path); + const res = await this.fetch(url, { + method: "GET", + headers: this.headers({ Accept: "text/event-stream" }), + }); + yield* this.readSse(res); + } + + private async *readSse(res: Response): AsyncIterable { if (!res.ok) await this.throwApiError(res); if (!res.body) return; diff --git a/src/cli/apps.ts b/src/cli/apps.ts new file mode 100644 index 0000000..c5c83c1 --- /dev/null +++ b/src/cli/apps.ts @@ -0,0 +1,132 @@ +import type { Command } from "commander"; +import type { CliContext, RegisterFn } from "./types.js"; +import { + appsListCommand, + appsGetCommand, + appsCreateCommand, + appsRedeployCommand, + appsDeleteCommand, + appsLogsCommand, + appsRunCommand, + appsSecretsListCommand, + appsSecretsSetCommand, + appsSecretsDeleteCommand, +} from "../commands/apps.js"; + +// `opper apps` — deploy agent source as a managed app (Opper Apps). +// The app spec (resources, scaling, description) lives in an opper.yaml +// next to the source, fly.io-style. +const register: RegisterFn = (program: Command, ctx: CliContext) => { + const apps = program + .command("apps") + .description("Deploy and manage agents as managed apps"); + + apps + .command("list") + .description("List apps in the project") + .action(async () => { + await appsListCommand({ key: ctx.key() }); + }); + + apps + .command("get") + .description("Show app status, size, and invoke URL") + .argument("", "app name") + .action(async (name: string) => { + await appsGetCommand({ name, key: ctx.key() }); + }); + + apps + .command("create") + .description("Upload source (or clone a repo) and deploy a new app") + .option("--name ", "app name; defaults to `name:` from opper.yaml") + .option("--dir ", "source directory", ".") + .option("--repo ", "git repo to clone and deploy (instead of --dir)") + .option("--ref ", "branch or tag to clone (with --repo)") + .option("--config ", 'config overrides, e.g. {"cpu":1,"memory":2048}') + .action( + async (cmdOpts: { + name?: string; + dir: string; + repo?: string; + ref?: string; + config?: string; + }) => { + await appsCreateCommand({ + ...(cmdOpts.name ? { name: cmdOpts.name } : {}), + dir: cmdOpts.dir, + ...(cmdOpts.repo ? { repo: cmdOpts.repo } : {}), + ...(cmdOpts.ref ? { ref: cmdOpts.ref } : {}), + ...(cmdOpts.config ? { config: cmdOpts.config } : {}), + key: ctx.key(), + }); + }, + ); + + apps + .command("redeploy") + .description("Upload new source and roll the app") + .argument("", "app name") + .option("--dir ", "source directory", ".") + .action(async (name: string, cmdOpts: { dir: string }) => { + await appsRedeployCommand({ name, dir: cmdOpts.dir, key: ctx.key() }); + }); + + apps + .command("delete") + .description("Stop and remove an app") + .argument("", "app name") + .action(async (name: string) => { + await appsDeleteCommand({ name, key: ctx.key() }); + }); + + apps + .command("logs") + .description("Stream app logs (Ctrl+C to stop)") + .argument("", "app name") + .action(async (name: string) => { + await appsLogsCommand({ name, key: ctx.key() }); + }); + + apps + .command("run") + .description("Invoke the app once") + .argument("", "app name") + .requiredOption("--input ", "input passed to the agent") + .action(async (name: string, cmdOpts: { input: string }) => { + await appsRunCommand({ name, input: cmdOpts.input, key: ctx.key() }); + }); + + const secrets = apps + .command("secrets") + .description("Manage encrypted app secrets (applied on redeploy)"); + + secrets + .command("list") + .description("List secret names") + .argument("", "app name") + .action(async (app: string) => { + await appsSecretsListCommand({ app, key: ctx.key() }); + }); + + secrets + .command("set") + .description("Set or update a secret") + .argument("", "app name") + .argument("", "secret name, e.g. OPPER_API_KEY") + .argument("", "secret value") + .action(async (app: string, name: string, value: string) => { + await appsSecretsSetCommand({ app, name, value, key: ctx.key() }); + }); + + secrets + .command("delete") + .description("Delete a secret") + .argument("", "app name") + .argument("", "secret name") + .action(async (app: string, name: string) => { + await appsSecretsDeleteCommand({ app, name, key: ctx.key() }); + }); +}; + +export default register; diff --git a/src/cli/help.ts b/src/cli/help.ts index 0b71e38..8dd9ee2 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -24,7 +24,7 @@ const GROUPS: HelpGroup[] = [ }, { title: "Agents", - commands: ["agents", "launch"], + commands: ["agents", "launch", "apps"], }, { title: "Platform", diff --git a/src/commands/apps.ts b/src/commands/apps.ts new file mode 100644 index 0000000..94174fc --- /dev/null +++ b/src/commands/apps.ts @@ -0,0 +1,315 @@ +import { readFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { parse as parseYaml } from "yaml"; +import { OpperApi } from "../api/client.js"; +import { resolveApiContext } from "../api/resolve.js"; +import { OpperError } from "../errors.js"; +import { brand } from "../ui/colors.js"; +import { printTable } from "../ui/table.js"; +import { cloneRepo } from "../util/git-clone.js"; +import { makeTarball } from "../util/tarball.js"; + +// Opper Apps — deploy agent source as a managed app (the deploy/Knative +// backend). The customer surface is task-api's /v3/apps/*; creation is +// CLI-only by design (the platform UI is a viewer). + +interface AppResponse { + id: string; + name: string; + description?: string; + status: string; + endpoint_url?: string; + runtime?: string; + config?: { cpu?: number; memory?: number; timeout?: number }; + created_at?: string; + updated_at?: string; + deploy_token?: string; +} + +interface ListResponse { + data: AppResponse[]; +} + +function invokeUrl(baseUrl: string, name: string): string { + return `${baseUrl.replace(/\/$/, "")}/v3/apps/${encodeURIComponent(name)}/run`; +} + +// ---- list ----------------------------------------------------------------- + +export interface AppsListOptions { + key: string; +} + +export async function appsListCommand(opts: AppsListOptions): Promise { + const ctx = await resolveApiContext(opts.key); + const api = new OpperApi(ctx); + const resp = await api.get("/v3/apps"); + const apps = resp.data ?? []; + if (apps.length === 0) { + console.log( + `No apps yet. Deploy one with ${brand.bold("opper apps create --name my-agent --dir .")}`, + ); + return; + } + const rows = apps.map((a) => [ + a.name, + a.status, + a.runtime ?? "", + a.config ? `${a.config.cpu ?? "?"} vCPU / ${a.config.memory ?? "?"} MiB` : "", + a.created_at ?? "", + ]); + printTable(["NAME", "STATUS", "RUNTIME", "RESOURCES", "CREATED"], rows); +} + +// ---- get ------------------------------------------------------------------ + +export interface AppsGetOptions { + name: string; + key: string; +} + +export async function appsGetCommand(opts: AppsGetOptions): Promise { + const ctx = await resolveApiContext(opts.key); + const api = new OpperApi(ctx); + const a = await api.get( + `/v3/apps/${encodeURIComponent(opts.name)}`, + ); + console.log(`${brand.bold("name:")} ${a.name}`); + if (a.description) console.log(`${brand.bold("about:")} ${a.description}`); + console.log(`${brand.bold("status:")} ${a.status}`); + if (a.runtime) console.log(`${brand.bold("runtime:")} ${a.runtime}`); + if (a.config) { + console.log( + `${brand.bold("size:")} ${a.config.cpu} vCPU / ${a.config.memory} MiB / ${a.config.timeout}s timeout`, + ); + } + console.log(`${brand.bold("invoke:")} POST ${invokeUrl(ctx.baseUrl, a.name)}`); + if (a.created_at) console.log(`${brand.bold("created:")} ${a.created_at}`); + console.log(`${brand.bold("id:")} ${a.id}`); +} + +// ---- create / redeploy ------------------------------------------------------ + +export interface AppsCreateOptions { + name?: string; + dir?: string; + repo?: string; + ref?: string; + config?: string; + key: string; +} + +/** Reads the app name from the source's opper.yaml (fly.io-style manifest). */ +async function manifestName(dir: string): Promise { + for (const f of ["opper.yaml", "opper.yml"]) { + const p = join(dir, f); + if (!existsSync(p)) continue; + try { + const m = parseYaml(await readFile(p, "utf8")) as { name?: unknown }; + if (typeof m?.name === "string" && m.name.trim()) return m.name.trim(); + } catch { + // Malformed manifest — the deploy service will reject the build with + // a precise error; don't duplicate validation here. + } + } + return undefined; +} + +async function sourceForm(dir: string, extra: Record): Promise { + const { path, cleanup } = await makeTarball(dir); + try { + const bytes = await readFile(path); + const form = new FormData(); + for (const [k, v] of Object.entries(extra)) form.set(k, v); + form.set( + "source", + new Blob([new Uint8Array(bytes)], { type: "application/gzip" }), + "src.tar.gz", + ); + return form; + } finally { + await cleanup(); + } +} + +export async function appsCreateCommand(opts: AppsCreateOptions): Promise { + const ctx = await resolveApiContext(opts.key); + const api = new OpperApi(ctx); + + // Source: a local directory (default ".") or a git repo to shallow-clone. + let srcDir = opts.dir ?? "."; + let cleanupClone: (() => Promise) | undefined; + if (opts.repo) { + const cloned = await cloneRepo(opts.repo, opts.ref); + srcDir = cloned.dir; + cleanupClone = cloned.cleanup; + console.log(brand.dim(`cloned ${opts.repo}${opts.ref ? `@${opts.ref}` : ""}`)); + } + + try { + // Name: explicit flag wins; otherwise the source's opper.yaml. + const name = opts.name ?? (await manifestName(srcDir)); + if (!name) { + throw new OpperError( + "INVALID_ARGUMENT", + "No app name given and the source has no opper.yaml with a name", + "Pass --name, or add `name: my-agent` to the opper.yaml manifest.", + ); + } + + const form = await sourceForm(srcDir, { + name, + ...(opts.config ? { config: opts.config } : {}), + }); + const a = await api.postMultipart("/v3/apps", form); + console.log(`${brand.bold(a.name)} ${a.status}`); + if (a.deploy_token) { + console.log( + `${brand.bold("deploy token:")} ${a.deploy_token} ${brand.dim("(shown once — store it now)")}`, + ); + } + console.log( + brand.dim(`build started — follow with: opper apps logs ${a.name}`), + ); + console.log(`${brand.bold("invoke:")} POST ${invokeUrl(ctx.baseUrl, a.name)}`); + } finally { + await cleanupClone?.(); + } +} + +export interface AppsRedeployOptions { + name: string; + dir: string; + key: string; +} + +export async function appsRedeployCommand( + opts: AppsRedeployOptions, +): Promise { + const ctx = await resolveApiContext(opts.key); + const api = new OpperApi(ctx); + const form = await sourceForm(opts.dir, {}); + const a = await api.postMultipart( + `/v3/apps/${encodeURIComponent(opts.name)}/redeploy`, + form, + ); + console.log(`${brand.bold(a.name)} ${a.status} ${brand.dim("(rebuilding)")}`); +} + +// ---- delete ----------------------------------------------------------------- + +export interface AppsDeleteOptions { + name: string; + key: string; +} + +export async function appsDeleteCommand(opts: AppsDeleteOptions): Promise { + const ctx = await resolveApiContext(opts.key); + const api = new OpperApi(ctx); + await api.del(`/v3/apps/${encodeURIComponent(opts.name)}`); + console.log(`${brand.bold(opts.name)} deleted`); +} + +// ---- logs ------------------------------------------------------------------- + +export interface AppsLogsOptions { + name: string; + key: string; +} + +export async function appsLogsCommand(opts: AppsLogsOptions): Promise { + const ctx = await resolveApiContext(opts.key); + const api = new OpperApi(ctx); + for await (const line of api.streamGet( + `/v3/apps/${encodeURIComponent(opts.name)}/logs`, + )) { + console.log(line); + } +} + +// ---- run -------------------------------------------------------------------- + +export interface AppsRunOptions { + name: string; + input: string; + key: string; +} + +export async function appsRunCommand(opts: AppsRunOptions): Promise { + const ctx = await resolveApiContext(opts.key); + const api = new OpperApi(ctx); + const resp = await api.post<{ data?: unknown }>( + `/v3/apps/${encodeURIComponent(opts.name)}/run`, + { input: opts.input }, + ); + const out = resp.data; + if (typeof out === "string") console.log(out); + else console.log(JSON.stringify(resp, null, 2)); +} + +// ---- secrets ------------------------------------------------------------------ + +export interface AppsSecretsListOptions { + app: string; + key: string; +} + +export async function appsSecretsListCommand( + opts: AppsSecretsListOptions, +): Promise { + const ctx = await resolveApiContext(opts.key); + const api = new OpperApi(ctx); + const resp = await api.get<{ data: Array<{ name: string; updated_at?: string }> }>( + `/v3/apps/${encodeURIComponent(opts.app)}/secrets`, + ); + const secrets = resp.data ?? []; + if (secrets.length === 0) { + console.log("No secrets set."); + return; + } + printTable( + ["NAME", "UPDATED"], + secrets.map((s) => [s.name, s.updated_at ?? ""]), + ); +} + +export interface AppsSecretsSetOptions { + app: string; + name: string; + value: string; + key: string; +} + +export async function appsSecretsSetCommand( + opts: AppsSecretsSetOptions, +): Promise { + const ctx = await resolveApiContext(opts.key); + const api = new OpperApi(ctx); + await api.post(`/v3/apps/${encodeURIComponent(opts.app)}/secrets`, { + name: opts.name, + value: opts.value, + }); + console.log( + `${brand.bold(opts.name)} set on ${opts.app} ${brand.dim("(applies on next redeploy)")}`, + ); +} + +export interface AppsSecretsDeleteOptions { + app: string; + name: string; + key: string; +} + +export async function appsSecretsDeleteCommand( + opts: AppsSecretsDeleteOptions, +): Promise { + const ctx = await resolveApiContext(opts.key); + const api = new OpperApi(ctx); + await api.del( + `/v3/apps/${encodeURIComponent(opts.app)}/secrets/${encodeURIComponent(opts.name)}`, + ); + console.log( + `${brand.bold(opts.name)} deleted from ${opts.app} ${brand.dim("(applies on next redeploy)")}`, + ); +} diff --git a/src/errors.ts b/src/errors.ts index 1471871..4a5fd6c 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -6,6 +6,7 @@ export type OpperErrorCode = | "AGENT_RESTORE_FAILED" | "API_ERROR" | "NETWORK_ERROR" + | "INVALID_ARGUMENT" | "USER_CANCELLED"; export const EXIT_CODES: Record = { @@ -16,6 +17,7 @@ export const EXIT_CODES: Record = { AGENT_RESTORE_FAILED: 5, API_ERROR: 6, NETWORK_ERROR: 7, + INVALID_ARGUMENT: 8, USER_CANCELLED: 0, }; diff --git a/src/index.ts b/src/index.ts index 6c570a0..7fa3994 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import registerSkills from "./cli/skills.js"; import registerEditors from "./cli/editors.js"; import registerAgents from "./cli/agents.js"; import registerPlatform from "./cli/platform.js"; +import registerApps from "./cli/apps.js"; import registerAsk from "./cli/ask.js"; import { addGroupedHelpText } from "./cli/help.js"; import { checkForUpdate } from "./util/update-check.js"; @@ -72,6 +73,7 @@ const registrars: RegisterFn[] = [ registerSkills, registerEditors, registerAgents, + registerApps, registerPlatform, ]; for (const register of registrars) register(program, ctx); diff --git a/src/util/git-clone.ts b/src/util/git-clone.ts new file mode 100644 index 0000000..3c2356a --- /dev/null +++ b/src/util/git-clone.ts @@ -0,0 +1,38 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { run } from "./run.js"; +import { OpperError } from "../errors.js"; + +/** + * Shallow-clones a git repo (optionally a branch/tag) into a fresh temp + * directory using the system `git`. Returns the checkout path and a + * cleanup function. + */ +export async function cloneRepo( + url: string, + ref?: string, +): Promise<{ dir: string; cleanup: () => Promise }> { + const tmp = await mkdtemp(join(tmpdir(), "opper-clone-")); + const args = [ + "clone", + "--depth", + "1", + ...(ref ? ["--branch", ref] : []), + url, + tmp, + ]; + const result = run("git", args); + if (result.code !== 0) { + await rm(tmp, { recursive: true, force: true }); + throw new OpperError( + "INVALID_ARGUMENT", + `git clone failed (exit ${result.code}): ${result.stderr.trim().split("\n").pop() ?? ""}`, + "Check the URL (and --ref branch/tag) and that you have access to the repository.", + ); + } + return { + dir: tmp, + cleanup: () => rm(tmp, { recursive: true, force: true }), + }; +} diff --git a/src/util/tarball.ts b/src/util/tarball.ts new file mode 100644 index 0000000..c4978bd --- /dev/null +++ b/src/util/tarball.ts @@ -0,0 +1,50 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { existsSync } from "node:fs"; +import { run } from "./run.js"; +import { OpperError } from "../errors.js"; + +// Directories that never belong in an app source upload. Mirrors the Go +// deploy CLI's exclusions (plus .venv variants pip/uv create). +const EXCLUDES = [".git", "node_modules", "__pycache__", ".venv", "venv"]; + +/** + * Packs `dir` into a gzipped tarball in a fresh temp directory using the + * system `tar` (BSD and GNU both accept this flag shape). Returns the + * tarball path and a cleanup function that removes the temp dir. + */ +export async function makeTarball( + dir: string, +): Promise<{ path: string; cleanup: () => Promise }> { + if (!existsSync(dir)) { + throw new OpperError( + "INVALID_ARGUMENT", + `Source directory not found: ${dir}`, + "Pass the directory containing your agent source via --dir.", + ); + } + const tmp = await mkdtemp(join(tmpdir(), "opper-app-")); + const out = join(tmp, "src.tar.gz"); + const args = [ + "-czf", + out, + ...EXCLUDES.flatMap((e) => ["--exclude", e]), + "-C", + dir, + ".", + ]; + const result = run("tar", args); + if (result.code !== 0) { + await rm(tmp, { recursive: true, force: true }); + throw new OpperError( + "INVALID_ARGUMENT", + `tar failed (exit ${result.code}): ${result.stderr.trim()}`, + "Check the directory is readable and `tar` is on PATH.", + ); + } + return { + path: out, + cleanup: () => rm(tmp, { recursive: true, force: true }), + }; +} diff --git a/test/commands/apps.test.ts b/test/commands/apps.test.ts new file mode 100644 index 0000000..bbeb3b5 --- /dev/null +++ b/test/commands/apps.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { useTempOpperHome } from "../helpers/temp-home.js"; +import { setSlot } from "../../src/auth/config.js"; + +const getMock = vi.fn(); +const postMock = vi.fn(); +const postMultipartMock = vi.fn(); +const delMock = vi.fn(); +vi.mock("../../src/api/client.js", () => ({ + OpperApi: vi.fn().mockImplementation(() => ({ + get: getMock, + post: postMock, + postMultipart: postMultipartMock, + del: delMock, + })), +})); + +const { + appsListCommand, + appsGetCommand, + appsCreateCommand, + appsRunCommand, + appsSecretsSetCommand, + appsDeleteCommand, +} = await import("../../src/commands/apps.js"); + +useTempOpperHome(); + +function captureLog() { + return vi.spyOn(console, "log").mockImplementation(() => {}); +} + +describe("apps list + get", () => { + beforeEach(() => { + getMock.mockReset(); + }); + + it("list prints a table from /v3/apps", async () => { + await setSlot("default", { apiKey: "k" }); + getMock.mockResolvedValue({ + data: [ + { + id: "a1", + name: "hermes", + status: "running", + runtime: "python", + config: { cpu: 1, memory: 3072 }, + created_at: "2026-06-11T00:00:00Z", + }, + ], + }); + const log = captureLog(); + try { + await appsListCommand({ key: "default" }); + expect(getMock).toHaveBeenCalledWith("/v3/apps"); + const out = log.mock.calls.map((c) => String(c[0])).join("\n"); + expect(out).toContain("hermes"); + expect(out).toContain("running"); + expect(out).toContain("1 vCPU / 3072 MiB"); + } finally { + log.mockRestore(); + } + }); + + it("get prints details and the public invoke URL", async () => { + await setSlot("default", { apiKey: "k", baseUrl: "https://api.example.test" }); + getMock.mockResolvedValue({ + id: "a1", + name: "hermes", + description: "The agent that grows with you", + status: "running", + config: { cpu: 1, memory: 3072, timeout: 900 }, + }); + const log = captureLog(); + try { + await appsGetCommand({ name: "hermes", key: "default" }); + const out = log.mock.calls.map((c) => String(c[0])).join("\n"); + expect(out).toContain("The agent that grows with you"); + expect(out).toContain("POST https://api.example.test/v3/apps/hermes/run"); + } finally { + log.mockRestore(); + } + }); +}); + +describe("apps create", () => { + beforeEach(() => { + postMultipartMock.mockReset(); + }); + + it("tars the directory and posts multipart with the flag name", async () => { + await setSlot("default", { apiKey: "k" }); + const dir = await mkdtemp(join(tmpdir(), "opper-app-src-")); + try { + await writeFile(join(dir, "agent.py"), "import opper_agents\n"); + postMultipartMock.mockResolvedValue({ + id: "a2", + name: "my-agent", + status: "pending", + deploy_token: "tok", + }); + const log = captureLog(); + try { + await appsCreateCommand({ name: "my-agent", dir, key: "default" }); + } finally { + log.mockRestore(); + } + expect(postMultipartMock).toHaveBeenCalledTimes(1); + const [path, form] = postMultipartMock.mock.calls[0] as [string, FormData]; + expect(path).toBe("/v3/apps"); + expect(form.get("name")).toBe("my-agent"); + const source = form.get("source") as Blob; + expect(source).toBeInstanceOf(Blob); + expect(source.size).toBeGreaterThan(0); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("falls back to the opper.yaml name when --name is omitted", async () => { + await setSlot("default", { apiKey: "k" }); + const dir = await mkdtemp(join(tmpdir(), "opper-app-src-")); + try { + await writeFile(join(dir, "opper.yaml"), "name: manifest-agent\ncpu: 1\n"); + await writeFile(join(dir, "agent.py"), "import opper_agents\n"); + postMultipartMock.mockResolvedValue({ + id: "a3", + name: "manifest-agent", + status: "pending", + }); + const log = captureLog(); + try { + await appsCreateCommand({ dir, key: "default" }); + } finally { + log.mockRestore(); + } + const [, form] = postMultipartMock.mock.calls[0] as [string, FormData]; + expect(form.get("name")).toBe("manifest-agent"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("errors when no name is available anywhere", async () => { + await setSlot("default", { apiKey: "k" }); + const dir = await mkdtemp(join(tmpdir(), "opper-app-src-")); + try { + await expect( + appsCreateCommand({ dir, key: "default" }), + ).rejects.toMatchObject({ code: "INVALID_ARGUMENT" }); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + +describe("apps run / secrets / delete", () => { + beforeEach(() => { + postMock.mockReset(); + delMock.mockReset(); + }); + + it("run posts {input} and prints the data string", async () => { + await setSlot("default", { apiKey: "k" }); + postMock.mockResolvedValue({ data: "Hello, Opper team!" }); + const log = captureLog(); + try { + await appsRunCommand({ name: "hermes", input: "hi", key: "default" }); + expect(postMock).toHaveBeenCalledWith("/v3/apps/hermes/run", { + input: "hi", + }); + const out = log.mock.calls.map((c) => String(c[0])).join("\n"); + expect(out).toContain("Hello, Opper team!"); + } finally { + log.mockRestore(); + } + }); + + it("secrets set posts name+value", async () => { + await setSlot("default", { apiKey: "k" }); + postMock.mockResolvedValue({}); + const log = captureLog(); + try { + await appsSecretsSetCommand({ + app: "hermes", + name: "OPPER_API_KEY", + value: "v", + key: "default", + }); + expect(postMock).toHaveBeenCalledWith("/v3/apps/hermes/secrets", { + name: "OPPER_API_KEY", + value: "v", + }); + } finally { + log.mockRestore(); + } + }); + + it("delete calls DELETE /v3/apps/{name}", async () => { + await setSlot("default", { apiKey: "k" }); + delMock.mockResolvedValue(undefined); + const log = captureLog(); + try { + await appsDeleteCommand({ name: "hermes", key: "default" }); + expect(delMock).toHaveBeenCalledWith("/v3/apps/hermes"); + } finally { + log.mockRestore(); + } + }); +}); From 618a0e28fecc5a27bea578b6e19eeec4e9190602 Mon Sep 17 00:00:00 2001 From: Mattias Lundell Date: Mon, 15 Jun 2026 12:26:13 +0200 Subject: [PATCH 2/4] =?UTF-8?q?feat(apps):=20opper=20apps=20shell=20?= =?UTF-8?q?=E2=80=94=20interactive=20terminal=20in=20the=20running=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `opper apps shell ` opens a real PTY in the app's container over the app's /run/shell WebSocket (the in-pod ttyd, proxied by the wrapper), through the normal API-key-authed chain — no SSH, no cluster access. It puts stdin in raw mode so Ctrl-C/Ctrl-D/arrows reach the remote shell, forwards SIGWINCH as ttyd resize frames, and restores the terminal on exit. Uses the `ws` client (Node 20 floor has no global WebSocket). Verified against a ttyd-enabled image: connects, runs a command, streams output back. Needs the deploy-side terminal (opper#2954) and the task-api websocket proxy (opper#2955) deployed to work end to end. Co-Authored-By: Claude Fable 5 --- package-lock.json | 76 ++++++++++++------------- package.json | 2 + src/cli/apps.ts | 9 +++ src/commands/apps-shell.ts | 114 +++++++++++++++++++++++++++++++++++++ 4 files changed, 162 insertions(+), 39 deletions(-) create mode 100644 src/commands/apps-shell.ts diff --git a/package-lock.json b/package-lock.json index 3f50532..ddd6959 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@opperai/login": "^0.4.0", "commander": "^12.1.0", "kleur": "^4.1.5", + "ws": "^8.21.0", "yaml": "^2.8.2", "zod": "^4.3.6" }, @@ -22,6 +23,7 @@ }, "devDependencies": { "@types/node": "^20.11.0", + "@types/ws": "^8.18.1", "tsx": "^4.19.0", "typescript": "^5.6.0", "vitest": "^2.1.0" @@ -676,9 +678,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -693,9 +692,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -710,9 +706,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -727,9 +720,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -744,9 +734,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -761,9 +748,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -778,9 +762,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -795,9 +776,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -812,9 +790,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -829,9 +804,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -846,9 +818,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -863,9 +832,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -880,9 +846,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -990,6 +953,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitest/expect": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", @@ -1549,6 +1522,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -1805,6 +1779,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.15.tgz", "integrity": "sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -2627,6 +2602,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -3238,6 +3214,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yaml": { "version": "2.8.3", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", @@ -3258,6 +3255,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 31ce9fd..ae89f29 100644 --- a/package.json +++ b/package.json @@ -29,11 +29,13 @@ "@opperai/login": "^0.4.0", "commander": "^12.1.0", "kleur": "^4.1.5", + "ws": "^8.21.0", "yaml": "^2.8.2", "zod": "^4.3.6" }, "devDependencies": { "@types/node": "^20.11.0", + "@types/ws": "^8.18.1", "tsx": "^4.19.0", "typescript": "^5.6.0", "vitest": "^2.1.0" diff --git a/src/cli/apps.ts b/src/cli/apps.ts index c5c83c1..6bb4654 100644 --- a/src/cli/apps.ts +++ b/src/cli/apps.ts @@ -1,5 +1,6 @@ import type { Command } from "commander"; import type { CliContext, RegisterFn } from "./types.js"; +import { appsShellCommand } from "../commands/apps-shell.js"; import { appsListCommand, appsGetCommand, @@ -97,6 +98,14 @@ const register: RegisterFn = (program: Command, ctx: CliContext) => { await appsRunCommand({ name, input: cmdOpts.input, key: ctx.key() }); }); + apps + .command("shell") + .description("Open an interactive terminal in the running app") + .argument("", "app name") + .action(async (name: string) => { + await appsShellCommand({ name, key: ctx.key() }); + }); + const secrets = apps .command("secrets") .description("Manage encrypted app secrets (applied on redeploy)"); diff --git a/src/commands/apps-shell.ts b/src/commands/apps-shell.ts new file mode 100644 index 0000000..a505480 --- /dev/null +++ b/src/commands/apps-shell.ts @@ -0,0 +1,114 @@ +import WebSocket from "ws"; + +import { resolveApiContext } from "../api/resolve.js"; +import { brand } from "../ui/colors.js"; +import { OpperError } from "../errors.js"; + +// ttyd wire protocol (the app's in-pod web terminal). Server→client and +// client→server messages are a single command byte followed by payload. +const OUTPUT = 0x30; // '0' server→client: terminal bytes +const INPUT = "0"; // client→server: keystrokes +const RESIZE = "1"; // client→server: {columns, rows} + +export interface AppsShellOptions { + name: string; + key: string; +} + +// appsShellCommand opens an interactive terminal in the running app's +// container. It connects to the app's /run/shell WebSocket (ttyd, proxied +// by the wrapper) through the normal API-key-authed chain — no SSH, no +// cluster access. stdin goes raw so keystrokes (incl. Ctrl-C/Ctrl-D) reach +// the remote shell rather than this process. +export async function appsShellCommand(opts: AppsShellOptions): Promise { + const ctx = await resolveApiContext(opts.key); + const wsURL = + ctx.baseUrl.replace(/^http/, "ws").replace(/\/+$/, "") + + `/v3/apps/${encodeURIComponent(opts.name)}/run/shell/ws`; + + const stdin = process.stdin; + const stdout = process.stdout; + if (!stdin.isTTY) { + throw new OpperError( + "INVALID_ARGUMENT", + "opper apps shell needs an interactive terminal", + "Run it directly in your terminal, not through a pipe.", + ); + } + + const ws = new WebSocket(wsURL, ["tty"], { + headers: { Authorization: `Bearer ${ctx.apiKey}` }, + }); + + let raw = false; + const enterRaw = () => { + if (!raw) { + stdin.setRawMode(true); + raw = true; + } + }; + const restore = () => { + if (raw) { + stdin.setRawMode(false); + raw = false; + } + stdin.pause(); + }; + + const onStdin = (chunk: Buffer) => { + // INPUT frame: command byte + raw bytes (binary-safe, so Ctrl chars + // and UTF-8 survive intact). + ws.send(Buffer.concat([Buffer.from(INPUT), chunk])); + }; + const onResize = () => { + ws.send( + RESIZE + + JSON.stringify({ columns: stdout.columns ?? 80, rows: stdout.rows ?? 24 }), + ); + }; + + await new Promise((resolve, reject) => { + ws.on("open", () => { + // ttyd init: the first message is the auth/size JSON (no command + // byte). AuthToken is empty — task-api already authed us. + ws.send( + JSON.stringify({ + AuthToken: "", + columns: stdout.columns ?? 80, + rows: stdout.rows ?? 24, + }), + ); + console.error( + brand.dim(`connected to ${opts.name} — exit the shell to disconnect`), + ); + enterRaw(); + stdin.resume(); + stdin.on("data", onStdin); + stdout.on("resize", onResize); + }); + + ws.on("message", (data: WebSocket.RawData) => { + const buf = Buffer.isBuffer(data) + ? data + : Buffer.from(data as ArrayBuffer); + // Only OUTPUT carries terminal bytes; title/preference frames are + // ignored. + if (buf[0] === OUTPUT) stdout.write(buf.subarray(1)); + }); + + ws.on("close", () => resolve()); + ws.on("error", (err: Error) => + reject( + new OpperError( + "NETWORK_ERROR", + `shell connection failed: ${err.message}`, + "Check the app is running (`opper apps get `) and that shells are enabled for it.", + ), + ), + ); + }).finally(() => { + stdin.off("data", onStdin); + stdout.off("resize", onResize); + restore(); + }); +} From 28743a2cd7acedaaceeebf7399228fb0ce90066c Mon Sep 17 00:00:00 2001 From: Mattias Lundell Date: Wed, 1 Jul 2026 11:44:30 +0200 Subject: [PATCH 3/4] feat(apps): --wait, secrets via stdin/file, delete confirmation; drop dead deploy_token - create/redeploy gain --wait: poll the build to a terminal state and exit non-zero (DEPLOY_FAILED, exit 9) on failure so CI can gate on a green deploy - secrets set reads the value from --from-stdin / --from-file, keeping it off argv and out of shell history; the positional value still works - delete confirms interactively and refuses in a non-TTY without --yes - drop the deploy_token branch: neither deploy nor task-api ever returns it Verified live on prod (api.opper.ai) end-to-end with the hello-agent example: create -> secret via stdin -> redeploy --wait (building->running, exit 0) -> run -> delete --yes; delete without --yes refused (exit 8). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cli/apps.ts | 49 ++++++++++++++--- src/commands/apps.ts | 109 ++++++++++++++++++++++++++++++++++--- src/errors.ts | 2 + test/commands/apps.test.ts | 98 ++++++++++++++++++++++++++++++++- 4 files changed, 240 insertions(+), 18 deletions(-) diff --git a/src/cli/apps.ts b/src/cli/apps.ts index 6bb4654..bfaee1d 100644 --- a/src/cli/apps.ts +++ b/src/cli/apps.ts @@ -45,6 +45,7 @@ const register: RegisterFn = (program: Command, ctx: CliContext) => { .option("--repo ", "git repo to clone and deploy (instead of --dir)") .option("--ref ", "branch or tag to clone (with --repo)") .option("--config ", 'config overrides, e.g. {"cpu":1,"memory":2048}') + .option("--wait", "wait for the build to finish; exit non-zero if it fails") .action( async (cmdOpts: { name?: string; @@ -52,6 +53,7 @@ const register: RegisterFn = (program: Command, ctx: CliContext) => { repo?: string; ref?: string; config?: string; + wait?: boolean; }) => { await appsCreateCommand({ ...(cmdOpts.name ? { name: cmdOpts.name } : {}), @@ -59,6 +61,7 @@ const register: RegisterFn = (program: Command, ctx: CliContext) => { ...(cmdOpts.repo ? { repo: cmdOpts.repo } : {}), ...(cmdOpts.ref ? { ref: cmdOpts.ref } : {}), ...(cmdOpts.config ? { config: cmdOpts.config } : {}), + ...(cmdOpts.wait ? { wait: true } : {}), key: ctx.key(), }); }, @@ -69,16 +72,27 @@ const register: RegisterFn = (program: Command, ctx: CliContext) => { .description("Upload new source and roll the app") .argument("", "app name") .option("--dir ", "source directory", ".") - .action(async (name: string, cmdOpts: { dir: string }) => { - await appsRedeployCommand({ name, dir: cmdOpts.dir, key: ctx.key() }); + .option("--wait", "wait for the build to finish; exit non-zero if it fails") + .action(async (name: string, cmdOpts: { dir: string; wait?: boolean }) => { + await appsRedeployCommand({ + name, + dir: cmdOpts.dir, + ...(cmdOpts.wait ? { wait: true } : {}), + key: ctx.key(), + }); }); apps .command("delete") .description("Stop and remove an app") .argument("", "app name") - .action(async (name: string) => { - await appsDeleteCommand({ name, key: ctx.key() }); + .option("-y, --yes", "skip the confirmation prompt") + .action(async (name: string, cmdOpts: { yes?: boolean }) => { + await appsDeleteCommand({ + name, + ...(cmdOpts.yes ? { yes: true } : {}), + key: ctx.key(), + }); }); apps @@ -123,10 +137,29 @@ const register: RegisterFn = (program: Command, ctx: CliContext) => { .description("Set or update a secret") .argument("", "app name") .argument("", "secret name, e.g. OPPER_API_KEY") - .argument("", "secret value") - .action(async (app: string, name: string, value: string) => { - await appsSecretsSetCommand({ app, name, value, key: ctx.key() }); - }); + .argument( + "[value]", + "secret value; omit and use --from-stdin/--from-file to keep it off argv", + ) + .option("--from-stdin", "read the value from stdin") + .option("--from-file ", "read the value from a file") + .action( + async ( + app: string, + name: string, + value: string | undefined, + cmdOpts: { fromStdin?: boolean; fromFile?: string }, + ) => { + await appsSecretsSetCommand({ + app, + name, + ...(value !== undefined ? { value } : {}), + ...(cmdOpts.fromStdin ? { fromStdin: true } : {}), + ...(cmdOpts.fromFile ? { fromFile: cmdOpts.fromFile } : {}), + key: ctx.key(), + }); + }, + ); secrets .command("delete") diff --git a/src/commands/apps.ts b/src/commands/apps.ts index 94174fc..7f639dd 100644 --- a/src/commands/apps.ts +++ b/src/commands/apps.ts @@ -2,6 +2,7 @@ import { readFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { parse as parseYaml } from "yaml"; +import { confirm, isCancel } from "@clack/prompts"; import { OpperApi } from "../api/client.js"; import { resolveApiContext } from "../api/resolve.js"; import { OpperError } from "../errors.js"; @@ -24,7 +25,6 @@ interface AppResponse { config?: { cpu?: number; memory?: number; timeout?: number }; created_at?: string; updated_at?: string; - deploy_token?: string; } interface ListResponse { @@ -35,6 +35,47 @@ function invokeUrl(baseUrl: string, name: string): string { return `${baseUrl.replace(/\/$/, "")}/v3/apps/${encodeURIComponent(name)}/run`; } +const sleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +// Polls an app until the build reaches a terminal state. "running" is +// success; "failed"/"stopped"/"paused" are terminal failures that throw +// DEPLOY_FAILED (exit 9) so CI/CD can gate on a green deploy. Status names +// mirror the deploy service (internal/domain: pending|building|running| +// failed|stopped|paused). +async function waitForReady(api: OpperApi, name: string): Promise { + const INTERVAL_MS = 3000; + const TIMEOUT_MS = 15 * 60 * 1000; + const start = Date.now(); + let last = ""; + for (;;) { + const a = await api.get( + `/v3/apps/${encodeURIComponent(name)}`, + ); + if (a.status !== last) { + // Progress to stderr so stdout stays clean for scripting. + console.error(brand.dim(` ${a.status}`)); + last = a.status; + } + if (a.status === "running") return a; + if (["failed", "stopped", "paused"].includes(a.status)) { + throw new OpperError( + "DEPLOY_FAILED", + `App ${name} did not come up (status: ${a.status})`, + `Inspect the build log: opper apps logs ${name}`, + ); + } + if (Date.now() - start > TIMEOUT_MS) { + throw new OpperError( + "DEPLOY_FAILED", + `Timed out after ${Math.round(TIMEOUT_MS / 60000)}m waiting for ${name} (last status: ${a.status})`, + `Check progress with: opper apps get ${name}`, + ); + } + await sleep(INTERVAL_MS); + } +} + // ---- list ----------------------------------------------------------------- export interface AppsListOptions { @@ -97,6 +138,7 @@ export interface AppsCreateOptions { repo?: string; ref?: string; config?: string; + wait?: boolean; key: string; } @@ -164,14 +206,14 @@ export async function appsCreateCommand(opts: AppsCreateOptions): Promise }); const a = await api.postMultipart("/v3/apps", form); console.log(`${brand.bold(a.name)} ${a.status}`); - if (a.deploy_token) { + if (opts.wait) { + const ready = await waitForReady(api, a.name); + console.log(`${brand.bold(ready.name)} ${ready.status}`); + } else { console.log( - `${brand.bold("deploy token:")} ${a.deploy_token} ${brand.dim("(shown once — store it now)")}`, + brand.dim(`build started — follow with: opper apps logs ${a.name}`), ); } - console.log( - brand.dim(`build started — follow with: opper apps logs ${a.name}`), - ); console.log(`${brand.bold("invoke:")} POST ${invokeUrl(ctx.baseUrl, a.name)}`); } finally { await cleanupClone?.(); @@ -181,6 +223,7 @@ export async function appsCreateCommand(opts: AppsCreateOptions): Promise export interface AppsRedeployOptions { name: string; dir: string; + wait?: boolean; key: string; } @@ -195,16 +238,40 @@ export async function appsRedeployCommand( form, ); console.log(`${brand.bold(a.name)} ${a.status} ${brand.dim("(rebuilding)")}`); + if (opts.wait) { + const ready = await waitForReady(api, a.name); + console.log(`${brand.bold(ready.name)} ${ready.status}`); + } } // ---- delete ----------------------------------------------------------------- export interface AppsDeleteOptions { name: string; + yes?: boolean; key: string; } export async function appsDeleteCommand(opts: AppsDeleteOptions): Promise { + if (!opts.yes) { + // Destructive + irreversible: require an explicit yes. Non-interactive + // callers (CI, pipes) must pass --yes rather than hang on a prompt. + if (!process.stdin.isTTY) { + throw new OpperError( + "INVALID_ARGUMENT", + `Refusing to delete "${opts.name}" without confirmation`, + "Re-run with --yes to delete non-interactively.", + ); + } + const ok = await confirm({ + message: `Delete app "${opts.name}"? This stops and removes it.`, + initialValue: false, + }); + if (isCancel(ok) || ok !== true) { + console.log("Cancelled — nothing deleted."); + return; + } + } const ctx = await resolveApiContext(opts.key); const api = new OpperApi(ctx); await api.del(`/v3/apps/${encodeURIComponent(opts.name)}`); @@ -277,18 +344,44 @@ export async function appsSecretsListCommand( export interface AppsSecretsSetOptions { app: string; name: string; - value: string; + value?: string; + fromStdin?: boolean; + fromFile?: string; key: string; } +async function readStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) chunks.push(chunk as Buffer); + return Buffer.concat(chunks).toString("utf8"); +} + export async function appsSecretsSetCommand( opts: AppsSecretsSetOptions, ): Promise { + // Resolve the value without ever requiring it on the command line, where + // it would leak into shell history and `ps`. Precedence: positional arg → + // --from-file (byte-exact) → --from-stdin (trailing newline trimmed so + // `echo secret | …` doesn't store the \n). + let value = opts.value; + if (value === undefined && opts.fromFile !== undefined) { + value = await readFile(opts.fromFile, "utf8"); + } + if (value === undefined && opts.fromStdin) { + value = (await readStdin()).replace(/\r?\n$/, ""); + } + if (value === undefined) { + throw new OpperError( + "INVALID_ARGUMENT", + "No secret value provided", + "Pass it as an argument, or use --from-file / --from-stdin.", + ); + } const ctx = await resolveApiContext(opts.key); const api = new OpperApi(ctx); await api.post(`/v3/apps/${encodeURIComponent(opts.app)}/secrets`, { name: opts.name, - value: opts.value, + value, }); console.log( `${brand.bold(opts.name)} set on ${opts.app} ${brand.dim("(applies on next redeploy)")}`, diff --git a/src/errors.ts b/src/errors.ts index 4a5fd6c..be1705e 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -7,6 +7,7 @@ export type OpperErrorCode = | "API_ERROR" | "NETWORK_ERROR" | "INVALID_ARGUMENT" + | "DEPLOY_FAILED" | "USER_CANCELLED"; export const EXIT_CODES: Record = { @@ -18,6 +19,7 @@ export const EXIT_CODES: Record = { API_ERROR: 6, NETWORK_ERROR: 7, INVALID_ARGUMENT: 8, + DEPLOY_FAILED: 9, USER_CANCELLED: 0, }; diff --git a/test/commands/apps.test.ts b/test/commands/apps.test.ts index bbeb3b5..821913a 100644 --- a/test/commands/apps.test.ts +++ b/test/commands/apps.test.ts @@ -100,7 +100,6 @@ describe("apps create", () => { id: "a2", name: "my-agent", status: "pending", - deploy_token: "tok", }); const log = captureLog(); try { @@ -204,10 +203,105 @@ describe("apps run / secrets / delete", () => { delMock.mockResolvedValue(undefined); const log = captureLog(); try { - await appsDeleteCommand({ name: "hermes", key: "default" }); + await appsDeleteCommand({ name: "hermes", yes: true, key: "default" }); expect(delMock).toHaveBeenCalledWith("/v3/apps/hermes"); } finally { log.mockRestore(); } }); }); + +describe("apps --wait / delete guard / secrets input", () => { + beforeEach(() => { + postMultipartMock.mockReset(); + getMock.mockReset(); + postMock.mockReset(); + delMock.mockReset(); + }); + + it("--wait polls the app until it is running", async () => { + await setSlot("default", { apiKey: "k" }); + const dir = await mkdtemp(join(tmpdir(), "opper-app-src-")); + try { + await writeFile(join(dir, "agent.py"), "x\n"); + postMultipartMock.mockResolvedValue({ id: "a", name: "w", status: "pending" }); + getMock.mockResolvedValue({ id: "a", name: "w", status: "running" }); + const log = captureLog(); + const err = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + await appsCreateCommand({ name: "w", dir, wait: true, key: "default" }); + expect(getMock).toHaveBeenCalledWith("/v3/apps/w"); + } finally { + err.mockRestore(); + log.mockRestore(); + } + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("--wait fails with DEPLOY_FAILED when the build fails", async () => { + await setSlot("default", { apiKey: "k" }); + const dir = await mkdtemp(join(tmpdir(), "opper-app-src-")); + try { + await writeFile(join(dir, "agent.py"), "x\n"); + postMultipartMock.mockResolvedValue({ id: "a", name: "w", status: "pending" }); + getMock.mockResolvedValue({ id: "a", name: "w", status: "failed" }); + const err = vi.spyOn(console, "error").mockImplementation(() => {}); + const log = captureLog(); + try { + await expect( + appsCreateCommand({ name: "w", dir, wait: true, key: "default" }), + ).rejects.toMatchObject({ code: "DEPLOY_FAILED" }); + } finally { + err.mockRestore(); + log.mockRestore(); + } + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("delete refuses without --yes in a non-interactive shell", async () => { + await setSlot("default", { apiKey: "k" }); + await expect( + appsDeleteCommand({ name: "hermes", key: "default" }), + ).rejects.toMatchObject({ code: "INVALID_ARGUMENT" }); + expect(delMock).not.toHaveBeenCalled(); + }); + + it("secrets set reads the value from --from-file (off argv)", async () => { + await setSlot("default", { apiKey: "k" }); + postMock.mockResolvedValue({}); + const dir = await mkdtemp(join(tmpdir(), "opper-secret-")); + try { + const f = join(dir, "val"); + await writeFile(f, "s3cr3t"); + const log = captureLog(); + try { + await appsSecretsSetCommand({ + app: "hermes", + name: "TOKEN", + fromFile: f, + key: "default", + }); + expect(postMock).toHaveBeenCalledWith("/v3/apps/hermes/secrets", { + name: "TOKEN", + value: "s3cr3t", + }); + } finally { + log.mockRestore(); + } + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("secrets set errors when no value is provided", async () => { + await setSlot("default", { apiKey: "k" }); + await expect( + appsSecretsSetCommand({ app: "hermes", name: "TOKEN", key: "default" }), + ).rejects.toMatchObject({ code: "INVALID_ARGUMENT" }); + expect(postMock).not.toHaveBeenCalled(); + }); +}); From 3a1665baddd3b4f73ad3587243e76f92b5eabfbe Mon Sep 17 00:00:00 2001 From: Mattias Lundell Date: Wed, 1 Jul 2026 13:37:38 +0200 Subject: [PATCH 4/4] refactor(apps): drop `opper apps run` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thin wrapper over POST /v3/apps//run with naive {data}-shape output and no streaming — create/get already print the invoke URL for curl/SDK. Trims the surface; no dependency change. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cli/apps.ts | 10 ---------- src/commands/apps.ts | 20 -------------------- test/commands/apps.test.ts | 19 +------------------ 3 files changed, 1 insertion(+), 48 deletions(-) diff --git a/src/cli/apps.ts b/src/cli/apps.ts index bfaee1d..fb54fa4 100644 --- a/src/cli/apps.ts +++ b/src/cli/apps.ts @@ -8,7 +8,6 @@ import { appsRedeployCommand, appsDeleteCommand, appsLogsCommand, - appsRunCommand, appsSecretsListCommand, appsSecretsSetCommand, appsSecretsDeleteCommand, @@ -103,15 +102,6 @@ const register: RegisterFn = (program: Command, ctx: CliContext) => { await appsLogsCommand({ name, key: ctx.key() }); }); - apps - .command("run") - .description("Invoke the app once") - .argument("", "app name") - .requiredOption("--input ", "input passed to the agent") - .action(async (name: string, cmdOpts: { input: string }) => { - await appsRunCommand({ name, input: cmdOpts.input, key: ctx.key() }); - }); - apps .command("shell") .description("Open an interactive terminal in the running app") diff --git a/src/commands/apps.ts b/src/commands/apps.ts index 7f639dd..0831ee6 100644 --- a/src/commands/apps.ts +++ b/src/commands/apps.ts @@ -295,26 +295,6 @@ export async function appsLogsCommand(opts: AppsLogsOptions): Promise { } } -// ---- run -------------------------------------------------------------------- - -export interface AppsRunOptions { - name: string; - input: string; - key: string; -} - -export async function appsRunCommand(opts: AppsRunOptions): Promise { - const ctx = await resolveApiContext(opts.key); - const api = new OpperApi(ctx); - const resp = await api.post<{ data?: unknown }>( - `/v3/apps/${encodeURIComponent(opts.name)}/run`, - { input: opts.input }, - ); - const out = resp.data; - if (typeof out === "string") console.log(out); - else console.log(JSON.stringify(resp, null, 2)); -} - // ---- secrets ------------------------------------------------------------------ export interface AppsSecretsListOptions { diff --git a/test/commands/apps.test.ts b/test/commands/apps.test.ts index 821913a..f144c91 100644 --- a/test/commands/apps.test.ts +++ b/test/commands/apps.test.ts @@ -22,7 +22,6 @@ const { appsListCommand, appsGetCommand, appsCreateCommand, - appsRunCommand, appsSecretsSetCommand, appsDeleteCommand, } = await import("../../src/commands/apps.js"); @@ -156,28 +155,12 @@ describe("apps create", () => { }); }); -describe("apps run / secrets / delete", () => { +describe("apps secrets / delete", () => { beforeEach(() => { postMock.mockReset(); delMock.mockReset(); }); - it("run posts {input} and prints the data string", async () => { - await setSlot("default", { apiKey: "k" }); - postMock.mockResolvedValue({ data: "Hello, Opper team!" }); - const log = captureLog(); - try { - await appsRunCommand({ name: "hermes", input: "hi", key: "default" }); - expect(postMock).toHaveBeenCalledWith("/v3/apps/hermes/run", { - input: "hi", - }); - const out = log.mock.calls.map((c) => String(c[0])).join("\n"); - expect(out).toContain("Hello, Opper team!"); - } finally { - log.mockRestore(); - } - }); - it("secrets set posts name+value", async () => { await setSlot("default", { apiKey: "k" }); postMock.mockResolvedValue({});