diff --git a/.changeset/native-contract-install.md b/.changeset/native-contract-install.md new file mode 100644 index 0000000..5f1d7cd --- /dev/null +++ b/.changeset/native-contract-install.md @@ -0,0 +1,5 @@ +--- +"playground-cli": patch +--- + +Run `dot contract install` through dot's native TUI and the released CDM install backend instead of spawning the CDM CLI. `dot init` now installs `cargo-pvm-contract` directly instead of running the CDM CLI installer. diff --git a/README.md b/README.md index f122250..6c0330d 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ The installer drops the binary into `~/.polkadot/bin/`, symlinks it at `~/.local End-to-end first-run setup. Login and toolchain install run **concurrently**; account setup runs **once both have completed successfully**. 1. **Login via the Polkadot mobile app** — a QR code is printed to the terminal. Scan it with the app. If you already have a session persisted in `~/.polkadot-apps/`, this step is skipped. -2. **Toolchain install** — `rustup`, nightly, `rust-src`, `cdm`, IPFS, and `gh`. Existing installs are detected and skipped. +2. **Toolchain install** — `rustup`, nightly, `rust-src`, `cargo-pvm-contract`, IPFS, and `git`. Existing installs are detected and skipped. 3. **Account setup** (only if a session is available) — in order: - **Fund** — if your balance on Paseo Asset Hub is below 1 PAS, Alice sends 10 PAS (testnet). - **Map** — `Revive.map_account` is signed by you on the mobile app so an H160 is associated with your SS58 address. @@ -83,7 +83,7 @@ CDM-backed workflows for contracts: - `dot contract deploy` builds, deploys, and registers CDM contracts with dot's logged-in signer by default. Pass `--suri //Alice` for local/dev signing. - `dot contract deploy --features ` forwards Cargo feature flags into CDM's build pipeline. - `dot contract deploy --registry-address
` targets a specific CDM registry. -- `dot contract install [libraries...]` runs `cdm install [libraries...]`; CDM still owns dependency installation and post-install hooks. +- `dot contract install [libraries...]` uses the CDM install backend with dot's native TUI, then writes `cdm.json` and CDM post-install outputs. ### `dot mod` diff --git a/package.json b/package.json index c3bdac7..c49b628 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "test:e2e:nightly": "tools/e2e-local.sh nightly" }, "dependencies": { - "@dotdm/contracts": "^3.0.0", + "@dotdm/cdm": "^0.6.13", + "@dotdm/contracts": "^3.1.0", "@dotdm/env": "^2.0.0", "@parity/dotns-cli": "0.6.1", "@parity/product-sdk-address": "^0.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9bd7fe3..299a3f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,9 +18,12 @@ importers: .: dependencies: + '@dotdm/cdm': + specifier: ^0.6.13 + version: 0.6.13(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(@polkadot-api/ink-contracts@0.6.2)(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2)(typescript@5.9.3) '@dotdm/contracts': - specifier: ^3.0.0 - version: 3.0.0(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2)(typescript@5.9.3) + specifier: ^3.1.0 + version: 3.1.0(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2)(typescript@5.9.3) '@dotdm/env': specifier: ^2.0.0 version: 2.0.0(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(esbuild@0.27.7)(rxjs@7.8.2) @@ -350,8 +353,11 @@ packages: resolution: {integrity: sha512-WXTuFvL3G+74SchFAtz3FgIYVOe196ycvGsMgvSH/8Goptb1qpIQtIuM4SOK9G9lhMWYpHxnXyy544ZhluFOew==} engines: {node: '>=6'} - '@dotdm/contracts@3.0.0': - resolution: {integrity: sha512-MumLFq8z+cP/EbeuiWtYVUU5qEEsKxvNZRCYU8hX7haAt7lFfqegeQKeZeXP0WWRS4Tn6ftKtSmCh9TJeXYP+g==} + '@dotdm/cdm@0.6.13': + resolution: {integrity: sha512-HW7auJUJcjjoWmjY/uRcOrlOtqdqAqWJRW/H/3Q4OwDgyN3glvROENBpbFf04KmzwbpRs4hz3wz4cN1g3sQCxg==} + + '@dotdm/contracts@3.1.0': + resolution: {integrity: sha512-YgYyskbUR1SLdkI7Q8euZ0L3hEsRlS/HGyRj5gyi5Np0Q8HN+i1D1LGVXbG9sMjlB3Sp+uU+3VgY1Hz93uxiRQ==} '@dotdm/env@2.0.0': resolution: {integrity: sha512-oFsCUlYgi2r6F9t1+y+P2S53t8ZmYZ6kuen0deZjROElJTCOl+bhs7iIf/qDHemkkmRXsmxuoJGM1lcNhzyGCw==} @@ -5239,7 +5245,28 @@ snapshots: '@leichtgewicht/ip-codec': 2.0.5 utf8-codec: 1.0.0 - '@dotdm/contracts@3.0.0(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2)(typescript@5.9.3)': + '@dotdm/cdm@0.6.13(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(@polkadot-api/ink-contracts@0.6.2)(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2)(typescript@5.9.3)': + dependencies: + '@dotdm/contracts': 3.1.0(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2)(typescript@5.9.3) + '@dotdm/env': 2.0.0(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(esbuild@0.27.7)(rxjs@7.8.2) + '@dotdm/utils': 0.4.0 + '@polkadot-api/sdk-ink': 0.7.0(@polkadot-api/ink-contracts@0.6.2)(polkadot-api@2.1.3(esbuild@0.27.7)(rxjs@7.8.2))(rxjs@7.8.2)(typescript@5.9.3) + polkadot-api: 2.1.3(esbuild@0.27.7)(rxjs@7.8.2) + transitivePeerDependencies: + - '@novasamatech/host-api' + - '@novasamatech/product-sdk' + - '@polkadot-api/ink-contracts' + - '@polkadot/api' + - '@polkadot/util' + - bufferutil + - esbuild + - rxjs + - supports-color + - typescript + - utf-8-validate + - zod + + '@dotdm/contracts@3.1.0(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2)(typescript@5.9.3)': dependencies: '@dotdm/env': 2.0.0(@novasamatech/host-api@0.7.9-4)(@novasamatech/product-sdk@0.7.9-4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(esbuild@0.27.7)(rxjs@7.8.2) '@dotdm/utils': 0.4.0 diff --git a/src/commands/contract.test.ts b/src/commands/contract.test.ts index 9bd238e..5e7a3f8 100644 --- a/src/commands/contract.test.ts +++ b/src/commands/contract.test.ts @@ -14,39 +14,37 @@ // limitations under the License. import { getRegistryAddress } from "@dotdm/env"; +import { computeTargetHash, type CdmJson } from "@dotdm/contracts"; import { DEFAULT_MNEMONIC as BULLETIN_DEPLOY_DEFAULT_MNEMONIC } from "bulletin-deploy"; import { describe, expect, it } from "vitest"; import { getChainConfig } from "../config.js"; import { - cdmPassthroughArgs, + parseContractInstallLibraryArg, resolveContractDeployTarget, + resolveContractInstallTarget, resolveContractSignerOptions, } from "./contract.js"; -describe("cdmPassthroughArgs", () => { - it("returns arguments after the contract subcommand", () => { - expect( - cdmPassthroughArgs( - ["node", "dot", "contract", "install", "@polkadot/reputation", "--name", "paseo"], - "install", - ), - ).toEqual(["@polkadot/reputation", "--name", "paseo"]); +describe("parseContractInstallLibraryArg", () => { + it("defaults to latest", () => { + expect(parseContractInstallLibraryArg("@polkadot/reputation")).toEqual({ + library: "@polkadot/reputation", + requestedVersion: "latest", + }); }); - it("handles the install alias", () => { - expect( - cdmPassthroughArgs( - ["node", "dot", "contract", "i", "@polkadot/reputation:3"], - "install", - ["i"], - ), - ).toEqual(["@polkadot/reputation:3"]); + it("parses explicit versions from the last colon", () => { + expect(parseContractInstallLibraryArg("@polkadot/reputation:3")).toEqual({ + library: "@polkadot/reputation", + requestedVersion: 3, + }); }); - it("falls back to the first matching subcommand without a contract parent", () => { - expect(cdmPassthroughArgs(["node", "dot", "deploy", "--features", "ci"], "deploy")).toEqual( - ["--features", "ci"], - ); + it("treats non-numeric suffixes as part of the package name", () => { + expect(parseContractInstallLibraryArg("@polkadot/reputation:beta")).toEqual({ + library: "@polkadot/reputation:beta", + requestedVersion: "latest", + }); }); }); @@ -83,6 +81,145 @@ describe("resolveContractDeployTarget", () => { }); }); +describe("resolveContractInstallTarget", () => { + it("uses the active playground chain by default", () => { + const cfg = getChainConfig(); + const ipfsGatewayUrl = cfg.bulletinGateway; + const registryAddress = getRegistryAddress(cfg.env); + expect(resolveContractInstallTarget({})).toEqual({ + assethubUrl: cfg.assetHubRpc, + ipfsGatewayUrl, + registryAddress, + targetHash: computeTargetHash(cfg.assetHubRpc, ipfsGatewayUrl, registryAddress), + chainName: undefined, + }); + }); + + it("prefers the first cdm.json target when no explicit target is supplied", () => { + const cdmJson: CdmJson = { + targets: { + abc123: { + "asset-hub": "wss://asset.example", + bulletin: "https://gateway.example/ipfs/", + registry: "0x1111111111111111111111111111111111111111", + }, + }, + dependencies: {}, + contracts: {}, + }; + + expect(resolveContractInstallTarget({}, cdmJson)).toEqual({ + assethubUrl: "wss://asset.example", + ipfsGatewayUrl: "https://gateway.example/ipfs/", + registryAddress: "0x1111111111111111111111111111111111111111", + targetHash: "abc123", + chainName: undefined, + }); + }); + + it("prefers a cdm.json target with dependencies when reinstalling", () => { + const cdmJson: CdmJson = { + targets: { + empty: { + "asset-hub": "wss://empty.example", + bulletin: "https://empty.example/ipfs", + registry: "0x1111111111111111111111111111111111111111", + }, + withDeps: { + "asset-hub": "wss://deps.example", + bulletin: "https://deps.example/ipfs", + registry: "0x2222222222222222222222222222222222222222", + }, + }, + dependencies: { + withDeps: { + "@polkadot/contexts": "latest", + }, + }, + contracts: {}, + }; + + expect(resolveContractInstallTarget({}, cdmJson)).toEqual({ + assethubUrl: "wss://deps.example", + ipfsGatewayUrl: "https://deps.example/ipfs", + registryAddress: "0x2222222222222222222222222222222222222222", + targetHash: "withDeps", + chainName: undefined, + }); + }); + + it("preserves legacy cdm.json target keys when resolving a saved target", () => { + const cdmJson: CdmJson = { + targets: { + legacyHash: { + "asset-hub": "wss://asset.example", + bulletin: "https://gateway.example/ipfs", + }, + }, + dependencies: { + legacyHash: { + "@polkadot/contexts": "latest", + }, + }, + contracts: {}, + }; + + const target = resolveContractInstallTarget({}, cdmJson); + expect(target.targetHash).toBe("legacyHash"); + expect(target.targetHash).not.toBe( + computeTargetHash(target.assethubUrl, target.ipfsGatewayUrl, target.registryAddress), + ); + }); + + it("allows --name custom to reuse cdm.json target connection details", () => { + const cdmJson: CdmJson = { + targets: { + abc123: { + "asset-hub": "wss://asset.example", + bulletin: "https://gateway.example/ipfs/", + registry: "0x1111111111111111111111111111111111111111", + }, + }, + dependencies: {}, + contracts: {}, + }; + + expect(resolveContractInstallTarget({ name: "custom" }, cdmJson)).toEqual({ + assethubUrl: "wss://asset.example", + ipfsGatewayUrl: "https://gateway.example/ipfs/", + registryAddress: "0x1111111111111111111111111111111111111111", + targetHash: "abc123", + chainName: undefined, + }); + }); + + it("accepts explicit endpoint and registry overrides", () => { + expect( + resolveContractInstallTarget({ + assethubUrl: "wss://asset.example", + ipfsGatewayUrl: "https://gateway.example/ipfs/", + registryAddress: "0x2222222222222222222222222222222222222222", + }), + ).toEqual({ + assethubUrl: "wss://asset.example", + ipfsGatewayUrl: "https://gateway.example/ipfs/", + registryAddress: "0x2222222222222222222222222222222222222222", + targetHash: computeTargetHash( + "wss://asset.example", + "https://gateway.example/ipfs/", + "0x2222222222222222222222222222222222222222", + ), + chainName: undefined, + }); + }); + + it("rejects non-H160 registry addresses", () => { + expect(() => resolveContractInstallTarget({ registryAddress: "0x1234" })).toThrow( + "Registry address must be a 20-byte hex address", + ); + }); +}); + describe("resolveContractSignerOptions", () => { it("preserves the default contract signer behavior", () => { expect(resolveContractSignerOptions({})).toEqual({ suri: undefined }); diff --git a/src/commands/contract.ts b/src/commands/contract.ts index fad731a..eafa95d 100644 --- a/src/commands/contract.ts +++ b/src/commands/contract.ts @@ -13,10 +13,31 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { spawn } from "node:child_process"; -import { resolve } from "node:path"; -import { resolveFeatures, type PipelineChainClient } from "@dotdm/contracts"; -import { getRegistryAddress } from "@dotdm/env"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { generateContractTypes, generateContractsAugmentation } from "@dotdm/cdm"; +import { + CONTRACTS_REGISTRY_ABI, + computeTargetHash, + generateSolidityImport, + hasBuildableSolidityProject, + readCdmJson, + resolveFeatures, + resolveTargetRegistryAddress, + type CdmJson, + type InstallLibraryRequest, + type InstallResult, + type PipelineChainClient, + type SolidityAbiEntry, + writeCdmJson, +} from "@dotdm/contracts"; +import { + connectIpfsGateway, + createCdmAssetHubClient, + getChainPreset, + getRegistryAddress, +} from "@dotdm/env"; +import { createContractFromClient } from "@parity/product-sdk-contracts"; import { paseo_asset_hub } from "@parity/product-sdk-descriptors/paseo-asset-hub"; import { paseo_bulletin } from "@parity/product-sdk-descriptors/paseo-bulletin"; import { DEFAULT_MNEMONIC as BULLETIN_DEPLOY_DEFAULT_MNEMONIC } from "bulletin-deploy"; @@ -28,12 +49,17 @@ import { getChainConfig } from "../config.js"; import { getBulletinAllowanceSigner } from "../utils/allowances/bulletin.js"; import { ensureSmartContractAllowance } from "../utils/allowances/smartContracts.js"; import { BULLETIN_WS_HEARTBEAT_MS } from "../utils/bulletinWs.js"; +import { suppressReviveTraceNoise } from "../utils/contractManifest.js"; import type { SignerMode } from "../utils/deploy/signerMode.js"; import { onProcessShutdown } from "../utils/process-guard.js"; import { resolveSigner, type ResolvedSigner, type SignerOptions } from "../utils/signer.js"; import { runContractDeployWithUI } from "./contractDeployUi.js"; +import { runContractInstallWithUI } from "./contractInstallUi.js"; -type CdmSubcommand = "deploy" | "install"; +const CDM_INCLUDE = ".cdm/**/*"; +// CDM registry getters are read-only, but Revive dry-run queries still require +// an origin that encodes on the target chain. This value is not a signer. +const REGISTRY_QUERY_ORIGIN_SS58 = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"; interface ContractDeployOpts { assethubUrl?: string; @@ -58,48 +84,16 @@ interface ContractDeployTarget { registryAddress: HexString; } -type ContractChainClient = PipelineChainClient & { destroy(): void }; - -export function cdmPassthroughArgs( - argv: string[], - subcommand: CdmSubcommand, - aliases: string[] = [], -): string[] { - const contractIndex = argv.indexOf("contract"); - const startAt = contractIndex === -1 ? 0 : contractIndex + 1; - const subcommandNames = new Set([subcommand, ...aliases]); - const subcommandIndex = argv.findIndex( - (arg, index) => index >= startAt && subcommandNames.has(arg), - ); - return subcommandIndex === -1 ? [] : argv.slice(subcommandIndex + 1); +interface ContractInstallTarget { + assethubUrl: string; + ipfsGatewayUrl: string; + registryAddress: HexString; + targetHash: string; + chainName?: string; } -async function runCdmSubprocess(subcommand: CdmSubcommand, args: string[]): Promise { - await new Promise((resolve, reject) => { - const child = spawn("cdm", [subcommand, ...args], { - stdio: "inherit", - env: process.env, - }); - - child.on("error", (err: NodeJS.ErrnoException) => { - if (err.code === "ENOENT") { - reject(new Error('cdm is not installed. Run "dot init" or install CDM manually.')); - return; - } - reject(err); - }); - - child.on("close", (code, signal) => { - if (signal) { - process.exitCode = signal === "SIGINT" ? 130 : 1; - resolve(); - return; - } - process.exitCode = code ?? 1; - resolve(); - }); - }); -} +type ContractChainClient = PipelineChainClient & { destroy(): void }; +type CdmTargetEntry = [string, CdmJson["targets"][string]]; function assertHexAddress(value: string, label: string): HexString { if (!/^0x[0-9a-fA-F]{40}$/.test(value)) { @@ -145,6 +139,72 @@ export function resolveContractDeployTarget(opts: ContractDeployOpts): ContractD }; } +export function parseContractInstallLibraryArg(arg: string): InstallLibraryRequest { + const colonIdx = arg.lastIndexOf(":"); + if (colonIdx > 0) { + const library = arg.slice(0, colonIdx); + const version = Number.parseInt(arg.slice(colonIdx + 1), 10); + if (!Number.isNaN(version)) return { library, requestedVersion: version }; + } + return { library: arg, requestedVersion: "latest" }; +} + +export function resolveContractInstallTarget( + opts: ContractInstallOpts, + cdmJson?: CdmJson, +): ContractInstallTarget { + const cfg = getChainConfig(); + let assethubUrl = opts.assethubUrl; + let ipfsGatewayUrl = opts.ipfsGatewayUrl; + let registryAddress = opts.registryAddress; + let chainName = opts.name; + + if (opts.name && opts.name !== "custom") { + const preset = getChainPreset(opts.name); + assethubUrl ??= preset.assethubUrl; + ipfsGatewayUrl ??= preset.ipfsGatewayUrl; + registryAddress ??= preset.registryAddress; + } + + const explicitTarget = + Boolean(opts.assethubUrl || opts.ipfsGatewayUrl || opts.registryAddress) || + Boolean(opts.name && opts.name !== "custom"); + let selectedTargetHash: string | undefined; + const selectedTargetEntry = explicitTarget ? undefined : selectCdmTargetEntry(cdmJson); + const selectedTarget = selectedTargetEntry?.[1]; + + if (selectedTarget) { + if (!assethubUrl && (!opts.name || opts.name === "custom")) { + assethubUrl = selectedTarget["asset-hub"]; + } + ipfsGatewayUrl ??= selectedTarget.bulletin; + registryAddress ??= resolveTargetRegistryAddress(selectedTarget); + selectedTargetHash = selectedTargetEntry[0]; + } + + assethubUrl ??= cfg.assetHubRpc; + ipfsGatewayUrl ??= cfg.bulletinGateway; + registryAddress ??= getRegistryAddress(cfg.env); + + return { + assethubUrl, + ipfsGatewayUrl, + registryAddress: assertHexAddress(registryAddress, "Registry address"), + targetHash: + selectedTargetHash ?? computeTargetHash(assethubUrl, ipfsGatewayUrl, registryAddress), + chainName: chainName === "custom" ? undefined : chainName, + }; +} + +function selectCdmTargetEntry(cdmJson: CdmJson | undefined): CdmTargetEntry | undefined { + const entries = Object.entries(cdmJson?.targets ?? {}); + return ( + entries.find( + ([targetHash]) => Object.keys(cdmJson?.dependencies[targetHash] ?? {}).length > 0, + ) ?? entries[0] + ); +} + async function createContractChainClient( target: ContractDeployTarget, ): Promise { @@ -239,6 +299,188 @@ async function runContractDeploy(opts: ContractDeployOpts): Promise { } } +function detectProjectType(rootDir: string): { + hasRust: boolean; + hasSolidity: boolean; + hasTypeScript: boolean; +} { + return { + hasRust: existsSync(resolve(rootDir, "Cargo.toml")), + hasSolidity: hasBuildableSolidityProject(rootDir), + hasTypeScript: existsSync(resolve(rootDir, "package.json")), + }; +} + +function installRequestsFromArgs( + libraries: string[], + cdmJson: CdmJson, + targetHash: string, +): InstallLibraryRequest[] { + if (libraries.length > 0) return libraries.map(parseContractInstallLibraryArg); + + const deps = cdmJson.dependencies[targetHash]; + if (!deps || Object.keys(deps).length === 0) { + throw new Error( + "No library specified and no dependencies found in cdm.json for this target.", + ); + } + + return Object.entries(deps).map(([library, version]) => ({ + library, + requestedVersion: version === "latest" ? "latest" : Number(version), + })); +} + +function updateCdmJsonAfterInstall( + cdmJson: CdmJson, + target: ContractInstallTarget, + requests: InstallLibraryRequest[], + results: InstallResult[], +): void { + cdmJson.targets[target.targetHash] = { + "asset-hub": target.assethubUrl, + bulletin: target.ipfsGatewayUrl, + registry: target.registryAddress, + }; + cdmJson.dependencies[target.targetHash] ??= {}; + cdmJson.contracts ??= {}; + cdmJson.contracts[target.targetHash] ??= {}; + + for (const result of results) { + const request = requests.find((entry) => entry.library === result.library); + if (!request) continue; + cdmJson.dependencies[target.targetHash][result.library] = request.requestedVersion; + cdmJson.contracts[target.targetHash][result.library] = { + version: result.version, + address: result.address, + abi: result.abi, + metadataCid: result.metadataCid, + }; + } +} + +function ensureTsconfigIncludesCdm(rootDir: string): void { + const tsconfigPath = resolve(rootDir, "tsconfig.json"); + + let tsconfig: Record; + if (existsSync(tsconfigPath)) { + try { + tsconfig = JSON.parse(readFileSync(tsconfigPath, "utf8")); + } catch { + return; + } + } else { + tsconfig = {}; + } + + const include = Array.isArray(tsconfig.include) ? tsconfig.include : []; + const alreadyHas = include.some( + (entry: unknown) => typeof entry === "string" && entry.replace(/^\.\//, "") === CDM_INCLUDE, + ); + if (alreadyHas) return; + + include.push(`./${CDM_INCLUDE}`); + tsconfig.include = include; + writeFileSync(tsconfigPath, `${JSON.stringify(tsconfig, null, 4)}\n`); +} + +function postInstallSolidity(rootDir: string, cdmJson: CdmJson, targetHash: string): void { + const contractsForTarget = cdmJson.contracts?.[targetHash]; + if (!contractsForTarget) return; + + for (const [library, data] of Object.entries(contractsForTarget)) { + const generated = generateSolidityImport({ + library, + address: data.address, + version: data.version, + abi: data.abi as SolidityAbiEntry[], + }); + const outputPath = resolve(rootDir, generated.path); + mkdirSync(dirname(outputPath), { recursive: true }); + writeFileSync(outputPath, generated.content); + } +} + +function postInstallTypeScript(rootDir: string, cdmJson: CdmJson, targetHash: string): void { + const contractsForTarget = cdmJson.contracts?.[targetHash]; + if (!contractsForTarget) return; + + const contracts = Object.entries(contractsForTarget).map(([library, data]) => ({ + library, + abi: data.abi as Parameters[0][number]["abi"], + })); + if (contracts.length === 0) return; + + const cdmDir = resolve(rootDir, ".cdm"); + mkdirSync(cdmDir, { recursive: true }); + writeFileSync(resolve(cdmDir, "cdm.d.ts"), generateContractTypes(contracts)); + writeFileSync(resolve(cdmDir, "contracts.d.ts"), generateContractsAugmentation(contracts)); + ensureTsconfigIncludesCdm(rootDir); +} + +function runPostInstallHooks(rootDir: string, cdmJson: CdmJson, targetHash: string): void { + const projectType = detectProjectType(rootDir); + if (projectType.hasSolidity) postInstallSolidity(rootDir, cdmJson, targetHash); + if (projectType.hasTypeScript) postInstallTypeScript(rootDir, cdmJson, targetHash); +} + +async function runContractInstall(libraries: string[], opts: ContractInstallOpts): Promise { + const rootDir = resolve(process.cwd()); + const cdmResult = readCdmJson(rootDir); + const cdmJson = cdmResult?.cdmJson ?? { targets: {}, dependencies: {}, contracts: {} }; + const target = resolveContractInstallTarget(opts, cdmJson); + const requests = installRequestsFromArgs(libraries, cdmJson, target.targetHash); + + let client: Awaited> | null = null; + const cleanupOnce = (() => { + let ran = false; + return () => { + if (ran) return; + ran = true; + try { + client?.destroy(); + } catch {} + }; + })(); + onProcessShutdown(cleanupOnce); + + try { + client = await createCdmAssetHubClient(target.assethubUrl, target.chainName); + await client.raw.assetHub.getChainSpecData(); + // Registry getters run through Revive dry-run queries, so product-sdk + // still needs a mapped origin to encode the call. This is not a signer. + const registry = suppressReviveTraceNoise( + await createContractFromClient( + client.raw.assetHub, + client.descriptors.assetHub, + target.registryAddress, + CONTRACTS_REGISTRY_ABI, + { defaultOrigin: REGISTRY_QUERY_ORIGIN_SS58 }, + ), + ); + const ipfs = connectIpfsGateway(target.ipfsGatewayUrl); + + const result = await runContractInstallWithUI({ + libraries: requests, + registry, + ipfs, + targetHash: target.targetHash, + registryAddress: target.registryAddress, + assethubUrl: target.assethubUrl, + ipfsGatewayUrl: target.ipfsGatewayUrl, + }); + + updateCdmJsonAfterInstall(cdmJson, target, requests, result.summary.results); + writeCdmJson(cdmJson, rootDir); + if (result.summary.results.length > 0) { + runPostInstallHooks(rootDir, cdmJson, target.targetHash); + } + if (!result.success) process.exitCode = 1; + } finally { + cleanupOnce(); + } +} + function makeDeployCommand(): Command { return new Command("deploy") .description("Build, deploy, and register CDM contracts with the dot signer") @@ -270,9 +512,9 @@ function makeInstallCommand(): Command { .option("-n, --name ", "Chain preset name (polkadot, paseo, preview-net, local)") .option("--ipfs-gateway-url ", "IPFS gateway URL for fetching metadata") .option("--registry-address
", "Registry contract address") - .action(async (_libraries: string[], _opts: ContractInstallOpts) => + .action(async (libraries: string[], opts: ContractInstallOpts) => runCliCommand("contract", { watchdog: true, hardExit: true }, () => - runCdmSubprocess("install", cdmPassthroughArgs(process.argv, "install", ["i"])), + runContractInstall(libraries, opts), ), ); } diff --git a/src/commands/contractInstallUi.tsx b/src/commands/contractInstallUi.tsx new file mode 100644 index 0000000..4d8854c --- /dev/null +++ b/src/commands/contractInstallUi.tsx @@ -0,0 +1,344 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { useEffect, useState, type ReactNode } from "react"; +import { Box, Text, render } from "ink"; +import { + installContracts, + type InstallContractsOptions, + type InstallEvent, + type InstallResult, + type InstallSummary, +} from "@dotdm/contracts"; +import { getNetworkLabel } from "../config.js"; +import { COLOR, GLYPH, Header, Mark, Row, Section, TIMING } from "../utils/ui/theme/index.js"; +import { VERSION_LABEL } from "../utils/version.js"; + +const COL_CONTRACT = 24; +const COL_VERSION = 10; +const COL_METADATA = 10; +const COL_ADDRESS = 14; + +type InstallState = "waiting" | "querying" | "fetching" | "done" | "error"; + +interface InstallStatus { + library: string; + state: InstallState; + error?: string; + version?: number; + address?: string; + metadataCid?: string; + savedPath?: string; +} + +export interface ContractInstallUiOptions extends Omit { + registryAddress: string; + assethubUrl: string; + ipfsGatewayUrl: string; +} + +export interface ContractInstallUiResult { + summary: InstallSummary; + success: boolean; +} + +class ContractInstallStatusAdapter { + readonly statuses = new Map(); + + constructor(libraries: string[]) { + for (const library of libraries) { + this.statuses.set(library, { library, state: "waiting" }); + } + } + + handleEvent = (event: InstallEvent) => { + switch (event.type) { + case "install-start": + this.update(event.library, "waiting"); + return; + case "query-start": + this.update(event.library, "querying"); + return; + case "query-done": + this.update(event.library, "fetching", { + version: event.version, + address: event.address, + metadataCid: event.metadataCid, + }); + return; + case "fetch-start": + this.update(event.library, "fetching", { metadataCid: event.metadataCid }); + return; + case "install-done": + this.update(event.library, "done", { + version: event.result.version, + address: event.result.address, + metadataCid: event.result.metadataCid, + savedPath: event.result.savedPath, + }); + return; + case "install-error": + this.update(event.library, "error", { error: event.error }); + return; + case "pipeline-done": + case "pipeline-error": + return; + } + }; + + private update(library: string, state: InstallState, extra?: Partial) { + const current = this.statuses.get(library) ?? { library, state: "waiting" }; + this.statuses.set(library, { ...current, state, ...extra }); + } +} + +export async function runContractInstallWithUI( + opts: ContractInstallUiOptions, +): Promise { + const { registryAddress, assethubUrl, ipfsGatewayUrl, ...installOpts } = opts; + const libraries = installOpts.libraries.map((entry) => entry.library); + const adapter = new ContractInstallStatusAdapter(libraries); + + const app = render( + , + ); + + let summary: InstallSummary; + try { + summary = await installContracts({ + ...installOpts, + onEvent: adapter.handleEvent, + }); + } finally { + await new Promise((resolve) => setTimeout(resolve, 200)); + app.unmount(); + } + + return { summary, success: summary.success }; +} + +function ContractInstallScreen({ + adapter, + libraries, + registryAddress, + assethubUrl, + ipfsGatewayUrl, +}: { + adapter: ContractInstallStatusAdapter; + libraries: string[]; + registryAddress: string; + assethubUrl: string; + ipfsGatewayUrl: string; +}) { + const [tick, setTick] = useState(0); + + useEffect(() => { + const timer = setInterval(() => setTick((current) => current + 1), TIMING.spinnerMs); + return () => clearInterval(timer); + }, []); + + return ( + +
+
+ + + +
+ + + ); +} + +function InstallTable({ + statuses, + libraries, + ipfsGatewayUrl, + tick, +}: { + statuses: Map; + libraries: string[]; + ipfsGatewayUrl: string; + tick: number; +}) { + const errors = libraries + .map((library) => statuses.get(library)) + .filter((status): status is InstallStatus & { error: string } => + Boolean(status?.state === "error" && status.error), + ); + + return ( + + + {libraries.map((library) => ( + + ))} + {errors.length > 0 && ( + + {errors.map((status) => ( + + ))} + + )} + + ); +} + +function HeaderRow() { + return ( + + + contract + + + version + + + metadata + + + address + + + ); +} + +function InstallRow({ + library, + status, + ipfsGatewayUrl, + tick, +}: { + library: string; + status: InstallStatus | undefined; + ipfsGatewayUrl: string; + tick: number; +}) { + return ( + + + + {library} + + + {versionCell(status, tick)} + {metadataCell(status, ipfsGatewayUrl, tick)} + + {status?.address ? ( + {truncateAddress(status.address)} + ) : ( + + )} + + + ); +} + +function versionCell(status: InstallStatus | undefined, tick: number) { + const state = status?.state ?? "waiting"; + if (state === "querying") return ; + if (state === "error" && status?.version === undefined) return ; + if (status?.version !== undefined) return v{status.version}; + return ; +} + +function metadataCell(status: InstallStatus | undefined, ipfsGatewayUrl: string, tick: number) { + const state = status?.state ?? "waiting"; + if (state === "fetching") return ; + if (state === "error" && status?.version !== undefined && !status.metadataCid) { + return ; + } + if (status?.metadataCid) { + return ( + + ); + } + return ; +} + +function Spinner({ tick }: { tick: number }) { + return {GLYPH.spinner[tick % GLYPH.spinner.length]}; +} + +function Idle() { + return {GLYPH.pending}; +} + +function HashText({ value, url }: { value: string; url?: string }) { + const label = {shortHash(value)}; + return url ? {label} : label; +} + +function Cell({ children, width }: { children: ReactNode; width?: number }) { + return ( + + {children} + + ); +} + +function shortHash(value: string): string { + if (value.startsWith("0x")) return value.slice(2, 6); + return value.slice(-4); +} + +function truncateAddress(address: string): string { + return address.length <= 12 ? address : `${address.slice(0, 6)}...${address.slice(-4)}`; +} + +function Link({ url, children }: { url: string; children: ReactNode }) { + return ( + + {`\x1b]8;;${url}\x07`} + {children} + {"\x1b]8;;\x07"} + + ); +} + +function ipfsUrl(gatewayUrl: string, cid: string): string { + return `${gatewayUrl.replace(/\/+$/, "")}/${cid.replace(/^\/+/, "")}`; +} diff --git a/src/utils/git.test.ts b/src/utils/git.test.ts index c13e20f..b531977 100644 --- a/src/utils/git.test.ts +++ b/src/utils/git.test.ts @@ -15,7 +15,7 @@ /** * Tests for git.ts — focused on sanitize() since it handles tricky - * ANSI/cursor output from child processes (pnpm, cdm, Ink programs), + * ANSI/cursor output from child processes (pnpm, Ink programs), * and runCommand's log-file tee behaviour. */ diff --git a/src/utils/toolchain.test.ts b/src/utils/toolchain.test.ts index 444e9a7..2e1b986 100644 --- a/src/utils/toolchain.test.ts +++ b/src/utils/toolchain.test.ts @@ -14,7 +14,7 @@ // limitations under the License. import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { prependPath } from "./toolchain.js"; +import { prependPath, TOOL_STEPS } from "./toolchain.js"; describe("prependPath", () => { let originalPath: string | undefined; @@ -52,3 +52,15 @@ describe("prependPath", () => { expect(process.env.PATH).toBe("/Users/me/.cargo/bin"); }); }); + +describe("TOOL_STEPS", () => { + it("installs cargo-pvm-contract directly instead of the CDM CLI installer", () => { + const names = TOOL_STEPS.map((step) => step.name); + expect(names).toContain("cargo-pvm-contract"); + expect(names).not.toContain("cdm & cargo-pvm-contract"); + + const step = TOOL_STEPS.find((entry) => entry.name === "cargo-pvm-contract"); + expect(step?.manualHint).toContain("cargo-pvm-contract"); + expect(step?.manualHint).not.toContain("contract-dependency-manager"); + }); +}); diff --git a/src/utils/toolchain.ts b/src/utils/toolchain.ts index e007dcc..973d027 100644 --- a/src/utils/toolchain.ts +++ b/src/utils/toolchain.ts @@ -73,8 +73,8 @@ async function hasRustSrc(): Promise { } } -async function hasCdm(): Promise { - return (await commandExists("cdm")) && (await commandExists("cargo-pvm-contract")); +async function hasCargoPvmContract(): Promise { + return commandExists("cargo-pvm-contract"); } function isIpfsInitialized(): boolean { @@ -88,6 +88,18 @@ export interface ToolStep { manualHint?: string; } +const CARGO_PVM_CONTRACT_INSTALL = ` +set -euo pipefail +tmp_dir="$(mktemp -d)" +cleanup() { + rm -rf "$tmp_dir" +} +trap cleanup EXIT +git clone --depth 1 --branch charles/cdm-integration https://github.com/paritytech/cargo-pvm-contract.git "$tmp_dir" +host_target="$(rustc -vV | awk '/^host:/ { print $2 }')" +cargo install --force --locked --target "$host_target" --path "$tmp_dir/crates/cargo-pvm-contract" +`.trim(); + export const TOOL_STEPS: ToolStep[] = [ { name: "rustup", @@ -116,15 +128,11 @@ export const TOOL_STEPS: ToolStep[] = [ install: (onData) => runPiped("rustup component add rust-src --toolchain nightly", onData), }, { - name: "cdm & cargo-pvm-contract", - check: () => hasCdm(), - install: (onData) => - runPiped( - "curl -fsSL https://raw.githubusercontent.com/paritytech/contract-dependency-manager/main/install.sh | bash", - onData, - ), + name: "cargo-pvm-contract", + check: () => hasCargoPvmContract(), + install: (onData) => runPiped(CARGO_PVM_CONTRACT_INSTALL, onData), manualHint: - "curl -fsSL https://raw.githubusercontent.com/paritytech/contract-dependency-manager/main/install.sh | bash", + "Install cargo-pvm-contract from https://github.com/paritytech/cargo-pvm-contract/tree/charles/cdm-integration", }, { name: "IPFS",