@layr-labs/ecloud-cli 0.1.0-dev.3 → 0.1.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +4 -4
  2. package/VERSION +2 -2
  3. package/dist/commands/auth/generate.js +46 -184
  4. package/dist/commands/auth/generate.js.map +1 -1
  5. package/dist/commands/auth/login.js +93 -234
  6. package/dist/commands/auth/login.js.map +1 -1
  7. package/dist/commands/auth/logout.js +30 -170
  8. package/dist/commands/auth/logout.js.map +1 -1
  9. package/dist/commands/auth/migrate.js +76 -216
  10. package/dist/commands/auth/migrate.js.map +1 -1
  11. package/dist/commands/auth/whoami.js +17 -145
  12. package/dist/commands/auth/whoami.js.map +1 -1
  13. package/dist/commands/billing/cancel.js +30 -164
  14. package/dist/commands/billing/cancel.js.map +1 -1
  15. package/dist/commands/billing/status.js +80 -213
  16. package/dist/commands/billing/status.js.map +1 -1
  17. package/dist/commands/billing/subscribe.js +45 -179
  18. package/dist/commands/billing/subscribe.js.map +1 -1
  19. package/dist/commands/compute/app/create.js +20 -148
  20. package/dist/commands/compute/app/create.js.map +1 -1
  21. package/dist/commands/compute/app/deploy.js +145 -243
  22. package/dist/commands/compute/app/deploy.js.map +1 -1
  23. package/dist/commands/compute/app/info.js +1 -2
  24. package/dist/commands/compute/app/info.js.map +1 -1
  25. package/dist/commands/compute/app/list.js +111 -194
  26. package/dist/commands/compute/app/list.js.map +1 -1
  27. package/dist/commands/compute/app/logs.js +20 -105
  28. package/dist/commands/compute/app/logs.js.map +1 -1
  29. package/dist/commands/compute/app/profile/set.js +64 -153
  30. package/dist/commands/compute/app/profile/set.js.map +1 -1
  31. package/dist/commands/compute/app/start.js +43 -132
  32. package/dist/commands/compute/app/start.js.map +1 -1
  33. package/dist/commands/compute/app/stop.js +43 -132
  34. package/dist/commands/compute/app/stop.js.map +1 -1
  35. package/dist/commands/compute/app/terminate.js +44 -131
  36. package/dist/commands/compute/app/terminate.js.map +1 -1
  37. package/dist/commands/compute/app/upgrade.js +108 -209
  38. package/dist/commands/compute/app/upgrade.js.map +1 -1
  39. package/dist/commands/compute/environment/list.js +12 -104
  40. package/dist/commands/compute/environment/list.js.map +1 -1
  41. package/dist/commands/compute/environment/set.js +18 -103
  42. package/dist/commands/compute/environment/set.js.map +1 -1
  43. package/dist/commands/compute/environment/show.js +30 -122
  44. package/dist/commands/compute/environment/show.js.map +1 -1
  45. package/dist/commands/compute/undelegate.js +18 -112
  46. package/dist/commands/compute/undelegate.js.map +1 -1
  47. package/dist/commands/upgrade.js +19 -159
  48. package/dist/commands/upgrade.js.map +1 -1
  49. package/dist/commands/version.js +23 -163
  50. package/dist/commands/version.js.map +1 -1
  51. package/package.json +2 -2
  52. package/dist/commands/telemetry.js +0 -213
  53. package/dist/commands/telemetry.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/commands/billing/cancel.ts","../../../src/client.ts","../../../src/flags.ts","../../../src/utils/prompts.ts","../../../src/utils/appResolver.ts","../../../src/utils/globalConfig.ts","../../../src/utils/appNames.ts","../../../src/telemetry.ts"],"sourcesContent":["import { Command, Flags } from \"@oclif/core\";\nimport { isSubscriptionActive } from \"@layr-labs/ecloud-sdk\";\nimport { createBillingClient } from \"../../client\";\nimport { commonFlags } from \"../../flags\";\nimport chalk from \"chalk\";\nimport { confirm } from \"@inquirer/prompts\";\nimport { withTelemetry } from \"../../telemetry\";\n\nexport default class BillingCancel extends Command {\n static description = \"Cancel subscription\";\n\n static flags = {\n \"private-key\": commonFlags[\"private-key\"],\n verbose: commonFlags.verbose,\n product: Flags.string({\n required: false,\n description: \"Product ID\",\n default: \"compute\",\n options: [\"compute\"],\n env: \"ECLOUD_PRODUCT_ID\",\n }),\n force: Flags.boolean({\n char: \"f\",\n description: \"Skip confirmation prompt\",\n default: false,\n }),\n };\n\n async run() {\n return withTelemetry(this, async () => {\n const { flags } = await this.parse(BillingCancel);\n const billing = await createBillingClient(flags);\n\n // Check subscription status first\n this.log(`\\nChecking subscription status for ${flags.product}...`);\n const status = await billing.getStatus({\n productId: flags.product as \"compute\",\n });\n\n // Check if there's an active subscription to cancel\n if (!isSubscriptionActive(status.subscriptionStatus)) {\n this.log(`\\n${chalk.gray(\"You don't have an active subscription to cancel.\")}`);\n this.log(chalk.gray(`Current status: ${status.subscriptionStatus}`));\n return;\n }\n\n // Confirm cancellation unless --force flag is used\n if (!flags.force) {\n const confirmed = await confirm({\n message: `${chalk.yellow(\"Warning:\")} This will cancel your ${flags.product} subscription. Continue?`,\n });\n if (!confirmed) {\n this.log(chalk.gray(\"\\nCancellation aborted.\"));\n return;\n }\n }\n\n this.log(`\\nCanceling subscription for ${flags.product}...`);\n\n const result = await billing.cancel({\n productId: flags.product as \"compute\",\n });\n\n // Handle response (defensive - should always be canceled at this point)\n if (result.type === \"canceled\") {\n this.log(`\\n${chalk.green(\"āœ“\")} Subscription canceled successfully.`);\n } else {\n this.log(\n `\\n${chalk.gray(\"Subscription status changed. Current status:\")} ${result.status}`,\n );\n }\n });\n }\n}\n","import {\n createComputeModule,\n createBillingModule,\n getEnvironmentConfig,\n requirePrivateKey,\n getPrivateKeyWithSource,\n} from \"@layr-labs/ecloud-sdk\";\nimport { CommonFlags, validateCommonFlags } from \"./flags\";\nimport { getPrivateKeyInteractive } from \"./utils/prompts\";\nimport { getClientId } from \"./utils/version\";\nimport { Hex } from \"viem\";\n\nexport async function createComputeClient(flags: CommonFlags) {\n flags = await validateCommonFlags(flags);\n\n const environment = flags.environment!;\n const environmentConfig = getEnvironmentConfig(environment);\n const rpcUrl = flags[\"rpc-url\"] || environmentConfig.defaultRPCURL;\n const { key: privateKey, source } = await requirePrivateKey({\n privateKey: flags[\"private-key\"],\n });\n\n if (flags.verbose) {\n console.log(`Using private key from: ${source}`);\n }\n\n return createComputeModule({\n verbose: flags.verbose,\n privateKey,\n rpcUrl,\n environment,\n clientId: getClientId(),\n skipTelemetry: true, // CLI already has telemetry, skip SDK telemetry\n });\n}\n\nexport async function createBillingClient(flags: { \"private-key\"?: string; verbose?: boolean }) {\n const result = await getPrivateKeyWithSource({\n privateKey: flags[\"private-key\"],\n });\n const privateKey = await getPrivateKeyInteractive(result?.key);\n\n return createBillingModule({\n verbose: flags.verbose ?? false,\n privateKey: privateKey as Hex,\n skipTelemetry: true, // CLI already has telemetry, skip SDK telemetry\n });\n}\n","import { Flags } from \"@oclif/core\";\nimport { getEnvironmentInteractive, getPrivateKeyInteractive } from \"./utils/prompts\";\nimport { getDefaultEnvironment } from \"./utils/globalConfig\";\n\nexport type CommonFlags = {\n verbose: boolean;\n environment?: string;\n \"private-key\"?: string;\n \"rpc-url\"?: string;\n};\n\nexport const commonFlags = {\n environment: Flags.string({\n required: false,\n description: \"Deployment environment to use\",\n env: \"ECLOUD_ENV\",\n }),\n \"private-key\": Flags.string({\n required: false,\n description: \"Private key for signing transactions\",\n env: \"ECLOUD_PRIVATE_KEY\",\n }),\n \"rpc-url\": Flags.string({\n required: false,\n description: \"RPC URL to connect to blockchain\",\n env: \"ECLOUD_RPC_URL\",\n }),\n verbose: Flags.boolean({\n required: false,\n description: \"Enable verbose logging (default: false)\",\n default: false,\n }),\n};\n\n// Validate or prompt for required common flags\nexport async function validateCommonFlags(flags: CommonFlags) {\n // If no environment is selected, default to the global config env\n if (!flags[\"environment\"]) {\n flags[\"environment\"] = getDefaultEnvironment();\n }\n // If the provided env is invalid, proceed to prompt\n flags[\"environment\"] = await getEnvironmentInteractive(flags[\"environment\"]);\n flags[\"private-key\"] = await getPrivateKeyInteractive(flags[\"private-key\"]);\n\n return flags;\n}\n","/**\n * Interactive prompts for CLI commands\n *\n * This module contains all interactive user prompts. These functions should only\n * be used in CLI commands, not in the SDK.\n */\n\nimport { input, select, password, confirm as inquirerConfirm } from \"@inquirer/prompts\";\nimport fs from \"fs\";\nimport path from \"path\";\nimport os from \"os\";\nimport { Address, isAddress } from \"viem\";\nimport { privateKeyToAccount } from \"viem/accounts\";\nimport {\n getEnvironmentConfig,\n getAvailableEnvironments,\n isEnvironmentAvailable,\n getAllAppsByDeveloper,\n getCategoryDescriptions,\n fetchTemplateCatalog,\n PRIMARY_LANGUAGES,\n AppProfile,\n validateAppName,\n validateImageReference,\n validateFilePath,\n validatePrivateKeyFormat,\n extractAppNameFromImage,\n UserApiClient,\n} from \"@layr-labs/ecloud-sdk\";\nimport { getAppInfosChunked } from \"./appResolver\";\nimport { getDefaultEnvironment, getProfileCache, setProfileCache } from \"./globalConfig\";\nimport { listApps, isAppNameAvailable, findAvailableName } from \"./appNames\";\nimport { getClientId } from \"./version\";\n\n// Helper to add hex prefix\nfunction addHexPrefix(value: string): `0x${string}` {\n if (value.startsWith(\"0x\")) {\n return value as `0x${string}`;\n }\n return `0x${value}` as `0x${string}`;\n}\n\n// ==================== Dockerfile Selection ====================\n\n/**\n * Prompt for Dockerfile selection\n */\nexport async function getDockerfileInteractive(dockerfilePath?: string): Promise<string> {\n // Check if provided via option\n if (dockerfilePath) {\n return dockerfilePath;\n }\n\n // Check if default Dockerfile exists in current directory\n // Use INIT_CWD if available (set by npm/pnpm to original cwd), otherwise fall back to process.cwd()\n const cwd = process.env.INIT_CWD || process.cwd();\n const dockerfilePath_resolved = path.join(cwd, \"Dockerfile\");\n\n if (!fs.existsSync(dockerfilePath_resolved)) {\n // No Dockerfile found, return empty string (deploy existing image)\n return \"\";\n }\n\n // Interactive prompt when Dockerfile exists\n console.log(`\\nFound Dockerfile in ${cwd}`);\n\n const choice = await select({\n message: \"Choose deployment method:\",\n choices: [\n { name: \"Build and deploy from Dockerfile\", value: \"build\" },\n { name: \"Deploy existing image from registry\", value: \"existing\" },\n ],\n });\n\n switch (choice) {\n case \"build\":\n // Return full path so SDK uses the correct directory\n return dockerfilePath_resolved;\n case \"existing\":\n return \"\";\n default:\n throw new Error(`Unexpected choice: ${choice}`);\n }\n}\n\n// ==================== Image Reference Selection ====================\n\ninterface RegistryInfo {\n Type: string;\n Username: string;\n URL: string;\n}\n\n/**\n * Extract hostname from a registry URL/string for safe comparison\n * This avoids the security issue of using .includes() which can match\n * substrings anywhere (e.g., \"malicious.docker.io.attacker.com\")\n */\nfunction extractHostname(registry: string): string {\n // Remove protocol if present\n let hostname = registry.replace(/^https?:\\/\\//, \"\");\n // Remove path and trailing slashes\n hostname = hostname.split(\"/\")[0];\n // Remove port if present\n hostname = hostname.split(\":\")[0];\n return hostname.toLowerCase();\n}\n\n/**\n * Check if a registry matches Docker Hub\n */\nfunction isDockerHub(registry: string): boolean {\n const hostname = extractHostname(registry);\n return (\n hostname === \"docker.io\" ||\n hostname === \"index.docker.io\" ||\n hostname === \"registry-1.docker.io\"\n );\n}\n\n/**\n * Check if a registry matches GitHub Container Registry\n */\nfunction isGHCR(registry: string): boolean {\n const hostname = extractHostname(registry);\n return hostname === \"ghcr.io\";\n}\n\n/**\n * Check if a registry matches Google Container Registry\n */\nfunction isGCR(registry: string): boolean {\n const hostname = extractHostname(registry);\n return hostname === \"gcr.io\" || hostname.endsWith(\".gcr.io\");\n}\n\n/**\n * Get credentials from Docker credential helper\n */\nasync function getCredentialsFromHelper(\n registry: string,\n): Promise<{ username: string; password: string } | undefined> {\n const dockerConfigPath = path.join(os.homedir(), \".docker\", \"config.json\");\n\n if (!fs.existsSync(dockerConfigPath)) {\n return undefined;\n }\n\n try {\n const config = JSON.parse(fs.readFileSync(dockerConfigPath, \"utf-8\"));\n const credsStore = config.credsStore;\n\n if (!credsStore) {\n return undefined;\n }\n\n const { execSync } = await import(\"child_process\");\n const helper = `docker-credential-${credsStore}`;\n\n try {\n const registryVariants: string[] = [];\n\n if (isDockerHub(registry)) {\n registryVariants.push(\"https://index.docker.io/v1/\");\n registryVariants.push(\"https://index.docker.io/v1\");\n registryVariants.push(\"index.docker.io\");\n registryVariants.push(\"docker.io\");\n } else {\n const baseRegistry = registry\n .replace(/^https?:\\/\\//, \"\")\n .replace(/\\/v1\\/?$/, \"\")\n .replace(/\\/$/, \"\");\n registryVariants.push(`https://${baseRegistry}`);\n registryVariants.push(`https://${baseRegistry}/v1/`);\n registryVariants.push(baseRegistry);\n }\n\n for (const variant of registryVariants) {\n try {\n const output = execSync(`echo \"${variant}\" | ${helper} get`, {\n encoding: \"utf-8\",\n });\n const creds = JSON.parse(output);\n if (creds.Username && creds.Secret) {\n return { username: creds.Username, password: creds.Secret };\n }\n } catch {\n continue;\n }\n }\n } catch {\n return undefined;\n }\n } catch {\n return undefined;\n }\n\n return undefined;\n}\n\nasync function getAvailableRegistries(): Promise<RegistryInfo[]> {\n const dockerConfigPath = path.join(os.homedir(), \".docker\", \"config.json\");\n\n if (!fs.existsSync(dockerConfigPath)) {\n return [];\n }\n\n try {\n const configContent = fs.readFileSync(dockerConfigPath, \"utf-8\");\n const config = JSON.parse(configContent);\n\n const auths = config.auths || {};\n const credsStore = config.credsStore;\n const gcrProjects = new Map<string, RegistryInfo>();\n const registries: RegistryInfo[] = [];\n\n for (const [registry, auth] of Object.entries(auths)) {\n const authData = auth as { username?: string; auth?: string };\n\n // Skip token entries (these are not actual registries)\n const hostname = extractHostname(registry);\n if (hostname.includes(\"access-token\") || hostname.includes(\"refresh-token\")) {\n continue;\n }\n\n let username = authData.username;\n let registryType = \"other\";\n let normalizedURL = registry;\n\n if (isDockerHub(registry)) {\n registryType = \"dockerhub\";\n normalizedURL = \"https://index.docker.io/v1/\";\n } else if (isGHCR(registry)) {\n registryType = \"ghcr\";\n normalizedURL = registry.replace(/^https?:\\/\\//, \"\").replace(/\\/v1\\/?$/, \"\");\n } else if (isGCR(registry)) {\n registryType = \"gcr\";\n normalizedURL = \"gcr.io\";\n }\n\n if (!username && credsStore) {\n const creds = await getCredentialsFromHelper(registry);\n if (creds) {\n username = creds.username;\n }\n }\n\n if (!username) {\n continue;\n }\n\n const info: RegistryInfo = {\n URL: normalizedURL,\n Username: username,\n Type: registryType,\n };\n\n if (registryType === \"gcr\") {\n if (!gcrProjects.has(username)) {\n gcrProjects.set(username, info);\n }\n continue;\n }\n\n registries.push(info);\n }\n\n for (const gcrInfo of Array.from(gcrProjects.values())) {\n registries.push(gcrInfo);\n }\n\n registries.sort((a, b) => {\n if (a.Type === \"dockerhub\") return -1;\n if (b.Type === \"dockerhub\") return 1;\n return a.Type.localeCompare(b.Type);\n });\n\n return registries;\n } catch {\n return [];\n }\n}\n\nfunction getDefaultAppName(): string {\n try {\n // Use INIT_CWD if available (set by npm/pnpm to original cwd)\n const cwd = process.env.INIT_CWD || process.cwd();\n return path.basename(cwd);\n } catch {\n return \"myapp\";\n }\n}\n\nfunction suggestImageReference(registry: RegistryInfo, imageName: string, tag: string): string {\n imageName = imageName.toLowerCase().replace(/_/g, \"-\");\n if (!tag) {\n tag = \"latest\";\n }\n\n switch (registry.Type) {\n case \"dockerhub\":\n return `${registry.Username}/${imageName}:${tag}`;\n case \"ghcr\":\n return `ghcr.io/${registry.Username}/${imageName}:${tag}`;\n case \"gcr\":\n return `gcr.io/${registry.Username}/${imageName}:${tag}`;\n default:\n let host = registry.URL;\n if (host.startsWith(\"https://\")) {\n host = host.substring(8);\n } else if (host.startsWith(\"http://\")) {\n host = host.substring(7);\n }\n host = host.replace(/\\/$/, \"\");\n return `${host}/${registry.Username}/${imageName}:${tag}`;\n }\n}\n\nfunction displayDetectedRegistries(registries: RegistryInfo[], appName: string): void {\n console.log(\"Detected authenticated registries:\");\n for (const reg of registries) {\n const suggestion = suggestImageReference(reg, appName, \"latest\");\n console.log(` ${reg.Type}: ${suggestion}`);\n }\n console.log();\n}\n\nfunction displayAuthenticationInstructions(): void {\n console.log(\"No authenticated registries detected.\");\n console.log(\"To authenticate:\");\n console.log(\" docker login <registry-url>\");\n console.log();\n}\n\nfunction displayRegistryExamples(appName: string): void {\n console.log(\"Examples:\");\n console.log(` docker.io/${appName.toLowerCase()}:latest`);\n console.log(` ghcr.io/username/${appName.toLowerCase()}:latest`);\n console.log(` gcr.io/project-id/${appName.toLowerCase()}:latest`);\n console.log();\n}\n\nasync function selectRegistryInteractive(\n registries: RegistryInfo[],\n imageName: string,\n tag: string,\n): Promise<string> {\n if (registries.length === 1) {\n const defaultRef = suggestImageReference(registries[0], imageName, tag);\n return input({\n message: \"Enter image reference:\",\n default: defaultRef,\n validate: (value) => {\n const result = validateImageReference(value);\n return result === true ? true : result;\n },\n });\n }\n\n const choices = registries.map((reg) => ({\n name: suggestImageReference(reg, imageName, tag),\n value: suggestImageReference(reg, imageName, tag),\n }));\n choices.push({ name: \"Enter custom image reference\", value: \"custom\" });\n\n const choice = await select({\n message: \"Select image destination:\",\n choices,\n });\n\n if (choice === \"custom\") {\n return input({\n message: \"Enter image reference:\",\n default: \"\",\n validate: (value) => {\n const result = validateImageReference(value);\n return result === true ? true : result;\n },\n });\n }\n\n return choice;\n}\n\n/**\n * Prompt for image reference\n */\nexport async function getImageReferenceInteractive(\n imageRef?: string,\n buildFromDockerfile: boolean = false,\n): Promise<string> {\n if (imageRef) {\n return imageRef;\n }\n\n const registries = await getAvailableRegistries();\n const appName = getDefaultAppName();\n\n if (buildFromDockerfile) {\n console.log(\"\\nšŸ“¦ Build & Push Configuration\");\n console.log(\"Your Docker image will be built and pushed to a registry\");\n console.log(\"so that Ecloud CLI can pull and run it in the TEE.\");\n console.log();\n\n if (registries.length > 0) {\n displayDetectedRegistries(registries, appName);\n return selectRegistryInteractive(registries, appName, \"latest\");\n }\n\n displayAuthenticationInstructions();\n } else {\n console.log(\"\\n🐳 Docker Image Selection\");\n console.log(\"Specify an existing Docker image from a registry to run in the TEE.\");\n console.log();\n }\n\n displayRegistryExamples(appName);\n\n const imageRefInput = await input({\n message: \"Enter Docker image reference:\",\n default: \"\",\n validate: (value) => {\n const result = validateImageReference(value);\n return result === true ? true : result;\n },\n });\n\n return imageRefInput;\n}\n\n// ==================== App Name Selection ====================\n\n/**\n * Get available app name interactively\n */\nasync function getAvailableAppNameInteractive(\n environment: string,\n imageRef: string,\n): Promise<string> {\n const baseName = extractAppNameFromImage(imageRef);\n const suggestedName = findAvailableName(environment, baseName);\n\n while (true) {\n console.log(\"\\nApp name selection:\");\n const name = await input({\n message: \"Enter app name:\",\n default: suggestedName,\n validate: (value: string) => {\n try {\n validateAppName(value);\n return true;\n } catch (err: any) {\n return err.message;\n }\n },\n });\n\n if (isAppNameAvailable(environment, name)) {\n return name;\n }\n\n console.log(`App name '${name}' is already taken.`);\n const newSuggested = findAvailableName(environment, name);\n console.log(`Suggested alternative: ${newSuggested}`);\n }\n}\n\n/**\n * Prompt for app name\n */\nexport async function getOrPromptAppName(\n appName: string | undefined,\n environment: string,\n imageRef: string,\n): Promise<string> {\n if (appName) {\n validateAppName(appName);\n if (isAppNameAvailable(environment, appName)) {\n return appName;\n }\n console.log(`Warning: App name '${appName}' is already taken.`);\n return getAvailableAppNameInteractive(environment, imageRef);\n }\n\n return getAvailableAppNameInteractive(environment, imageRef);\n}\n\n// ==================== Environment File Selection ====================\n\n/**\n * Prompt for environment file\n */\nexport async function getEnvFileInteractive(envFilePath?: string): Promise<string> {\n if (envFilePath && fs.existsSync(envFilePath)) {\n return envFilePath;\n }\n\n if (fs.existsSync(\".env\")) {\n return \".env\";\n }\n\n console.log(\"\\nEnvironment file not found.\");\n console.log(\"Environment files contain variables like RPC_URL, etc.\");\n\n const choice = await select({\n message: \"Choose an option:\",\n choices: [\n { name: \"Enter path to existing env file\", value: \"enter\" },\n { name: \"Continue without env file\", value: \"continue\" },\n ],\n });\n\n switch (choice) {\n case \"enter\":\n const envFile = await input({\n message: \"Enter environment file path:\",\n default: \"\",\n validate: (value) => {\n const result = validateFilePath(value);\n return result === true ? true : result;\n },\n });\n return envFile;\n case \"continue\":\n return \"\";\n default:\n throw new Error(`Unexpected choice: ${choice}`);\n }\n}\n\n// ==================== Instance Type Selection ====================\n\n/**\n * Prompt for instance type\n */\nexport async function getInstanceTypeInteractive(\n instanceType: string | undefined,\n defaultSKU: string,\n availableTypes: Array<{ sku: string; description: string }>,\n): Promise<string> {\n if (instanceType) {\n // Validate provided instance type\n const valid = availableTypes.find((t) => t.sku === instanceType);\n if (valid) {\n return instanceType;\n }\n const validSKUs = availableTypes.map((t) => t.sku).join(\", \");\n throw new Error(`Invalid instance-type: ${instanceType} (must be one of: ${validSKUs})`);\n }\n\n const isCurrentType = defaultSKU !== \"\";\n if (defaultSKU === \"\" && availableTypes.length > 0) {\n defaultSKU = availableTypes[0].sku;\n }\n\n if (isCurrentType && defaultSKU) {\n console.log(`\\nSelect instance type (current: ${defaultSKU}):`);\n } else {\n console.log(\"\\nSelect instance type:\");\n }\n\n const choices = availableTypes.map((it) => {\n let name = `${it.sku} - ${it.description}`;\n if (it.sku === defaultSKU) {\n name += isCurrentType ? \" (current)\" : \" (default)\";\n }\n return { name, value: it.sku };\n });\n\n const choice = await select({\n message: \"Choose instance:\",\n choices,\n });\n\n return choice;\n}\n\n// ==================== Log Visibility Selection ====================\n\nexport type LogVisibility = \"public\" | \"private\" | \"off\";\n\n/**\n * Prompt for log settings\n */\nexport async function getLogSettingsInteractive(\n logVisibility?: LogVisibility,\n): Promise<{ logRedirect: string; publicLogs: boolean }> {\n if (logVisibility) {\n switch (logVisibility) {\n case \"public\":\n return { logRedirect: \"always\", publicLogs: true };\n case \"private\":\n return { logRedirect: \"always\", publicLogs: false };\n case \"off\":\n return { logRedirect: \"\", publicLogs: false };\n default:\n throw new Error(\n `Invalid log-visibility: ${logVisibility} (must be public, private, or off)`,\n );\n }\n }\n\n const choice = await select({\n message: \"Do you want to view your app's logs?\",\n choices: [\n { name: \"Yes, but only viewable by app and platform admins\", value: \"private\" },\n { name: \"Yes, publicly viewable by anyone\", value: \"public\" },\n { name: \"No, disable logs entirely\", value: \"off\" },\n ],\n });\n\n switch (choice) {\n case \"private\":\n return { logRedirect: \"always\", publicLogs: false };\n case \"public\":\n return { logRedirect: \"always\", publicLogs: true };\n case \"off\":\n return { logRedirect: \"\", publicLogs: false };\n default:\n throw new Error(`Unexpected choice: ${choice}`);\n }\n}\n\n// ==================== App ID Selection ====================\n\n// Contract app status constants\nexport const ContractAppStatusStarted = 1;\nexport const ContractAppStatusStopped = 2;\nexport const ContractAppStatusTerminated = 3;\nexport const ContractAppStatusSuspended = 4;\n\nexport function getContractStatusString(status: number): string {\n switch (status) {\n case ContractAppStatusStarted:\n return \"Started\";\n case ContractAppStatusStopped:\n return \"Stopped\";\n case ContractAppStatusTerminated:\n return \"Terminated\";\n case ContractAppStatusSuspended:\n return \"Suspended\";\n default:\n return \"Unknown\";\n }\n}\n\nfunction getStatusPriority(status: number, isExited: boolean): number {\n if (isExited) {\n return 1;\n }\n switch (status) {\n case ContractAppStatusStarted:\n return 0;\n case ContractAppStatusStopped:\n return 2;\n case ContractAppStatusTerminated:\n return 3;\n default:\n return 4;\n }\n}\n\n/**\n * Get sort priority for status string (lower = higher priority, shown first)\n * Used for sorting apps in list and selection displays\n */\nexport function getStatusSortPriority(status: string): number {\n switch (status.toLowerCase()) {\n case \"running\":\n case \"started\":\n return 0; // Running apps first\n case \"deploying\":\n case \"upgrading\":\n case \"resuming\":\n return 1; // In-progress operations second\n case \"stopped\":\n case \"stopping\":\n return 2; // Stopped third\n case \"suspended\":\n return 3; // Suspended fourth\n case \"failed\":\n return 4; // Failed fifth\n case \"terminated\":\n return 5; // Terminated last\n default:\n return 6;\n }\n}\n\nfunction formatAppDisplay(environmentName: string, appID: Address, profileName: string): string {\n if (profileName) {\n return `${profileName} (${environmentName}:${appID})`;\n }\n return `${environmentName}:${appID}`;\n}\n\nexport interface GetAppIDOptions {\n appID?: string | Address;\n environment: string;\n privateKey?: string;\n rpcUrl?: string;\n action?: string;\n}\n\n/**\n * Prompt for app ID (supports app name or address)\n */\nexport async function getOrPromptAppID(\n appIDOrOptions: string | Address | GetAppIDOptions | undefined,\n environment?: string,\n): Promise<Address> {\n let options: GetAppIDOptions;\n if (environment !== undefined) {\n options = {\n appID: appIDOrOptions as string | Address | undefined,\n environment: environment,\n };\n } else if (\n appIDOrOptions &&\n typeof appIDOrOptions === \"object\" &&\n \"environment\" in appIDOrOptions\n ) {\n options = appIDOrOptions as GetAppIDOptions;\n } else {\n options = {\n appID: appIDOrOptions as string | Address | undefined,\n environment: \"sepolia\",\n };\n }\n\n if (options.appID) {\n const normalized =\n typeof options.appID === \"string\" ? addHexPrefix(options.appID) : options.appID;\n\n if (isAddress(normalized)) {\n return normalized as Address;\n }\n\n // Check profile cache first (remote profile names)\n const profileCache = getProfileCache(options.environment);\n if (profileCache) {\n const searchName = (options.appID as string).toLowerCase();\n for (const [appId, name] of Object.entries(profileCache)) {\n if (name.toLowerCase() === searchName) {\n return appId as Address;\n }\n }\n }\n\n // Fall back to local registry\n const apps = listApps(options.environment);\n const foundAppID = apps[options.appID as string];\n if (foundAppID) {\n return addHexPrefix(foundAppID) as Address;\n }\n\n throw new Error(\n `App name '${options.appID}' not found in environment '${options.environment}'`,\n );\n }\n\n return getAppIDInteractive(options);\n}\n\nasync function getAppIDInteractive(options: GetAppIDOptions): Promise<Address> {\n const action = options.action || \"view\";\n const environment = options.environment || \"sepolia\";\n const environmentConfig = getEnvironmentConfig(environment);\n\n if (!options.privateKey || !options.rpcUrl) {\n return getAppIDInteractiveFromRegistry(environment, action);\n }\n\n console.log(`\\nSelect an app to ${action}:\\n`);\n\n const privateKeyHex = addHexPrefix(options.privateKey);\n const account = privateKeyToAccount(privateKeyHex);\n const developerAddr = account.address;\n\n const { apps, appConfigs } = await getAllAppsByDeveloper(\n options.rpcUrl,\n environmentConfig,\n developerAddr,\n );\n\n if (apps.length === 0) {\n throw new Error(\"no apps found for your address\");\n }\n\n // Build profile names from cache, API, and local registry\n const profileNames: Record<string, string> = {};\n\n // Load from profile cache first (remote profiles take priority)\n let cachedProfiles = getProfileCache(environment);\n\n // If cache is empty/expired, fetch fresh profile names from API\n if (!cachedProfiles) {\n try {\n const userApiClient = new UserApiClient(\n environmentConfig,\n options.privateKey,\n options.rpcUrl,\n getClientId(),\n );\n const appInfos = await getAppInfosChunked(userApiClient, apps);\n\n // Build and cache profile names\n const freshProfiles: Record<string, string> = {};\n for (const info of appInfos) {\n if (info.profile?.name) {\n const normalizedId = String(info.address).toLowerCase();\n freshProfiles[normalizedId] = info.profile.name;\n }\n }\n\n // Save to cache for future use\n setProfileCache(environment, freshProfiles);\n cachedProfiles = freshProfiles;\n } catch {\n // On error, continue without profile names\n cachedProfiles = {};\n }\n }\n\n // Add cached profiles to profileNames\n for (const [appId, name] of Object.entries(cachedProfiles)) {\n profileNames[appId.toLowerCase()] = name;\n }\n\n // Also include local registry names (for apps without remote profiles)\n const localApps = listApps(environment);\n for (const [name, appID] of Object.entries(localApps)) {\n const normalizedID = String(appID).toLowerCase();\n if (!profileNames[normalizedID]) {\n profileNames[normalizedID] = name;\n }\n }\n\n const isEligible = (status: number): boolean => {\n switch (action) {\n case \"view\":\n case \"view info for\":\n case \"set profile for\":\n return true;\n case \"start\":\n return status === ContractAppStatusStopped || status === ContractAppStatusSuspended;\n case \"stop\":\n return status === ContractAppStatusStarted;\n default:\n return status !== ContractAppStatusTerminated && status !== ContractAppStatusSuspended;\n }\n };\n\n interface AppItem {\n addr: Address;\n display: string;\n status: number;\n index: number;\n }\n\n const appItems: AppItem[] = [];\n for (let i = 0; i < apps.length; i++) {\n const appAddr = apps[i];\n const config = appConfigs[i];\n const status = config.status;\n\n if (!isEligible(status)) {\n continue;\n }\n\n const statusStr = getContractStatusString(status);\n const profileName = profileNames[String(appAddr).toLowerCase()] || \"\";\n const displayName = formatAppDisplay(environmentConfig.name, appAddr, profileName);\n\n appItems.push({\n addr: appAddr,\n display: `${displayName} - ${statusStr}`,\n status,\n index: i,\n });\n }\n\n appItems.sort((a, b) => {\n const aPriority = getStatusPriority(a.status, false);\n const bPriority = getStatusPriority(b.status, false);\n\n if (aPriority !== bPriority) {\n return aPriority - bPriority;\n }\n\n return b.index - a.index;\n });\n\n if (appItems.length === 0) {\n switch (action) {\n case \"start\":\n throw new Error(\"no startable apps found - only Stopped apps can be started\");\n case \"stop\":\n throw new Error(\"no running apps found - only Running apps can be stopped\");\n default:\n throw new Error(\"no active apps found\");\n }\n }\n\n const choices = appItems.map((item) => ({\n name: item.display,\n value: item.addr,\n }));\n\n const selected = await select({\n message: \"Select app:\",\n choices,\n });\n\n return selected as Address;\n}\n\nasync function getAppIDInteractiveFromRegistry(\n environment: string,\n action: string,\n): Promise<Address> {\n // Build combined app list from profile cache and local registry\n const allApps: Record<string, string> = {}; // name -> appId\n\n // Add from profile cache (remote profiles)\n const cachedProfiles = getProfileCache(environment);\n if (cachedProfiles) {\n for (const [appId, name] of Object.entries(cachedProfiles)) {\n allApps[name] = appId;\n }\n }\n\n // Add from local registry (may override or add new entries)\n const localApps = listApps(environment);\n for (const [name, appId] of Object.entries(localApps)) {\n if (!allApps[name]) {\n allApps[name] = appId;\n }\n }\n\n if (Object.keys(allApps).length === 0) {\n console.log(\"\\nNo apps found in registry.\");\n console.log(\"You can enter an app ID (address) or app name.\");\n console.log();\n\n const appIDInput = await input({\n message: \"Enter app ID or name:\",\n default: \"\",\n validate: (value: string) => {\n if (!value) {\n return \"App ID or name cannot be empty\";\n }\n const normalized = addHexPrefix(value);\n if (isAddress(normalized)) {\n return true;\n }\n return \"Invalid app ID address\";\n },\n });\n\n const normalized = addHexPrefix(appIDInput);\n if (isAddress(normalized)) {\n return normalized as Address;\n }\n throw new Error(`Invalid app ID address: ${appIDInput}`);\n }\n\n const choices = Object.entries(allApps).map(([name, appID]) => {\n const displayName = `${name} (${appID})`;\n return { name: displayName, value: appID };\n });\n\n choices.push({ name: \"Enter custom app ID or name\", value: \"custom\" });\n\n console.log(`\\nSelect an app to ${action}:`);\n\n const selected = await select({\n message: \"Choose app:\",\n choices,\n });\n\n if (selected === \"custom\") {\n const appIDInput = await input({\n message: \"Enter app ID or name:\",\n default: \"\",\n validate: (value: string) => {\n if (!value) {\n return \"App ID or name cannot be empty\";\n }\n const normalized = addHexPrefix(value);\n if (isAddress(normalized)) {\n return true;\n }\n if (allApps[value]) {\n return true;\n }\n return \"Invalid app ID or name not found\";\n },\n });\n\n const normalized = addHexPrefix(appIDInput);\n if (isAddress(normalized)) {\n return normalized as Address;\n }\n const foundAppID = allApps[appIDInput];\n if (foundAppID) {\n return addHexPrefix(foundAppID) as Address;\n }\n throw new Error(`Failed to resolve app ID from input: ${appIDInput}`);\n }\n\n return addHexPrefix(selected) as Address;\n}\n\n// ==================== Resource Usage Monitoring Selection ====================\n\nexport type ResourceUsageMonitoring = \"enable\" | \"disable\";\n\n/**\n * Prompt for resource usage monitoring settings\n */\nexport async function getResourceUsageMonitoringInteractive(\n resourceUsageMonitoring?: ResourceUsageMonitoring,\n): Promise<ResourceUsageMonitoring> {\n if (resourceUsageMonitoring) {\n switch (resourceUsageMonitoring) {\n case \"enable\":\n case \"disable\":\n return resourceUsageMonitoring;\n default:\n throw new Error(\n `Invalid resource-usage-monitoring: ${resourceUsageMonitoring} (must be enable or disable)`,\n );\n }\n }\n\n const choice = await select({\n message: \"Show resource usage (CPU/memory) for your app?\",\n choices: [\n { name: \"Yes, enable resource usage monitoring\", value: \"enable\" },\n { name: \"No, disable resource usage monitoring\", value: \"disable\" },\n ],\n });\n\n return choice as ResourceUsageMonitoring;\n}\n\n// ==================== Confirmation ====================\n\n/**\n * Confirm prompts the user to confirm an action with a yes/no question.\n */\nexport async function confirm(prompt: string): Promise<boolean> {\n return confirmWithDefault(prompt, false);\n}\n\n/**\n * ConfirmWithDefault prompts the user to confirm an action with a yes/no question and a default value.\n */\nexport async function confirmWithDefault(\n prompt: string,\n defaultValue: boolean = false,\n): Promise<boolean> {\n return await inquirerConfirm({\n message: prompt,\n default: defaultValue,\n });\n}\n\n// ==================== Private Key ====================\n\n/**\n * Get private key - first tries keyring, then prompts interactively\n */\nexport async function getPrivateKeyInteractive(privateKey?: string): Promise<string> {\n // If provided directly, validate and return\n if (privateKey) {\n if (!validatePrivateKeyFormat(privateKey)) {\n throw new Error(\"Invalid private key format\");\n }\n return privateKey;\n }\n\n // Try to get from keyring using SDK's resolver\n const { getPrivateKeyWithSource } = await import(\"@layr-labs/ecloud-sdk\");\n const result = await getPrivateKeyWithSource({ privateKey: undefined });\n\n if (result) {\n return result.key;\n }\n\n // No key in keyring, prompt user\n const key = await password({\n message: \"Enter private key:\",\n mask: true,\n validate: (value: string) => {\n if (!value.trim()) {\n return \"Private key is required\";\n }\n if (!validatePrivateKeyFormat(value)) {\n return \"Invalid private key format (must be 64 hex characters, optionally prefixed with 0x)\";\n }\n return true;\n },\n });\n\n return key.trim();\n}\n\n// ==================== Environment Selection ====================\n\n/**\n * Prompt for environment selection\n */\nexport async function getEnvironmentInteractive(environment?: string): Promise<string> {\n if (environment) {\n try {\n getEnvironmentConfig(environment);\n if (!isEnvironmentAvailable(environment)) {\n throw new Error(`Environment ${environment} is not available in this build`);\n }\n return environment;\n } catch {\n // Invalid environment, continue to prompt\n }\n }\n\n const availableEnvs = getAvailableEnvironments();\n\n let defaultEnv: string | undefined;\n const configDefaultEnv = getDefaultEnvironment();\n if (configDefaultEnv && availableEnvs.includes(configDefaultEnv)) {\n try {\n getEnvironmentConfig(configDefaultEnv);\n defaultEnv = configDefaultEnv;\n } catch {\n // Default env is invalid, ignore it\n }\n }\n\n const choices = [];\n if (availableEnvs.includes(\"sepolia\")) {\n choices.push({ name: \"sepolia - Ethereum Sepolia testnet\", value: \"sepolia\" });\n }\n if (availableEnvs.includes(\"sepolia-dev\")) {\n choices.push({ name: \"sepolia-dev - Ethereum Sepolia testnet (dev)\", value: \"sepolia-dev\" });\n }\n if (availableEnvs.includes(\"mainnet-alpha\")) {\n choices.push({\n name: \"mainnet-alpha - Ethereum mainnet (āš ļø uses real funds)\",\n value: \"mainnet-alpha\",\n });\n }\n\n if (choices.length === 0) {\n throw new Error(\"No environments available in this build\");\n }\n\n const env = await select({\n message: \"Select environment:\",\n choices,\n default: defaultEnv,\n });\n\n return env;\n}\n\n// ==================== Template Selection ====================\n\n/**\n * Prompt for project name\n */\nexport async function promptProjectName(): Promise<string> {\n return input({ message: \"Enter project name:\" });\n}\n\n/**\n * Prompt for language selection\n */\nexport async function promptLanguage(): Promise<string> {\n return select({\n message: \"Select language:\",\n choices: PRIMARY_LANGUAGES,\n });\n}\n\n/**\n * Select template interactively\n */\nexport async function selectTemplateInteractive(language: string): Promise<string> {\n const catalog = await fetchTemplateCatalog();\n const categoryDescriptions = getCategoryDescriptions(catalog, language);\n\n if (Object.keys(categoryDescriptions).length === 0) {\n throw new Error(`No templates found for language ${language}`);\n }\n\n const categories = Object.keys(categoryDescriptions).sort();\n\n const options = categories.map((category) => {\n const description = categoryDescriptions[category];\n if (description) {\n return { name: `${category}: ${description}`, value: category };\n }\n return { name: category, value: category };\n });\n\n const selected = await select({\n message: \"Select template:\",\n choices: options,\n });\n\n return selected;\n}\n\n// ==================== App Profile ====================\n\nconst MAX_DESCRIPTION_LENGTH = 1000;\nconst MAX_IMAGE_SIZE = 4 * 1024 * 1024; // 4MB\nconst VALID_IMAGE_EXTENSIONS = [\".jpg\", \".jpeg\", \".png\"];\nconst VALID_X_HOSTS = [\"twitter.com\", \"www.twitter.com\", \"x.com\", \"www.x.com\"];\n\nexport function validateURL(rawURL: string): string | undefined {\n if (!rawURL.trim()) {\n return \"URL cannot be empty\";\n }\n\n try {\n const url = new URL(rawURL);\n if (url.protocol !== \"http:\" && url.protocol !== \"https:\") {\n return \"URL scheme must be http or https\";\n }\n } catch {\n return \"Invalid URL format\";\n }\n\n return undefined;\n}\n\nexport function validateXURL(rawURL: string): string | undefined {\n const urlErr = validateURL(rawURL);\n if (urlErr) {\n return urlErr;\n }\n\n try {\n const url = new URL(rawURL);\n const host = url.hostname.toLowerCase();\n\n if (!VALID_X_HOSTS.includes(host)) {\n return \"URL must be a valid X/Twitter URL (x.com or twitter.com)\";\n }\n\n if (!url.pathname || url.pathname === \"/\") {\n return \"X URL must include a username or profile path\";\n }\n } catch {\n return \"Invalid X URL format\";\n }\n\n return undefined;\n}\n\nexport function validateDescription(description: string): string | undefined {\n if (!description.trim()) {\n return \"Description cannot be empty\";\n }\n\n if (description.length > MAX_DESCRIPTION_LENGTH) {\n return `Description cannot exceed ${MAX_DESCRIPTION_LENGTH} characters`;\n }\n\n return undefined;\n}\n\nexport function validateImagePath(filePath: string): string | undefined {\n const cleanedPath = filePath.trim().replace(/^[\"']|[\"']$/g, \"\");\n\n if (!cleanedPath) {\n return \"Image path cannot be empty\";\n }\n\n if (!fs.existsSync(cleanedPath)) {\n return `Image file not found: ${cleanedPath}`;\n }\n\n const stats = fs.statSync(cleanedPath);\n if (stats.isDirectory()) {\n return \"Path is a directory, not a file\";\n }\n\n if (stats.size > MAX_IMAGE_SIZE) {\n const sizeMB = (stats.size / (1024 * 1024)).toFixed(2);\n return `Image file size (${sizeMB} MB) exceeds maximum allowed size of 4 MB`;\n }\n\n const ext = path.extname(cleanedPath).toLowerCase();\n if (!VALID_IMAGE_EXTENSIONS.includes(ext)) {\n return \"Image must be JPG or PNG format\";\n }\n\n return undefined;\n}\n\n/**\n * Validate an app profile object\n * Returns an error message if validation fails, undefined if valid\n */\nexport function validateAppProfile(profile: {\n name: string;\n website?: string;\n description?: string;\n xURL?: string;\n imagePath?: string;\n}): string | undefined {\n // Name is required\n if (!profile.name || !profile.name.trim()) {\n return \"Profile name is required\";\n }\n\n try {\n validateAppName(profile.name);\n } catch (err: any) {\n return `Invalid profile name: ${err.message}`;\n }\n\n // Validate optional fields if provided\n if (profile.website) {\n const websiteErr = validateURL(profile.website);\n if (websiteErr) {\n return `Invalid website: ${websiteErr}`;\n }\n }\n\n if (profile.description) {\n const descErr = validateDescription(profile.description);\n if (descErr) {\n return `Invalid description: ${descErr}`;\n }\n }\n\n if (profile.xURL) {\n const xURLErr = validateXURL(profile.xURL);\n if (xURLErr) {\n return `Invalid X URL: ${xURLErr}`;\n }\n }\n\n if (profile.imagePath) {\n const imageErr = validateImagePath(profile.imagePath);\n if (imageErr) {\n return `Invalid image: ${imageErr}`;\n }\n }\n\n return undefined;\n}\n\n/**\n * Collect app profile information interactively\n */\nexport async function getAppProfileInteractive(\n defaultName: string = \"\",\n allowRetry: boolean = true,\n): Promise<AppProfile | undefined> {\n while (true) {\n const name = await getAppNameForProfile(defaultName);\n const website = await getAppWebsiteInteractive();\n const description = await getAppDescriptionInteractive();\n const xURL = await getAppXURLInteractive();\n const imagePath = await getAppImageInteractive();\n\n const profile: AppProfile = {\n name,\n website,\n description,\n xURL,\n imagePath,\n };\n\n console.log(\"\\n\" + formatProfileForDisplay(profile));\n\n const confirmed = await inquirerConfirm({\n message: \"Continue with this profile?\",\n default: true,\n });\n\n if (confirmed) {\n return profile;\n }\n\n if (!allowRetry) {\n throw new Error(\"Profile confirmation cancelled\");\n }\n\n const retry = await inquirerConfirm({\n message: \"Would you like to re-enter the information?\",\n default: true,\n });\n\n if (!retry) {\n return undefined;\n }\n\n defaultName = name;\n }\n}\n\nasync function getAppNameForProfile(defaultName: string): Promise<string> {\n if (defaultName) {\n validateAppName(defaultName);\n return defaultName;\n }\n\n return await input({\n message: \"App name:\",\n default: \"\",\n validate: (value: string) => {\n if (!value.trim()) {\n return \"Name is required\";\n }\n try {\n validateAppName(value);\n return true;\n } catch (err: any) {\n return err.message;\n }\n },\n });\n}\n\nasync function getAppWebsiteInteractive(): Promise<string | undefined> {\n const website = await input({\n message: \"Website URL (optional):\",\n default: \"\",\n validate: (value: string) => {\n if (!value.trim()) {\n return true;\n }\n const err = validateURL(value);\n return err ? err : true;\n },\n });\n\n if (!website.trim()) {\n return undefined;\n }\n\n return website;\n}\n\nasync function getAppDescriptionInteractive(): Promise<string | undefined> {\n const description = await input({\n message: \"Description (optional):\",\n default: \"\",\n validate: (value: string) => {\n if (!value.trim()) {\n return true;\n }\n const err = validateDescription(value);\n return err ? err : true;\n },\n });\n\n if (!description.trim()) {\n return undefined;\n }\n\n return description;\n}\n\nasync function getAppXURLInteractive(): Promise<string | undefined> {\n const xURL = await input({\n message: \"X (Twitter) URL (optional):\",\n default: \"\",\n validate: (value: string) => {\n if (!value.trim()) {\n return true;\n }\n const err = validateXURL(value);\n return err ? err : true;\n },\n });\n\n if (!xURL.trim()) {\n return undefined;\n }\n\n return xURL;\n}\n\nasync function getAppImageInteractive(): Promise<string | undefined> {\n const wantsImage = await inquirerConfirm({\n message: \"Would you like to upload an app icon/logo?\",\n default: false,\n });\n\n if (!wantsImage) {\n return undefined;\n }\n\n const imagePath = await input({\n message:\n \"Image path (drag & drop image file or enter path - JPG/PNG, max 4MB, square recommended):\",\n default: \"\",\n validate: (value: string) => {\n if (!value.trim()) {\n return true;\n }\n const err = validateImagePath(value);\n return err ? err : true;\n },\n });\n\n if (!imagePath.trim()) {\n return undefined;\n }\n\n return imagePath.trim().replace(/^[\"']|[\"']$/g, \"\");\n}\n\nfunction formatProfileForDisplay(profile: AppProfile): string {\n let output = \"\\nšŸ“‹ Profile Summary:\\n\";\n output += ` Name: ${profile.name}\\n`;\n if (profile.website) {\n output += ` Website: ${profile.website}\\n`;\n }\n if (profile.description) {\n output += ` Description: ${profile.description}\\n`;\n }\n if (profile.xURL) {\n output += ` X URL: ${profile.xURL}\\n`;\n }\n if (profile.imagePath) {\n output += ` Image: ${profile.imagePath}\\n`;\n }\n return output;\n}\n","/**\n * App Resolver - Centralized app name resolution with caching\n *\n * Resolution priority:\n * 1. Check if input is already a valid hex address\n * 2. Check profile cache (24h TTL)\n * 3. Fetch from remote API if cache miss/stale\n * 4. Fall back to local registry for legacy apps\n */\n\nimport { Address, isAddress } from \"viem\";\nimport { privateKeyToAccount } from \"viem/accounts\";\nimport {\n UserApiClient,\n getAllAppsByDeveloper,\n EnvironmentConfig,\n AppInfo,\n} from \"@layr-labs/ecloud-sdk\";\nimport { getProfileCache, setProfileCache, updateProfileCacheEntry } from \"./globalConfig\";\nimport {\n listApps as listLocalApps,\n getAppName as getLocalAppName,\n resolveAppIDFromRegistry,\n} from \"./appNames\";\nimport { getClientId } from \"./version\";\n\nconst CHUNK_SIZE = 10;\n\n/**\n * Fetch app infos in chunks (getInfos has a limit of 10 apps per request)\n * Fetches all chunks concurrently for better performance\n */\nexport async function getAppInfosChunked(\n userApiClient: UserApiClient,\n appIds: Address[],\n addressCount?: number,\n): Promise<AppInfo[]> {\n if (appIds.length === 0) {\n return [];\n }\n\n const chunks: Address[][] = [];\n for (let i = 0; i < appIds.length; i += CHUNK_SIZE) {\n chunks.push(appIds.slice(i, i + CHUNK_SIZE));\n }\n\n const chunkResults = await Promise.all(\n chunks.map((chunk) => userApiClient.getInfos(chunk, addressCount)),\n );\n\n return chunkResults.flat();\n}\n\n/**\n * AppResolver handles app name resolution with remote profile support and caching\n */\nexport class AppResolver {\n private profileNames: Record<string, string> = {}; // appId (lowercase) -> name\n private cacheInitialized = false;\n\n constructor(\n private readonly environment: string,\n private readonly environmentConfig: EnvironmentConfig,\n private readonly privateKey?: string,\n private readonly rpcUrl?: string,\n ) {}\n\n /**\n * Resolve app name or ID to a valid Address\n * @param appIDOrName - App ID (hex address) or app name\n * @returns Resolved app address\n * @throws Error if app cannot be resolved\n */\n async resolveAppID(appIDOrName: string): Promise<Address> {\n if (!appIDOrName) {\n throw new Error(\"App ID or name is required\");\n }\n\n // Normalize and check if it's already a valid address\n const normalized = appIDOrName.startsWith(\"0x\") ? appIDOrName : `0x${appIDOrName}`;\n if (isAddress(normalized)) {\n return normalized as Address;\n }\n\n // Ensure cache is initialized\n await this.ensureCacheInitialized();\n\n // Search profile names for a match (case-insensitive)\n const searchName = appIDOrName.toLowerCase();\n for (const [appId, name] of Object.entries(this.profileNames)) {\n if (name.toLowerCase() === searchName) {\n return appId as Address;\n }\n }\n\n // Fall back to local registry\n const localAppId = resolveAppIDFromRegistry(this.environment, appIDOrName);\n if (localAppId) {\n return localAppId as Address;\n }\n\n throw new Error(`App '${appIDOrName}' not found in environment '${this.environment}'`);\n }\n\n /**\n * Get app name from app ID\n * @param appID - App address\n * @returns Profile name if found, empty string otherwise\n */\n async getAppName(appID: string | Address): Promise<string> {\n const normalizedId = String(appID).toLowerCase();\n\n // Ensure cache is initialized\n await this.ensureCacheInitialized();\n\n // Check profile names first\n const profileName = this.profileNames[normalizedId];\n if (profileName) {\n return profileName;\n }\n\n // Fall back to local registry\n return getLocalAppName(this.environment, appID as string);\n }\n\n /**\n * Check if an app name is available (not used by any existing app)\n * @param name - Name to check\n * @returns true if available, false if taken\n */\n async isAppNameAvailable(name: string): Promise<boolean> {\n await this.ensureCacheInitialized();\n\n const searchName = name.toLowerCase();\n\n // Check profile names\n for (const profileName of Object.values(this.profileNames)) {\n if (profileName.toLowerCase() === searchName) {\n return false;\n }\n }\n\n // Check local registry\n const localApps = listLocalApps(this.environment);\n return !localApps[name];\n }\n\n /**\n * Find an available app name by appending numbers if needed\n * @param baseName - Base name to start with\n * @returns Available name (may have number suffix)\n */\n async findAvailableName(baseName: string): Promise<string> {\n // Check if base name is available\n if (await this.isAppNameAvailable(baseName)) {\n return baseName;\n }\n\n // Try with incrementing numbers\n for (let i = 2; i <= 100; i++) {\n const candidate = `${baseName}-${i}`;\n if (await this.isAppNameAvailable(candidate)) {\n return candidate;\n }\n }\n\n // Fallback to timestamp if somehow we have 100+ duplicates\n return `${baseName}-${Date.now()}`;\n }\n\n /**\n * Get all profile names (for display/listing purposes)\n * @returns Map of appId -> name\n */\n async getAllProfileNames(): Promise<Record<string, string>> {\n await this.ensureCacheInitialized();\n return { ...this.profileNames };\n }\n\n /**\n * Update cache with a new profile name (call after deploy or profile set)\n */\n updateCacheEntry(appId: string, profileName: string): void {\n const normalizedId = appId.toLowerCase();\n this.profileNames[normalizedId] = profileName;\n updateProfileCacheEntry(this.environment, appId, profileName);\n }\n\n /**\n * Ensure the profile cache is initialized\n * Loads from disk cache if valid, otherwise fetches from API\n */\n private async ensureCacheInitialized(): Promise<void> {\n if (this.cacheInitialized) {\n return;\n }\n\n // Try to load from disk cache first\n const cachedProfiles = getProfileCache(this.environment);\n if (cachedProfiles) {\n this.profileNames = cachedProfiles;\n this.cacheInitialized = true;\n return;\n }\n\n // Cache miss or expired - fetch from API\n await this.fetchProfilesFromAPI();\n this.cacheInitialized = true;\n }\n\n /**\n * Fetch profile names from the remote API and update cache\n */\n private async fetchProfilesFromAPI(): Promise<void> {\n // Need private key and rpcUrl for authenticated API calls\n if (!this.privateKey || !this.rpcUrl) {\n // Can't fetch from API without credentials - use empty cache\n this.profileNames = {};\n return;\n }\n\n try {\n // Get all apps for the current developer\n const account = privateKeyToAccount(this.privateKey as `0x${string}`);\n const { apps } = await getAllAppsByDeveloper(\n this.rpcUrl,\n this.environmentConfig,\n account.address,\n );\n\n if (apps.length === 0) {\n this.profileNames = {};\n setProfileCache(this.environment, {});\n return;\n }\n\n // Fetch info for all apps to get profile names\n const userApiClient = new UserApiClient(\n this.environmentConfig,\n this.privateKey,\n this.rpcUrl,\n getClientId(),\n );\n const appInfos = await getAppInfosChunked(userApiClient, apps);\n\n // Build profile names map\n const profiles: Record<string, string> = {};\n for (const info of appInfos) {\n if (info.profile?.name) {\n const normalizedId = String(info.address).toLowerCase();\n profiles[normalizedId] = info.profile.name;\n }\n }\n\n this.profileNames = profiles;\n setProfileCache(this.environment, profiles);\n } catch (error) {\n // On error, use empty cache - don't fail the command\n console.debug?.(\"Failed to fetch profiles from API:\", error);\n this.profileNames = {};\n }\n }\n}\n\n/**\n * Create an AppResolver instance\n * Convenience function for creating resolver with common parameters\n */\nexport function createAppResolver(\n environment: string,\n environmentConfig: EnvironmentConfig,\n privateKey?: string,\n rpcUrl?: string,\n): AppResolver {\n return new AppResolver(environment, environmentConfig, privateKey, rpcUrl);\n}\n","/**\n * Global configuration management\n *\n * Stores user-level configuration that persists across all CLI usage.\n * - $XDG_CONFIG_HOME/ecloud[BuildSuffix]/config.yaml (if XDG_CONFIG_HOME is set)\n * - Or ~/.config/ecloud[BuildSuffix]/config.yaml (fallback)\n *\n * Where BuildSuffix is:\n * - \"\" (empty) for production builds\n * - \"-dev\" for development builds\n */\n\nimport * as fs from \"fs\";\nimport * as path from \"path\";\nimport * as os from \"os\";\nimport { load as loadYaml, dump as dumpYaml } from \"js-yaml\";\nimport { getBuildType } from \"@layr-labs/ecloud-sdk\";\nimport * as crypto from \"crypto\";\nconst GLOBAL_CONFIG_FILE = \"config.yaml\";\n\nexport interface ProfileCacheEntry {\n updated_at: number; // Unix timestamp in milliseconds\n profiles: { [appId: string]: string }; // appId -> profile name\n}\n\nexport interface GlobalConfig {\n first_run?: boolean;\n telemetry_enabled?: boolean;\n user_uuid?: string;\n default_environment?: string;\n last_version_check?: number;\n last_known_version?: string;\n profile_cache?: {\n [environment: string]: ProfileCacheEntry;\n };\n}\n\n// Profile cache TTL: 24 hours in milliseconds\nconst PROFILE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;\n\n/**\n * Get the XDG-compliant directory where global ecloud config should be stored\n */\nfunction getGlobalConfigDir(): string {\n // First check XDG_CONFIG_HOME\n const configHome = process.env.XDG_CONFIG_HOME;\n\n let baseDir: string;\n if (configHome && path.isAbsolute(configHome)) {\n baseDir = configHome;\n } else {\n // Fall back to ~/.config\n baseDir = path.join(os.homedir(), \".config\");\n }\n\n // Use environment-specific config directory\n const buildType = getBuildType();\n const buildSuffix = buildType === \"dev\" ? \"-dev\" : \"\";\n const configDirName = `ecloud${buildSuffix}`;\n\n return path.join(baseDir, configDirName);\n}\n\n/**\n * Get the full path to the global config file\n */\nfunction getGlobalConfigPath(): string {\n return path.join(getGlobalConfigDir(), GLOBAL_CONFIG_FILE);\n}\n\n/**\n * Load global configuration, creating defaults if needed\n */\nexport function loadGlobalConfig(): GlobalConfig {\n const configPath = getGlobalConfigPath();\n\n // If file doesn't exist, return defaults for first run\n if (!fs.existsSync(configPath)) {\n return {\n first_run: true,\n };\n }\n\n try {\n const content = fs.readFileSync(configPath, \"utf-8\");\n const config = loadYaml(content) as GlobalConfig;\n return config || { first_run: true };\n } catch {\n // If parsing fails, return defaults\n return {\n first_run: true,\n };\n }\n}\n\n/**\n * Save global configuration to disk\n */\nexport function saveGlobalConfig(config: GlobalConfig): void {\n const configPath = getGlobalConfigPath();\n\n // Ensure directory exists\n const configDir = path.dirname(configPath);\n fs.mkdirSync(configDir, { recursive: true, mode: 0o755 });\n\n // Write config file\n const content = dumpYaml(config, { lineWidth: -1 });\n fs.writeFileSync(configPath, content, { mode: 0o644 });\n}\n\n/**\n * Get the user's preferred deployment environment\n */\nexport function getDefaultEnvironment(): string | undefined {\n const config = loadGlobalConfig();\n return config.default_environment;\n}\n\n/**\n * Set the user's preferred deployment environment\n */\nexport function setDefaultEnvironment(environment: string): void {\n const config = loadGlobalConfig();\n config.default_environment = environment;\n config.first_run = false; // No longer first run after setting environment\n saveGlobalConfig(config);\n}\n\n/**\n * Check if this is the user's first time running the CLI\n */\nexport function isFirstRun(): boolean {\n const config = loadGlobalConfig();\n return config.first_run === true;\n}\n\n/**\n * Mark that the first run has been completed\n */\nexport function markFirstRunComplete(): void {\n const config = loadGlobalConfig();\n config.first_run = false;\n saveGlobalConfig(config);\n}\n\n/**\n * Get the global telemetry preference\n */\nexport function getGlobalTelemetryPreference(): boolean | undefined {\n const config = loadGlobalConfig();\n return config.telemetry_enabled;\n}\n\n/**\n * Set the global telemetry preference\n */\nexport function setGlobalTelemetryPreference(enabled: boolean): void {\n const config = loadGlobalConfig();\n config.telemetry_enabled = enabled;\n config.first_run = false; // No longer first run after setting preference\n saveGlobalConfig(config);\n}\n\n// ==================== Profile Cache Functions ====================\n\n/**\n * Get cached profile names for an environment\n * Returns null if cache is missing or expired (older than 24 hours)\n */\nexport function getProfileCache(environment: string): Record<string, string> | null {\n const config = loadGlobalConfig();\n const cacheEntry = config.profile_cache?.[environment];\n\n if (!cacheEntry) {\n return null;\n }\n\n // Check if cache is expired\n const now = Date.now();\n if (now - cacheEntry.updated_at > PROFILE_CACHE_TTL_MS) {\n return null;\n }\n\n return cacheEntry.profiles;\n}\n\n/**\n * Set cached profile names for an environment\n */\nexport function setProfileCache(environment: string, profiles: Record<string, string>): void {\n const config = loadGlobalConfig();\n\n if (!config.profile_cache) {\n config.profile_cache = {};\n }\n\n config.profile_cache[environment] = {\n updated_at: Date.now(),\n profiles,\n };\n\n saveGlobalConfig(config);\n}\n\n/**\n * Invalidate profile cache for a specific environment or all environments\n */\nexport function invalidateProfileCache(environment?: string): void {\n const config = loadGlobalConfig();\n\n if (!config.profile_cache) {\n return;\n }\n\n if (environment) {\n // Invalidate specific environment\n delete config.profile_cache[environment];\n } else {\n // Invalidate all environments\n config.profile_cache = {};\n }\n\n saveGlobalConfig(config);\n}\n\n/**\n * Update a single profile name in the cache\n * This is useful after deploy or profile set to update just one entry\n */\nexport function updateProfileCacheEntry(\n environment: string,\n appId: string,\n profileName: string,\n): void {\n const config = loadGlobalConfig();\n\n if (!config.profile_cache) {\n config.profile_cache = {};\n }\n\n if (!config.profile_cache[environment]) {\n config.profile_cache[environment] = {\n updated_at: Date.now(),\n profiles: {},\n };\n }\n\n // Normalize appId to lowercase for consistent lookups\n const normalizedAppId = appId.toLowerCase();\n config.profile_cache[environment].profiles[normalizedAppId] = profileName;\n config.profile_cache[environment].updated_at = Date.now();\n\n saveGlobalConfig(config);\n}\n\n/**\n * Get the user UUID from global config, or generate a new one if it doesn't exist\n */\nexport function getOrCreateUserUUID(): string {\n const config = loadGlobalConfig();\n if (config.user_uuid) {\n return config.user_uuid;\n }\n\n // Generate a new UUID (v4)\n const uuid = generateUUID();\n\n // Save it to config\n config.user_uuid = uuid;\n config.first_run = false;\n saveGlobalConfig(config);\n\n return uuid;\n}\n\n/**\n * Generate a UUID v4\n */\nfunction generateUUID(): string {\n // UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx\n // Use cryptographically secure random values.\n const bytes = crypto.randomBytes(16);\n // Per RFC 4122 section 4.4, set bits for version and `clock_seq_hi_and_reserved`\n bytes[6] = (bytes[6] & 0x0f) | 0x40; // Version 4\n bytes[8] = (bytes[8] & 0x3f) | 0x80; // Variant 10\n const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, \"0\"));\n return (\n hex.slice(0, 4).join(\"\") +\n hex.slice(4, 6).join(\"\") +\n \"-\" +\n hex.slice(6, 8).join(\"\") +\n \"-\" +\n hex.slice(8, 10).join(\"\") +\n \"-\" +\n hex.slice(10, 12).join(\"\") +\n \"-\" +\n hex.slice(12, 16).join(\"\")\n );\n}\n\n/**\n * Save user UUID to global config (preserves existing UUID if present)\n */\nexport function saveUserUUID(userUUID: string): void {\n const config = loadGlobalConfig();\n // Only update if not already set\n if (!config.user_uuid) {\n config.user_uuid = userUUID;\n saveGlobalConfig(config);\n }\n}\n","/**\n * App name registry\n *\n * - Stores in ~/.eigenx/apps/{environment}.yaml\n * - Uses YAML format with version and apps structure\n * - Format: {version: \"1.0.0\", apps: {name: {app_id: \"...\", created_at: ..., updated_at: ...}}}\n */\n\nimport * as fs from \"fs\";\nimport * as path from \"path\";\nimport * as os from \"os\";\nimport { load as loadYaml, dump as dumpYaml } from \"js-yaml\";\n\nconst CONFIG_DIR = path.join(os.homedir(), \".eigenx\");\nconst APPS_DIR = path.join(CONFIG_DIR, \"apps\");\nconst APP_REGISTRY_VERSION = \"1.0.0\";\n\ninterface AppRegistry {\n version: string;\n apps: {\n [name: string]: {\n app_id: string;\n created_at?: string;\n updated_at?: string;\n };\n };\n}\n\n/**\n * Get the path to the app registry file for an environment\n */\nfunction getAppRegistryPath(environment: string): string {\n return path.join(APPS_DIR, `${environment}.yaml`);\n}\n\n/**\n * Load app registry from disk\n */\nfunction loadAppRegistry(environment: string): AppRegistry {\n const filePath = getAppRegistryPath(environment);\n\n // If file doesn't exist, return empty registry\n if (!fs.existsSync(filePath)) {\n return {\n version: APP_REGISTRY_VERSION,\n apps: {},\n };\n }\n\n try {\n const content = fs.readFileSync(filePath, \"utf-8\");\n const registry = loadYaml(content) as AppRegistry;\n\n // Initialize apps map if nil\n if (!registry.apps) {\n registry.apps = {};\n }\n\n return registry;\n } catch {\n // If parsing fails, return empty registry\n return {\n version: APP_REGISTRY_VERSION,\n apps: {},\n };\n }\n}\n\n/**\n * Save app registry to disk\n */\nfunction saveAppRegistry(environment: string, registry: AppRegistry): void {\n const filePath = getAppRegistryPath(environment);\n\n // Ensure directory exists\n if (!fs.existsSync(APPS_DIR)) {\n fs.mkdirSync(APPS_DIR, { recursive: true });\n }\n\n // Write YAML file\n const yamlContent = dumpYaml(registry, {\n lineWidth: -1, // No line wrapping\n quotingType: '\"',\n });\n fs.writeFileSync(filePath, yamlContent, { mode: 0o644 });\n}\n\n/**\n * Resolve app ID or name to app ID (for CLI use)\n */\nexport function resolveAppIDFromRegistry(environment: string, appIDOrName: string): string | null {\n // First check if it's already a valid hex address\n if (/^0x[a-fA-F0-9]{40}$/.test(appIDOrName)) {\n return appIDOrName;\n }\n\n // Try to load from registry\n const registry = loadAppRegistry(environment);\n\n // Look up by name\n const app = registry.apps[appIDOrName];\n if (app) {\n return app.app_id;\n }\n\n return null;\n}\n\n/**\n * Set app name for an environment\n */\nexport async function setAppName(\n environment: string,\n appIDOrName: string,\n newName: string,\n): Promise<void> {\n const registry = loadAppRegistry(environment);\n\n // Resolve the target app ID\n let targetAppID: string | null = resolveAppIDFromRegistry(environment, appIDOrName);\n if (!targetAppID) {\n // If can't resolve, check if it's a valid app ID\n if (/^0x[a-fA-F0-9]{40}$/.test(appIDOrName)) {\n targetAppID = appIDOrName;\n } else {\n throw new Error(`invalid app ID or name: ${appIDOrName}`);\n }\n }\n\n // Normalize app ID for comparison\n const targetAppIDLower = targetAppID.toLowerCase();\n\n // Find and remove any existing names for this app ID\n for (const [name, app] of Object.entries(registry.apps)) {\n if (app?.app_id && String(app.app_id).toLowerCase() === targetAppIDLower) {\n delete registry.apps[name];\n }\n }\n\n // If newName is empty, we're just removing the name\n if (newName === \"\") {\n saveAppRegistry(environment, registry);\n return;\n }\n\n // Add the new name entry\n const now = new Date().toISOString();\n registry.apps[newName] = {\n app_id: targetAppID,\n created_at: now,\n updated_at: now,\n };\n\n saveAppRegistry(environment, registry);\n}\n\n/**\n * Get app name for an environment\n */\nexport function getAppName(environment: string, appID: string): string {\n const registry = loadAppRegistry(environment);\n const normalizedAppID = appID.toLowerCase();\n\n // Search for the app ID in the registry\n for (const [name, app] of Object.entries(registry.apps)) {\n if (app?.app_id && String(app.app_id).toLowerCase() === normalizedAppID) {\n return name;\n }\n }\n\n return \"\";\n}\n\n/**\n * List all apps for an environment\n */\nexport function listApps(environment: string): Record<string, string> {\n const registry = loadAppRegistry(environment);\n const result: Record<string, string> = {};\n\n // Convert registry format (name -> app_id) to result format (name -> appID)\n for (const [name, app] of Object.entries(registry.apps)) {\n if (app?.app_id) {\n result[name] = String(app.app_id);\n }\n }\n\n return result;\n}\n\n/**\n * Check if an app name is available in the given environment\n */\nexport function isAppNameAvailable(environment: string, name: string): boolean {\n const apps = listApps(environment);\n return !apps[name];\n}\n\n/**\n * Find an available app name by appending numbers if needed\n */\nexport function findAvailableName(environment: string, baseName: string): string {\n const apps = listApps(environment);\n\n // Check if base name is available\n if (!apps[baseName]) {\n return baseName;\n }\n\n // Try with incrementing numbers\n for (let i = 2; i <= 100; i++) {\n const candidate = `${baseName}-${i}`;\n if (!apps[candidate]) {\n return candidate;\n }\n }\n\n // Fallback to timestamp if somehow we have 100+ duplicates\n return `${baseName}-${Date.now()}`;\n}\n","/**\n * Telemetry utilities for CLI commands\n *\n * Provides helpers to wrap command execution with telemetry tracking\n */\n\nimport {\n createTelemetryClient,\n createAppEnvironment,\n createMetricsContext,\n addMetric,\n addMetricWithDimensions,\n emitMetrics,\n type TelemetryClient,\n getBuildType,\n} from \"@layr-labs/ecloud-sdk\";\nimport { Command } from \"@oclif/core\";\nimport {\n getDefaultEnvironment,\n getOrCreateUserUUID,\n getGlobalTelemetryPreference,\n} from \"./utils/globalConfig\";\n\n/**\n * Create a telemetry client for CLI usage\n */\nexport function createCLITelemetryClient(): TelemetryClient {\n // Get user UUID from CLI's globalConfig (handles I/O)\n const userUUID = getOrCreateUserUUID();\n const environment = createAppEnvironment(userUUID);\n\n // Get telemetry preference from CLI's globalConfig\n const telemetryEnabled = getGlobalTelemetryPreference();\n\n return createTelemetryClient(environment, \"ecloud-cli\", {\n telemetryEnabled: telemetryEnabled === true, // Only enabled if explicitly set to true\n });\n}\n\n/**\n * Wrap a command execution with telemetry\n *\n * @param command - The CLI command instance\n * @param action - The command action to execute\n * @returns The result of the action\n */\nexport async function withTelemetry<T>(command: Command, action: () => Promise<T>): Promise<T> {\n const client = createCLITelemetryClient();\n const metrics = createMetricsContext();\n\n // Set source to identify CLI usage\n metrics.properties[\"source\"] = \"ecloud-cli\";\n\n // Set command name in properties\n metrics.properties[\"command\"] = command.id || command.constructor.name;\n\n // Set environment in properties\n const environment = getDefaultEnvironment() || \"sepolia\";\n metrics.properties[\"environment\"] = environment;\n\n // Set buildType in properties\n const buildType = getBuildType() || \"prod\";\n metrics.properties[\"build_type\"] = buildType;\n\n // Set CLI version if available\n const cliVersion = command.config.version;\n if (cliVersion) {\n metrics.properties[\"cli_version\"] = cliVersion;\n }\n\n // Add initial count metric\n addMetric(metrics, \"Count\", 1);\n\n let actionError: Error | undefined;\n let result: T;\n\n try {\n result = await action();\n return result;\n } catch (err) {\n actionError = err instanceof Error ? err : new Error(String(err));\n throw err;\n } finally {\n // Add result metric\n const resultValue = actionError ? \"Failure\" : \"Success\";\n const dimensions: Record<string, string> = {};\n if (actionError) {\n dimensions[\"error\"] = actionError.message;\n }\n addMetricWithDimensions(metrics, resultValue, 1, dimensions);\n\n // Add duration metric\n const duration = Date.now() - metrics.startTime.getTime();\n addMetric(metrics, \"DurationMilliseconds\", duration);\n\n // Emit all metrics\n try {\n await emitMetrics(client, metrics);\n await client.close();\n } catch {\n // Silently ignore telemetry errors\n }\n }\n}\n"],"mappings":"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","names":["Flags","getEnvironmentConfig","fs","path","os","isAddress","privateKeyToAccount","getAllAppsByDeveloper","UserApiClient","fs","path","os","loadYaml","dumpYaml","getPrivateKeyWithSource","getBuildType","getBuildType","Flags"]}
