diff --git a/README.md b/README.md index b927242..8fa6384 100644 --- a/README.md +++ b/README.md @@ -75,24 +75,35 @@ Link your local project directory to a specific project and environment in ManUp ```bash cd /path/to/my-project + +# Interactive wizard: manup init + +# Or scriptable non-interactive linking by project & environment name: +manup init -p my-project -e dev ``` -This interactively prompts you to select a project and environment, creating a local `.manup.json` configuration file in your directory. +This creates a local `.manup.json` configuration file in your directory that automatically scopes subsequent CLI commands. --- ## 🔐 Managing Secrets +All secret commands support environment and project resolution by name (e.g. `dev`, `prod`, `staging`), common abbreviations (`dev`, `prod`, `stage`), or ID: + ### List Secrets ```bash -# List masked secrets +# List secrets for linked environment (masked values) manup secrets -# Reveal plain values +# Reveal plaintext values manup secrets ls --reveal +# Specify environment and project by name +manup secrets ls -e dev +manup secrets ls -p backend-api -e prod + # Output JSON manup secrets --json ``` @@ -101,58 +112,74 @@ manup secrets --json ```bash manup secrets get DATABASE_URL +manup secrets get DATABASE_URL -e prod ``` ### Create or Update a Secret ```bash # Using argument pair -manup secrets set API_TOKEN super_secret_val +manup secrets set API_TOKEN super_secret_val -e dev # Using KEY=VALUE syntax -manup secrets set API_TOKEN=super_secret_val +manup secrets set API_TOKEN=super_secret_val -e prod -p backend-api ``` ### Delete a Secret ```bash -manup secrets delete API_TOKEN +manup secrets delete API_TOKEN -e dev ``` ### Export Secrets ```bash # Export to .env file -manup secrets export --out .env +manup secrets export -e prod --out .env # Export as shell export commands -manup secrets export --format export +manup secrets export -e dev --format export -# Output JSON map -manup secrets export --format json +# Output JSON key-value map +manup secrets export -e staging --format json ``` --- ## 🚀 Running Commands with Vault Secrets (`manup run`) -Inject all environment secrets from your linked ManUp vault into any command without writing them to disk: +Inject all environment secrets from your ManUp vault directly into any process environment without writing secrets to disk: ```bash -# Node.js app -manup run -- node index.js - -# NPM script +# Runs with linked environment secrets manup run -- npm start - -# Python / Docker / Custom scripts +manup run -- node index.js manup run -- python app.py + +# Switch environment or project on the fly by name: +manup run -e dev -- npm start +manup run -e prod -- npm test +manup run -p backend -e staging -- npm run build ``` -Override environment on the fly: +--- + +## 🔑 Managing API Keys (`manup keys`) + +Provision and manage access tokens (including project-scoped API keys) directly from the CLI: ```bash -manup run --env -- npm test +# List all API keys +manup keys + +# Create an organization-wide key (read/write) +manup keys create ci-runner + +# Create a read-only key scoped to a specific project with 90-day expiration +manup keys create prod-deployer --scope read-only --project backend --days 90 + +# Revoke an API key +manup keys delete ``` --- diff --git a/docs/index.html b/docs/index.html index 0d2298f..ef9e8ef 100644 --- a/docs/index.html +++ b/docs/index.html @@ -594,6 +594,7 @@
  • manup login
  • manup whoami
  • manup init
  • +
  • manup keys
  • manup secrets
  • manup secrets export
  • manup run
  • @@ -632,6 +633,7 @@
  • manup login
  • manup whoami
  • manup init
  • +
  • manup keys
  • manup secrets
  • manup secrets export
  • manup run
  • @@ -651,7 +653,7 @@
    - v0.2.0 • Open Source + v0.3.0 • Open Source

    ManUp CLI

    Manage, synchronize, and inject environment secrets seamlessly across your projects and automation pipelines. @@ -731,7 +733,7 @@

    🔒 Machine-Bound AES Encryption

    👤 Status Check (manup whoami)

    -

    Displays active server URL, logged-in user email, organization ID, and linked directory details.

    +

    Displays active server URL, logged-in user email, organization ID, API key scope, project isolation status, and linked directory details.

    terminal
    manup whoami
    @@ -744,56 +746,90 @@

    🔗 Workspace Linking (manup init)

    Links your current directory to a specific project and environment in your ManUp vault by creating a local .manup.json file.

    terminal
    -
    manup init
    +
    # Interactive wizard
    +manup init
    +
    +# Non-interactive scriptable linking by name
    +manup init -p backend -e dev
    +
    +
    + + +
    +

    🔑 API Key Management (manup keys)

    +

    Provision, inspect, and revoke API keys with support for project scoping and role-based access.

    + +
    +
    terminal
    +
    # List all active API keys
    +manup keys
    +
    +# Create an organization-wide key (read/write)
    +manup keys create ci-runner
    +
    +# Create a project-scoped read-only key with 90-day expiration
    +manup keys create prod-worker --scope read-only --project backend --days 90
    +
    +# Revoke an API key
    +manup keys delete <keyId>

    🔐 Secret Operations (manup secrets)

    -

    Full CRUD operations for managing decrypted environment variables in your vault.

    +

    Full CRUD operations for managing decrypted environment variables in your vault. Supports environment and project targeting by name (e.g. dev, prod), common abbreviations, or ID.

    List Secrets

    terminal
    -
    # List masked secrets
    +          
    # List masked secrets for linked environment
     manup secrets
     
    -# Reveal plain secret values
    -manup secrets ls --reveal
    +# Reveal plain values +manup secrets ls --reveal + +# Target environment and project by name +manup secrets ls -e dev +manup secrets ls -p backend -e prod

    Set Secret

    terminal
    -
    manup secrets set DATABASE_URL postgres://user:pass@localhost:5432/db
    +
    # Argument pair or KEY=VALUE syntax
    +manup secrets set DATABASE_URL postgres://user:pass@localhost:5432/db -e dev
    +manup secrets set PORT=3000 -e prod -p backend

    Get Secret

    terminal
    -
    manup secrets get DATABASE_URL
    +
    manup secrets get DATABASE_URL -e prod

    Delete Secret

    terminal
    -
    manup secrets delete DATABASE_URL
    +
    manup secrets delete OLD_TOKEN -e dev

    📄 Exporting Secrets

    -

    Export environment variables to .env files or standard export scripts.

    +

    Export environment variables to .env files, standard shell export scripts, or JSON maps.

    terminal
    -
    # Write secrets to .env file
    -manup secrets export --out .env
    +          
    # Write secrets to .env file by environment name
    +manup secrets export -e prod --out .env
    +
    +# Output shell export format
    +manup secrets export -e dev --format export
     
     # Output JSON format
    -manup secrets export --format json
    +manup secrets export -e staging --format json
    @@ -804,11 +840,16 @@

    🚀 Running Commands (manup run)

    terminal
    -
    # Inject secrets into Node.js application
    +          
    # Inject secrets into Node.js application (linked environment)
     manup run -- node server.js
     
     # Inject secrets into npm dev server
    -manup run -- npm run dev
    +manup run -- npm run dev + +# Switch environment on the fly by name +manup run -e dev -- npm start +manup run -e prod -- npm test +manup run -p backend -e staging -- npm run build
    diff --git a/package-lock.json b/package-lock.json index 0f53adc..9072f36 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "manup-cli", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "manup-cli", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "dependencies": { "@clack/prompts": "^0.9.1", diff --git a/package.json b/package.json index b4e8734..bb39695 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "manup-cli", - "version": "0.2.0", + "version": "0.3.0", "description": "Official CLI for ManUp Secrets Vault (https://github.com/Amanbig/ManUp) — Manage, synchronize, and inject environment secrets seamlessly across projects.", "type": "module", "main": "./dist/index.js", diff --git a/src/api/client.ts b/src/api/client.ts index 20523c2..d46570a 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -9,6 +9,10 @@ export interface User { type?: string; organizationId: string; organizationName?: string; + apiKeyId?: string | null; + apiKeyScope?: 'full' | 'read-only' | null; + apiKeyProjectId?: string | null; + apiKeyProjectName?: string | null; } export interface Project { @@ -44,6 +48,10 @@ export interface ApiKeyItem { name: string; apiKey?: string; scope: 'full' | 'read-only'; + projectId?: string | null; + projectName?: string | null; + rateLimit?: number; + requestCount?: number; createdAt?: string; expiresAt?: string | null; lastUsedAt?: string | null; @@ -148,8 +156,16 @@ export class ManUpClient { name: string, scope: 'full' | 'read-only' = 'full', expiresAt?: string, + projectId?: string | null, + expiresInDays?: number, ): Promise { - const res = await this.axiosInstance.post('/users/api-keys', { name, scope, expiresAt }); + const res = await this.axiosInstance.post('/users/api-keys', { + name, + scope, + expiresAt, + projectId, + expiresInDays, + }); return res.data; } @@ -220,4 +236,156 @@ export class ManUpClient { public async deleteSecret(secretId: string): Promise { await this.axiosInstance.delete(`/secrets/${secretId}`); } + + // --- Resolution and Query Helpers --- + + public async querySecrets(params: { + env: string; + project?: string; + format?: 'json' | 'dotenv' | 'kv'; + }): Promise<{ data: any; headers: any }> { + const queryParams: Record = { env: params.env }; + if (params.project) { + queryParams.project = params.project; + } + if (params.format) { + queryParams.format = params.format; + } + const res = await this.axiosInstance.get('/secrets', { + params: queryParams, + transformResponse: params.format === 'dotenv' ? [(data) => data] : undefined, + }); + return { data: res.data, headers: res.headers }; + } + + public async resolveProject(projectQuery?: string): Promise { + const localCfg = getLocalConfig(); + const query = (projectQuery || localCfg?.projectId || localCfg?.projectName)?.trim(); + + const projects = await this.listProjects(); + if (projects.length === 0) { + throw new Error('No projects found in organization.'); + } + + if (query) { + const match = projects.find( + (p) => p.id === query || p.name.toLowerCase() === query.toLowerCase(), + ); + if (match) return match; + throw new Error( + `Project '${query}' not found. Available projects: ${projects.map((p) => p.name).join(', ')}`, + ); + } + + if (projects.length === 1) { + return projects[0]; + } + + throw new Error( + `Multiple projects found (${projects.map((p) => p.name).join(', ')}). Please specify --project .`, + ); + } + + public async resolveEnvironment( + envQuery?: string, + projectQuery?: string, + ): Promise<{ project: Project; environment: Environment }> { + const localCfg = getLocalConfig(); + const project = await this.resolveProject(projectQuery); + const environments = await this.listEnvironments(project.id); + + if (environments.length === 0) { + throw new Error(`Project '${project.name}' has no configured environments.`); + } + + const query = (envQuery || localCfg?.environmentId || localCfg?.environmentName)?.trim(); + + if (!query) { + if (environments.length === 1) { + return { project, environment: environments[0] }; + } + throw new Error( + `Environment is required. Pass --env . Available: ${environments.map((e) => e.name).join(', ')}`, + ); + } + + const cleanQuery = query.toLowerCase(); + + // 1. Direct ID match + let match = environments.find((e) => e.id === query); + + // 2. Exact name match (case-insensitive) + if (!match) { + match = environments.find((e) => e.name.toLowerCase() === cleanQuery); + } + + // 3. Common abbreviations (prod -> production, dev -> development, stage -> staging) + if (!match) { + match = environments.find((e) => { + const n = e.name.toLowerCase(); + if (cleanQuery === 'prod' && (n === 'production' || n.startsWith('prod'))) return true; + if (cleanQuery === 'dev' && (n === 'development' || n.startsWith('dev'))) return true; + if (cleanQuery === 'stage' && (n === 'staging' || n.startsWith('stag'))) return true; + return false; + }); + } + + if (match) { + return { project, environment: match }; + } + + throw new Error( + `Environment '${query}' not found in project '${project.name}'. Available: ${environments.map((e) => e.name).join(', ')}`, + ); + } + + public async fetchSecrets(options: { env?: string; project?: string }): Promise<{ + secrets: Secret[]; + environment: { id: string; name: string }; + project: { id: string; name: string }; + }> { + const localCfg = getLocalConfig(); + const envQuery = options.env || localCfg?.environmentId || localCfg?.environmentName; + const projectQuery = options.project || localCfg?.projectId || localCfg?.projectName; + + if (envQuery) { + try { + const { data, headers } = await this.querySecrets({ + env: envQuery, + project: projectQuery, + format: 'json', + }); + + if (Array.isArray(data)) { + const envId = headers?.['x-environment-id'] || localCfg?.environmentId || ''; + const envName = + headers?.['x-environment-name'] || + options.env || + localCfg?.environmentName || + 'Environment'; + const projId = headers?.['x-project-id'] || localCfg?.projectId || ''; + const projName = + headers?.['x-project-name'] || options.project || localCfg?.projectName || 'Project'; + + return { + secrets: data, + environment: { id: envId, name: envName }, + project: { id: projId, name: projName }, + }; + } + } catch (err: any) { + if (err.response?.status !== 404 && err.response?.status !== 400) { + throw err; + } + } + } + + const { project, environment } = await this.resolveEnvironment(options.env, options.project); + const secrets = await this.getSecrets(environment.id); + return { + secrets, + environment: { id: environment.id, name: environment.name }, + project: { id: project.id, name: project.name }, + }; + } } diff --git a/src/commands/apiKeys.ts b/src/commands/apiKeys.ts new file mode 100644 index 0000000..cca3356 --- /dev/null +++ b/src/commands/apiKeys.ts @@ -0,0 +1,159 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { ManUpClient, type ApiKeyItem } from '../api/client.js'; +import { logger, createSpinner, printTable } from '../utils/logger.js'; + +export const keysCommand = new Command('keys') + .alias('api-keys') + .alias('key') + .description('Manage API keys (list, create, revoke)'); + +// --- Command: keys list (default) --- +keysCommand + .command('list', { isDefault: true }) + .alias('ls') + .description('List all active API keys') + .option('--json', 'Output results as JSON') + .action(async (options) => { + const spinner = createSpinner('Fetching API keys...'); + spinner.start(); + + try { + const client = new ManUpClient(); + const keys = await client.listApiKeys(); + spinner.stop(); + + if (options.json) { + console.log(JSON.stringify(keys, null, 2)); + return; + } + + if (keys.length === 0) { + logger.info('No API keys found for this account.'); + return; + } + + logger.title('API Keys'); + const rows = keys.map((k: ApiKeyItem) => { + const projectScope = k.projectName + ? `${k.projectName} (${k.projectId})` + : chalk.gray('All Projects (Org-wide)'); + + const expires = k.expiresAt ? new Date(k.expiresAt).toLocaleDateString() : 'Never'; + const lastUsed = k.lastUsedAt ? new Date(k.lastUsedAt).toLocaleString() : 'Never'; + + return [ + k.id, + k.name, + k.scope, + projectScope, + k.createdAt ? new Date(k.createdAt).toLocaleDateString() : '-', + lastUsed, + expires, + ]; + }); + + printTable( + ['Key ID', 'Name', 'Scope', 'Project Scope', 'Created', 'Last Used', 'Expires'], + rows, + ); + } catch (err: any) { + spinner.fail('Failed to fetch API keys'); + logger.error(err.response?.data?.detail || err.message); + process.exit(1); + } + }); + +// --- Command: keys create --- +keysCommand + .command('create ') + .description('Create a new API key') + .option('-s, --scope ', 'Key scope: "full" (read/write) or "read-only"', 'full') + .option('-p, --project ', 'Scope key to a specific project name or ID (optional)') + .option('-d, --days ', 'Expiration in days (e.g. 30, 90)') + .option('--json', 'Output result as JSON') + .action(async (nameArg: string, options) => { + const name = nameArg.trim(); + if (!name) { + logger.error('API key name is required.'); + process.exit(1); + } + + const scope = options.scope === 'read-only' ? 'read-only' : 'full'; + let projectId: string | null = null; + let projectName: string | null = null; + + const spinner = createSpinner('Generating API key...'); + spinner.start(); + + try { + const client = new ManUpClient(); + + if (options.project) { + const project = await client.resolveProject(options.project); + projectId = project.id; + projectName = project.name; + } + + const days = options.days ? parseInt(options.days, 10) : undefined; + const result = await client.createApiKey(name, scope, undefined, projectId, days); + spinner.stop(); + + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + logger.success(`API Key created successfully for "${result.name}"!`); + if (result.apiKey) { + console.log('\n' + chalk.bgGreen.black.bold(' YOUR NEW API KEY ') + '\n'); + console.log(chalk.bold.green(result.apiKey)); + console.log( + '\n' + + chalk.yellow( + '⚠️ Save this key securely! It will NEVER be displayed again by the server.', + ) + + '\n', + ); + } + + const rows = [ + ['Key ID', result.id], + ['Name', result.name], + ['Scope', result.scope], + [ + 'Project Scope', + projectName ? `${projectName} (${projectId})` : 'All Projects (Org-wide)', + ], + ['Expires At', result.expiresAt ? new Date(result.expiresAt).toLocaleString() : 'Never'], + ]; + + printTable(['Property', 'Value'], rows); + } catch (err: any) { + spinner.fail('Failed to create API key'); + logger.error(err.response?.data?.detail || err.message); + process.exit(1); + } + }); + +// --- Command: keys delete --- +keysCommand + .command('delete ') + .alias('rm') + .alias('revoke') + .description('Revoke and delete an API key by ID') + .action(async (idArg: string) => { + const keyId = idArg.trim(); + const spinner = createSpinner(`Revoking API key "${keyId}"...`); + spinner.start(); + + try { + const client = new ManUpClient(); + await client.deleteApiKey(keyId); + spinner.succeed(`API Key "${keyId}" has been revoked.`); + } catch (err: any) { + spinner.fail('Failed to revoke API key'); + logger.error(err.response?.data?.detail || err.message); + process.exit(1); + } + }); diff --git a/src/commands/environments.ts b/src/commands/environments.ts index 71de26c..7f80478 100644 --- a/src/commands/environments.ts +++ b/src/commands/environments.ts @@ -1,28 +1,23 @@ import { Command } from 'commander'; import { ManUpClient } from '../api/client.js'; -import { getLocalConfig } from '../config/store.js'; import { logger, createSpinner, printTable } from '../utils/logger.js'; export const envCommand = new Command('env') .alias('environments') .description('List project environments') - .option('-p, --project ', 'Project ID (defaults to linked project in .manup.json)') + .option( + '-p, --project ', + 'Project name or ID (defaults to linked project in .manup.json)', + ) .option('--json', 'Output results as JSON') .action(async (options) => { - const localCfg = getLocalConfig(); - const projectId = options.project || localCfg?.projectId; - - if (!projectId) { - logger.error('Project ID is required. Pass --project or run `manup init` first.'); - process.exit(1); - } - const spinner = createSpinner('Fetching environments...'); spinner.start(); try { const client = new ManUpClient(); - const environments = await client.listEnvironments(projectId); + const project = await client.resolveProject(options.project); + const environments = await client.listEnvironments(project.id); spinner.stop(); if (options.json) { @@ -31,11 +26,11 @@ export const envCommand = new Command('env') } if (environments.length === 0) { - logger.info('No environments found for this project.'); + logger.info(`No environments found for project "${project.name}".`); return; } - logger.title('Environments'); + logger.title(`Environments — ${project.name}`); const rows = environments.map((e) => [ e.id, e.name, @@ -46,5 +41,6 @@ export const envCommand = new Command('env') } catch (err: any) { spinner.fail('Failed to fetch environments'); logger.error(err.response?.data?.detail || err.message); + process.exit(1); } }); diff --git a/src/commands/init.ts b/src/commands/init.ts index 24ea021..9644b18 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -6,7 +6,39 @@ import { logger, createSpinner } from '../utils/logger.js'; export const initCommand = new Command('init') .description('Link current workspace directory to a ManUp project and environment') - .action(async () => { + .option('-p, --project ', 'Project name or ID to link') + .option('-e, --env ', 'Environment name or ID to link') + .action(async (options) => { + // Non-interactive linking if flags are passed + if (options.project || options.env) { + const spinner = createSpinner('Resolving project and environment...'); + spinner.start(); + try { + const client = new ManUpClient(); + const { project, environment } = await client.resolveEnvironment( + options.env, + options.project, + ); + spinner.stop(); + + const filePath = setLocalConfig({ + projectId: project.id, + projectName: project.name, + environmentId: environment.id, + environmentName: environment.name, + }); + + logger.success( + `Successfully linked directory to project "${project.name}" [${environment.name}]!\nSaved configuration to ${filePath}`, + ); + return; + } catch (err: any) { + spinner.fail('Link failed'); + logger.error(err.response?.data?.detail || err.message); + process.exit(1); + } + } + p.intro('🔗 ManUp Link Project'); const spinner = createSpinner('Fetching projects...'); diff --git a/src/commands/run.ts b/src/commands/run.ts index a515aa5..11011fb 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -1,40 +1,35 @@ import { Command } from 'commander'; import { execa } from 'execa'; import { ManUpClient, type Secret } from '../api/client.js'; -import { getLocalConfig } from '../config/store.js'; import { logger, createSpinner } from '../utils/logger.js'; export const runCommand = new Command('run') .description('Run a command with secrets injected into process environment') - .option('-e, --env ', 'Environment ID (defaults to linked environment)') + .option( + '-e, --env ', + 'Environment name (e.g. dev, prod), abbreviation, or ID (defaults to linked environment)', + ) + .option('-p, --project ', 'Project name or ID (defaults to linked project)') .allowUnknownOption(true) .argument('', 'Command and arguments to execute') .action(async (commandArgs: string[], options) => { - const localCfg = getLocalConfig(); - const envId = options.env || localCfg?.environmentId; - - if (!envId) { - logger.error( - 'Environment ID is required. Pass --env or run `manup init` to link a workspace.', - ); - process.exit(1); - } - const spinner = createSpinner('Fetching vault secrets...'); spinner.start(); - let secretMap: Record = {}; + const secretMap: Record = {}; try { const client = new ManUpClient(); - const secrets = await client.getSecrets(envId); + const res = await client.fetchSecrets({ env: options.env, project: options.project }); spinner.stop(); - secrets.forEach((s: Secret) => { + res.secrets.forEach((s: Secret) => { secretMap[s.key] = s.value; }); - logger.info(`Injected ${secrets.length} secrets into environment.`); + logger.info( + `Injected ${res.secrets.length} secrets from ${res.project.name} [${res.environment.name}] into environment.`, + ); } catch (err: any) { spinner.fail('Failed to fetch secrets from vault'); logger.error(err.response?.data?.detail || err.message); diff --git a/src/commands/secrets.ts b/src/commands/secrets.ts index 8827e8a..6471b90 100644 --- a/src/commands/secrets.ts +++ b/src/commands/secrets.ts @@ -1,23 +1,13 @@ import { Command } from 'commander'; import fs from 'node:fs'; import { ManUpClient, type Secret } from '../api/client.js'; -import { getLocalConfig } from '../config/store.js'; import { logger, createSpinner, printTable } from '../utils/logger.js'; -const resolveEnvironmentId = (optionsEnv?: string): string => { - const localCfg = getLocalConfig(); - const envId = optionsEnv || localCfg?.environmentId; - if (!envId) { - logger.error( - 'Environment ID is required. Pass --env or run `manup init` to link a workspace.', - ); - process.exit(1); - } - return envId; -}; - const maskSecret = (value: string): string => { if (!value) return ''; + if (value.includes('\n')) { + return '**** (multi-line)'; + } if (value.length <= 4) return '****'; return value.substring(0, 2) + '****' + value.substring(value.length - 2); }; @@ -31,31 +21,34 @@ secretsCommand .command('list', { isDefault: true }) .alias('ls') .description('List all secrets in current or specified environment') - .option('-e, --env ', 'Environment ID (defaults to linked environment)') + .option( + '-e, --env ', + 'Environment name (e.g. dev, prod), abbreviation, or ID (defaults to linked environment)', + ) + .option('-p, --project ', 'Project name or ID (defaults to linked project)') .option('-r, --reveal', 'Reveal plain secret values instead of masking') .option('--json', 'Output results as JSON') .action(async (options) => { - const envId = resolveEnvironmentId(options.env); const spinner = createSpinner('Retrieving decrypted secrets...'); spinner.start(); try { const client = new ManUpClient(); - const secrets = await client.getSecrets(envId); + const res = await client.fetchSecrets({ env: options.env, project: options.project }); spinner.stop(); if (options.json) { - console.log(JSON.stringify(secrets, null, 2)); + console.log(JSON.stringify(res.secrets, null, 2)); return; } - if (secrets.length === 0) { - logger.info('No secrets found in this environment.'); + if (res.secrets.length === 0) { + logger.info(`No secrets found in ${res.project.name} [${res.environment.name}].`); return; } - logger.title('Environment Secrets'); - const rows = secrets.map((s: Secret) => [ + logger.title(`Secrets — ${res.project.name} [${res.environment.name}]`); + const rows = res.secrets.map((s: Secret) => [ s.key, options.reveal ? s.value : maskSecret(s.value), s.name || '-', @@ -66,6 +59,7 @@ secretsCommand } catch (err: any) { spinner.fail('Failed to retrieve secrets'); logger.error(err.response?.data?.detail || err.message); + process.exit(1); } }); @@ -73,22 +67,27 @@ secretsCommand secretsCommand .command('get ') .description('Get value of a specific secret by key') - .option('-e, --env ', 'Environment ID (defaults to linked environment)') + .option( + '-e, --env ', + 'Environment name (e.g. dev, prod), abbreviation, or ID (defaults to linked environment)', + ) + .option('-p, --project ', 'Project name or ID (defaults to linked project)') .option('--json', 'Output as JSON object') .action(async (keyArg: string, options) => { - const envId = resolveEnvironmentId(options.env); - const targetKey = keyArg.toUpperCase(); + const targetKey = keyArg.toUpperCase().trim(); const spinner = createSpinner(`Fetching secret "${targetKey}"...`); spinner.start(); try { const client = new ManUpClient(); - const secrets = await client.getSecrets(envId); + const res = await client.fetchSecrets({ env: options.env, project: options.project }); spinner.stop(); - const secret = secrets.find((s: Secret) => s.key === targetKey); + const secret = res.secrets.find((s: Secret) => s.key === targetKey); if (!secret) { - logger.error(`Secret with key "${targetKey}" not found in environment.`); + logger.error( + `Secret with key "${targetKey}" not found in ${res.project.name} [${res.environment.name}].`, + ); process.exit(1); } @@ -100,6 +99,7 @@ secretsCommand } catch (err: any) { spinner.fail('Failed to fetch secret'); logger.error(err.response?.data?.detail || err.message); + process.exit(1); } }); @@ -107,10 +107,13 @@ secretsCommand secretsCommand .command('set [value]') .description('Create or update a secret (key=value or separate arguments)') - .option('-e, --env ', 'Environment ID (defaults to linked environment)') + .option( + '-e, --env ', + 'Environment name (e.g. dev, prod), abbreviation, or ID (defaults to linked environment)', + ) + .option('-p, --project ', 'Project name or ID (defaults to linked project)') .option('-n, --name ', 'Optional note/name for secret') .action(async (keyArg: string, valueArg: string | undefined, options) => { - const envId = resolveEnvironmentId(options.env); let key = keyArg; let value = valueArg; @@ -130,16 +133,25 @@ secretsCommand key = key.toUpperCase().trim(); - const spinner = createSpinner(`Saving secret "${key}"...`); + const spinner = createSpinner('Resolving environment target...'); spinner.start(); try { const client = new ManUpClient(); - await client.setSecret(envId, key, value, options.name); - spinner.succeed(`Successfully saved secret "${key}".`); + const { project, environment } = await client.resolveEnvironment( + options.env, + options.project, + ); + + spinner.text = `Saving secret "${key}" to ${project.name} [${environment.name}]...`; + await client.setSecret(environment.id, key, value, options.name); + spinner.succeed( + `Successfully saved secret "${key}" in ${project.name} [${environment.name}].`, + ); } catch (err: any) { spinner.fail('Failed to save secret'); logger.error(err.response?.data?.detail || err.message); + process.exit(1); } }); @@ -148,29 +160,41 @@ secretsCommand .command('delete ') .alias('rm') .description('Delete a secret by key') - .option('-e, --env ', 'Environment ID (defaults to linked environment)') + .option( + '-e, --env ', + 'Environment name (e.g. dev, prod), abbreviation, or ID (defaults to linked environment)', + ) + .option('-p, --project ', 'Project name or ID (defaults to linked project)') .action(async (keyArg: string, options) => { - const envId = resolveEnvironmentId(options.env); - const targetKey = keyArg.toUpperCase(); - const spinner = createSpinner(`Locating secret "${targetKey}"...`); + const targetKey = keyArg.toUpperCase().trim(); + const spinner = createSpinner('Resolving environment target...'); spinner.start(); try { const client = new ManUpClient(); - const secrets = await client.getSecrets(envId); + const { project, environment } = await client.resolveEnvironment( + options.env, + options.project, + ); + + spinner.text = `Locating secret "${targetKey}"...`; + const secrets = await client.getSecrets(environment.id); const secret = secrets.find((s: Secret) => s.key === targetKey); if (!secret) { - spinner.fail(`Secret "${targetKey}" not found.`); + spinner.fail(`Secret "${targetKey}" not found in ${project.name} [${environment.name}].`); process.exit(1); } spinner.text = `Deleting secret "${targetKey}"...`; await client.deleteSecret(secret.id); - spinner.succeed(`Successfully deleted secret "${targetKey}".`); + spinner.succeed( + `Successfully deleted secret "${targetKey}" from ${project.name} [${environment.name}].`, + ); } catch (err: any) { spinner.fail('Failed to delete secret'); logger.error(err.response?.data?.detail || err.message); + process.exit(1); } }); @@ -178,46 +202,64 @@ secretsCommand secretsCommand .command('export') .description('Export environment secrets to file or stdout') - .option('-e, --env ', 'Environment ID (defaults to linked environment)') + .option( + '-e, --env ', + 'Environment name (e.g. dev, prod), abbreviation, or ID (defaults to linked environment)', + ) + .option('-p, --project ', 'Project name or ID (defaults to linked project)') .option('-f, --format ', 'Export format: env, json, export (default: env)', 'env') .option('-o, --out ', 'Output file path (e.g. .env)') .action(async (options) => { - const envId = resolveEnvironmentId(options.env); const spinner = createSpinner('Fetching secrets for export...'); spinner.start(); try { const client = new ManUpClient(); - const secrets = await client.getSecrets(envId); + const res = await client.fetchSecrets({ env: options.env, project: options.project }); spinner.stop(); let outputContent = ''; if (options.format === 'json') { const secretObj: Record = {}; - secrets.forEach((s: Secret) => { + res.secrets.forEach((s: Secret) => { secretObj[s.key] = s.value; }); outputContent = JSON.stringify(secretObj, null, 2); } else if (options.format === 'export') { - outputContent = secrets + outputContent = res.secrets .map((s: Secret) => `export ${s.key}="${s.value.replace(/"/g, '\\"')}"`) .join('\n'); } else { // standard .env format - outputContent = secrets - .map((s: Secret) => `${s.key}="${s.value.replace(/"/g, '\\"')}"`) + outputContent = res.secrets + .map((s: Secret) => { + const val = s.value; + if ( + val.includes('\n') || + val.includes('"') || + val.includes(' ') || + val.includes('#') || + val.includes('$') + ) { + return `${s.key}="${val.replace(/"/g, '\\"')}"`; + } + return `${s.key}=${val}`; + }) .join('\n'); } if (options.out) { fs.writeFileSync(options.out, outputContent + '\n', 'utf-8'); - logger.success(`Exported ${secrets.length} secrets to ${options.out}`); + logger.success( + `Exported ${res.secrets.length} secrets from ${res.project.name} [${res.environment.name}] to ${options.out}`, + ); } else { console.log(outputContent); } } catch (err: any) { spinner.fail('Export failed'); logger.error(err.response?.data?.detail || err.message); + process.exit(1); } }); diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts index 9248566..c7aa211 100644 --- a/src/commands/whoami.ts +++ b/src/commands/whoami.ts @@ -54,6 +54,20 @@ export const whoamiCommand = new Command('whoami') ], ]; + if (user.apiKeyScope) { + rows.push([ + 'API Key Scope', + user.apiKeyScope === 'read-only' ? 'read-only' : 'full (read/write)', + ]); + } + + if (authTypeLabel.includes('API Key')) { + const projScope = user.apiKeyProjectId + ? `${user.apiKeyProjectName || 'Project'} (${user.apiKeyProjectId})` + : 'All Projects (Organization-wide)'; + rows.push(['API Key Project Scope', projScope]); + } + if (localCfg) { rows.push([ 'Linked Project', diff --git a/src/index.ts b/src/index.ts index fb0e235..771ef65 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,18 +1,19 @@ import { Command } from 'commander'; import { loginCommand, logoutCommand } from './commands/login.js'; import { whoamiCommand } from './commands/whoami.js'; -import { initCommand } from './commands/init.js'; // wait, filename is init.js when built! +import { initCommand } from './commands/init.js'; import { projectsCommand } from './commands/projects.js'; import { envCommand } from './commands/environments.js'; import { secretsCommand } from './commands/secrets.js'; import { runCommand } from './commands/run.js'; +import { keysCommand } from './commands/apiKeys.js'; const program = new Command(); program .name('manup') .description('🔒 Official CLI for ManUp Secrets Vault — Manage & inject environment secrets') - .version('1.0.0'); + .version('0.3.0'); program.addCommand(loginCommand); program.addCommand(logoutCommand); @@ -20,6 +21,7 @@ program.addCommand(whoamiCommand); program.addCommand(initCommand); program.addCommand(projectsCommand); program.addCommand(envCommand); +program.addCommand(keysCommand); program.addCommand(secretsCommand); program.addCommand(runCommand);