@@ -39,6 +39,7 @@ import {
3939 safePatchCvm ,
4040 safeProvisionCvm ,
4141 safeUpdateCvmVisibility ,
42+ safeCommitCvmUpdate ,
4243 convertToHostname ,
4344 isValidHostname ,
4445} from "@phala/cloud" ;
@@ -85,6 +86,11 @@ interface Options {
8586 publicLogs ?: boolean ;
8687 publicSysinfo ?: boolean ;
8788 listed ?: boolean ;
89+ prepareOnly ?: boolean ;
90+ commit ?: boolean ;
91+ token ?: string ;
92+ composeHash ?: string ;
93+ transactionHash ?: string ;
8894 [ key : string ] : unknown ;
8995}
9096
@@ -982,6 +988,11 @@ const updateCvm = async (
982988 patchBody . public_sysinfo = validatedOptions . publicSysinfo ;
983989 }
984990
991+ // Add prepareOnly flag if set
992+ if ( validatedOptions . prepareOnly ) {
993+ patchBody . prepareOnly = true ;
994+ }
995+
985996 logger . info ( `Updating CVM ${ validatedOptions . uuid } ...` ) ;
986997 // biome-ignore lint/suspicious/noExplicitAny: dynamic patch body
987998 const patchResult = await safePatchCvm ( client , patchBody as any ) ;
@@ -996,8 +1007,112 @@ const updateCvm = async (
9961007
9971008 const result = patchResult . data ;
9981009
1010+ // --prepare-only on a CVM that doesn't require on-chain hash
1011+ if ( validatedOptions . prepareOnly && ! result . requiresOnChainHash ) {
1012+ const msg =
1013+ "--prepare-only has no effect on this CVM: it does not use on-chain KMS. The update was applied directly." ;
1014+ if ( validatedOptions . json !== false ) {
1015+ stdout . write (
1016+ `${ JSON . stringify ( { success : true , prepare_only : false , message : msg } , null , 2 ) } \n` ,
1017+ ) ;
1018+ } else {
1019+ logger . warn ( msg ) ;
1020+ }
1021+ return ;
1022+ }
1023+
9991024 // Two-phase flow: on-chain KMS requires compose hash registration
10001025 if ( result . requiresOnChainHash ) {
1026+ // --prepare-only mode: output commit info and stop
1027+ if ( validatedOptions . prepareOnly ) {
1028+ // Build explorer link for the contract address
1029+ const chainId = result . kmsInfo ?. chain_id ;
1030+ const chain = result . kmsInfo ?. chain as
1031+ | { name ?: string ; blockExplorers ?: { default ?: { url ?: string } } }
1032+ | undefined ;
1033+ const contractAddress = cvm . app_id ;
1034+ const explorerUrl = chain ?. blockExplorers ?. default ?. url ;
1035+ const contractExplorerUrl =
1036+ explorerUrl && contractAddress
1037+ ? `${ explorerUrl } /address/${ contractAddress . startsWith ( "0x" ) ? contractAddress : `0x${ contractAddress } ` } `
1038+ : undefined ;
1039+
1040+ const composeHashHex = result . composeHash . startsWith ( "0x" )
1041+ ? result . composeHash
1042+ : `0x${ result . composeHash } ` ;
1043+
1044+ const onchain = result . onchainStatus ;
1045+ const output = {
1046+ success : true ,
1047+ prepare_only : true ,
1048+ compose_hash : composeHashHex ,
1049+ app_id : cvm . app_id ,
1050+ device_id : result . deviceId ,
1051+ kms_info : result . kmsInfo ,
1052+ chain_id : chainId ,
1053+ contract_explorer_url : contractExplorerUrl ,
1054+ onchain_status : onchain ,
1055+ commit_token : result . commitToken ,
1056+ commit_url : result . commitUrl ,
1057+ api_commit_url : result . apiCommitUrl ,
1058+ } ;
1059+
1060+ if ( validatedOptions . json !== false ) {
1061+ stdout . write ( `${ JSON . stringify ( output , null , 2 ) } \n` ) ;
1062+ } else {
1063+ const lines = [
1064+ "CVM update prepared successfully (pending on-chain approval)." ,
1065+ "" ,
1066+ `Compose Hash: ${ composeHashHex } ` ,
1067+ `App ID: ${ cvm . app_id } ` ,
1068+ `Device ID: ${ result . deviceId } ` ,
1069+ ] ;
1070+ if ( chainId ) {
1071+ lines . push (
1072+ `Chain: ${ chain ?. name || "Unknown" } (ID: ${ chainId } )` ,
1073+ ) ;
1074+ }
1075+ if ( contractExplorerUrl ) {
1076+ lines . push ( `Contract: ${ contractExplorerUrl } ` ) ;
1077+ }
1078+ lines . push (
1079+ `Commit Token: ${ result . commitToken || "N/A" } ` ,
1080+ `Commit URL: ${ result . commitUrl || "N/A" } ` ,
1081+ `API Commit URL: ${ result . apiCommitUrl || "N/A" } (POST)` ,
1082+ ) ;
1083+ if ( onchain ) {
1084+ const hashStatus = onchain . compose_hash_allowed
1085+ ? "registered"
1086+ : "NOT registered" ;
1087+ const deviceStatus = onchain . device_id_allowed
1088+ ? "registered"
1089+ : "NOT registered" ;
1090+ lines . push (
1091+ "" ,
1092+ "On-chain Status:" ,
1093+ ` Compose Hash: ${ hashStatus } ` ,
1094+ ` Device ID: ${ deviceStatus } ` ,
1095+ ) ;
1096+ if ( onchain . is_allowed ) {
1097+ lines . push (
1098+ " All prerequisites met. You can commit with --transaction-hash already-registered." ,
1099+ ) ;
1100+ }
1101+ }
1102+ lines . push (
1103+ "" ,
1104+ "To complete the update after on-chain approval:" ,
1105+ ` phala deploy --cvm-id ${ validatedOptions . uuid } \\` ,
1106+ " --commit \\" ,
1107+ ` --token ${ result . commitToken || "<token>" } \\` ,
1108+ ` --compose-hash ${ composeHashHex } \\` ,
1109+ " --transaction-hash <tx-hash>" ,
1110+ ) ;
1111+ stdout . write ( `${ lines . join ( "\n" ) } \n` ) ;
1112+ }
1113+ return ;
1114+ }
1115+
10011116 if ( ! validatedOptions . privateKey ) {
10021117 throw new Error ( "Private key is required for contract DstackApp" ) ;
10031118 }
@@ -1139,11 +1254,94 @@ const updateCvm = async (
11391254 }
11401255} ;
11411256
1257+ /**
1258+ * Commit a previously prepared CVM update using a commit token.
1259+ * Skips compose file reading and env processing — goes straight to the API.
1260+ */
1261+ const commitCvmUpdate = async (
1262+ validatedOptions : Options ,
1263+ client : Client < typeof API_VERSION > ,
1264+ stdout : NodeJS . WriteStream ,
1265+ ) => {
1266+ if ( ! validatedOptions . token ) {
1267+ throw new Error ( "--token is required for --commit mode" ) ;
1268+ }
1269+ if ( ! validatedOptions . uuid ) {
1270+ throw new Error ( "--cvm-id is required for --commit mode" ) ;
1271+ }
1272+ if ( ! validatedOptions . transactionHash ) {
1273+ logger . info (
1274+ "No --transaction-hash provided, using 'already-registered' (state-only check)" ,
1275+ ) ;
1276+ }
1277+
1278+ logger . info ( `Committing CVM update for ${ validatedOptions . uuid } ...` ) ;
1279+
1280+ const commitResult = await safeCommitCvmUpdate ( client , {
1281+ id : validatedOptions . uuid ,
1282+ token : validatedOptions . token ,
1283+ composeHash : validatedOptions . composeHash || "" ,
1284+ transactionHash : validatedOptions . transactionHash || "" ,
1285+ } ) ;
1286+
1287+ if ( ! commitResult . success ) {
1288+ const errMsg =
1289+ commitResult . error instanceof Error
1290+ ? commitResult . error . message
1291+ : String ( commitResult . error ) ;
1292+ const isExpired = errMsg . includes ( "expired" ) || errMsg . includes ( "Invalid" ) ;
1293+ const hint = isExpired
1294+ ? " Run --prepare-only again to get a new commit token."
1295+ : "" ;
1296+ throw new Error ( `Failed to commit CVM update: ${ errMsg } ${ hint } ` ) ;
1297+ }
1298+
1299+ if ( validatedOptions . json !== false ) {
1300+ stdout . write (
1301+ `${ JSON . stringify (
1302+ {
1303+ success : true ,
1304+ vm_uuid : validatedOptions . uuid ,
1305+ correlation_id : commitResult . data . correlationId ,
1306+ status : commitResult . data . status ,
1307+ } ,
1308+ null ,
1309+ 2 ,
1310+ ) } \n`,
1311+ ) ;
1312+ } else {
1313+ stdout . write (
1314+ `CVM update committed successfully! Correlation ID: ${ commitResult . data . correlationId } \n` ,
1315+ ) ;
1316+ }
1317+ } ;
1318+
11421319export async function runDeploy (
11431320 input : DeployCommandInput ,
11441321 context : CommandContext ,
11451322) : Promise < void > {
11461323 try {
1324+ // Handle --commit mode: skip compose file reading entirely
1325+ // commit-update endpoint is token-based (no API key required),
1326+ // but we still need a client with the correct base URL.
1327+ if ( input . commit ) {
1328+ const resolved = resolveAuthForContext ( undefined , {
1329+ apiToken : input . apiToken ,
1330+ } ) ;
1331+ const client = createClient ( {
1332+ apiKey : resolved . apiKey ,
1333+ baseURL : resolved . baseURL ,
1334+ version : API_VERSION ,
1335+ } ) ;
1336+
1337+ const uuid = context . cvmId
1338+ ? CvmIdSchema . parse ( context . cvmId ) . cvmId
1339+ : undefined ;
1340+
1341+ await commitCvmUpdate ( { ...input , uuid } , client , context . stdout ) ;
1342+ return ;
1343+ }
1344+
11471345 // Use positional argument if provided, otherwise use the --compose option
11481346 // Fallback to phala.toml compose_file if not specified
11491347 const dockerComposePath =
0 commit comments