1
+ {"version":3,"sources":["../../../src/commands/billing/cancel.ts","../../../src/client.ts","../../../src/flags.ts","../../../src/utils/prompts.ts","../../../src/utils/appResolver.ts","../../../src/utils/globalConfig.ts","../../../src/utils/appNames.ts"],"sourcesContent":["import { Command, Flags } from \"@oclif/core\";\nimport { isSubscriptionActive } from \"@layr-labs/ecloud-sdk\";\nimport { createBillingClient } from \"../../client\";\nimport { commonFlags } from \"../../flags\";\nimport chalk from \"chalk\";\nimport { confirm } from \"@inquirer/prompts\";\n\nexport default class BillingCancel extends Command {\n static description = \"Cancel subscription\";\n\n static flags = {\n \"private-key\": commonFlags[\"private-key\"],\n verbose: commonFlags.verbose,\n product: Flags.string({\n required: false,\n description: \"Product ID\",\n default: \"compute\",\n options: [\"compute\"],\n env: \"ECLOUD_PRODUCT_ID\",\n }),\n force: Flags.boolean({\n char: \"f\",\n description: \"Skip confirmation prompt\",\n default: false,\n }),\n };\n\n async run() {\n const { flags } = await this.parse(BillingCancel);\n const billing = await createBillingClient(flags);\n\n // Check subscription status first\n this.log(`\\nChecking subscription status for ${flags.product}...`);\n const status = await billing.getStatus({\n productId: flags.product as \"compute\",\n });\n\n // Check if there's an active subscription to cancel\n if (!isSubscriptionActive(status.subscriptionStatus)) {\n this.log(`\\n${chalk.gray(\"You don't have an active subscription to cancel.\")}`);\n this.log(chalk.gray(`Current status: ${status.subscriptionStatus}`));\n return;\n }\n\n // Confirm cancellation unless --force flag is used\n if (!flags.force) {\n const confirmed = await confirm({\n message: `${chalk.yellow(\"Warning:\")} This will cancel your ${flags.product} subscription. Continue?`,\n });\n if (!confirmed) {\n this.log(chalk.gray(\"\\nCancellation aborted.\"));\n return;\n }\n }\n\n this.log(`\\nCanceling subscription for ${flags.product}...`);\n\n const result = await billing.cancel({\n productId: flags.product as \"compute\",\n });\n\n // Handle response (defensive - should always be canceled at this point)\n if (result.type === \"canceled\") {\n this.log(`\\n${chalk.green(\"āœ“\")} Subscription canceled successfully.`);\n } else {\n this.log(`\\n${chalk.gray(\"Subscription status changed. Current status:\")} ${result.status}`);\n }\n }\n}\n","import {\n createAppModule,\n createBillingModule,\n getEnvironmentConfig,\n requirePrivateKey,\n getPrivateKeyWithSource,\n} from \"@layr-labs/ecloud-sdk\";\nimport { CommonFlags, validateCommonFlags } from \"./flags\";\nimport { getPrivateKeyInteractive } from \"./utils/prompts\";\nimport { getClientId } from \"./utils/version\";\nimport { Hex } from \"viem\";\n\nexport async function createAppClient(flags: CommonFlags) {\n flags = await validateCommonFlags(flags);\n\n const environment = flags.environment!;\n const environmentConfig = getEnvironmentConfig(environment);\n const rpcUrl = flags[\"rpc-url\"] || environmentConfig.defaultRPCURL;\n const { key: privateKey, source } = await requirePrivateKey({\n privateKey: flags[\"private-key\"],\n });\n\n if (flags.verbose) {\n console.log(`Using private key from: ${source}`);\n }\n\n return createAppModule({\n verbose: flags.verbose,\n privateKey,\n rpcUrl,\n environment,\n clientId: getClientId(),\n });\n}\n\nexport async function createBillingClient(flags: { \"private-key\"?: string; verbose?: boolean }) {\n const result = await getPrivateKeyWithSource({\n privateKey: flags[\"private-key\"],\n });\n const privateKey = await getPrivateKeyInteractive(result?.key);\n\n return createBillingModule({\n verbose: flags.verbose ?? false,\n privateKey: privateKey as Hex,\n });\n}\n","import { Flags } from \"@oclif/core\";\nimport { getEnvironmentInteractive, getPrivateKeyInteractive } from \"./utils/prompts\";\nimport { getDefaultEnvironment } from \"./utils/globalConfig\";\n\nexport type CommonFlags = {\n verbose: boolean;\n environment?: string;\n \"private-key\"?: string;\n \"rpc-url\"?: string;\n};\n\nexport const commonFlags = {\n environment: Flags.string({\n required: false,\n description: \"Deployment environment to use\",\n env: \"ECLOUD_ENV\",\n }),\n \"private-key\": Flags.string({\n required: false,\n description: \"Private key for signing transactions\",\n env: \"ECLOUD_PRIVATE_KEY\",\n }),\n \"rpc-url\": Flags.string({\n required: false,\n description: \"RPC URL to connect to blockchain\",\n env: \"ECLOUD_RPC_URL\",\n }),\n verbose: Flags.boolean({\n required: false,\n description: \"Enable verbose logging (default: false)\",\n default: false,\n }),\n};\n\n// Validate or prompt for required common flags\nexport async function validateCommonFlags(flags: CommonFlags) {\n // If no environment is selected, default to the global config env\n if (!flags[\"environment\"]) {\n flags[\"environment\"] = getDefaultEnvironment();\n }\n // If the provided env is invalid, proceed to prompt\n flags[\"environment\"] = await getEnvironmentInteractive(flags[\"environment\"]);\n flags[\"private-key\"] = await getPrivateKeyInteractive(flags[\"private-key\"]);\n\n return flags;\n}\n","/**\n * Interactive prompts for CLI commands\n *\n * This module contains all interactive user prompts. These functions should only\n * be used in CLI commands, not in the SDK.\n */\n\nimport { input, select, password, confirm as inquirerConfirm } from \"@inquirer/prompts\";\nimport fs from \"fs\";\nimport path from \"path\";\nimport os from \"os\";\nimport { Address, isAddress } from \"viem\";\nimport { privateKeyToAccount } from \"viem/accounts\";\nimport {\n getEnvironmentConfig,\n getAvailableEnvironments,\n isEnvironmentAvailable,\n getAllAppsByDeveloper,\n getCategoryDescriptions,\n fetchTemplateCatalog,\n PRIMARY_LANGUAGES,\n AppProfile,\n validateAppName,\n validateImageReference,\n validateFilePath,\n validatePrivateKeyFormat,\n extractAppNameFromImage,\n UserApiClient,\n} from \"@layr-labs/ecloud-sdk\";\nimport { getAppInfosChunked } from \"./appResolver\";\nimport { getDefaultEnvironment, getProfileCache, setProfileCache } from \"./globalConfig\";\nimport { listApps, isAppNameAvailable, findAvailableName } from \"./appNames\";\nimport { getClientId } from \"./version\";\n\n// Helper to add hex prefix\nfunction addHexPrefix(value: string): `0x${string}` {\n if (value.startsWith(\"0x\")) {\n return value as `0x${string}`;\n }\n return `0x${value}` as `0x${string}`;\n}\n\n// ==================== Dockerfile Selection ====================\n\n/**\n * Prompt for Dockerfile selection\n */\nexport async function getDockerfileInteractive(dockerfilePath?: string): Promise<string> {\n // Check if provided via option\n if (dockerfilePath) {\n return dockerfilePath;\n }\n\n // Check if default Dockerfile exists in current directory\n // Use INIT_CWD if available (set by npm/pnpm to original cwd), otherwise fall back to process.cwd()\n const cwd = process.env.INIT_CWD || process.cwd();\n const dockerfilePath_resolved = path.join(cwd, \"Dockerfile\");\n\n if (!fs.existsSync(dockerfilePath_resolved)) {\n // No Dockerfile found, return empty string (deploy existing image)\n return \"\";\n }\n\n // Interactive prompt when Dockerfile exists\n console.log(`\\nFound Dockerfile in ${cwd}`);\n\n const choice = await select({\n message: \"Choose deployment method:\",\n choices: [\n { name: \"Build and deploy from Dockerfile\", value: \"build\" },\n { name: \"Deploy existing image from registry\", value: \"existing\" },\n ],\n });\n\n switch (choice) {\n case \"build\":\n // Return full path so SDK uses the correct directory\n return dockerfilePath_resolved;\n case \"existing\":\n return \"\";\n default:\n throw new Error(`Unexpected choice: ${choice}`);\n }\n}\n\n// ==================== Image Reference Selection ====================\n\ninterface RegistryInfo {\n Type: string;\n Username: string;\n URL: string;\n}\n\n/**\n * Extract hostname from a registry URL/string for safe comparison\n * This avoids the security issue of using .includes() which can match\n * substrings anywhere (e.g., \"malicious.docker.io.attacker.com\")\n */\nfunction extractHostname(registry: string): string {\n // Remove protocol if present\n let hostname = registry.replace(/^https?:\\/\\//, \"\");\n // Remove path and trailing slashes\n hostname = hostname.split(\"/\")[0];\n // Remove port if present\n hostname = hostname.split(\":\")[0];\n return hostname.toLowerCase();\n}\n\n/**\n * Check if a registry matches Docker Hub\n */\nfunction isDockerHub(registry: string): boolean {\n const hostname = extractHostname(registry);\n return (\n hostname === \"docker.io\" ||\n hostname === \"index.docker.io\" ||\n hostname === \"registry-1.docker.io\"\n );\n}\n\n/**\n * Check if a registry matches GitHub Container Registry\n */\nfunction isGHCR(registry: string): boolean {\n const hostname = extractHostname(registry);\n return hostname === \"ghcr.io\";\n}\n\n/**\n * Check if a registry matches Google Container Registry\n */\nfunction isGCR(registry: string): boolean {\n const hostname = extractHostname(registry);\n return hostname === \"gcr.io\" || hostname.endsWith(\".gcr.io\");\n}\n\n/**\n * Get credentials from Docker credential helper\n */\nasync function getCredentialsFromHelper(\n registry: string,\n): Promise<{ username: string; password: string } | undefined> {\n const dockerConfigPath = path.join(os.homedir(), \".docker\", \"config.json\");\n\n if (!fs.existsSync(dockerConfigPath)) {\n return undefined;\n }\n\n try {\n const config = JSON.parse(fs.readFileSync(dockerConfigPath, \"utf-8\"));\n const credsStore = config.credsStore;\n\n if (!credsStore) {\n return undefined;\n }\n\n const { execSync } = await import(\"child_process\");\n const helper = `docker-credential-${credsStore}`;\n\n try {\n const registryVariants: string[] = [];\n\n if (isDockerHub(registry)) {\n registryVariants.push(\"https://index.docker.io/v1/\");\n registryVariants.push(\"https://index.docker.io/v1\");\n registryVariants.push(\"index.docker.io\");\n registryVariants.push(\"docker.io\");\n } else {\n const baseRegistry = registry\n .replace(/^https?:\\/\\//, \"\")\n .replace(/\\/v1\\/?$/, \"\")\n .replace(/\\/$/, \"\");\n registryVariants.push(`https://${baseRegistry}`);\n registryVariants.push(`https://${baseRegistry}/v1/`);\n registryVariants.push(baseRegistry);\n }\n\n for (const variant of registryVariants) {\n try {\n const output = execSync(`echo \"${variant}\" | ${helper} get`, {\n encoding: \"utf-8\",\n });\n const creds = JSON.parse(output);\n if (creds.Username && creds.Secret) {\n return { username: creds.Username, password: creds.Secret };\n }\n } catch {\n continue;\n }\n }\n } catch {\n return undefined;\n }\n } catch {\n return undefined;\n }\n\n return undefined;\n}\n\nasync function getAvailableRegistries(): Promise<RegistryInfo[]> {\n const dockerConfigPath = path.join(os.homedir(), \".docker\", \"config.json\");\n\n if (!fs.existsSync(dockerConfigPath)) {\n return [];\n }\n\n try {\n const configContent = fs.readFileSync(dockerConfigPath, \"utf-8\");\n const config = JSON.parse(configContent);\n\n const auths = config.auths || {};\n const credsStore = config.credsStore;\n const gcrProjects = new Map<string, RegistryInfo>();\n const registries: RegistryInfo[] = [];\n\n for (const [registry, auth] of Object.entries(auths)) {\n const authData = auth as { username?: string; auth?: string };\n\n // Skip token entries (these are not actual registries)\n const hostname = extractHostname(registry);\n if (hostname.includes(\"access-token\") || hostname.includes(\"refresh-token\")) {\n continue;\n }\n\n let username = authData.username;\n let registryType = \"other\";\n let normalizedURL = registry;\n\n if (isDockerHub(registry)) {\n registryType = \"dockerhub\";\n normalizedURL = \"https://index.docker.io/v1/\";\n } else if (isGHCR(registry)) {\n registryType = \"ghcr\";\n normalizedURL = registry.replace(/^https?:\\/\\//, \"\").replace(/\\/v1\\/?$/, \"\");\n } else if (isGCR(registry)) {\n registryType = \"gcr\";\n normalizedURL = \"gcr.io\";\n }\n\n if (!username && credsStore) {\n const creds = await getCredentialsFromHelper(registry);\n if (creds) {\n username = creds.username;\n }\n }\n\n if (!username) {\n continue;\n }\n\n const info: RegistryInfo = {\n URL: normalizedURL,\n Username: username,\n Type: registryType,\n };\n\n if (registryType === \"gcr\") {\n if (!gcrProjects.has(username)) {\n gcrProjects.set(username, info);\n }\n continue;\n }\n\n registries.push(info);\n }\n\n for (const gcrInfo of Array.from(gcrProjects.values())) {\n registries.push(gcrInfo);\n }\n\n registries.sort((a, b) => {\n if (a.Type === \"dockerhub\") return -1;\n if (b.Type === \"dockerhub\") return 1;\n return a.Type.localeCompare(b.Type);\n });\n\n return registries;\n } catch {\n return [];\n }\n}\n\nfunction getDefaultAppName(): string {\n try {\n // Use INIT_CWD if available (set by npm/pnpm to original cwd)\n const cwd = process.env.INIT_CWD || process.cwd();\n return path.basename(cwd);\n } catch {\n return \"myapp\";\n }\n}\n\nfunction suggestImageReference(registry: RegistryInfo, imageName: string, tag: string): string {\n imageName = imageName.toLowerCase().replace(/_/g, \"-\");\n if (!tag) {\n tag = \"latest\";\n }\n\n switch (registry.Type) {\n case \"dockerhub\":\n return `${registry.Username}/${imageName}:${tag}`;\n case \"ghcr\":\n return `ghcr.io/${registry.Username}/${imageName}:${tag}`;\n case \"gcr\":\n return `gcr.io/${registry.Username}/${imageName}:${tag}`;\n default:\n let host = registry.URL;\n if (host.startsWith(\"https://\")) {\n host = host.substring(8);\n } else if (host.startsWith(\"http://\")) {\n host = host.substring(7);\n }\n host = host.replace(/\\/$/, \"\");\n return `${host}/${registry.Username}/${imageName}:${tag}`;\n }\n}\n\nfunction displayDetectedRegistries(registries: RegistryInfo[], appName: string): void {\n console.log(\"Detected authenticated registries:\");\n for (const reg of registries) {\n const suggestion = suggestImageReference(reg, appName, \"latest\");\n console.log(` ${reg.Type}: ${suggestion}`);\n }\n console.log();\n}\n\nfunction displayAuthenticationInstructions(): void {\n console.log(\"No authenticated registries detected.\");\n console.log(\"To authenticate:\");\n console.log(\" docker login <registry-url>\");\n console.log();\n}\n\nfunction displayRegistryExamples(appName: string): void {\n console.log(\"Examples:\");\n console.log(` docker.io/${appName.toLowerCase()}:latest`);\n console.log(` ghcr.io/username/${appName.toLowerCase()}:latest`);\n console.log(` gcr.io/project-id/${appName.toLowerCase()}:latest`);\n console.log();\n}\n\nasync function selectRegistryInteractive(\n registries: RegistryInfo[],\n imageName: string,\n tag: string,\n): Promise<string> {\n if (registries.length === 1) {\n const defaultRef = suggestImageReference(registries[0], imageName, tag);\n return input({\n message: \"Enter image reference:\",\n default: defaultRef,\n validate: (value) => {\n const result = validateImageReference(value);\n return result === true ? true : result;\n },\n });\n }\n\n const choices = registries.map((reg) => ({\n name: suggestImageReference(reg, imageName, tag),\n value: suggestImageReference(reg, imageName, tag),\n }));\n choices.push({ name: \"Enter custom image reference\", value: \"custom\" });\n\n const choice = await select({\n message: \"Select image destination:\",\n choices,\n });\n\n if (choice === \"custom\") {\n return input({\n message: \"Enter image reference:\",\n default: \"\",\n validate: (value) => {\n const result = validateImageReference(value);\n return result === true ? true : result;\n },\n });\n }\n\n return choice;\n}\n\n/**\n * Prompt for image reference\n */\nexport async function getImageReferenceInteractive(\n imageRef?: string,\n buildFromDockerfile: boolean = false,\n): Promise<string> {\n if (imageRef) {\n return imageRef;\n }\n\n const registries = await getAvailableRegistries();\n const appName = getDefaultAppName();\n\n if (buildFromDockerfile) {\n console.log(\"\\nšŸ“¦ Build & Push Configuration\");\n console.log(\"Your Docker image will be built and pushed to a registry\");\n console.log(\"so that Ecloud CLI can pull and run it in the TEE.\");\n console.log();\n\n if (registries.length > 0) {\n displayDetectedRegistries(registries, appName);\n return selectRegistryInteractive(registries, appName, \"latest\");\n }\n\n displayAuthenticationInstructions();\n } else {\n console.log(\"\\n🐳 Docker Image Selection\");\n console.log(\"Specify an existing Docker image from a registry to run in the TEE.\");\n console.log();\n }\n\n displayRegistryExamples(appName);\n\n const imageRefInput = await input({\n message: \"Enter Docker image reference:\",\n default: \"\",\n validate: (value) => {\n const result = validateImageReference(value);\n return result === true ? true : result;\n },\n });\n\n return imageRefInput;\n}\n\n// ==================== App Name Selection ====================\n\n/**\n * Get available app name interactively\n */\nasync function getAvailableAppNameInteractive(\n environment: string,\n imageRef: string,\n): Promise<string> {\n const baseName = extractAppNameFromImage(imageRef);\n const suggestedName = findAvailableName(environment, baseName);\n\n while (true) {\n console.log(\"\\nApp name selection:\");\n const name = await input({\n message: \"Enter app name:\",\n default: suggestedName,\n validate: (value: string) => {\n try {\n validateAppName(value);\n return true;\n } catch (err: any) {\n return err.message;\n }\n },\n });\n\n if (isAppNameAvailable(environment, name)) {\n return name;\n }\n\n console.log(`App name '${name}' is already taken.`);\n const newSuggested = findAvailableName(environment, name);\n console.log(`Suggested alternative: ${newSuggested}`);\n }\n}\n\n/**\n * Prompt for app name\n */\nexport async function getOrPromptAppName(\n appName: string | undefined,\n environment: string,\n imageRef: string,\n): Promise<string> {\n if (appName) {\n validateAppName(appName);\n if (isAppNameAvailable(environment, appName)) {\n return appName;\n }\n console.log(`Warning: App name '${appName}' is already taken.`);\n return getAvailableAppNameInteractive(environment, imageRef);\n }\n\n return getAvailableAppNameInteractive(environment, imageRef);\n}\n\n// ==================== Environment File Selection ====================\n\n/**\n * Prompt for environment file\n */\nexport async function getEnvFileInteractive(envFilePath?: string): Promise<string> {\n if (envFilePath && fs.existsSync(envFilePath)) {\n return envFilePath;\n }\n\n if (fs.existsSync(\".env\")) {\n return \".env\";\n }\n\n console.log(\"\\nEnvironment file not found.\");\n console.log(\"Environment files contain variables like RPC_URL, etc.\");\n\n const choice = await select({\n message: \"Choose an option:\",\n choices: [\n { name: \"Enter path to existing env file\", value: \"enter\" },\n { name: \"Continue without env file\", value: \"continue\" },\n ],\n });\n\n switch (choice) {\n case \"enter\":\n const envFile = await input({\n message: \"Enter environment file path:\",\n default: \"\",\n validate: (value) => {\n const result = validateFilePath(value);\n return result === true ? true : result;\n },\n });\n return envFile;\n case \"continue\":\n return \"\";\n default:\n throw new Error(`Unexpected choice: ${choice}`);\n }\n}\n\n// ==================== Instance Type Selection ====================\n\n/**\n * Prompt for instance type\n */\nexport async function getInstanceTypeInteractive(\n instanceType: string | undefined,\n defaultSKU: string,\n availableTypes: Array<{ sku: string; description: string }>,\n): Promise<string> {\n if (instanceType) {\n // Validate provided instance type\n const valid = availableTypes.find((t) => t.sku === instanceType);\n if (valid) {\n return instanceType;\n }\n const validSKUs = availableTypes.map((t) => t.sku).join(\", \");\n throw new Error(`Invalid instance-type: ${instanceType} (must be one of: ${validSKUs})`);\n }\n\n const isCurrentType = defaultSKU !== \"\";\n if (defaultSKU === \"\" && availableTypes.length > 0) {\n defaultSKU = availableTypes[0].sku;\n }\n\n if (isCurrentType && defaultSKU) {\n console.log(`\\nSelect instance type (current: ${defaultSKU}):`);\n } else {\n console.log(\"\\nSelect instance type:\");\n }\n\n const choices = availableTypes.map((it) => {\n let name = `${it.sku} - ${it.description}`;\n if (it.sku === defaultSKU) {\n name += isCurrentType ? \" (current)\" : \" (default)\";\n }\n return { name, value: it.sku };\n });\n\n const choice = await select({\n message: \"Choose instance:\",\n choices,\n });\n\n return choice;\n}\n\n// ==================== Log Visibility Selection ====================\n\nexport type LogVisibility = \"public\" | \"private\" | \"off\";\n\n/**\n * Prompt for log settings\n */\nexport async function getLogSettingsInteractive(\n logVisibility?: LogVisibility,\n): Promise<{ logRedirect: string; publicLogs: boolean }> {\n if (logVisibility) {\n switch (logVisibility) {\n case \"public\":\n return { logRedirect: \"always\", publicLogs: true };\n case \"private\":\n return { logRedirect: \"always\", publicLogs: false };\n case \"off\":\n return { logRedirect: \"\", publicLogs: false };\n default:\n throw new Error(\n `Invalid log-visibility: ${logVisibility} (must be public, private, or off)`,\n );\n }\n }\n\n const choice = await select({\n message: \"Do you want to view your app's logs?\",\n choices: [\n { name: \"Yes, but only viewable by app and platform admins\", value: \"private\" },\n { name: \"Yes, publicly viewable by anyone\", value: \"public\" },\n { name: \"No, disable logs entirely\", value: \"off\" },\n ],\n });\n\n switch (choice) {\n case \"private\":\n return { logRedirect: \"always\", publicLogs: false };\n case \"public\":\n return { logRedirect: \"always\", publicLogs: true };\n case \"off\":\n return { logRedirect: \"\", publicLogs: false };\n default:\n throw new Error(`Unexpected choice: ${choice}`);\n }\n}\n\n// ==================== App ID Selection ====================\n\n// Contract app status constants\nexport const ContractAppStatusStarted = 1;\nexport const ContractAppStatusStopped = 2;\nexport const ContractAppStatusTerminated = 3;\nexport const ContractAppStatusSuspended = 4;\n\nexport function getContractStatusString(status: number): string {\n switch (status) {\n case ContractAppStatusStarted:\n return \"Started\";\n case ContractAppStatusStopped:\n return \"Stopped\";\n case ContractAppStatusTerminated:\n return \"Terminated\";\n case ContractAppStatusSuspended:\n return \"Suspended\";\n default:\n return \"Unknown\";\n }\n}\n\nfunction getStatusPriority(status: number, isExited: boolean): number {\n if (isExited) {\n return 1;\n }\n switch (status) {\n case ContractAppStatusStarted:\n return 0;\n case ContractAppStatusStopped:\n return 2;\n case ContractAppStatusTerminated:\n return 3;\n default:\n return 4;\n }\n}\n\n/**\n * Get sort priority for status string (lower = higher priority, shown first)\n * Used for sorting apps in list and selection displays\n */\nexport function getStatusSortPriority(status: string): number {\n switch (status.toLowerCase()) {\n case \"running\":\n case \"started\":\n return 0; // Running apps first\n case \"deploying\":\n case \"upgrading\":\n case \"resuming\":\n return 1; // In-progress operations second\n case \"stopped\":\n case \"stopping\":\n return 2; // Stopped third\n case \"suspended\":\n return 3; // Suspended fourth\n case \"failed\":\n return 4; // Failed fifth\n case \"terminated\":\n return 5; // Terminated last\n default:\n return 6;\n }\n}\n\nfunction formatAppDisplay(environmentName: string, appID: Address, profileName: string): string {\n if (profileName) {\n return `${profileName} (${environmentName}:${appID})`;\n }\n return `${environmentName}:${appID}`;\n}\n\nexport interface GetAppIDOptions {\n appID?: string | Address;\n environment: string;\n privateKey?: string;\n rpcUrl?: string;\n action?: string;\n}\n\n/**\n * Prompt for app ID (supports app name or address)\n */\nexport async function getOrPromptAppID(\n appIDOrOptions: string | Address | GetAppIDOptions | undefined,\n environment?: string,\n): Promise<Address> {\n let options: GetAppIDOptions;\n if (environment !== undefined) {\n options = {\n appID: appIDOrOptions as string | Address | undefined,\n environment: environment,\n };\n } else if (\n appIDOrOptions &&\n typeof appIDOrOptions === \"object\" &&\n \"environment\" in appIDOrOptions\n ) {\n options = appIDOrOptions as GetAppIDOptions;\n } else {\n options = {\n appID: appIDOrOptions as string | Address | undefined,\n environment: \"sepolia\",\n };\n }\n\n if (options.appID) {\n const normalized =\n typeof options.appID === \"string\" ? addHexPrefix(options.appID) : options.appID;\n\n if (isAddress(normalized)) {\n return normalized as Address;\n }\n\n // Check profile cache first (remote profile names)\n const profileCache = getProfileCache(options.environment);\n if (profileCache) {\n const searchName = (options.appID as string).toLowerCase();\n for (const [appId, name] of Object.entries(profileCache)) {\n if (name.toLowerCase() === searchName) {\n return appId as Address;\n }\n }\n }\n\n // Fall back to local registry\n const apps = listApps(options.environment);\n const foundAppID = apps[options.appID as string];\n if (foundAppID) {\n return addHexPrefix(foundAppID) as Address;\n }\n\n throw new Error(\n `App name '${options.appID}' not found in environment '${options.environment}'`,\n );\n }\n\n return getAppIDInteractive(options);\n}\n\nasync function getAppIDInteractive(options: GetAppIDOptions): Promise<Address> {\n const action = options.action || \"view\";\n const environment = options.environment || \"sepolia\";\n const environmentConfig = getEnvironmentConfig(environment);\n\n if (!options.privateKey || !options.rpcUrl) {\n return getAppIDInteractiveFromRegistry(environment, action);\n }\n\n console.log(`\\nSelect an app to ${action}:\\n`);\n\n const privateKeyHex = addHexPrefix(options.privateKey);\n const account = privateKeyToAccount(privateKeyHex);\n const developerAddr = account.address;\n\n const { apps, appConfigs } = await getAllAppsByDeveloper(\n options.rpcUrl,\n environmentConfig,\n developerAddr,\n );\n\n if (apps.length === 0) {\n throw new Error(\"no apps found for your address\");\n }\n\n // Build profile names from cache, API, and local registry\n const profileNames: Record<string, string> = {};\n\n // Load from profile cache first (remote profiles take priority)\n let cachedProfiles = getProfileCache(environment);\n\n // If cache is empty/expired, fetch fresh profile names from API\n if (!cachedProfiles) {\n try {\n const userApiClient = new UserApiClient(\n environmentConfig,\n options.privateKey,\n options.rpcUrl,\n getClientId(),\n );\n const appInfos = await getAppInfosChunked(userApiClient, apps);\n\n // Build and cache profile names\n const freshProfiles: Record<string, string> = {};\n for (const info of appInfos) {\n if (info.profile?.name) {\n const normalizedId = String(info.address).toLowerCase();\n freshProfiles[normalizedId] = info.profile.name;\n }\n }\n\n // Save to cache for future use\n setProfileCache(environment, freshProfiles);\n cachedProfiles = freshProfiles;\n } catch {\n // On error, continue without profile names\n cachedProfiles = {};\n }\n }\n\n // Add cached profiles to profileNames\n for (const [appId, name] of Object.entries(cachedProfiles)) {\n profileNames[appId.toLowerCase()] = name;\n }\n\n // Also include local registry names (for apps without remote profiles)\n const localApps = listApps(environment);\n for (const [name, appID] of Object.entries(localApps)) {\n const normalizedID = String(appID).toLowerCase();\n if (!profileNames[normalizedID]) {\n profileNames[normalizedID] = name;\n }\n }\n\n const isEligible = (status: number): boolean => {\n switch (action) {\n case \"view\":\n case \"view info for\":\n case \"set profile for\":\n return true;\n case \"start\":\n return status === ContractAppStatusStopped || status === ContractAppStatusSuspended;\n case \"stop\":\n return status === ContractAppStatusStarted;\n default:\n return status !== ContractAppStatusTerminated && status !== ContractAppStatusSuspended;\n }\n };\n\n interface AppItem {\n addr: Address;\n display: string;\n status: number;\n index: number;\n }\n\n const appItems: AppItem[] = [];\n for (let i = 0; i < apps.length; i++) {\n const appAddr = apps[i];\n const config = appConfigs[i];\n const status = config.status;\n\n if (!isEligible(status)) {\n continue;\n }\n\n const statusStr = getContractStatusString(status);\n const profileName = profileNames[String(appAddr).toLowerCase()] || \"\";\n const displayName = formatAppDisplay(environmentConfig.name, appAddr, profileName);\n\n appItems.push({\n addr: appAddr,\n display: `${displayName} - ${statusStr}`,\n status,\n index: i,\n });\n }\n\n appItems.sort((a, b) => {\n const aPriority = getStatusPriority(a.status, false);\n const bPriority = getStatusPriority(b.status, false);\n\n if (aPriority !== bPriority) {\n return aPriority - bPriority;\n }\n\n return b.index - a.index;\n });\n\n if (appItems.length === 0) {\n switch (action) {\n case \"start\":\n throw new Error(\"no startable apps found - only Stopped apps can be started\");\n case \"stop\":\n throw new Error(\"no running apps found - only Running apps can be stopped\");\n default:\n throw new Error(\"no active apps found\");\n }\n }\n\n const choices = appItems.map((item) => ({\n name: item.display,\n value: item.addr,\n }));\n\n const selected = await select({\n message: \"Select app:\",\n choices,\n });\n\n return selected as Address;\n}\n\nasync function getAppIDInteractiveFromRegistry(\n environment: string,\n action: string,\n): Promise<Address> {\n // Build combined app list from profile cache and local registry\n const allApps: Record<string, string> = {}; // name -> appId\n\n // Add from profile cache (remote profiles)\n const cachedProfiles = getProfileCache(environment);\n if (cachedProfiles) {\n for (const [appId, name] of Object.entries(cachedProfiles)) {\n allApps[name] = appId;\n }\n }\n\n // Add from local registry (may override or add new entries)\n const localApps = listApps(environment);\n for (const [name, appId] of Object.entries(localApps)) {\n if (!allApps[name]) {\n allApps[name] = appId;\n }\n }\n\n if (Object.keys(allApps).length === 0) {\n console.log(\"\\nNo apps found in registry.\");\n console.log(\"You can enter an app ID (address) or app name.\");\n console.log();\n\n const appIDInput = await input({\n message: \"Enter app ID or name:\",\n default: \"\",\n validate: (value: string) => {\n if (!value) {\n return \"App ID or name cannot be empty\";\n }\n const normalized = addHexPrefix(value);\n if (isAddress(normalized)) {\n return true;\n }\n return \"Invalid app ID address\";\n },\n });\n\n const normalized = addHexPrefix(appIDInput);\n if (isAddress(normalized)) {\n return normalized as Address;\n }\n throw new Error(`Invalid app ID address: ${appIDInput}`);\n }\n\n const choices = Object.entries(allApps).map(([name, appID]) => {\n const displayName = `${name} (${appID})`;\n return { name: displayName, value: appID };\n });\n\n choices.push({ name: \"Enter custom app ID or name\", value: \"custom\" });\n\n console.log(`\\nSelect an app to ${action}:`);\n\n const selected = await select({\n message: \"Choose app:\",\n choices,\n });\n\n if (selected === \"custom\") {\n const appIDInput = await input({\n message: \"Enter app ID or name:\",\n default: \"\",\n validate: (value: string) => {\n if (!value) {\n return \"App ID or name cannot be empty\";\n }\n const normalized = addHexPrefix(value);\n if (isAddress(normalized)) {\n return true;\n }\n if (allApps[value]) {\n return true;\n }\n return \"Invalid app ID or name not found\";\n },\n });\n\n const normalized = addHexPrefix(appIDInput);\n if (isAddress(normalized)) {\n return normalized as Address;\n }\n const foundAppID = allApps[appIDInput];\n if (foundAppID) {\n return addHexPrefix(foundAppID) as Address;\n }\n throw new Error(`Failed to resolve app ID from input: ${appIDInput}`);\n }\n\n return addHexPrefix(selected) as Address;\n}\n\n// ==================== Resource Usage Monitoring Selection ====================\n\nexport type ResourceUsageMonitoring = \"enable\" | \"disable\";\n\n/**\n * Prompt for resource usage monitoring settings\n */\nexport async function getResourceUsageMonitoringInteractive(\n resourceUsageMonitoring?: ResourceUsageMonitoring,\n): Promise<ResourceUsageMonitoring> {\n if (resourceUsageMonitoring) {\n switch (resourceUsageMonitoring) {\n case \"enable\":\n case \"disable\":\n return resourceUsageMonitoring;\n default:\n throw new Error(\n `Invalid resource-usage-monitoring: ${resourceUsageMonitoring} (must be enable or disable)`,\n );\n }\n }\n\n const choice = await select({\n message: \"Show resource usage (CPU/memory) for your app?\",\n choices: [\n { name: \"Yes, enable resource usage monitoring\", value: \"enable\" },\n { name: \"No, disable resource usage monitoring\", value: \"disable\" },\n ],\n });\n\n return choice as ResourceUsageMonitoring;\n}\n\n// ==================== Confirmation ====================\n\n/**\n * Confirm prompts the user to confirm an action with a yes/no question.\n */\nexport async function confirm(prompt: string): Promise<boolean> {\n return confirmWithDefault(prompt, false);\n}\n\n/**\n * ConfirmWithDefault prompts the user to confirm an action with a yes/no question and a default value.\n */\nexport async function confirmWithDefault(\n prompt: string,\n defaultValue: boolean = false,\n): Promise<boolean> {\n return await inquirerConfirm({\n message: prompt,\n default: defaultValue,\n });\n}\n\n// ==================== Private Key ====================\n\n/**\n * Get private key - first tries keyring, then prompts interactively\n */\nexport async function getPrivateKeyInteractive(privateKey?: string): Promise<string> {\n // If provided directly, validate and return\n if (privateKey) {\n if (!validatePrivateKeyFormat(privateKey)) {\n throw new Error(\"Invalid private key format\");\n }\n return privateKey;\n }\n\n // Try to get from keyring using SDK's resolver\n const { getPrivateKeyWithSource } = await import(\"@layr-labs/ecloud-sdk\");\n const result = await getPrivateKeyWithSource({ privateKey: undefined });\n\n if (result) {\n return result.key;\n }\n\n // No key in keyring, prompt user\n const key = await password({\n message: \"Enter private key:\",\n mask: true,\n validate: (value: string) => {\n if (!value.trim()) {\n return \"Private key is required\";\n }\n if (!validatePrivateKeyFormat(value)) {\n return \"Invalid private key format (must be 64 hex characters, optionally prefixed with 0x)\";\n }\n return true;\n },\n });\n\n return key.trim();\n}\n\n// ==================== Environment Selection ====================\n\n/**\n * Prompt for environment selection\n */\nexport async function getEnvironmentInteractive(environment?: string): Promise<string> {\n if (environment) {\n try {\n getEnvironmentConfig(environment);\n if (!isEnvironmentAvailable(environment)) {\n throw new Error(`Environment ${environment} is not available in this build`);\n }\n return environment;\n } catch {\n // Invalid environment, continue to prompt\n }\n }\n\n const availableEnvs = getAvailableEnvironments();\n\n let defaultEnv: string | undefined;\n const configDefaultEnv = getDefaultEnvironment();\n if (configDefaultEnv && availableEnvs.includes(configDefaultEnv)) {\n try {\n getEnvironmentConfig(configDefaultEnv);\n defaultEnv = configDefaultEnv;\n } catch {\n // Default env is invalid, ignore it\n }\n }\n\n const choices = [];\n if (availableEnvs.includes(\"sepolia\")) {\n choices.push({ name: \"sepolia - Ethereum Sepolia testnet\", value: \"sepolia\" });\n }\n if (availableEnvs.includes(\"sepolia-dev\")) {\n choices.push({ name: \"sepolia-dev - Ethereum Sepolia testnet (dev)\", value: \"sepolia-dev\" });\n }\n if (availableEnvs.includes(\"mainnet-alpha\")) {\n choices.push({\n name: \"mainnet-alpha - Ethereum mainnet (āš ļø uses real funds)\",\n value: \"mainnet-alpha\",\n });\n }\n\n if (choices.length === 0) {\n throw new Error(\"No environments available in this build\");\n }\n\n const env = await select({\n message: \"Select environment:\",\n choices,\n default: defaultEnv,\n });\n\n return env;\n}\n\n// ==================== Template Selection ====================\n\n/**\n * Prompt for project name\n */\nexport async function promptProjectName(): Promise<string> {\n return input({ message: \"Enter project name:\" });\n}\n\n/**\n * Prompt for language selection\n */\nexport async function promptLanguage(): Promise<string> {\n return select({\n message: \"Select language:\",\n choices: PRIMARY_LANGUAGES,\n });\n}\n\n/**\n * Select template interactively\n */\nexport async function selectTemplateInteractive(language: string): Promise<string> {\n const catalog = await fetchTemplateCatalog();\n const categoryDescriptions = getCategoryDescriptions(catalog, language);\n\n if (Object.keys(categoryDescriptions).length === 0) {\n throw new Error(`No templates found for language ${language}`);\n }\n\n const categories = Object.keys(categoryDescriptions).sort();\n\n const options = categories.map((category) => {\n const description = categoryDescriptions[category];\n if (description) {\n return { name: `${category}: ${description}`, value: category };\n }\n return { name: category, value: category };\n });\n\n const selected = await select({\n message: \"Select template:\",\n choices: options,\n });\n\n return selected;\n}\n\n// ==================== App Profile ====================\n\nconst MAX_DESCRIPTION_LENGTH = 1000;\nconst MAX_IMAGE_SIZE = 4 * 1024 * 1024; // 4MB\nconst VALID_IMAGE_EXTENSIONS = [\".jpg\", \".jpeg\", \".png\"];\nconst VALID_X_HOSTS = [\"twitter.com\", \"www.twitter.com\", \"x.com\", \"www.x.com\"];\n\nexport function validateURL(rawURL: string): string | undefined {\n if (!rawURL.trim()) {\n return \"URL cannot be empty\";\n }\n\n try {\n const url = new URL(rawURL);\n if (url.protocol !== \"http:\" && url.protocol !== \"https:\") {\n return \"URL scheme must be http or https\";\n }\n } catch {\n return \"Invalid URL format\";\n }\n\n return undefined;\n}\n\nexport function validateXURL(rawURL: string): string | undefined {\n const urlErr = validateURL(rawURL);\n if (urlErr) {\n return urlErr;\n }\n\n try {\n const url = new URL(rawURL);\n const host = url.hostname.toLowerCase();\n\n if (!VALID_X_HOSTS.includes(host)) {\n return \"URL must be a valid X/Twitter URL (x.com or twitter.com)\";\n }\n\n if (!url.pathname || url.pathname === \"/\") {\n return \"X URL must include a username or profile path\";\n }\n } catch {\n return \"Invalid X URL format\";\n }\n\n return undefined;\n}\n\nexport function validateDescription(description: string): string | undefined {\n if (!description.trim()) {\n return \"Description cannot be empty\";\n }\n\n if (description.length > MAX_DESCRIPTION_LENGTH) {\n return `Description cannot exceed ${MAX_DESCRIPTION_LENGTH} characters`;\n }\n\n return undefined;\n}\n\nexport function validateImagePath(filePath: string): string | undefined {\n const cleanedPath = filePath.trim().replace(/^[\"']|[\"']$/g, \"\");\n\n if (!cleanedPath) {\n return \"Image path cannot be empty\";\n }\n\n if (!fs.existsSync(cleanedPath)) {\n return `Image file not found: ${cleanedPath}`;\n }\n\n const stats = fs.statSync(cleanedPath);\n if (stats.isDirectory()) {\n return \"Path is a directory, not a file\";\n }\n\n if (stats.size > MAX_IMAGE_SIZE) {\n const sizeMB = (stats.size / (1024 * 1024)).toFixed(2);\n return `Image file size (${sizeMB} MB) exceeds maximum allowed size of 4 MB`;\n }\n\n const ext = path.extname(cleanedPath).toLowerCase();\n if (!VALID_IMAGE_EXTENSIONS.includes(ext)) {\n return \"Image must be JPG or PNG format\";\n }\n\n return undefined;\n}\n\n/**\n * Validate an app profile object\n * Returns an error message if validation fails, undefined if valid\n */\nexport function validateAppProfile(profile: {\n name: string;\n website?: string;\n description?: string;\n xURL?: string;\n imagePath?: string;\n}): string | undefined {\n // Name is required\n if (!profile.name || !profile.name.trim()) {\n return \"Profile name is required\";\n }\n\n try {\n validateAppName(profile.name);\n } catch (err: any) {\n return `Invalid profile name: ${err.message}`;\n }\n\n // Validate optional fields if provided\n if (profile.website) {\n const websiteErr = validateURL(profile.website);\n if (websiteErr) {\n return `Invalid website: ${websiteErr}`;\n }\n }\n\n if (profile.description) {\n const descErr = validateDescription(profile.description);\n if (descErr) {\n return `Invalid description: ${descErr}`;\n }\n }\n\n if (profile.xURL) {\n const xURLErr = validateXURL(profile.xURL);\n if (xURLErr) {\n return `Invalid X URL: ${xURLErr}`;\n }\n }\n\n if (profile.imagePath) {\n const imageErr = validateImagePath(profile.imagePath);\n if (imageErr) {\n return `Invalid image: ${imageErr}`;\n }\n }\n\n return undefined;\n}\n\n/**\n * Collect app profile information interactively\n */\nexport async function getAppProfileInteractive(\n defaultName: string = \"\",\n allowRetry: boolean = true,\n): Promise<AppProfile | undefined> {\n while (true) {\n const name = await getAppNameForProfile(defaultName);\n const website = await getAppWebsiteInteractive();\n const description = await getAppDescriptionInteractive();\n const xURL = await getAppXURLInteractive();\n const imagePath = await getAppImageInteractive();\n\n const profile: AppProfile = {\n name,\n website,\n description,\n xURL,\n imagePath,\n };\n\n console.log(\"\\n\" + formatProfileForDisplay(profile));\n\n const confirmed = await inquirerConfirm({\n message: \"Continue with this profile?\",\n default: true,\n });\n\n if (confirmed) {\n return profile;\n }\n\n if (!allowRetry) {\n throw new Error(\"Profile confirmation cancelled\");\n }\n\n const retry = await inquirerConfirm({\n message: \"Would you like to re-enter the information?\",\n default: true,\n });\n\n if (!retry) {\n return undefined;\n }\n\n defaultName = name;\n }\n}\n\nasync function getAppNameForProfile(defaultName: string): Promise<string> {\n if (defaultName) {\n validateAppName(defaultName);\n return defaultName;\n }\n\n return await input({\n message: \"App name:\",\n default: \"\",\n validate: (value: string) => {\n if (!value.trim()) {\n return \"Name is required\";\n }\n try {\n validateAppName(value);\n return true;\n } catch (err: any) {\n return err.message;\n }\n },\n });\n}\n\nasync function getAppWebsiteInteractive(): Promise<string | undefined> {\n const website = await input({\n message: \"Website URL (optional):\",\n default: \"\",\n validate: (value: string) => {\n if (!value.trim()) {\n return true;\n }\n const err = validateURL(value);\n return err ? err : true;\n },\n });\n\n if (!website.trim()) {\n return undefined;\n }\n\n return website;\n}\n\nasync function getAppDescriptionInteractive(): Promise<string | undefined> {\n const description = await input({\n message: \"Description (optional):\",\n default: \"\",\n validate: (value: string) => {\n if (!value.trim()) {\n return true;\n }\n const err = validateDescription(value);\n return err ? err : true;\n },\n });\n\n if (!description.trim()) {\n return undefined;\n }\n\n return description;\n}\n\nasync function getAppXURLInteractive(): Promise<string | undefined> {\n const xURL = await input({\n message: \"X (Twitter) URL (optional):\",\n default: \"\",\n validate: (value: string) => {\n if (!value.trim()) {\n return true;\n }\n const err = validateXURL(value);\n return err ? err : true;\n },\n });\n\n if (!xURL.trim()) {\n return undefined;\n }\n\n return xURL;\n}\n\nasync function getAppImageInteractive(): Promise<string | undefined> {\n const wantsImage = await inquirerConfirm({\n message: \"Would you like to upload an app icon/logo?\",\n default: false,\n });\n\n if (!wantsImage) {\n return undefined;\n }\n\n const imagePath = await input({\n message:\n \"Image path (drag & drop image file or enter path - JPG/PNG, max 4MB, square recommended):\",\n default: \"\",\n validate: (value: string) => {\n if (!value.trim()) {\n return true;\n }\n const err = validateImagePath(value);\n return err ? err : true;\n },\n });\n\n if (!imagePath.trim()) {\n return undefined;\n }\n\n return imagePath.trim().replace(/^[\"']|[\"']$/g, \"\");\n}\n\nfunction formatProfileForDisplay(profile: AppProfile): string {\n let output = \"\\nšŸ“‹ Profile Summary:\\n\";\n output += ` Name: ${profile.name}\\n`;\n if (profile.website) {\n output += ` Website: ${profile.website}\\n`;\n }\n if (profile.description) {\n output += ` Description: ${profile.description}\\n`;\n }\n if (profile.xURL) {\n output += ` X URL: ${profile.xURL}\\n`;\n }\n if (profile.imagePath) {\n output += ` Image: ${profile.imagePath}\\n`;\n }\n return output;\n}\n","/**\n * App Resolver - Centralized app name resolution with caching\n *\n * Resolution priority:\n * 1. Check if input is already a valid hex address\n * 2. Check profile cache (24h TTL)\n * 3. Fetch from remote API if cache miss/stale\n * 4. Fall back to local registry for legacy apps\n */\n\nimport { Address, isAddress } from \"viem\";\nimport { privateKeyToAccount } from \"viem/accounts\";\nimport {\n UserApiClient,\n getAllAppsByDeveloper,\n EnvironmentConfig,\n AppInfo,\n} from \"@layr-labs/ecloud-sdk\";\nimport { getProfileCache, setProfileCache, updateProfileCacheEntry } from \"./globalConfig\";\nimport {\n listApps as listLocalApps,\n getAppName as getLocalAppName,\n resolveAppIDFromRegistry,\n} from \"./appNames\";\nimport { getClientId } from \"./version\";\n\nconst CHUNK_SIZE = 10;\n\n/**\n * Fetch app infos in chunks (getInfos has a limit of 10 apps per request)\n * Fetches all chunks concurrently for better performance\n */\nexport async function getAppInfosChunked(\n userApiClient: UserApiClient,\n appIds: Address[],\n addressCount?: number,\n): Promise<AppInfo[]> {\n if (appIds.length === 0) {\n return [];\n }\n\n const chunks: Address[][] = [];\n for (let i = 0; i < appIds.length; i += CHUNK_SIZE) {\n chunks.push(appIds.slice(i, i + CHUNK_SIZE));\n }\n\n const chunkResults = await Promise.all(\n chunks.map((chunk) => userApiClient.getInfos(chunk, addressCount)),\n );\n\n return chunkResults.flat();\n}\n\n/**\n * AppResolver handles app name resolution with remote profile support and caching\n */\nexport class AppResolver {\n private profileNames: Record<string, string> = {}; // appId (lowercase) -> name\n private cacheInitialized = false;\n\n constructor(\n private readonly environment: string,\n private readonly environmentConfig: EnvironmentConfig,\n private readonly privateKey?: string,\n private readonly rpcUrl?: string,\n ) {}\n\n /**\n * Resolve app name or ID to a valid Address\n * @param appIDOrName - App ID (hex address) or app name\n * @returns Resolved app address\n * @throws Error if app cannot be resolved\n */\n async resolveAppID(appIDOrName: string): Promise<Address> {\n if (!appIDOrName) {\n throw new Error(\"App ID or name is required\");\n }\n\n // Normalize and check if it's already a valid address\n const normalized = appIDOrName.startsWith(\"0x\") ? appIDOrName : `0x${appIDOrName}`;\n if (isAddress(normalized)) {\n return normalized as Address;\n }\n\n // Ensure cache is initialized\n await this.ensureCacheInitialized();\n\n // Search profile names for a match (case-insensitive)\n const searchName = appIDOrName.toLowerCase();\n for (const [appId, name] of Object.entries(this.profileNames)) {\n if (name.toLowerCase() === searchName) {\n return appId as Address;\n }\n }\n\n // Fall back to local registry\n const localAppId = resolveAppIDFromRegistry(this.environment, appIDOrName);\n if (localAppId) {\n return localAppId as Address;\n }\n\n throw new Error(`App '${appIDOrName}' not found in environment '${this.environment}'`);\n }\n\n /**\n * Get app name from app ID\n * @param appID - App address\n * @returns Profile name if found, empty string otherwise\n */\n async getAppName(appID: string | Address): Promise<string> {\n const normalizedId = String(appID).toLowerCase();\n\n // Ensure cache is initialized\n await this.ensureCacheInitialized();\n\n // Check profile names first\n const profileName = this.profileNames[normalizedId];\n if (profileName) {\n return profileName;\n }\n\n // Fall back to local registry\n return getLocalAppName(this.environment, appID as string);\n }\n\n /**\n * Check if an app name is available (not used by any existing app)\n * @param name - Name to check\n * @returns true if available, false if taken\n */\n async isAppNameAvailable(name: string): Promise<boolean> {\n await this.ensureCacheInitialized();\n\n const searchName = name.toLowerCase();\n\n // Check profile names\n for (const profileName of Object.values(this.profileNames)) {\n if (profileName.toLowerCase() === searchName) {\n return false;\n }\n }\n\n // Check local registry\n const localApps = listLocalApps(this.environment);\n return !localApps[name];\n }\n\n /**\n * Find an available app name by appending numbers if needed\n * @param baseName - Base name to start with\n * @returns Available name (may have number suffix)\n */\n async findAvailableName(baseName: string): Promise<string> {\n // Check if base name is available\n if (await this.isAppNameAvailable(baseName)) {\n return baseName;\n }\n\n // Try with incrementing numbers\n for (let i = 2; i <= 100; i++) {\n const candidate = `${baseName}-${i}`;\n if (await this.isAppNameAvailable(candidate)) {\n return candidate;\n }\n }\n\n // Fallback to timestamp if somehow we have 100+ duplicates\n return `${baseName}-${Date.now()}`;\n }\n\n /**\n * Get all profile names (for display/listing purposes)\n * @returns Map of appId -> name\n */\n async getAllProfileNames(): Promise<Record<string, string>> {\n await this.ensureCacheInitialized();\n return { ...this.profileNames };\n }\n\n /**\n * Update cache with a new profile name (call after deploy or profile set)\n */\n updateCacheEntry(appId: string, profileName: string): void {\n const normalizedId = appId.toLowerCase();\n this.profileNames[normalizedId] = profileName;\n updateProfileCacheEntry(this.environment, appId, profileName);\n }\n\n /**\n * Ensure the profile cache is initialized\n * Loads from disk cache if valid, otherwise fetches from API\n */\n private async ensureCacheInitialized(): Promise<void> {\n if (this.cacheInitialized) {\n return;\n }\n\n // Try to load from disk cache first\n const cachedProfiles = getProfileCache(this.environment);\n if (cachedProfiles) {\n this.profileNames = cachedProfiles;\n this.cacheInitialized = true;\n return;\n }\n\n // Cache miss or expired - fetch from API\n await this.fetchProfilesFromAPI();\n this.cacheInitialized = true;\n }\n\n /**\n * Fetch profile names from the remote API and update cache\n */\n private async fetchProfilesFromAPI(): Promise<void> {\n // Need private key and rpcUrl for authenticated API calls\n if (!this.privateKey || !this.rpcUrl) {\n // Can't fetch from API without credentials - use empty cache\n this.profileNames = {};\n return;\n }\n\n try {\n // Get all apps for the current developer\n const account = privateKeyToAccount(this.privateKey as `0x${string}`);\n const { apps } = await getAllAppsByDeveloper(\n this.rpcUrl,\n this.environmentConfig,\n account.address,\n );\n\n if (apps.length === 0) {\n this.profileNames = {};\n setProfileCache(this.environment, {});\n return;\n }\n\n // Fetch info for all apps to get profile names\n const userApiClient = new UserApiClient(\n this.environmentConfig,\n this.privateKey,\n this.rpcUrl,\n getClientId(),\n );\n const appInfos = await getAppInfosChunked(userApiClient, apps);\n\n // Build profile names map\n const profiles: Record<string, string> = {};\n for (const info of appInfos) {\n if (info.profile?.name) {\n const normalizedId = String(info.address).toLowerCase();\n profiles[normalizedId] = info.profile.name;\n }\n }\n\n this.profileNames = profiles;\n setProfileCache(this.environment, profiles);\n } catch (error) {\n // On error, use empty cache - don't fail the command\n console.debug?.(\"Failed to fetch profiles from API:\", error);\n this.profileNames = {};\n }\n }\n}\n\n/**\n * Create an AppResolver instance\n * Convenience function for creating resolver with common parameters\n */\nexport function createAppResolver(\n environment: string,\n environmentConfig: EnvironmentConfig,\n privateKey?: string,\n rpcUrl?: string,\n): AppResolver {\n return new AppResolver(environment, environmentConfig, privateKey, rpcUrl);\n}\n","/**\n * Global configuration management\n *\n * Stores user-level configuration that persists across all CLI usage.\n * - $XDG_CONFIG_HOME/ecloud[BuildSuffix]/config.yaml (if XDG_CONFIG_HOME is set)\n * - Or ~/.config/ecloud[BuildSuffix]/config.yaml (fallback)\n *\n * Where BuildSuffix is:\n * - \"\" (empty) for production builds\n * - \"-dev\" for development builds\n */\n\nimport * as fs from \"fs\";\nimport * as path from \"path\";\nimport * as os from \"os\";\nimport { load as loadYaml, dump as dumpYaml } from \"js-yaml\";\nimport { getBuildType } from \"@layr-labs/ecloud-sdk\";\n\nconst GLOBAL_CONFIG_FILE = \"config.yaml\";\n\nexport interface ProfileCacheEntry {\n updated_at: number; // Unix timestamp in milliseconds\n profiles: { [appId: string]: string }; // appId -> profile name\n}\n\nexport interface GlobalConfig {\n first_run?: boolean;\n telemetry_enabled?: boolean;\n user_uuid?: string;\n default_environment?: string;\n last_version_check?: number;\n last_known_version?: string;\n profile_cache?: {\n [environment: string]: ProfileCacheEntry;\n };\n}\n\n// Profile cache TTL: 24 hours in milliseconds\nconst PROFILE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;\n\n/**\n * Get the XDG-compliant directory where global ecloud config should be stored\n */\nfunction getGlobalConfigDir(): string {\n // First check XDG_CONFIG_HOME\n const configHome = process.env.XDG_CONFIG_HOME;\n\n let baseDir: string;\n if (configHome && path.isAbsolute(configHome)) {\n baseDir = configHome;\n } else {\n // Fall back to ~/.config\n baseDir = path.join(os.homedir(), \".config\");\n }\n\n // Use environment-specific config directory\n const buildType = getBuildType();\n const buildSuffix = buildType === \"dev\" ? \"-dev\" : \"\";\n const configDirName = `ecloud${buildSuffix}`;\n\n return path.join(baseDir, configDirName);\n}\n\n/**\n * Get the full path to the global config file\n */\nfunction getGlobalConfigPath(): string {\n return path.join(getGlobalConfigDir(), GLOBAL_CONFIG_FILE);\n}\n\n/**\n * Load global configuration, creating defaults if needed\n */\nexport function loadGlobalConfig(): GlobalConfig {\n const configPath = getGlobalConfigPath();\n\n // If file doesn't exist, return defaults for first run\n if (!fs.existsSync(configPath)) {\n return {\n first_run: true,\n };\n }\n\n try {\n const content = fs.readFileSync(configPath, \"utf-8\");\n const config = loadYaml(content) as GlobalConfig;\n return config || { first_run: true };\n } catch {\n // If parsing fails, return defaults\n return {\n first_run: true,\n };\n }\n}\n\n/**\n * Save global configuration to disk\n */\nexport function saveGlobalConfig(config: GlobalConfig): void {\n const configPath = getGlobalConfigPath();\n\n // Ensure directory exists\n const configDir = path.dirname(configPath);\n fs.mkdirSync(configDir, { recursive: true, mode: 0o755 });\n\n // Write config file\n const content = dumpYaml(config, { lineWidth: -1 });\n fs.writeFileSync(configPath, content, { mode: 0o644 });\n}\n\n/**\n * Get the user's preferred deployment environment\n */\nexport function getDefaultEnvironment(): string | undefined {\n const config = loadGlobalConfig();\n return config.default_environment;\n}\n\n/**\n * Set the user's preferred deployment environment\n */\nexport function setDefaultEnvironment(environment: string): void {\n const config = loadGlobalConfig();\n config.default_environment = environment;\n config.first_run = false; // No longer first run after setting environment\n saveGlobalConfig(config);\n}\n\n/**\n * Check if this is the user's first time running the CLI\n */\nexport function isFirstRun(): boolean {\n const config = loadGlobalConfig();\n return config.first_run === true;\n}\n\n/**\n * Mark that the first run has been completed\n */\nexport function markFirstRunComplete(): void {\n const config = loadGlobalConfig();\n config.first_run = false;\n saveGlobalConfig(config);\n}\n\n/**\n * Get the global telemetry preference\n */\nexport function getGlobalTelemetryPreference(): boolean | undefined {\n const config = loadGlobalConfig();\n return config.telemetry_enabled;\n}\n\n/**\n * Set the global telemetry preference\n */\nexport function setGlobalTelemetryPreference(enabled: boolean): void {\n const config = loadGlobalConfig();\n config.telemetry_enabled = enabled;\n config.first_run = false; // No longer first run after setting preference\n saveGlobalConfig(config);\n}\n\n// ==================== Profile Cache Functions ====================\n\n/**\n * Get cached profile names for an environment\n * Returns null if cache is missing or expired (older than 24 hours)\n */\nexport function getProfileCache(environment: string): Record<string, string> | null {\n const config = loadGlobalConfig();\n const cacheEntry = config.profile_cache?.[environment];\n\n if (!cacheEntry) {\n return null;\n }\n\n // Check if cache is expired\n const now = Date.now();\n if (now - cacheEntry.updated_at > PROFILE_CACHE_TTL_MS) {\n return null;\n }\n\n return cacheEntry.profiles;\n}\n\n/**\n * Set cached profile names for an environment\n */\nexport function setProfileCache(environment: string, profiles: Record<string, string>): void {\n const config = loadGlobalConfig();\n\n if (!config.profile_cache) {\n config.profile_cache = {};\n }\n\n config.profile_cache[environment] = {\n updated_at: Date.now(),\n profiles,\n };\n\n saveGlobalConfig(config);\n}\n\n/**\n * Invalidate profile cache for a specific environment or all environments\n */\nexport function invalidateProfileCache(environment?: string): void {\n const config = loadGlobalConfig();\n\n if (!config.profile_cache) {\n return;\n }\n\n if (environment) {\n // Invalidate specific environment\n delete config.profile_cache[environment];\n } else {\n // Invalidate all environments\n config.profile_cache = {};\n }\n\n saveGlobalConfig(config);\n}\n\n/**\n * Update a single profile name in the cache\n * This is useful after deploy or profile set to update just one entry\n */\nexport function updateProfileCacheEntry(\n environment: string,\n appId: string,\n profileName: string,\n): void {\n const config = loadGlobalConfig();\n\n if (!config.profile_cache) {\n config.profile_cache = {};\n }\n\n if (!config.profile_cache[environment]) {\n config.profile_cache[environment] = {\n updated_at: Date.now(),\n profiles: {},\n };\n }\n\n // Normalize appId to lowercase for consistent lookups\n const normalizedAppId = appId.toLowerCase();\n config.profile_cache[environment].profiles[normalizedAppId] = profileName;\n config.profile_cache[environment].updated_at = Date.now();\n\n saveGlobalConfig(config);\n}\n","/**\n * App name registry\n *\n * - Stores in ~/.eigenx/apps/{environment}.yaml\n * - Uses YAML format with version and apps structure\n * - Format: {version: \"1.0.0\", apps: {name: {app_id: \"...\", created_at: ..., updated_at: ...}}}\n */\n\nimport * as fs from \"fs\";\nimport * as path from \"path\";\nimport * as os from \"os\";\nimport { load as loadYaml, dump as dumpYaml } from \"js-yaml\";\n\nconst CONFIG_DIR = path.join(os.homedir(), \".eigenx\");\nconst APPS_DIR = path.join(CONFIG_DIR, \"apps\");\nconst APP_REGISTRY_VERSION = \"1.0.0\";\n\ninterface AppRegistry {\n version: string;\n apps: {\n [name: string]: {\n app_id: string;\n created_at?: string;\n updated_at?: string;\n };\n };\n}\n\n/**\n * Get the path to the app registry file for an environment\n */\nfunction getAppRegistryPath(environment: string): string {\n return path.join(APPS_DIR, `${environment}.yaml`);\n}\n\n/**\n * Load app registry from disk\n */\nfunction loadAppRegistry(environment: string): AppRegistry {\n const filePath = getAppRegistryPath(environment);\n\n // If file doesn't exist, return empty registry\n if (!fs.existsSync(filePath)) {\n return {\n version: APP_REGISTRY_VERSION,\n apps: {},\n };\n }\n\n try {\n const content = fs.readFileSync(filePath, \"utf-8\");\n const registry = loadYaml(content) as AppRegistry;\n\n // Initialize apps map if nil\n if (!registry.apps) {\n registry.apps = {};\n }\n\n return registry;\n } catch {\n // If parsing fails, return empty registry\n return {\n version: APP_REGISTRY_VERSION,\n apps: {},\n };\n }\n}\n\n/**\n * Save app registry to disk\n */\nfunction saveAppRegistry(environment: string, registry: AppRegistry): void {\n const filePath = getAppRegistryPath(environment);\n\n // Ensure directory exists\n if (!fs.existsSync(APPS_DIR)) {\n fs.mkdirSync(APPS_DIR, { recursive: true });\n }\n\n // Write YAML file\n const yamlContent = dumpYaml(registry, {\n lineWidth: -1, // No line wrapping\n quotingType: '\"',\n });\n fs.writeFileSync(filePath, yamlContent, { mode: 0o644 });\n}\n\n/**\n * Resolve app ID or name to app ID (for CLI use)\n */\nexport function resolveAppIDFromRegistry(environment: string, appIDOrName: string): string | null {\n // First check if it's already a valid hex address\n if (/^0x[a-fA-F0-9]{40}$/.test(appIDOrName)) {\n return appIDOrName;\n }\n\n // Try to load from registry\n const registry = loadAppRegistry(environment);\n\n // Look up by name\n const app = registry.apps[appIDOrName];\n if (app) {\n return app.app_id;\n }\n\n return null;\n}\n\n/**\n * Set app name for an environment\n */\nexport async function setAppName(\n environment: string,\n appIDOrName: string,\n newName: string,\n): Promise<void> {\n const registry = loadAppRegistry(environment);\n\n // Resolve the target app ID\n let targetAppID: string | null = resolveAppIDFromRegistry(environment, appIDOrName);\n if (!targetAppID) {\n // If can't resolve, check if it's a valid app ID\n if (/^0x[a-fA-F0-9]{40}$/.test(appIDOrName)) {\n targetAppID = appIDOrName;\n } else {\n throw new Error(`invalid app ID or name: ${appIDOrName}`);\n }\n }\n\n // Normalize app ID for comparison\n const targetAppIDLower = targetAppID.toLowerCase();\n\n // Find and remove any existing names for this app ID\n for (const [name, app] of Object.entries(registry.apps)) {\n if (app?.app_id && String(app.app_id).toLowerCase() === targetAppIDLower) {\n delete registry.apps[name];\n }\n }\n\n // If newName is empty, we're just removing the name\n if (newName === \"\") {\n saveAppRegistry(environment, registry);\n return;\n }\n\n // Add the new name entry\n const now = new Date().toISOString();\n registry.apps[newName] = {\n app_id: targetAppID,\n created_at: now,\n updated_at: now,\n };\n\n saveAppRegistry(environment, registry);\n}\n\n/**\n * Get app name for an environment\n */\nexport function getAppName(environment: string, appID: string): string {\n const registry = loadAppRegistry(environment);\n const normalizedAppID = appID.toLowerCase();\n\n // Search for the app ID in the registry\n for (const [name, app] of Object.entries(registry.apps)) {\n if (app?.app_id && String(app.app_id).toLowerCase() === normalizedAppID) {\n return name;\n }\n }\n\n return \"\";\n}\n\n/**\n * List all apps for an environment\n */\nexport function listApps(environment: string): Record<string, string> {\n const registry = loadAppRegistry(environment);\n const result: Record<string, string> = {};\n\n // Convert registry format (name -> app_id) to result format (name -> appID)\n for (const [name, app] of Object.entries(registry.apps)) {\n if (app?.app_id) {\n result[name] = String(app.app_id);\n }\n }\n\n return result;\n}\n\n/**\n * Check if an app name is available in the given environment\n */\nexport function isAppNameAvailable(environment: string, name: string): boolean {\n const apps = listApps(environment);\n return !apps[name];\n}\n\n/**\n * Find an available app name by appending numbers if needed\n */\nexport function findAvailableName(environment: string, baseName: string): string {\n const apps = listApps(environment);\n\n // Check if base name is available\n if (!apps[baseName]) {\n return baseName;\n }\n\n // Try with incrementing numbers\n for (let i = 2; i <= 100; i++) {\n const candidate = `${baseName}-${i}`;\n if (!apps[candidate]) {\n return candidate;\n }\n }\n\n // Fallback to timestamp if somehow we have 100+ duplicates\n return `${baseName}-${Date.now()}`;\n}\n"],"mappings":"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","names":["Flags","getEnvironmentConfig","fs","path","os","isAddress","privateKeyToAccount","getAllAppsByDeveloper","UserApiClient","fs","path","os","loadYaml","dumpYaml","getPrivateKeyWithSource","Flags"]}