From 612520bbff5f69d940901e6ca4a9df205d19a1f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Dvo=C5=99=C3=A1k?= <150935816+Dvorinka@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:13:34 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20interactive=20terminal=20=E2=80=94=20WS?= =?UTF-8?q?-bridged=20docker=20exec=20PTY=20(#46)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /services/:id/terminal upgrades to a WebSocket attached to a `docker exec` PTY in the service's running replica. JSON frames both ways: stdin/resize out, stdout/exit/error in. Shell probe tries each candidate (bash→sh) since `command -v` needs a shell to run; distroless images get a clean "no shell" error instead of an OCI failure dump. - docker.Client gains ExecAttach + ExecResize wrappers - Frontend: xterm.js replaces the one-off console section — live PTY, fit-addon resizes forwarded over the socket - CLI: `containr shell ` bridges the local TTY (raw mode, SIGWINCH resize forwarding); `containr exec` keeps the one-off command path - Access mirrors service logs: project manage scope required Generated with [Devin](https://devin.ai) Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ROADMAP2.md | 9 +- app/backend/internal/api/routes.go | 3 + app/backend/internal/api/terminal.go | 212 ++++++++++++++++++ app/backend/internal/cli/commands/exec.go | 113 ++++++++++ app/backend/internal/cli/root.go | 1 + app/backend/internal/docker/client.go | 15 ++ app/frontend/package-lock.json | 17 ++ app/frontend/package.json | 2 + .../service/components/ServiceTerminal.tsx | 107 +++++++++ .../service/pages/ServiceDetailPage.tsx | 75 +------ app/frontend/src/generated/api-types.ts | 60 +++++ docs/api/openapi.yaml | 29 +++ docs/guides/CLI.md | 2 + 13 files changed, 570 insertions(+), 75 deletions(-) create mode 100644 app/backend/internal/api/terminal.go create mode 100644 app/backend/internal/cli/commands/exec.go create mode 100644 app/frontend/src/features/service/components/ServiceTerminal.tsx diff --git a/ROADMAP2.md b/ROADMAP2.md index 2b11bef..917c64e 100644 --- a/ROADMAP2.md +++ b/ROADMAP2.md @@ -307,9 +307,12 @@ and wired refs, one click or one CLI call. UI at `/operations` (Operate nav) with live refetch; `containr operations` CLI + `containr_operations_get` MCP. Remaining: agent commands once node fleet lands; flush/stuck-job tools. -- [ ] **Interactive terminal** — upgrade exec to WS PTY (`xterm.js`); - `nodes/:id/terminal` via agent (dflow's wetty without the extra - container). +- [x] **Interactive terminal** — `GET /services/:id/terminal` upgrades + to a WebSocket bridged to a `docker exec` PTY (bash→sh fallback, + `TERM=xterm-256color`, resize frames). xterm.js Console section on the + service page; `containr shell ` bridges a local TTY; distroless + images get a clean "no shell" error. Remaining: `nodes/:id/terminal` + via agent. - [x] **Team invites** — `POST /admin/invites` returns a one-time link (sha256-hashed token at rest, optional email binding, TTL); public `GET /auth/invites/:token` validates and diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index 4103c24..c4d3c96 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -388,6 +388,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg authed.POST("/deployments/:id/rollback", handleRollbackDeployment) authed.POST("/deployments/:id/cancel", handleCancelDeployment) + // Interactive terminal — WebSocket → docker exec pty. + authed.GET("/services/:id/terminal", handleServiceTerminal) + // Environment variables — secrets, owner/admin only. authed.GET("/services/:id/variables", handleGetVariables) authed.PUT("/services/:id/variables", handleUpdateVariables) diff --git a/app/backend/internal/api/terminal.go b/app/backend/internal/api/terminal.go new file mode 100644 index 0000000..ed460c4 --- /dev/null +++ b/app/backend/internal/api/terminal.go @@ -0,0 +1,212 @@ +package api + +import ( + "context" + "encoding/json" + "io" + "log" + "net/http" + "sort" + "strings" + "sync" + "time" + + "containr/internal/database" + "containr/internal/docker" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/filters" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/gorilla/websocket" +) + +// Interactive terminal: a WebSocket bridged to a `docker exec` pty in the +// service's container. Protocol is JSON text frames both ways: +// client → server: {"type":"stdin","data":"..."} | {"type":"resize","cols":N,"rows":N} +// server → client: {"type":"stdout","data":"..."} | {"type":"exit","code":N} | {"type":"error","message":"..."} +// Shell preference: $SHELL, then bash, then sh. + +type terminalFrame struct { + Type string `json:"type"` + Data string `json:"data,omitempty"` + Cols uint `json:"cols,omitempty"` + Rows uint `json:"rows,omitempty"` + Code int `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} + +func handleServiceTerminal(c *gin.Context) { + db := c.MustGet("db").(*database.DB) + serviceID, err := uuid.Parse(c.Param("id")) + if err != nil { + respondError(c, http.StatusBadRequest, "INVALID_ID", "invalid service id") + return + } + projectID, found := serviceProjectID(db, serviceID) + if !found { + respondError(c, http.StatusNotFound, "NOT_FOUND", "service not found") + return + } + if _, allowed := projectManageAccess(c, db, projectID); !allowed { + respondError(c, http.StatusNotFound, "NOT_FOUND", "service not found") + return + } + dockerClient, _ := c.Get("docker_client") + client, ok := dockerClient.(*docker.Client) + if !ok || client == nil { + respondError(c, http.StatusServiceUnavailable, "DOCKER_UNAVAILABLE", "Docker is not configured") + return + } + + ctx := c.Request.Context() + containers, err := client.ListContainersFiltered(ctx, filters.NewArgs( + filters.Arg("label", "containr.service="+serviceID.String()), + ), false) + if err != nil || len(containers) == 0 { + respondError(c, http.StatusConflict, "NO_CONTAINER", "service has no running container") + return + } + // Prefer a running container. With several matches, deterministically + // pick the first `containr--` replica by name — that is the + // actual workload naming; auxiliary containers get ignored. + target := "" + sort.Slice(containers, func(i, j int) bool { return containers[i].Names[0] < containers[j].Names[0] }) + for _, ctr := range containers { + if ctr.State == "running" && strings.HasPrefix(ctr.Names[0], "/containr-") { + target = ctr.ID + break + } + } + if target == "" { + for _, ctr := range containers { + if ctr.State == "running" { + target = ctr.ID + break + } + } + } + if target == "" { + target = containers[0].ID + } + + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + return + } + defer conn.Close() + + send := func(f terminalFrame) bool { + return conn.WriteJSON(f) == nil + } + + // Pick a shell that actually exists — distroless images may have none. + // `command -v` can't probe without a shell, so start each candidate and + // watch for the OCI "no such file" failure. + var stream types.HijackedResponse + var execID string + attached := false + for _, candidate := range []string{"/bin/bash", "/bin/sh", "sh"} { + exec, err := client.ExecCreate(ctx, target, docker.ExecConfig{ + Cmd: []string{candidate}, + AttachStdin: true, + AttachStdout: true, + AttachStderr: true, + Tty: true, + Env: []string{"TERM=xterm-256color", "COLORTERM=truecolor"}, + }) + if err != nil { + continue + } + s, err := client.ExecAttach(ctx, exec.ID) + if err != nil { + continue + } + if execStartFailed(ctx, client, exec.ID, s) { + s.Close() + continue + } + stream, execID, attached = s, exec.ID, true + break + } + if !attached { + send(terminalFrame{Type: "error", Message: "no shell available in this container image"}) + return + } + defer stream.Close() + + // The client sends a resize frame with its real size right after + // connecting; start at a sane default meanwhile. + _ = client.ExecResize(ctx, execID, 80, 24) + + var wsWrite sync.Mutex + writeJSON := func(f terminalFrame) { + wsWrite.Lock() + defer wsWrite.Unlock() + _ = conn.WriteJSON(f) + } + + // Container → client. + go func() { + buf := make([]byte, 8192) + for { + n, err := stream.Reader.Read(buf) + if n > 0 { + writeJSON(terminalFrame{Type: "stdout", Data: string(buf[:n])}) + } + if err != nil { + if err != io.EOF { + log.Printf("terminal: exec stream ended: %v", err) + } + inspect, ierr := client.ExecInspect(context.Background(), execID) + code := 0 + if ierr == nil { + code = inspect.ExitCode + } + writeJSON(terminalFrame{Type: "exit", Code: code}) + _ = conn.WriteControl(websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseNormalClosure, "session ended"), + time.Now().Add(time.Second)) + return + } + } + }() + + // Client → container. + for { + _, raw, err := conn.ReadMessage() + if err != nil { + return + } + var f terminalFrame + if err := json.Unmarshal(raw, &f); err != nil { + continue + } + switch f.Type { + case "stdin": + if _, err := stream.Conn.Write([]byte(f.Data)); err != nil { + return + } + case "resize": + _ = client.ExecResize(ctx, execID, f.Cols, f.Rows) + case "ping": + writeJSON(terminalFrame{Type: "pong"}) + } + } +} + +// execStartFailed reports whether an attached exec failed to launch +// (missing binary → non-zero exit, not running). The OCI failure takes a +// moment to surface, so a short grace period elapses before judging. +func execStartFailed(ctx context.Context, client *docker.Client, execID string, _ types.HijackedResponse) bool { + select { + case <-ctx.Done(): + return true + case <-time.After(500 * time.Millisecond): + } + inspect, err := client.ExecInspect(ctx, execID) + if err != nil { + return false + } + return !inspect.Running && inspect.ExitCode != 0 +} diff --git a/app/backend/internal/cli/commands/exec.go b/app/backend/internal/cli/commands/exec.go new file mode 100644 index 0000000..c63a099 --- /dev/null +++ b/app/backend/internal/cli/commands/exec.go @@ -0,0 +1,113 @@ +package commands + +import ( + "fmt" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/gorilla/websocket" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +// ShellCmd opens an interactive shell inside a service's running container. +var ShellCmd = &cobra.Command{ + Use: "shell ", + Short: "Interactive shell inside the service container", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + serviceID := args[0] + cli, err := client() + if err != nil { + return err + } + base := strings.TrimRight(cli.BaseURL, "/") + wsURL := strings.Replace(base, "http", "ws", 1) + "/services/" + serviceID + "/terminal" + + header := http.Header{} + header.Set("Authorization", "Bearer "+cli.Token) + conn, _, err := websocket.DefaultDialer.Dial(wsURL, header) + if err != nil { + return fmt.Errorf("terminal: %w", err) + } + defer conn.Close() + + fd := int(os.Stdin.Fd()) + raw := term.IsTerminal(fd) + var oldState *term.State + if raw { + oldState, err = term.MakeRaw(fd) + if err == nil { + defer term.Restore(fd, oldState) + } + if w, h, err := term.GetSize(fd); err == nil { + _ = conn.WriteJSON(map[string]interface{}{"type": "resize", "cols": w, "rows": h}) + } + // Forward SIGWINCH resizes. + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGWINCH) + defer signal.Stop(sigCh) + go func() { + for range sigCh { + if w, h, err := term.GetSize(fd); err == nil { + _ = conn.WriteJSON(map[string]interface{}{"type": "resize", "cols": w, "rows": h}) + } + } + }() + } + + done := make(chan struct{}) + // stdin → ws + go func() { + buf := make([]byte, 4096) + for { + n, err := os.Stdin.Read(buf) + if n > 0 { + if werr := conn.WriteJSON(map[string]interface{}{ + "type": "stdin", "data": string(buf[:n]), + }); werr != nil { + return + } + } + if err != nil { + return + } + } + }() + // ws → stdout + go func() { + defer close(done) + for { + var frame struct { + Type string `json:"type"` + Data string `json:"data"` + Code int `json:"code"` + Message string `json:"message"` + } + if err := conn.ReadJSON(&frame); err != nil { + return + } + switch frame.Type { + case "stdout": + _, _ = os.Stdout.WriteString(frame.Data) + case "exit": + fmt.Fprintf(os.Stderr, "\r\n[session ended — exit %d]\r\n", frame.Code) + return + case "error": + fmt.Fprintf(os.Stderr, "\r\nerror: %s\r\n", frame.Message) + return + } + } + }() + + select { + case <-done: + case <-time.After(24 * time.Hour): + } + return nil + }, +} diff --git a/app/backend/internal/cli/root.go b/app/backend/internal/cli/root.go index 62b6eb3..ac73b47 100644 --- a/app/backend/internal/cli/root.go +++ b/app/backend/internal/cli/root.go @@ -90,6 +90,7 @@ func init() { rootCmd.AddCommand(commands.WebhooksCmd) rootCmd.AddCommand(commands.OperationsCmd) rootCmd.AddCommand(commands.ActivityCmd) + rootCmd.AddCommand(commands.ShellCmd) rootCmd.AddCommand(commands.BannersCmd) rootCmd.AddCommand(commands.InvitesCmd) rootCmd.AddCommand(commands.UpCmd) diff --git a/app/backend/internal/docker/client.go b/app/backend/internal/docker/client.go index b1d08f1..ceb9ce7 100644 --- a/app/backend/internal/docker/client.go +++ b/app/backend/internal/docker/client.go @@ -516,6 +516,21 @@ func (c *Client) ExecRun(ctx context.Context, containerID string, cmd []string) return buf.String(), inspect.ExitCode, nil } +// ExecAttach attaches to a running exec instance, returning the hijacked +// bidirectional stream. With Tty enabled the stream carries raw pty data +// (no stdcopy multiplexing). +func (c *Client) ExecAttach(ctx context.Context, execID string) (types.HijackedResponse, error) { + return c.cli.ContainerExecAttach(ctx, execID, container.ExecAttachOptions{}) +} + +// ExecResize resizes the pty of a running exec instance. +func (c *Client) ExecResize(ctx context.Context, execID string, cols, rows uint) error { + return c.cli.ContainerExecResize(ctx, execID, container.ResizeOptions{ + Height: rows, + Width: cols, + }) +} + // GetImageInfo returns information about a Docker image func (c *Client) GetImageInfo(ctx context.Context, imageName string) (*ImageInfo, error) { images, err := c.cli.ImageList(ctx, image.ListOptions{}) diff --git a/app/frontend/package-lock.json b/app/frontend/package-lock.json index 06c1088..d76d961 100644 --- a/app/frontend/package-lock.json +++ b/app/frontend/package-lock.json @@ -33,6 +33,8 @@ "@tailwindcss/typography": "^0.5.19", "@tanstack/react-query": "^5.66.0", "@types/dompurify": "^3.0.5", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "@xyflow/react": "^12.10.0", "autoprefixer": "^10.4.24", "axios": "^1.7.9", @@ -4262,6 +4264,21 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/@xyflow/react": { "version": "12.10.1", "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz", diff --git a/app/frontend/package.json b/app/frontend/package.json index 73c5952..b758cd3 100644 --- a/app/frontend/package.json +++ b/app/frontend/package.json @@ -44,6 +44,8 @@ "@tailwindcss/typography": "^0.5.19", "@tanstack/react-query": "^5.66.0", "@types/dompurify": "^3.0.5", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "@xyflow/react": "^12.10.0", "autoprefixer": "^10.4.24", "axios": "^1.7.9", diff --git a/app/frontend/src/features/service/components/ServiceTerminal.tsx b/app/frontend/src/features/service/components/ServiceTerminal.tsx new file mode 100644 index 0000000..d559cb9 --- /dev/null +++ b/app/frontend/src/features/service/components/ServiceTerminal.tsx @@ -0,0 +1,107 @@ +import { useEffect, useRef, useState } from 'react'; +import { Terminal } from '@xterm/xterm'; +import { FitAddon } from '@xterm/addon-fit'; +import '@xterm/xterm/css/xterm.css'; +import { Loader2 } from 'lucide-react'; +import { getApiBaseUrl } from '@/lib/api-client'; + +type Frame = { + type: 'stdin' | 'resize' | 'stdout' | 'exit' | 'error' | 'ping' | 'pong'; + data?: string; + cols?: number; + rows?: number; + code?: number; + message?: string; +}; + +// Interactive shell: WebSocket → docker exec pty in the service container. +export function ServiceTerminal({ serviceId }: { serviceId: string }) { + const hostRef = useRef(null); + const [status, setStatus] = useState<'connecting' | 'live' | 'ended' | 'error'>('connecting'); + + useEffect(() => { + if (!hostRef.current) return; + + const term = new Terminal({ + fontFamily: 'var(--font-mono, ui-monospace, monospace)', + fontSize: 13, + cursorBlink: true, + convertEol: false, + theme: { + background: '#0a0a0c', + foreground: '#d4d4d8', + cursor: '#a1a1aa', + selectionBackground: '#3f3f46', + }, + }); + const fit = new FitAddon(); + term.loadAddon(fit); + term.open(hostRef.current); + fit.fit(); + + const wsUrl = getApiBaseUrl().replace(/^http/, 'ws'); + const ws = new WebSocket(`${wsUrl}/services/${serviceId}/terminal`); + + ws.onopen = () => { + setStatus('live'); + const { cols, rows } = term; + const frame: Frame = { type: 'resize', cols, rows }; + ws.send(JSON.stringify(frame)); + }; + ws.onmessage = (ev) => { + let f: Frame; + try { + f = JSON.parse(ev.data); + } catch { + return; + } + if (f.type === 'stdout' && f.data) term.write(f.data); + else if (f.type === 'exit') { + setStatus('ended'); + term.write(`\r\n\x1b[90m[session ended — exit ${f.code ?? 0}]\x1b[0m\r\n`); + } else if (f.type === 'error') { + setStatus('error'); + term.write(`\r\n\x1b[31m${f.message ?? 'terminal error'}\x1b[0m\r\n`); + } + }; + ws.onerror = () => setStatus((s) => (s === 'live' ? s : 'error')); + ws.onclose = () => setStatus((s) => (s === 'error' ? s : 'ended')); + + const dataSub = term.onData((data) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'stdin', data } satisfies Frame)); + } + }); + + const observer = new ResizeObserver(() => { + fit.fit(); + if (ws.readyState === WebSocket.OPEN) { + const { cols, rows } = term; + ws.send(JSON.stringify({ type: 'resize', cols, rows } satisfies Frame)); + } + }); + observer.observe(hostRef.current); + + return () => { + observer.disconnect(); + dataSub.dispose(); + ws.close(); + term.dispose(); + }; + }, [serviceId]); + + return ( +
+ {status === 'connecting' && ( +
+ +
+ )} +
+
+ ); +} diff --git a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx index dff065b..0a69be8 100644 --- a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx +++ b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx @@ -26,7 +26,6 @@ import { deleteScalingPolicy, getServiceScalingState, manualScaleService, - execInService, rollbackDeployment, cancelDeployment, updateServiceVariables, @@ -59,6 +58,7 @@ import { getCurrentUserProfile } from '@/lib/api-client'; import { parseDotenv, validateVariableRows, type VariableDraft } from '../variable-utils'; import { formatBytes, formatDate, formatRelative, seededMetric } from '@/lib/time'; import { EnhancedMetricCard, LineAreaChart, DonutChart } from '@/shared/components'; +import { ServiceTerminal } from '@/features/service/components/ServiceTerminal'; import { Activity, FileText, @@ -338,21 +338,6 @@ export function ServiceDetailPage() { }, }); - // --- Console --- - const [consoleInput, setConsoleInput] = useState(''); - const [consoleHistory, setConsoleHistory] = useState< - Array<{ command: string; output: string; exitCode: number; error: string }> - >([]); - const execMutation = useMutation({ - mutationFn: (command: string) => execInService(serviceId, command), - onSuccess: (result, command) => { - setConsoleHistory((prev) => [ - { command, output: result.output ?? '', exitCode: result.exit_code ?? 0, error: result.error ?? '' }, - ...prev, - ]); - setConsoleInput(''); - }, - }); const triggerCronMutation = useMutation({ mutationFn: (id: string) => triggerCronJob(id), onSuccess: () => { @@ -1927,64 +1912,10 @@ export function ServiceDetailPage() {

