diff --git a/readme.md b/readme.md index 886366f..2d588f2 100644 --- a/readme.md +++ b/readme.md @@ -34,6 +34,43 @@ DOKPLOY_API_KEY="YOUR_API_KEY" The CLI loads it automatically. Shell environment variables take priority over the `.env` file. +### Multiple accounts (profiles) + +Manage several Dokploy instances or organizations with named profiles. Profiles are stored in `~/.dokploy/config.json` (override with `DOKPLOY_CONFIG_DIR`). + +```bash +# Save credentials under a named profile +dokploy auth --profile prod -u https://panel.dokploy.com -t PROD_API_KEY +dokploy auth --profile staging -u https://staging.example.com -t STAGING_API_KEY + +# List profiles +dokploy profiles list +# * prod https://panel.dokploy.com +# staging https://staging.example.com + +# Switch the active profile +dokploy profiles use staging + +# Show the active profile +dokploy profiles current + +# Remove a profile +dokploy profiles remove prod +``` + +Override the active profile per-command with the global `--profile` flag or the `DOKPLOY_PROFILE` env var: + +```bash +dokploy --profile prod project list +DOKPLOY_PROFILE=staging dokploy project list +``` + +Without a `--profile` flag, credentials resolve in this order: + +1. `DOKPLOY_URL` + `DOKPLOY_API_KEY` / `DOKPLOY_AUTH_TOKEN` env vars +2. The active profile (set with `dokploy profiles use`, or the first profile saved) +3. The `default` profile + ## Usage ```bash diff --git a/src/client.ts b/src/client.ts index dc31d14..8bb985e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,4 +1,5 @@ import * as fs from "node:fs"; +import * as os from "node:os"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import axios, { type AxiosInstance } from "axios"; @@ -6,13 +7,27 @@ import chalk from "chalk"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const configPath = path.join(__dirname, "..", "config.json"); +const legacyConfigPath = path.join(__dirname, "..", "config.json"); +const configDir = + process.env.DOKPLOY_CONFIG_DIR ?? path.join(os.homedir(), ".dokploy"); +const configPath = path.join(configDir, "config.json"); export interface AuthConfig { token: string; url: string; } +interface StoredProfile extends AuthConfig {} + +interface StoredConfig { + currentProfile: string; + profiles: Record; +} + +export function getConfigPath(): string { + return configPath; +} + function loadEnvFile(): void { const envPath = path.resolve(process.cwd(), ".env"); if (!fs.existsSync(envPath)) return; @@ -24,50 +39,149 @@ function loadEnvFile(): void { const eqIndex = trimmed.indexOf("="); if (eqIndex === -1) continue; const key = trimmed.slice(0, eqIndex).trim(); - const value = trimmed.slice(eqIndex + 1).trim().replace(/^["']|["']$/g, ""); + const value = trimmed + .slice(eqIndex + 1) + .trim() + .replace(/^["']|["']$/g, ""); if (!process.env[key]) { process.env[key] = value; } } } -export function readAuthConfig(): AuthConfig { +function readStoredConfig(): StoredConfig | null { + if (!fs.existsSync(configPath)) return null; + try { + return JSON.parse(fs.readFileSync(configPath, "utf8")) as StoredConfig; + } catch { + return null; + } +} + +function migrateLegacyConfig(): void { + if (fs.existsSync(configPath)) return; + if (!fs.existsSync(legacyConfigPath)) return; + + try { + const legacy = JSON.parse( + fs.readFileSync(legacyConfigPath, "utf8"), + ) as AuthConfig; + if (legacy?.url && legacy?.token) { + saveAuthConfig(legacy.url, legacy.token, "default"); + fs.renameSync(legacyConfigPath, `${legacyConfigPath}.bak`); + } + } catch { + // ignore malformed legacy config + } +} + +export function getCurrentProfile(): string { + const envProfile = process.env.DOKPLOY_PROFILE; + if (envProfile) return envProfile; + + const config = readStoredConfig(); + if (config?.currentProfile) return config.currentProfile; + + return "default"; +} + +export function listProfiles(): { name: string; url: string }[] { + migrateLegacyConfig(); + const config = readStoredConfig(); + if (!config) return []; + return Object.entries(config.profiles).map(([name, profile]) => ({ + name, + url: profile.url, + })); +} + +export function setCurrentProfile(name: string): void { + const config = readStoredConfig() ?? { + currentProfile: "default", + profiles: {}, + }; + if (!config.profiles[name]) { + throw new Error( + `Profile '${name}' does not exist. Run 'dokploy auth --profile ' first.`, + ); + } + config.currentProfile = name; + fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), { + mode: 0o600, + }); +} + +export function removeProfile(name: string): void { + const config = readStoredConfig(); + if (!config?.profiles[name]) { + throw new Error(`Profile '${name}' does not exist.`); + } + delete config.profiles[name]; + if (config.currentProfile === name) { + const remaining = Object.keys(config.profiles); + config.currentProfile = remaining[0] ?? "default"; + } + fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), { + mode: 0o600, + }); +} + +export function readAuthConfig(profile?: string): AuthConfig { loadEnvFile(); + migrateLegacyConfig(); + const selectedProfile = profile ?? getCurrentProfile(); const envToken = process.env.DOKPLOY_API_KEY ?? process.env.DOKPLOY_AUTH_TOKEN; const envUrl = process.env.DOKPLOY_URL; - if (envToken && envUrl) { + // Explicit profile selection takes priority, but allow env vars to + // override when no profile is selected (backward compatible). + if (!profile && envToken && envUrl) { return { token: envToken, url: envUrl }; } - if (!fs.existsSync(configPath)) { - console.error( - chalk.red( - "No configuration found. Please run 'dokploy auth' first or set DOKPLOY_URL and DOKPLOY_AUTH_TOKEN environment variables.", - ), - ); - process.exit(1); - } + const config = readStoredConfig(); + const stored = config?.profiles[selectedProfile]; - const config = JSON.parse(fs.readFileSync(configPath, "utf8")); - const { token, url } = config; + if (stored?.url && stored?.token) { + return { url: stored.url, token: stored.token }; + } - if (!url || !token) { - console.error( - chalk.red( - "Incomplete auth config. Run 'dokploy auth' or set environment variables.", - ), - ); - process.exit(1); + if (envToken && envUrl) { + return { token: envToken, url: envUrl }; } - return { token, url }; + console.error( + chalk.red( + `No configuration found for profile '${selectedProfile}'. Run 'dokploy auth --profile ${selectedProfile} -u -t ' or set DOKPLOY_URL and DOKPLOY_API_KEY environment variables.`, + ), + ); + process.exit(1); } -export function saveAuthConfig(url: string, token: string): void { - fs.writeFileSync(configPath, JSON.stringify({ url, token }, null, 2)); +export function saveAuthConfig( + url: string, + token: string, + profile = "default", +): void { + const config = readStoredConfig() ?? { + currentProfile: "default", + profiles: {}, + }; + config.profiles[profile] = { url, token }; + if ( + config.currentProfile === "default" || + Object.keys(config.profiles).length === 1 + ) { + config.currentProfile = profile; + } + fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), { + mode: 0o600, + }); } export function createClient(): AxiosInstance { diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 91b6826..0a2b858 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -7,6 +7,11 @@ export function registerAuthCommand(program: Command) { program .command("auth") .description("Authenticate with your Dokploy server") + .option( + "-p, --profile ", + "Profile name to save these credentials under (default: default)", + "default", + ) .requiredOption( "-u, --url ", "Server URL (e.g., https://panel.dokploy.com)", @@ -15,7 +20,7 @@ export function registerAuthCommand(program: Command) { "-t, --token ", "API key from your Dokploy dashboard", ) - .action(async (opts: { url: string; token: string }) => { + .action(async (opts: { url: string; token: string; profile: string }) => { const url = opts.url.replace(/\/+$/, ""); console.log(chalk.blue("Validating credentials...")); @@ -28,8 +33,12 @@ export function registerAuthCommand(program: Command) { }, }); - saveAuthConfig(url, opts.token); - console.log(chalk.green("Authenticated successfully.")); + saveAuthConfig(url, opts.token, opts.profile); + console.log( + chalk.green( + `Authenticated successfully. Saved profile '${opts.profile}'.`, + ), + ); } catch (error: any) { console.error(chalk.red(`Authentication failed: ${error.message}`)); process.exit(1); diff --git a/src/commands/profile.ts b/src/commands/profile.ts new file mode 100644 index 0000000..8e1be39 --- /dev/null +++ b/src/commands/profile.ts @@ -0,0 +1,80 @@ +import chalk from "chalk"; +import type { Command } from "commander"; +import { + getCurrentProfile, + listProfiles, + removeProfile, + setCurrentProfile, +} from "../client.js"; + +export function registerProfileCommands(program: Command) { + const profiles = program + .command("profiles") + .description("Manage multiple Dokploy accounts (profiles)"); + + profiles + .command("list") + .description("List all saved profiles") + .action(() => { + const items = listProfiles(); + const current = getCurrentProfile(); + + if (items.length === 0) { + console.log( + chalk.yellow( + "No profiles found. Run 'dokploy auth --profile -u -t ' to add one.", + ), + ); + return; + } + + for (const { name, url } of items) { + const marker = name === current ? "*" : " "; + console.log(`${marker} ${chalk.cyan(name.padEnd(16))} ${url}`); + } + console.log(chalk.dim(`\nActive profile: ${current}`)); + }); + + profiles + .command("use ") + .description("Switch the active profile") + .action((name: string) => { + try { + setCurrentProfile(name); + console.log(chalk.green(`Switched to profile '${name}'.`)); + } catch (error: any) { + console.error(chalk.red(error.message)); + process.exit(1); + } + }); + + profiles + .command("current") + .description("Show the active profile") + .action(() => { + console.log(getCurrentProfile()); + }); + + profiles + .command("remove ") + .description("Remove a saved profile") + .action((name: string) => { + try { + removeProfile(name); + console.log(chalk.green(`Removed profile '${name}'.`)); + } catch (error: any) { + console.error(chalk.red(error.message)); + process.exit(1); + } + }); + + profiles.addHelpText( + "after", + ` +Examples: + dokploy auth --profile prod -u https://panel.dokploy.com -t + dokploy profiles list + dokploy profiles use prod + dokploy --profile staging project list`, + ); +} diff --git a/src/index.ts b/src/index.ts index add938b..ab289c5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { readFileSync } from "node:fs"; import chalk from "chalk"; import { program } from "commander"; import { registerAuthCommand } from "./commands/auth.js"; +import { registerProfileCommands } from "./commands/profile.js"; import { registerGeneratedCommands } from "./generated/commands.js"; const packageJson = JSON.parse( @@ -20,11 +21,21 @@ program .name(pkg.name) .version(pkg.version) .description(pkg.description) + .enablePositionalOptions() + .option("--profile ", "Profile to use (overrides active profile)") .action(() => { program.help(); }); +program.hook("preAction", (_thisCommand) => { + const rootOpts = program.opts() as { profile?: string }; + if (rootOpts.profile) { + process.env.DOKPLOY_PROFILE = rootOpts.profile; + } +}); + registerAuthCommand(program); +registerProfileCommands(program); registerGeneratedCommands(program); const argv = process.argv.filter((arg) => arg !== "--"); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 8e215a0..52f6f64 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -1,8 +1,9 @@ import { execFileSync } from "node:child_process"; import * as fs from "node:fs"; +import * as os from "node:os"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, ".."); @@ -11,10 +12,16 @@ const packageJson = JSON.parse( fs.readFileSync(path.join(ROOT, "package.json"), "utf8"), ) as { version: string }; +const testConfigDir = path.join(os.tmpdir(), `dokploy-cli-test-${process.pid}`); + function run(...args: string[]): string { return execFileSync("node", [CLI, ...args], { encoding: "utf8", - env: { ...process.env, NO_COLOR: "1" }, + env: { + ...process.env, + NO_COLOR: "1", + DOKPLOY_CONFIG_DIR: testConfigDir, + }, }); } @@ -88,3 +95,83 @@ describe("CLI", () => { expect(output).toContain("Usage:"); }); }); + +describe("profiles command", () => { + beforeEach(() => { + fs.mkdirSync(testConfigDir, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(testConfigDir, { recursive: true, force: true }); + }); + + function writeConfig( + profiles: Record, + currentProfile: string, + ) { + fs.writeFileSync( + path.join(testConfigDir, "config.json"), + JSON.stringify({ currentProfile, profiles }, null, 2), + ); + } + + it("should list profiles", () => { + writeConfig( + { + prod: { url: "https://prod.example.com", token: "t" }, + staging: { url: "https://staging.example.com", token: "t" }, + }, + "prod", + ); + const output = run("profiles", "list"); + expect(output).toContain("prod"); + expect(output).toContain("staging"); + expect(output).toContain("https://staging.example.com"); + }); + + it("should switch the active profile", () => { + writeConfig( + { + prod: { url: "https://prod.example.com", token: "t" }, + staging: { url: "https://staging.example.com", token: "t" }, + }, + "prod", + ); + run("profiles", "use", "staging"); + const output = run("profiles", "current"); + expect(output.trim()).toBe("staging"); + }); + + it("should show current profile", () => { + writeConfig( + { prod: { url: "https://prod.example.com", token: "t" } }, + "prod", + ); + const output = run("profiles", "current"); + expect(output.trim()).toBe("prod"); + }); + + it("should remove a profile", () => { + writeConfig( + { + prod: { url: "https://prod.example.com", token: "t" }, + staging: { url: "https://staging.example.com", token: "t" }, + }, + "prod", + ); + run("profiles", "remove", "staging"); + const output = run("profiles", "list"); + expect(output).not.toContain("staging"); + expect(output).toContain("prod"); + }); + + it("should expose --profile global flag in help", () => { + const output = run("--help"); + expect(output).toContain("--profile"); + }); + + it("should expose profiles in root help", () => { + const output = run("--help"); + expect(output).toContain("profiles"); + }); +}); diff --git a/tests/client.test.ts b/tests/client.test.ts index 849213f..322f47b 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -1,24 +1,38 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const testConfigDir = path.join(os.tmpdir(), `dokploy-test-${process.pid}`); + describe("readAuthConfig", () => { const originalEnv = { ...process.env }; beforeEach(() => { + process.env.DOKPLOY_CONFIG_DIR = testConfigDir; + fs.mkdirSync(testConfigDir, { recursive: true }); delete process.env.DOKPLOY_URL; delete process.env.DOKPLOY_API_KEY; delete process.env.DOKPLOY_AUTH_TOKEN; + delete process.env.DOKPLOY_PROFILE; }); afterEach(() => { process.env = { ...originalEnv }; + fs.rmSync(testConfigDir, { recursive: true, force: true }); vi.restoreAllMocks(); + vi.resetModules(); }); + async function loadClient() { + return import("../src/client.js"); + } + it("should read from DOKPLOY_API_KEY env var", async () => { process.env.DOKPLOY_URL = "https://test.dokploy.com"; process.env.DOKPLOY_API_KEY = "test-key-123"; - const { readAuthConfig } = await import("../src/client.js"); + const { readAuthConfig } = await loadClient(); const config = readAuthConfig(); expect(config.url).toBe("https://test.dokploy.com"); @@ -29,7 +43,7 @@ describe("readAuthConfig", () => { process.env.DOKPLOY_URL = "https://test.dokploy.com"; process.env.DOKPLOY_AUTH_TOKEN = "auth-token-456"; - const { readAuthConfig } = await import("../src/client.js"); + const { readAuthConfig } = await loadClient(); const config = readAuthConfig(); expect(config.url).toBe("https://test.dokploy.com"); @@ -41,13 +55,122 @@ describe("readAuthConfig", () => { process.env.DOKPLOY_API_KEY = "api-key"; process.env.DOKPLOY_AUTH_TOKEN = "auth-token"; - const { readAuthConfig } = await import("../src/client.js"); + const { readAuthConfig } = await loadClient(); const config = readAuthConfig(); expect(config.token).toBe("api-key"); }); }); +describe("profile management", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env.DOKPLOY_CONFIG_DIR = testConfigDir; + fs.mkdirSync(testConfigDir, { recursive: true }); + delete process.env.DOKPLOY_URL; + delete process.env.DOKPLOY_API_KEY; + delete process.env.DOKPLOY_AUTH_TOKEN; + delete process.env.DOKPLOY_PROFILE; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + fs.rmSync(testConfigDir, { recursive: true, force: true }); + vi.resetModules(); + }); + + async function loadClient() { + return import("../src/client.js"); + } + + it("should save and read multiple profiles", async () => { + const { saveAuthConfig, readAuthConfig } = await loadClient(); + saveAuthConfig("https://prod.example.com", "prod-token", "prod"); + saveAuthConfig("https://staging.example.com", "staging-token", "staging"); + + const prod = readAuthConfig("prod"); + const staging = readAuthConfig("staging"); + + expect(prod).toEqual({ + url: "https://prod.example.com", + token: "prod-token", + }); + expect(staging).toEqual({ + url: "https://staging.example.com", + token: "staging-token", + }); + }); + + it("should save to default profile when none specified", async () => { + const { saveAuthConfig, readAuthConfig } = await loadClient(); + saveAuthConfig("https://prod.example.com", "prod-token"); + + const config = readAuthConfig("default"); + expect(config).toEqual({ + url: "https://prod.example.com", + token: "prod-token", + }); + }); + + it("should switch active profile and persist it", async () => { + const { saveAuthConfig, setCurrentProfile, getCurrentProfile } = + await loadClient(); + saveAuthConfig("https://prod.example.com", "prod-token", "prod"); + saveAuthConfig("https://staging.example.com", "staging-token", "staging"); + + expect(getCurrentProfile()).toBe("prod"); + setCurrentProfile("staging"); + expect(getCurrentProfile()).toBe("staging"); + }); + + it("should resolve active profile when no explicit profile given", async () => { + const { saveAuthConfig, setCurrentProfile, readAuthConfig } = + await loadClient(); + saveAuthConfig("https://prod.example.com", "prod-token", "prod"); + saveAuthConfig("https://staging.example.com", "staging-token", "staging"); + setCurrentProfile("staging"); + + const config = readAuthConfig(); + expect(config.url).toBe("https://staging.example.com"); + expect(config.token).toBe("staging-token"); + }); + + it("should respect DOKPLOY_PROFILE env var over active profile", async () => { + const { saveAuthConfig, readAuthConfig } = await loadClient(); + saveAuthConfig("https://prod.example.com", "prod-token", "prod"); + saveAuthConfig("https://staging.example.com", "staging-token", "staging"); + + process.env.DOKPLOY_PROFILE = "staging"; + const config = readAuthConfig(); + expect(config.url).toBe("https://staging.example.com"); + }); + + it("should list profiles", async () => { + const { saveAuthConfig, listProfiles } = await loadClient(); + saveAuthConfig("https://prod.example.com", "prod-token", "prod"); + saveAuthConfig("https://staging.example.com", "staging-token", "staging"); + + const profiles = listProfiles(); + expect(profiles).toHaveLength(2); + expect(profiles[0]).toEqual({ + name: "prod", + url: "https://prod.example.com", + }); + }); + + it("should remove a profile", async () => { + const { saveAuthConfig, removeProfile, listProfiles } = await loadClient(); + saveAuthConfig("https://prod.example.com", "prod-token", "prod"); + saveAuthConfig("https://staging.example.com", "staging-token", "staging"); + + removeProfile("prod"); + const profiles = listProfiles(); + expect(profiles).toHaveLength(1); + expect(profiles[0].name).toBe("staging"); + }); +}); + describe("saveAuthConfig", () => { it("should write config with correct structure", async () => { const { saveAuthConfig } = await import("../src/client.js");