|
1 | 1 | import { Command, Flags } from "@oclif/core"; |
2 | | -import { logVisibility } from "@layr-labs/ecloud-sdk"; |
| 2 | +import { getEnvironmentConfig, UserApiClient, formatETH, isMainnet } from "@layr-labs/ecloud-sdk"; |
| 3 | +import { createPublicClient, http } from "viem"; |
| 4 | +import { mainnet } from "viem/chains"; |
3 | 5 | import { createAppClient } from "../../../client"; |
4 | 6 | import { commonFlags } from "../../../flags"; |
| 7 | +import { |
| 8 | + getDockerfileInteractive, |
| 9 | + getImageReferenceInteractive, |
| 10 | + getOrPromptAppName, |
| 11 | + getEnvFileInteractive, |
| 12 | + getInstanceTypeInteractive, |
| 13 | + getLogSettingsInteractive, |
| 14 | + getAppProfileInteractive, |
| 15 | + LogVisibility, |
| 16 | + confirm, |
| 17 | +} from "../../../utils/prompts"; |
5 | 18 | import chalk from "chalk"; |
6 | 19 |
|
7 | 20 | export default class AppDeploy extends Command { |
@@ -40,28 +53,157 @@ export default class AppDeploy extends Command { |
40 | 53 | required: false, |
41 | 54 | description: |
42 | 55 | "Machine instance type to use e.g. g1-standard-4t, g1-standard-8t", |
43 | | - options: ["g1-standard-4t", "g1-standard-8t"], |
44 | 56 | env: "ECLOUD_INSTANCE_TYPE", |
45 | 57 | }), |
| 58 | + "skip-profile": Flags.boolean({ |
| 59 | + required: false, |
| 60 | + description: "Skip app profile setup", |
| 61 | + default: false, |
| 62 | + }), |
46 | 63 | }; |
47 | 64 |
|
48 | 65 | async run() { |
49 | 66 | const { flags } = await this.parse(AppDeploy); |
50 | 67 | const app = await createAppClient(flags); |
51 | 68 |
|
| 69 | + // Get environment config for fetching available instance types |
| 70 | + const environment = flags.environment || "sepolia"; |
| 71 | + const environmentConfig = getEnvironmentConfig(environment); |
| 72 | + const rpcUrl = flags["rpc-url"] || environmentConfig.defaultRPCURL; |
| 73 | + |
| 74 | + // 1. Get dockerfile path interactively |
| 75 | + const dockerfilePath = await getDockerfileInteractive(flags.dockerfile); |
| 76 | + const buildFromDockerfile = dockerfilePath !== ""; |
| 77 | + |
| 78 | + // 2. Get image reference interactively (context-aware) |
| 79 | + const imageRef = await getImageReferenceInteractive( |
| 80 | + flags["image-ref"], |
| 81 | + buildFromDockerfile |
| 82 | + ); |
| 83 | + |
| 84 | + // 3. Get app name interactively |
| 85 | + const appName = await getOrPromptAppName(flags.name, environment, imageRef); |
| 86 | + |
| 87 | + // 4. Get env file path interactively |
| 88 | + const envFilePath = await getEnvFileInteractive(flags["env-file"]); |
| 89 | + |
| 90 | + // 5. Get instance type interactively |
| 91 | + // First, fetch available instance types from backend |
| 92 | + const availableTypes = await fetchAvailableInstanceTypes( |
| 93 | + environmentConfig, |
| 94 | + flags["private-key"], |
| 95 | + rpcUrl |
| 96 | + ); |
| 97 | + const instanceType = await getInstanceTypeInteractive( |
| 98 | + flags["instance-type"], |
| 99 | + "", // No default for new deployments |
| 100 | + availableTypes |
| 101 | + ); |
| 102 | + |
| 103 | + // 6. Get log visibility interactively |
| 104 | + const logSettings = await getLogSettingsInteractive( |
| 105 | + flags["log-visibility"] as LogVisibility | undefined |
| 106 | + ); |
| 107 | + |
| 108 | + // 7. Optionally collect app profile |
| 109 | + let profile = undefined; |
| 110 | + if (!flags["skip-profile"]) { |
| 111 | + try { |
| 112 | + // Extract suggested name from image reference |
| 113 | + const suggestedName = appName; |
| 114 | + this.log( |
| 115 | + "\nSet up a public profile for your app (you can skip this):" |
| 116 | + ); |
| 117 | + profile = await getAppProfileInteractive(suggestedName, true); |
| 118 | + } catch { |
| 119 | + // Profile collection cancelled or failed - continue without profile |
| 120 | + profile = undefined; |
| 121 | + } |
| 122 | + } |
| 123 | + |
| 124 | + // 8. Estimate gas cost on mainnet and prompt for confirmation |
| 125 | + let gasParams: { maxFeePerGas?: bigint; maxPriorityFeePerGas?: bigint } | undefined; |
| 126 | + |
| 127 | + if (isMainnet(environmentConfig)) { |
| 128 | + const chain = mainnet; |
| 129 | + const publicClient = createPublicClient({ |
| 130 | + chain, |
| 131 | + transport: http(rpcUrl), |
| 132 | + }); |
| 133 | + |
| 134 | + // Get current gas prices for estimation |
| 135 | + const fees = await publicClient.estimateFeesPerGas(); |
| 136 | + // Deploy typically has 2-3 executions in the batch |
| 137 | + const estimatedGas = BigInt(300000); // Conservative estimate for deploy batch |
| 138 | + const maxCostWei = estimatedGas * fees.maxFeePerGas; |
| 139 | + const maxCostEth = formatETH(maxCostWei); |
| 140 | + |
| 141 | + const confirmed = await confirm( |
| 142 | + `This deployment will cost up to ${maxCostEth} ETH. Continue?` |
| 143 | + ); |
| 144 | + if (!confirmed) { |
| 145 | + this.log(`\n${chalk.gray(`Deployment cancelled`)}`); |
| 146 | + return; |
| 147 | + } |
| 148 | + |
| 149 | + gasParams = { |
| 150 | + maxFeePerGas: fees.maxFeePerGas, |
| 151 | + maxPriorityFeePerGas: fees.maxPriorityFeePerGas, |
| 152 | + }; |
| 153 | + } |
| 154 | + |
| 155 | + // 9. Deploy with all gathered parameters |
52 | 156 | const res = await app.deploy({ |
53 | | - name: flags.name, |
54 | | - dockerfile: flags.dockerfile, |
55 | | - envFile: flags["env-file"], |
56 | | - imageRef: flags["image-ref"], |
57 | | - logVisibility: flags["log-visibility"] as logVisibility, |
58 | | - instanceType: flags["instance-type"], |
| 157 | + name: appName, |
| 158 | + dockerfile: dockerfilePath, |
| 159 | + envFile: envFilePath, |
| 160 | + imageRef: imageRef, |
| 161 | + logVisibility: logSettings.publicLogs |
| 162 | + ? "public" |
| 163 | + : logSettings.logRedirect |
| 164 | + ? "private" |
| 165 | + : "off", |
| 166 | + instanceType, |
| 167 | + profile, |
| 168 | + gas: gasParams, |
59 | 169 | }); |
60 | 170 |
|
61 | 171 | if (!res.tx || !res.ipAddress) { |
62 | | - this.log(`\n${chalk.gray(`Deploy ${res.ipAddress ? "failed" : "aborted"}`)}`); |
| 172 | + this.log( |
| 173 | + `\n${chalk.gray(`Deploy ${res.ipAddress ? "failed" : "aborted"}`)}` |
| 174 | + ); |
63 | 175 | } else { |
64 | | - this.log(`\n✅ ${chalk.green(`App deployed successfully ${chalk.bold(`(id: ${res.appID}, ip: ${res.ipAddress})`)}`)}`); |
| 176 | + this.log( |
| 177 | + `\n✅ ${chalk.green(`App deployed successfully ${chalk.bold(`(id: ${res.appID}, ip: ${res.ipAddress})`)}`)}` |
| 178 | + ); |
65 | 179 | } |
66 | 180 | } |
67 | 181 | } |
| 182 | + |
| 183 | +/** |
| 184 | + * Fetch available instance types from backend |
| 185 | + */ |
| 186 | +async function fetchAvailableInstanceTypes( |
| 187 | + environmentConfig: any, |
| 188 | + privateKey?: string, |
| 189 | + rpcUrl?: string |
| 190 | +): Promise<Array<{ sku: string; description: string }>> { |
| 191 | + try { |
| 192 | + const userApiClient = new UserApiClient( |
| 193 | + environmentConfig, |
| 194 | + privateKey, |
| 195 | + rpcUrl |
| 196 | + ); |
| 197 | + |
| 198 | + const skuList = await userApiClient.getSKUs(); |
| 199 | + if (skuList.skus.length === 0) { |
| 200 | + throw new Error("No instance types available from server"); |
| 201 | + } |
| 202 | + |
| 203 | + return skuList.skus; |
| 204 | + } catch (err: any) { |
| 205 | + console.warn(`Failed to fetch instance types: ${err.message}`); |
| 206 | + // Return a default fallback |
| 207 | + return [{ sku: "g1-standard-4t", description: "Standard 4-thread instance" }]; |
| 208 | + } |
| 209 | +} |
0 commit comments