Console

-

One-off commands via docker exec — 30s limit, 64KB output cap

+

Interactive shell inside the service container

- -
{ - e.preventDefault(); - const cmd = consoleInput.trim(); - if (cmd && !execMutation.isPending) execMutation.mutate(cmd); - }} - > - $ - setConsoleInput(e.target.value)} - placeholder="e.g. env | sort | head -20" - disabled={execMutation.isPending} - className="flex-1 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm font-mono text-[var(--text-primary)] disabled:opacity-50" - /> - -
- - {execMutation.isError && ( -

- {(execMutation.error as Error)?.message ?? 'Command failed'} -

- )} - - {consoleHistory.length === 0 ? ( -

- Commands run inside the service's running container as its default user. No interactive shell — stdout/stderr is captured and returned. -

- ) : ( -
- {consoleHistory.map((entry, i) => ( -
-
-

$ {entry.command}

- - {entry.error ? 'error' : `exit ${entry.exitCode}`} - -
- {(entry.output || entry.error) && ( -
-                        {entry.output}{entry.error ? `\n${entry.error}` : ''}
-                      
- )} -
- ))} -
- )} + )} diff --git a/app/frontend/src/generated/api-types.ts b/app/frontend/src/generated/api-types.ts index e94224e..dcc1031 100644 --- a/app/frontend/src/generated/api-types.ts +++ b/app/frontend/src/generated/api-types.ts @@ -1056,6 +1056,66 @@ export interface paths { patch?: never; trace?: never; }; + "/services/{id}/terminal": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Interactive terminal (WebSocket) + * @description Upgrades to a WebSocket bridged to a `docker exec` PTY in the service's running container (bash if present, else sh). Cookie/bearer auth as usual. Client→server JSON frames: `{"type":"stdin","data":"..."}`, `{"type":"resize","cols":N,"rows":N}`. Server→client frames: `{"type":"stdout","data":"..."}`, `{"type":"exit","code":N}`, `{"type":"error","message":"..."}`. + */ + get: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Switching protocols — WebSocket session established */ + 101: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Service not found or not visible */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Service has no running container */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Docker unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/internal/wake/{id}": { parameters: { query?: never; diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3d58648..e9db1dd 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -720,6 +720,35 @@ paths: items: type: string + /services/{id}/terminal: + get: + tags: + - Services + summary: Interactive terminal (WebSocket) + description: >- + Upgrades to a WebSocket bridged to a `docker exec` PTY in the + service's running container (bash if present, else sh). + Cookie/bearer auth as usual. + Client→server JSON frames: `{"type":"stdin","data":"..."}`, + `{"type":"resize","cols":N,"rows":N}`. + Server→client frames: `{"type":"stdout","data":"..."}`, + `{"type":"exit","code":N}`, `{"type":"error","message":"..."}`. + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '101': + description: Switching protocols — WebSocket session established + '404': + description: Service not found or not visible + '409': + description: Service has no running container + '503': + description: Docker unavailable + /internal/wake/{id}: get: tags: diff --git a/docs/guides/CLI.md b/docs/guides/CLI.md index 717ac9b..2fccb5e 100644 --- a/docs/guides/CLI.md +++ b/docs/guides/CLI.md @@ -86,6 +86,8 @@ operations live ops view: active deploys, queue depth, 24h failures, cron runs, backups activity enriched activity feed; --severity/--category/ --resource/--project/--limit filters +shell interactive PTY shell inside the service + container (bash→sh fallback; resizes follow) banners list|create|update|delete announcement banners (admin); active banners print on `auth status` invites list|create|revoke team invite links (admin); create