From 2b3b98b9fdcd369f94887a05cea1827978cddeb1 Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Thu, 2 Apr 2026 21:53:49 +0800 Subject: [PATCH 01/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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 986f1acd6b925e452a0608c8fc91717822363d0f Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Fri, 3 Apr 2026 19:00:32 +0800 Subject: [PATCH 08/13] fix(cli): show on-chain status before requiring private key in replicate When compose hash or device is not registered on-chain, display what needs registration before demanding --private-key. If no private key is provided, show prepare output and actionable error message instead of a generic "private key required" error. --- cli/src/commands/cvms/replicate/index.ts | 86 +++++++++++++++--------- 1 file changed, 56 insertions(+), 30 deletions(-) diff --git a/cli/src/commands/cvms/replicate/index.ts b/cli/src/commands/cvms/replicate/index.ts index d7c1fa96..5b9643b4 100644 --- a/cli/src/commands/cvms/replicate/index.ts +++ b/cli/src/commands/cvms/replicate/index.ts @@ -395,42 +395,68 @@ async function runCvmsReplicateCommand( } 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", + const needsDevice = !prereqs.data.deviceAllowed; + const needsCompose = !prereqs.data.composeHashAllowed; + + if (needsDevice || needsCompose) { + const missing: string[] = []; + if (needsCompose) { + missing.push( + `compose hash (${preparePayload.composeHash.slice(0, 12)}...)`, ); } - } + if (needsDevice) { + missing.push(`device (${preparePayload.deviceId.slice(0, 12)}...)`); + } + logger.info(`On-chain registration required: ${missing.join(", ")}`); - 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; + const privateKey = input.privateKey || process.env.PRIVATE_KEY; + if (!privateKey) { + formatPrepareOutput(preparePayload, context); throw new Error( - receiptError?.message || "Failed to register compose hash on-chain", + `On-chain registration required (${missing.join(", ")}). Pass --private-key or set PRIVATE_KEY to register automatically, or use --prepare-only to handle registration separately.`, + ); + } + const typedKey = privateKey as `0x${string}`; + + if (needsDevice) { + const deviceResult = await safeAddDevice({ + chain: kmsInfo.chain, + rpcUrl: input.rpcUrl, + appAddress: sourceCvm.app_id as `0x${string}`, + deviceId: preparePayload.deviceId, + privateKey: typedKey, + }); + if (!deviceResult.success) { + const deviceError = + "error" in deviceResult ? deviceResult.error : undefined; + throw new Error( + deviceError?.message || "Failed to register device on-chain", + ); + } + } + + if (needsCompose) { + const receiptResult = await safeAddComposeHash({ + chain: kmsInfo.chain, + rpcUrl: input.rpcUrl, + appId: sourceCvm.app_id as `0x${string}`, + composeHash: preparePayload.composeHash, + privateKey: typedKey, + }); + 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", ); } - transactionHash = String( - (receiptResult.data as { transactionHash?: string }) - .transactionHash || "already-registered", - ); } const replica = (await commitReplica(client, sourceCvm.vm_uuid, { From 600cb66b9c8732a9e89fe296fc01d117a6d3f012 Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Fri, 3 Apr 2026 19:02:11 +0800 Subject: [PATCH 09/13] fix(cli): show full values in on-chain registration error --- cli/src/commands/cvms/replicate/index.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/cli/src/commands/cvms/replicate/index.ts b/cli/src/commands/cvms/replicate/index.ts index 5b9643b4..9c81b105 100644 --- a/cli/src/commands/cvms/replicate/index.ts +++ b/cli/src/commands/cvms/replicate/index.ts @@ -401,20 +401,17 @@ async function runCvmsReplicateCommand( if (needsDevice || needsCompose) { const missing: string[] = []; if (needsCompose) { - missing.push( - `compose hash (${preparePayload.composeHash.slice(0, 12)}...)`, - ); + missing.push("compose hash"); } if (needsDevice) { - missing.push(`device (${preparePayload.deviceId.slice(0, 12)}...)`); + missing.push("device"); } - logger.info(`On-chain registration required: ${missing.join(", ")}`); const privateKey = input.privateKey || process.env.PRIVATE_KEY; if (!privateKey) { formatPrepareOutput(preparePayload, context); throw new Error( - `On-chain registration required (${missing.join(", ")}). Pass --private-key or set PRIVATE_KEY to register automatically, or use --prepare-only to handle registration separately.`, + `On-chain registration required: ${missing.join(", ")} not registered. Pass --private-key or set PRIVATE_KEY to register automatically, or use --prepare-only to handle registration separately.`, ); } const typedKey = privateKey as `0x${string}`; From 15ff65d20a86702c519df63df4b86989832152f1 Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Sat, 4 Apr 2026 01:43:30 +0800 Subject: [PATCH 10/13] fix(cli): resolve app_id ambiguity with --compose-hash in replicate When app_id matches multiple CVMs (ERR-03-010) and --compose-hash is provided, fallback to listing CVMs and filtering by compose_hash to find the specific source instance. --- cli/src/commands/cvms/replicate/index.ts | 46 ++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/cli/src/commands/cvms/replicate/index.ts b/cli/src/commands/cvms/replicate/index.ts index 9c81b105..d95b862d 100644 --- a/cli/src/commands/cvms/replicate/index.ts +++ b/cli/src/commands/cvms/replicate/index.ts @@ -11,6 +11,7 @@ import { safeCheckOnChainPrerequisites, safeGetAvailableNodes, safeGetCvmInfo, + safeGetCvmList, encryptEnvVars, formatErrorMessage, formatStructuredError, @@ -293,11 +294,52 @@ async function runCvmsReplicateCommand( } const client = await getClient(context); + let sourceCvm: Awaited< + ReturnType> + >["data"] extends infer T + ? NonNullable + : never; const cvmResult = await safeGetCvmInfo(client, context.cvmId); if (!cvmResult.success) { - throw cvmResult.error; + // When identifier matches multiple CVMs and --compose-hash is given, + // resolve by listing CVMs and picking the one with matching compose_hash. + const isMultiple = + cvmResult.error && + typeof cvmResult.error === "object" && + "errorCode" in cvmResult.error && + cvmResult.error.errorCode === "ERR-03-010"; + if (isMultiple && input.composeHash) { + const listResult = await safeGetCvmList(client); + if (!listResult.success) { + throw cvmResult.error; + } + const cleanHash = input.composeHash.replace(/^0x/, "").toLowerCase(); + const matched = listResult.data.items.filter( + (cvm) => + cvm.app_id === context.cvmId?.id && + cvm.compose_hash?.toLowerCase() === cleanHash, + ); + if (matched.length === 1 && matched[0].vm_uuid) { + const resolved = await safeGetCvmInfo(client, { + id: matched[0].vm_uuid, + }); + if (!resolved.success) { + throw resolved.error; + } + sourceCvm = resolved.data; + } else if (matched.length === 0) { + throw new Error( + `No CVM found with app_id ${context.cvmId.id} and compose_hash ${input.composeHash}`, + ); + } else { + throw cvmResult.error; + } + } else { + throw cvmResult.error; + } + } else { + sourceCvm = cvmResult.data; } - const sourceCvm = cvmResult.data; if (!sourceCvm.vm_uuid) { throw new Error("Source CVM has no vm_uuid"); From fcfed6e7dab1a609ff09e52c2a1448ea1c3f4fc4 Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Sat, 4 Apr 2026 01:46:02 +0800 Subject: [PATCH 11/13] fix(cli): strip app_ prefix when matching cvm list by app_id --- cli/src/commands/cvms/replicate/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cli/src/commands/cvms/replicate/index.ts b/cli/src/commands/cvms/replicate/index.ts index d95b862d..ed8ce281 100644 --- a/cli/src/commands/cvms/replicate/index.ts +++ b/cli/src/commands/cvms/replicate/index.ts @@ -314,9 +314,10 @@ async function runCvmsReplicateCommand( throw cvmResult.error; } const cleanHash = input.composeHash.replace(/^0x/, "").toLowerCase(); + const rawId = (context.cvmId?.id ?? "").replace(/^app_/, ""); const matched = listResult.data.items.filter( (cvm) => - cvm.app_id === context.cvmId?.id && + cvm.app_id === rawId && cvm.compose_hash?.toLowerCase() === cleanHash, ); if (matched.length === 1 && matched[0].vm_uuid) { @@ -329,7 +330,7 @@ async function runCvmsReplicateCommand( sourceCvm = resolved.data; } else if (matched.length === 0) { throw new Error( - `No CVM found with app_id ${context.cvmId.id} and compose_hash ${input.composeHash}`, + `No CVM found with app_id ${rawId} and compose_hash ${input.composeHash}`, ); } else { throw cvmResult.error; From 2f2266a7ab0ffb93c94d458298ec5082111e402c Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Sat, 4 Apr 2026 01:51:14 +0800 Subject: [PATCH 12/13] fix(cli): use app CVMs endpoint for compose-hash disambiguation Replace full CVM list with GET /apps/{appId}/cvms to resolve app_id ambiguity. Show available compose hashes (deduplicated) when no match. --- cli/src/commands/cvms/replicate/index.ts | 32 ++++++++++++++++-------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/cli/src/commands/cvms/replicate/index.ts b/cli/src/commands/cvms/replicate/index.ts index ed8ce281..ecbb0283 100644 --- a/cli/src/commands/cvms/replicate/index.ts +++ b/cli/src/commands/cvms/replicate/index.ts @@ -10,8 +10,8 @@ import { safeAddDevice, safeCheckOnChainPrerequisites, safeGetAvailableNodes, + safeGetAppCvms, safeGetCvmInfo, - safeGetCvmList, encryptEnvVars, formatErrorMessage, formatStructuredError, @@ -309,17 +309,21 @@ async function runCvmsReplicateCommand( "errorCode" in cvmResult.error && cvmResult.error.errorCode === "ERR-03-010"; if (isMultiple && input.composeHash) { - const listResult = await safeGetCvmList(client); - if (!listResult.success) { + const rawId = (context.cvmId?.id ?? "") + .replace(/^app_/, "") + .replace(/^0x/, "") + .toLowerCase(); + const cleanHash = input.composeHash.replace(/^0x/, "").toLowerCase(); + const appCvmsResult = await safeGetAppCvms(client, { appId: rawId }); + if (!appCvmsResult.success) { throw cvmResult.error; } - const cleanHash = input.composeHash.replace(/^0x/, "").toLowerCase(); - const rawId = (context.cvmId?.id ?? "").replace(/^app_/, ""); - const matched = listResult.data.items.filter( - (cvm) => - cvm.app_id === rawId && - cvm.compose_hash?.toLowerCase() === cleanHash, - ); + const matched = appCvmsResult.data.filter((cvm) => { + const cvmHash = (cvm.compose_hash ?? "") + .replace(/^0x/, "") + .toLowerCase(); + return cvmHash === cleanHash; + }); if (matched.length === 1 && matched[0].vm_uuid) { const resolved = await safeGetCvmInfo(client, { id: matched[0].vm_uuid, @@ -329,8 +333,14 @@ async function runCvmsReplicateCommand( } sourceCvm = resolved.data; } else if (matched.length === 0) { + const available = [ + ...new Set( + appCvmsResult.data.map((c) => c.compose_hash).filter(Boolean), + ), + ]; throw new Error( - `No CVM found with app_id ${rawId} and compose_hash ${input.composeHash}`, + `No CVM instance with compose_hash ${input.composeHash} found for app ${rawId}. ` + + `Available compose hashes: ${available.length > 0 ? available.join(", ") : "none"}`, ); } else { throw cvmResult.error; From 0c6a50c7fc57370ecb61359f34f04210c645ca18 Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Sat, 4 Apr 2026 02:18:17 +0800 Subject: [PATCH 13/13] fix(sdk): chain resolution, owner pre-check, and ABI error definitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS SDK: - CvmKmsInfoV20260121Schema: add chain_id → chain transform (parity with KmsInfoSchema used by 2025-10-28 API) - addDevice/addComposeHash: pre-check contract owner before submitting tx, give clear error if sender is not owner - dstackAppAbi: add OwnableUnauthorizedAccount and OwnableInvalidOwner error definitions for proper error decoding CLI: - Use sourceCvm.kms_info.chain (from SDK transform) instead of manual chain_id mapping - Use safeGetAppCvms for app_id disambiguation instead of full CVM list - Remove debug logging --- cli/src/commands/cvms/replicate/index.ts | 65 ++++++++----------- js/src/actions/blockchains/abi/dstack_app.ts | 12 ++++ .../actions/blockchains/add_compose_hash.ts | 21 ++++++ js/src/actions/blockchains/add_device.ts | 13 ++++ js/src/types/cvm_info_v20260121.ts | 14 +++- 5 files changed, 86 insertions(+), 39 deletions(-) diff --git a/cli/src/commands/cvms/replicate/index.ts b/cli/src/commands/cvms/replicate/index.ts index ecbb0283..a60d67c3 100644 --- a/cli/src/commands/cvms/replicate/index.ts +++ b/cli/src/commands/cvms/replicate/index.ts @@ -308,43 +308,28 @@ async function runCvmsReplicateCommand( typeof cvmResult.error === "object" && "errorCode" in cvmResult.error && cvmResult.error.errorCode === "ERR-03-010"; - if (isMultiple && input.composeHash) { + if (isMultiple) { const rawId = (context.cvmId?.id ?? "") .replace(/^app_/, "") .replace(/^0x/, "") .toLowerCase(); - const cleanHash = input.composeHash.replace(/^0x/, "").toLowerCase(); const appCvmsResult = await safeGetAppCvms(client, { appId: rawId }); - if (!appCvmsResult.success) { + if (!appCvmsResult.success || appCvmsResult.data.length === 0) { throw cvmResult.error; } - const matched = appCvmsResult.data.filter((cvm) => { - const cvmHash = (cvm.compose_hash ?? "") - .replace(/^0x/, "") - .toLowerCase(); - return cvmHash === cleanHash; - }); - if (matched.length === 1 && matched[0].vm_uuid) { - const resolved = await safeGetCvmInfo(client, { - id: matched[0].vm_uuid, - }); - if (!resolved.success) { - throw resolved.error; - } - sourceCvm = resolved.data; - } else if (matched.length === 0) { - const available = [ - ...new Set( - appCvmsResult.data.map((c) => c.compose_hash).filter(Boolean), - ), - ]; - throw new Error( - `No CVM instance with compose_hash ${input.composeHash} found for app ${rawId}. ` + - `Available compose hashes: ${available.length > 0 ? available.join(", ") : "none"}`, - ); - } else { + // Pick first instance as source; compose_hash is passed to the + // replicate API and the backend resolves the revision. + const first = appCvmsResult.data[0]; + if (!first.vm_uuid) { throw cvmResult.error; } + const resolved = await safeGetCvmInfo(client, { + id: first.vm_uuid, + }); + if (!resolved.success) { + throw resolved.error; + } + sourceCvm = resolved.data; } else { throw cvmResult.error; } @@ -419,13 +404,17 @@ async function runCvmsReplicateCommand( "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", - ); + const chain = ( + sourceCvm as { + kms_info?: { + chain?: Parameters< + typeof safeCheckOnChainPrerequisites + >[0]["chain"]; + }; + } + ).kms_info?.chain; + if (!chain) { + throw new Error("Source CVM kms_info is missing chain configuration"); } if (!sourceCvm.app_id) { throw new Error( @@ -434,7 +423,7 @@ async function runCvmsReplicateCommand( } const prereqs = await safeCheckOnChainPrerequisites({ - chain: kmsInfo.chain, + chain: chain, rpcUrl: input.rpcUrl, appAddress: sourceCvm.app_id as `0x${string}`, deviceId: preparePayload.deviceId, @@ -471,7 +460,7 @@ async function runCvmsReplicateCommand( if (needsDevice) { const deviceResult = await safeAddDevice({ - chain: kmsInfo.chain, + chain: chain, rpcUrl: input.rpcUrl, appAddress: sourceCvm.app_id as `0x${string}`, deviceId: preparePayload.deviceId, @@ -488,7 +477,7 @@ async function runCvmsReplicateCommand( if (needsCompose) { const receiptResult = await safeAddComposeHash({ - chain: kmsInfo.chain, + chain: chain, rpcUrl: input.rpcUrl, appId: sourceCvm.app_id as `0x${string}`, composeHash: preparePayload.composeHash, diff --git a/js/src/actions/blockchains/abi/dstack_app.ts b/js/src/actions/blockchains/abi/dstack_app.ts index 14d49fe6..7214ae71 100644 --- a/js/src/actions/blockchains/abi/dstack_app.ts +++ b/js/src/actions/blockchains/abi/dstack_app.ts @@ -60,6 +60,18 @@ export const dstackAppAbi = [ type: "function", }, + // ── Errors ─────────────────────────────────────────────────────── + { + inputs: [{ internalType: "address", name: "owner", type: "address" }], + name: "OwnableInvalidOwner", + type: "error", + }, + { + inputs: [{ internalType: "address", name: "account", type: "address" }], + name: "OwnableUnauthorizedAccount", + type: "error", + }, + // ── Events ─────────────────────────────────────────────────────── { anonymous: false, diff --git a/js/src/actions/blockchains/add_compose_hash.ts b/js/src/actions/blockchains/add_compose_hash.ts index 88061b44..7acadf77 100644 --- a/js/src/actions/blockchains/add_compose_hash.ts +++ b/js/src/actions/blockchains/add_compose_hash.ts @@ -498,6 +498,27 @@ export async function addComposeHash => { const hash = await clients.walletClient.writeContract({ diff --git a/js/src/actions/blockchains/add_device.ts b/js/src/actions/blockchains/add_device.ts index 0b9055af..94c7b9bf 100644 --- a/js/src/actions/blockchains/add_device.ts +++ b/js/src/actions/blockchains/add_device.ts @@ -229,6 +229,19 @@ export async function addDevice => { return clients.walletClient.writeContract({ address: contractAddress, diff --git a/js/src/types/cvm_info_v20260121.ts b/js/src/types/cvm_info_v20260121.ts index b2067ac4..48989be3 100644 --- a/js/src/types/cvm_info_v20260121.ts +++ b/js/src/types/cvm_info_v20260121.ts @@ -1,5 +1,7 @@ +import type { Chain } from "viem"; import { z } from "zod"; import { CvmNetworkUrlsV20251028Schema } from "./cvm_info_v20251028"; +import { SUPPORTED_CHAINS } from "./supported_chains"; export const BillingPeriodSchema = z.enum(["skip", "hourly", "monthly"]); export type BillingPeriod = z.infer; @@ -43,7 +45,7 @@ export const CvmOsInfoV20260121Schema = z.object({ }); export type CvmOsInfoV20260121 = z.infer; -export const CvmKmsInfoV20260121Schema = z.object({ +const CvmKmsInfoV20260121BaseSchema = z.object({ chain_id: z.number().int().nullable().optional(), dstack_kms_address: z.string().nullable().optional(), dstack_app_address: z.string().nullable().optional(), @@ -51,6 +53,16 @@ export const CvmKmsInfoV20260121Schema = z.object({ rpc_endpoint: z.string().nullable().optional(), encrypted_env_pubkey: z.string().nullable().optional(), }); + +export const CvmKmsInfoV20260121Schema = CvmKmsInfoV20260121BaseSchema.transform((data) => { + if (data.chain_id != null) { + const chain: Chain | undefined = SUPPORTED_CHAINS[data.chain_id]; + if (chain) { + return { ...data, chain } as typeof data & { chain: Chain }; + } + } + return data as typeof data; +}); export type CvmKmsInfoV20260121 = z.infer; export const CvmProgressInfoV20260121Schema = z.object({