diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5455f443..448e8438 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,3 +1,5 @@ +fail_fast: true + repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.12.10 @@ -10,21 +12,84 @@ repos: files: ^python/ - repo: local hooks: + # ── Go ────────────────────────────────────────────────────────────── - id: gofmt name: gofmt entry: bash -c 'cd go && files=$(gofmt -l .); [ -z "$files" ] || gofmt -l -w $files' language: system files: ^go/.*\.go$ pass_filenames: false + priority: 10 - id: govet name: go vet entry: bash -c 'cd go && go vet ./...' language: system files: ^go/.*\.go$ pass_filenames: false + priority: 20 - id: gotest name: go test entry: bash -c 'cd go && go test ./...' language: system files: ^go/.*\.go$ pass_filenames: false + require_serial: true + priority: 30 + + # ── JS SDK (js/) ──────────────────────────────────────────────────── + # Mirrors release-npm.yml: lint → type-check → test (build is covered by CI). + - id: biome-js + name: Biome (JS SDK) + entry: bash -c 'cd js && bun run lint' + language: system + files: ^js/ + types_or: [javascript, ts, tsx, jsx] + pass_filenames: false + priority: 10 + - id: typecheck-js + name: TypeScript (JS SDK) + entry: bash -c 'cd js && bun run type-check' + language: system + files: ^js/ + types_or: [javascript, ts, tsx, jsx] + pass_filenames: false + priority: 20 + - id: test-js + name: JS SDK tests + entry: bash -c 'cd js && bun run test' + language: system + files: ^js/ + types_or: [javascript, ts, tsx, jsx] + pass_filenames: false + require_serial: true + priority: 30 + + # ── CLI (cli/) ────────────────────────────────────────────────────── + # Mirrors release-npm.yml: lint → type-check → test. CLI consumes + # @phala/cloud via workspace link to js/dist, so a stale js/dist can + # mask real issues; rebuild js before committing if you've changed it. + - id: biome-cli + name: Biome (CLI) + entry: bash -c 'cd cli && bun run lint' + language: system + files: ^cli/ + types_or: [javascript, ts, tsx, jsx] + pass_filenames: false + priority: 10 + - id: typecheck-cli + name: TypeScript (CLI) + entry: bash -c 'cd cli && bun run type-check' + language: system + files: ^cli/ + types_or: [javascript, ts, tsx, jsx] + pass_filenames: false + priority: 20 + - id: test-cli + name: CLI tests + entry: bash -c 'cd cli && bun run test' + language: system + files: ^cli/ + types_or: [javascript, ts, tsx, jsx] + pass_filenames: false + require_serial: true + priority: 30 diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 421705af..98bc8359 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -1,3 +1,46 @@ +## [1.1.16](https://github.com/Phala-Network/phala-cloud/compare/cli-v1.1.15...cli-v1.1.16) (2026-04-10) + +### fix + +* **cli:** correct deploy --commit requirements in --help ([157f103](https://github.com/Phala-Network/phala-cloud/commit/157f103bb4033cc49abdacc6541d28db15b8a82b)) + +### refactor + +* clarify --help strings and unify --transaction-hash option ([0749dc0](https://github.com/Phala-Network/phala-cloud/commit/0749dc0bc193697ef44d1ac86069d689838b9f58)) +## [1.1.15](https://github.com/Phala-Network/phala-cloud/compare/cli-v1.1.14...cli-v1.1.15) (2026-04-10) + +### feat + +* **cli:** add profiles command aliases (list, rm, mv) ([b46dbc7](https://github.com/Phala-Network/phala-cloud/commit/b46dbc7c46ef30e5829edaee1e72ddaec9041b2b)) +* **cli:** add profiles use, rename, delete subcommands ([3a7ab22](https://github.com/Phala-Network/phala-cloud/commit/3a7ab22e5635aa4906cf0e22d41d07141ffdea86)) +* **cli:** add renameProfile utility to credentials ([1e07a24](https://github.com/Phala-Network/phala-cloud/commit/1e07a24f847d86b7c5c08050f4d91a7bc447e5fe)) +* **cli:** unify global option handling ([8dc77c1](https://github.com/Phala-Network/phala-cloud/commit/8dc77c19c5ddd241e4baeb568981563f670b857f)) + +### fix + +* **cli:** add two-phase replicate flow with on-chain approval ([a77a011](https://github.com/Phala-Network/phala-cloud/commit/a77a011e47e34129c3a5b17c9adcd7f60ee3bec8)) +* **cli:** improve replicate output formatting and error display ([87cd7c8](https://github.com/Phala-Network/phala-cloud/commit/87cd7c8bff9c963219da4f03201f26ca2b57e966)) +* **cli:** resolve app_id ambiguity with --compose-hash in replicate ([15ff65d](https://github.com/Phala-Network/phala-cloud/commit/15ff65d20a86702c519df63df4b86989832152f1)) +* **cli:** show full values in on-chain registration error ([600cb66](https://github.com/Phala-Network/phala-cloud/commit/600cb66b9c8732a9e89fe296fc01d117a6d3f012)) +* **cli:** show on-chain status before requiring private key in replicate ([986f1ac](https://github.com/Phala-Network/phala-cloud/commit/986f1acd6b925e452a0608c8fc91717822363d0f)) +* **cli:** strip app_ prefix when matching cvm list by app_id ([fcfed6e](https://github.com/Phala-Network/phala-cloud/commit/fcfed6e7dab1a609ff09e52c2a1448ea1c3f4fc4)) +* **cli:** support universal cvm ids for replicate ([5fa84ba](https://github.com/Phala-Network/phala-cloud/commit/5fa84ba486cb3329e6b222bbc8a4edf03e1a41a3)) +* **cli:** use app CVMs endpoint for compose-hash disambiguation ([2f2266a](https://github.com/Phala-Network/phala-cloud/commit/2f2266a7ab0ffb93c94d458298ec5082111e402c)) +* **cli:** use correct pubkey source for CVM replicate env encryption ([2b3b98b](https://github.com/Phala-Network/phala-cloud/commit/2b3b98b9fdcd369f94887a05cea1827978cddeb1)) +* **cli:** use instance-level cvm replication ([fd0cb03](https://github.com/Phala-Network/phala-cloud/commit/fd0cb0352c35987606d1efe901fb6a6b305b6319)) +* **cli:** use plaintext output for cvm replicate ([1a9ba68](https://github.com/Phala-Network/phala-cloud/commit/1a9ba688895a3334cae4a69defc3f4d429062841)) +* **cli:** use sdk private key flow in allow-devices ([d697761](https://github.com/Phala-Network/phala-cloud/commit/d697761a30a7b19cbbefff61ed0b74d82ae94ae4)) +* **sdk:** chain resolution, owner pre-check, and ABI error definitions ([0c6a50c](https://github.com/Phala-Network/phala-cloud/commit/0c6a50c7fc57370ecb61359f34f04210c645ca18)) + +### refactor + +* add `phala help ` subcommand with bundled topics ([8880334](https://github.com/Phala-Network/phala-cloud/commit/88803342ad4a5506642d3d517be55361659b1b0e)) +* add RPC timeout + retry hint for allow-devices ([7a20577](https://github.com/Phala-Network/phala-cloud/commit/7a20577545e3da42bd37017574db962f5fc953f6)) +* add transaction progress logging and RPC transport timeouts ([29849a2](https://github.com/Phala-Network/phala-cloud/commit/29849a283025fe74543254e75afae888ab29f848)) +* allow direct app identifier in allow-devices to avoid CVM ambiguity ([a440bed](https://github.com/Phala-Network/phala-cloud/commit/a440bedb6334c87ed3b2bf82a6813421ee5204a4)) +* log RPC URL before blockchain operations ([c9956b2](https://github.com/Phala-Network/phala-cloud/commit/c9956b22b0fe1c7a66b13f9b7bc04051600fb1f4)) +* skip device list in allow-devices ls when allowAnyDevice is on ([597bbb4](https://github.com/Phala-Network/phala-cloud/commit/597bbb4be1e69d1832093ee167bf69eda801e3e2)) +* unify --private-key / --rpc-url and add ETH_RPC_URL fallback ([162ff8a](https://github.com/Phala-Network/phala-cloud/commit/162ff8a0451ca799061534d3eb9711a85aebd1af)) ## [1.1.14](https://github.com/Phala-Network/phala-cloud/compare/cli-v1.1.13...cli-v1.1.14) (2026-03-27) ### feat diff --git a/cli/docs/help/topics/envs.md b/cli/docs/help/topics/envs.md new file mode 100644 index 00000000..975594b3 --- /dev/null +++ b/cli/docs/help/topics/envs.md @@ -0,0 +1,72 @@ +# Environment Variables + +All environment variables recognized by the Phala CLI. Command-line flags always take precedence over environment variables when both are set. + +## Authentication + +- `PHALA_CLOUD_API_KEY` — API token. Overrides the token stored by `phala login`. +- `PHALA_CLOUD_API_PREFIX` — API base URL. Default: `https://cloud-api.phala.com/api/v1`. + +The `@phala/cloud` JS SDK also reads both directly when `createClient()` is +called without explicit config. + +## On-chain KMS + +These variables apply to `deploy`, `allow-devices`, `cvms replicate`, and +`envs update` when the CVM uses Ethereum or Base KMS: + +- `PRIVATE_KEY` — Private key for signing on-chain transactions. + Precedence: `--private-key` > `PRIVATE_KEY`. +- `ETH_RPC_URL` — RPC endpoint. Follows the foundry/cast convention, so an + existing `ETH_RPC_URL` from those tools works automatically. + Precedence: `--rpc-url` > `ETH_RPC_URL` > chain default. + +Example: + + export ETH_RPC_URL=https://mainnet.base.org + export PRIVATE_KEY=0x... + phala deploy --kms base + +## Debug logging + +`DEBUG` has two independent consumers that behave differently: + +- **CLI debug output** — any non-empty value enables the CLI's own debug + messages on stderr (gray prefix). +- **API request logging** — the `@phala/cloud` JS SDK uses the `debug` npm + package with namespace `phala::api-client`. Set `DEBUG=phala::api-client` + to print every HTTP request in cURL-like format (method, URL, headers, + body). + +Examples: + + # CLI debug only + DEBUG=1 phala deploy + + # API request cURL logging (also triggers CLI debug, since "phala::api-client" is truthy) + DEBUG=phala::api-client phala deploy + + # Everything + DEBUG=* phala deploy + +## Self-update + +- `PHALA_UPDATE_CHANNEL` — Release channel for `phala self update` (e.g. `latest`, `beta`). +- `PHALA_DISABLE_UPDATE_CHECK` — Any truthy value disables the background update notice. +- `CI` — Standard CI flag. Any truthy value disables the update notice. + +## Miscellaneous + +- `CLOUD_URL` — Override the Phala Cloud portal URL used in printed links. + Default: `https://cloud.phala.com`. + +## Internal (undocumented) + +- `PHALA_CLOUD_DIR` — Override the credentials directory. Default: `~/.phala-cloud`. + Testing only. + +## See also + +- `phala api --help` for API-specific flags and env vars +- `phala login --help` for authentication flow +- `phala deploy --help` for on-chain KMS flags diff --git a/cli/package.json b/cli/package.json index c9898396..00732f39 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "phala", - "version": "1.1.14", + "version": "1.1.16", "description": "CLI for Managing Phala Cloud Services", "author": { "name": "Phala Network", @@ -27,7 +27,8 @@ "pha": "./dist/index.js" }, "scripts": { - "build": "tsup && bun link", + "build": "bun run gen:help && tsup && bun link", + "gen:help": "bun run scripts/gen-help-topics.ts", "test": "bun test", "test:e2e": "bun test test/e2e-full/", "test:interface": "bun test test/interface-compat/", diff --git a/cli/scripts/gen-help-topics.ts b/cli/scripts/gen-help-topics.ts new file mode 100644 index 00000000..94a2dbb9 --- /dev/null +++ b/cli/scripts/gen-help-topics.ts @@ -0,0 +1,112 @@ +#!/usr/bin/env bun +/** + * Generate src/commands/help/topics.generated.ts from docs/help/topics/*.md. + * + * Each .md file becomes one help topic. The topic name is the file basename + * (without extension). The description is the first non-empty, non-heading + * line of the file. + * + * Run: bun run gen:help + */ +import { spawnSync } from "node:child_process"; +import { readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const ROOT = resolve(__dirname, ".."); +const TOPICS_DIR = join(ROOT, "docs", "help", "topics"); +const OUT_FILE = join(ROOT, "src", "commands", "help", "topics.generated.ts"); + +interface Topic { + name: string; + description: string; + content: string; +} + +function extractDescription(content: string): string { + for (const rawLine of content.split("\n")) { + const line = rawLine.trim(); + if (line === "") continue; + if (line.startsWith("#")) continue; + return line; + } + return ""; +} + +function collectTopics(): Topic[] { + const entries = readdirSync(TOPICS_DIR, { withFileTypes: true }); + const topics: Topic[] = []; + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!entry.name.endsWith(".md")) continue; + const name = entry.name.replace(/\.md$/, ""); + const content = readFileSync(join(TOPICS_DIR, entry.name), "utf-8"); + topics.push({ + name, + description: extractDescription(content), + content, + }); + } + topics.sort((a, b) => a.name.localeCompare(b.name)); + return topics; +} + +function serializeString(value: string): string { + // Use JSON.stringify for safe escaping of quotes, backslashes, newlines, etc. + return JSON.stringify(value); +} + +function render(topics: Topic[]): string { + const header = [ + "// AUTO-GENERATED by scripts/gen-help-topics.ts — do not edit manually.", + "// Run `bun run gen:help` to regenerate after editing docs/help/topics/*.md.", + "", + "export interface HelpTopic {", + "\treadonly name: string;", + "\treadonly description: string;", + "\treadonly content: string;", + "}", + "", + "export const helpTopics: Readonly> = Object.freeze({", + ]; + const body: string[] = []; + for (const topic of topics) { + body.push(`\t${JSON.stringify(topic.name)}: Object.freeze({`); + body.push(`\t\tname: ${serializeString(topic.name)},`); + body.push(`\t\tdescription: ${serializeString(topic.description)},`); + body.push(`\t\tcontent: ${serializeString(topic.content)},`); + body.push("\t}),"); + } + const footer = ["});", ""]; + return [...header, ...body, ...footer].join("\n"); +} + +function formatWithBiome(file: string): void { + // Run biome on just this file so the generated output matches the + // project's formatting rules and `bun run fmt` stays idempotent. + const result = spawnSync( + "bunx", + ["biome", "format", "--write", relative(ROOT, file)], + { + cwd: ROOT, + stdio: "inherit", + }, + ); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +function main(): void { + const topics = collectTopics(); + const output = render(topics); + writeFileSync(OUT_FILE, output, "utf-8"); + formatWithBiome(OUT_FILE); + console.log( + `Generated ${OUT_FILE} (${topics.length} topic${topics.length === 1 ? "" : "s"})`, + ); +} + +main(); diff --git a/cli/src/commands/allow-devices/command.ts b/cli/src/commands/allow-devices/command.ts index 47dd581b..b4eef40e 100644 --- a/cli/src/commands/allow-devices/command.ts +++ b/cli/src/commands/allow-devices/command.ts @@ -1,13 +1,17 @@ import { z } from "zod"; import type { CommandMeta, CommandGroup } from "@/src/core/types"; -import { interactiveOption } from "@/src/core/common-flags"; +import { + interactiveOption, + privateKeyOption, + rpcUrlOption, +} from "@/src/core/common-flags"; import { jsonOption } from "@/src/commands/status/command"; // Use a custom cvm argument that does NOT match "cvmId" target, // so the dispatcher won't intercept -i for CVM interactive selection. const cvmArgument = { name: "cvm", - description: "CVM identifier (UUID, app_id, instance_id, or name)", + description: "CVM or app identifier (UUID, app_id, instance_id, or name)", required: true, target: "cvm", }; @@ -16,7 +20,7 @@ export const allowDevicesGroup: CommandGroup = { path: ["allow-devices"], meta: { name: "allow-devices", - description: "Manage on-chain device allowlist for a CVM's app contract", + description: "Manage on-chain device allowlist for an app contract", stability: "unstable", }, }; @@ -29,7 +33,7 @@ export const allowDevicesListMeta: CommandMeta = { description: "List allowed devices from the on-chain contract", stability: "unstable", arguments: [cvmArgument], - options: [jsonOption], + options: [rpcUrlOption, jsonOption], examples: [ { name: "List devices on-chain", @@ -40,6 +44,7 @@ export const allowDevicesListMeta: CommandMeta = { export const allowDevicesListSchema = z.object({ cvm: z.string(), + rpcUrl: z.string().optional(), json: z.boolean().default(false), }); @@ -49,33 +54,21 @@ export type AllowDevicesListInput = z.infer; export const allowDevicesAddMeta: CommandMeta = { name: "add", - description: "Add device(s) to the on-chain allowlist", + description: "Add devices to the on-chain allowlist", stability: "unstable", arguments: [ cvmArgument, { name: "device_id", description: - "Device ID (bytes32 hex) or node name to resolve device_id from available nodes", + "Device ID (bytes32 hex) or node name. Node names are resolved to device IDs from available nodes.", required: false, target: "deviceId", }, ], options: [ - { - name: "private-key", - description: "Private key for signing the transaction", - type: "string", - target: "privateKey", - group: "advanced", - }, - { - name: "rpc-url", - description: "Custom RPC URL for the blockchain", - type: "string", - target: "rpcUrl", - group: "advanced", - }, + privateKeyOption, + rpcUrlOption, { name: "wait", description: @@ -115,33 +108,21 @@ export type AllowDevicesAddInput = z.infer; export const allowDevicesRemoveMeta: CommandMeta = { name: "remove", aliases: ["rm"], - description: "Remove device(s) from the on-chain allowlist", + description: "Remove devices from the on-chain allowlist", stability: "unstable", arguments: [ cvmArgument, { name: "device_id", description: - "Device ID (bytes32 hex) or node name to resolve device_id from available nodes", + "Device ID (bytes32 hex) or node name. Node names are resolved to device IDs from available nodes.", required: false, target: "deviceId", }, ], options: [ - { - name: "private-key", - description: "Private key for signing the transaction", - type: "string", - target: "privateKey", - group: "advanced", - }, - { - name: "rpc-url", - description: "Custom RPC URL for the blockchain", - type: "string", - target: "rpcUrl", - group: "advanced", - }, + privateKeyOption, + rpcUrlOption, { name: "wait", description: @@ -181,7 +162,8 @@ export type AllowDevicesRemoveInput = z.infer; export const allowDevicesAllowAnyMeta: CommandMeta = { name: "allow-any", - description: "Set allow-any-device flag on the contract", + description: + "Set the allow-any-device flag on the contract. Requires --enable or --disable.", stability: "unstable", arguments: [cvmArgument], options: [ @@ -197,20 +179,8 @@ export const allowDevicesAllowAnyMeta: CommandMeta = { type: "boolean", target: "disable", }, - { - name: "private-key", - description: "Private key for signing the transaction", - type: "string", - target: "privateKey", - group: "advanced", - }, - { - name: "rpc-url", - description: "Custom RPC URL for the blockchain", - type: "string", - target: "rpcUrl", - group: "advanced", - }, + privateKeyOption, + rpcUrlOption, { name: "wait", description: @@ -252,24 +222,13 @@ export type AllowDevicesAllowAnyInput = z.infer< export const allowDevicesDisallowAnyMeta: CommandMeta = { name: "disallow-any", - description: "Disable allow-any-device on the contract", + description: + "Disable allow-any-device on the contract. Equivalent to `allow-any --disable`.", stability: "unstable", arguments: [cvmArgument], options: [ - { - name: "private-key", - description: "Private key for signing the transaction", - type: "string", - target: "privateKey", - group: "advanced", - }, - { - name: "rpc-url", - description: "Custom RPC URL for the blockchain", - type: "string", - target: "rpcUrl", - group: "advanced", - }, + privateKeyOption, + rpcUrlOption, { name: "wait", description: @@ -320,20 +279,8 @@ export const allowDevicesToggleAllowAnyMeta: CommandMeta = { type: "boolean", target: "disable", }, - { - name: "private-key", - description: "Private key for signing the transaction", - type: "string", - target: "privateKey", - group: "advanced", - }, - { - name: "rpc-url", - description: "Custom RPC URL for the blockchain", - type: "string", - target: "rpcUrl", - group: "advanced", - }, + privateKeyOption, + rpcUrlOption, { name: "wait", description: diff --git a/cli/src/commands/allow-devices/index.ts b/cli/src/commands/allow-devices/index.ts index e26b4d52..01e9f619 100644 --- a/cli/src/commands/allow-devices/index.ts +++ b/cli/src/commands/allow-devices/index.ts @@ -1,4 +1,3 @@ -import chalk from "chalk"; import inquirer from "inquirer"; import { safeGetCvmInfo, @@ -17,7 +16,6 @@ import { defineCommand } from "@/src/core/define-command"; import type { CommandContext } from "@/src/core/types"; import { getClient } from "@/src/lib/client"; import { printTable } from "@/src/lib/table"; -import { logger } from "@/src/utils/logger"; import { allowDevicesGroup, allowDevicesListMeta, @@ -43,6 +41,7 @@ import { // ── Helpers ───────────────────────────────────────────────────────── const DEVICE_ID_REGEX = /^(0x)?[0-9a-fA-F]{64}$/; +const RAW_APP_ID_REGEX = /^[0-9a-fA-F]{40}$/; export function normalizeDeviceId(deviceId: string): `0x${string}` { const normalized = deviceId.startsWith("0x") ? deviceId : `0x${deviceId}`; @@ -53,6 +52,18 @@ export function isValidDeviceId(deviceId: string): boolean { return DEVICE_ID_REGEX.test(deviceId); } +export function isAppAllowlistIdentifier(identifier: string): boolean { + return identifier.startsWith("app_") || RAW_APP_ID_REGEX.test(identifier); +} + +export function normalizeAllowlistAppId(identifier: string): string { + const rawAppId = identifier.startsWith("app_") + ? identifier.slice(4) + : identifier; + + return RAW_APP_ID_REGEX.test(rawAppId) ? rawAppId.toLowerCase() : rawAppId; +} + export function txExplorerUrl( chain: (typeof SUPPORTED_CHAINS)[keyof typeof SUPPORTED_CHAINS], txHash: string | undefined, @@ -63,6 +74,243 @@ export function txExplorerUrl( return `${baseUrl}/tx/${txHash}`; } +// ── RPC timeout + error hint ──────────────────────────────────────── + +type SupportedChain = (typeof SUPPORTED_CHAINS)[keyof typeof SUPPORTED_CHAINS]; + +// Cap how long the CLI will wait on any single SDK call before surfacing +// the hang to the user. The public RPC (viem's hard-coded chain default) is +// often rate-limited, so we fail fast and let the user retry with --rpc-url. +const CLI_RPC_TIMEOUT_MS = 30_000; +// Shorter budget per iteration of the allowlist polling loop so one stuck +// request cannot consume the whole outer timeout. +const CLI_RPC_POLL_TIMEOUT_MS = 15_000; + +export class RpcTimeoutError extends Error { + readonly rpcUrl: string; + readonly timeoutMs: number; + constructor(rpcUrl: string, timeoutMs: number) { + super( + `RPC request exceeded ${timeoutMs}ms waiting for a response from ${rpcUrl}`, + ); + this.name = "RpcTimeoutError"; + this.rpcUrl = rpcUrl; + this.timeoutMs = timeoutMs; + } +} + +const RPC_ERROR_PATTERNS: readonly RegExp[] = [ + /timeout/i, + /HttpRequestError/i, + /TimeoutError/i, + /fetch failed/i, + /ECONN(?:REFUSED|RESET|ABORTED)/i, + /ENOTFOUND/, + /EAI_AGAIN/, + /socket hang up/i, + /network error/i, + /\b429\b/, + /\b502\b/, + /\b503\b/, + /\b504\b/, + /\b1015\b/, + /rate[\s-]?limit/i, + /too many requests/i, +]; + +export function isRpcConnectivityError(error: unknown): boolean { + if (error instanceof RpcTimeoutError) return true; + const messages: string[] = []; + let current: unknown = error; + let depth = 0; + while (current && depth < 5) { + if (current instanceof Error) { + messages.push(`${current.name}: ${current.message}`); + current = (current as { cause?: unknown }).cause; + } else { + messages.push(String(current)); + break; + } + depth += 1; + } + const combined = messages.join("\n"); + return RPC_ERROR_PATTERNS.some((re) => re.test(combined)); +} + +export async function withRpcTimeout( + operation: Promise, + rpcUrl: string, + timeoutMs: number = CLI_RPC_TIMEOUT_MS, +): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + operation, + new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new RpcTimeoutError(rpcUrl, timeoutMs)); + }, timeoutMs); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +export function resolveEffectiveRpcUrl( + chain: SupportedChain, + overrideRpcUrl?: string, +): string { + return overrideRpcUrl || chain.rpcUrls.default.http[0] || "the default RPC"; +} + +// Recommended public RPC per chain. One-shot hint, not an endorsement — +// sourced by manually picking a well-known entry from chainlist.org +// (e.g. https://chainlist.org/chain/1). Keep the list tiny on purpose: +// CLI error hints should not try to curate a fresh provider registry. +function recommendedRpcForChain(chainId: number): string | null { + switch (chainId) { + case 1: // Ethereum mainnet + return "https://ethereum-rpc.publicnode.com"; + case 8453: // Base mainnet + return "https://base-rpc.publicnode.com"; + default: + return null; + } +} + +function chainlistUrl(chainId: number): string { + return `https://chainlist.org/chain/${chainId}`; +} + +// Flags whose value must be redacted when echoed back in an error message. +const SENSITIVE_FLAG_NAMES: ReadonlySet = new Set([ + "--private-key", + "--privateKey", +]); + +function maskSensitiveArgs(args: readonly string[]): string[] { + const result: string[] = []; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (SENSITIVE_FLAG_NAMES.has(arg) && i + 1 < args.length) { + result.push(arg, "$PRIVATE_KEY"); + i += 1; + continue; + } + const eqIdx = arg.indexOf("="); + if (eqIdx > 0 && SENSITIVE_FLAG_NAMES.has(arg.slice(0, eqIdx))) { + result.push(`${arg.slice(0, eqIdx)}=$PRIVATE_KEY`); + continue; + } + result.push(arg); + } + return result; +} + +function replaceOrAppendRpcUrl( + args: readonly string[], + rpcUrl: string, +): string[] { + const result: string[] = []; + let replaced = false; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg === "--rpc-url" && i + 1 < args.length) { + result.push("--rpc-url", rpcUrl); + i += 1; + replaced = true; + continue; + } + if (arg.startsWith("--rpc-url=")) { + result.push(`--rpc-url=${rpcUrl}`); + replaced = true; + continue; + } + result.push(arg); + } + if (!replaced) { + result.push("--rpc-url", rpcUrl); + } + return result; +} + +// POSIX single-quote quoting for args that contain shell-special characters. +function shellQuote(arg: string): string { + if (/^[a-zA-Z0-9_\-./:=@,+%]+$/.test(arg)) return arg; + return `'${arg.replace(/'/g, "'\\''")}'`; +} + +function reconstructRetryCommand(rpcUrl: string): string { + // Skip the runtime binary (argv[0]) and the script path (argv[1]); keep + // only user-supplied arguments. Command name is always rendered as + // "phala" — the canonical distributed binary — even when the user is + // running via `bun run src ...` in dev. + const userArgs = process.argv.slice(2); + const masked = maskSensitiveArgs(userArgs); + const withRpc = replaceOrAppendRpcUrl(masked, rpcUrl); + return ["phala", ...withRpc].map(shellQuote).join(" "); +} + +function writeStderr(line = ""): void { + process.stderr.write(`${line}\n`); +} + +function logRpcHint(chain: SupportedChain, rpcUrl: string): void { + writeStderr( + `The ${chain.name} RPC endpoint (${rpcUrl}) appears unreachable or rate-limited.`, + ); + + const recommended = recommendedRpcForChain(chain.id); + if (recommended) { + writeStderr("Retry with:"); + writeStderr(` ${reconstructRetryCommand(recommended)}`); + } else { + writeStderr( + "Retry by appending --rpc-url to your command with a different endpoint.", + ); + } + + writeStderr(); + writeStderr( + `More public ${chain.name} RPC endpoints: ${chainlistUrl(chain.id)}`, + ); + writeStderr( + "For production workloads, register a paid provider for reliability:", + ); + writeStderr(" Alchemy: https://www.alchemy.com"); + writeStderr(" Infura: https://www.infura.io"); + writeStderr(" QuickNode: https://www.quicknode.com"); +} + +function maybeLogRpcHint( + error: unknown, + chain: SupportedChain | undefined, + rpcUrl: string | undefined, + json = false, +): void { + if (json) return; + if (!chain || !rpcUrl) return; + if (!isRpcConnectivityError(error)) return; + logRpcHint(chain, rpcUrl); +} + +function logPendingTransaction(params: { + chain: (typeof SUPPORTED_CHAINS)[keyof typeof SUPPORTED_CHAINS]; + description: string; + txHash: string; + json?: boolean; +}) { + if (params.json) return; + const { chain, description, txHash } = params; + writeStderr(`${description} submitted: ${txHash}`); + const explorerUrl = txExplorerUrl(chain, txHash); + if (explorerUrl) { + writeStderr(`Explorer: ${explorerUrl}`); + } + writeStderr("Waiting for 1 confirmation..."); +} + /** * Determine the allow-any flag value for the `allow-any` command. * Returns null if neither --enable nor --disable was specified (caller should fail). @@ -101,7 +349,7 @@ export function buildAlreadyAllowedSet( } async function waitForAllowlistState(params: { - chain: (typeof SUPPORTED_CHAINS)[keyof typeof SUPPORTED_CHAINS]; + chain: SupportedChain; rpcUrl?: string; appAddress: `0x${string}`; deviceIds: string[]; @@ -109,29 +357,50 @@ async function waitForAllowlistState(params: { description: string; timeoutMs?: number; intervalMs?: number; + json?: boolean; }): Promise { const { chain, rpcUrl, appAddress, deviceIds, condition, description } = params; const timeoutMs = params.timeoutMs ?? 60_000; const intervalMs = params.intervalMs ?? 2_000; const deadline = Date.now() + timeoutMs; + const effectiveRpc = resolveEffectiveRpcUrl(chain, rpcUrl); + const json = params.json ?? false; + let lastError: unknown; while (Date.now() < deadline) { - const result = await getAllowedDevices({ - chain, - rpcUrl, - appAddress, - deviceIds, - }); - if (condition(result)) { - return true; + try { + const result = await withRpcTimeout( + getAllowedDevices({ + chain, + rpcUrl, + appAddress, + deviceIds, + }), + effectiveRpc, + CLI_RPC_POLL_TIMEOUT_MS, + ); + if (condition(result)) { + return true; + } + lastError = undefined; + } catch (error) { + if (!isRpcConnectivityError(error)) { + throw error; + } + lastError = error; } await new Promise((resolve) => { setTimeout(resolve, intervalMs); }); } - logger.warn(`Timeout waiting for on-chain state: ${description}`); + if (!json) { + writeStderr(`Warning: timeout waiting for on-chain state: ${description}`); + } + if (lastError) { + maybeLogRpcHint(lastError, chain, effectiveRpc, json); + } return false; } @@ -156,6 +425,7 @@ function resolvePrivateKey(input: { privateKey?: string }): `0x${string}` { async function resolveDeviceIdOrNodeName( deviceInput: string, + context: CommandContext, ): Promise<`0x${string}`> { if (isValidDeviceId(deviceInput)) { return normalizeDeviceId(deviceInput); @@ -167,7 +437,7 @@ async function resolveDeviceIdOrNodeName( ); } - const client = await getClient(); + const client = await getClient(context); const nodesResult = await safeGetAvailableNodes(client); if (!nodesResult.success) { throw new Error( @@ -202,27 +472,34 @@ async function resolveAppContract( cvmIdentifier: string, context: CommandContext, ) { - const client = await getClient(); + const client = await getClient(context); - const infoResult = await safeGetCvmInfo(client, { id: cvmIdentifier }); - if (!infoResult.success) { - context.fail(infoResult.error.message); - return null; - } + let appId: string; + if (isAppAllowlistIdentifier(cvmIdentifier)) { + appId = normalizeAllowlistAppId(cvmIdentifier); + } else { + const infoResult = await safeGetCvmInfo(client, { id: cvmIdentifier }); + if (!infoResult.success) { + context.fail(infoResult.error.message); + return null; + } - const cvm = infoResult.data; - if (!cvm) { - context.fail("CVM not found"); - return null; - } + const cvm = infoResult.data; + if (!cvm) { + context.fail("CVM not found"); + return null; + } - const appId = cvm.app_id; - if (!appId) { - context.fail("CVM has no app_id assigned yet."); - return null; + appId = cvm.app_id; + if (!appId) { + context.fail("CVM has no app_id assigned yet."); + return null; + } } - const allowlistResult = await safeGetAppDeviceAllowlist(client, { appId }); + const allowlistResult = await safeGetAppDeviceAllowlist(client, { + appId: normalizeAllowlistAppId(appId), + }); if (!allowlistResult.success) { context.fail(allowlistResult.error.message); return null; @@ -260,17 +537,30 @@ async function resolveAppContract( // ── list ──────────────────────────────────────────────────────────── async function runList( - input: AllowDevicesListInput, + rawInput: AllowDevicesListInput, context: CommandContext, ): Promise { + // Fall back to ETH_RPC_URL env var (foundry/cast convention) + const input: AllowDevicesListInput = { + ...rawInput, + rpcUrl: rawInput.rpcUrl || process.env.ETH_RPC_URL, + }; + const say = (line = ""): void => { + if (!input.json) writeStderr(line); + }; + + let effectiveRpc: string | undefined; + let activeChain: SupportedChain | undefined; try { const resolved = await resolveAppContract(input.cvm, context); if (!resolved) return 1; const { chain, appContractAddress } = resolved; + activeChain = chain; + effectiveRpc = resolveEffectiveRpcUrl(chain, input.rpcUrl); // Get all platform nodes to build device_id → node_name map - const client = await getClient(); + const client = await getClient(context); const nodesResult = await safeGetAvailableNodes(client); const nodesByDeviceId = new Map(); if (nodesResult.success) { @@ -280,18 +570,22 @@ async function runList( } } } else { - logger.warn( - "Could not fetch platform nodes — node names will not be shown.", + say( + "Warning: could not fetch platform nodes — node names will not be shown.", ); } // Query chain directly with all known device IDs const allDeviceIds = Array.from(nodesByDeviceId.keys()); - const onChain = await getAllowedDevices({ - chain, - appAddress: appContractAddress, - deviceIds: allDeviceIds, - }); + const onChain = await withRpcTimeout( + getAllowedDevices({ + chain, + rpcUrl: input.rpcUrl, + appAddress: appContractAddress, + deviceIds: allDeviceIds, + }), + effectiveRpc, + ); if (input.json) { context.success({ @@ -299,23 +593,33 @@ async function runList( chain: chain.name, owner: onChain.owner, allowAnyDevice: onChain.allowAnyDevice, - devices: onChain.devices.map((did) => ({ - deviceId: did, - nodeName: nodesByDeviceId.get(did.toLowerCase()) ?? null, - })), + // When allowAnyDevice is on, the per-device allowlist is + // effectively empty (all devices are implicitly allowed) — + // getAllowedDevices just echoes the queried IDs back. + devices: onChain.allowAnyDevice + ? [] + : onChain.devices.map((did) => ({ + deviceId: did, + nodeName: nodesByDeviceId.get(did.toLowerCase()) ?? null, + })), }); return 0; } - logger.info(`Contract: ${appContractAddress}`); - logger.info(`Chain: ${chain.name}`); - logger.info(`Owner: ${onChain.owner}`); - logger.info( - `Allow Any Device: ${onChain.allowAnyDevice ? chalk.green("yes") : chalk.red("no")}`, - ); + say(`Contract: ${appContractAddress}`); + say(`Chain: ${chain.name}`); + say(`Owner: ${onChain.owner}`); + say(`Allow Any Device: ${onChain.allowAnyDevice ? "yes" : "no"}`); + + // When allowAnyDevice is on, the per-device list from getAllowedDevices + // is just the queried IDs reflected back (all platform nodes), not a + // meaningful allowlist. Skip the table to avoid misleading the reader. + if (onChain.allowAnyDevice) { + return 0; + } if (onChain.devices.length === 0) { - logger.info("No devices found"); + say("No devices found"); return 0; } @@ -329,7 +633,7 @@ async function runList( return 0; } catch (error) { - logger.logDetailedError(error); + maybeLogRpcHint(error, activeChain, effectiveRpc, input.json); context.fail( `Failed: ${error instanceof Error ? error.message : String(error)}`, ); @@ -340,20 +644,33 @@ async function runList( // ── add ───────────────────────────────────────────────────────────── async function runAdd( - input: AllowDevicesAddInput, + rawInput: AllowDevicesAddInput, context: CommandContext, ): Promise { + // Fall back to ETH_RPC_URL env var (foundry/cast convention) + const input: AllowDevicesAddInput = { + ...rawInput, + rpcUrl: rawInput.rpcUrl || process.env.ETH_RPC_URL, + }; + const say = (line = ""): void => { + if (!input.json) writeStderr(line); + }; + + let effectiveRpc: string | undefined; + let activeChain: SupportedChain | undefined; try { const resolved = await resolveAppContract(input.cvm, context); if (!resolved) return 1; const { chain, appContractAddress, allowlist } = resolved; + activeChain = chain; + effectiveRpc = resolveEffectiveRpcUrl(chain, input.rpcUrl); const privateKey = resolvePrivateKey(input); let deviceIds: `0x${string}`[]; if (input.interactive && !input.deviceId) { - const client = await getClient(); + const client = await getClient(context); const nodesResult = await safeGetAvailableNodes(client); if (!nodesResult.success) { context.fail(nodesResult.error.message); @@ -370,7 +687,7 @@ async function runAdd( !alreadyAllowed.has(normalizeDeviceId(n.device_id)), ); if (candidates.length === 0) { - logger.info("All available devices are already in the allowlist."); + say("All available devices are already in the allowlist."); return 0; } @@ -389,13 +706,13 @@ async function runAdd( ]); if (selected.length === 0) { - logger.info("No devices selected."); + say("No devices selected."); return 0; } deviceIds = selected.map((id) => normalizeDeviceId(id)); } else if (input.deviceId) { - const deviceId = await resolveDeviceIdOrNodeName(input.deviceId); + const deviceId = await resolveDeviceIdOrNodeName(input.deviceId, context); deviceIds = [deviceId]; } else { context.fail("Device ID is required. Use -i to select interactively."); @@ -409,20 +726,41 @@ async function runAdd( }[] = []; for (const deviceId of deviceIds) { - const result = await safeAddDevice({ - chain, - rpcUrl: input.rpcUrl, - appAddress: appContractAddress, - deviceId, - privateKey, - skipPrerequisiteChecks: true, - }); + say(`Submitting add-device transaction for ${deviceId}...`); + say(`RPC URL: ${effectiveRpc}`); + + const result = await withRpcTimeout( + safeAddDevice({ + chain, + rpcUrl: input.rpcUrl, + appAddress: appContractAddress, + deviceId, + privateKey, + skipPrerequisiteChecks: true, + timeout: CLI_RPC_TIMEOUT_MS, + onTransactionSubmitted: (txHash) => { + logPendingTransaction({ + chain, + description: `Add-device transaction for ${deviceId}`, + txHash, + json: input.json, + }); + }, + }), + effectiveRpc, + ); if (!result.success) { const err = result as { success: false; error: { message: string }; }; + maybeLogRpcHint( + new Error(err.error.message), + chain, + effectiveRpc, + input.json, + ); context.fail(`Failed to add ${deviceId}: ${err.error.message}`); return 1; } @@ -441,10 +779,10 @@ async function runAdd( } for (const r of results) { - logger.success(`Added ${r.deviceId}`); - logger.info(`Transaction: ${r.txHash}`); + say(`Added ${r.deviceId}`); + say(`Transaction: ${r.txHash}`); if (r.explorer) { - logger.info(`Explorer: ${r.explorer}`); + say(`Explorer: ${r.explorer}`); } } @@ -455,6 +793,7 @@ async function runAdd( appAddress: appContractAddress, deviceIds, description: "devices to be allowed", + json: input.json, condition: (state) => { // When allowAnyDevice is true, all devices pass the check // regardless of per-device state. Skip wait to avoid false positive. @@ -468,9 +807,9 @@ async function runAdd( context.fail("Devices not observed on-chain within timeout."); return 1; } - logger.success("On-chain allowlist updated."); + say("On-chain allowlist updated."); } else { - logger.info( + say( "Backend allowlist API may lag behind chain. Use --wait to verify via RPC.", ); } @@ -478,10 +817,10 @@ async function runAdd( return 0; } catch (error) { if (isExitPromptError(error)) { - logger.info("Cancelled."); + say("Cancelled."); return 0; } - logger.logDetailedError(error); + maybeLogRpcHint(error, activeChain, effectiveRpc, input.json); context.fail( `Failed: ${error instanceof Error ? error.message : String(error)}`, ); @@ -492,14 +831,27 @@ async function runAdd( // ── remove ────────────────────────────────────────────────────────── async function runRemove( - input: AllowDevicesRemoveInput, + rawInput: AllowDevicesRemoveInput, context: CommandContext, ): Promise { + // Fall back to ETH_RPC_URL env var (foundry/cast convention) + const input: AllowDevicesRemoveInput = { + ...rawInput, + rpcUrl: rawInput.rpcUrl || process.env.ETH_RPC_URL, + }; + const say = (line = ""): void => { + if (!input.json) writeStderr(line); + }; + + let effectiveRpc: string | undefined; + let activeChain: SupportedChain | undefined; try { const resolved = await resolveAppContract(input.cvm, context); if (!resolved) return 1; const { chain, appContractAddress, allowlist } = resolved; + activeChain = chain; + effectiveRpc = resolveEffectiveRpcUrl(chain, input.rpcUrl); const privateKey = resolvePrivateKey(input); let deviceIds: `0x${string}`[]; @@ -528,13 +880,13 @@ async function runRemove( ]); if (selected.length === 0) { - logger.info("No devices selected."); + say("No devices selected."); return 0; } deviceIds = selected.map((id) => normalizeDeviceId(id)); } else if (input.deviceId) { - const deviceId = await resolveDeviceIdOrNodeName(input.deviceId); + const deviceId = await resolveDeviceIdOrNodeName(input.deviceId, context); deviceIds = [deviceId]; } else { context.fail("Device ID is required. Use -i to select interactively."); @@ -548,20 +900,41 @@ async function runRemove( }[] = []; for (const deviceId of deviceIds) { - const result = await safeRemoveDevice({ - chain, - rpcUrl: input.rpcUrl, - appAddress: appContractAddress, - deviceId, - privateKey, - skipPrerequisiteChecks: true, - }); + say(`Submitting remove-device transaction for ${deviceId}...`); + say(`RPC URL: ${effectiveRpc}`); + + const result = await withRpcTimeout( + safeRemoveDevice({ + chain, + rpcUrl: input.rpcUrl, + appAddress: appContractAddress, + deviceId, + privateKey, + skipPrerequisiteChecks: true, + timeout: CLI_RPC_TIMEOUT_MS, + onTransactionSubmitted: (txHash) => { + logPendingTransaction({ + chain, + description: `Remove-device transaction for ${deviceId}`, + txHash, + json: input.json, + }); + }, + }), + effectiveRpc, + ); if (!result.success) { const err = result as { success: false; error: { message: string }; }; + maybeLogRpcHint( + new Error(err.error.message), + chain, + effectiveRpc, + input.json, + ); context.fail(`Failed to remove ${deviceId}: ${err.error.message}`); return 1; } @@ -580,25 +953,28 @@ async function runRemove( } for (const r of results) { - logger.success(`Removed ${r.deviceId}`); - logger.info(`Transaction: ${r.txHash}`); + say(`Removed ${r.deviceId}`); + say(`Transaction: ${r.txHash}`); if (r.explorer) { - logger.info(`Explorer: ${r.explorer}`); + say(`Explorer: ${r.explorer}`); } } if (input.wait) { // When allowAnyDevice is true, removed devices still appear as "allowed" // because getAllowedDevices short-circuits. Warn and skip the wait. - const preCheck = await getAllowedDevices({ - chain, - rpcUrl: input.rpcUrl, - appAddress: appContractAddress, - deviceIds: [], - }); + const preCheck = await withRpcTimeout( + getAllowedDevices({ + chain, + rpcUrl: input.rpcUrl, + appAddress: appContractAddress, + deviceIds: [], + }), + effectiveRpc, + ); if (preCheck.allowAnyDevice) { - logger.warn( - "allowAnyDevice is enabled — removed devices still appear as allowed. " + + say( + "Warning: allowAnyDevice is enabled — removed devices still appear as allowed. " + "Disable allow-any-device first if you want per-device enforcement.", ); } else { @@ -608,6 +984,7 @@ async function runRemove( appAddress: appContractAddress, deviceIds, description: "devices to be removed", + json: input.json, condition: (state) => deviceIds.every( (id) => @@ -620,10 +997,10 @@ async function runRemove( context.fail("Devices still appear on-chain after timeout."); return 1; } - logger.success("On-chain allowlist updated."); + say("On-chain allowlist updated."); } } else { - logger.info( + say( "Backend allowlist API may lag behind chain. Use --wait to verify via RPC.", ); } @@ -631,10 +1008,10 @@ async function runRemove( return 0; } catch (error) { if (isExitPromptError(error)) { - logger.info("Cancelled."); + say("Cancelled."); return 0; } - logger.logDetailedError(error); + maybeLogRpcHint(error, activeChain, effectiveRpc, input.json); context.fail( `Failed: ${error instanceof Error ? error.message : String(error)}`, ); @@ -645,9 +1022,14 @@ async function runRemove( // ── allow-any ─────────────────────────────────────────────────────── async function runAllowAny( - input: AllowDevicesAllowAnyInput, + rawInput: AllowDevicesAllowAnyInput, context: CommandContext, ): Promise { + // Fall back to ETH_RPC_URL env var (foundry/cast convention) + const input: AllowDevicesAllowAnyInput = { + ...rawInput, + rpcUrl: rawInput.rpcUrl || process.env.ETH_RPC_URL, + }; const allow = resolveAllowAnyFlag(input); if (allow === null) { context.fail( @@ -658,9 +1040,13 @@ async function runAllowAny( return 1; } + let effectiveRpc: string | undefined; + let activeChain: SupportedChain | undefined; try { const resolved = await resolveAppContract(input.cvm, context); if (!resolved) return 1; + activeChain = resolved.chain; + effectiveRpc = resolveEffectiveRpcUrl(resolved.chain, input.rpcUrl); return await executeSetAllowAny(input, context, { chain: resolved.chain, @@ -668,7 +1054,7 @@ async function runAllowAny( allow, }); } catch (error) { - logger.logDetailedError(error); + maybeLogRpcHint(error, activeChain, effectiveRpc, input.json); context.fail( `Failed: ${error instanceof Error ? error.message : String(error)}`, ); @@ -677,12 +1063,21 @@ async function runAllowAny( } async function runDisallowAny( - input: AllowDevicesDisallowAnyInput, + rawInput: AllowDevicesDisallowAnyInput, context: CommandContext, ): Promise { + // Fall back to ETH_RPC_URL env var (foundry/cast convention) + const input: AllowDevicesDisallowAnyInput = { + ...rawInput, + rpcUrl: rawInput.rpcUrl || process.env.ETH_RPC_URL, + }; + let effectiveRpc: string | undefined; + let activeChain: SupportedChain | undefined; try { const resolved = await resolveAppContract(input.cvm, context); if (!resolved) return 1; + activeChain = resolved.chain; + effectiveRpc = resolveEffectiveRpcUrl(resolved.chain, input.rpcUrl); return await executeSetAllowAny(input, context, { chain: resolved.chain, @@ -690,7 +1085,7 @@ async function runDisallowAny( allow: false, }); } catch (error) { - logger.logDetailedError(error); + maybeLogRpcHint(error, activeChain, effectiveRpc, input.json); context.fail( `Failed: ${error instanceof Error ? error.message : String(error)}`, ); @@ -699,12 +1094,21 @@ async function runDisallowAny( } async function runToggleAllowAny( - input: AllowDevicesToggleAllowAnyInput, + rawInput: AllowDevicesToggleAllowAnyInput, context: CommandContext, ): Promise { + // Fall back to ETH_RPC_URL env var (foundry/cast convention) + const input: AllowDevicesToggleAllowAnyInput = { + ...rawInput, + rpcUrl: rawInput.rpcUrl || process.env.ETH_RPC_URL, + }; + let effectiveRpc: string | undefined; + let activeChain: SupportedChain | undefined; try { const resolved = await resolveAppContract(input.cvm, context); if (!resolved) return 1; + activeChain = resolved.chain; + effectiveRpc = resolveEffectiveRpcUrl(resolved.chain, input.rpcUrl); const allow = resolveToggleAllowAny({ enable: input.enable, @@ -722,7 +1126,7 @@ async function runToggleAllowAny( allow, }); } catch (error) { - logger.logDetailedError(error); + maybeLogRpcHint(error, activeChain, effectiveRpc, input.json); context.fail( `Failed: ${error instanceof Error ? error.message : String(error)}`, ); @@ -739,24 +1143,48 @@ async function executeSetAllowAny( }, context: CommandContext, params: { - chain: (typeof SUPPORTED_CHAINS)[keyof typeof SUPPORTED_CHAINS]; + chain: SupportedChain; appContractAddress: `0x${string}`; allow: boolean; }, ): Promise { + const json = input.json ?? false; + const say = (line = ""): void => { + if (!json) writeStderr(line); + }; + const { chain, appContractAddress, allow } = params; const privateKey = resolvePrivateKey(input); - const result = await safeSetAllowAnyDevice({ - chain, - rpcUrl: input.rpcUrl, - appAddress: appContractAddress, - allow, - privateKey: privateKey as `0x${string}`, - }); + const effectiveRpc = resolveEffectiveRpcUrl(chain, input.rpcUrl); + say( + `Submitting allow-any-device transaction (${allow ? "enable" : "disable"})...`, + ); + say(`RPC URL: ${effectiveRpc}`); + + const result = await withRpcTimeout( + safeSetAllowAnyDevice({ + chain, + rpcUrl: input.rpcUrl, + appAddress: appContractAddress, + allow, + privateKey: privateKey as `0x${string}`, + timeout: CLI_RPC_TIMEOUT_MS, + onTransactionSubmitted: (txHash) => { + logPendingTransaction({ + chain, + description: "Allow-any-device transaction", + txHash, + json, + }); + }, + }), + effectiveRpc, + ); if (!result.success) { const err = result as { success: false; error: { message: string } }; + maybeLogRpcHint(new Error(err.error.message), chain, effectiveRpc, json); context.fail(err.error.message); return 1; } @@ -764,7 +1192,7 @@ async function executeSetAllowAny( const data = result.data as SetAllowAnyDevice; const explorerUrl = txExplorerUrl(chain, data.transactionHash); - if (input.json) { + if (json) { context.success({ ...data, explorer: explorerUrl ?? undefined, @@ -772,12 +1200,10 @@ async function executeSetAllowAny( return 0; } - logger.success( - `Allow-any-device ${allow ? "enabled" : "disabled"} successfully!`, - ); - logger.info(`Transaction: ${data.transactionHash}`); + say(`Allow-any-device ${allow ? "enabled" : "disabled"} successfully!`); + say(`Transaction: ${data.transactionHash}`); if (explorerUrl) { - logger.info(`Explorer: ${explorerUrl}`); + say(`Explorer: ${explorerUrl}`); } if (input.wait) { @@ -787,15 +1213,16 @@ async function executeSetAllowAny( appAddress: appContractAddress, deviceIds: [], description: `allowAnyDevice=${allow}`, + json, condition: (state) => state.allowAnyDevice === allow, }); if (!ok) { context.fail(`allowAnyDevice did not become ${allow} within timeout.`); return 1; } - logger.success("On-chain allow-any state updated."); + say("On-chain allow-any state updated."); } else { - logger.info( + say( "Backend allowlist API may lag behind chain. Use --wait to verify via RPC.", ); } diff --git a/cli/src/commands/apps/index.ts b/cli/src/commands/apps/index.ts index 1c990e28..617032df 100644 --- a/cli/src/commands/apps/index.ts +++ b/cli/src/commands/apps/index.ts @@ -25,7 +25,7 @@ async function runAppsCommand( context: CommandContext, ): Promise { try { - const client = await getClient(); + const client = await getClient(context); const result = await listAppsWithCvmStatus(client as never, { page: input.page, pageSize: input.pageSize, diff --git a/cli/src/commands/auth/logout/index.ts b/cli/src/commands/auth/logout/index.ts index 45e6a269..671337cb 100644 --- a/cli/src/commands/auth/logout/index.ts +++ b/cli/src/commands/auth/logout/index.ts @@ -11,7 +11,7 @@ import { async function runLogoutCommand( _input: LogoutCommandInput, - _context: CommandContext, + context: CommandContext, ): Promise { // Show deprecation warning logger.warn( @@ -24,13 +24,20 @@ async function runLogoutCommand( const current = loadCredentialsFile(); const profile = current?.current_profile; removeProfile(); - logger.success( - profile - ? `Credentials removed successfully (profile: ${profile})` - : "Credentials removed successfully", - ); + const message = profile + ? `Credentials removed successfully (profile: ${profile})` + : "Credentials removed successfully"; + if (context.globalOptions?.json) { + context.success({ message, profile: profile || null }); + return 0; + } + logger.success(message); return 0; } catch (error) { + if (context.globalOptions?.json) { + context.fail("Failed to remove credentials"); + return 1; + } logger.error("Failed to remove credentials"); logger.logDetailedError(error); return 1; diff --git a/cli/src/commands/cp/index.ts b/cli/src/commands/cp/index.ts index 4e25dd01..0abe4a1e 100644 --- a/cli/src/commands/cp/index.ts +++ b/cli/src/commands/cp/index.ts @@ -94,7 +94,7 @@ async function runCpCommand( if (!configGatewayDomain) { try { - const client = await getClient(); + const client = await getClient(context); const cvmInfo = await fetchCvmInfo(client, cvmId); instanceId = cvmInfo.appId; gatewayDomain = cvmInfo.gatewayDomain; diff --git a/cli/src/commands/cvms/attestation/index.ts b/cli/src/commands/cvms/attestation/index.ts index 05f71912..0481278c 100644 --- a/cli/src/commands/cvms/attestation/index.ts +++ b/cli/src/commands/cvms/attestation/index.ts @@ -24,7 +24,7 @@ async function runCvmsAttestationCommand( } try { - const client = await getClient(); + const client = await getClient(context); const infoResult = await safeGetCvmInfo(client, context.cvmId); if (!infoResult.success) { diff --git a/cli/src/commands/cvms/delete/index.ts b/cli/src/commands/cvms/delete/index.ts index 6430a166..c088b461 100644 --- a/cli/src/commands/cvms/delete/index.ts +++ b/cli/src/commands/cvms/delete/index.ts @@ -22,7 +22,8 @@ async function runCvmsDeleteCommand( } try { - const client = await getClient(); + const client = await getClient(context); + const isJson = context.globalOptions?.json === true; // Get CVM details for confirmation message const infoResult = await safeGetCvmInfo(client, context.cvmId); @@ -52,6 +53,14 @@ async function runCvmsDeleteCommand( ]); if (!confirm) { + if (isJson) { + context.success({ + deleted: false, + cancelled: true, + cvm: cvmIdentifier, + }); + return 0; + } logger.info("Deletion cancelled"); return 0; } @@ -62,16 +71,21 @@ async function runCvmsDeleteCommand( spinner.stop(true); if (!result.success) { - logger.error( + context.fail( `Failed to delete CVM ${cvmIdentifier}: ${result.error.message}`, ); return 1; } + if (isJson) { + context.success({ deleted: true, cvm: cvmIdentifier }); + return 0; + } + logger.success(`CVM ${cvmIdentifier} deleted successfully`); return 0; } catch (error) { - logger.error("Failed to delete CVM"); + context.fail("Failed to delete CVM"); logger.logDetailedError(error); return 1; } diff --git a/cli/src/commands/cvms/device-allowlist/index.ts b/cli/src/commands/cvms/device-allowlist/index.ts index 3caee1ad..6208d811 100644 --- a/cli/src/commands/cvms/device-allowlist/index.ts +++ b/cli/src/commands/cvms/device-allowlist/index.ts @@ -23,7 +23,7 @@ async function runCvmsDeviceAllowlistCommand( } try { - const client = await getClient(); + const client = await getClient(context); const infoResult = await safeGetCvmInfo(client, context.cvmId); if (!infoResult.success) { diff --git a/cli/src/commands/cvms/get/index.ts b/cli/src/commands/cvms/get/index.ts index 2b58d4fd..b44570dc 100644 --- a/cli/src/commands/cvms/get/index.ts +++ b/cli/src/commands/cvms/get/index.ts @@ -25,7 +25,7 @@ async function runCvmsGetCommand( try { const spinner = logger.startSpinner("Fetching CVM details"); - const client = await getClient(); + const client = await getClient(context); const result = await safeGetCvmInfo(client, context.cvmId); spinner.stop(true); diff --git a/cli/src/commands/cvms/list-node/index.ts b/cli/src/commands/cvms/list-node/index.ts index bba2e4e4..85bb787e 100644 --- a/cli/src/commands/cvms/list-node/index.ts +++ b/cli/src/commands/cvms/list-node/index.ts @@ -13,11 +13,12 @@ import { async function runCvmsListNodesCommand( _input: CvmsListNodesCommandInput, - _context: CommandContext, + context: CommandContext, ): Promise { try { - const client = await getClient(); + const client = await getClient(context); const result = await safeGetAvailableNodes(client); + const isJson = context.globalOptions?.json === true; if (!result.success) { throw new Error(result.error.message); @@ -26,6 +27,11 @@ async function runCvmsListNodesCommand( const { nodes: teepods, kms_list: kmsList } = result.data as AvailableNodesResponse; + if (isJson) { + context.success({ nodes: teepods ?? [], kmsList: kmsList ?? [] }); + return 0; + } + if (!teepods || teepods.length === 0) { logger.info("No available nodes found."); return 0; @@ -66,7 +72,7 @@ async function runCvmsListNodesCommand( return 0; } catch (error) { - logger.error("Failed to list available nodes"); + context.fail("Failed to list available nodes"); logger.logDetailedError(error); return 1; } diff --git a/cli/src/commands/cvms/list/index.ts b/cli/src/commands/cvms/list/index.ts index 7dfacc3a..99745a5c 100644 --- a/cli/src/commands/cvms/list/index.ts +++ b/cli/src/commands/cvms/list/index.ts @@ -24,7 +24,7 @@ async function runCvmsListCommand( context: CommandContext, ): Promise { try { - const client = await getClient(); + const client = await getClient(context); const result = await listAppsWithCvmStatus(client as never, { page: input.page, pageSize: input.pageSize, diff --git a/cli/src/commands/cvms/logs-handler.ts b/cli/src/commands/cvms/logs-handler.ts index 7f60518a..5ce5ceb3 100644 --- a/cli/src/commands/cvms/logs-handler.ts +++ b/cli/src/commands/cvms/logs-handler.ts @@ -9,9 +9,10 @@ import { getClient } from "@/src/lib/client"; */ export async function checkAndWarnIfLogsDisabled( appId: string, + context?: Pick, ): Promise { try { - const client = await getClient(); + const client = await getClient(context); const result = await safeGetCvmInfo(client, { id: appId }); if (result.success && result.data.public_logs === false) { logger.warn("Logs are disabled for this CVM (public_logs=false)."); @@ -125,7 +126,7 @@ export function createLogsHandler( if (logs.trim()) { console.log(logs); } else { - const logsDisabled = await checkAndWarnIfLogsDisabled(appId); + const logsDisabled = await checkAndWarnIfLogsDisabled(appId, context); if (!logsDisabled) { logger.info("No logs available"); } @@ -138,7 +139,7 @@ export function createLogsHandler( error instanceof Error ? error.message : String(error) }`, ); - await checkAndWarnIfLogsDisabled(appId); + await checkAndWarnIfLogsDisabled(appId, context); return 1; } }; diff --git a/cli/src/commands/cvms/replicate/command.ts b/cli/src/commands/cvms/replicate/command.ts index 2836979d..b268298f 100644 --- a/cli/src/commands/cvms/replicate/command.ts +++ b/cli/src/commands/cvms/replicate/command.ts @@ -1,6 +1,12 @@ import { z } from "zod"; import type { CommandMeta } from "@/src/core/types"; -import { cvmIdArgument, interactiveOption } from "@/src/core/common-flags"; +import { + cvmIdArgument, + interactiveOption, + privateKeyOption, + rpcUrlOption, + transactionHashOption, +} from "@/src/core/common-flags"; export const cvmsReplicateCommandMeta: CommandMeta = { name: "replicate", @@ -10,42 +16,31 @@ export const cvmsReplicateCommandMeta: CommandMeta = { options: [ { name: "node-id", - description: "Node ID for replica", + aliases: ["teepod-id"], + description: "Target node ID for the replica.", type: "string", target: "nodeId", }, { name: "compose-hash", description: - "Explicit compose hash to replicate. Required when the source app has multiple live instances", + "Compose hash to replicate. Required when the source app has multiple live instances.", type: "string", target: "composeHash", }, { name: "env-file", shorthand: "e", - description: "Path to environment file", + description: "Path to environment file.", type: "string", target: "envFile", }, - { - name: "private-key", - description: "Private key for signing transactions.", - type: "string", - target: "privateKey", - group: "advanced", - }, - { - name: "rpc-url", - description: "RPC URL for the blockchain.", - type: "string", - target: "rpcUrl", - group: "advanced", - }, + privateKeyOption, + rpcUrlOption, { name: "prepare-only", description: - "Only prepare the replica (generate commit token) without performing on-chain operations.", + "Prepare the replica and generate a commit token. Skips all on-chain operations.", type: "boolean", target: "prepareOnly", group: "advanced", @@ -53,26 +48,19 @@ export const cvmsReplicateCommandMeta: CommandMeta = { { name: "commit", description: - "Commit a previously prepared replica using a commit token. Requires --token and --compose-hash.", + "Commit a previously prepared replica using a commit token. Requires --token, --compose-hash, and --transaction-hash.", type: "boolean", target: "commit", group: "advanced", }, { name: "token", - description: "Commit token from a prepare-only replica request", + description: "Commit token from a prepare-only replica request.", type: "string", target: "token", group: "advanced", }, - { - name: "transaction-hash", - description: - "Transaction hash proving on-chain compose hash registration. Use already-registered for state-only verification.", - type: "string", - target: "transactionHash", - group: "advanced", - }, + transactionHashOption, interactiveOption, ], examples: [ diff --git a/cli/src/commands/cvms/replicate/index.ts b/cli/src/commands/cvms/replicate/index.ts index a60d67c3..ba127768 100644 --- a/cli/src/commands/cvms/replicate/index.ts +++ b/cli/src/commands/cvms/replicate/index.ts @@ -282,9 +282,15 @@ async function commitReplica( } async function runCvmsReplicateCommand( - input: CvmsReplicateCommandInput, + rawInput: CvmsReplicateCommandInput, context: CommandContext, ): Promise { + // Fall back to ETH_RPC_URL env var (foundry/cast convention) + const input: CvmsReplicateCommandInput = { + ...rawInput, + rpcUrl: rawInput.rpcUrl || process.env.ETH_RPC_URL, + }; + try { if (!context.cvmId) { context.fail( diff --git a/cli/src/commands/cvms/resize/index.ts b/cli/src/commands/cvms/resize/index.ts index d36fd3a8..e3b07045 100644 --- a/cli/src/commands/cvms/resize/index.ts +++ b/cli/src/commands/cvms/resize/index.ts @@ -80,7 +80,7 @@ async function runCvmsResizeCommand( } try { - const client = await getClient(); + const client = await getClient(context); const infoResult = await safeGetCvmInfo(client, context.cvmId); if (!infoResult.success) { diff --git a/cli/src/commands/cvms/restart/index.ts b/cli/src/commands/cvms/restart/index.ts index 30bcb646..e003794d 100644 --- a/cli/src/commands/cvms/restart/index.ts +++ b/cli/src/commands/cvms/restart/index.ts @@ -24,7 +24,8 @@ async function runCvmsRestartCommand( } try { - const client = await getClient(); + const client = await getClient(context); + const isJson = context.globalOptions?.json === true; // Check if CVM is ready before restarting (not in_progress) const infoResult = await safeGetCvmInfo(client, context.cvmId); @@ -42,7 +43,7 @@ async function runCvmsRestartCommand( ); // Wait for CVM to be ready using existing utility - await waitForCvmReady(cvmInfo.vm_uuid, 300000); + await waitForCvmReady(cvmInfo.vm_uuid, 300000, context); } const spinner = logger.startSpinner("Restarting CVM"); @@ -56,11 +57,19 @@ async function runCvmsRestartCommand( spinner.stop(true); if (!result.success) { - logger.error(`Failed to restart CVM: ${result.error.message}`); + context.fail(`Failed to restart CVM: ${result.error.message}`); return 1; } const response = result.data as VM; + if (isJson) { + context.success({ + cvm: response, + dashboardUrl: `${CLOUD_URL}/dashboard/cvms/app_${response.app_id}`, + message: "CVM restart requested", + }); + return 0; + } logger.break(); logger.keyValueTable( @@ -83,7 +92,7 @@ ${CLOUD_URL}/dashboard/cvms/app_${response.app_id}`, ); return 0; } catch (error) { - logger.error("Failed to restart CVM"); + context.fail("Failed to restart CVM"); logger.logDetailedError(error); return 1; } diff --git a/cli/src/commands/cvms/runtime-config/index.ts b/cli/src/commands/cvms/runtime-config/index.ts index 35cad7b0..03b7072a 100644 --- a/cli/src/commands/cvms/runtime-config/index.ts +++ b/cli/src/commands/cvms/runtime-config/index.ts @@ -22,7 +22,7 @@ async function runCvmsRuntimeConfigCommand( } try { - const client = await getClient(); + const client = await getClient(context); const result = await safeGetCvmUserConfig(client, context.cvmId); if (!result.success) { diff --git a/cli/src/commands/cvms/start/index.ts b/cli/src/commands/cvms/start/index.ts index 6915e707..622921bd 100644 --- a/cli/src/commands/cvms/start/index.ts +++ b/cli/src/commands/cvms/start/index.ts @@ -23,7 +23,8 @@ async function runCvmsStartCommand( } try { - const client = await getClient(); + const client = await getClient(context); + const isJson = context.globalOptions?.json === true; const spinner = logger.startSpinner("Starting CVM"); const cvmId = context.cvmId; @@ -34,11 +35,19 @@ async function runCvmsStartCommand( spinner.stop(true); if (!result.success) { - logger.error(`Failed to start CVM: ${result.error.message}`); + context.fail(`Failed to start CVM: ${result.error.message}`); return 1; } const response = result.data as VM; + if (isJson) { + context.success({ + cvm: response, + dashboardUrl: `${CLOUD_URL}/dashboard/cvms/app_${response.app_id}`, + message: "CVM start requested", + }); + return 0; + } logger.break(); logger.keyValueTable( @@ -58,7 +67,7 @@ ${CLOUD_URL}/dashboard/cvms/app_${response.app_id}`, ); return 0; } catch (error) { - logger.error("Failed to start CVM"); + context.fail("Failed to start CVM"); logger.logDetailedError(error); return 1; } diff --git a/cli/src/commands/cvms/stop/index.ts b/cli/src/commands/cvms/stop/index.ts index 8c570996..71c6d44c 100644 --- a/cli/src/commands/cvms/stop/index.ts +++ b/cli/src/commands/cvms/stop/index.ts @@ -23,7 +23,8 @@ async function runCvmsStopCommand( } try { - const client = await getClient(); + const client = await getClient(context); + const isJson = context.globalOptions?.json === true; const spinner = logger.startSpinner("Stopping CVM"); const cvmId = context.cvmId; @@ -34,11 +35,19 @@ async function runCvmsStopCommand( spinner.stop(true); if (!result.success) { - logger.error(`Failed to stop CVM: ${result.error.message}`); + context.fail(`Failed to stop CVM: ${result.error.message}`); return 1; } const response = result.data as VM; + if (isJson) { + context.success({ + cvm: response, + dashboardUrl: `${CLOUD_URL}/dashboard/cvms/app_${response.app_id}`, + message: "CVM stop requested", + }); + return 0; + } logger.break(); logger.keyValueTable( @@ -58,7 +67,7 @@ ${CLOUD_URL}/dashboard/cvms/app_${response.app_id}`, ); return 0; } catch (error) { - logger.error("Failed to stop CVM"); + context.fail("Failed to stop CVM"); logger.logDetailedError(error); return 1; } diff --git a/cli/src/commands/cvms/upgrade/index.ts b/cli/src/commands/cvms/upgrade/index.ts index d05f8a7b..c756ebfe 100644 --- a/cli/src/commands/cvms/upgrade/index.ts +++ b/cli/src/commands/cvms/upgrade/index.ts @@ -54,7 +54,7 @@ async function runCvmsUpgradeCommand( return 1; } - const client = await getClient(); + const client = await getClient(context); const infoResult = await safeGetCvmInfo(client, context.cvmId); if (!infoResult.success) { diff --git a/cli/src/commands/deploy/command.ts b/cli/src/commands/deploy/command.ts index 5deef2c5..39efe22f 100644 --- a/cli/src/commands/deploy/command.ts +++ b/cli/src/commands/deploy/command.ts @@ -8,6 +8,9 @@ import { import { cvmIdOption, interactiveOption, + privateKeyOption, + rpcUrlOption, + transactionHashOption, uuidOption, } from "@/src/core/common-flags"; @@ -158,20 +161,8 @@ export const deployCommandMeta: CommandMeta = { target: "preLaunchScript", group: "advanced", }, - { - name: "private-key", - description: "Private key for signing transactions.", - type: "string", - target: "privateKey", - group: "advanced", - }, - { - name: "rpc-url", - description: "RPC URL for the blockchain.", - type: "string", - target: "rpcUrl", - group: "advanced", - }, + privateKeyOption, + rpcUrlOption, { name: "wait", description: "Wait for deployment/update completion", @@ -197,7 +188,7 @@ export const deployCommandMeta: CommandMeta = { { name: "prepare-only", description: - "Only prepare the update (generate commit token) without performing on-chain operations. For multisig workflows.", + "Prepare the update and generate a commit token. Skips all on-chain operations. Intended for multisig workflows.", type: "boolean", target: "prepareOnly", group: "advanced", @@ -205,33 +196,27 @@ export const deployCommandMeta: CommandMeta = { { name: "commit", description: - "Commit a previously prepared update using a commit token. Requires --token, --compose-hash, and --transaction-hash.", + "Commit a previously prepared update using a commit token. Requires --token; --compose-hash and --transaction-hash are read from the token when omitted.", type: "boolean", target: "commit", group: "advanced", }, { name: "token", - description: "Commit token from a prepare-only update", + description: "Commit token from a prepare-only update.", type: "string", target: "token", group: "advanced", }, { name: "compose-hash", - description: "Compose hash from a prepare-only update", - type: "string", - target: "composeHash", - group: "advanced", - }, - { - name: "transaction-hash", description: - "Transaction hash proving on-chain compose hash registration", + "Compose hash from a prepare-only update. Optional in --commit mode when the token can provide it.", type: "string", - target: "transactionHash", + target: "composeHash", group: "advanced", }, + transactionHashOption, { name: "public-logs", description: "Make CVM logs publicly accessible (default: true)", diff --git a/cli/src/commands/deploy/handler.ts b/cli/src/commands/deploy/handler.ts index 435cf56a..dda91265 100644 --- a/cli/src/commands/deploy/handler.ts +++ b/cli/src/commands/deploy/handler.ts @@ -198,12 +198,15 @@ function handleProvisionError( const API_VERSION = "2025-10-28" as const; async function getApiClient({ + context, apiToken, interactive, -}: Readonly>): Promise< - Client -> { - const resolved = resolveAuthForContext(undefined, { apiToken }); +}: Readonly< + Pick & { + context?: Pick; + } +>): Promise> { + const resolved = resolveAuthForContext(context, { apiToken }); if (resolved.apiKey) { return createClient({ apiKey: resolved.apiKey, @@ -922,6 +925,7 @@ const updateCvm = async ( envs: EnvVar[] | undefined, client: Client, stdout: NodeJS.WriteStream, + context?: Pick, preLaunchScriptContent?: string, ) => { const cvm_result = await safeGetCvmInfo(client, { @@ -1226,6 +1230,7 @@ const updateCvm = async ( await waitForCvmReady( validatedOptions.uuid as string, 300000, // 5 minutes timeout + context, ); } catch (error: unknown) { logger.logDetailedError(error, "Wait for CVM Ready"); @@ -1325,7 +1330,7 @@ export async function runDeploy( // commit-update endpoint is token-based (no API key required), // but we still need a client with the correct base URL. if (input.commit) { - const resolved = resolveAuthForContext(undefined, { + const resolved = resolveAuthForContext(context, { apiToken: input.apiToken, }); const client = createClient({ @@ -1348,6 +1353,7 @@ export async function runDeploy( input.compose || context.projectConfig.compose_file; const client = await getApiClient({ + context, apiToken: input.apiToken, interactive: input.interactive, }); @@ -1394,6 +1400,8 @@ export async function runDeploy( : context.projectConfig.env_file ? [context.projectConfig.env_file] : undefined, + // Fall back to ETH_RPC_URL env var (foundry/cast convention) + rpcUrl: input.rpcUrl || process.env.ETH_RPC_URL, }; const envs = await resolveEnvVars(optionsWithEnvFile); @@ -1425,6 +1433,7 @@ export async function runDeploy( envs, client, context.stdout, + context, preLaunchScriptContent, ); } else { diff --git a/cli/src/commands/envs/encrypt/index.ts b/cli/src/commands/envs/encrypt/index.ts index 20fc547e..12d60ca4 100644 --- a/cli/src/commands/envs/encrypt/index.ts +++ b/cli/src/commands/envs/encrypt/index.ts @@ -29,7 +29,7 @@ async function runEnvsEncryptCommand( return 1; } - const client = await getClient(); + const client = await getClient(context); const result = await safeGetCvmInfo(client, context.cvmId); if (!result.success) { diff --git a/cli/src/commands/envs/update/command.ts b/cli/src/commands/envs/update/command.ts index 0cec103a..296dc5f6 100644 --- a/cli/src/commands/envs/update/command.ts +++ b/cli/src/commands/envs/update/command.ts @@ -1,6 +1,11 @@ import { z } from "zod"; import type { CommandMeta } from "@/src/core/types"; -import { cvmIdArgument, interactiveOption } from "@/src/core/common-flags"; +import { + cvmIdArgument, + interactiveOption, + privateKeyOption, + rpcUrlOption, +} from "@/src/core/common-flags"; export const envsUpdateCommandMeta: CommandMeta = { name: "update", @@ -27,21 +32,8 @@ export const envsUpdateCommandMeta: CommandMeta = { target: "encryptedEnv", group: "basic", }, - { - name: "private-key", - description: - "Private key for signing on-chain transactions (or set PRIVATE_KEY env var)", - type: "string", - target: "privateKey", - group: "advanced", - }, - { - name: "rpc-url", - description: "RPC URL for the blockchain", - type: "string", - target: "rpcUrl", - group: "advanced", - }, + privateKeyOption, + rpcUrlOption, ], examples: [ { diff --git a/cli/src/commands/envs/update/index.ts b/cli/src/commands/envs/update/index.ts index 7332c966..4b94de26 100644 --- a/cli/src/commands/envs/update/index.ts +++ b/cli/src/commands/envs/update/index.ts @@ -17,9 +17,15 @@ import { } from "./command"; async function runEnvsUpdateCommand( - input: EnvsUpdateCommandInput, + rawInput: EnvsUpdateCommandInput, context: CommandContext, ): Promise { + // Fall back to ETH_RPC_URL env var (foundry/cast convention) + const input: EnvsUpdateCommandInput = { + ...rawInput, + rpcUrl: rawInput.rpcUrl || process.env.ETH_RPC_URL, + }; + if (!context.cvmId) { context.fail( "No CVM ID provided. Pass a CVM identifier or set it in phala.toml.", @@ -43,7 +49,7 @@ async function runEnvsUpdateCommand( } try { - const client = await getClient(); + const client = await getClient(context); const cvmResult = await safeGetCvmInfo(client, context.cvmId); if (!cvmResult.success) { diff --git a/cli/src/commands/help/command.ts b/cli/src/commands/help/command.ts new file mode 100644 index 00000000..c5752cba --- /dev/null +++ b/cli/src/commands/help/command.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; +import type { CommandMeta } from "@/src/core/types"; +import { helpTopics } from "./topics.generated"; + +function buildExamples(): { name: string; value: string }[] { + const examples: { name: string; value: string }[] = [ + { + name: "List available help topics", + value: "phala help", + }, + ]; + for (const topic of Object.values(helpTopics)) { + examples.push({ + name: `Show "${topic.name}" topic`, + value: `phala help ${topic.name}`, + }); + } + return examples; +} + +export const helpCommandMeta: CommandMeta = { + name: "help", + category: "advanced", + description: "Show help topics bundled with the CLI", + stability: "stable", + arguments: [ + { + name: "topic", + description: "Help topic name (see `phala help` for the list)", + required: false, + target: "topic", + }, + ], + examples: buildExamples(), +}; + +export const helpCommandSchema = z.object({ + topic: z.string().optional(), +}); + +export type HelpCommandInput = z.infer; diff --git a/cli/src/commands/help/index.ts b/cli/src/commands/help/index.ts new file mode 100644 index 00000000..74219794 --- /dev/null +++ b/cli/src/commands/help/index.ts @@ -0,0 +1,59 @@ +import { defineCommand } from "@/src/core/define-command"; +import type { CommandContext } from "@/src/core/types"; +import { + helpCommandMeta, + helpCommandSchema, + type HelpCommandInput, +} from "./command"; +import { helpTopics } from "./topics.generated"; + +function listTopics(): string { + const topics = Object.values(helpTopics); + if (topics.length === 0) { + return "This build contains no help topics.\n"; + } + const lines: string[] = ["Available help topics:", ""]; + for (const topic of topics) { + lines.push(` ${topic.name.padEnd(18)}${topic.description}`.trimEnd()); + } + lines.push(""); + lines.push('Use "phala help " to read a topic.'); + return `${lines.join("\n")}\n`; +} + +async function runHelpCommand( + input: HelpCommandInput, + context: CommandContext, +): Promise { + if (!input.topic) { + context.stdout.write(listTopics()); + return 0; + } + + const name = input.topic.trim().toLowerCase(); + const topic = helpTopics[name]; + if (!topic) { + const available = Object.keys(helpTopics).sort().join(", "); + context.fail( + available + ? `Unknown help topic "${input.topic}". Available: ${available}` + : `Unknown help topic "${input.topic}". This build contains no help topics.`, + ); + return 1; + } + + const content = topic.content.endsWith("\n") + ? topic.content + : `${topic.content}\n`; + context.stdout.write(content); + return 0; +} + +export const helpCommand = defineCommand({ + path: ["help"], + meta: helpCommandMeta, + schema: helpCommandSchema, + handler: runHelpCommand, +}); + +export default helpCommand; diff --git a/cli/src/commands/help/topics.generated.ts b/cli/src/commands/help/topics.generated.ts new file mode 100644 index 00000000..439257d9 --- /dev/null +++ b/cli/src/commands/help/topics.generated.ts @@ -0,0 +1,18 @@ +// AUTO-GENERATED by scripts/gen-help-topics.ts — do not edit manually. +// Run `bun run gen:help` to regenerate after editing docs/help/topics/*.md. + +export interface HelpTopic { + readonly name: string; + readonly description: string; + readonly content: string; +} + +export const helpTopics: Readonly> = Object.freeze({ + envs: Object.freeze({ + name: "envs", + description: + "All environment variables recognized by the Phala CLI. Command-line flags always take precedence over environment variables when both are set.", + content: + '# Environment Variables\n\nAll environment variables recognized by the Phala CLI. Command-line flags always take precedence over environment variables when both are set.\n\n## Authentication\n\n- `PHALA_CLOUD_API_KEY` — API token. Overrides the token stored by `phala login`.\n- `PHALA_CLOUD_API_PREFIX` — API base URL. Default: `https://cloud-api.phala.com/api/v1`.\n\nThe `@phala/cloud` JS SDK also reads both directly when `createClient()` is\ncalled without explicit config.\n\n## On-chain KMS\n\nThese variables apply to `deploy`, `allow-devices`, `cvms replicate`, and\n`envs update` when the CVM uses Ethereum or Base KMS:\n\n- `PRIVATE_KEY` — Private key for signing on-chain transactions.\n Precedence: `--private-key` > `PRIVATE_KEY`.\n- `ETH_RPC_URL` — RPC endpoint. Follows the foundry/cast convention, so an\n existing `ETH_RPC_URL` from those tools works automatically.\n Precedence: `--rpc-url` > `ETH_RPC_URL` > chain default.\n\nExample:\n\n export ETH_RPC_URL=https://mainnet.base.org\n export PRIVATE_KEY=0x...\n phala deploy --kms base\n\n## Debug logging\n\n`DEBUG` has two independent consumers that behave differently:\n\n- **CLI debug output** — any non-empty value enables the CLI\'s own debug\n messages on stderr (gray prefix).\n- **API request logging** — the `@phala/cloud` JS SDK uses the `debug` npm\n package with namespace `phala::api-client`. Set `DEBUG=phala::api-client`\n to print every HTTP request in cURL-like format (method, URL, headers,\n body).\n\nExamples:\n\n # CLI debug only\n DEBUG=1 phala deploy\n\n # API request cURL logging (also triggers CLI debug, since "phala::api-client" is truthy)\n DEBUG=phala::api-client phala deploy\n\n # Everything\n DEBUG=* phala deploy\n\n## Self-update\n\n- `PHALA_UPDATE_CHANNEL` — Release channel for `phala self update` (e.g. `latest`, `beta`).\n- `PHALA_DISABLE_UPDATE_CHECK` — Any truthy value disables the background update notice.\n- `CI` — Standard CI flag. Any truthy value disables the update notice.\n\n## Miscellaneous\n\n- `CLOUD_URL` — Override the Phala Cloud portal URL used in printed links.\n Default: `https://cloud.phala.com`.\n\n## Internal (undocumented)\n\n- `PHALA_CLOUD_DIR` — Override the credentials directory. Default: `~/.phala-cloud`.\n Testing only.\n\n## See also\n\n- `phala api --help` for API-specific flags and env vars\n- `phala login --help` for authentication flow\n- `phala deploy --help` for on-chain KMS flags\n', + }), +}); diff --git a/cli/src/commands/instance-types/index.ts b/cli/src/commands/instance-types/index.ts index 0560b986..30fc0c83 100644 --- a/cli/src/commands/instance-types/index.ts +++ b/cli/src/commands/instance-types/index.ts @@ -26,7 +26,7 @@ async function runInstanceTypesCommand( context: CommandContext, ): Promise { try { - const client = await getClient(); + const client = await getClient(context); // If family is specified, list only that family if (input.family) { diff --git a/cli/src/commands/kms/chain/index.ts b/cli/src/commands/kms/chain/index.ts index 5ef3a4bf..5a3a769e 100644 --- a/cli/src/commands/kms/chain/index.ts +++ b/cli/src/commands/kms/chain/index.ts @@ -16,7 +16,7 @@ function createChainHandler(chain: string) { context: CommandContext, ): Promise { try { - const client = await getClient(); + const client = await getClient(context); const result = await safeGetKmsOnChainDetail(client, { chain, diff --git a/cli/src/commands/kms/list/index.ts b/cli/src/commands/kms/list/index.ts index 5e4e7c9d..cba07169 100644 --- a/cli/src/commands/kms/list/index.ts +++ b/cli/src/commands/kms/list/index.ts @@ -21,7 +21,7 @@ async function runKmsListCommand( context: CommandContext, ): Promise { try { - const client = await getClient(); + const client = await getClient(context); const result = await safeGetKmsList(client, { is_onchain: true, diff --git a/cli/src/commands/logout/index.ts b/cli/src/commands/logout/index.ts index 3454d120..34a8cc78 100644 --- a/cli/src/commands/logout/index.ts +++ b/cli/src/commands/logout/index.ts @@ -11,19 +11,26 @@ import { async function runLogoutCommand( _input: LogoutCommandInput, - _context: CommandContext, + context: CommandContext, ): Promise { try { const current = loadCredentialsFile(); const profile = current?.current_profile; removeProfile(); - logger.success( - profile - ? `Credentials removed successfully (profile: ${profile})` - : "Credentials removed successfully", - ); + const message = profile + ? `Credentials removed successfully (profile: ${profile})` + : "Credentials removed successfully"; + if (context.globalOptions?.json) { + context.success({ message, profile: profile || null }); + return 0; + } + logger.success(message); return 0; } catch (error) { + if (context.globalOptions?.json) { + context.fail("Failed to remove credentials"); + return 1; + } logger.error("Failed to remove credentials"); logger.logDetailedError(error); return 1; diff --git a/cli/src/commands/nodes/list/index.ts b/cli/src/commands/nodes/list/index.ts index 0ffd5685..c2e91c28 100644 --- a/cli/src/commands/nodes/list/index.ts +++ b/cli/src/commands/nodes/list/index.ts @@ -15,7 +15,7 @@ async function runNodesListCommand( context: CommandContext, ): Promise { try { - const client = await getClient(); + const client = await getClient(context); // Get current workspace to obtain teamSlug const userResult = await safeGetCurrentUser(client); diff --git a/cli/src/commands/os-images/index.ts b/cli/src/commands/os-images/index.ts index b7b0433c..2e34e2bb 100644 --- a/cli/src/commands/os-images/index.ts +++ b/cli/src/commands/os-images/index.ts @@ -15,7 +15,7 @@ async function runOsImagesCommand( context: CommandContext, ): Promise { try { - const client = await getClient(); + const client = await getClient(context); const isDev = input.dev ? true : input.prod ? false : undefined; diff --git a/cli/src/commands/profiles/command.ts b/cli/src/commands/profiles/command.ts index 9d17952b..b93bb97b 100644 --- a/cli/src/commands/profiles/command.ts +++ b/cli/src/commands/profiles/command.ts @@ -8,6 +8,7 @@ export const profilesCommandMeta: CommandMeta = { stability: "stable", arguments: [], options: [], + aliases: ["list"], }; export const profilesCommandSchema = z.object({}); diff --git a/cli/src/commands/profiles/delete/command.ts b/cli/src/commands/profiles/delete/command.ts index dd8c5c1c..80a6a714 100644 --- a/cli/src/commands/profiles/delete/command.ts +++ b/cli/src/commands/profiles/delete/command.ts @@ -4,7 +4,7 @@ import type { CommandMeta } from "@/src/core/types"; export const profilesDeleteCommandMeta: CommandMeta = { name: "delete", category: "profile", - description: "Delete a profile", + description: "Delete an auth profile", stability: "stable", arguments: [ { @@ -15,6 +15,7 @@ export const profilesDeleteCommandMeta: CommandMeta = { }, ], options: [], + aliases: ["rm"], }; export const profilesDeleteCommandSchema = z.object({ diff --git a/cli/src/commands/profiles/delete/index.ts b/cli/src/commands/profiles/delete/index.ts index b6757b78..85ceab9f 100644 --- a/cli/src/commands/profiles/delete/index.ts +++ b/cli/src/commands/profiles/delete/index.ts @@ -14,22 +14,35 @@ import { async function runProfilesDeleteCommand( input: ProfilesDeleteCommandInput, - _context: CommandContext, + context: CommandContext, ): Promise { try { const wasActive = getCurrentProfile()?.name === input.profileName; const profilesBefore = listProfiles(); if (!profilesBefore.includes(input.profileName)) { + if (context.globalOptions?.json) { + context.fail(`Profile "${input.profileName}" not found`); + return 1; + } logger.error(`Profile "${input.profileName}" not found`); return 1; } removeProfile(input.profileName); - logger.success(`Deleted profile "${input.profileName}"`); + const newCurrent = getCurrentProfile(); + + if (context.globalOptions?.json) { + context.success({ + deleted: input.profileName, + wasActive, + currentProfile: newCurrent?.name || null, + }); + return 0; + } + logger.success(`Deleted profile "${input.profileName}"`); if (wasActive) { - const newCurrent = getCurrentProfile(); if (newCurrent) { logger.info(`Switched to profile "${newCurrent.name}"`); } else { @@ -40,6 +53,10 @@ async function runProfilesDeleteCommand( return 0; } catch (error) { const message = error instanceof Error ? error.message : String(error); + if (context.globalOptions?.json) { + context.fail(message); + return 1; + } logger.error(message); return 1; } diff --git a/cli/src/commands/profiles/index.ts b/cli/src/commands/profiles/index.ts index 1e6d5251..425803a7 100644 --- a/cli/src/commands/profiles/index.ts +++ b/cli/src/commands/profiles/index.ts @@ -15,12 +15,16 @@ import { async function runProfilesCommand( _input: ProfilesCommandInput, - _context: CommandContext, + context: CommandContext, ): Promise { const profiles = listProfiles(); const currentProfile = getCurrentProfile(); if (profiles.length === 0) { + if (context.globalOptions?.json) { + context.success({ profiles: [] }); + return 0; + } logger.warn("No profiles found. Please login first."); return 0; } @@ -39,6 +43,19 @@ async function runProfilesCommand( }; }); + if (context.globalOptions?.json) { + context.success({ + profiles: profiles.map((profile) => ({ + name: profile, + workspace: credentials?.profiles[profile]?.workspace?.name || null, + user: credentials?.profiles[profile]?.user?.username || null, + apiEndpoint: credentials?.profiles[profile]?.api_prefix || null, + current: currentProfile?.name === profile, + })), + }); + return 0; + } + printTable(columns, rows); return 0; } diff --git a/cli/src/commands/profiles/rename/command.ts b/cli/src/commands/profiles/rename/command.ts index 77e66350..744a2502 100644 --- a/cli/src/commands/profiles/rename/command.ts +++ b/cli/src/commands/profiles/rename/command.ts @@ -4,7 +4,7 @@ import type { CommandMeta } from "@/src/core/types"; export const profilesRenameCommandMeta: CommandMeta = { name: "rename", category: "profile", - description: "Rename a profile", + description: "Rename an auth profile", stability: "stable", arguments: [ { @@ -21,6 +21,7 @@ export const profilesRenameCommandMeta: CommandMeta = { }, ], options: [], + aliases: ["mv"], }; export const profilesRenameCommandSchema = z.object({ diff --git a/cli/src/commands/profiles/rename/index.ts b/cli/src/commands/profiles/rename/index.ts index e06d241c..aca93f34 100644 --- a/cli/src/commands/profiles/rename/index.ts +++ b/cli/src/commands/profiles/rename/index.ts @@ -10,11 +10,19 @@ import { async function runProfilesRenameCommand( input: ProfilesRenameCommandInput, - _context: CommandContext, + context: CommandContext, ): Promise { try { const wasActive = getCurrentProfile()?.name === input.oldName; renameProfile(input.oldName, input.newName); + if (context.globalOptions?.json) { + context.success({ + oldName: input.oldName, + newName: input.newName, + wasActive, + }); + return 0; + } logger.success(`Renamed profile "${input.oldName}" to "${input.newName}"`); if (wasActive) { logger.info(`Current profile updated to "${input.newName}"`); @@ -22,6 +30,10 @@ async function runProfilesRenameCommand( return 0; } catch (error) { const message = error instanceof Error ? error.message : String(error); + if (context.globalOptions?.json) { + context.fail(message); + return 1; + } logger.error(message); return 1; } diff --git a/cli/src/commands/profiles/use/command.ts b/cli/src/commands/profiles/use/command.ts index 3a275a17..cf56afd0 100644 --- a/cli/src/commands/profiles/use/command.ts +++ b/cli/src/commands/profiles/use/command.ts @@ -4,7 +4,7 @@ import type { CommandMeta } from "@/src/core/types"; export const profilesUseCommandMeta: CommandMeta = { name: "use", category: "profile", - description: "Switch to a profile", + description: "Switch to an auth profile", stability: "stable", arguments: [ { diff --git a/cli/src/commands/profiles/use/index.ts b/cli/src/commands/profiles/use/index.ts index db305732..2b3c0897 100644 --- a/cli/src/commands/profiles/use/index.ts +++ b/cli/src/commands/profiles/use/index.ts @@ -26,30 +26,43 @@ function isExitPromptError(error: unknown): boolean { async function runProfilesUseCommand( input: ProfilesUseCommandInput, - _context: CommandContext, + context: CommandContext, ): Promise { try { if (input.interactive) { - return await interactiveSwitch(); + return await interactiveSwitch(context); } if (!input.profileName) { + if (context.globalOptions?.json) { + context.fail("Missing required argument: profile-name"); + return 1; + } logger.error("Missing required argument: profile-name"); logger.info("Usage: phala profiles use "); logger.info(" phala profiles use -i"); return 1; } - return directSwitch(input.profileName); + return directSwitch(input.profileName, context); } catch (error) { if (isExitPromptError(error)) { console.log(); + if (context.globalOptions?.json) { + context.success({ cancelled: true }); + return 0; + } logger.info("Switch cancelled."); return 0; } const message = error instanceof Error ? error.message : String(error); + if (context.globalOptions?.json) { + context.fail(message); + return 1; + } + if (message.includes("not found")) { logger.error(message); const profiles = listProfiles(); @@ -69,16 +82,28 @@ async function runProfilesUseCommand( } } -async function interactiveSwitch(): Promise { +async function interactiveSwitch(context: CommandContext): Promise { const profiles = listProfiles(); const currentProfile = getCurrentProfile(); if (profiles.length === 0) { + if (context.globalOptions?.json) { + context.fail("No profiles found. Please login first."); + return 1; + } logger.warn("No profiles found. Please login first."); return 1; } if (profiles.length === 1) { + if (context.globalOptions?.json) { + context.success({ + profile: profiles[0], + workspace: currentProfile?.info.workspace?.name || null, + changed: false, + }); + return 0; + } logger.info(`Only one profile available: ${profiles[0]}`); return 0; } @@ -106,12 +131,28 @@ async function interactiveSwitch(): Promise { ]); if (selectedProfile === currentProfile?.name) { + if (context.globalOptions?.json) { + context.success({ + profile: selectedProfile, + workspace: currentProfile?.info.workspace?.name || null, + changed: false, + }); + return 0; + } logger.info(`Already using profile "${selectedProfile}"`); return 0; } switchProfile(selectedProfile); const newProfile = getCurrentProfile(); + if (context.globalOptions?.json) { + context.success({ + profile: selectedProfile, + workspace: newProfile?.info.workspace?.name || null, + changed: true, + }); + return 0; + } logger.success(`Switched to profile "${selectedProfile}"`); if (newProfile?.info.workspace?.name) { logger.info(`Workspace: ${newProfile.info.workspace.name}`); @@ -119,16 +160,32 @@ async function interactiveSwitch(): Promise { return 0; } -function directSwitch(profileName: string): number { +function directSwitch(profileName: string, context: CommandContext): number { const currentProfile = getCurrentProfile(); if (currentProfile?.name === profileName) { + if (context.globalOptions?.json) { + context.success({ + profile: profileName, + workspace: currentProfile.info.workspace?.name || null, + changed: false, + }); + return 0; + } logger.info(`Already using profile "${profileName}"`); return 0; } switchProfile(profileName); const newProfile = getCurrentProfile(); + if (context.globalOptions?.json) { + context.success({ + profile: profileName, + workspace: newProfile?.info.workspace?.name || null, + changed: true, + }); + return 0; + } logger.success(`Switched to profile "${profileName}"`); if (newProfile?.info.workspace?.name) { logger.info(`Workspace: ${newProfile.info.workspace.name}`); diff --git a/cli/src/commands/ps/index.ts b/cli/src/commands/ps/index.ts index 57ddd457..4656bfe4 100644 --- a/cli/src/commands/ps/index.ts +++ b/cli/src/commands/ps/index.ts @@ -35,7 +35,7 @@ async function runPsCommand( } try { - const client = await getClient(); + const client = await getClient(context); const result = await safeGetCvmContainersStats(client, context.cvmId); if (!result.success) { diff --git a/cli/src/commands/ssh-keys/add/index.ts b/cli/src/commands/ssh-keys/add/index.ts index 360310ac..b58cfe2e 100644 --- a/cli/src/commands/ssh-keys/add/index.ts +++ b/cli/src/commands/ssh-keys/add/index.ts @@ -54,7 +54,7 @@ async function runSshKeysAddCommand( const keyName = input.name ?? `${hostname()}-${basename(keyFilePath, ".pub")}`; - const client = await getClient(); + const client = await getClient(context); const result = await safeCreateSshKey(client, { name: keyName, public_key: publicKey, diff --git a/cli/src/commands/ssh-keys/import-github/index.ts b/cli/src/commands/ssh-keys/import-github/index.ts index a8584d30..62f5117c 100644 --- a/cli/src/commands/ssh-keys/import-github/index.ts +++ b/cli/src/commands/ssh-keys/import-github/index.ts @@ -15,7 +15,7 @@ async function runSshKeysImportGithubCommand( context: CommandContext, ): Promise { try { - const client = await getClient(); + const client = await getClient(context); const spinner = logger.startSpinner( `Importing SSH keys from github.com/${input.githubUsername}`, ); diff --git a/cli/src/commands/ssh-keys/list/index.ts b/cli/src/commands/ssh-keys/list/index.ts index ee359323..1ca4a3c7 100644 --- a/cli/src/commands/ssh-keys/list/index.ts +++ b/cli/src/commands/ssh-keys/list/index.ts @@ -16,7 +16,7 @@ async function runSshKeysListCommand( context: CommandContext, ): Promise { try { - const client = await getClient(); + const client = await getClient(context); const result = await safeListSshKeys(client); if (!result.success) { diff --git a/cli/src/commands/ssh-keys/remove/index.ts b/cli/src/commands/ssh-keys/remove/index.ts index 26b8e703..8622596b 100644 --- a/cli/src/commands/ssh-keys/remove/index.ts +++ b/cli/src/commands/ssh-keys/remove/index.ts @@ -11,9 +11,11 @@ import { type SshKeysRemoveCommandInput, } from "./command"; -async function selectSshKey(): Promise { +async function selectSshKey( + context: Pick, +): Promise { const spinner = logger.startSpinner("Fetching SSH keys"); - const client = await getClient(); + const client = await getClient(context); const result = await safeListSshKeys(client); spinner.stop(true); @@ -53,7 +55,7 @@ async function runSshKeysRemoveCommand( let keyId = input.keyId; if (!keyId && input.interactive) { - keyId = await selectSshKey(); + keyId = await selectSshKey(context); if (!keyId) { return 0; } @@ -66,7 +68,7 @@ async function runSshKeysRemoveCommand( return 1; } - const client = await getClient(); + const client = await getClient(context); const result = await safeDeleteSshKey(client, { keyId }); if (!result.success) { diff --git a/cli/src/commands/ssh/index.ts b/cli/src/commands/ssh/index.ts index 9ccd28da..f796fe29 100644 --- a/cli/src/commands/ssh/index.ts +++ b/cli/src/commands/ssh/index.ts @@ -85,7 +85,7 @@ async function runSshCommand( if (!configGatewayDomain) { try { - const client = await getClient(); + const client = await getClient(context); const cvmInfo = await fetchCvmInfo(client, cvmId); instanceId = cvmInfo.appId; gatewayDomain = cvmInfo.gatewayDomain; diff --git a/cli/src/commands/switch/index.ts b/cli/src/commands/switch/index.ts index 7cec17ff..85a7aa24 100644 --- a/cli/src/commands/switch/index.ts +++ b/cli/src/commands/switch/index.ts @@ -26,30 +26,43 @@ function isExitPromptError(error: unknown): boolean { async function runSwitchCommand( input: SwitchCommandInput, - _context: CommandContext, + context: CommandContext, ): Promise { try { if (input.interactive) { - return await interactiveSwitch(); + return await interactiveSwitch(context); } if (!input.profileName) { + if (context.globalOptions?.json) { + context.fail("Missing required argument: profile-name"); + return 1; + } logger.error("Missing required argument: profile-name"); logger.info("Usage: phala switch "); logger.info(" phala switch -i"); return 1; } - return directSwitch(input.profileName); + return directSwitch(input.profileName, context); } catch (error) { if (isExitPromptError(error)) { console.log(); + if (context.globalOptions?.json) { + context.success({ cancelled: true }); + return 0; + } logger.info("Switch cancelled."); return 0; } const message = error instanceof Error ? error.message : String(error); + if (context.globalOptions?.json) { + context.fail(message); + return 1; + } + if (message.includes("not found")) { logger.error(message); const profiles = listProfiles(); @@ -69,16 +82,28 @@ async function runSwitchCommand( } } -async function interactiveSwitch(): Promise { +async function interactiveSwitch(context: CommandContext): Promise { const profiles = listProfiles(); const currentProfile = getCurrentProfile(); if (profiles.length === 0) { + if (context.globalOptions?.json) { + context.fail("No profiles found. Please login first."); + return 1; + } logger.warn("No profiles found. Please login first."); return 1; } if (profiles.length === 1) { + if (context.globalOptions?.json) { + context.success({ + profile: profiles[0], + workspace: currentProfile?.info.workspace?.name || null, + changed: false, + }); + return 0; + } logger.info(`Only one profile available: ${profiles[0]}`); return 0; } @@ -106,12 +131,28 @@ async function interactiveSwitch(): Promise { ]); if (selectedProfile === currentProfile?.name) { + if (context.globalOptions?.json) { + context.success({ + profile: selectedProfile, + workspace: currentProfile?.info.workspace?.name || null, + changed: false, + }); + return 0; + } logger.info(`Already using profile "${selectedProfile}"`); return 0; } switchProfile(selectedProfile); const newProfile = getCurrentProfile(); + if (context.globalOptions?.json) { + context.success({ + profile: selectedProfile, + workspace: newProfile?.info.workspace?.name || null, + changed: true, + }); + return 0; + } logger.success(`Switched to profile "${selectedProfile}"`); if (newProfile?.info.workspace?.name) { logger.info(`Workspace: ${newProfile.info.workspace.name}`); @@ -119,16 +160,32 @@ async function interactiveSwitch(): Promise { return 0; } -function directSwitch(profileName: string): number { +function directSwitch(profileName: string, context: CommandContext): number { const currentProfile = getCurrentProfile(); if (currentProfile?.name === profileName) { + if (context.globalOptions?.json) { + context.success({ + profile: profileName, + workspace: currentProfile.info.workspace?.name || null, + changed: false, + }); + return 0; + } logger.info(`Already using profile "${profileName}"`); return 0; } switchProfile(profileName); const newProfile = getCurrentProfile(); + if (context.globalOptions?.json) { + context.success({ + profile: profileName, + workspace: newProfile?.info.workspace?.name || null, + changed: true, + }); + return 0; + } logger.success(`Switched to profile "${profileName}"`); if (newProfile?.info.workspace?.name) { logger.info(`Workspace: ${newProfile.info.workspace.name}`); diff --git a/cli/src/core/common-flags.ts b/cli/src/core/common-flags.ts index 02a22198..f07d491f 100644 --- a/cli/src/core/common-flags.ts +++ b/cli/src/core/common-flags.ts @@ -25,9 +25,17 @@ export const interactiveOption: CommandOption = { group: "basic", }; +export const globalInteractiveOption: CommandOption = { + name: "interactive", + description: "Enable interactive mode for commands that support it", + type: "boolean", + target: "interactive", + group: "basic", +}; + export const apiTokenOption: CommandOption = { name: "api-token", - description: "API token used for authentication", + description: "API token for authenticating with Phala Cloud", type: "string", target: "apiToken", aliases: ["api-key"], @@ -45,6 +53,15 @@ export const jsonOption: CommandOption = { group: "basic", }; +export const profileOption: CommandOption = { + name: "profile", + description: "Temporarily use a different auth profile for this command", + type: "string", + target: "profile", + argumentName: "profile", + group: "basic", +}; + export const apiVersionOption: CommandOption = { name: "api-version", description: "API version to use (e.g. 2025-10-28, 2026-01-21)", @@ -53,14 +70,6 @@ export const apiVersionOption: CommandOption = { group: "advanced", }; -export const globalCommandOptions: readonly CommandOption[] = [ - helpOption, - versionOption, - apiTokenOption, - jsonOption, - apiVersionOption, -]; - export const commonAuthOptions: readonly CommandOption[] = [apiTokenOption]; /** @@ -97,3 +106,57 @@ export const uuidOption: CommandOption = { deprecated: true, group: "deprecated", }; + +/** + * Private key option (--private-key) + * Used by on-chain KMS commands for signing transactions. + * Falls back to PRIVATE_KEY env var. + */ +export const privateKeyOption: CommandOption = { + name: "private-key", + description: + "Private key for signing on-chain transactions (or set PRIVATE_KEY env var)", + type: "string", + target: "privateKey", + group: "advanced", +}; + +/** + * RPC URL option (--rpc-url) + * Used by on-chain KMS commands to override the default RPC endpoint. + * Falls back to ETH_RPC_URL env var (foundry/cast convention). + */ +export const rpcUrlOption: CommandOption = { + name: "rpc-url", + description: + "RPC URL for on-chain KMS transactions (or set ETH_RPC_URL env var)", + type: "string", + target: "rpcUrl", + group: "advanced", +}; + +/** + * Transaction hash option (--transaction-hash) + * Used by deploy and cvms replicate to prove on-chain compose hash registration. + * Accepts the literal sentinel `already-registered` to skip the proof and + * fall back to state-only verification. + */ +export const transactionHashOption: CommandOption = { + name: "transaction-hash", + description: + "Transaction hash proving on-chain compose hash registration. Pass `already-registered` to skip the proof and rely on state-only verification.", + type: "string", + target: "transactionHash", + group: "advanced", +}; + +export const globalCommandOptions: readonly CommandOption[] = [ + helpOption, + versionOption, + apiTokenOption, + jsonOption, + globalInteractiveOption, + cvmIdOption, + profileOption, + apiVersionOption, +]; diff --git a/cli/src/core/completion.ts b/cli/src/core/completion.ts index 86403aa2..81b986f8 100644 --- a/cli/src/core/completion.ts +++ b/cli/src/core/completion.ts @@ -1,5 +1,6 @@ +import { globalCommandOptions } from "./common-flags"; import type { CommandRegistry } from "./registry"; -import type { CommandPath } from "./types"; +import type { CommandOption, CommandPath } from "./types"; export interface CompletionContext { readonly registry: CommandRegistry; @@ -104,31 +105,43 @@ function getFlagCompletions( const node = registry.getNode(commandPath); if (!node?.command) { - // Global flags return { - suggestions: ["--help", "--version"].filter((flag) => - flag.startsWith(fragment), + suggestions: collectOptionCompletions(globalCommandOptions).filter( + (flag) => flag.startsWith(fragment), ), }; } - // Get command-specific flags - const options = node.command.meta.options || []; + const flags = Array.from( + new Set([ + ...collectOptionCompletions(globalCommandOptions), + ...collectOptionCompletions(node.command.meta.options || []), + ]), + ); + + const suggestions = flags.filter((flag) => flag.startsWith(fragment)); + + return { suggestions }; +} + +function collectOptionCompletions(options: readonly CommandOption[]): string[] { const flags: string[] = []; - // Add long flags for (const option of options) { - if (!option.hidden) { - flags.push(`--${option.name}`); + if (option.hidden) continue; + flags.push(`--${option.name}`); + if (option.shorthand) { + flags.push(`-${option.shorthand}`); + } + for (const alias of option.aliases ?? []) { + flags.push(`--${alias}`); + } + if (option.type === "boolean" && option.negatedName) { + flags.push(`--${option.negatedName}`); } } - // Add global flags - flags.push("--help", "--version"); - - const suggestions = flags.filter((flag) => flag.startsWith(fragment)); - - return { suggestions }; + return flags; } /** diff --git a/cli/src/core/dispatcher.ts b/cli/src/core/dispatcher.ts index 2a3881ed..eb83eafb 100644 --- a/cli/src/core/dispatcher.ts +++ b/cli/src/core/dispatcher.ts @@ -113,6 +113,7 @@ export async function dispatchCommand( const { definition, consumed } = resolved; const commandArgv = argv.slice(consumed.length); + const projectConfig = getProjectConfig(); try { const parsedArguments = parseCommandArguments( @@ -173,6 +174,23 @@ export async function dispatchCommand( parsedArguments, ); + const globalOptions = { + apiToken: + typeof schemaInput.options.apiToken === "string" + ? schemaInput.options.apiToken + : undefined, + json: schemaInput.options.json === true, + interactive: + schemaInput.options.json === true + ? false + : schemaInput.options.interactive === true, + profile: + typeof schemaInput.options.profile === "string" + ? schemaInput.options.profile + : undefined, + apiVersion: typeof rawApiVersion === "string" ? rawApiVersion : undefined, + } as const; + const mergedInput = { ...schemaInput.options, ...schemaInput.positionals, @@ -186,8 +204,7 @@ export async function dispatchCommand( // Always check for cvmId, even if not explicitly in mergedInput const rawCvmId = "cvmId" in mergedInput ? mergedInput.cvmId : undefined; - const isInteractive = - "interactive" in mergedInput && mergedInput.interactive === true; + const isInteractive = globalOptions.interactive; // DEBUG if (parsedArguments.flags["--debug"]) { @@ -227,7 +244,7 @@ export async function dispatchCommand( // Priority 3: phala.toml configuration (if nothing specified above) if (!cvmId) { - const projectCvmId = getProjectConfig().cvm_id; + const projectCvmId = projectConfig.cvm_id; if (projectCvmId) { cvmId = { id: projectCvmId }; } @@ -252,7 +269,8 @@ export async function dispatchCommand( stdout, stderr, stdin, - projectConfig: getProjectConfig(), + projectConfig, + globalOptions, cvmId, cli: { executableName, @@ -316,6 +334,17 @@ export async function dispatchCommand( } const parsedInput = definition.schema.parse(mergedInput); + const handlerInput = Object.freeze({ + ...parsedInput, + json: globalOptions.json, + interactive: globalOptions.interactive, + ...(globalOptions.apiToken !== undefined + ? { apiToken: globalOptions.apiToken } + : {}), + ...(globalOptions.profile !== undefined + ? { profile: globalOptions.profile } + : {}), + }); // DEBUG: Check context.cvmId before calling handler if (parsedArguments.flags["--debug"]) { @@ -325,7 +354,7 @@ export async function dispatchCommand( ); } - const result = await definition.run(parsedInput, context); + const result = await definition.run(handlerInput, context); const updateNotice = await updateNoticePromise; if (typeof result === "number") { if (result === 0 && updateNotice && !isInJsonMode()) { diff --git a/cli/src/core/fig-spec.ts b/cli/src/core/fig-spec.ts index ec41bf34..838021b4 100644 --- a/cli/src/core/fig-spec.ts +++ b/cli/src/core/fig-spec.ts @@ -3,8 +3,9 @@ * https://fig.io/docs */ +import { globalCommandOptions } from "./common-flags"; import type { CommandRegistry } from "./registry"; -import type { CommandPath } from "./types"; +import type { CommandOption, CommandPath } from "./types"; interface FigSpec { name: string; @@ -43,16 +44,7 @@ export function generateFigSpec( name: executableName, description: "CLI for Managing Phala Cloud Services", subcommands: [], - options: [ - { - name: ["-h", "--help"], - description: "Show help information", - }, - { - name: ["-v", "--version"], - description: "Show CLI version", - }, - ], + options: globalCommandOptions.map(toFigOption), }; // Generate subcommands recursively @@ -88,40 +80,20 @@ function generateSubcommandSpec( subcommands: [], }; - // Add command-specific options - if (node.command?.meta.options) { - for (const option of node.command.meta.options) { - if (option.hidden) continue; - - const names: string[] = [`--${option.name}`]; - if (option.shorthand) { - names.unshift(`-${option.shorthand}`); - } - - spec.options?.push({ - name: names, - description: option.description, - args: - option.type !== "boolean" - ? { - name: option.argumentName || "value", - } - : undefined, - }); - } - } - - // Add global options - spec.options?.push( - { - name: ["-h", "--help"], - description: "Show help information", - }, - { - name: ["-v", "--version"], - description: "Show CLI version", - }, + const options = [ + ...globalCommandOptions, + ...(node.command?.meta.options ?? []), + ]; + const uniqueOptions = Array.from( + new Map( + options + .filter((option) => !option.hidden) + .map((option) => [option.name, option]), + ).values(), ); + if (uniqueOptions.length > 0) { + spec.options = uniqueOptions.map(toFigOption); + } // Add arguments if (node.command?.meta.arguments) { @@ -150,6 +122,31 @@ function generateSubcommandSpec( return spec; } +function toFigOption(option: CommandOption): FigOption { + const names = [`--${option.name}`]; + if (option.shorthand) { + names.unshift(`-${option.shorthand}`); + } + for (const alias of option.aliases ?? []) { + names.push(`--${alias}`); + } + if (option.type === "boolean" && option.negatedName) { + names.push(`--${option.negatedName}`); + } + + return { + name: names, + description: option.description, + args: + option.type !== "boolean" + ? { + name: option.argumentName || "value", + } + : undefined, + hidden: option.hidden, + }; +} + /** * Export Fig spec as TypeScript module */ diff --git a/cli/src/core/help.test.ts b/cli/src/core/help.test.ts index db7d6d78..2f97c252 100644 --- a/cli/src/core/help.test.ts +++ b/cli/src/core/help.test.ts @@ -44,6 +44,80 @@ describe("formatCommandHelp", () => { expect(text).toContain("--legacy "); }); + test("should render a shared global option only once using command signature", () => { + const definition: CommandDefinition = { + path: ["demo-interactive"], + meta: { + name: "demo-interactive", + description: "Demo command (shared global option)", + stability: "stable", + options: [{ name: "interactive", shorthand: "i", type: "boolean" }], + }, + schema: z.object({}), + run: () => undefined, + }; + + const registry = new CommandRegistry(); + registry.registerCommand(definition); + const text = formatCommandHelp({ + executableName: "phala", + definition, + registry, + }); + + expect(text).toContain("Global options:"); + expect(text).toContain("-i, --interactive"); + expect(text).not.toContain("Basic options:\n -i, --interactive"); + }); + + test("should hide global profile option for login and api commands", () => { + const registry = new CommandRegistry(); + + const loginDefinition: CommandDefinition = { + path: ["login"], + meta: { + name: "login", + description: "Login command", + stability: "stable", + options: [{ name: "profile", type: "string" }], + }, + schema: z.object({}), + run: () => undefined, + }; + registry.registerCommand(loginDefinition); + const loginText = formatCommandHelp({ + executableName: "phala", + definition: loginDefinition, + registry, + }); + expect(loginText).toContain("Basic options:"); + expect(loginText).toContain("--profile "); + expect(loginText).not.toContain( + "Temporarily use a different auth profile for this command", + ); + + const apiDefinition: CommandDefinition = { + path: ["api"], + meta: { + name: "api", + description: "API command", + stability: "stable", + options: [], + }, + schema: z.object({}), + run: () => undefined, + }; + registry.registerCommand(apiDefinition); + const apiText = formatCommandHelp({ + executableName: "phala", + definition: apiDefinition, + registry, + }); + expect(apiText).not.toContain( + "Temporarily use a different auth profile for this command", + ); + }); + test("should omit global shorthand when it conflicts with command option shorthand", () => { const definition: CommandDefinition = { path: ["demo-conflict"], diff --git a/cli/src/core/help.ts b/cli/src/core/help.ts index 2174c5ef..e925aec3 100644 --- a/cli/src/core/help.ts +++ b/cli/src/core/help.ts @@ -1,3 +1,4 @@ +import { helpTopics } from "@/src/commands/help/topics.generated"; import { globalCommandOptions } from "./common-flags"; import type { CommandDefinition, @@ -13,6 +14,18 @@ function formatStabilityIndicator(stability: CommandStability): string { return ""; } +function appendHelpTopicsBlock(lines: string[], executableName: string): void { + const topics = Object.values(helpTopics); + if (topics.length === 0) return; + lines.push("Help topics:"); + for (const topic of topics) { + lines.push(` ${topic.name.padEnd(18)}${topic.description}`.trimEnd()); + } + lines.push(""); + lines.push(`Use "${executableName} help " to read a topic.`); + lines.push(""); +} + interface GlobalHelpOptions { readonly registry: CommandRegistry; readonly executableName: string; @@ -139,7 +152,8 @@ export function formatGlobalHelp(options: GlobalHelpOptions): string { lines.push(""); } - lines.push(""); + appendHelpTopicsBlock(lines, executableName); + lines.push("Global options:"); for (const option of globalCommandOptions) { const sig = formatOptionSignature(option); @@ -223,11 +237,28 @@ export function formatCommandHelp(options: CommandHelpOptions): string { } const allOptions = [...(definition.meta.options ?? [])]; - const visibleGlobalOptions = globalCommandOptions.filter((o) => !o.hidden); + const hiddenGlobalOptionNames = new Set(); + if ( + (definition.path.length === 1 && + (definition.path[0] === "login" || definition.path[0] === "api")) || + (definition.path.length === 2 && + definition.path[0] === "auth" && + definition.path[1] === "login") + ) { + hiddenGlobalOptionNames.add("profile"); + } + const visibleCommandOptions = allOptions.filter((o) => !o.hidden); - const globalOptionNames = new Set(globalCommandOptions.map((o) => o.name)); + const commandOptionsByName = new Map( + visibleCommandOptions.map((option) => [option.name, option]), + ); + const visibleGlobalOptions = globalCommandOptions + .filter((option) => !option.hidden) + .filter((option) => !hiddenGlobalOptionNames.has(option.name)) + .map((option) => commandOptionsByName.get(option.name) ?? option); + const globalOptionNames = new Set(visibleGlobalOptions.map((o) => o.name)); const visibleNonGlobalCommandOptions = visibleCommandOptions.filter( - (o) => !globalOptionNames.has(o.name), + (option) => !globalOptionNames.has(option.name), ); if (visibleGlobalOptions.length > 0) { diff --git a/cli/src/core/parser.test.ts b/cli/src/core/parser.test.ts index 230ba5fc..2e56459c 100644 --- a/cli/src/core/parser.test.ts +++ b/cli/src/core/parser.test.ts @@ -86,6 +86,25 @@ describe("parseCommandArguments", () => { }); }); + test("should parse newly supported global options", () => { + const result = parseCommandArguments( + [ + "--api-token", + "tok", + "--profile", + "team", + "--interactive", + "--cvm-id", + "app_123", + ], + [], + ); + expect(result.flags["--api-token"]).toBe("tok"); + expect(result.flags["--profile"]).toBe("team"); + expect(result.flags["--interactive"]).toBe(true); + expect(result.flags["--cvm-id"]).toBe("app_123"); + }); + describe("boolean flags with negatedName", () => { const booleanOptions = [ { diff --git a/cli/src/core/types.ts b/cli/src/core/types.ts index 9657cadb..67c7954e 100644 --- a/cli/src/core/types.ts +++ b/cli/src/core/types.ts @@ -79,6 +79,14 @@ export interface CommandMeta { readonly examples?: readonly CommandExample[]; } +export interface CommandGlobalOptions { + readonly apiToken?: string; + readonly json: boolean; + readonly interactive: boolean; + readonly profile?: string; + readonly apiVersion?: string; +} + export interface CommandContext { readonly argv: readonly string[]; readonly rawFlags: Record; @@ -89,6 +97,7 @@ export interface CommandContext { readonly stderr: NodeJS.WriteStream; readonly stdin: NodeJS.ReadStream; readonly projectConfig: RuntimeProjectConfig; + readonly globalOptions?: CommandGlobalOptions; readonly cli?: { readonly executableName: string; readonly packageName: string; diff --git a/cli/src/index.ts b/cli/src/index.ts index 3dbec12a..7980dbe8 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -12,6 +12,7 @@ import { cvmsCommands } from "./commands/cvms"; import { deployCommand } from "./commands/deploy"; import { envsCommands } from "./commands/envs"; import { dockerCommands } from "./commands/docker"; +import { helpCommand } from "./commands/help"; import { linkCommand } from "./commands/link"; import { loginCommand } from "./commands/login"; import { logoutCommand } from "./commands/logout"; @@ -77,6 +78,7 @@ registry.registerCommand(statusCommand); registry.registerCommand(whoamiCommand); registry.registerCommand(completionCommand); registry.registerCommand(osImagesCommand); +registry.registerCommand(helpCommand); // Command groups + subcommands registry.registerGroup(selfCommands.group); diff --git a/cli/src/lib/client.ts b/cli/src/lib/client.ts index 1fc58b50..1579b113 100644 --- a/cli/src/lib/client.ts +++ b/cli/src/lib/client.ts @@ -13,7 +13,10 @@ export interface ClientWithAuth { readonly auth: ResolvedAuth; } -type AuthContextLike = Pick; +type AuthContextLike = Pick< + CommandContext, + "env" | "projectConfig" | "globalOptions" +>; function getDefaultContext(): AuthContextLike { return { @@ -32,8 +35,8 @@ export function resolveAuthForContext( const ctx = context ?? getDefaultContext(); return resolveAuth({ env: ctx.env, - apiToken: options?.apiToken, - profile: options?.profile, + apiToken: options?.apiToken ?? ctx.globalOptions?.apiToken, + profile: options?.profile ?? ctx.globalOptions?.profile, projectProfile: ctx.projectConfig.profile, }); } diff --git a/cli/src/utils/credentials.test.ts b/cli/src/utils/credentials.test.ts index ef8bca95..5dc2fdcf 100644 --- a/cli/src/utils/credentials.test.ts +++ b/cli/src/utils/credentials.test.ts @@ -81,6 +81,34 @@ describe("credentials", () => { expect(resolved.tokenSource).toBe("flag"); }); + test("resolveAuth uses explicit profile over projectProfile and current_profile", () => { + upsertProfile({ + profileName: "a", + token: "token-a", + apiPrefix: "https://a.example/api/v1", + workspaceName: "a", + user: { username: "u" }, + setCurrent: true, + }); + upsertProfile({ + profileName: "b", + token: "token-b", + apiPrefix: "https://b.example/api/v1", + workspaceName: "b", + user: { username: "u" }, + setCurrent: false, + }); + + const resolved = resolveAuth({ + env: process.env, + profile: "b", + projectProfile: "a", + }); + expect(resolved.profileName).toBe("b"); + expect(resolved.apiKey).toBe("token-b"); + expect(resolved.baseURL).toBe("https://b.example/api/v1"); + }); + test("resolveAuth uses projectProfile over current_profile", () => { upsertProfile({ profileName: "a", diff --git a/cli/src/utils/cvms.ts b/cli/src/utils/cvms.ts index d269ee3b..0e600df8 100644 --- a/cli/src/utils/cvms.ts +++ b/cli/src/utils/cvms.ts @@ -1,4 +1,5 @@ import { safeGetCvmInfo } from "@phala/cloud"; +import type { CommandContext } from "@/src/core/types"; import { getClient } from "@/src/lib/client"; import { logger } from "./logger"; @@ -13,8 +14,9 @@ import { logger } from "./logger"; export async function waitForCvmReady( cvmId: string, timeoutMs = 300000, // 5 minutes default + context?: Pick, ): Promise { - const client = await getClient(); + const client = await getClient(context); const startTime = Date.now(); const checkIntervalMs = 2000; // Check every 2 seconds diff --git a/cli/test/commands/allow-devices.test.ts b/cli/test/commands/allow-devices.test.ts index 50afdf0f..4d6dabe0 100644 --- a/cli/test/commands/allow-devices.test.ts +++ b/cli/test/commands/allow-devices.test.ts @@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test"; import { normalizeDeviceId, isValidDeviceId, + isAppAllowlistIdentifier, + normalizeAllowlistAppId, txExplorerUrl, resolveAllowAnyFlag, resolveToggleAllowAny, @@ -61,6 +63,42 @@ describe("isValidDeviceId", () => { }); }); +// ── app identifier helpers ───────────────────────────────────────── + +describe("isAppAllowlistIdentifier", () => { + test("accepts raw 40-char hex app IDs", () => { + expect(isAppAllowlistIdentifier("a".repeat(40))).toBe(true); + }); + + test("accepts app_-prefixed identifiers", () => { + expect(isAppAllowlistIdentifier(`app_${"b".repeat(40)}`)).toBe(true); + }); + + test("rejects UUIDs", () => { + expect( + isAppAllowlistIdentifier("550e8400-e29b-41d4-a716-446655440000"), + ).toBe(false); + }); +}); + +describe("normalizeAllowlistAppId", () => { + test("strips the app_ prefix", () => { + expect(normalizeAllowlistAppId(`app_${"c".repeat(40)}`)).toBe( + "c".repeat(40), + ); + }); + + test("lowercases hex app IDs", () => { + expect(normalizeAllowlistAppId(`app_${"AB".repeat(20)}`)).toBe( + "ab".repeat(20), + ); + }); + + test("keeps non-hex prefixed identifiers unchanged after prefix removal", () => { + expect(normalizeAllowlistAppId("app_custom-id")).toBe("custom-id"); + }); +}); + // ── txExplorerUrl ─────────────────────────────────────────────────── describe("txExplorerUrl", () => { diff --git a/js/CHANGELOG.md b/js/CHANGELOG.md index d0c543bd..ce897cd5 100644 --- a/js/CHANGELOG.md +++ b/js/CHANGELOG.md @@ -1,3 +1,19 @@ +## [0.2.7](https://github.com/Phala-Network/phala-cloud/compare/js-v0.2.6...js-v0.2.7) (2026-04-10) + +### feat + +* add compose_hash_registered to provision response schema ([be02cf9](https://github.com/Phala-Network/phala-cloud/commit/be02cf903d1219c63693d2e355ea86114bdf4841)) + +### fix + +* **js:** update addComposeHash test mock for owner pre-check ([456ce00](https://github.com/Phala-Network/phala-cloud/commit/456ce0022f23e315e6e0b06341026fa1ec1a73c0)) +* parse StructuredError details array in 465 error handlers ([50129dc](https://github.com/Phala-Network/phala-cloud/commit/50129dcc87803e979fde0d624f3187e0547a5aca)) +* preserve StructuredError response body in error conversion ([ae7eecc](https://github.com/Phala-Network/phala-cloud/commit/ae7eecc487ea1c6f0892b383e2a22e41e0e6593d)) +* **sdk:** chain resolution, owner pre-check, and ABI error definitions ([0c6a50c](https://github.com/Phala-Network/phala-cloud/commit/0c6a50c7fc57370ecb61359f34f04210c645ca18)) + +### refactor + +* add transaction progress logging and RPC transport timeouts ([29849a2](https://github.com/Phala-Network/phala-cloud/commit/29849a283025fe74543254e75afae888ab29f848)) ## [0.2.6](https://github.com/Phala-Network/phala-cloud/compare/js-v0.2.5...js-v0.2.6) (2026-03-27) ### feat diff --git a/js/package.json b/js/package.json index 151807b3..409856d9 100644 --- a/js/package.json +++ b/js/package.json @@ -1,6 +1,6 @@ { "name": "@phala/cloud", - "version": "0.2.6", + "version": "0.2.7", "description": "TypeScript SDK for Phala Cloud API", "main": "dist/index.js", "module": "dist/index.mjs", diff --git a/js/src/actions/blockchains/add_compose_hash.test.ts b/js/src/actions/blockchains/add_compose_hash.test.ts index 93023749..20de6e16 100644 --- a/js/src/actions/blockchains/add_compose_hash.test.ts +++ b/js/src/actions/blockchains/add_compose_hash.test.ts @@ -53,6 +53,11 @@ const mockValidateNetworkPrerequisites = validateNetworkPrerequisites as MockedF const mockCreateTransactionTracker = createTransactionTracker as MockedFunction; const mockExecuteTransactionWithRetry = executeTransactionWithRetry as MockedFunction; +// Address derived from validRequest.privateKey via privateKeyToAccount; the +// addComposeHash owner pre-check compares this against the contract's owner() +// return value, so the readContract mock below must return the same address. +const TEST_SENDER_ADDRESS = "0x1Be31A94361a391bBaFB2a4CCd704F57dc04d4bb" as `0x${string}`; + describe("addComposeHash", () => { let mockPublicClient: Partial; let mockWalletClient: Partial; @@ -73,7 +78,7 @@ describe("addComposeHash", () => { contractAddress: null, cumulativeGasUsed: BigInt(21000), effectiveGasPrice: BigInt(1000000000), - from: "0xabcdef1234567890abcdef1234567890abcdef12" as `0x${string}`, + from: TEST_SENDER_ADDRESS, logsBloom: "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" as `0x${string}`, to: "0x1234567890abcdef1234567890abcdef12345678" as `0x${string}`, transactionIndex: 0, @@ -85,14 +90,21 @@ describe("addComposeHash", () => { vi.clearAllMocks(); // Mock PublicClient + // Pre-check reads owner() and compares to sender; all other readContract + // calls keep their historical tuple shape. mockPublicClient = { - readContract: vi.fn().mockResolvedValue([true, "0x1234567890abcdef1234567890abcdef12345678" as `0x${string}`]), + readContract: vi.fn().mockImplementation(async (args: any) => { + if (args?.functionName === "owner") { + return TEST_SENDER_ADDRESS; + } + return [true, "0x1234567890abcdef1234567890abcdef12345678" as `0x${string}`]; + }), }; // Mock WalletClient mockWalletClient = { account: { - address: "0xabcdef1234567890abcdef1234567890abcdef12" as `0x${string}`, + address: TEST_SENDER_ADDRESS, }, chain: base, writeContract: vi.fn().mockResolvedValue("0x123...abc" as `0x${string}`), @@ -123,7 +135,7 @@ describe("addComposeHash", () => { details: { currentChainId: base.id, balance: parseEther("1.0"), - address: "0xabcdef1234567890abcdef1234567890abcdef12" as `0x${string}`, + address: TEST_SENDER_ADDRESS, }, }); @@ -238,7 +250,7 @@ describe("addComposeHash", () => { details: { currentChainId: base.id, balance: parseEther("0.1"), // Only 0.1 ETH - address: "0xabcdef1234567890abcdef1234567890abcdef12" as `0x${string}`, + address: TEST_SENDER_ADDRESS, }, }); @@ -364,7 +376,7 @@ describe("addComposeHash", () => { details: { currentChainId: 999, balance: parseEther("1.0"), - address: "0xabcdef1234567890abcdef1234567890abcdef12" as `0x${string}`, + address: TEST_SENDER_ADDRESS, }, }); @@ -379,7 +391,7 @@ describe("addComposeHash", () => { details: { currentChainId: base.id, balance: parseEther("0.0001"), - address: "0xabcdef1234567890abcdef1234567890abcdef12" as `0x${string}`, + address: TEST_SENDER_ADDRESS, }, }); diff --git a/js/src/actions/blockchains/add_device.ts b/js/src/actions/blockchains/add_device.ts index 94c7b9bf..a64d3811 100644 --- a/js/src/actions/blockchains/add_device.ts +++ b/js/src/actions/blockchains/add_device.ts @@ -172,13 +172,14 @@ export async function addDevice