@keyshade/cli
Version:
496 lines (436 loc) • 14.6 kB
text/typescript
/* eslint-disable @typescript-eslint/indent */
import BaseCommand from './base.command'
import { io } from 'socket.io-client'
import { spawn } from 'child_process'
import type {
CommandActionData,
CommandArgument,
CommandOption
} from '@/types/command/command.types'
import {
fetchPrivateKey,
fetchProjectRootConfig,
fetchProjectRootConfigFromPath
} from '@/util/configuration'
import type { ProjectRootConfig } from '@/types/index.types'
import type {
ClientRegisteredResponse,
Configuration,
RunData
} from '@/types/command/run.types'
import { decrypt } from '@/util/decrypt'
import { SecretController, VariableController } from '@keyshade/api-client'
import { log, spinner } from '@clack/prompts'
import { clearSpinnerLines, showError, showSuccess } from '@/util/prompt'
export default class RunCommand extends BaseCommand {
private processEnvironmentalVariables = {}
private projectSlug: string
private environmentSlug: string
private command: string
private childProcess = null
getName(): string {
return 'run'
}
getDescription(): string {
return 'Run a command with live configuration updates from keyshade'
}
getArguments(): CommandArgument[] {
return [{ name: '<command...>', description: 'Command to run' }]
}
canMakeHttpRequests(): boolean {
return true
}
getOptions(): CommandOption[] {
return [
{
short: '-e',
long: '--environment <slug>',
description: 'Environment to configure'
},
{
short: '-w',
long: '--workspace <slug>',
description: 'Workspace to configure'
},
{
short: '-p',
long: '--project <slug>',
description: 'Project to configure'
},
{
short: '-f',
long: '--config-file <path>',
description: 'Path to config file (default: keyshade.json)'
}
]
}
async action({ options, args }: CommandActionData): Promise<void> {
// Join all arguments to form the complete command
if (args.length === 0) {
throw new Error('No command provided')
}
// @ts-expect-error -- false positive, might be an error on commander.js
// args return string[][] instead of string[]
this.command = args[0].join(' ')
// Pass all relevant options to fetchConfigurations for proper precedence handling
const configurations = await this.fetchConfigurations({
workspace: options.workspace,
project: options.project,
environment: options.environment,
configFile: options.configFile
})
await this.connectToSocket(configurations)
await this.sleep(3000)
await this.prefetchConfigurations(configurations.privateKey)
this.spawnCommand()
process.on('SIGINT', () => {
void this.killCommand()
process.exit(0)
})
}
private async fetchConfigurations(
options: {
workspace?: string
project?: string
environment?: string
configFile?: string
} = {}
): Promise<RunData> {
// Step 1: Load base configuration from file
let baseConfig: ProjectRootConfig
if (options.configFile) {
// Use custom config file if specified
baseConfig = await fetchProjectRootConfigFromPath(options.configFile)
} else {
// Use default keyshade.json
baseConfig = await fetchProjectRootConfig()
}
// Step 2: Override with flags (highest precedence)
const finalConfig = {
workspace: options.workspace ?? baseConfig.workspace,
project: options.project ?? baseConfig.project,
environment: options.environment ?? baseConfig.environment,
quitOnDecryptionFailure: baseConfig.quitOnDecryptionFailure
}
// Step 3: Fetch private key for the project
const privateKey = await fetchPrivateKey(finalConfig.project)
if (!privateKey) {
throw new Error(
`Private key not found for project '${finalConfig.project}'. Please run 'keyshade init' or 'keyshade config private-key add' to add a private key.`
)
}
return {
...finalConfig,
privateKey
}
}
private getWebsocketType(baseUrl: string) {
if (baseUrl.startsWith('https')) {
return 'wss'
}
return 'ws'
}
private async connectToSocket(data: RunData) {
const loading = spinner()
loading.start('Connecting to keyshade servers...')
await this.sleep(2000)
// Fix: Parse the full host from baseUrl, not just the last segment
const url = new URL(this.baseUrl)
const websocketUrl = `${this.getWebsocketType(this.baseUrl)}://${url.host}/change-notifier`
const privateKey = data.privateKey
const quitOnDecryptionFailure = data.quitOnDecryptionFailure
const ioClient = io(websocketUrl, {
autoConnect: false,
extraHeaders: {
'x-keyshade-token': this.token
},
transports: ['websocket']
})
ioClient.connect()
ioClient.on('connect', async () => {
ioClient.emit('register-client-app', {
workspaceSlug: data.workspace,
projectSlug: data.project,
environmentSlug: data.environment
})
ioClient.on('configuration-updated', async (data: Configuration) => {
log.info(`${data.name} got updated. Restarting the process...`)
if (!data.isPlaintext) {
try {
data.value = await decrypt(privateKey, data.value)
} catch (error) {
if (quitOnDecryptionFailure) {
await showError(
`Failed decrypting ${data.name}'s value. Stopping the process.`
)
process.exit(1)
} else {
await showError(
`Failed decrypting ${data.name}'s value. No changes will be made to the process.`
)
return
}
}
}
this.processEnvironmentalVariables[data.name] = data.value
await this.restartCommand()
})
// Set a timeout for registration response
const registrationTimeout = setTimeout(() => {
showError(
'We tried connecting to keyshade servers for 30 seconds but failed. Please try again later.'
)
.catch(() => {})
.finally(() => process.exit(1))
}, 30000)
ioClient.on(
'client-registered',
async (registrationResponse: ClientRegisteredResponse) => {
clearTimeout(registrationTimeout)
if (registrationResponse.success) {
this.projectSlug = data.project
this.environmentSlug = data.environment
loading.stop()
clearSpinnerLines()
await showSuccess('Successfully connected to keyshade servers!')
} else {
// Extract meaningful error message
let errorMessage = 'Unknown error'
if (typeof registrationResponse.message === 'string') {
// If it is just a string, use it directly
errorMessage = registrationResponse.message
} else if (
typeof registrationResponse.message === 'object' &&
registrationResponse.message !== null
) {
// If the message is an object and not null
// Attempt to parse the message if it's a JSON string
// Handle nested error structure
const msgObj = registrationResponse.message as any
const nestedMessage = msgObj.response?.message || msgObj.message // Fallback to message if response is not available
if (typeof nestedMessage === 'string') {
try {
const parsed = JSON.parse(nestedMessage)
if (parsed.header && parsed.body) {
errorMessage = `${parsed.header}: ${parsed.body}`
} else {
errorMessage = nestedMessage
}
} catch {
// If parsing fails, fallback to string representation
errorMessage = nestedMessage
}
} else {
// If the message is not a string, stringify it
errorMessage = JSON.stringify(msgObj)
}
} else {
// Handle other types (undefined, null, etc.)
errorMessage = String(registrationResponse.message)
}
loading.stop()
clearSpinnerLines()
await showError(
`We encountered an error while connecting you to our servers: ${errorMessage}`
)
process.exit(1)
}
}
)
})
}
private async prefetchConfigurations(privateKey: string) {
log.info('Fetching existing secrets and variables from your project...')
const secretController = new SecretController(this.baseUrl)
const variableController = new VariableController(this.baseUrl)
const secretsResponse = await secretController.getAllSecretsOfEnvironment(
{
environmentSlug: this.environmentSlug,
projectSlug: this.projectSlug
},
{
'x-keyshade-token': this.token
}
)
if (!secretsResponse.success) {
throw new Error(secretsResponse.error.message)
}
const variablesResponse =
await variableController.getAllVariablesOfEnvironment(
{
environmentSlug: this.environmentSlug,
projectSlug: this.projectSlug
},
{
'x-keyshade-token': this.token
}
)
if (!variablesResponse.success) {
throw new Error(variablesResponse.error.message)
}
// Decrypt secrets if not already decrypted
const decryptedSecrets: Array<Omit<Configuration, 'isPlaintext'>> = []
for (const secret of secretsResponse.data) {
const decryptedValue = await decrypt(privateKey, secret.value)
decryptedSecrets.push({
name: secret.name,
value: decryptedValue
})
}
// Merge secrets and variables
const configurations = [...decryptedSecrets, ...variablesResponse.data]
log.info(
`Fetched ${secretsResponse.data.length} secrets and ${variablesResponse.data.length} variables`
)
// Set the configurations as environmental variables
configurations.forEach((config) => {
this.processEnvironmentalVariables[config.name] = config.value
})
}
private async sleep(ms: number) {
return await new Promise((resolve) => {
setTimeout(resolve, ms)
})
}
private async waitForExit(
child: ReturnType<typeof spawn>,
timeoutMs = 15000
): Promise<void> {
if (!child) return
await new Promise<void>((resolve, reject) => {
let settled = false
const onExit = () => {
if (!settled) {
settled = true
resolve()
}
}
child.once('exit', onExit)
child.once('close', onExit)
const t = setTimeout(() => {
if (!settled) {
settled = true
reject(new Error('Child did not exit before timeout'))
}
}, timeoutMs)
// Cleanup if promise resolves early
const cleanup = () => {
clearTimeout(t)
}
child.once('exit', cleanup)
child.once('close', cleanup)
})
}
private spawnCommand() {
// POSIX: create a new process group so -PID signals the entire tree
const isWin = process.platform === 'win32'
this.childProcess = spawn(this.command, [], {
shell: true,
stdio: 'inherit',
env: {
...process.env,
...Object.fromEntries(
Object.entries(this.processEnvironmentalVariables).map(
([key, value]) => [key, String(value)]
)
)
},
detached: !isWin // only on POSIX
})
// Allow parent to exit independently of child on POSIX process groups
if (!isWin && this.childProcess?.pid) {
this.childProcess.unref()
}
this.childProcess.on(
'exit',
(code: number | null, signal: NodeJS.Signals | null) => {
// Do NOT exit the CLI on child exit; we want to keep listening & restart on updates.
if (code === 0) {
log.info(
`Command exited successfully${signal ? ` (signal ${signal})` : ''}.`
)
} else {
log.info(
`Command exited${code !== null ? ` with code ${code}` : ''}${signal ? ` (signal ${signal})` : ''}.`
)
}
}
)
}
private async restartCommand() {
try {
await this.killCommand() // now truly awaits exit
this.spawnCommand()
} catch (e) {
console.error('Failed to restart command:', e)
// As a last resort, try to spawn anyway
this.spawnCommand()
}
}
private async killCommand() {
const child = this.childProcess
if (!child?.pid) return
const isWin = process.platform === 'win32'
const pid = child.pid
try {
// 1) Try graceful termination
if (isWin) {
// /T kills the whole tree; /F is force; first attempt without /F
await new Promise<void>((resolve) => {
const killer = spawn('taskkill', ['/PID', String(pid), '/T'], {
stdio: 'ignore'
})
killer.on('exit', () => {
resolve()
})
killer.on('error', () => {
resolve()
}) // ignore errors, we’ll force kill below if needed
})
} else {
// Negative PID targets the process group (requires detached: true at spawn)
try {
process.kill(-pid, 'SIGTERM')
} catch {
// Fallback to direct child if not in its own group
try {
process.kill(pid, 'SIGTERM')
} catch {}
}
}
// 2) Wait up to 5s for clean exit
try {
await this.waitForExit(child, 5000)
} catch {
// 3) Force kill if it didn’t exit
if (isWin) {
await new Promise<void>((resolve) => {
const killer = spawn(
'taskkill',
['/PID', String(pid), '/T', '/F'],
{ stdio: 'ignore' }
)
killer.on('exit', () => {
resolve()
})
killer.on('error', () => {
resolve()
})
})
} else {
try {
process.kill(-pid, 'SIGKILL')
} catch {
try {
process.kill(pid, 'SIGKILL')
} catch {}
}
// Give the OS a moment to reap and free the port
await this.sleep(200)
}
}
} finally {
this.childProcess = null
}
}
}