From 2b3b98b9fdcd369f94887a05cea1827978cddeb1 Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Thu, 2 Apr 2026 21:53:49 +0800 Subject: [PATCH 01/10] fix(cli): use correct pubkey source for CVM replicate env encryption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The replicate command was using getCvmComposeConfig which calls /cvms/{id}/compose_file — an endpoint that does not return env_pubkey. This caused "undefined is not an object" when encrypting env vars. Switch to safeGetCvmInfo + getEncryptPubkey, matching the approach used by `phala env encrypt`. Remove the unused getCvmComposeConfig helper and CvmComposeConfigResponse type. --- cli/src/api/cvms.ts | 18 --------------- cli/src/api/types.ts | 28 ++++++++++-------------- cli/src/commands/cvms/replicate/index.ts | 21 +++++++++++++----- 3 files changed, 26 insertions(+), 41 deletions(-) diff --git a/cli/src/api/cvms.ts b/cli/src/api/cvms.ts index 85b6bd82..13a7f6d6 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) 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/cvms/replicate/index.ts b/cli/src/commands/cvms/replicate/index.ts index f87e4e30..76d8e6e1 100644 --- a/cli/src/commands/cvms/replicate/index.ts +++ b/cli/src/commands/cvms/replicate/index.ts @@ -1,7 +1,9 @@ import fs from "node:fs"; import path from "node:path"; -import { CvmIdSchema, encryptEnvVars } from "@phala/cloud"; -import { getCvmComposeConfig, replicateCvm } from "@/src/api/cvms"; +import { CvmIdSchema, safeGetCvmInfo, 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 type { CommandContext } from "@/src/core/types"; @@ -48,10 +50,16 @@ async function runCvmsReplicateCommand( } const envVars = parseEnvFile(envPath); - const cvmConfig = await getCvmComposeConfig(normalizedCvmId); + + const client = await getClient(); + const result = await safeGetCvmInfo(client, { id: normalizedCvmId }); + if (!result.success) { + throw new Error(result.error.message); + } + const pubkey = await getEncryptPubkey(client, result.data); logger.info("Encrypting environment variables..."); - encryptedEnv = await encryptEnvVars(envVars, cvmConfig.env_pubkey); + encryptedEnv = await encryptEnvVars(envVars, pubkey); } const requestBody: { teepod_id?: number; encrypted_env?: string } = {}; @@ -69,9 +77,10 @@ async function runCvmsReplicateCommand( `Successfully created replica of CVM UUID: ${normalizedCvmId} with App ID: ${replica.app_id}`, ); + const vmUuid = replica.vm_uuid?.replace(/-/g, "") ?? ""; logger.keyValueTable( { - "CVM UUID": replica.vm_uuid.replace(/-/g, ""), + "CVM UUID": vmUuid, "App ID": replica.app_id, Name: replica.name, Status: replica.status, @@ -81,7 +90,7 @@ async function runCvmsReplicateCommand( "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, "")}`, + `${process.env.CLOUD_URL || "https://cloud.phala.com"}/dashboard/cvms/${vmUuid}`, }, { borderStyle: "rounded" }, ); From 1a9ba688895a3334cae4a69defc3f4d429062841 Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Thu, 2 Apr 2026 23:28:17 +0800 Subject: [PATCH 02/10] fix(cli): use plaintext output for cvm replicate --- cli/src/commands/cvms/replicate/index.ts | 60 ++++++++++++++---------- 1 file changed, 35 insertions(+), 25 deletions(-) diff --git a/cli/src/commands/cvms/replicate/index.ts b/cli/src/commands/cvms/replicate/index.ts index 76d8e6e1..215d6918 100644 --- a/cli/src/commands/cvms/replicate/index.ts +++ b/cli/src/commands/cvms/replicate/index.ts @@ -5,6 +5,7 @@ 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 { logger } from "@/src/utils/logger"; @@ -58,7 +59,15 @@ async function runCvmsReplicateCommand( } const pubkey = await getEncryptPubkey(client, result.data); - logger.info("Encrypting environment variables..."); + if (!isInJsonMode()) { + context.stdout.write(`app_id: ${result.data.app_id}\n`); + context.stdout.write(`kms_type: ${result.data.kms_type}\n`); + context.stdout.write( + `kms_contract: ${result.data.kms_info?.dstack_kms_address ?? "-"}\n`, + ); + context.stdout.write(`env_pubkey: ${pubkey}\n`); + context.stdout.write("Encrypting environment variables...\n"); + } encryptedEnv = await encryptEnvVars(envVars, pubkey); } @@ -73,32 +82,33 @@ async function runCvmsReplicateCommand( const replica = await replicateCvm(normalizedCvmId, requestBody); - logger.success( - `Successfully created replica of CVM UUID: ${normalizedCvmId} with App ID: ${replica.app_id}`, - ); + if (isInJsonMode()) { + context.success(replica); + return 0; + } const vmUuid = replica.vm_uuid?.replace(/-/g, "") ?? ""; - logger.keyValueTable( - { - "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": - replica.app_url || - `${process.env.CLOUD_URL || "https://cloud.phala.com"}/dashboard/cvms/${vmUuid}`, - }, - { borderStyle: "rounded" }, - ); - - logger.success( - `Your CVM replica is being created. You can check its status with: -phala cvms get ${replica.app_id}`, - ); + const appUrl = + replica.app_url || + `${process.env.CLOUD_URL || "https://cloud.phala.com"}/dashboard/cvms/${vmUuid}`; + const lines = [ + "CVM replica created successfully.", + "", + `Source CVM UUID: ${normalizedCvmId}`, + `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}`, + "", + "Your CVM replica is being created. You can check its status with:", + `phala cvms get ${replica.app_id}`, + ]; + context.stdout.write(`${lines.join("\n")}\n`); return 0; } catch (error) { logger.error("Failed to create CVM replica"); From d697761a30a7b19cbbefff61ed0b74d82ae94ae4 Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Thu, 2 Apr 2026 23:28:17 +0800 Subject: [PATCH 03/10] fix(cli): use sdk private key flow in allow-devices --- cli/src/commands/allow-devices/index.ts | 47 +++---------------------- 1 file changed, 4 insertions(+), 43 deletions(-) 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, }); From 5fa84ba486cb3329e6b222bbc8a4edf03e1a41a3 Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Thu, 2 Apr 2026 23:41:40 +0800 Subject: [PATCH 04/10] fix(cli): support universal cvm ids for replicate --- cli/src/commands/cvms/replicate/index.ts | 27 ++++++++++++------------ 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/cli/src/commands/cvms/replicate/index.ts b/cli/src/commands/cvms/replicate/index.ts index 215d6918..0e39d190 100644 --- a/cli/src/commands/cvms/replicate/index.ts +++ b/cli/src/commands/cvms/replicate/index.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import { CvmIdSchema, safeGetCvmInfo, encryptEnvVars } from "@phala/cloud"; +import { safeGetCvmInfo, 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"; @@ -41,8 +41,13 @@ async function runCvmsReplicateCommand( return 1; } - const { cvmId: normalizedCvmId } = CvmIdSchema.parse(context.cvmId); let encryptedEnv: string | undefined; + const client = await getClient(); + const result = await safeGetCvmInfo(client, context.cvmId); + if (!result.success) { + throw new Error(result.error.message); + } + const sourceCvm = result.data; if (input.envFile) { const envPath = path.resolve(process.cwd(), input.envFile); @@ -51,19 +56,13 @@ async function runCvmsReplicateCommand( } const envVars = parseEnvFile(envPath); - - const client = await getClient(); - const result = await safeGetCvmInfo(client, { id: normalizedCvmId }); - if (!result.success) { - throw new Error(result.error.message); - } - const pubkey = await getEncryptPubkey(client, result.data); + const pubkey = await getEncryptPubkey(client, sourceCvm); if (!isInJsonMode()) { - context.stdout.write(`app_id: ${result.data.app_id}\n`); - context.stdout.write(`kms_type: ${result.data.kms_type}\n`); + context.stdout.write(`app_id: ${sourceCvm.app_id}\n`); + context.stdout.write(`kms_type: ${sourceCvm.kms_type}\n`); context.stdout.write( - `kms_contract: ${result.data.kms_info?.dstack_kms_address ?? "-"}\n`, + `kms_contract: ${sourceCvm.kms_info?.dstack_kms_address ?? "-"}\n`, ); context.stdout.write(`env_pubkey: ${pubkey}\n`); context.stdout.write("Encrypting environment variables...\n"); @@ -80,7 +79,7 @@ async function runCvmsReplicateCommand( requestBody.encrypted_env = encryptedEnv; } - const replica = await replicateCvm(normalizedCvmId, requestBody); + const replica = await replicateCvm(sourceCvm.app_id, requestBody); if (isInJsonMode()) { context.success(replica); @@ -94,7 +93,7 @@ async function runCvmsReplicateCommand( const lines = [ "CVM replica created successfully.", "", - `Source CVM UUID: ${normalizedCvmId}`, + `Source CVM ID: ${context.cvmId.id}`, `CVM UUID: ${vmUuid || "-"}`, `App ID: ${replica.app_id}`, `Name: ${replica.name}`, From fd0cb0352c35987606d1efe901fb6a6b305b6319 Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Fri, 3 Apr 2026 00:24:53 +0800 Subject: [PATCH 05/10] fix(cli): use instance-level cvm replication --- cli/src/api/cvms.ts | 6 +- cli/src/commands/cvms/replicate/index.ts | 74 ++++++++++++++++-------- 2 files changed, 54 insertions(+), 26 deletions(-) diff --git a/cli/src/api/cvms.ts b/cli/src/api/cvms.ts index 13a7f6d6..97230536 100644 --- a/cli/src/api/cvms.ts +++ b/cli/src/api/cvms.ts @@ -150,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; @@ -163,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/commands/cvms/replicate/index.ts b/cli/src/commands/cvms/replicate/index.ts index 0e39d190..5459fd90 100644 --- a/cli/src/commands/cvms/replicate/index.ts +++ b/cli/src/commands/cvms/replicate/index.ts @@ -1,6 +1,14 @@ import fs from "node:fs"; import path from "node:path"; -import { safeGetCvmInfo, encryptEnvVars } from "@phala/cloud"; +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"; @@ -8,6 +16,7 @@ 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, @@ -42,12 +51,19 @@ async function runCvmsReplicateCommand( } let encryptedEnv: string | undefined; - const client = await getClient(); - const result = await safeGetCvmInfo(client, context.cvmId); - if (!result.success) { - throw new Error(result.error.message); + 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 = result.data; + const sourceCvm = cvmResult.data; + const workspace = currentUserResult.data.workspace; if (input.envFile) { const envPath = path.resolve(process.cwd(), input.envFile); @@ -57,16 +73,6 @@ async function runCvmsReplicateCommand( const envVars = parseEnvFile(envPath); const pubkey = await getEncryptPubkey(client, sourceCvm); - - if (!isInJsonMode()) { - context.stdout.write(`app_id: ${sourceCvm.app_id}\n`); - context.stdout.write(`kms_type: ${sourceCvm.kms_type}\n`); - context.stdout.write( - `kms_contract: ${sourceCvm.kms_info?.dstack_kms_address ?? "-"}\n`, - ); - context.stdout.write(`env_pubkey: ${pubkey}\n`); - context.stdout.write("Encrypting environment variables...\n"); - } encryptedEnv = await encryptEnvVars(envVars, pubkey); } @@ -79,7 +85,15 @@ async function runCvmsReplicateCommand( requestBody.encrypted_env = encryptedEnv; } - const replica = await replicateCvm(sourceCvm.app_id, requestBody); + if (!sourceCvm.vm_uuid) { + throw new Error("Source CVM has no vm_uuid"); + } + + const replica = await replicateCvm( + sourceCvm.app_id, + sourceCvm.vm_uuid, + requestBody, + ); if (isInJsonMode()) { context.success(replica); @@ -87,13 +101,17 @@ async function runCvmsReplicateCommand( } 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 = - replica.app_url || - `${process.env.CLOUD_URL || "https://cloud.phala.com"}/dashboard/cvms/${vmUuid}`; + workspace.slug && vmUuid + ? `${CLOUD_URL}/${workspace.slug}/apps/${replica.app_id}/instances/${vmUuid}` + : replica.app_url || "-"; const lines = [ - "CVM replica created successfully.", - "", `Source CVM ID: ${context.cvmId.id}`, + `Team: ${teamLabel}`, `CVM UUID: ${vmUuid || "-"}`, `App ID: ${replica.app_id}`, `Name: ${replica.name}`, @@ -103,14 +121,22 @@ async function runCvmsReplicateCommand( `Memory: ${replica.memory} MB`, `Disk Size: ${replica.disk_size} GB`, `App URL: ${appUrl}`, - "", - "Your CVM replica is being created. You can check its status with:", - `phala cvms get ${replica.app_id}`, ]; 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; } From a77a011e47e34129c3a5b17c9adcd7f60ee3bec8 Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Fri, 3 Apr 2026 12:52:38 +0800 Subject: [PATCH 06/10] fix(cli): add two-phase replicate flow with on-chain approval - Add --compose-hash, --prepare-only, --commit, --token, --transaction-hash options - Rename --teepod-id to --node-id with name resolution support - Handle 465 responses for on-chain KMS prepare flow - Add commit command hints to prepare-only output - Use instance-level /cvms/{vm_uuid}/replicas endpoint --- cli/src/commands/cvms/replicate/command.ts | 79 +++- cli/src/commands/cvms/replicate/index.ts | 451 ++++++++++++++++++--- 2 files changed, 460 insertions(+), 70 deletions(-) diff --git a/cli/src/commands/cvms/replicate/command.ts b/cli/src/commands/cvms/replicate/command.ts index 4c41dba5..2836979d 100644 --- a/cli/src/commands/cvms/replicate/command.ts +++ b/cli/src/commands/cvms/replicate/command.ts @@ -9,10 +9,17 @@ export const cvmsReplicateCommandMeta: CommandMeta = { arguments: [cvmIdArgument], options: [ { - name: "teepod-id", - description: "TEEPod ID for replica", + name: "node-id", + description: "Node ID for replica", type: "string", - target: "teepodId", + target: "nodeId", + }, + { + name: "compose-hash", + description: + "Explicit compose hash to replicate. Required when the source app has multiple live instances", + type: "string", + target: "composeHash", }, { name: "env-file", @@ -21,20 +28,82 @@ export const cvmsReplicateCommandMeta: CommandMeta = { type: "string", target: "envFile", }, + { + name: "private-key", + description: "Private key for signing transactions.", + type: "string", + target: "privateKey", + group: "advanced", + }, + { + name: "rpc-url", + description: "RPC URL for the blockchain.", + type: "string", + target: "rpcUrl", + group: "advanced", + }, + { + name: "prepare-only", + description: + "Only prepare the replica (generate commit token) without performing on-chain operations.", + type: "boolean", + target: "prepareOnly", + group: "advanced", + }, + { + name: "commit", + description: + "Commit a previously prepared replica using a commit token. Requires --token and --compose-hash.", + type: "boolean", + target: "commit", + group: "advanced", + }, + { + name: "token", + description: "Commit token from a prepare-only replica request", + type: "string", + target: "token", + group: "advanced", + }, + { + name: "transaction-hash", + description: + "Transaction hash proving on-chain compose hash registration. Use already-registered for state-only verification.", + type: "string", + target: "transactionHash", + group: "advanced", + }, interactiveOption, ], examples: [ { name: "Replicate a CVM", - value: "phala cvms replicate 1234 --teepod-id 5", + value: "phala cvms replicate 1234 --node-id 5", + }, + { + name: "Prepare a replica for multisig approval", + value: + "phala cvms replicate 1234 --node-id 5 --compose-hash --prepare-only", + }, + { + name: "Commit a prepared replica", + value: + "phala cvms replicate 1234 --commit --token --compose-hash --transaction-hash ", }, ], }; export const cvmsReplicateCommandSchema = z.object({ cvmId: z.string().optional(), - teepodId: z.string().optional(), + nodeId: z.string().optional(), + composeHash: z.string().optional(), envFile: z.string().optional(), + privateKey: z.string().optional(), + rpcUrl: z.string().optional(), + prepareOnly: z.boolean().default(false), + commit: z.boolean().default(false), + token: z.string().optional(), + transactionHash: z.string().optional(), interactive: z.boolean().default(false), }); diff --git a/cli/src/commands/cvms/replicate/index.ts b/cli/src/commands/cvms/replicate/index.ts index 5459fd90..701f0acd 100644 --- a/cli/src/commands/cvms/replicate/index.ts +++ b/cli/src/commands/cvms/replicate/index.ts @@ -3,19 +3,23 @@ import path from "node:path"; import { type PhalaCloudError, ResourceError, - formatErrorMessage, - formatStructuredError, + type Client, + type ErrorLink, + type EnvVar, + safeAddComposeHash, + safeAddDevice, + safeCheckOnChainPrerequisites, + safeGetAvailableNodes, safeGetCvmInfo, - safeGetCurrentUser, encryptEnvVars, + formatErrorMessage, + formatStructuredError, } 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 { @@ -24,7 +28,22 @@ import { type CvmsReplicateCommandInput, } from "./command"; -function parseEnvFile(filePath: string): { key: string; value: string }[] { +interface ReplicaPreparePayload { + composeHash: string; + appId: string; + deviceId: string; + kmsInfo?: unknown; + commitToken?: string; + commitUrl?: string; + apiCommitUrl?: string; + onchainStatus?: { + compose_hash_allowed: boolean; + device_id_allowed: boolean; + is_allowed: boolean; + }; +} + +function parseEnvFile(filePath: string): EnvVar[] { const envContent = fs.readFileSync(filePath, "utf-8"); return envContent .split("\n") @@ -38,6 +57,230 @@ function parseEnvFile(filePath: string): { key: string; value: string }[] { }); } +function extractDetailsMap(error: ResourceError): Record { + const map: Record = {}; + const details = (error.structuredDetails ?? []) as Array<{ + field?: string; + value?: unknown; + }>; + for (const item of details) { + if (item.field) { + map[item.field] = item.value; + } + } + return map; +} + +function getPreparePayload(error: ResourceError): ReplicaPreparePayload | null { + const status = (error as unknown as { status?: number }).status; + if (status !== 465) { + return null; + } + const details = extractDetailsMap(error); + return { + composeHash: String(details.compose_hash ?? ""), + appId: String(details.app_id ?? ""), + deviceId: String(details.device_id ?? ""), + kmsInfo: details.kms_info, + commitToken: details.commit_token as string | undefined, + commitUrl: details.commit_url as string | undefined, + apiCommitUrl: details.api_commit_url as string | undefined, + onchainStatus: details.onchain_status as + | ReplicaPreparePayload["onchainStatus"] + | undefined, + }; +} + +function readPrivateKey(input: CvmsReplicateCommandInput): `0x${string}` { + const privateKey = input.privateKey || process.env.PRIVATE_KEY; + if (!privateKey) { + throw new Error( + "Private key is required for on-chain replica approval. Pass --private-key or set PRIVATE_KEY.", + ); + } + return privateKey as `0x${string}`; +} + +async function loadEncryptedEnv( + client: Client<"2026-01-21">, + sourceCvm: Parameters[1], + envFile?: string, +): Promise { + if (!envFile) { + return undefined; + } + const envPath = path.resolve(process.cwd(), envFile); + if (!fs.existsSync(envPath)) { + throw new Error(`Environment file not found: ${envPath}`); + } + const envVars = parseEnvFile(envPath); + const pubkey = await getEncryptPubkey(client, sourceCvm); + return encryptEnvVars(envVars, pubkey); +} + +async function resolveNodeId( + client: Client<"2026-01-21">, + nodeInput?: string, +): Promise { + if (!nodeInput) { + return undefined; + } + + const trimmed = nodeInput.trim(); + if (/^\d+$/.test(trimmed)) { + return Number.parseInt(trimmed, 10); + } + + const nodesResult = await safeGetAvailableNodes(client); + if (!nodesResult.success) { + const nodesError = "error" in nodesResult ? nodesResult.error : undefined; + throw new Error( + nodesError?.message || `Failed to resolve node name \"${trimmed}\"`, + ); + } + + const matches = nodesResult.data.nodes.filter( + (node) => node.name.toLowerCase() === trimmed.toLowerCase(), + ); + if (matches.length === 0) { + throw new Error(`Node \"${trimmed}\" not found.`); + } + if (matches.length > 1) { + throw new Error( + `Node name \"${trimmed}\" is ambiguous (${matches.length} matches). Use an explicit node ID.`, + ); + } + return matches[0].teepod_id; +} + +function formatReplicaOutput( + replica: Record, + context: CommandContext, +): void { + if (isInJsonMode()) { + context.success(replica); + return; + } + + const workspace = context.projectConfig; + const vmUuid = + typeof replica.vm_uuid === "string" + ? replica.vm_uuid.replace(/-/g, "") + : ""; + const workspaceSlug = + typeof replica.workspace_slug === "string" + ? replica.workspace_slug + : workspace.slug; + const workspaceName = + typeof replica.workspace_name === "string" + ? replica.workspace_name + : workspaceSlug || "-"; + const teamLabel = + workspaceSlug && workspaceSlug !== workspaceName + ? `${workspaceName} (${workspaceSlug})` + : workspaceSlug || workspaceName; + const appUrl = + workspaceSlug && vmUuid && typeof replica.app_id === "string" + ? `${CLOUD_URL}/${workspaceSlug}/apps/${replica.app_id}/instances/${vmUuid}` + : typeof replica.app_url === "string" + ? replica.app_url + : "-"; + const teepodName = + typeof replica.teepod === "object" && + replica.teepod !== null && + "name" in replica.teepod && + typeof replica.teepod.name === "string" + ? replica.teepod.name + : typeof replica.teepod_name === "string" + ? replica.teepod_name + : "-"; + const lines = [ + `Source CVM ID: ${context.cvmId?.id || replica.source_cvm_id || "-"}`, + `Team: ${teamLabel}`, + `CVM UUID: ${vmUuid || "-"}`, + `App ID: ${replica.app_id}`, + `Name: ${replica.name}`, + `Status: ${replica.status}`, + `Node: ${teepodName} (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`); +} + +function formatPrepareOutput( + payload: ReplicaPreparePayload, + context: CommandContext, +): void { + if (isInJsonMode()) { + context.success({ success: true, prepare_only: true, ...payload }); + return; + } + + const lines = [ + "CVM replica prepared successfully (pending on-chain approval).", + "", + `Compose Hash: ${payload.composeHash}`, + `App ID: ${payload.appId}`, + `Device ID: ${payload.deviceId}`, + ]; + if (payload.commitToken) { + lines.push(`Commit Token: ${payload.commitToken}`); + } + if (payload.commitUrl) { + lines.push(`Commit URL: ${payload.commitUrl}`); + } + if (payload.apiCommitUrl) { + lines.push(`API Commit URL: ${payload.apiCommitUrl}`); + } + if (payload.onchainStatus) { + lines.push( + "", + "On-chain Status:", + ` Compose Hash: ${payload.onchainStatus.compose_hash_allowed ? "registered" : "NOT registered"}`, + ` Device ID: ${payload.onchainStatus.device_id_allowed ? "registered" : "NOT registered"}`, + ); + if (payload.onchainStatus.is_allowed) { + lines.push( + " All prerequisites met. You can commit with --transaction-hash already-registered.", + ); + } + } + if (payload.commitToken) { + const composeHashHex = payload.composeHash.startsWith("0x") + ? payload.composeHash + : `0x${payload.composeHash}`; + lines.push( + "", + "To complete the replica after on-chain approval:", + " phala cvms replicate \\", + " --commit \\", + ` --token ${payload.commitToken} \\`, + ` --compose-hash ${composeHashHex} \\`, + " --transaction-hash ", + ); + } + context.stdout.write(`${lines.join("\n")}\n`); +} + +async function commitReplica( + client: Client<"2026-01-21">, + sourceVmUuid: string, + payload: { + token: string; + composeHash: string; + transactionHash: string; + }, +): Promise> { + return client.post(`/cvms/${sourceVmUuid}/commit-replica`, { + token: payload.token, + compose_hash: payload.composeHash, + transaction_hash: payload.transactionHash, + }); +} + async function runCvmsReplicateCommand( input: CvmsReplicateCommandInput, context: CommandContext, @@ -50,87 +293,165 @@ async function runCvmsReplicateCommand( return 1; } - let encryptedEnv: string | undefined; const client = await getClient(context); - const [cvmResult, currentUserResult] = await Promise.all([ - safeGetCvmInfo(client, context.cvmId), - safeGetCurrentUser(client), - ]); + const cvmResult = await safeGetCvmInfo(client, context.cvmId); 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); - if (!fs.existsSync(envPath)) { - throw new Error(`Environment file not found: ${envPath}`); - } - - const envVars = parseEnvFile(envPath); - const pubkey = await getEncryptPubkey(client, sourceCvm); - encryptedEnv = await encryptEnvVars(envVars, pubkey); + if (!sourceCvm.vm_uuid) { + throw new Error("Source CVM has no vm_uuid"); } - const requestBody: { teepod_id?: number; encrypted_env?: string } = {}; + if (input.commit) { + if (!input.token) { + throw new Error("--token is required for --commit mode"); + } + if (!input.composeHash) { + throw new Error("--compose-hash is required for --commit mode"); + } + const replica = (await commitReplica(client, sourceCvm.vm_uuid, { + token: input.token, + composeHash: input.composeHash, + transactionHash: input.transactionHash || "already-registered", + })) as Record; + formatReplicaOutput(replica, context); + return 0; + } - if (input.teepodId) { - requestBody.teepod_id = Number.parseInt(input.teepodId, 10); + const encryptedEnv = await loadEncryptedEnv( + client, + sourceCvm, + input.envFile, + ); + const resolvedNodeId = await resolveNodeId(client, input.nodeId); + const requestBody: Record = {}; + if (resolvedNodeId !== undefined) { + requestBody.node_id = resolvedNodeId; } if (encryptedEnv) { requestBody.encrypted_env = encryptedEnv; } - - if (!sourceCvm.vm_uuid) { - throw new Error("Source CVM has no vm_uuid"); + if (input.composeHash) { + requestBody.compose_hash = input.composeHash; } - const replica = await replicateCvm( - sourceCvm.app_id, - sourceCvm.vm_uuid, - requestBody, - ); + try { + const replica = (await client.post( + `/cvms/${sourceCvm.vm_uuid}/replicas`, + requestBody, + { + headers: input.prepareOnly ? { "X-Prepare-Only": "true" } : undefined, + }, + )) as Record; + formatReplicaOutput(replica, context); + return 0; + } catch (error) { + if (!(error instanceof ResourceError)) { + throw error; + } + + const preparePayload = getPreparePayload(error); + if (!preparePayload) { + throw error; + } + + if (input.prepareOnly) { + formatPrepareOutput(preparePayload, context); + return 0; + } + + if (!preparePayload.commitToken) { + throw new Error( + "Replica prepare response did not include a commit token", + ); + } + const kmsInfo = (preparePayload.kmsInfo ?? {}) as { + chain?: Parameters[0]["chain"]; + }; + if (!kmsInfo.chain) { + throw new Error( + "Replica prepare response is missing chain info required for on-chain approval", + ); + } + if (!sourceCvm.app_id) { + throw new Error( + "Source CVM is missing app_id required for on-chain approval", + ); + } + + const prereqs = await safeCheckOnChainPrerequisites({ + chain: kmsInfo.chain, + rpcUrl: input.rpcUrl, + appAddress: sourceCvm.app_id as `0x${string}`, + deviceId: preparePayload.deviceId, + composeHash: preparePayload.composeHash, + }); + if (!prereqs.success) { + const prereqError = "error" in prereqs ? prereqs.error : undefined; + throw new Error( + prereqError?.message || "Failed to check on-chain prerequisites", + ); + } + + let transactionHash = input.transactionHash || "already-registered"; + if (!prereqs.data.deviceAllowed) { + const deviceResult = await safeAddDevice({ + chain: kmsInfo.chain, + rpcUrl: input.rpcUrl, + appAddress: sourceCvm.app_id as `0x${string}`, + deviceId: preparePayload.deviceId, + privateKey: readPrivateKey(input), + }); + if (!deviceResult.success) { + const deviceError = + "error" in deviceResult ? deviceResult.error : undefined; + throw new Error( + deviceError?.message || "Failed to register device on-chain", + ); + } + } - if (isInJsonMode()) { - context.success(replica); + if (!prereqs.data.composeHashAllowed) { + const receiptResult = await safeAddComposeHash({ + chain: kmsInfo.chain, + rpcUrl: input.rpcUrl, + appId: sourceCvm.app_id as `0x${string}`, + composeHash: preparePayload.composeHash, + privateKey: readPrivateKey(input), + }); + if (!receiptResult.success) { + const receiptError = + "error" in receiptResult ? receiptResult.error : undefined; + throw new Error( + receiptError?.message || "Failed to register compose hash on-chain", + ); + } + transactionHash = String( + (receiptResult.data as { transactionHash?: string }) + .transactionHash || "already-registered", + ); + } + + const replica = (await commitReplica(client, sourceCvm.vm_uuid, { + token: preparePayload.commitToken, + composeHash: preparePayload.composeHash, + transactionHash, + })) as Record; + formatReplicaOutput(replica, context); return 0; } - - 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", - ); + const links = error.links as ErrorLink[] | undefined; + if (links && links.length > 0) { + for (const link of links) { + process.stderr.write(` ${link.label}: ${link.url}\n`); + } + } return 1; } if (error instanceof Error) { From 87cd7c8bff9c963219da4f03201f26ca2b57e966 Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Fri, 3 Apr 2026 16:56:37 +0800 Subject: [PATCH 07/10] fix(cli): improve replicate output formatting and error display - Show Team (from source CVM workspace) and App URL in replica output - Keep CVM UUID dashes intact (don't strip hyphens) - Show KMS type in output - Add 0x prefix to compose hash, app id, device id in prepare output - Preserve ResourceError from safeGetCvmInfo so error codes (e.g. ERR-03-003, ERR-03-009) and suggestions are displayed --- cli/src/commands/cvms/replicate/index.ts | 41 ++++++++++++------------ 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/cli/src/commands/cvms/replicate/index.ts b/cli/src/commands/cvms/replicate/index.ts index 701f0acd..d7c1fa96 100644 --- a/cli/src/commands/cvms/replicate/index.ts +++ b/cli/src/commands/cvms/replicate/index.ts @@ -156,32 +156,28 @@ async function resolveNodeId( function formatReplicaOutput( replica: Record, context: CommandContext, + sourceCvm?: { + workspace?: { slug?: string | null; name?: string } | null; + kms_type?: string | null; + }, ): void { if (isInJsonMode()) { context.success(replica); return; } - const workspace = context.projectConfig; - const vmUuid = - typeof replica.vm_uuid === "string" - ? replica.vm_uuid.replace(/-/g, "") - : ""; + const vmUuid = typeof replica.vm_uuid === "string" ? replica.vm_uuid : ""; const workspaceSlug = - typeof replica.workspace_slug === "string" - ? replica.workspace_slug - : workspace.slug; - const workspaceName = - typeof replica.workspace_name === "string" - ? replica.workspace_name - : workspaceSlug || "-"; + sourceCvm?.workspace?.slug || context.projectConfig.slug || ""; + const workspaceName = sourceCvm?.workspace?.name || workspaceSlug || "-"; const teamLabel = workspaceSlug && workspaceSlug !== workspaceName ? `${workspaceName} (${workspaceSlug})` : workspaceSlug || workspaceName; + const vmUuidCompact = vmUuid.replace(/-/g, ""); const appUrl = - workspaceSlug && vmUuid && typeof replica.app_id === "string" - ? `${CLOUD_URL}/${workspaceSlug}/apps/${replica.app_id}/instances/${vmUuid}` + workspaceSlug && vmUuidCompact && typeof replica.app_id === "string" + ? `${CLOUD_URL}/${workspaceSlug}/apps/${replica.app_id}/instances/${vmUuidCompact}` : typeof replica.app_url === "string" ? replica.app_url : "-"; @@ -194,6 +190,7 @@ function formatReplicaOutput( : typeof replica.teepod_name === "string" ? replica.teepod_name : "-"; + const kmsType = sourceCvm?.kms_type || "-"; const lines = [ `Source CVM ID: ${context.cvmId?.id || replica.source_cvm_id || "-"}`, `Team: ${teamLabel}`, @@ -201,6 +198,7 @@ function formatReplicaOutput( `App ID: ${replica.app_id}`, `Name: ${replica.name}`, `Status: ${replica.status}`, + `KMS: ${kmsType}`, `Node: ${teepodName} (ID: ${replica.teepod_id})`, `vCPUs: ${replica.vcpu}`, `Memory: ${replica.memory} MB`, @@ -219,12 +217,13 @@ function formatPrepareOutput( return; } + const ensureHex = (v: string) => (v && !v.startsWith("0x") ? `0x${v}` : v); const lines = [ "CVM replica prepared successfully (pending on-chain approval).", "", - `Compose Hash: ${payload.composeHash}`, - `App ID: ${payload.appId}`, - `Device ID: ${payload.deviceId}`, + `Compose Hash: ${ensureHex(payload.composeHash)}`, + `App ID: ${ensureHex(payload.appId)}`, + `Device ID: ${ensureHex(payload.deviceId)}`, ]; if (payload.commitToken) { lines.push(`Commit Token: ${payload.commitToken}`); @@ -296,7 +295,7 @@ async function runCvmsReplicateCommand( const client = await getClient(context); const cvmResult = await safeGetCvmInfo(client, context.cvmId); if (!cvmResult.success) { - throw new Error(cvmResult.error.message); + throw cvmResult.error; } const sourceCvm = cvmResult.data; @@ -316,7 +315,7 @@ async function runCvmsReplicateCommand( composeHash: input.composeHash, transactionHash: input.transactionHash || "already-registered", })) as Record; - formatReplicaOutput(replica, context); + formatReplicaOutput(replica, context, sourceCvm); return 0; } @@ -345,7 +344,7 @@ async function runCvmsReplicateCommand( headers: input.prepareOnly ? { "X-Prepare-Only": "true" } : undefined, }, )) as Record; - formatReplicaOutput(replica, context); + formatReplicaOutput(replica, context, sourceCvm); return 0; } catch (error) { if (!(error instanceof ResourceError)) { @@ -439,7 +438,7 @@ async function runCvmsReplicateCommand( composeHash: preparePayload.composeHash, transactionHash, })) as Record; - formatReplicaOutput(replica, context); + formatReplicaOutput(replica, context, sourceCvm); return 0; } } catch (error) { From 50129dcc87803e979fde0d624f3187e0547a5aca Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Fri, 3 Apr 2026 18:38:26 +0800 Subject: [PATCH 08/10] fix: parse StructuredError details array in 465 error handlers The 465 error handlers in updateCvmEnvs, updatePrelaunchScript, and updateDockerCompose expected flat fields on the error detail object (e.g. detail.compose_hash), but the backend returns StructuredError format with a details array: { details: [{ field: "compose_hash", value: "..." }, ...] } Extract fields from the details array using the same pattern already used in patchCvm's extractDetailsMap function. This fixes the two-phase on-chain signing flow for contract-owned KMS CVMs. --- js/src/actions/cvms/update_cvm_envs.ts | 34 ++++++++++++++----- js/src/actions/cvms/update_docker_compose.ts | 33 +++++++++++++----- .../actions/cvms/update_prelaunch_script.ts | 33 +++++++++++++----- 3 files changed, 73 insertions(+), 27 deletions(-) diff --git a/js/src/actions/cvms/update_cvm_envs.ts b/js/src/actions/cvms/update_cvm_envs.ts index 2e945e47..6690d99a 100644 --- a/js/src/actions/cvms/update_cvm_envs.ts +++ b/js/src/actions/cvms/update_cvm_envs.ts @@ -214,15 +214,31 @@ const { action: updateCvmEnvs, safeAction: safeUpdateCvmEnvs } = defineAction< if (detail && typeof detail === "object") { const detailObj = detail as Record; - // Return the 465 data as a successful response with status: "precondition_required" - return { - status: "precondition_required" as const, - message: (detailObj.message as string) || "Compose hash verification required", - compose_hash: detailObj.compose_hash as string, - app_id: detailObj.app_id as string, - device_id: detailObj.device_id as string, - kms_info: detailObj.kms_info, - }; + // Backend returns StructuredError format with fields in a details array: + // { error_code, message, details: [{ field, value }, ...], suggestions, links } + // Extract field values from the details array + const detailsArray = detailObj.details as + | Array<{ field: string; value: unknown }> + | undefined; + + if (detailsArray && Array.isArray(detailsArray)) { + const fieldMap = new Map(detailsArray.map((d) => [d.field, d.value])); + const composeHash = fieldMap.get("compose_hash"); + const appId = fieldMap.get("app_id"); + const deviceId = fieldMap.get("device_id"); + const kmsInfo = fieldMap.get("kms_info"); + + if (composeHash && appId) { + return { + status: "precondition_required" as const, + message: (detailObj.message as string) || "Compose hash verification required", + compose_hash: composeHash as string, + app_id: appId as string, + device_id: (deviceId as string) || "", + kms_info: kmsInfo, + }; + } + } } } diff --git a/js/src/actions/cvms/update_docker_compose.ts b/js/src/actions/cvms/update_docker_compose.ts index af576d2c..e10d26ef 100644 --- a/js/src/actions/cvms/update_docker_compose.ts +++ b/js/src/actions/cvms/update_docker_compose.ts @@ -229,15 +229,30 @@ const { action: updateDockerCompose, safeAction: safeUpdateDockerCompose } = def if (detail && typeof detail === "object") { const detailObj = detail as Record; - // Return the 465 data as a successful response with status: "precondition_required" - return { - status: "precondition_required" as const, - message: (detailObj.message as string) || "Compose hash verification required", - compose_hash: detailObj.compose_hash as string, - app_id: detailObj.app_id as string, - device_id: detailObj.device_id as string, - kms_info: detailObj.kms_info, - }; + // Backend returns StructuredError format with fields in a details array: + // { error_code, message, details: [{ field, value }, ...], suggestions, links } + const detailsArray = detailObj.details as + | Array<{ field: string; value: unknown }> + | undefined; + + if (detailsArray && Array.isArray(detailsArray)) { + const fieldMap = new Map(detailsArray.map((d) => [d.field, d.value])); + const composeHash = fieldMap.get("compose_hash"); + const appId = fieldMap.get("app_id"); + const deviceId = fieldMap.get("device_id"); + const kmsInfo = fieldMap.get("kms_info"); + + if (composeHash && appId) { + return { + status: "precondition_required" as const, + message: (detailObj.message as string) || "Compose hash verification required", + compose_hash: composeHash as string, + app_id: appId as string, + device_id: (deviceId as string) || "", + kms_info: kmsInfo, + }; + } + } } } diff --git a/js/src/actions/cvms/update_prelaunch_script.ts b/js/src/actions/cvms/update_prelaunch_script.ts index 2af1dbcd..4cb0a2dd 100644 --- a/js/src/actions/cvms/update_prelaunch_script.ts +++ b/js/src/actions/cvms/update_prelaunch_script.ts @@ -226,15 +226,30 @@ const { action: updatePreLaunchScript, safeAction: safeUpdatePreLaunchScript } = if (detail && typeof detail === "object") { const detailObj = detail as Record; - // Return the 465 data as a successful response with status: "precondition_required" - return { - status: "precondition_required" as const, - message: (detailObj.message as string) || "Compose hash verification required", - compose_hash: detailObj.compose_hash as string, - app_id: detailObj.app_id as string, - device_id: detailObj.device_id as string, - kms_info: detailObj.kms_info, - }; + // Backend returns StructuredError format with fields in a details array: + // { error_code, message, details: [{ field, value }, ...], suggestions, links } + const detailsArray = detailObj.details as + | Array<{ field: string; value: unknown }> + | undefined; + + if (detailsArray && Array.isArray(detailsArray)) { + const fieldMap = new Map(detailsArray.map((d) => [d.field, d.value])); + const composeHash = fieldMap.get("compose_hash"); + const appId = fieldMap.get("app_id"); + const deviceId = fieldMap.get("device_id"); + const kmsInfo = fieldMap.get("kms_info"); + + if (composeHash && appId) { + return { + status: "precondition_required" as const, + message: (detailObj.message as string) || "Compose hash verification required", + compose_hash: composeHash as string, + app_id: appId as string, + device_id: (deviceId as string) || "", + kms_info: kmsInfo, + }; + } + } } } From ae7eecc487ea1c6f0892b383e2a22e41e0e6593d Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Fri, 3 Apr 2026 19:04:12 +0800 Subject: [PATCH 09/10] fix: preserve StructuredError response body in error conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RequestError.fromFetchError set detail to parseResult.data.detail, which is undefined for StructuredError responses (they use 'details' array, not 'detail'). This caused the original response body to be lost when converting FetchError → RequestError → BusinessError. When detail is undefined but error.data contains a StructuredError (has error_code), fall back to using error.data as detail. This allows parseStructuredError in parseApiError to correctly parse it and return a ResourceError with the full structured data. Also remove debug console.log from updateCvmEnvs. --- js/src/utils/errors.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/js/src/utils/errors.ts b/js/src/utils/errors.ts index d99e0bae..6176706d 100644 --- a/js/src/utils/errors.ts +++ b/js/src/utils/errors.ts @@ -135,13 +135,22 @@ export class RequestError extends PhalaCloudError implements ApiError { const parseResult = ApiErrorSchema.safeParse(error.data); if (parseResult.success) { + // If ApiErrorSchema matched but detail is undefined, the response may use + // StructuredError format (error_code, details array, etc). Fall back to + // the raw response data so parseStructuredError can handle it downstream. + const detail = + parseResult.data.detail ?? + (error.data && typeof error.data === "object" && "error_code" in error.data + ? error.data + : undefined); + return new RequestError(error.message, { status: error.status ?? undefined, statusText: error.statusText ?? undefined, data: error.data, request: error.request ?? undefined, response: error.response ?? undefined, - detail: parseResult.data.detail as + detail: detail as | string | Record | Array<{ From c6e16ef294cf1ae82cbe169d191163a936a3d25f Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Fri, 3 Apr 2026 19:26:37 +0800 Subject: [PATCH 10/10] test: add tests for StructuredError 465 handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - errors.test.ts: verify RequestError.fromFetchError preserves StructuredError response body as detail, and parseApiError produces ResourceError with correct fields - update_cvm_envs.test.ts: verify 465 → precondition_required conversion with compose_hash, app_id, kms_info extraction --- js/src/actions/cvms/update_cvm_envs.test.ts | 155 ++++++++++++++++++++ js/src/utils/errors.test.ts | 69 +++++++++ 2 files changed, 224 insertions(+) create mode 100644 js/src/actions/cvms/update_cvm_envs.test.ts diff --git a/js/src/actions/cvms/update_cvm_envs.test.ts b/js/src/actions/cvms/update_cvm_envs.test.ts new file mode 100644 index 00000000..eb4b89ca --- /dev/null +++ b/js/src/actions/cvms/update_cvm_envs.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { updateCvmEnvs, safeUpdateCvmEnvs } from "./update_cvm_envs"; +import { PhalaCloudError } from "../../utils/errors"; +import type { Client } from "../../client"; + +function make465Error() { + return new PhalaCloudError("Compose hash registration required", { + status: 465, + statusText: "Hash Registration Required", + detail: { + error_code: "ERR-01-005", + message: "Compose hash registration required on-chain", + details: [ + { field: "compose_hash", value: "0xhash123" }, + { field: "app_id", value: "0xapp456" }, + { field: "device_id", value: "0xdevice789" }, + { + field: "kms_info", + value: { + id: "kms_test", + slug: "kms-base-prod9", + url: "https://kms.example.com", + version: "v0.5.7", + chain_id: 8453, + kms_contract_address: "0xkms123", + gateway_app_id: "0xgateway456", + }, + }, + ], + }, + }); +} + +describe("updateCvmEnvs", () => { + let mockClient: Client; + + beforeEach(() => { + mockClient = { + patch: vi.fn(), + } as unknown as Client; + }); + + it("should return in_progress on success", async () => { + vi.mocked(mockClient.patch).mockResolvedValue({ + status: "in_progress", + message: "Update initiated", + correlation_id: "corr-123", + allowed_envs_changed: false, + }); + + const result = await updateCvmEnvs(mockClient, { + id: "test-cvm-id", + encrypted_env: "encrypted-data", + env_keys: ["KEY1"], + }); + + expect(result.status).toBe("in_progress"); + if (result.status === "in_progress") { + expect(result.correlation_id).toBe("corr-123"); + } + }); + + it("should return precondition_required on 465", async () => { + vi.mocked(mockClient.patch).mockRejectedValue(make465Error()); + + const result = await updateCvmEnvs(mockClient, { + id: "test-cvm-id", + encrypted_env: "encrypted-data", + env_keys: ["KEY1"], + }); + + expect(result.status).toBe("precondition_required"); + if (result.status === "precondition_required") { + expect(result.compose_hash).toBe("0xhash123"); + expect(result.app_id).toBe("0xapp456"); + expect(result.device_id).toBe("0xdevice789"); + expect(result.kms_info).toBeDefined(); + } + }); + + it("should throw on non-465 errors", async () => { + const error = new PhalaCloudError("Not found", { + status: 404, + statusText: "Not Found", + detail: "CVM not found", + }); + vi.mocked(mockClient.patch).mockRejectedValue(error); + + await expect( + updateCvmEnvs(mockClient, { + id: "test-cvm-id", + encrypted_env: "encrypted-data", + }), + ).rejects.toThrow(); + }); +}); + +describe("safeUpdateCvmEnvs", () => { + let mockClient: Client; + + beforeEach(() => { + mockClient = { + patch: vi.fn(), + } as unknown as Client; + }); + + it("should return success for in_progress result", async () => { + vi.mocked(mockClient.patch).mockResolvedValue({ + status: "in_progress", + message: "Update initiated", + correlation_id: "corr-123", + allowed_envs_changed: false, + }); + + const result = await safeUpdateCvmEnvs(mockClient, { + id: "test-cvm-id", + encrypted_env: "encrypted-data", + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.status).toBe("in_progress"); + } + }); + + it("should return success for 465 precondition_required result", async () => { + vi.mocked(mockClient.patch).mockRejectedValue(make465Error()); + + const result = await safeUpdateCvmEnvs(mockClient, { + id: "test-cvm-id", + encrypted_env: "encrypted-data", + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.status).toBe("precondition_required"); + } + }); + + it("should return error for non-465 failures", async () => { + const error = new PhalaCloudError("Server error", { + status: 500, + statusText: "Internal Server Error", + detail: "Unexpected error", + }); + vi.mocked(mockClient.patch).mockRejectedValue(error); + + const result = await safeUpdateCvmEnvs(mockClient, { + id: "test-cvm-id", + encrypted_env: "encrypted-data", + }); + + expect(result.success).toBe(false); + }); +}); diff --git a/js/src/utils/errors.test.ts b/js/src/utils/errors.test.ts index 6c2ab959..2c0dfb00 100644 --- a/js/src/utils/errors.test.ts +++ b/js/src/utils/errors.test.ts @@ -3,6 +3,7 @@ import { parseApiError, PhalaCloudError, RequestError, + ResourceError, ValidationError, AuthError, BusinessError, @@ -459,3 +460,71 @@ describe("Error type discriminator properties", () => { expect(error.isUnknownError).toBeUndefined(); }); }); + +describe("RequestError.fromFetchError with StructuredError responses", () => { + function makeStructuredFetchError(status: number) { + return { + message: `[PATCH] "/api/cvms/abc123/envs": ${status} `, + status, + statusText: "", + data: { + error_code: "ERR-01-005", + message: "Compose hash registration required on-chain", + details: [ + { field: "compose_hash", value: "0xhash123", message: null }, + { field: "app_id", value: "0xapp456", message: null }, + { field: "device_id", value: "0xdevice789", message: null }, + { + field: "kms_info", + value: { + id: "kms_test", + slug: "kms-base-prod9", + url: "https://kms.example.com", + version: "v0.5.7", + chain_id: 8453, + kms_contract_address: "0xkms123", + gateway_app_id: "0xgateway456", + }, + message: null, + }, + ], + suggestions: ["Register the compose hash on-chain"], + links: [{ url: "https://docs.example.com", label: "Docs" }], + }, + request: "/api/cvms/abc123/envs", + response: {} as Response, + } as unknown; + } + + it("should preserve StructuredError data as detail when ApiErrorSchema.detail is undefined", () => { + const fetchError = makeStructuredFetchError(465); + const requestError = RequestError.fromFetchError(fetchError as never); + + // detail should be the full StructuredError object, not undefined or a string + expect(requestError.detail).toBeDefined(); + expect(typeof requestError.detail).toBe("object"); + + const detail = requestError.detail as Record; + expect(detail.error_code).toBe("ERR-01-005"); + expect(detail.details).toBeDefined(); + expect(Array.isArray(detail.details)).toBe(true); + }); + + it("should produce ResourceError when parsed through parseApiError", () => { + const fetchError = makeStructuredFetchError(465); + const requestError = RequestError.fromFetchError(fetchError as never); + const error = parseApiError(requestError); + + expect(error).toBeInstanceOf(ResourceError); + expect(error).toBeInstanceOf(BusinessError); + expect(error).toBeInstanceOf(PhalaCloudError); + expect(error.status).toBe(465); + + // detail should still contain the StructuredError object + expect(error.detail).toBeDefined(); + expect(typeof error.detail).toBe("object"); + const detail = error.detail as Record; + expect(detail.error_code).toBe("ERR-01-005"); + expect(Array.isArray(detail.details)).toBe(true); + }); +});