diff --git a/cli/src/api/cvms.ts b/cli/src/api/cvms.ts index 85b6bd82..97230536 100644 --- a/cli/src/api/cvms.ts +++ b/cli/src/api/cvms.ts @@ -1,7 +1,6 @@ import { safeGetCvmList, safeGetCvmInfo, - safeGetCvmComposeFile, type Client, type CvmInfoDetailV20260121, } from "@phala/cloud"; @@ -20,7 +19,6 @@ import type { GetCvmNetworkResponse, TeepodResponse, PubkeyResponse, - CvmComposeConfigResponse, UpgradeResponse, } from "./types"; import inquirer from "inquirer"; @@ -45,22 +43,6 @@ export async function getCvmByAppId( return result.data; } -/** - * Get CVM compose configuration - */ -export async function getCvmComposeConfig( - cvmId: string, -): Promise { - const client = await getClient(); - const result = await safeGetCvmComposeFile(client, { id: cvmId }); - - if (!result.success) { - throw new Error(result.error.message); - } - - return result.data as CvmComposeConfigResponse; -} - /** * Get CVM network information * @param appId App ID (with or without app_ prefix) @@ -168,11 +150,13 @@ export interface ResizeCvmPayload { } /** - * Replicate a CVM + * Replicate a CVM instance * @param appId App ID (with or without app_ prefix) + * @param vmUuid Source CVM UUID (with or without dashes) */ export async function replicateCvm( appId: string, + vmUuid: string, payload: { teepod_id?: number; encrypted_env?: string; @@ -181,7 +165,7 @@ export async function replicateCvm( const client = await getClient(); const cleanAppId = appId.replace(/^app_/, ""); const response = await client.post( - `cvms/app_${cleanAppId}/replicas`, + `apps/${cleanAppId}/cvms/${vmUuid}/replicas`, payload, ); return replicateCvmResponseSchema.parse(response); diff --git a/cli/src/api/types.ts b/cli/src/api/types.ts index 7dc0f192..a247cc1d 100644 --- a/cli/src/api/types.ts +++ b/cli/src/api/types.ts @@ -148,22 +148,22 @@ export const replicateCvmResponseSchema = z.object({ id: z.number(), name: z.string(), }), - user_id: z.number().nullable(), + user_id: z.number().nullable().optional(), app_id: z.string(), - vm_uuid: z.string().nullable(), - instance_id: z.string().nullable(), - app_url: z.string().nullable(), - base_image: z.string().nullable(), + vm_uuid: z.string().nullable().optional(), + instance_id: z.string().nullable().optional(), + app_url: z.string().nullable().optional(), + base_image: z.string().nullable().optional(), vcpu: z.number(), memory: z.number(), disk_size: z.number(), - manifest_version: z.number().nullable(), - version: z.string().nullable(), - runner: z.string().nullable(), - docker_compose_file: z.string().nullable(), - features: z.array(z.string()).nullable(), + manifest_version: z.number().nullable().optional(), + version: z.string().nullable().optional(), + runner: z.string().nullable().optional(), + docker_compose_file: z.string().nullable().optional(), + features: z.array(z.string()).nullable().optional(), created_at: z.string(), - encrypted_env_pubkey: z.string().nullable(), + encrypted_env_pubkey: z.string().nullable().optional(), }); export type ReplicateCvmResponse = z.infer; @@ -309,9 +309,3 @@ export interface AvailableNodesResponse { nodes: TEEPod[]; kms_list?: KmsListItem[]; } - -// CVM Compose Config Response -export interface CvmComposeConfigResponse { - env_pubkey: string; - [key: string]: unknown; -} diff --git a/cli/src/commands/allow-devices/index.ts b/cli/src/commands/allow-devices/index.ts index d66a28cf..e26b4d52 100644 --- a/cli/src/commands/allow-devices/index.ts +++ b/cli/src/commands/allow-devices/index.ts @@ -1,14 +1,5 @@ import chalk from "chalk"; import inquirer from "inquirer"; -import { - type Chain, - type PublicClient, - type WalletClient, - createPublicClient, - createWalletClient, - http, -} from "viem"; -import { privateKeyToAccount, nonceManager } from "viem/accounts"; import { safeGetCvmInfo, safeGetAppDeviceAllowlist, @@ -163,24 +154,6 @@ function resolvePrivateKey(input: { privateKey?: string }): `0x${string}` { return (key.startsWith("0x") ? key : `0x${key}`) as `0x${string}`; } -function createSharedClients( - chain: Chain, - privateKey: `0x${string}`, - rpcUrl?: string, -) { - const account = privateKeyToAccount(privateKey, { nonceManager }); - const publicClient = createPublicClient({ - chain, - transport: http(rpcUrl), - }) as unknown as PublicClient; - const walletClient = createWalletClient({ - account, - chain, - transport: http(rpcUrl), - }) as unknown as WalletClient; - return { publicClient, walletClient }; -} - async function resolveDeviceIdOrNodeName( deviceInput: string, ): Promise<`0x${string}`> { @@ -429,12 +402,6 @@ async function runAdd( return 1; } - const { publicClient, walletClient } = createSharedClients( - chain, - privateKey, - input.rpcUrl, - ); - const results: { deviceId: string; txHash: string; @@ -444,10 +411,10 @@ async function runAdd( for (const deviceId of deviceIds) { const result = await safeAddDevice({ chain, + rpcUrl: input.rpcUrl, appAddress: appContractAddress, deviceId, - walletClient, - publicClient, + privateKey, skipPrerequisiteChecks: true, }); @@ -574,12 +541,6 @@ async function runRemove( return 1; } - const { publicClient, walletClient } = createSharedClients( - chain, - privateKey, - input.rpcUrl, - ); - const results: { deviceId: string; txHash: string; @@ -589,10 +550,10 @@ async function runRemove( for (const deviceId of deviceIds) { const result = await safeRemoveDevice({ chain, + rpcUrl: input.rpcUrl, appAddress: appContractAddress, deviceId, - walletClient, - publicClient, + privateKey, skipPrerequisiteChecks: true, }); diff --git a/cli/src/commands/cvms/replicate/index.ts b/cli/src/commands/cvms/replicate/index.ts index f87e4e30..5459fd90 100644 --- a/cli/src/commands/cvms/replicate/index.ts +++ b/cli/src/commands/cvms/replicate/index.ts @@ -1,10 +1,22 @@ import fs from "node:fs"; import path from "node:path"; -import { CvmIdSchema, encryptEnvVars } from "@phala/cloud"; -import { getCvmComposeConfig, replicateCvm } from "@/src/api/cvms"; +import { + type PhalaCloudError, + ResourceError, + formatErrorMessage, + formatStructuredError, + safeGetCvmInfo, + safeGetCurrentUser, + encryptEnvVars, +} from "@phala/cloud"; +import { replicateCvm } from "@/src/api/cvms"; +import { getClient } from "@/src/lib/client"; +import { getEncryptPubkey } from "@/src/commands/envs/get-encrypt-pubkey"; import { defineCommand } from "@/src/core/define-command"; +import { isInJsonMode } from "@/src/core/json-mode"; import type { CommandContext } from "@/src/core/types"; +import { CLOUD_URL } from "@/src/utils/constants"; import { logger } from "@/src/utils/logger"; import { cvmsReplicateCommandMeta, @@ -38,8 +50,20 @@ async function runCvmsReplicateCommand( return 1; } - const { cvmId: normalizedCvmId } = CvmIdSchema.parse(context.cvmId); let encryptedEnv: string | undefined; + const client = await getClient(context); + const [cvmResult, currentUserResult] = await Promise.all([ + safeGetCvmInfo(client, context.cvmId), + safeGetCurrentUser(client), + ]); + if (!cvmResult.success) { + throw new Error(cvmResult.error.message); + } + if (!currentUserResult.success) { + throw new Error(currentUserResult.error.message); + } + const sourceCvm = cvmResult.data; + const workspace = currentUserResult.data.workspace; if (input.envFile) { const envPath = path.resolve(process.cwd(), input.envFile); @@ -48,10 +72,8 @@ async function runCvmsReplicateCommand( } const envVars = parseEnvFile(envPath); - const cvmConfig = await getCvmComposeConfig(normalizedCvmId); - - logger.info("Encrypting environment variables..."); - encryptedEnv = await encryptEnvVars(envVars, cvmConfig.env_pubkey); + const pubkey = await getEncryptPubkey(client, sourceCvm); + encryptedEnv = await encryptEnvVars(envVars, pubkey); } const requestBody: { teepod_id?: number; encrypted_env?: string } = {}; @@ -63,36 +85,58 @@ async function runCvmsReplicateCommand( requestBody.encrypted_env = encryptedEnv; } - const replica = await replicateCvm(normalizedCvmId, requestBody); + if (!sourceCvm.vm_uuid) { + throw new Error("Source CVM has no vm_uuid"); + } - logger.success( - `Successfully created replica of CVM UUID: ${normalizedCvmId} with App ID: ${replica.app_id}`, + const replica = await replicateCvm( + sourceCvm.app_id, + sourceCvm.vm_uuid, + requestBody, ); - logger.keyValueTable( - { - "CVM UUID": replica.vm_uuid.replace(/-/g, ""), - "App ID": replica.app_id, - Name: replica.name, - Status: replica.status, - TEEPod: `${replica.teepod.name} (ID: ${replica.teepod_id})`, - vCPUs: replica.vcpu, - Memory: `${replica.memory} MB`, - "Disk Size": `${replica.disk_size} GB`, - "App URL": - replica.app_url || - `${process.env.CLOUD_URL || "https://cloud.phala.com"}/dashboard/cvms/${replica.vm_uuid.replace(/-/g, "")}`, - }, - { borderStyle: "rounded" }, - ); + if (isInJsonMode()) { + context.success(replica); + return 0; + } - logger.success( - `Your CVM replica is being created. You can check its status with: -phala cvms get ${replica.app_id}`, - ); + const vmUuid = replica.vm_uuid?.replace(/-/g, "") ?? ""; + const teamLabel = + workspace.slug && workspace.slug !== workspace.name + ? `${workspace.name} (${workspace.slug})` + : workspace.slug || workspace.name; + const appUrl = + workspace.slug && vmUuid + ? `${CLOUD_URL}/${workspace.slug}/apps/${replica.app_id}/instances/${vmUuid}` + : replica.app_url || "-"; + const lines = [ + `Source CVM ID: ${context.cvmId.id}`, + `Team: ${teamLabel}`, + `CVM UUID: ${vmUuid || "-"}`, + `App ID: ${replica.app_id}`, + `Name: ${replica.name}`, + `Status: ${replica.status}`, + `TEEPod: ${replica.teepod.name} (ID: ${replica.teepod_id})`, + `vCPUs: ${replica.vcpu}`, + `Memory: ${replica.memory} MB`, + `Disk Size: ${replica.disk_size} GB`, + `App URL: ${appUrl}`, + ]; + context.stdout.write(`${lines.join("\n")}\n`); return 0; } catch (error) { logger.error("Failed to create CVM replica"); + if (error instanceof ResourceError) { + process.stderr.write(`${formatStructuredError(error)}\n`); + process.stderr.write( + "Reference the error code above in the handbook for remediation details.\n", + ); + return 1; + } + if (error instanceof Error) { + process.stderr.write(`${formatErrorMessage(error as PhalaCloudError)}\n`); + return 1; + } logger.logDetailedError(error); return 1; } diff --git a/cli/src/commands/profiles/delete/command.ts b/cli/src/commands/profiles/delete/command.ts new file mode 100644 index 00000000..dd8c5c1c --- /dev/null +++ b/cli/src/commands/profiles/delete/command.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; +import type { CommandMeta } from "@/src/core/types"; + +export const profilesDeleteCommandMeta: CommandMeta = { + name: "delete", + category: "profile", + description: "Delete a profile", + stability: "stable", + arguments: [ + { + name: "profile-name", + description: "Profile name", + required: true, + target: "profileName", + }, + ], + options: [], +}; + +export const profilesDeleteCommandSchema = z.object({ + profileName: z.string().min(1), +}); + +export type ProfilesDeleteCommandInput = z.infer< + typeof profilesDeleteCommandSchema +>; diff --git a/cli/src/commands/profiles/delete/index.ts b/cli/src/commands/profiles/delete/index.ts new file mode 100644 index 00000000..b6757b78 --- /dev/null +++ b/cli/src/commands/profiles/delete/index.ts @@ -0,0 +1,55 @@ +import { defineCommand } from "@/src/core/define-command"; +import type { CommandContext } from "@/src/core/types"; +import { + removeProfile, + getCurrentProfile, + listProfiles, +} from "@/src/utils/credentials"; +import { logger } from "@/src/utils/logger"; +import { + profilesDeleteCommandMeta, + profilesDeleteCommandSchema, + type ProfilesDeleteCommandInput, +} from "./command"; + +async function runProfilesDeleteCommand( + input: ProfilesDeleteCommandInput, + _context: CommandContext, +): Promise { + try { + const wasActive = getCurrentProfile()?.name === input.profileName; + const profilesBefore = listProfiles(); + + if (!profilesBefore.includes(input.profileName)) { + logger.error(`Profile "${input.profileName}" not found`); + return 1; + } + + removeProfile(input.profileName); + logger.success(`Deleted profile "${input.profileName}"`); + + if (wasActive) { + const newCurrent = getCurrentProfile(); + if (newCurrent) { + logger.info(`Switched to profile "${newCurrent.name}"`); + } else { + logger.info("No profiles remaining. Please login again."); + } + } + + return 0; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(message); + return 1; + } +} + +export const profilesDeleteCommand = defineCommand({ + path: ["profiles", "delete"], + meta: profilesDeleteCommandMeta, + schema: profilesDeleteCommandSchema, + handler: runProfilesDeleteCommand, +}); + +export default profilesDeleteCommand; diff --git a/cli/src/commands/profiles/index.ts b/cli/src/commands/profiles/index.ts index 746f0cce..1e6d5251 100644 --- a/cli/src/commands/profiles/index.ts +++ b/cli/src/commands/profiles/index.ts @@ -50,4 +50,8 @@ export const profilesCommand = defineCommand({ handler: runProfilesCommand, }); +export { profilesUseCommand } from "./use"; +export { profilesRenameCommand } from "./rename"; +export { profilesDeleteCommand } from "./delete"; + export default profilesCommand; diff --git a/cli/src/commands/profiles/rename/command.ts b/cli/src/commands/profiles/rename/command.ts new file mode 100644 index 00000000..77e66350 --- /dev/null +++ b/cli/src/commands/profiles/rename/command.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; +import type { CommandMeta } from "@/src/core/types"; + +export const profilesRenameCommandMeta: CommandMeta = { + name: "rename", + category: "profile", + description: "Rename a profile", + stability: "stable", + arguments: [ + { + name: "old-name", + description: "Current profile name", + required: true, + target: "oldName", + }, + { + name: "new-name", + description: "New profile name", + required: true, + target: "newName", + }, + ], + options: [], +}; + +export const profilesRenameCommandSchema = z.object({ + oldName: z.string().min(1), + newName: z.string().min(1), +}); + +export type ProfilesRenameCommandInput = z.infer< + typeof profilesRenameCommandSchema +>; diff --git a/cli/src/commands/profiles/rename/index.ts b/cli/src/commands/profiles/rename/index.ts new file mode 100644 index 00000000..e06d241c --- /dev/null +++ b/cli/src/commands/profiles/rename/index.ts @@ -0,0 +1,37 @@ +import { defineCommand } from "@/src/core/define-command"; +import type { CommandContext } from "@/src/core/types"; +import { renameProfile, getCurrentProfile } from "@/src/utils/credentials"; +import { logger } from "@/src/utils/logger"; +import { + profilesRenameCommandMeta, + profilesRenameCommandSchema, + type ProfilesRenameCommandInput, +} from "./command"; + +async function runProfilesRenameCommand( + input: ProfilesRenameCommandInput, + _context: CommandContext, +): Promise { + try { + const wasActive = getCurrentProfile()?.name === input.oldName; + renameProfile(input.oldName, input.newName); + logger.success(`Renamed profile "${input.oldName}" to "${input.newName}"`); + if (wasActive) { + logger.info(`Current profile updated to "${input.newName}"`); + } + return 0; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(message); + return 1; + } +} + +export const profilesRenameCommand = defineCommand({ + path: ["profiles", "rename"], + meta: profilesRenameCommandMeta, + schema: profilesRenameCommandSchema, + handler: runProfilesRenameCommand, +}); + +export default profilesRenameCommand; diff --git a/cli/src/commands/profiles/use/command.ts b/cli/src/commands/profiles/use/command.ts new file mode 100644 index 00000000..3a275a17 --- /dev/null +++ b/cli/src/commands/profiles/use/command.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; +import type { CommandMeta } from "@/src/core/types"; + +export const profilesUseCommandMeta: CommandMeta = { + name: "use", + category: "profile", + description: "Switch to a profile", + stability: "stable", + arguments: [ + { + name: "profile-name", + description: "Profile name", + required: true, + target: "profileName", + }, + ], + options: [ + { + name: "interactive", + shorthand: "i", + description: "Select profile interactively", + type: "boolean", + target: "interactive", + }, + ], +}; + +export const profilesUseCommandSchema = z.object({ + profileName: z.string().min(1).optional(), + interactive: z.boolean().default(false), +}); + +export type ProfilesUseCommandInput = z.infer; diff --git a/cli/src/commands/profiles/use/index.ts b/cli/src/commands/profiles/use/index.ts new file mode 100644 index 00000000..db305732 --- /dev/null +++ b/cli/src/commands/profiles/use/index.ts @@ -0,0 +1,146 @@ +import chalk from "chalk"; +import inquirer from "inquirer"; +import { defineCommand } from "@/src/core/define-command"; +import type { CommandContext } from "@/src/core/types"; +import { + switchProfile, + listProfiles, + getCurrentProfile, + loadCredentialsFile, +} from "@/src/utils/credentials"; +import { logger } from "@/src/utils/logger"; +import { + profilesUseCommandMeta, + profilesUseCommandSchema, + type ProfilesUseCommandInput, +} from "./command"; + +function isExitPromptError(error: unknown): boolean { + return ( + error !== null && + typeof error === "object" && + "name" in error && + (error as { name: string }).name === "ExitPromptError" + ); +} + +async function runProfilesUseCommand( + input: ProfilesUseCommandInput, + _context: CommandContext, +): Promise { + try { + if (input.interactive) { + return await interactiveSwitch(); + } + + if (!input.profileName) { + logger.error("Missing required argument: profile-name"); + logger.info("Usage: phala profiles use "); + logger.info(" phala profiles use -i"); + return 1; + } + + return directSwitch(input.profileName); + } catch (error) { + if (isExitPromptError(error)) { + console.log(); + logger.info("Switch cancelled."); + return 0; + } + + const message = error instanceof Error ? error.message : String(error); + + if (message.includes("not found")) { + logger.error(message); + const profiles = listProfiles(); + if (profiles.length > 0) { + logger.info("Available profiles:"); + for (const profile of profiles) { + console.log(chalk.gray(` - ${profile}`)); + } + } else { + logger.info("No profiles found. Please login first."); + } + } else { + logger.error("Failed to switch profile"); + logger.logDetailedError(error); + } + return 1; + } +} + +async function interactiveSwitch(): Promise { + const profiles = listProfiles(); + const currentProfile = getCurrentProfile(); + + if (profiles.length === 0) { + logger.warn("No profiles found. Please login first."); + return 1; + } + + if (profiles.length === 1) { + logger.info(`Only one profile available: ${profiles[0]}`); + return 0; + } + + const credentials = loadCredentialsFile(); + const choices = profiles.map((profile) => { + const profileInfo = credentials?.profiles[profile]; + const workspace = profileInfo?.workspace?.name || "unknown"; + const isCurrent = currentProfile?.name === profile; + const marker = isCurrent ? " (current)" : ""; + return { + name: `${profile} (workspace: ${workspace})${marker}`, + value: profile, + }; + }); + + const { selectedProfile } = await inquirer.prompt([ + { + type: "list", + name: "selectedProfile", + message: "Select a profile to switch to:", + choices, + default: currentProfile?.name, + }, + ]); + + if (selectedProfile === currentProfile?.name) { + logger.info(`Already using profile "${selectedProfile}"`); + return 0; + } + + switchProfile(selectedProfile); + const newProfile = getCurrentProfile(); + logger.success(`Switched to profile "${selectedProfile}"`); + if (newProfile?.info.workspace?.name) { + logger.info(`Workspace: ${newProfile.info.workspace.name}`); + } + return 0; +} + +function directSwitch(profileName: string): number { + const currentProfile = getCurrentProfile(); + + if (currentProfile?.name === profileName) { + logger.info(`Already using profile "${profileName}"`); + return 0; + } + + switchProfile(profileName); + const newProfile = getCurrentProfile(); + logger.success(`Switched to profile "${profileName}"`); + if (newProfile?.info.workspace?.name) { + logger.info(`Workspace: ${newProfile.info.workspace.name}`); + } + return 0; +} + +export const profilesUseCommand = defineCommand({ + path: ["profiles", "use"], + meta: profilesUseCommandMeta, + schema: profilesUseCommandSchema, + handler: runProfilesUseCommand, +}); + +export default profilesUseCommand; diff --git a/cli/src/index.ts b/cli/src/index.ts index 8104cd6f..3dbec12a 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -20,7 +20,12 @@ import { nodesCommands } from "./commands/nodes"; import { osImagesCommand } from "./commands/os-images"; import { simulatorCommands } from "./commands/simulator"; import { statusCommand } from "./commands/status"; -import { profilesCommand } from "./commands/profiles"; +import { + profilesCommand, + profilesUseCommand, + profilesRenameCommand, + profilesDeleteCommand, +} from "./commands/profiles"; import { switchCommand } from "./commands/switch"; import { completionCommand } from "./commands/completion"; import { sshCommand } from "./commands/ssh"; @@ -63,6 +68,9 @@ registry.registerCommand(cvmsRuntimeConfigCommand); registry.registerCommand(loginCommand); registry.registerCommand(logoutCommand); registry.registerCommand(profilesCommand); +registry.registerCommand(profilesUseCommand); +registry.registerCommand(profilesRenameCommand); +registry.registerCommand(profilesDeleteCommand); registry.registerCommand(switchCommand); registry.registerCommand(apiCommand); registry.registerCommand(statusCommand); diff --git a/cli/src/utils/credentials.ts b/cli/src/utils/credentials.ts index c660449c..827f4df6 100644 --- a/cli/src/utils/credentials.ts +++ b/cli/src/utils/credentials.ts @@ -258,6 +258,39 @@ export function removeProfile(profileName?: string): void { saveCredentialsFile(next); } +export function renameProfile(oldName: string, newName: string): void { + const normalizedOld = normalizeProfileName(oldName); + const normalizedNew = normalizeProfileName(newName); + const current = loadCredentialsFile(); + + if (!current) { + throw new Error("No credentials file found. Please login first."); + } + + if (!current.profiles[normalizedOld]) { + throw new Error(`Profile "${normalizedOld}" not found`); + } + + if (current.profiles[normalizedNew]) { + throw new Error(`Profile "${normalizedNew}" already exists`); + } + + const nextProfiles = { ...current.profiles }; + nextProfiles[normalizedNew] = nextProfiles[normalizedOld]; + delete nextProfiles[normalizedOld]; + + const nextCurrent = + current.current_profile === normalizedOld + ? normalizedNew + : current.current_profile; + + saveCredentialsFile({ + ...current, + current_profile: nextCurrent, + profiles: nextProfiles, + }); +} + export function listProfiles(): string[] { const current = loadCredentialsFile(); if (!current) return [];