From 2fafbcfcfdf24406c90341db96dc901f879ed838 Mon Sep 17 00:00:00 2001 From: Nathan Aronson Date: Sat, 18 Apr 2026 16:59:22 -0400 Subject: [PATCH 1/4] Part of CLI --- cli/.gitignore | 6 + cli/Cargo.toml | 6 - cli/bin/dploy | 2 + cli/package.json | 41 + cli/plan.txt | 404 ++++++ cli/pnpm-lock.yaml | 1652 ++++++++++++++++++++++ cli/src/cli.tsx | 108 ++ cli/src/commands/deploy.tsx | 32 + cli/src/commands/list.tsx | 24 + cli/src/commands/login.tsx | 126 ++ cli/src/commands/logout.tsx | 24 + cli/src/commands/open.ts | 12 + cli/src/commands/status.tsx | 17 + cli/src/commands/stop.tsx | 23 + cli/src/commands/whoami.tsx | 62 + cli/src/components/AppShell.tsx | 32 + cli/src/components/DeploymentSummary.tsx | 35 + cli/src/components/EnvSummary.tsx | 13 + cli/src/components/ErrorPanel.tsx | 28 + cli/src/components/Header.tsx | 22 + cli/src/components/KeyHints.tsx | 17 + cli/src/components/StatusBadge.tsx | 25 + cli/src/components/StatusBar.tsx | 33 + cli/src/components/Step.tsx | 40 + cli/src/components/StepList.tsx | 19 + cli/src/components/Table.tsx | 39 + cli/src/hooks/useAuth.ts | 6 + cli/src/hooks/useDeploymentPoll.ts | 47 + cli/src/hooks/useElapsed.ts | 17 + cli/src/lib/api.ts | 70 + cli/src/lib/bundle.ts | 59 + cli/src/lib/config.ts | 14 + cli/src/lib/env.ts | 32 + cli/src/lib/github.ts | 12 + cli/src/lib/mock.ts | 77 + cli/src/lib/oauth.ts | 48 + cli/src/lib/types.ts | 44 + cli/src/lib/version.ts | 1 + cli/src/main.rs | 3 - cli/tsconfig.json | 19 + cli/tsup.config.ts | 12 + 41 files changed, 3294 insertions(+), 9 deletions(-) create mode 100644 cli/.gitignore delete mode 100644 cli/Cargo.toml create mode 100755 cli/bin/dploy create mode 100644 cli/package.json create mode 100644 cli/plan.txt create mode 100644 cli/pnpm-lock.yaml create mode 100644 cli/src/cli.tsx create mode 100644 cli/src/commands/deploy.tsx create mode 100644 cli/src/commands/list.tsx create mode 100644 cli/src/commands/login.tsx create mode 100644 cli/src/commands/logout.tsx create mode 100644 cli/src/commands/open.ts create mode 100644 cli/src/commands/status.tsx create mode 100644 cli/src/commands/stop.tsx create mode 100644 cli/src/commands/whoami.tsx create mode 100644 cli/src/components/AppShell.tsx create mode 100644 cli/src/components/DeploymentSummary.tsx create mode 100644 cli/src/components/EnvSummary.tsx create mode 100644 cli/src/components/ErrorPanel.tsx create mode 100644 cli/src/components/Header.tsx create mode 100644 cli/src/components/KeyHints.tsx create mode 100644 cli/src/components/StatusBadge.tsx create mode 100644 cli/src/components/StatusBar.tsx create mode 100644 cli/src/components/Step.tsx create mode 100644 cli/src/components/StepList.tsx create mode 100644 cli/src/components/Table.tsx create mode 100644 cli/src/hooks/useAuth.ts create mode 100644 cli/src/hooks/useDeploymentPoll.ts create mode 100644 cli/src/hooks/useElapsed.ts create mode 100644 cli/src/lib/api.ts create mode 100644 cli/src/lib/bundle.ts create mode 100644 cli/src/lib/config.ts create mode 100644 cli/src/lib/env.ts create mode 100644 cli/src/lib/github.ts create mode 100644 cli/src/lib/mock.ts create mode 100644 cli/src/lib/oauth.ts create mode 100644 cli/src/lib/types.ts create mode 100644 cli/src/lib/version.ts delete mode 100644 cli/src/main.rs create mode 100644 cli/tsconfig.json create mode 100644 cli/tsup.config.ts diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 0000000..0a93c16 --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +*.log +.DS_Store +.env +.env.* diff --git a/cli/Cargo.toml b/cli/Cargo.toml deleted file mode 100644 index 16d2db2..0000000 --- a/cli/Cargo.toml +++ /dev/null @@ -1,6 +0,0 @@ -[package] -name = "cli" -version = "0.1.0" -edition = "2024" - -[dependencies] diff --git a/cli/bin/dploy b/cli/bin/dploy new file mode 100755 index 0000000..c667116 --- /dev/null +++ b/cli/bin/dploy @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import("../dist/cli.js"); diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000..d6b1505 --- /dev/null +++ b/cli/package.json @@ -0,0 +1,41 @@ +{ + "name": "dploy", + "version": "0.0.1", + "description": "Vercel-style just-deploy-it CLI for Dedalus", + "type": "module", + "bin": { + "dploy": "./bin/dploy" + }, + "files": [ + "bin", + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "typecheck": "tsc --noEmit", + "start": "node ./bin/dploy" + }, + "dependencies": { + "@inkjs/ui": "^2.0.0", + "conf": "^13.0.0", + "dotenv": "^16.4.0", + "ignore": "^5.3.0", + "ink": "^5.0.0", + "meow": "^13.2.0", + "open": "^10.1.0", + "react": "^18.3.0", + "tar": "^7.4.0", + "undici": "^6.19.0" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "@types/react": "^18.3.0", + "@types/tar": "^6.1.0", + "tsup": "^8.2.0", + "typescript": "^5.5.0" + }, + "engines": { + "node": ">=20" + } +} diff --git a/cli/plan.txt b/cli/plan.txt new file mode 100644 index 0000000..cda1a32 --- /dev/null +++ b/cli/plan.txt @@ -0,0 +1,404 @@ +================================================================================ + dploy CLI — Final Implementation Plan +================================================================================ + +One-liner: Vercel-style "just deploy it" CLI. Bundles current folder (or clones +a GitHub repo), ships to Dedalus, OpenClaw figures out the env + exposes a +port, you get a URL in <60s. + +Team split: + Nathan — CLI (this doc) + Ryan — Agent #2 (port exposure) + Sam — Agent #1 (code analysis / run commands) + Kaitlyn — Frontend (auth, API keys, dashboard) + +================================================================================ + 1. STACK +================================================================================ + + Ink 5 — React for CLIs + @inkjs/ui — Spinner, StatusMessage, ProgressBar, Select, Confirm + meow — arg parsing (Ink-canonical) + TypeScript + tsup — zero-config bundler, ESM output, shebang preserved + undici — fetch + streams + tar — tarball creation + ignore — .gitignore-style matching + dotenv — .env parsing (handles quotes, multiline, comments) + conf — persistent config at ~/.dploy/config.json + open — xdg-open/start for `dploy open` + +================================================================================ + 2. COMMANDS +================================================================================ + + dploy → alias for `dploy deploy` + dploy deploy [path|github-url] + --env KEY=val repeatable, inline env vars + --env-file explicit env file (default: auto-detect .env) + --name deployment name + --follow stay attached after ready + + dploy list interactive table + dploy status one-shot status view + dploy stop teardown (with confirm) + dploy open open URL in browser + + dploy login browser OAuth flow (primary) + dploy login --token manual paste fallback + dploy logout + dploy whoami prints current user + + dploy --version + dploy --help + + Cut for MVP: `keys` commands (frontend owns), `logs` command (tail is in + status view anyway), `dev` watch mode, `env set/unset`. + +================================================================================ + 4. API ROUTES +================================================================================ + + POST /api/upload multipart → { uploadId, size } + POST /api/deployments body → { deploymentId } + GET /api/deployments → Deployment[] + GET /api/deployments/:id → Deployment (with currentStep, logs tail) + DELETE /api/deployments/:id teardown + + GET /api/auth/login query: ?cli_port= → OAuth redirect + GET /api/auth/callback OAuth callback, posts token to cli_port + POST /api/auth/logout + GET /api/auth/me → { user, org } + + Auth: every authed request sends `Authorization: Bearer ` where + is either an API key (from frontend) or OAuth-issued CLI token. + +================================================================================ + 5. SHARED CONTRACTS (lock these with team FIRST) +================================================================================ + + type DeploymentStatus = + | "pending" // record created + | "uploading" // receiving tarball + | "provisioning" // dedalus sandbox spinning up + | "cloning" // git clone (github path only) + | "analyzing" // Agent #1 reading files + | "installing" // running install commands + | "starting" // running start command + | "exposing" // Agent #2 opening port + | "ready" // URL live + | "failed" + | "stopped"; + + type Deployment = { + id: string; + name: string; + status: DeploymentStatus; + currentStep?: string; // "Running pnpm install" + source: { type: "upload" | "github"; ref: string }; + runCommand?: string; + ports?: { internal: number; public: string }[]; + url?: string; + logs?: string[]; // tail, last ~50 lines + createdAt: string; + updatedAt: string; + error?: string; + }; + + type CreateDeploymentBody = { + source: { type: "upload"; id: string } | { type: "github"; url: string }; + env?: Record; + name?: string; + }; + + CLI polls GET /api/deployments/:id every 1s; no SSE needed. + +================================================================================ + 6. PROJECT STRUCTURE +================================================================================ + + dploy-cli/ + package.json + tsup.config.ts + tsconfig.json + bin/ + dploy # shebang → dist/cli.js + src/ + cli.tsx # meow entry, routes to command view + commands/ + deploy.tsx + list.tsx + status.tsx + stop.tsx + open.ts + login.tsx # tsx because OAuth flow has Ink UI + logout.ts + whoami.tsx + components/ + AppShell.tsx # Header + body + StatusBar layout wrapper + Header.tsx # "▲ dploy" brand + command name + version + StatusBar.tsx # bottom bar: user · apiUrl · elapsed + KeyHints.tsx # "↑↓ nav · enter select · q quit" + ErrorPanel.tsx # red bordered error box + actionable hint + Step.tsx # ✔/⠋/✖ + label + detail lines + StepList.tsx # manages Step[] state + DeploymentSummary.tsx # "🚀 Deployed" panel + StatusBadge.tsx # colored dot + status text + Table.tsx # for list view + EnvSummary.tsx # "Env variables (3): ..." + hooks/ + useDeploymentPoll.ts # polls GET /:id, derives step state + useAuth.ts # reads config, guards commands + useElapsed.ts # mm:ss timer for long-running views + lib/ + api.ts # undici wrapper, auth header, typed errors + config.ts # conf-backed ~/.dploy/config.json + bundle.ts # tar+gzip with ignore rules + env.ts # load + merge env sources + oauth.ts # local callback server + browser open + types.ts # mirror backend contracts + github.ts # isGithubUrl(), parseGithubUrl() + +================================================================================ + 7. DEPENDENCIES +================================================================================ + + { + "bin": { "dploy": "./bin/dploy" }, + "dependencies": { + "ink": "^5", + "@inkjs/ui": "^2", + "react": "^18", + "meow": "^13", + "undici": "^6", + "tar": "^7", + "ignore": "^5", + "dotenv": "^16", + "conf": "^13", + "open": "^10" + }, + "devDependencies": { + "typescript": "^5", + "tsup": "^8", + "@types/react": "^18", + "@types/tar": "^6", + "@types/node": "^20" + } + } + +================================================================================ + 8. IMPLEMENTATION PHASES +================================================================================ + + Total target: ~4.5 hours for a polished demo. + Nathan does 1–3, Ryan handles 4 + Agent #2 in parallel, polish together in 5. + +-------------------------------------------------------------------------------- +PHASE 1 — Scaffold + token auth (40 min) +-------------------------------------------------------------------------------- + + [ ] `pnpm init`, install deps listed above + [ ] tsup.config.ts: ESM output, shebang preserved, target node20 + [ ] bin/dploy → `#!/usr/bin/env node\nimport("../dist/cli.js");` + [ ] tsconfig.json: jsx=react-jsx, moduleResolution=bundler, strict + [ ] src/cli.tsx: meow parses flags + positionals, switches on first positional, + render() for the matching command + [ ] lib/config.ts: conf instance, schema { token?: string, apiUrl: string, + defaultApiUrl='https://api.dploy.dev' } + [ ] lib/api.ts: undici `request` wrapper, injects Authorization header, + throws typed ApiError { status, code, message } + [ ] commands/login.tsx: supports `--token` path immediately. OAuth in phase 1.5. + [ ] commands/logout.ts: clears token from config + + CHECKPOINT: `dploy login --token foo && cat ~/.dploy/config.json` works. + +-------------------------------------------------------------------------------- +PHASE 1.5 — OAuth login (30 min) +-------------------------------------------------------------------------------- + + lib/oauth.ts flow: + 1. Pick random free port on 127.0.0.1 (http.createServer + listen(0)) + 2. Start local server, routes: + GET /callback?token=... → save token, respond "You can close this + window", resolve promise, shutdown server + 3. Open browser to `${apiUrl}/api/auth/login?cli_port=${port}` + 4. Backend does OAuth dance, on success redirects to + http://127.0.0.1:${port}/callback?token=... + 5. CLI shows Ink spinner "Waiting for browser..." with 2min timeout + 6. On token received: POST /api/auth/me to fetch user, print "Logged in as X" + + CHECKPOINT: `dploy login` opens browser, completes flow, `dploy whoami` prints user. + +-------------------------------------------------------------------------------- +PHASE 2 — Deploy path, no Ink UI yet (50 min) +-------------------------------------------------------------------------------- + + [ ] lib/github.ts: isGithubUrl(s) via regex, parseGithubUrl(s) + [ ] lib/bundle.ts: + - walk CWD recursively + - default ignores: node_modules, .git, dist, build, .next, out, target, + .venv, venv, __pycache__, .DS_Store, .env, .env.*, *.log, .dploy + - merge with .gitignore + .dployignore via `ignore` package + - tar.c({ gzip: true, cwd, filter }) → stream to os.tmpdir()/dploy-.tar.gz + - return { path, size, fileCount } + - WARN if size > 50MB + [ ] lib/env.ts: + - loadEnv({ file?, inline?: string[], skipAuto?: boolean }): Record + - auto-loads .env from CWD unless skipAuto + - dotenv.parse() for files + - inline "KEY=val" strings override file values + - throws if file path given but missing + [ ] commands/deploy.tsx (logic first, placeholder render): + 1. parse args: path or github-url, --env*, --env-file, --no-env, --name + 2. detect github vs local path + 3. if local: bundle → POST /api/upload multipart → { uploadId } + if github: skip to step 5 with { type: "github", url } + 4. env = loadEnv(...) + 5. POST /api/deployments { source, env, name } → { deploymentId } + 6. console.log(deploymentId); exit(0) + + CHECKPOINT: `dploy deploy` uploads + creates deployment. Prove integration. + +-------------------------------------------------------------------------------- +PHASE 3 — Ink DeployView (70 min) ⭐ DEMO MOMENT +-------------------------------------------------------------------------------- + + Spend the time here. This is what judges watch. + + [ ] hooks/useDeploymentPoll.ts: + - takes deploymentId + - setInterval 1000ms, GET /api/deployments/:id + - reducer maps DeploymentStatus → step states: + status ≤ currentStep.order → "done" + status === currentStep.order → "running" (with currentStep detail) + status > currentStep.order → "pending" + status === "failed" → mark current as "failed" + status === "ready" → transition to summary view + - clear interval on terminal status + - return { steps, deployment, phase: 'polling'|'ready'|'failed' } + + [ ] components/Step.tsx: + - props: { state, label, details?: string[] } + - renders: ✔ (green) | ⠋ (cyan spinner) | ✖ (red) | ○ (gray) + label + - details rendered as indented "└─ ..." lines + + [ ] components/StepList.tsx: maps Step[] → elements, adds elapsed timer + + [ ] components/DeploymentSummary.tsx: the "🚀 Deployed" panel + 🚀 Deployed + + https://abc123.dploy.dev + + agent decided: pnpm install && pnpm dev + exposed port: 3000 → public + time to deploy: 47s + + dploy stop abc123 to tear down + dploy open abc123 to open in browser + + [ ] commands/deploy.tsx renders: + - (keys only) before upload + - during upload + - during polling + - on ready + - on failed + + Step definitions (order matters): + 1. "Bundle project" → done after upload + 2. "Upload" → done when POST /upload returns + 3. "Provision sandbox" → status in ["provisioning"] + 4. "Clone repo" (gh only) → status in ["cloning"] + 5. "Analyze project" → status in ["analyzing"] + 6. "Install dependencies" → status in ["installing"] + 7. "Start server" → status in ["starting"] + 8. "Expose port" → status in ["exposing"] + + CHECKPOINT: `dploy deploy` shows live-updating step list, ends with URL panel. + +-------------------------------------------------------------------------------- +PHASE 4 — Other commands (60 min, parallelizable) +-------------------------------------------------------------------------------- + + [ ] commands/list.tsx: + - GET /api/deployments on mount + - with columns: status dot, name, URL, age (relative) + - @inkjs/ui
row.status }, + { header: "Name", width: 22, render: (row) => row.name }, + { + header: "URL", + width: 30, + render: (row) => row.url ?? "pending", + }, + { + header: "Age", + width: 10, + render: (row) => formatRelativeTime(row.createdAt), + }, + ]} + rows={deployments} + selectedIndex={isRawModeSupported ? selectedIndex : undefined} + /> + + + {selected ? ( + + + + ) : null} + + )} ); } + +function ListInput({ + count, + onExit, + onMove, +}: { + count: number; + onExit: () => void; + onMove: (delta: number) => void; +}) { + useInput((input, key) => { + if (input === "q" || key.escape) { + onExit(); + return; + } + + if (count === 0) return; + if (key.upArrow || input === "k") onMove(-1); + if (key.downArrow || input === "j") onMove(1); + }); + + return null; +} + +function clampIndex(value: number, count: number): number { + if (count <= 0) return 0; + if (value < 0) return count - 1; + if (value >= count) return 0; + return value; +} diff --git a/cli/src/commands/login.tsx b/cli/src/commands/login.tsx index c7839c5..cf80bc2 100644 --- a/cli/src/commands/login.tsx +++ b/cli/src/commands/login.tsx @@ -3,8 +3,9 @@ import { Box, Text, useApp } from "ink"; import { Spinner } from "@inkjs/ui"; import { AppShell } from "../components/AppShell.js"; import { ErrorPanel } from "../components/ErrorPanel.js"; -import { api, ApiError } from "../lib/api.js"; +import { api } from "../lib/api.js"; import { config } from "../lib/config.js"; +import { errorMessage } from "../lib/errors.js"; import { MOCK_TOKEN } from "../lib/mock.js"; import { runOAuthLogin } from "../lib/oauth.js"; import type { AuthMe } from "../lib/types.js"; @@ -56,7 +57,7 @@ export function Login({ token: providedToken, mock }: Props) { setTimeout(() => exit(), 400); } catch (err) { if (cancelled) return; - setError(err instanceof ApiError ? err.message : String(err)); + setError(errorMessage(err)); setPhase("failed"); setTimeout(() => exit(), 50); process.exitCode = 1; diff --git a/cli/src/commands/status.tsx b/cli/src/commands/status.tsx index 560b6f0..41b296d 100644 --- a/cli/src/commands/status.tsx +++ b/cli/src/commands/status.tsx @@ -1,17 +1,77 @@ -import { useEffect } from "react"; -import { Text, useApp } from "ink"; +import { useEffect, useState } from "react"; +import { Box, Text, useApp, useInput, useStdin } from "ink"; +import { Spinner } from "@inkjs/ui"; +import { api } from "../lib/api.js"; +import type { Deployment } from "../lib/types.js"; import { AppShell } from "../components/AppShell.js"; +import { DeploymentDetails } from "../components/DeploymentDetails.js"; +import { ErrorPanel } from "../components/ErrorPanel.js"; +import { useAuth } from "../hooks/useAuth.js"; +import { errorMessage } from "../lib/errors.js"; export function Status({ id }: { id: string }) { const { exit } = useApp(); + const { isRawModeSupported } = useStdin(); + const { isAuthed } = useAuth(); + const [deployment, setDeployment] = useState(); + const [error, setError] = useState(); + useEffect(() => { - const t = setTimeout(() => exit(), 50); - return () => clearTimeout(t); - }, [exit]); + let cancelled = false; + + async function run(): Promise { + try { + if (!isAuthed) { + throw new Error("Not logged in. Run `dploy login` first."); + } + + const result = await api(`/api/deployments/${id}`); + if (cancelled) return; + setDeployment(result); + } catch (err) { + if (cancelled) return; + process.exitCode = 1; + setError(errorMessage(err)); + } + } + + void run(); + return () => { + cancelled = true; + }; + }, [id, isAuthed]); + + useEffect(() => { + if (isRawModeSupported) return; + if (!deployment && !error) return; + const timeout = setTimeout(() => exit(), 50); + return () => clearTimeout(timeout); + }, [deployment, error, exit, isRawModeSupported]); return ( - - status view not implemented (phase 4) + + {isRawModeSupported ? : null} + {error ? ( + + ) : deployment ? ( + + ) : ( + + + Fetching deployment… + + )} ); } + +function QuitInput({ onExit }: { onExit: () => void }) { + useInput((input, key) => { + if (input === "q" || key.escape) onExit(); + }); + + return null; +} diff --git a/cli/src/commands/stop.tsx b/cli/src/commands/stop.tsx index d9af22e..5748b9c 100644 --- a/cli/src/commands/stop.tsx +++ b/cli/src/commands/stop.tsx @@ -1,23 +1,159 @@ -import { useEffect } from "react"; -import { Text, useApp } from "ink"; +import { useEffect, useState } from "react"; +import { Box, Text, useApp, useInput, useStdin } from "ink"; +import { Spinner } from "@inkjs/ui"; +import { api } from "../lib/api.js"; +import type { Deployment } from "../lib/types.js"; import { AppShell } from "../components/AppShell.js"; +import { DeploymentDetails } from "../components/DeploymentDetails.js"; +import { ErrorPanel } from "../components/ErrorPanel.js"; +import { useAuth } from "../hooks/useAuth.js"; +import { errorMessage } from "../lib/errors.js"; -export function Stop({ id }: { id: string }) { +type Phase = "loading" | "confirm" | "stopping" | "done" | "cancelled" | "failed"; + +export function Stop({ id, yes = false }: { id: string; yes?: boolean }) { const { exit } = useApp(); + const { isRawModeSupported } = useStdin(); + const { isAuthed } = useAuth(); + const [phase, setPhase] = useState("loading"); + const [deployment, setDeployment] = useState(); + const [error, setError] = useState(); + const [result, setResult] = useState(); + useEffect(() => { - const t = setTimeout(() => exit(), 50); - return () => clearTimeout(t); - }, [exit]); + let cancelled = false; + + async function load(): Promise { + try { + if (!isAuthed) { + throw new Error("Not logged in. Run `dploy login` first."); + } + + const found = await api(`/api/deployments/${id}`); + if (cancelled) return; + setDeployment(found); + + if (yes) { + void stopDeployment(); + return; + } + + if (!isRawModeSupported) { + throw new Error("Confirmation requires an interactive terminal. Re-run with `dploy stop --yes`."); + } + + setPhase("confirm"); + } catch (err) { + if (cancelled) return; + process.exitCode = 1; + setError(errorMessage(err)); + setPhase("failed"); + } + } + + async function stopDeployment(): Promise { + try { + setPhase("stopping"); + const stopped = await api(`/api/deployments/${id}`, { + method: "DELETE", + }); + if (cancelled) return; + setResult(stopped); + setPhase("done"); + } catch (err) { + if (cancelled) return; + process.exitCode = 1; + setError(errorMessage(err)); + setPhase("failed"); + } + } + + void load(); + return () => { + cancelled = true; + }; + }, [id, isAuthed, isRawModeSupported, yes]); + + useEffect(() => { + if (phase !== "done" && phase !== "failed" && phase !== "cancelled") return; + const timeout = setTimeout(() => exit(), 1400); + return () => clearTimeout(timeout); + }, [exit, phase]); return ( - stop flow not implemented (phase 4) + {phase === "confirm" && deployment && isRawModeSupported ? ( + { + setPhase("cancelled"); + }} + onConfirm={async () => { + try { + setPhase("stopping"); + const stopped = await api(`/api/deployments/${id}`, { + method: "DELETE", + }); + setResult(stopped); + setPhase("done"); + } catch (err) { + process.exitCode = 1; + setError(errorMessage(err)); + setPhase("failed"); + } + }} + /> + ) : null} + + {error ? ( + + ) : phase === "loading" ? ( + + + Loading deployment… + + ) : phase === "stopping" ? ( + + + Stopping deployment… + + ) : phase === "done" && result ? ( + + ✔ Deployment stopped. + + + ) : phase === "cancelled" ? ( + Stop cancelled. + ) : deployment ? ( + + Stop this deployment? + + + ) : null} ); } + +function StopInput({ + onConfirm, + onCancel, +}: { + onConfirm: () => void | Promise; + onCancel: () => void; +}) { + useInput((input, key) => { + if (input === "y") void onConfirm(); + if (input === "n" || input === "q" || key.escape) onCancel(); + }); + + return null; +} diff --git a/cli/src/commands/whoami.tsx b/cli/src/commands/whoami.tsx index 1a38df9..a77eb04 100644 --- a/cli/src/commands/whoami.tsx +++ b/cli/src/commands/whoami.tsx @@ -3,7 +3,8 @@ import { Box, Text, useApp } from "ink"; import { Spinner } from "@inkjs/ui"; import { AppShell } from "../components/AppShell.js"; import { ErrorPanel } from "../components/ErrorPanel.js"; -import { api, ApiError } from "../lib/api.js"; +import { api } from "../lib/api.js"; +import { errorMessage } from "../lib/errors.js"; import { useAuth } from "../hooks/useAuth.js"; import type { AuthMe } from "../lib/types.js"; @@ -26,7 +27,7 @@ export function Whoami() { setTimeout(() => exit(), 50); }, (err) => { - setError(err instanceof ApiError ? err.message : String(err)); + setError(errorMessage(err)); setTimeout(() => exit(), 50); process.exitCode = 1; }, diff --git a/cli/src/components/DeploymentDetails.tsx b/cli/src/components/DeploymentDetails.tsx new file mode 100644 index 0000000..79780e4 --- /dev/null +++ b/cli/src/components/DeploymentDetails.tsx @@ -0,0 +1,69 @@ +import { Box, Text } from "ink"; +import { elapsedSeconds, formatRelativeTime } from "../lib/time.js"; +import type { Deployment } from "../lib/types.js"; +import { DeploymentSummary } from "./DeploymentSummary.js"; +import { StatusBadge } from "./StatusBadge.js"; + +export function DeploymentDetails({ + deployment, + showActions = true, +}: { + deployment: Deployment; + showActions?: boolean; +}) { + const port = deployment.ports?.[0]; + + return ( + + {deployment.name} + + + {deployment.id} + + + + + source: {deployment.source.type} + {deployment.source.ref} + + updated {formatRelativeTime(deployment.updatedAt)} + {deployment.currentStep ? ( + current step: {deployment.currentStep} + ) : null} + {deployment.status !== "ready" && deployment.url ? ( + url: {deployment.url} + ) : null} + {deployment.status !== "ready" && deployment.runCommand ? ( + + run command: {deployment.runCommand} + + ) : null} + {deployment.status !== "ready" && port ? ( + + exposed port: {port.internal} → {port.public} + + ) : null} + + + {deployment.error ? ( + + {deployment.error} + + ) : null} + + {deployment.status === "ready" ? ( + + ) : null} + + {showActions ? ( + + dploy status {deployment.id} for more detail + dploy stop {deployment.id} to tear down + + ) : null} + + ); +} diff --git a/cli/src/components/Table.tsx b/cli/src/components/Table.tsx index f53e049..e24ce52 100644 --- a/cli/src/components/Table.tsx +++ b/cli/src/components/Table.tsx @@ -9,12 +9,14 @@ export type Column = { type Props = { columns: Column[]; rows: T[]; + selectedIndex?: number; }; -export function Table({ columns, rows }: Props) { +export function Table({ columns, rows, selectedIndex }: Props) { return ( + {selectedIndex !== undefined ? : null} {columns.map((c) => ( {c.header} @@ -23,9 +25,18 @@ export function Table({ columns, rows }: Props) { {rows.map((row, i) => ( + {selectedIndex !== undefined ? ( + + + {selectedIndex === i ? "›" : " "} + + + ) : null} {columns.map((c) => ( - {truncate(c.render(row), c.width - 1)} + + {truncate(c.render(row), c.width - 1)} + ))} diff --git a/cli/src/hooks/useDeploymentPoll.ts b/cli/src/hooks/useDeploymentPoll.ts index 90b8638..c00c37f 100644 --- a/cli/src/hooks/useDeploymentPoll.ts +++ b/cli/src/hooks/useDeploymentPoll.ts @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; -import { api, ApiError } from "../lib/api.js"; +import { api } from "../lib/api.js"; +import { errorMessage } from "../lib/errors.js"; import type { Deployment, DeploymentStatus } from "../lib/types.js"; type Phase = "polling" | "ready" | "failed" | "stopped"; @@ -30,7 +31,7 @@ export function useDeploymentPoll(id: string | undefined, intervalMs = 1000): Po if (phase !== "polling") clearInterval(timer); } catch (err) { if (cancelled) return; - const msg = err instanceof ApiError ? err.message : String(err); + const msg = errorMessage(err); setState((s) => ({ ...s, error: msg })); } } diff --git a/cli/src/lib/errors.ts b/cli/src/lib/errors.ts new file mode 100644 index 0000000..3b03c1f --- /dev/null +++ b/cli/src/lib/errors.ts @@ -0,0 +1,7 @@ +import { ApiError } from "./api.js"; + +export function errorMessage(err: unknown): string { + if (err instanceof ApiError) return err.message; + if (err instanceof Error) return err.message; + return String(err); +} diff --git a/cli/src/lib/time.ts b/cli/src/lib/time.ts new file mode 100644 index 0000000..82ee487 --- /dev/null +++ b/cli/src/lib/time.ts @@ -0,0 +1,19 @@ +export function formatRelativeTime(value: string): string { + const diffSec = Math.max(0, Math.floor((Date.now() - Date.parse(value)) / 1000)); + + if (diffSec < 5) return "just now"; + if (diffSec < 60) return `${diffSec}s ago`; + + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + + const diffDay = Math.floor(diffHr / 24); + return `${diffDay}d ago`; +} + +export function elapsedSeconds(start: string, end: string): number { + return Math.max(1, Math.round((Date.parse(end) - Date.parse(start)) / 1000)); +}