diff --git a/package.json b/package.json index f5773d5..33cc8ec 100644 --- a/package.json +++ b/package.json @@ -1,58 +1,62 @@ { - "name": "@dokploy/cli", - "description": "A CLI to manage dokploy server remotely", - "version": "0.3.1", - "author": "Mauricio Siu", - "licenses": [ - { - "type": "MIT", - "url": "https://github.com/Dokploy/cli/blob/master/LICENSE" - } - ], - "publishConfig": { - "access": "public" - }, - "bin": { - "dokploy": "./dist/index.js" - }, - "bugs": "https://github.com/Dokploy/cli/issues", - "dependencies": { - "axios": "^1.7.2", - "chalk": "^5.3.0", - "commander": "^13.1.0" - }, - "devDependencies": { - "@biomejs/biome": "2.1.1", - "@types/node": "^18", - "tsx": "^4.21.0", - "typescript": "^5", - "vitest": "^4.1.4" - }, - "engines": { - "node": ">=18.0.0" - }, - "files": [ - "/dist" - ], - "homepage": "https://github.com/Dokploy/cli", - "keywords": [ - "dokploy", - "cli" - ], - "license": "MIT", - "main": "dist/index.js", - "type": "module", - "repository": "Dokploy/cli", - "scripts": { - "build": "tsc -b", - "generate": "tsx scripts/generate.ts", - "prebuild": "pnpm run generate", - "dev": "tsx src/index.ts", - "lint": "biome check --write .", - "test": "vitest run" - }, - "types": "dist/index.d.ts", - "pnpm": { - "onlyBuiltDependencies": ["esbuild"] - } + "name": "@dokploy/cli", + "description": "A CLI to manage dokploy server remotely", + "version": "0.3.1", + "author": "Mauricio Siu", + "licenses": [ + { + "type": "MIT", + "url": "https://github.com/Dokploy/cli/blob/master/LICENSE" + } + ], + "publishConfig": { + "access": "public" + }, + "bin": { + "dokploy": "./dist/index.js" + }, + "bugs": "https://github.com/Dokploy/cli/issues", + "dependencies": { + "axios": "^1.7.2", + "chalk": "^5.3.0", + "commander": "^13.1.0", + "ws": "^8.20.0" + }, + "devDependencies": { + "@biomejs/biome": "2.1.1", + "@types/node": "^18", + "@types/ws": "^8.18.1", + "tsx": "^4.21.0", + "typescript": "^5", + "vitest": "^4.1.4" + }, + "engines": { + "node": ">=18.0.0" + }, + "files": [ + "/dist" + ], + "homepage": "https://github.com/Dokploy/cli", + "keywords": [ + "dokploy", + "cli" + ], + "license": "MIT", + "main": "dist/index.js", + "type": "module", + "repository": "Dokploy/cli", + "scripts": { + "build": "tsc -b", + "generate": "tsx scripts/generate.ts", + "prebuild": "pnpm run generate", + "dev": "tsx src/index.ts", + "lint": "biome check --write .", + "test": "vitest run" + }, + "types": "dist/index.d.ts", + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild" + ] + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f0dfec..27c9538 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: commander: specifier: ^13.1.0 version: 13.1.0 + ws: + specifier: ^8.20.0 + version: 8.20.0 devDependencies: '@biomejs/biome': specifier: 2.1.1 @@ -24,6 +27,9 @@ importers: '@types/node': specifier: ^18 version: 18.19.130 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 tsx: specifier: ^4.21.0 version: 4.21.0 @@ -386,6 +392,9 @@ packages: '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@vitest/expect@4.1.4': resolution: {integrity: sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==} @@ -804,6 +813,18 @@ packages: engines: {node: '>=8'} hasBin: true + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + 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 + snapshots: '@biomejs/biome@2.1.1': @@ -1017,6 +1038,10 @@ snapshots: dependencies: undici-types: 5.26.5 + '@types/ws@8.18.1': + dependencies: + '@types/node': 18.19.130 + '@vitest/expect@4.1.4': dependencies: '@standard-schema/spec': 1.1.0 @@ -1381,3 +1406,5 @@ snapshots: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + + ws@8.20.0: {} diff --git a/readme.md b/readme.md index 886366f..a62ec42 100644 --- a/readme.md +++ b/readme.md @@ -8,6 +8,14 @@ Dokploy CLI is a command-line tool to manage your Dokploy server remotely. It pr npm install -g @dokploy/cli ``` +## Getting Your API Key + +1. Log in to your Dokploy panel +2. Click **Account** (bottom-left corner) +3. Go to **Profile** +4. Scroll down to **API/CLI Keys** +5. Create a new key and copy it + ## Authentication ### Option 1: Using the `auth` command @@ -34,6 +42,94 @@ DOKPLOY_API_KEY="YOUR_API_KEY" The CLI loads it automatically. Shell environment variables take priority over the `.env` file. +## Exec Command + +Open an interactive terminal session in a running Dokploy service container — similar to `heroku run`. No SSH access required, just a Dokploy API key. + +```bash +dokploy exec --app [--project ] [--env ] [--shell ] [command...] +``` + +### Options + +| Option | Description | Default | +|---|---|---| +| `--app ` | Service name in Dokploy (required) | | +| `--project ` | Project name (if ambiguous across projects) | auto-detect | +| `--env ` | Environment name (e.g. staging, production) | auto-detect | +| `--shell ` | Shell to use (bash, sh, zsh, ash) | bash | + +### Examples + +```bash +# Interactive shell into a container +dokploy exec --env staging --app Web + +# Rails console +dokploy exec --env staging --app Web rails c + +# Run a one-off command +dokploy exec --app Web -- rails db:migrate + +# If app name is unique across environments, --env is optional +dokploy exec --app Web rails c + +# Narrow by project if the same app name exists in multiple projects +dokploy exec --project "My Project" --env staging --app Web rails c + +# Connect to non-application services (redis, postgres, etc.) +dokploy exec --app Cache +``` + +If the same service name exists in multiple environments or projects, the CLI will prompt you to disambiguate: + +``` +Multiple services named "Web" found. Use --project and/or --env to specify: + - My Project / staging (applications) + - My Project / production (applications) +``` + +### Supported service types + +The `exec` command works with all Dokploy service types: + +- Applications +- Redis +- PostgreSQL +- MongoDB +- MySQL +- MariaDB +- Compose + +### How it works + +The `exec` command connects to containers through Dokploy's built-in WebSocket terminal — the same mechanism used by the web panel's Terminal tab. + +``` +Step 1: Find the service + dokploy exec --app Web --env staging + │ │ + ▼ ▼ + Call project.all API → search all projects/environments/service types + → resolve to internal Docker app name + server ID + +Step 2: Find the running container + Call docker.getContainersByAppLabel API + → find running containers for the service + → pick the first running replica + +Step 3: Connect via WebSocket + Open WebSocket to /docker-container-terminal + → authenticate with x-api-key header + → pipe stdin/stdout between your terminal and the container +``` + +``` +Your terminal → HTTPS/WSS → Dokploy panel → SSH → Docker host → docker exec → container +``` + +No direct SSH access to the server is needed — authentication is handled entirely through the Dokploy API key. + ## Usage ```bash diff --git a/src/commands/exec.ts b/src/commands/exec.ts new file mode 100644 index 0000000..08bee02 --- /dev/null +++ b/src/commands/exec.ts @@ -0,0 +1,297 @@ +import axios from "axios"; +import chalk from "chalk"; +import type { Command } from "commander"; +import WebSocket from "ws"; +import { readAuthConfig } from "../client.js"; + +interface AuthConfig { + token: string; + url: string; +} + +interface AppInfo { + appName: string; + serviceId: string; + serviceType: string; + serverId?: string; + envName: string; + projectName: string; +} + +interface ContainerInfo { + containerId: string; + name: string; + state: string; +} + +const SERVICE_TYPES = [ + { + key: "applications", + idField: "applicationId", + endpoint: "application.one", + }, + { key: "redis", idField: "redisId", endpoint: "redis.one" }, + { key: "postgres", idField: "postgresId", endpoint: "postgres.one" }, + { key: "mongo", idField: "mongoId", endpoint: "mongo.one" }, + { key: "mysql", idField: "mysqlId", endpoint: "mysql.one" }, + { key: "mariadb", idField: "mariadbId", endpoint: "mariadb.one" }, + { key: "compose", idField: "composeId", endpoint: "compose.one" }, +] as const; + +async function trpcGet( + auth: AuthConfig, + endpoint: string, + params: Record, +) { + const response = await axios.get(`${auth.url}/api/trpc/${endpoint}`, { + params: { input: JSON.stringify({ json: params }) }, + headers: { "x-api-key": auth.token }, + }); + return response.data?.result?.data?.json ?? response.data; +} + +async function findService( + auth: AuthConfig, + appName: string, + envName?: string, + projectName?: string, +): Promise { + const projects = await trpcGet(auth, "project.all", {}); + + const matches: AppInfo[] = []; + + for (const project of projects) { + if ( + projectName && + project.name.toLowerCase() !== projectName.toLowerCase() + ) { + continue; + } + + for (const env of project.environments ?? []) { + if (envName && env.name.toLowerCase() !== envName.toLowerCase()) { + continue; + } + + for (const svcType of SERVICE_TYPES) { + const services = env[svcType.key] ?? []; + for (const svc of services) { + if ( + svc.name === appName || + svc.appName === appName || + svc[svcType.idField] === appName + ) { + const detail = await trpcGet(auth, svcType.endpoint, { + [svcType.idField]: svc[svcType.idField], + }); + matches.push({ + appName: detail.appName, + serviceId: svc[svcType.idField], + serviceType: svcType.key, + serverId: detail.serverId ?? undefined, + envName: env.name, + projectName: project.name, + }); + } + } + } + } + } + + if (matches.length === 0) { + const hints: string[] = []; + if (projectName) hints.push(`project "${projectName}"`); + if (envName) hints.push(`environment "${envName}"`); + const hintStr = hints.length > 0 ? ` in ${hints.join(", ")}` : ""; + throw new Error( + `Service "${appName}" not found${hintStr}. Available services:\n${listServices(projects)}`, + ); + } + + if (matches.length > 1) { + const list = matches + .map((m) => ` - ${m.projectName} / ${m.envName} (${m.serviceType})`) + .join("\n"); + throw new Error( + `Multiple services named "${appName}" found. Use --project and/or --env to specify:\n${list}`, + ); + } + + return matches[0]; +} + +function listServices(projects: any[]): string { + const services: string[] = []; + for (const project of projects) { + for (const env of project.environments ?? []) { + for (const svcType of SERVICE_TYPES) { + for (const svc of env[svcType.key] ?? []) { + const type = svcType.key === "applications" ? "app" : svcType.key; + services.push( + ` - ${svc.name} [${type}] (${project.name} / ${env.name})`, + ); + } + } + } + } + return services.join("\n") || " (none)"; +} + +async function findContainer( + auth: AuthConfig, + appName: string, + serverId?: string, +): Promise { + const params: Record = { + appName, + type: "standalone", + }; + if (serverId) { + params.serverId = serverId; + } + + const containers: ContainerInfo[] = await trpcGet( + auth, + "docker.getContainersByAppLabel", + params, + ); + + const running = containers.filter((c) => c.state === "running"); + + if (running.length === 0) { + throw new Error( + `No running containers found for "${appName}". Is the service deployed?`, + ); + } + + return running[0]; +} + +export function registerExecCommand(program: Command) { + program + .command("exec") + .description( + "Execute a command in a running Dokploy service container (like heroku run)", + ) + .requiredOption("--app ", "Service name in Dokploy") + .option("--project ", "Project name in Dokploy") + .option("--env ", "Environment name (e.g. staging, production)") + .option("--shell ", "Shell to use (bash, sh, zsh, ash)", "bash") + .argument("[command...]", "Command to run (e.g. rails c)") + .action( + async ( + commandArgs: string[], + opts: { app: string; project?: string; env?: string; shell: string }, + ) => { + const auth = readAuthConfig(); + + const labels: string[] = []; + if (opts.project) labels.push(opts.project); + if (opts.env) labels.push(opts.env); + const labelStr = labels.length > 0 ? ` (${labels.join(" / ")})` : ""; + console.log(chalk.blue(`Finding service "${opts.app}"${labelStr}...`)); + + let appInfo: AppInfo; + try { + appInfo = await findService(auth, opts.app, opts.env, opts.project); + } catch (err: any) { + console.error(chalk.red(err.message)); + process.exit(1); + } + + const typeLabel = + appInfo.serviceType === "applications" ? "app" : appInfo.serviceType; + console.log( + chalk.blue( + `Found ${appInfo.appName} [${typeLabel}] (${appInfo.projectName} / ${appInfo.envName}), looking for running container...`, + ), + ); + + let container: ContainerInfo; + try { + container = await findContainer( + auth, + appInfo.appName, + appInfo.serverId, + ); + } catch (err: any) { + console.error(chalk.red(err.message)); + process.exit(1); + } + + console.log( + chalk.blue( + `Connecting to container ${container.containerId} (${container.name})...`, + ), + ); + + // Build WebSocket URL + const wsProtocol = auth.url.startsWith("https") ? "wss" : "ws"; + const host = auth.url.replace(/^https?:\/\//, ""); + + let wsUrl = `${wsProtocol}://${host}/docker-container-terminal?containerId=${container.containerId}&activeWay=${opts.shell}`; + if (appInfo.serverId) { + wsUrl += `&serverId=${appInfo.serverId}`; + } + + const ws = new WebSocket(wsUrl, { + headers: { + "x-api-key": auth.token, + }, + }); + + const userCommand = + commandArgs.length > 0 ? commandArgs.join(" ") : null; + + ws.on("open", () => { + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + } + process.stdin.resume(); + + if (userCommand) { + setTimeout(() => { + ws.send(`${userCommand}\n`); + }, 500); + } + + process.stdin.on("data", (data) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(data.toString("utf8")); + } + }); + }); + + ws.on("message", (data) => { + const msg = data.toString(); + if (msg.includes("Container closed with code:")) { + process.stdout.write(msg); + ws.close(); + return; + } + process.stdout.write(msg); + }); + + ws.on("close", () => { + if (process.stdin.isTTY) { + process.stdin.setRawMode(false); + } + process.exit(0); + }); + + ws.on("error", (err) => { + if (process.stdin.isTTY) { + process.stdin.setRawMode(false); + } + console.error(chalk.red(`\nConnection error: ${err.message}`)); + process.exit(1); + }); + + for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.on(signal, () => { + ws.close(); + }); + } + }, + ); +} diff --git a/src/index.ts b/src/index.ts index 3e6b1c5..bb35605 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import chalk from "chalk"; import { program } from "commander"; import { registerAuthCommand } from "./commands/auth.js"; +import { registerExecCommand } from "./commands/exec.js"; import { registerGeneratedCommands } from "./generated/commands.js"; const pkg = { @@ -20,6 +21,7 @@ program }); registerAuthCommand(program); +registerExecCommand(program); registerGeneratedCommands(program); const argv = process.argv.filter((arg) => arg !== "--");