From 0f75303dc262b8a211b5e113de54a31004ca0ccf Mon Sep 17 00:00:00 2001 From: Mario Ruci Date: Tue, 22 Sep 2026 16:56:58 +0200 Subject: [PATCH 1/2] refactor(release): run the backend deploy on icp-cli dfx now prints a deprecation notice pointing at icp-cli on every invocation, so the deploy path moves over. The script imports the signing key with `icp identity import` and upgrades the control panel with `icp canister install`, resolving the canister from canister_ids.json since icp-cli identifies a network by URL rather than by a dfx.json name. Isolating the identity store goes back to repointing HOME, which is what icp-cli keys its store off. That was the thing that broke under dfx, where HOME also carried the dfxvm version store; icp-cli is a single binary with no version shim, so there is nothing to hide. The toolchain check now verifies the version rather than just presence. An npm-installed icp under a node version manager can shadow a newer one on PATH, which is exactly how this was hit in testing: 0.3.0 winning over 1.3.0 and failing on argument parsing deep inside a deploy. --- .github/actions/deploy-backend/action.yaml | 8 ++-- scripts/deploy-backend | 48 ++++++++++++---------- 2 files changed, 31 insertions(+), 25 deletions(-) diff --git a/.github/actions/deploy-backend/action.yaml b/.github/actions/deploy-backend/action.yaml index 4f8fc0fc8..368cc1d83 100644 --- a/.github/actions/deploy-backend/action.yaml +++ b/.github/actions/deploy-backend/action.yaml @@ -34,10 +34,10 @@ runs: shell: bash # Needed for orbit-cli, which performs the registry publish. run: pnpm install --frozen-lockfile - - name: 'Install dfx' - uses: dfinity/setup-dfx@e50c04f104ee4285ec010f10609483cf41e4d365 # main (no tags/releases published) - with: - dfx-version: '0.30.2' + - name: 'Install icp-cli' + shell: bash + # dfx is deprecated in favour of icp-cli. + run: npm install --global @icp-sdk/icp-cli@1.5.0 - name: 'Install icx-asset' shell: bash run: cargo install --locked icx-asset --version 0.29.0 diff --git a/scripts/deploy-backend b/scripts/deploy-backend index 3fbadcd8e..d1c613ff3 100755 --- a/scripts/deploy-backend +++ b/scripts/deploy-backend @@ -15,9 +15,10 @@ set -eEuo pipefail # The signing key can be a pem file (--pem) or read straight from 1Password # (--op op://Vault/Item/field), so nothing has to be saved to disk by hand. # -# Needs: gh, jq, cargo, dfx, icx-asset, orbit-cli (and op for --op). +# Needs: gh, jq, cargo, icp, icx-asset, orbit-cli (and op for --op). REPO="dfinity/orbit" +ICP_MIN_VERSION="1.3.0" ROOT="$(cd "$(dirname "$0")/.." && pwd)" # target -> release tag prefix, wasm file name, artifacts subdir, cargo package @@ -84,11 +85,18 @@ done [[ -z "$PEM" || -f "$PEM" ]] || die "pem file not found: $PEM" cd "$ROOT" -# A dry run only resolves and verifies artifacts, so it needs neither dfx nor +# A dry run only resolves and verifies artifacts, so it needs neither icp nor # orbit-cli. Do not make people install a toolchain to check a checksum. for c in gh jq cargo; do command -v "$c" >/dev/null || die "$c not found"; done if [[ -z "$DRY" ]]; then - command -v dfx >/dev/null || die "dfx not found" + command -v icp >/dev/null || die "icp not found: see https://cli.internetcomputer.org" + # Version checked, not just presence. An npm-installed icp under a node + # version manager can shadow a newer one on PATH, and the old argument + # handling fails deep inside a deploy rather than here. + icp_version="$(icp --version | awk '{ print $2 }')" + if [[ "$(printf '%s\n' "$ICP_MIN_VERSION" "$icp_version" | sort -V | head -1)" != "$ICP_MIN_VERSION" ]]; then + die "icp $icp_version is too old, need $ICP_MIN_VERSION or newer (found at $(command -v icp))" + fi if [[ "$TARGET" != "control-panel" ]]; then command -v orbit-cli >/dev/null || die "orbit-cli not found: run pnpm install at the repo root" fi @@ -171,12 +179,12 @@ done if [[ -n "$DRY" ]]; then echo "dry run: artifacts verified, nothing shipped."; exit 0; fi -# 2. Load the key into a throwaway dfx home. +# 2. Load the key into a throwaway icp home. # -# DFX_CONFIG_ROOT only, never HOME. dfx is usually a dfxvm shim that resolves -# the version pinned in dfx.json under $HOME, so repointing HOME hides the -# installed toolchain and dfx exits with "0.30.2 is not installed". Both dfx -# and orbit-cli read the identity store from DFX_CONFIG_ROOT. +# icp-cli keys its identity store off HOME and is a single binary with no +# version shim behind it, so pointing HOME at a temp directory isolates the +# store without hiding the toolchain. Doing it after the downloads keeps the +# real HOME in place for gh. if [[ -n "$OP_REF" ]]; then command -v op >/dev/null || die "1Password CLI (op) not found" echo "==> reading key from 1Password" @@ -185,17 +193,15 @@ if [[ -n "$OP_REF" ]]; then PEM="$TMP_PEM" fi -DFX_HOME="$(mktemp -d)"; chmod 700 "$DFX_HOME" -export DFX_CONFIG_ROOT="$DFX_HOME" -dfx identity import "$IDENTITY" "$PEM" --storage-mode plaintext --force - -# Every network in dfx.json other than local points at mainnet, and dfx refuses -# to use a plaintext identity against mainnet unless this is set. The identity -# has to be plaintext because nothing here can answer a passphrase prompt, so -# the warning is expected rather than something to fix. It applies to the -# control-panel install and to the `dfx canister call` that orbit-cli runs -# underneath `registry publish`. -export DFX_WARNING=-mainnet_plaintext_identity +ICP_HOME="$(mktemp -d)"; chmod 700 "$ICP_HOME" +export HOME="$ICP_HOME" +# Plaintext because nothing here can answer a passphrase prompt. icp-cli warns +# about that on import, which is expected rather than something to fix. +icp identity import "$IDENTITY" --from-pem "$PEM" --storage plaintext +# Settings live under the throwaway home too, so this neither nags about +# updates nor sends telemetry from a deploy, and leaves real settings alone. +icp settings update-check off >/dev/null +icp settings telemetry off >/dev/null # 3. Ship it. case "$TARGET" in @@ -205,8 +211,8 @@ case "$TARGET" in ;; control-panel) echo "==> upgrading the control-panel canister on $NETWORK" - dfx canister install control_panel \ - --network "$NETWORK" --identity "$IDENTITY" \ + icp canister install "$(jq -re --arg n "$NETWORK" '.control_panel[$n]' canister_ids.json)" \ + --network https://icp0.io --root-key mainnet --identity "$IDENTITY" \ --wasm "artifacts/control-panel/${WASM[control-panel]}" \ --mode upgrade --yes ;; From 76d9713d93cdfa30176b8b5cbfd1443d2b49c54b Mon Sep 17 00:00:00 2001 From: Mario Ruci Date: Tue, 22 Sep 2026 16:56:58 +0200 Subject: [PATCH 2/2] refactor(cli): call the control panel through icp-cli Follows the script: `registry publish` shells out per registry entry, and those calls were the last dfx dependency. icp-cli returns the raw reply bytes rather than a decoded value, so the reply is decoded against the generated control-panel interface, which is now emitted as a JS IDL factory alongside the existing type binding. Arguments go through argv instead of a shell string and a temp file. Registry entries carry free-form descriptions and tags, so a quote in one of those previously broke the Candid argument or injected into the command. The wasm chunk store id now comes from canister_ids.json rather than `dfx canister id`, and icx-asset gets its pem from `icp identity export`, which keeps it independent of the store layout. That removes the last reader of the dfx identity store, so the homedir lookup goes with it. `assertReplicaIsHealthy` is dropped rather than ported. It shelled out to `dfx ping` and every caller invoked it without awaiting, so it never gated anything and its rejection went unhandled. --- cli/package.json | 4 +- cli/src/generated/control_panel.did.d.ts | 4 + cli/src/generated/control_panel.did.js | 313 +++++++++++++++++++++++ cli/src/registry/icp.ts | 99 +++++++ cli/src/registry/list.ts | 3 - cli/src/registry/publish.ts | 62 ++--- cli/src/registry/registry.core.ts | 26 +- cli/src/registry/remove.ts | 17 +- cli/src/utils.ts | 40 --- 9 files changed, 458 insertions(+), 110 deletions(-) create mode 100644 cli/src/generated/control_panel.did.d.ts create mode 100644 cli/src/generated/control_panel.did.js create mode 100644 cli/src/registry/icp.ts diff --git a/cli/package.json b/cli/package.json index 3d4562b3e..750575b1e 100644 --- a/cli/package.json +++ b/cli/package.json @@ -6,11 +6,11 @@ }, "main": "cli.js", "scripts": { - "build": "tsc && mkdir -p dist/audit/generated && cp src/audit/generated/station.did.js dist/audit/generated/", + "build": "tsc && mkdir -p dist/audit/generated dist/generated && cp src/audit/generated/station.did.js dist/audit/generated/ && cp src/generated/control_panel.did.js dist/generated/", "start": "node dist/cli.js", "expose": "pnpm link --global", "test": "vitest run", - "generate-types": "didc bind --target ts ../core/control-panel/api/spec.did > ./src/generated/control_panel.d.ts", + "generate-types": "didc bind --target ts ../core/control-panel/api/spec.did > ./src/generated/control_panel.d.ts && didc bind --target js ../core/control-panel/api/spec.did | sed -E 's/^export const ([a-zA-Z_]+)/exports.\\1/g' > ./src/generated/control_panel.did.js", "generate-station-types": "didc bind --target ts ../core/station/api/spec.did > ./src/audit/generated/station.did.d.ts && didc bind --target js ../core/station/api/spec.did | sed -E 's/^export const ([a-zA-Z_]+)/exports.\\1/g' > ./src/audit/generated/station.did.js && cp ../core/station/api/spec.did ./src/audit/generated/station.did" }, "devDependencies": { diff --git a/cli/src/generated/control_panel.did.d.ts b/cli/src/generated/control_panel.did.d.ts new file mode 100644 index 000000000..eaec8260b --- /dev/null +++ b/cli/src/generated/control_panel.did.d.ts @@ -0,0 +1,4 @@ +import type { IDL } from '@dfinity/candid'; + +export declare const idlFactory: IDL.InterfaceFactory; +export declare const init: (args: { IDL: typeof IDL }) => IDL.Type[]; diff --git a/cli/src/generated/control_panel.did.js b/cli/src/generated/control_panel.did.js new file mode 100644 index 000000000..1b9c14162 --- /dev/null +++ b/cli/src/generated/control_panel.did.js @@ -0,0 +1,313 @@ +exports.idlFactory = ({ IDL }) => { + const WasmModuleExtraChunks = IDL.Record({ + 'wasm_module_hash' : IDL.Vec(IDL.Nat8), + 'store_canister' : IDL.Principal, + 'extra_chunks_key' : IDL.Text, + }); + const WasmModuleRegistryEntryDependency = IDL.Record({ + 'name' : IDL.Text, + 'version' : IDL.Text, + }); + const WasmModuleRegistryEntryValueInput = IDL.Record({ + 'wasm_module' : IDL.Vec(IDL.Nat8), + 'module_extra_chunks' : IDL.Opt(WasmModuleExtraChunks), + 'version' : IDL.Text, + 'dependencies' : IDL.Vec(WasmModuleRegistryEntryDependency), + }); + const RegistryEntryValueInput = IDL.Variant({ + 'WasmModule' : WasmModuleRegistryEntryValueInput, + }); + const Metadata = IDL.Record({ 'key' : IDL.Text, 'value' : IDL.Text }); + const RegistryEntryInput = IDL.Record({ + 'categories' : IDL.Vec(IDL.Text), + 'value' : RegistryEntryValueInput, + 'metadata' : IDL.Vec(Metadata), + 'name' : IDL.Text, + 'tags' : IDL.Vec(IDL.Text), + 'description' : IDL.Text, + }); + const AddRegistryEntryInput = IDL.Record({ 'entry' : RegistryEntryInput }); + const UUID = IDL.Text; + const TimestampRFC3339 = IDL.Text; + const WasmModuleRegistryEntryValue = IDL.Record({ + 'module_extra_chunks' : IDL.Opt(WasmModuleExtraChunks), + 'version' : IDL.Text, + 'dependencies' : IDL.Vec(WasmModuleRegistryEntryDependency), + 'wasm_artifact_id' : UUID, + }); + const RegistryEntryValue = IDL.Variant({ + 'WasmModule' : WasmModuleRegistryEntryValue, + }); + const RegistryEntry = IDL.Record({ + 'id' : UUID, + 'categories' : IDL.Vec(IDL.Text), + 'updated_at' : IDL.Opt(TimestampRFC3339), + 'value' : RegistryEntryValue, + 'metadata' : IDL.Vec(Metadata), + 'name' : IDL.Text, + 'tags' : IDL.Vec(IDL.Text), + 'description' : IDL.Text, + 'created_at' : TimestampRFC3339, + }); + const AddRegistryEntryResponse = IDL.Record({ 'entry' : RegistryEntry }); + const ApiError = IDL.Record({ + 'code' : IDL.Text, + 'message' : IDL.Opt(IDL.Text), + 'details' : IDL.Opt(IDL.Vec(IDL.Tuple(IDL.Text, IDL.Text))), + }); + const AddRegistryEntryResult = IDL.Variant({ + 'Ok' : AddRegistryEntryResponse, + 'Err' : ApiError, + }); + const UserSubscriptionStatus = IDL.Variant({ + 'Unsubscribed' : IDL.Null, + 'Approved' : IDL.Null, + 'Denylisted' : IDL.Null, + 'Pending' : IDL.Null, + }); + const CanDeployStationResponse = IDL.Variant({ + 'NotAllowed' : UserSubscriptionStatus, + 'Allowed' : IDL.Nat64, + 'QuotaExceeded' : IDL.Null, + }); + const CanDeployStationResult = IDL.Variant({ + 'Ok' : CanDeployStationResponse, + 'Err' : ApiError, + }); + const DeleteRegistryEntryInput = IDL.Record({ 'id' : UUID }); + const DeleteRegistryEntryResponse = IDL.Record({ 'entry' : RegistryEntry }); + const DeleteRegistryEntryResult = IDL.Variant({ + 'Ok' : DeleteRegistryEntryResponse, + 'Err' : ApiError, + }); + const User = IDL.Record({ + 'last_active' : TimestampRFC3339, + 'subscription_status' : UserSubscriptionStatus, + 'identity' : IDL.Principal, + }); + const RemoveUserResult = IDL.Variant({ + 'Ok' : IDL.Record({ 'user' : User }), + 'Err' : ApiError, + }); + const SubnetFilter = IDL.Record({ 'subnet_type' : IDL.Opt(IDL.Text) }); + const SubnetSelection = IDL.Variant({ + 'Filter' : SubnetFilter, + 'Subnet' : IDL.Record({ 'subnet' : IDL.Principal }), + }); + const DeployStationAdminUserInput = IDL.Record({ + 'username' : IDL.Text, + 'identity' : IDL.Principal, + }); + const DeployStationInput = IDL.Record({ + 'name' : IDL.Text, + 'subnet_selection' : IDL.Opt(SubnetSelection), + 'admins' : IDL.Vec(DeployStationAdminUserInput), + 'associate_with_caller' : IDL.Opt( + IDL.Record({ 'labels' : IDL.Vec(IDL.Text) }) + ), + }); + const StationID = IDL.Principal; + const DeployStationResult = IDL.Variant({ + 'Ok' : IDL.Record({ 'canister_id' : StationID }), + 'Err' : ApiError, + }); + const RegistryEntryUpdateInput = IDL.Record({ + 'categories' : IDL.Opt(IDL.Vec(IDL.Text)), + 'value' : IDL.Opt(RegistryEntryValueInput), + 'metadata' : IDL.Opt(IDL.Vec(Metadata)), + 'tags' : IDL.Opt(IDL.Vec(IDL.Text)), + 'description' : IDL.Opt(IDL.Text), + }); + const EditRegistryEntryInput = IDL.Record({ + 'id' : UUID, + 'entry' : RegistryEntryUpdateInput, + }); + const EditRegistryEntryResponse = IDL.Record({ 'entry' : RegistryEntry }); + const EditRegistryEntryResult = IDL.Variant({ + 'Ok' : EditRegistryEntryResponse, + 'Err' : ApiError, + }); + const GetArtifactInput = IDL.Record({ 'artifact_id' : UUID }); + const Sha256Hex = IDL.Text; + const Artifact = IDL.Record({ + 'id' : UUID, + 'hash' : Sha256Hex, + 'artifact' : IDL.Vec(IDL.Nat8), + 'size' : IDL.Nat64, + 'created_at' : TimestampRFC3339, + }); + const GetArtifactResponse = IDL.Record({ 'artifact' : Artifact }); + const GetArtifactResult = IDL.Variant({ + 'Ok' : GetArtifactResponse, + 'Err' : ApiError, + }); + const GetRegistryEntryInput = IDL.Record({ 'id' : UUID }); + const GetRegistryEntryResponse = IDL.Record({ 'entry' : RegistryEntry }); + const GetRegistryEntryResult = IDL.Variant({ + 'Ok' : GetRegistryEntryResponse, + 'Err' : ApiError, + }); + const GetUserResult = IDL.Variant({ + 'Ok' : IDL.Record({ 'user' : User }), + 'Err' : ApiError, + }); + const HeaderField = IDL.Tuple(IDL.Text, IDL.Text); + const HttpRequest = IDL.Record({ + 'url' : IDL.Text, + 'method' : IDL.Text, + 'body' : IDL.Vec(IDL.Nat8), + 'headers' : IDL.Vec(HeaderField), + }); + const HttpResponse = IDL.Record({ + 'body' : IDL.Vec(IDL.Nat8), + 'headers' : IDL.Vec(HeaderField), + 'status_code' : IDL.Nat16, + }); + const ListUserStationsInput = IDL.Record({ + 'filter_by_labels' : IDL.Opt(IDL.Vec(IDL.Text)), + }); + const UserStation = IDL.Record({ + 'name' : IDL.Text, + 'labels' : IDL.Vec(IDL.Text), + 'canister_id' : StationID, + }); + const ListUserStationsResult = IDL.Variant({ + 'Ok' : IDL.Record({ 'stations' : IDL.Vec(UserStation) }), + 'Err' : ApiError, + }); + const ManageUserStationsInput = IDL.Variant({ + 'Add' : IDL.Vec(UserStation), + 'Remove' : IDL.Vec(StationID), + 'Update' : IDL.Vec( + IDL.Record({ 'station' : UserStation, 'index' : IDL.Opt(IDL.Nat64) }) + ), + }); + const ManageUserStationsResult = IDL.Variant({ + 'Ok' : IDL.Null, + 'Err' : ApiError, + }); + const NextWasmModuleVersionInput = IDL.Record({ + 'name' : IDL.Text, + 'current_version' : IDL.Text, + }); + const NextWasmModuleVersionResponse = IDL.Record({ + 'entry' : IDL.Opt(RegistryEntry), + }); + const NextWasmModuleVersionResult = IDL.Variant({ + 'Ok' : NextWasmModuleVersionResponse, + 'Err' : ApiError, + }); + const RegisterUserInput = IDL.Record({ 'station' : IDL.Opt(UserStation) }); + const RegisterUserResult = IDL.Variant({ + 'Ok' : IDL.Record({ 'user' : User }), + 'Err' : ApiError, + }); + const SortDirection = IDL.Variant({ 'Asc' : IDL.Null, 'Desc' : IDL.Null }); + const RegistryEntrySortBy = IDL.Variant({ + 'Version' : SortDirection, + 'CreatedAt' : SortDirection, + }); + const PaginationInput = IDL.Record({ + 'offset' : IDL.Opt(IDL.Nat64), + 'limit' : IDL.Opt(IDL.Nat16), + }); + const RegistryEntryValueKind = IDL.Variant({ 'WasmModule' : IDL.Null }); + const SearchRegistryFilterKind = IDL.Variant({ + 'Kind' : RegistryEntryValueKind, + 'Name' : IDL.Text, + 'Namespace' : IDL.Text, + }); + const SearchRegistryInput = IDL.Record({ + 'sort_by' : IDL.Opt(RegistryEntrySortBy), + 'pagination' : IDL.Opt(PaginationInput), + 'filter_by' : IDL.Vec(SearchRegistryFilterKind), + }); + const SearchRegistryResponse = IDL.Record({ + 'total' : IDL.Nat64, + 'entries' : IDL.Vec(RegistryEntry), + 'next_offset' : IDL.Opt(IDL.Nat64), + }); + const SearchRegistryResult = IDL.Variant({ + 'Ok' : SearchRegistryResponse, + 'Err' : ApiError, + }); + const SetUserActiveResult = IDL.Variant({ + 'Ok' : IDL.Null, + 'Err' : ApiError, + }); + const UploadCanisterModulesInput = IDL.Record({ + 'station_wasm_module_extra_chunks' : IDL.Opt( + IDL.Opt(WasmModuleExtraChunks) + ), + 'station_wasm_module' : IDL.Opt(IDL.Vec(IDL.Nat8)), + 'upgrader_wasm_module' : IDL.Opt(IDL.Vec(IDL.Nat8)), + }); + const UploadUploadCanisterModulesInputResult = IDL.Variant({ + 'Ok' : IDL.Null, + 'Err' : ApiError, + }); + return IDL.Service({ + 'add_registry_entry' : IDL.Func( + [AddRegistryEntryInput], + [AddRegistryEntryResult], + [], + ), + 'can_deploy_station' : IDL.Func([], [CanDeployStationResult], ['query']), + 'delete_registry_entry' : IDL.Func( + [DeleteRegistryEntryInput], + [DeleteRegistryEntryResult], + [], + ), + 'delete_user' : IDL.Func([], [RemoveUserResult], []), + 'deploy_station' : IDL.Func( + [DeployStationInput], + [DeployStationResult], + [], + ), + 'edit_registry_entry' : IDL.Func( + [EditRegistryEntryInput], + [EditRegistryEntryResult], + [], + ), + 'get_artifact' : IDL.Func( + [GetArtifactInput], + [GetArtifactResult], + ['query'], + ), + 'get_registry_entry' : IDL.Func( + [GetRegistryEntryInput], + [GetRegistryEntryResult], + ['query'], + ), + 'get_user' : IDL.Func([], [GetUserResult], ['query']), + 'http_request' : IDL.Func([HttpRequest], [HttpResponse], ['query']), + 'list_user_stations' : IDL.Func( + [ListUserStationsInput], + [ListUserStationsResult], + ['query'], + ), + 'manage_user_stations' : IDL.Func( + [ManageUserStationsInput], + [ManageUserStationsResult], + [], + ), + 'next_wasm_module_version' : IDL.Func( + [NextWasmModuleVersionInput], + [NextWasmModuleVersionResult], + ['query'], + ), + 'register_user' : IDL.Func([RegisterUserInput], [RegisterUserResult], []), + 'search_registry' : IDL.Func( + [SearchRegistryInput], + [SearchRegistryResult], + ['query'], + ), + 'set_user_active' : IDL.Func([], [SetUserActiveResult], []), + 'upload_canister_modules' : IDL.Func( + [UploadCanisterModulesInput], + [UploadUploadCanisterModulesInputResult], + [], + ), + }); +}; +exports.init = ({ IDL }) => { return []; }; diff --git a/cli/src/registry/icp.ts b/cli/src/registry/icp.ts new file mode 100644 index 000000000..0b84f1886 --- /dev/null +++ b/cli/src/registry/icp.ts @@ -0,0 +1,99 @@ +import { IDL } from '@dfinity/candid'; +import { Principal } from '@dfinity/principal'; +import { execFile } from 'child_process'; +import { randomBytes } from 'crypto'; +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { promisify } from 'util'; +import { idlFactory } from '../generated/control_panel.did.js'; +import { getReplicaUrl, ROOT_PATH } from '../utils'; + +const execFileAsync = promisify(execFile); + +const LOCAL_URL_PREFIXES = ['http://localhost', 'http://127.0.0.1', 'http://[::1]']; + +const controlPanel = idlFactory({ IDL }) as IDL.ServiceClass; + +// icp-cli hands back the raw reply bytes rather than a decoded value, so the +// reply is decoded here against the control-panel interface. +const replyTypes = (method: string): IDL.Type[] => { + const field = controlPanel._fields.find(([name]) => name === method); + if (!field) { + throw new Error(`'${method}' is not a method of the control-panel interface.`); + } + + return field[1].retTypes; +}; + +export const getCanisterId = (name: string, network: string): Principal => { + const candidates = [ + join(ROOT_PATH, '.dfx', network, 'canister_ids.json'), + join(ROOT_PATH, 'canister_ids.json'), + ]; + + for (const path of candidates) { + if (!existsSync(path)) { + continue; + } + + const id = JSON.parse(readFileSync(path, 'utf-8'))?.[name]?.[network]; + if (typeof id === 'string') { + return Principal.fromText(id); + } + } + + throw new Error(`No canister id recorded for '${name}' on network '${network}'.`); +}; + +// icp-cli identifies a network by URL plus the root key to verify replies with, +// so the URL comes from dfx.json and the key follows from whether it is local. +const networkArgs = async (network: string): Promise => { + const url = await getReplicaUrl(network); + const isLocal = LOCAL_URL_PREFIXES.some(prefix => url.startsWith(prefix)); + + return ['--network', url, '--root-key', isLocal ? 'fetch' : 'mainnet']; +}; + +export const callControlPanel = async (opts: { + method: string; + arg: string; + network: string; + identity: string; + query?: boolean; +}): Promise => { + // Passing argv directly rather than a shell string: registry entries carry + // free-form descriptions and tags, and a quote in one of those would + // otherwise break the Candid argument or inject into the command. + const args = [ + 'canister', + 'call', + ...(await networkArgs(opts.network)), + '--identity', + opts.identity, + '--json', + ...(opts.query ? ['--query'] : []), + getCanisterId('control_panel', opts.network).toText(), + opts.method, + opts.arg, + ]; + + const { stdout } = await execFileAsync('icp', args, { maxBuffer: 64 * 1024 * 1024 }); + const { response_bytes: hex } = JSON.parse(stdout) as { response_bytes: string }; + // Copied into its own Uint8Array: a Buffer is a view into a shared pool, and + // the decoder reads through to the underlying ArrayBuffer. + const [decoded] = IDL.decode(replyTypes(opts.method), Uint8Array.from(Buffer.from(hex, 'hex'))); + + return decoded as T; +}; + +// icx-asset signs with a pem file, so the key has to come back out of whatever +// store icp-cli keeps it in. Exporting keeps this independent of the store +// layout, which differs per platform and per storage mode. +export const exportIdentityPem = async (identity: string): Promise => { + const { stdout } = await execFileAsync('icp', ['identity', 'export', identity]); + const path = join(tmpdir(), `orbit-cli-${identity}-${randomBytes(8).toString('hex')}.pem`); + writeFileSync(path, stdout, { encoding: 'utf-8', mode: 0o600 }); + + return path; +}; diff --git a/cli/src/registry/list.ts b/cli/src/registry/list.ts index e19775732..f1d30560d 100644 --- a/cli/src/registry/list.ts +++ b/cli/src/registry/list.ts @@ -1,5 +1,4 @@ import { createCommand } from 'commander'; -import { assertReplicaIsHealthy } from '../utils'; import { Application, applicationToRegistryEntryMap, @@ -29,8 +28,6 @@ command ); command.action(async options => { - assertReplicaIsHealthy(options.network); - const entries = await searchRegistry({ name: applicationToRegistryEntryMap[options.app as Application], network: options.network, diff --git a/cli/src/registry/publish.ts b/cli/src/registry/publish.ts index 79846f603..88ee66adf 100644 --- a/cli/src/registry/publish.ts +++ b/cli/src/registry/publish.ts @@ -7,14 +7,13 @@ import { join } from 'path'; import { AddRegistryEntryResult, EditRegistryEntryResult } from '../generated/control_panel'; import { assertCommandExists, - assertReplicaIsHealthy, cargoProjectVersion, execAsync, - getIdentityPemFilePath, getReplicaUrl, readFileIntoUint8Array, toBlobString, } from '../utils'; +import { callControlPanel, exportIdentityPem } from './icp'; import { Application, applicationToRegistryEntryMap, @@ -128,24 +127,13 @@ command parseRegistryApplication, ); -// Saves the argument in a temporary file and returns the path to the file. -const saveArgumentInTempFile = async (argument: string): Promise => { - const tempFilePath = join(tmpdir(), 'orbit-cli-argument-' + Math.random().toString(36).slice(2)); - - await writeFile(tempFilePath, argument, { - encoding: 'utf-8', - }); - - return tempFilePath; -}; - command.action(async options => { - assertReplicaIsHealthy(options.network); - assertCommandExists('dfx'); + assertCommandExists('icp'); assertCommandExists('icx-asset'); const replicaUrl = await getReplicaUrl(options.network); - const identityPemPath = await getIdentityPemFilePath(options.identity); + const identityPemPath = await exportIdentityPem(options.identity); + process.on('exit', () => rmSync(identityPemPath, { force: true })); // Finds the wasm chunk store canister id. const wasmChunkStoreId = getWasmChunkStoreId(options.network); @@ -205,8 +193,7 @@ command.action(async options => { if (maybeRegistryId) { console.log(`Updating the registry entry for ${entry.name} with id(${maybeRegistryId})...`); - const argumentFile = await saveArgumentInTempFile(` - record { + const arg = `(record { id = "${maybeRegistryId}"; entry = record { description = opt "${entry.description}"; @@ -227,16 +214,14 @@ command.action(async options => { } } } - } - `); - - const unparsed = await execAsync(` - dfx canister call --identity '${options.identity}' --network '${options.network}' --output json control_panel edit_registry_entry --argument-file '${argumentFile}' - `); - - rmSync(argumentFile); - - const result: EditRegistryEntryResult = JSON.parse(unparsed); + })`; + + const result = await callControlPanel({ + method: 'edit_registry_entry', + network: options.network, + identity: options.identity, + arg, + }); if ('Err' in result) { throw new Error(`Failed to update the registry entry: ${JSON.stringify(result.Err)}`); } @@ -244,8 +229,7 @@ command.action(async options => { console.log(`Registry with id ${maybeRegistryId} has been updated for ${entry.name}.`); } else { console.log(`Adding the registry entry for ${entry.name}...`); - const argumentFile = await saveArgumentInTempFile(` - record { + const arg = `(record { entry = record { name = "${entry.name}"; description = "${entry.description}"; @@ -266,16 +250,14 @@ command.action(async options => { } } } - } - `); - - const unparsed = await execAsync(` - dfx canister call --identity '${options.identity}' --network '${options.network}' --output json control_panel add_registry_entry --argument-file '${argumentFile}' - `); - - rmSync(argumentFile); - - const result: AddRegistryEntryResult = JSON.parse(unparsed); + })`; + + const result = await callControlPanel({ + method: 'add_registry_entry', + network: options.network, + identity: options.identity, + arg, + }); if ('Err' in result) { throw new Error(`Failed to add the registry entry: ${JSON.stringify(result.Err)}`); } diff --git a/cli/src/registry/registry.core.ts b/cli/src/registry/registry.core.ts index 1caee85b4..5fecb1e03 100644 --- a/cli/src/registry/registry.core.ts +++ b/cli/src/registry/registry.core.ts @@ -1,6 +1,5 @@ -import { execSync } from 'child_process'; import { RegistryEntry, SearchRegistryResult } from '../generated/control_panel'; -import { execAsync } from '../utils'; +import { callControlPanel, getCanisterId } from './icp'; import { Principal } from '@dfinity/principal'; export enum Application { @@ -8,13 +7,8 @@ export enum Application { Upgrader = 'upgrader', } -export const getWasmChunkStoreId = (network: string = 'local'): Principal => { - const maybeCanisterId = execSync(`dfx canister id wasm_chunk_store --network ${network}`) - .toString() - .trim(); - - return Principal.fromText(maybeCanisterId); -}; +export const getWasmChunkStoreId = (network: string = 'local'): Principal => + getCanisterId('wasm_chunk_store', network); export const applicationToRegistryEntryMap: Record = { [Application.Station]: '@orbit/station', @@ -52,17 +46,19 @@ export const searchRegistry = async (opts: { let offset = 0; const entries: RegistryEntry[] = []; do { - const unparsed = await execAsync(` - dfx canister call --identity '${opts.identity}' --network '${opts.network}' --output json control_panel search_registry 'record { + const result = await callControlPanel({ + method: 'search_registry', + network: opts.network, + identity: opts.identity, + query: true, + arg: `(record { pagination = opt record { offset = opt ${offset}; limit = opt 50; }; sort_by = opt variant { Version = variant { Desc } }; filter_by = vec { variant { Name = "${opts.name}" } }; - }' - `); - - const result: SearchRegistryResult = JSON.parse(unparsed); + })`, + }); if ('Err' in result) { throw new Error(`Failed to search the registry: ${JSON.stringify(result.Err)}`); } diff --git a/cli/src/registry/remove.ts b/cli/src/registry/remove.ts index 9b7dbc62b..47e2aeed6 100644 --- a/cli/src/registry/remove.ts +++ b/cli/src/registry/remove.ts @@ -1,5 +1,5 @@ import { createCommand } from 'commander'; -import { assertReplicaIsHealthy, execAsync } from '../utils'; +import { callControlPanel } from './icp'; import { DeleteRegistryEntryResult } from '~/generated/control_panel'; const command = createCommand('remove').description( @@ -20,16 +20,13 @@ command .requiredOption('-k, --id ', 'The id of the registry entry to remove.'); command.action(async options => { - assertReplicaIsHealthy(options.network); - console.log(`Removing the registry entry with id ${options.id}...`); - const unparsed = await execAsync(` - dfx canister call --identity '${options.identity}' --network '${options.network}' --output json control_panel delete_registry_entry '(record { - id = "${options.id}" - })' - `); - - const result: DeleteRegistryEntryResult = JSON.parse(unparsed); + const result = await callControlPanel({ + method: 'delete_registry_entry', + network: options.network, + identity: options.identity, + arg: `(record { id = "${options.id}" })`, + }); if ('Ok' in result) { console.log('The registry entry has been removed.'); return; diff --git a/cli/src/utils.ts b/cli/src/utils.ts index 93274298e..192621149 100644 --- a/cli/src/utils.ts +++ b/cli/src/utils.ts @@ -1,20 +1,10 @@ import { exec, execSync } from 'child_process'; -import { existsSync } from 'fs'; import { readFile } from 'fs/promises'; -import { homedir } from 'os'; import { join } from 'path'; import { promisify } from 'util'; export const ROOT_PATH = join(__dirname, '../..'); export const DFX_PATH = join(ROOT_PATH, 'dfx.json'); -// DFX_CONFIG_ROOT stands in for the home directory, the same way dfx itself -// treats it. Without this a caller that isolates the identity store has to -// repoint HOME as well, which hides the dfxvm version store and makes dfx -// report the pinned version as not installed. -export const DFX_DEFAULT_IDENTITY_STORE_PATH = join( - process.env.DFX_CONFIG_ROOT || homedir(), - '.config/dfx/identity', -); // Parse a string of arguments separated by a separator and return an array of strings. export const parseArgsListSplitByComma = (arg?: string): string[] => { @@ -78,16 +68,6 @@ export const toBlobString = (buffer: Uint8Array): string => { .join(''); }; -export const assertReplicaIsHealthy = async (network: string): Promise => { - const ping: { replica_health_status?: string } = JSON.parse( - await execAsync(`dfx ping '${network}'`), - ); - - if (ping.replica_health_status?.toLowerCase() !== 'healthy') { - throw new Error('The replica is not healthy.'); - } -}; - export const commandExists = (command: string): boolean => { try { execSync(`command -v ${command}`, { stdio: 'ignore' }); @@ -131,23 +111,3 @@ export const getReplicaUrl = async (network: string): Promise => { throw new Error(`Network '${network}' does not have a replica URL.`); }; - -export const getIdentityPemFilePath = async (identity: string): Promise => { - if (!existsSync(DFX_DEFAULT_IDENTITY_STORE_PATH)) { - throw new Error('Identity store not found.'); - } - - const possiblePaths = [ - join(DFX_DEFAULT_IDENTITY_STORE_PATH, identity, `${identity}.pem`), - join(DFX_DEFAULT_IDENTITY_STORE_PATH, identity, `identity.pem`), - join(DFX_DEFAULT_IDENTITY_STORE_PATH, identity, `id.pem`), - ]; - - for (const path of possiblePaths) { - if (existsSync(path)) { - return path; - } - } - - throw new Error(`PEM file not found for identity: ${identity}`); -};