From 80da55bc7e89b8c717fc1cd410d47a640f1abd5c Mon Sep 17 00:00:00 2001 From: Adam Avramov Date: Fri, 3 Nov 2023 13:37:57 +0200 Subject: [PATCH] wip 34.5 --- agent/chain.test.ts | 10 +- agent/chain.ts | 6 +- agent/code.test.ts | 6 +- agent/deploy.ts | 21 +- agent/devnet.test.ts | 58 +- agent/devnet.ts | 51 +- agent/store.ts | 16 +- agent/stub.ts | 118 ++- ...rt-mocknet.ts.old => scrt-mocknet-impl.ts} | 512 +++------- connect/scrt/scrt-mocknet.test.ts.old | 10 +- connect/scrt/scrt-mocknet.ts | 148 +++ fadroma.test.ts | 3 +- fadroma.ts | 8 +- fixtures/fixtures.ts | 4 + ops/build.ts | 51 +- ops/config.ts | 13 +- ops/deploy.test.ts | 84 -- ops/devnets.test.ts | 40 +- ops/devnets.ts | 130 +-- ops/project.test.ts | 143 ++- ops/project.ts | 920 +++++++++--------- ops/prompts.test.ts | 22 + ops/prompts.ts | 133 +++ ops/scaffold.ts | 207 ---- ops/stores.test.ts | 4 +- ops/stores.ts | 44 +- ops/tools.test.ts | 3 + ops/tools.ts | 350 +++++++ ops/wizard.test.ts | 29 - ops/wizard.ts | 365 ------- 30 files changed, 1743 insertions(+), 1766 deletions(-) rename connect/scrt/{scrt-mocknet.ts.old => scrt-mocknet-impl.ts} (64%) create mode 100644 connect/scrt/scrt-mocknet.ts delete mode 100644 ops/deploy.test.ts create mode 100644 ops/prompts.test.ts create mode 100644 ops/prompts.ts delete mode 100644 ops/scaffold.ts create mode 100644 ops/tools.test.ts create mode 100644 ops/tools.ts delete mode 100644 ops/wizard.test.ts delete mode 100644 ops/wizard.ts diff --git a/agent/chain.test.ts b/agent/chain.test.ts index 7da88537de9..c690e9d4276 100644 --- a/agent/chain.test.ts +++ b/agent/chain.test.ts @@ -50,9 +50,13 @@ export async function testUnauthenticated () { assert(chain.contract() instanceof ContractClient) - assert(await chain.getCodeId('')) - assert(await chain.getCodeHashOfAddress('')) - assert(await chain.getCodeHashOfCodeId('')) + const state = new Stub.ChainState() + state.uploads.set("123", { codeHash: "abc", codeData: new Uint8Array() }) + state.instances.set("stub1abc", { codeId: "123" }) + chain = new Stub.Agent({ state }) + assert.equal(await chain.getCodeId('stub1abc'), "123") + assert.equal(await chain.getCodeHashOfAddress('stub1abc'), "abc") + assert.equal(await chain.getCodeHashOfCodeId('123'), "abc") } export async function testAuthenticated () { diff --git a/agent/chain.ts b/agent/chain.ts index d6f93df6078..3c34def3ea1 100644 --- a/agent/chain.ts +++ b/agent/chain.ts @@ -9,7 +9,7 @@ import type { CodeHash, CodeId } from './code' import { CompiledCode, UploadedCode } from './code' import { ContractInstance, } from './deploy' import { ContractClient, ContractClientClass } from './client' -import type { DevnetHandle } from './devnet' +import type { Devnet } from './devnet' import { assignDevnet } from './devnet' /** A chain can be in one of the following modes: */ @@ -69,7 +69,7 @@ export abstract class Agent { fees?: { send?: IFee, upload?: IFee, init?: IFee, exec?: IFee } /** If this is a devnet, this contains an interface to the devnet container. */ - devnet?: DevnetHandle + devnet?: Devnet /** Whether this chain is stopped. */ stopped?: boolean @@ -89,7 +89,7 @@ export abstract class Agent { if (properties?.chainId && properties?.chainId !== properties?.devnet?.chainId) { this.log.warn('chain.id: ignoring override (devnet)') } - if (properties?.url && properties?.url.toString() !== properties?.devnet?.url.toString()) { + if (properties?.url && properties?.url.toString() !== properties?.devnet?.url?.toString()) { this.log.warn('chain.url: ignoring override (devnet)') } if (properties?.mode && properties?.mode !== Mode.Devnet) { diff --git a/agent/code.test.ts b/agent/code.test.ts index cc8b9e1227c..0079e8071b0 100644 --- a/agent/code.test.ts +++ b/agent/code.test.ts @@ -54,13 +54,12 @@ export async function testCodeUnits () { deepEqual(compiled1.toReceipt(), { codeHash: undefined, codePath: undefined, - buildInfo: undefined, }) console.log(compiled1) assert(!compiled1.isValid()) compiled1.codePath = fixture('empty.wasm') assert(compiled1.isValid()) - await(compiled1.computeHash()) + assert(await compiled1.computeHash()) const uploaded1 = new UploadedCode() deepEqual(uploaded1.toReceipt(), { @@ -68,7 +67,8 @@ export async function testCodeUnits () { chainId: undefined, codeId: undefined, uploadBy: undefined, - uploadTx: undefined + uploadTx: undefined, + uploadGas: undefined }) console.log(uploaded1) assert(!uploaded1.isValid()) diff --git a/agent/deploy.ts b/agent/deploy.ts index dde023e9d82..4cb93bf143a 100644 --- a/agent/deploy.ts +++ b/agent/deploy.ts @@ -232,12 +232,11 @@ export class Deployment extends Map { return unit } - async build ( - options: Parameters[0] = {} - ): Promise> { + async build (options: Parameters[0] = {}): + Promise> + { const building: Array> = [] for (const [name, unit] of this.entries()) { - console.log({unit}) if (!unit.source?.isValid() && unit.compiled?.isValid()) { this.log.warn(`Missing source for ${unit.compiled.codeHash}`) } else { @@ -251,9 +250,9 @@ export class Deployment extends Map { return built } - async upload ( - options: Parameters[0] = {} - ): Promise> { + async upload (options: Parameters[0] = {}): + Promise> + { const uploading: Array> = [] for (const [name, unit] of this.entries()) { uploading.push(unit.upload(options)) @@ -265,9 +264,11 @@ export class Deployment extends Map { return uploaded } - async deploy ( - options: Parameters[0] = {} - ): Promise> { + async deploy (options: Parameters[0] & { + deployStore?: DeployStore + } = {}): + Promise> + { const deploying: Array> = [] for (const [name, unit] of this.entries()) { if (unit instanceof ContractInstance) { diff --git a/agent/devnet.test.ts b/agent/devnet.test.ts index b83366ec493..86c668c3fc9 100644 --- a/agent/devnet.test.ts +++ b/agent/devnet.test.ts @@ -1,23 +1,48 @@ import assert from 'node:assert' +import { Error } from './base' import * as Stub from './stub' import { Mode } from './chain' -import { assignDevnet } from './devnet' +import type { Agent } from './chain' +import { Devnet, assignDevnet } from './devnet' -export default async function testDevnet () { - const devnet = { - accounts: [], - chainId: 'foo', - platform: 'bar', - running: false, - stateDir: '/tmp/foo', - url: new URL('http://example.com'), - async start () { return this }, - async getAccount () { return {} }, - async assertPresence () {} +class MyDevnet extends Devnet { + accounts = [] + chainId = 'foo' + platform = 'bar' + running = false + stateDir = '/tmp/foo' + url = new URL('http://example.com') + + async start (): Promise { + this.running = true + return this + } + + async pause (): Promise { + this.running = false + return this + } + + async import (...args: unknown[]): Promise { + throw new Error("unimplemented") + } + + async export (...args: unknown[]) { + throw new Error("unimplemented") + } + + async mirror (...args: unknown[]) { + throw new Error("unimplemented") + } + + async getAccount (name: string): Promise> { + return { name } } - const chain = new Stub.Agent({ - mode: Mode.Devnet, chainId: 'bar', url: 'http://asdf.com', devnet, - }) +} + +export default async function testDevnet () { + const devnet = new MyDevnet() + const chain = new Stub.Agent({ mode: Mode.Devnet, chainId: 'bar', url: 'http://asdf.com', devnet }) // Properties from Devnet are passed onto Chain assert.equal(chain.devnet, devnet) //assert.equal(chain.chainId, 'foo') @@ -29,9 +54,7 @@ export default async function testDevnet () { assert.throws(()=>chain.devnet=devnet) assert.throws(()=>chain.stopped=true) await chain.authenticate({ name: 'Alice' }) - const chain2 = new Stub.Agent({ mode: Mode.Mainnet, devnet }) - const agent: any = {} assignDevnet(agent as any, devnet) agent.id @@ -44,5 +67,4 @@ export default async function testDevnet () { assert.throws(()=>agent.mode = "") assert.throws(()=>agent.devnet = "") assert.throws(()=>agent.stopped = "") - } diff --git a/agent/devnet.ts b/agent/devnet.ts index 3e85f3e3b98..deadb313b43 100644 --- a/agent/devnet.ts +++ b/agent/devnet.ts @@ -1,25 +1,38 @@ -import type { Agent } from './chain' +import { assign } from './base' +import type { Agent, ChainId } from './chain' import { Mode } from './chain' -/** Interface for Devnet (implementation is in @hackbg/fadroma). */ -export interface DevnetHandle { - accounts: string[] - chainId: string - platform: string - running: boolean - stateDir: string - url: URL - - containerId?: string - imageTag?: string - port?: string|number - - start (): Promise - getAccount (name: string): Promise> - assertPresence (): Promise +export abstract class Devnet { + /** List of genesis accounts that will be given an initial balance + * when creating the devnet container for the first time. */ + accounts: Array = [ 'Admin', 'Alice', 'Bob', 'Carol', 'Mallory' ] + /** The chain ID that will be passed to the devnet node. */ + chainId?: ChainId + /** Which kind of devnet to launch */ + platform?: string + /** Is this thing on? */ + running: boolean = false + /** URL for connecting to a remote devnet. */ + url?: string|URL + + abstract start (): Promise + + abstract pause (): Promise + + abstract import (...args: unknown[]): Promise + + abstract export (...args: unknown[]): Promise + + abstract mirror (...args: unknown[]): Promise + + abstract getAccount (name: string): Promise> + + constructor (properties?: Partial) { + assign(this, properties, ["accounts", "chainId", "platform", "running", "url"]) + } } -export function assignDevnet (agent: Agent, devnet: DevnetHandle) { +export function assignDevnet (agent: Agent, devnet: Devnet) { Object.defineProperties(agent, { id: { enumerable: true, configurable: true, @@ -30,7 +43,7 @@ export function assignDevnet (agent: Agent, devnet: DevnetHandle) { }, url: { enumerable: true, configurable: true, - get: () => devnet.url.toString(), + get: () => devnet.url?.toString(), set: () => { throw new Error("can't override url of devnet") } diff --git a/agent/store.ts b/agent/store.ts index fab04627440..8f8620214a1 100644 --- a/agent/store.ts +++ b/agent/store.ts @@ -2,12 +2,11 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see . **/ import { Console, Error } from './base' -import type { Class, Name } from './base' +import type { Name } from './base' import type { CodeHash } from './code' -import type { ChainId } from './chain' -import { SourceCode, CompiledCode, UploadedCode } from './code' +import { UploadedCode } from './code' import { Deployment } from './deploy' -import type { DeploymentClass, DeploymentState } from './deploy' +import type { DeploymentState } from './deploy' export class UploadStore extends Map { log = new Console('UploadStore') @@ -40,8 +39,13 @@ export class DeployStore extends Map { super() } - get (name: Name): DeploymentState|undefined { - return super.get(name) + selected?: DeploymentState = undefined + + get (name?: Name): DeploymentState|undefined { + if (arguments.length === 0) { + return this.selected + } + return super.get(name!) } set (name: Name, state: Partial|DeploymentState): this { diff --git a/agent/stub.ts b/agent/stub.ts index a6ab2bcb815..3b46bcec316 100644 --- a/agent/stub.ts +++ b/agent/stub.ts @@ -2,19 +2,93 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see . **/ import type { Address, Message, Label, TxHash } from './base' -import { Console } from './base' +import { assign, Console, Error, base16, sha256 } from './base' import type { ICoin } from './token' import { Agent, BatchBuilder } from './chain' +import type { ChainId } from './chain' import { Compiler, CompiledCode, UploadedCode } from './code' import type { CodeHash, CodeId, SourceCode } from './code' import { ContractInstance } from './deploy' +import { Devnet } from './devnet' + +class StubChainState extends Devnet { + chainId: string = 'stub' + + lastCodeId = 0 + + accounts = new Map + }>() + + uploads = new Map() + + instances = new Map() + + constructor (properties?: Partial) { + super(properties as Partial) + assign(this, properties, ["chainId", "lastCodeId", "accounts", "uploads", "instances"]) + } + + async upload (codeData: Uint8Array) { + this.lastCodeId++ + const codeId = String(this.lastCodeId) + let upload + this.uploads.set(codeId, upload = { + codeId, + chainId: this.chainId, + codeHash: base16.encode(sha256(codeData)).toLowerCase(), + codeData, + }) + return upload + } + + async start (): Promise { + this.running = true + return this + } + + async pause (): Promise { + this.running = false + return this + } + + async import (...args: unknown[]): Promise { + throw new Error("StubChainState#import: not implemented") + } + + async export (...args: unknown[]): Promise { + throw new Error("StubChainState#export: not implemented") + } + + async mirror (...args: unknown[]): Promise { + throw new Error("StubChainState#mirror: not implemented") + } + + async getAccount (name: string): Promise> { + throw new Error("StubChainState#getAccount: not implemented") + } +} class StubAgent extends Agent { - protected lastCodeHash = 0 + state: StubChainState = new StubChainState() defaultDenom: string = 'ustub' + constructor (properties?: Partial) { + super(properties) + if (properties?.state) { + this.state = properties.state + } + } + async getBlockInfo () { return { height: + new Date() } } @@ -23,16 +97,22 @@ class StubAgent extends Agent { return this.getBlockInfo().then(({height})=>height) } - async getCodeId (address: Address) { - return 'stub-code-id' + async getCodeId (address: Address): Promise { + const contract = this.state.instances.get(address) + if (!contract) { + throw new Error(`unknown contract ${address}`) + } + return contract.codeId } - async getCodeHashOfAddress (address: Address) { - return 'stub-code-hash' + async getCodeHashOfAddress (address: Address): Promise { + return this.getCodeHashOfCodeId(await this.getCodeId(address)) } - async getCodeHashOfCodeId (id: CodeId) { - return 'stub-code-hash' + async getCodeHashOfCodeId (id: CodeId): Promise { + const code = this.state.uploads.get(id) + if (!code) throw new Error(`unknown code ${id}`) + return code.codeHash } doQuery (contract: { address: Address }, message: Message): Promise { @@ -47,12 +127,8 @@ class StubAgent extends Agent { return Promise.resolve() } - protected doUpload (data: Uint8Array): Promise { - this.lastCodeHash = this.lastCodeHash + 1 - return Promise.resolve(new UploadedCode({ - chainId: this.chainId, - codeId: String(this.lastCodeHash), - })) + protected async doUpload (codeData: Uint8Array): Promise { + return new UploadedCode(await this.state.upload(codeData)) } protected doInstantiate ( @@ -106,9 +182,10 @@ class StubBatchBuilder extends BatchBuilder { } export { - StubAgent as Agent, + StubAgent as Agent, StubBatchBuilder as BatchBuilder, - StubCompiler as Compiler, + StubCompiler as Compiler, + StubChainState as ChainState } /** A compiler that does nothing. Used for testing. */ @@ -127,13 +204,4 @@ export class StubCompiler extends Compiler { codeHash: 'stub', }) } - - async buildMany ( - sources: (string|Partial)[], ...args: unknown[] - ): Promise { - return Promise.all(sources.map(source=>new CompiledCode({ - codePath: 'stub', - codeHash: 'stub', - }))) - } } diff --git a/connect/scrt/scrt-mocknet.ts.old b/connect/scrt/scrt-mocknet-impl.ts similarity index 64% rename from connect/scrt/scrt-mocknet.ts.old rename to connect/scrt/scrt-mocknet-impl.ts index af963709801..54f0b638d51 100644 --- a/connect/scrt/scrt-mocknet.ts.old +++ b/connect/scrt/scrt-mocknet-impl.ts @@ -1,236 +1,40 @@ -/** Fadroma. Copyright (C) 2023 Hack.bg. License: GNU AGPLv3 or custom. - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . **/ -import type { - AgentClass, Uint128, - Address, CodeHash, ChainId, CodeId, Message, Label, - Into -} from '@fadroma/agent' +import type { CodeHash, CodeId, Address } from '@fadroma/agent' import { - randomBech32, sha256, base16, bech32, - brailleDump, Error as BaseError, Console as BaseConsole, bold, colors, into, - Mode, Agent, BatchBuilder, - ContractInstance, UploadedCode + Console, bold, Error, Stub, base16, sha256, into, bech32, randomBech32, + ContractInstance, } from '@fadroma/agent' - import * as secp256k1 from '@noble/secp256k1' import * as ed25519 from '@noble/ed25519' -type MocknetUpload = { - codeId: CodeId, - codeHash: CodeHash, - wasm: Uint8Array, - module: WebAssembly.Module, - cwVersion: CW, - meta?: any, -} - -/** Chain instance containing a local mocknet. */ -export class Mocknet extends Agent { - log = new Console('mocknet') - - /** Current block height. Increments when accessing nextBlock */ - _height = 0 - - /** Native token. */ - defaultDenom = 'umock' - - /** Simulation of bank module. */ - balances: Record = {} - - /** Increments when uploading to assign sequential code ids. */ - lastCodeId = 0 - - /** Map of code hash to code id. */ - codeIdOfCodeHash: Record = {} - - /** Map of contract address to code id. */ - codeIdOfAddress: Record = {} - - /** Map of contract address to label. */ - labelOfAddress: Record = {} - - /** Map of code ID to WASM code blobs. */ - uploads: Record = {} - - /** The address of this agent. */ - address: Address = randomBech32(MOCKNET_ADDRESS_PREFIX).slice(0,20) - - /** Map of addresses to WASM instances. */ - contracts: Record> = {} +type ScrtCWVersion = '0.x'|'1.x' - constructor (options: Partial = {}) { - super({ - chainId: 'mocknet', - ...options, - mode: Mode.Mocknet - }) - this.log.label = this.id - this.uploads = options.uploads ?? this.uploads - if (Object.keys(this.uploads).length > 0) { - this.lastCodeId = Object.keys(this.uploads).map(x=>Number(x)).reduce((x,y)=>Math.max(x,y), 0) - } - } - - get isMocknet () { - return true - } - - get height () { - return Promise.resolve(this._height) - } - - get nextBlock () { - this._height++ - return Promise.resolve(this._height) - } - - async getHash (arg: Address) { - return this.contracts[arg].codeHash as CodeHash - } - - async getCodeId (arg: any) { - const codeId = this.codeIdOfCodeHash[arg] ?? this.codeIdOfAddress[arg] - if (!codeId) throw new Error(`No code id for hash ${arg}`) - return Promise.resolve(codeId) - } - - async getLabel (address: Address) { - return this.labelOfAddress[address] - } - - async getBalance (address: Address) { - return this.balances[address] || '0' - } - - async upload (wasm: Uint8Array, meta?: any) { - if (wasm.length < 1) throw new Error('Tried to upload empty binary.') - const chainId = this.id - const codeId = String(++this.lastCodeId) - this.log.log('uploading code id', codeId) - const codeHash = codeHashForBlob(wasm) - this.log.log('compiling', wasm.length, 'bytes') - const module = await WebAssembly.compile(wasm) - this.log.log('compiled', wasm.length, 'bytes') - const exports = WebAssembly.Module.exports(module) - const cwVersion = this.checkVersion(exports.map(x=>x.name)) - if (cwVersion === null) throw new Error.NoCWVersion(wasm, module, exports) - this.codeIdOfCodeHash[codeHash] = String(codeId) - this.uploads[codeId] = { codeId, codeHash, wasm, meta, module, cwVersion } - this.log - .log('code', codeId) - .log('hash', codeHash) - return this.uploads[codeId] - } +type ScrtMocknetUpload = { + codeHash: CodeHash + codeData: Uint8Array + wasmModule: WebAssembly.Module + cosmWasmVersion: ScrtCWVersion +} - getCode (codeId: CodeId) { - const code = this.uploads[codeId] - if (!code) throw new Error(`No code with id ${codeId}`) - return code - } +export class ScrtMocknetState extends Stub.ChainState { + log = new Console('ScrtMocknetState') - checkVersion (exports: string[]): CW|null { - switch (true) { - case !!(exports.indexOf('instantiate') > -1): return '1.x' - case !!(exports.indexOf('init') > -1): return '0.x' - } - return null - } + declare uploads: Map - async passCallbacks ( - cwVersion: CW, - sender: Address, - messages: Array - ) { - if (!sender) throw new Error("mocknet.passCallbacks: can't pass callbacks without sender") - switch (cwVersion) { - case '0.x': for (const message of messages) { - const { wasm } = message || {} - if (!wasm) { this.log.warnNonWasm(message); continue } - const { instantiate, execute } = wasm - if (instantiate) { - const { code_id: codeId, callback_code_hash: codeHash, label, msg, send } = instantiate - const instance = await this.instantiate(sender, new ContractInstance({ - codeHash, codeId, label, initMsg: JSON.parse(b64toUtf8(msg)), - })) - this.log.initCallback(sender, label, codeId, codeHash, instance.address!) - } else if (execute) { - const { contract_addr, callback_code_hash, msg, send } = execute - const response = await this.mocknetExecute( - sender, - { address: contract_addr, codeHash: callback_code_hash }, - JSON.parse(b64toUtf8(msg)), - { execSend: send } - ) - this.log.execCallback(sender, contract_addr, callback_code_hash) - } else { - this.log.warnNonInitExec(message) - } - }; break - case '1.x': for (const message of messages) { - const { msg: { wasm = {} } = {} } = message||{} - if (!wasm) { this.log.warnNonWasm(message); continue } - const { instantiate, execute } = wasm - if (instantiate) { - const { code_id: codeId, code_hash: codeHash, label, msg, send } = instantiate - const instance = await this.instantiate(sender, new ContractInstance({ - codeHash, codeId, label, initMsg: JSON.parse(b64toUtf8(msg)), - })) - this.log.initCallback(sender, label, codeId, codeHash, instance.address!) - } else if (execute) { - const { contract_addr, callback_code_hash, msg, send } = execute - const response = await this.mocknetExecute( - sender, - { address: contract_addr, codeHash: callback_code_hash }, - JSON.parse(b64toUtf8(msg)), - { execSend: send } - ) - this.log.execCallback(sender, contract_addr, callback_code_hash) - } else { - this.log.warnNonInitExec(message) - } - }; break - default: throw Object.assign( - new Error('passCallback: unknown CW version'), { sender, messages } - ) + async upload (codeData: Uint8Array): Promise { + if (codeData.length < 1) throw new Error('Tried to upload empty binary.') + const upload = await super.upload(codeData) as ScrtMocknetUpload + const wasmModule = await WebAssembly.compile(upload) + const wasmExports = WebAssembly.Module.exports(wasmModule) + const cosmWasmVersion = this.checkVersion(exports.map((x: { name: string })=>x.name)) + if (cosmWasmVersion === null) { + throw new Error("failed to detect CosmWasm version from uploaded binary") } + upload.wasmModule = wasmModule + upload.cosmWasmVersion = cosmWasmVersion + return upload } - getApi () { - return Promise.resolve({}) - } - - get account () { - this.log.warn('account: stub') - return Promise.resolve({}) - } - - /** Upload a binary to the mocknet. */ - protected async doUpload (wasm: Uint8Array): Promise { - return new UploadedCode(await this.chain.upload(wasm)) - } - - /** Instantiate a contract on the mocknet. */ - protected async doInstantiate ( - codeId: CodeId|Partial, - options: { - initMsg: Into - } - ): Promise> { - options = { ...options } - options.initMsg = await into(options.initMsg) - const { address, codeHash, label } = await this.mocknetInstantiate(this.address, options) - return { - chainId: this.chainId, - address: address!, - codeHash: codeHash!, - label: label!, - initBy: this.address, - initTx: '' - } - } - - async mocknetInstantiate ( + async instantiate ( sender: Address, instance: Partial ): Promise { @@ -241,7 +45,9 @@ export class Mocknet extends Agent { if (codeHash !== expectedCodeHash) this.log.warn('Wrong code hash passed with code id', codeId) // Resolve lazy init const msg = await into(initMsg) - if (typeof msg === 'undefined') throw new Error.NoInitMsg() + if (typeof msg === 'undefined') { + throw new Error("can't instantiate without init message") + } // Generate address and construct contract const address = randomBech32(MOCKNET_ADDRESS_PREFIX).slice(0,20) const mocknet = this @@ -264,15 +70,7 @@ export class Mocknet extends Agent { } } - protected async doExecute ( - contract: { address: Address }, - message: Message, - options?: Parameters[2] - ): Promise { - return await this.mocknetExecute(this.address, contract, message, options) - } - - async mocknetExecute ( + async execute ( sender: Address, { address }: Partial, message: Message, @@ -286,93 +84,105 @@ export class Mocknet extends Agent { const result = contract.execute({ sender, msg: message }) const response = parseResult(result, 'execute', address) if (response.data !== null) response.data = b64toUtf8(response.data) - await this.passCallbacks(contract.cwVersion!, address!, response.messages) + await this.state.passCallbacks(contract.cwVersion!, address!, response.messages) return response } - protected async doQuery ( - contract: Address|{address: Address}, - message: Message - ): Promise { - return await this.mocknetQuery(contract, message) - } - - async mocknetQuery (queried: Address|{address: Address}, message: Message): Promise { - const contract = this.getContract(queried) - return contract.query({ msg: message }) - } - - send (_1:any, _2:any, _3?:any, _4?:any, _5?:any) { - this.log.warn('send: stub') - return Promise.resolve() - } - - sendMany (_1:any, _2:any, _3?:any, _4?:any) { - this.log.warn('sendMany: stub') - return Promise.resolve() - } - - getContract (address?: Address|{ address: Address }) { - if (typeof address === 'object') { - address = address.address - } - if (!address) { - throw new Error.NoAddress() - } - const instance = this.contracts[address] - if (!instance) { - throw new Error.WrongAddress(address) + protected checkVersion (exports: string[]): ScrtCWVersion|null { + switch (true) { + case !!(exports.indexOf('instantiate') > -1): return '1.x' + case !!(exports.indexOf('init') > -1): return '0.x' } - return instance + return null } -} - -class MocknetBatch extends BatchBuilder { - messages: object[] = [] - - declare agent: Mocknet - - get log () { - return this.agent.log.sub('(batch)') + protected async passCallbacks (cwVersion: CW, sender:Address, messages: unknown[]) { + if (!sender) throw new Error("mocknet.passCallbacks: can't pass callbacks without sender") + switch (cwVersion) { + case '0.x': return this.passCallbacks_CW0(sender, messages) + case '1.x': return this.passCallbacks_CW1(sender, messages) + default: throw Object.assign(new Error(`passCallbacks: unknown CW version ${cwVersion}`), { + sender, messages + }) + } } - async submit (memo = "") { - this.log.info('Submitting mocknet batch...') - const results = [] - for (const { - init, - instantiate = init, - exec, - execute = exec - } of this.messages) { - if (!!init) { - const { sender, codeId, codeHash, label, msg, funds } = init - results.push(await this.agent.instantiate(codeId, { - initMsg: msg, codeHash, label, + protected async passCallbacks_CW0 (sender: Address, messages: unknown[]) { + for (const message of messages) { + const { wasm } = message || {} + if (!wasm) { this.log.warnNonWasm(message); continue } + const { instantiate, execute } = wasm + if (instantiate) { + const { code_id: codeId, callback_code_hash: codeHash, label, msg, send } = instantiate + const instance = await this.instantiate(sender, new ContractInstance({ + codeHash, codeId, label, initMsg: JSON.parse(b64toUtf8(msg)), })) - } else if (!!exec) { - const { sender, contract: address, codeHash, msg, funds: execSend } = exec - results.push(await this.agent.execute({ address, codeHash }, msg, { execSend })) + this.log.debug( + `callback from ${bold(sender)}: instantiated contract`, bold(label), + 'from code id', bold(codeId), 'with hash', bold(codeHash), + 'at address', bold(instance) + ) + } else if (execute) { + const { contract_addr, callback_code_hash, msg, send } = execute + const response = await this.execute( + sender, + { address: contract_addr, codeHash: callback_code_hash }, + JSON.parse(b64toUtf8(msg)), + { execSend: send } + ) + this.log.debug( + `Callback from ${bold(sender)}: executed transaction`, + 'on contract', bold(contract.address), 'with hash', bold(contract.codeHash), + ) } else { - this.log.warn('MocknetBatch#submit: found unknown message in batch, ignoring') - results.push(null) + this.log.warn( + 'mocknet.execute: transaction returned wasm message that was not '+ + '"instantiate" or "execute", ignoring:', + message + ) } } - return results } - save (name: string): Promise { - throw new Error('MocknetBatch#save: not implemented') + protected async passCallbacks_CW1 (sender: Address, messages: unknown[]) { + for (const message of messages) { + const { msg: { wasm = {} } = {} } = message||{} + if (!wasm) { this.log.warnNonWasm(message); continue } + const { instantiate, execute } = wasm + if (instantiate) { + const { code_id: codeId, code_hash: codeHash, label, msg, send } = instantiate + const instance = await this.instantiate(sender, new ContractInstance({ + codeHash, codeId, label, initMsg: JSON.parse(b64toUtf8(msg)), + })) + this.log.debug( + `callback from ${bold(sender)}: instantiated contract`, bold(label), + 'from code id', bold(codeId), 'with hash', bold(codeHash), + 'at address', bold(instance) + ) + } else if (execute) { + const { contract_addr, callback_code_hash, msg, send } = execute + const response = await this.execute( + sender, + { address: contract_addr, codeHash: callback_code_hash }, + JSON.parse(b64toUtf8(msg)), + { execSend: send } + ) + this.log.debug( + `Callback from ${bold(sender)}: executed transaction`, + 'on contract', bold(contract.address), 'with hash', bold(contract.codeHash), + ) + } else { + this.log.warn( + 'mocknet.execute: transaction returned wasm message that was not '+ + '"instantiate" or "execute", ignoring:', + message + ) + } + } } - } -export { Mocknet as Agent } - -export type CW = '0.x' | '1.x' - -export type CWAPI = { +export type ScrtCWAPI = { imports: { memory: WebAssembly.Memory env: { @@ -430,10 +240,9 @@ export type CWAPI = { } }[V]) - -export class MocknetContract { +export class MocknetContract { log = new Console('mocknet') - mocknet?: Mocknet + mocknet?: ScrtMocknet address?: Address codeHash?: CodeHash codeId?: CodeId @@ -551,7 +360,9 @@ export class MocknetContract { } makeContext = (sender: Address, now: number = + new Date()) => { - if (!this.mocknet) throw new Error.NoChain() + if (!this.mocknet) { + throw new Error("missing mocknet for contract") + } const chain_id = this.mocknet.chainId const height = Math.floor(now/5000) const time = Math.floor(now/1000) @@ -624,10 +435,16 @@ export class MocknetContract { const req = readUtf8(memory, reqPointer) log.debug(bold(address), 'query_chain:', req) const { wasm } = JSON.parse(req) - if (!wasm) throw new Error.Query.NonWasm(address, req) + if (!wasm) { + throw new Error("non-wasm query") + } const { smart } = wasm - if (!wasm) throw new Error.Query.NonSmart(address, req) - if (!mocknet) throw new Error.Query.NoMocknet(address, req) + if (!wasm) { + throw new Error("non-smart query") + } + if (!mocknet) { + throw new Error("missing mocknet backend") + } const { contract_addr, callback_code_hash, msg } = smart const queried = mocknet.getContract(contract_addr) if (!queried) throw new Error( @@ -783,7 +600,7 @@ declare namespace WebAssembly { class Instance { exports: T } - function instantiate (code: unknown, world: unknown): + function instantiate (code: unknown, world: unknown): Promise['exports']>> class Memory { constructor (options: { initial: number, maximum: number }) @@ -833,6 +650,7 @@ export const parseResult = ( } throw new Error(`Mocknet ${action}: contract ${address} returned non-Result type`) } + /** Read region properties from pointer to region. */ export const region = (buffer: any, ptr: Pointer): Region => { const u32a = new Uint32Array(buffer) @@ -841,6 +659,7 @@ export const region = (buffer: any, ptr: Pointer): Region => { const used = u32a[ptr/4+2] // Region.length return [addr, size, used, u32a] } + /** Read contents of region referenced by region pointer into a string. */ export const readUtf8 = ({ memory: { buffer }, deallocate }: Allocator, ptr: Pointer): string => { const [addr, size, used] = region(buffer, ptr) @@ -850,6 +669,7 @@ export const readUtf8 = ({ memory: { buffer }, deallocate }: Allocator, ptr: Poi drop({ deallocate }, ptr) return data } + /** Read contents of region referenced by region pointer into a string. */ export const readBuffer = ({ memory: { buffer } }: Allocator, ptr: Pointer): Buffer => { const [addr, size, used] = region(buffer, ptr) @@ -860,11 +680,13 @@ export const readBuffer = ({ memory: { buffer } }: Allocator, ptr: Pointer): Buf } return output } + /** Serialize a datum into a JSON string and pass it into the contract. */ export const passJson = (memory: Allocator, data: T): Pointer => { if (typeof data === 'undefined') throw new Error('Tried to pass undefined value into contract') return passBuffer(memory, utf8toBuffer(JSON.stringify(data))) } + /** Allocate region, write data to it, and return the pointer. * See: https://github.com/KhronosGroup/KTX-Software/issues/371#issuecomment-822299324 */ export const passBuffer = ({ memory, allocate }: Allocator, data: ArrayLike): Pointer => { @@ -875,9 +697,11 @@ export const passBuffer = ({ memory, allocate }: Allocator, data: ArrayLike writeToRegion(memory, ptr, encoder.encode(data)) + /** Write data to address of region referenced by pointer. */ export const writeToRegion = ( { memory: { buffer } }: Allocator, ptr: Pointer, data: ArrayLike @@ -890,83 +714,27 @@ export const writeToRegion = ( u32a![usedPointer] = data.length // set Region.length write(buffer, addr, data) } + /** Write data to memory address. */ export const write = (buffer: ArrayLike, addr: number, data: ArrayLike): void => new Uint8Array(buffer).set(data, addr) + /** Write UTF8-encoded data to memory address. */ export const writeUtf8 = (buffer: ArrayLike, addr: number, data: string): void => new Uint8Array(buffer).set(encoder.encode(data), addr) + /** Deallocate memory. Fails silently if no deallocate callback is exposed by the blob. */ export const drop = ({ deallocate }: { deallocate: Allocator['deallocate'] }, ptr: Pointer): void => deallocate && deallocate(ptr) + /** Convert base64 string to utf8 string */ export const b64toUtf8 = (str: string) => Buffer.from(str, 'base64').toString('utf8') + /** Convert utf8 string to base64 string */ export const utf8toB64 = (str: string) => Buffer.from(str, 'utf8').toString('base64') + /** Convert utf8 string to buffer. */ export const utf8toBuffer = (str: string) => Buffer.from(str, 'utf8') + /** Convert buffer to utf8 string. */ export const bufferToUtf8 = (buf: Buffer) => buf.toString('utf8') - -export const Console = (()=>{ - return class MocknetConsole extends BaseConsole { - constructor (label = 'mocknet') { super(label) } - warnStub = (name: string) => this.warn(`mocknet: ${name}: stub`) - showDebug = true - initCallback = ( - sender: Address, label: Label, codeId: CodeId, codeHash: CodeHash, instance: Address - ) => this.debug( - `callback from ${bold(sender)}: instantiated contract`, bold(label), - 'from code id', bold(codeId), 'with hash', bold(codeHash), - 'at address', bold(instance) - ) - execCallback = ( - sender: Address, contract: Address, codeHash: CodeHash - ) => this.debug( - `Callback from ${bold(sender)}: executed transaction`, - 'on contract', bold(contract), 'with hash', bold(codeHash), - ) - warnNonWasm = (message: unknown) => this.warn( - 'mocknet.execute: transaction returned non-wasm message, ignoring:', - message - ) - warnNonInitExec = (message: unknown) => this.warn( - 'mocknet.execute: transaction returned wasm message that was not '+ - '"instantiate" or "execute", ignoring:', - message - ) - } -})() - -export const Error = (()=>{ - class MocknetError extends BaseError { - static NoAddress = this.define('NoAddress', () => - `Mocknet: can't get instance without address`) - static WrongAddress = this.define('WrongAddress', (address: string) => - `Mocknet: no contract at ${address}`) - static NoChain = this.define('NoChain', () => - `MocknetAgent: chain not set`) - static NoBackend = this.define('NoBackend', () => - `Mocknet: backend not set`) - static NoInitMsg = this.define('NoInitMsg', () => - 'Mocknet: tried to instantiate with undefined initMsg') - static NoCWVersion = this.define('NoCWVersion', - (_, __, ___) => 'Mocknet: failed to detect CosmWasm API version from module', - (err, wasmCode, wasmModule, wasmExports) => Object.assign(err, { - wasmCode, wasmModule, wasmExports - })) - static Query: typeof MocknetError_Query - } - class MocknetError_Query extends MocknetError { - static NonWasm = this.define('NonWasm', (address, req) => - `Mocknet: contract ${address} made a non-wasm query: ${JSON.stringify(req)}`, - (err, req) => Object.assign(err, { req })) - static NonSmart = this.define('NonSmart', (address, req) => - `Mocknet: contract ${address} made a non-smart wasm query: ${JSON.stringify(req)}`, - (err, req) => Object.assign(err, { req })) - static NoMocknet = this.define('NoMocknet', (address, req) => - `Mocknet: contract ${address} made a query while isolated: ${JSON.stringify(req)}`, - (err, req) => Object.assign(err, { req })) - } - return Object.assign(MocknetError, { Query: MocknetError_Query }) -})() //MocknetError diff --git a/connect/scrt/scrt-mocknet.test.ts.old b/connect/scrt/scrt-mocknet.test.ts.old index 8f21c88ef98..4fa77304b65 100644 --- a/connect/scrt/scrt-mocknet.test.ts.old +++ b/connect/scrt/scrt-mocknet.test.ts.old @@ -1,16 +1,12 @@ import assert from 'node:assert' import * as Mocknet from './scrt-mocknet' +import * as MocknetImpl from './scrt-mocknet-impl' export default async function testScrtMocknet () { - new Mocknet.Console('test message').log('...').trace('...').debug('...') // **Base64 I/O:** Fields that are of type `Binary` (query responses and the `data` field of handle // responses) are returned by the contract as Base64-encoded strings // If `to_binary` is used to produce the `Binary`, it's also JSON encoded through Serde. // These functions are used by the mocknet code to encode/decode the base64. - assert.equal(Mocknet.b64toUtf8('IkVjaG8i'), '"Echo"') - assert.equal(Mocknet.utf8toB64('"Echo"'), 'IkVjaG8i') - let key: string - let value: string - let data: string + assert.equal(MocknetImpl.b64toUtf8('IkVjaG8i'), '"Echo"') + assert.equal(MocknetImpl.utf8toB64('"Echo"'), 'IkVjaG8i') } - diff --git a/connect/scrt/scrt-mocknet.ts b/connect/scrt/scrt-mocknet.ts new file mode 100644 index 00000000000..aca62c596d3 --- /dev/null +++ b/connect/scrt/scrt-mocknet.ts @@ -0,0 +1,148 @@ +import { Stub, Console, BatchBuilder } from '@fadroma/agent' + +/** Chain instance containing a local mocknet. */ +export class ScrtMocknet extends Stub.Agent { + log = new Console('ScrtMocknet') + + /** Current block height. Increments when accessing nextBlock */ + _height = 0 + + /** Native token. */ + defaultDenom = 'umock' + + /** The address of this agent. */ + address: Address = randomBech32(MOCKNET_ADDRESS_PREFIX).slice(0,20) + + /** Map of addresses to WASM instances. */ + contracts: Record> = {} + + constructor (options: Partial = {}) { + super({ chainId: 'mocknet', ...options, mode: Mode.Mocknet }) + this.log.label += ` (${this.chainId})` + } + + get isMocknet () { + return true + } + + get height () { + return Promise.resolve(this._height) + } + + get nextBlock () { + this._height++ + return Promise.resolve(this._height) + } + + getApi () { + return Promise.resolve({}) + } + + get account () { + this.log.warn('account: stub') + return Promise.resolve({}) + } + + /** Instantiate a contract on the mocknet. */ + protected async doInstantiate ( + codeId: CodeId|Partial, + options: { + initMsg: Into + } + ): Promise> { + options = { ...options } + options.initMsg = await into(options.initMsg) + const { address, codeHash, label } = await this.state.instantiate(this.address, options) + return { + chainId: this.chainId, + address: address!, + codeHash: codeHash!, + label: label!, + initBy: this.address, + initTx: '' + } + } + + protected async doExecute ( + contract: { address: Address }, + message: Message, + options?: Parameters[2] + ): Promise { + return await this.state.execute(this.address, contract, message, options) + } + + protected async doQuery ( + contract: Address|{address: Address}, + message: Message + ): Promise { + return await this.mocknetQuery(contract, message) + } + + async mocknetQuery (queried: Address|{address: Address}, message: Message): Promise { + const contract = this.getContract(queried) + return contract.query({ msg: message }) + } + + send (_1:any, _2:any, _3?:any, _4?:any, _5?:any) { + this.log.warn('send: stub') + return Promise.resolve() + } + + sendMany (_1:any, _2:any, _3?:any, _4?:any) { + this.log.warn('sendMany: stub') + return Promise.resolve() + } + + getContract (address?: Address|{ address: Address }) { + if (typeof address === 'object') { + address = address.address + } + if (!address) { + throw new Error.NoAddress() + } + const instance = this.contracts[address] + if (!instance) { + throw new Error.WrongAddress(address) + } + return instance + } + +} + +class ScrtMocknetBatchBuilder extends BatchBuilder { + messages: object[] = [] + + get log () { + return this.agent.log.sub('(batch)') + } + + async submit (memo = "") { + this.log.info('Submitting mocknet batch...') + const results = [] + for (const { + init, + instantiate = init, + exec, + execute = exec + } of this.messages) { + if (!!init) { + const { sender, codeId, codeHash, label, msg, funds } = init + results.push(await this.agent.instantiate(codeId, { + initMsg: msg, codeHash, label, + })) + } else if (!!exec) { + const { sender, contract: address, codeHash, msg, funds: execSend } = exec + results.push(await this.agent.execute({ address, codeHash }, msg, { execSend })) + } else { + this.log.warn('MocknetBatch#submit: found unknown message in batch, ignoring') + results.push(null) + } + } + return results + } + + save (name: string): Promise { + throw new Error('MocknetBatch#save: not implemented') + } + +} diff --git a/fadroma.test.ts b/fadroma.test.ts index 90379b64c40..c143b6e3862 100644 --- a/fadroma.test.ts +++ b/fadroma.test.ts @@ -5,10 +5,11 @@ import { Suite } from '@hackbg/ensuite' export default new Suite([ ['agent', () => import('./agent/agent.test')], ['build', () => import('./ops/build.test')], - ['deploy', () => import('./ops/deploy.test')], ['devnets', () => import('./ops/devnets.test')], ['project', () => import('./ops/project.test')], + ['prompts', () => import('./ops/prompts.test')], ['stores', () => import('./ops/stores.test')], + ['tools', () => import('./ops/tools.test')], ['connect', () => import('./connect/connect.test')], //['wizard', () => import('./ops/wizard.test')], //['factory', () => import ('./Factory.spec.ts.md')], diff --git a/fadroma.ts b/fadroma.ts index 151c748ff82..ba8d6d30662 100644 --- a/fadroma.ts +++ b/fadroma.ts @@ -34,10 +34,10 @@ connectModes['OKP4Devnet'] = CW.OKP4.Agent.devnet = (options: Partial = {} + function example (name: string, wasm: any, hash: any) { return examples[name] = { name, @@ -25,10 +27,12 @@ function example (name: string, wasm: any, hash: any) { hash } } + example('Empty', 'empty.wasm', 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855') example('KV', 'fadroma-example-kv@HEAD.wasm', '16dea8b55237085f24af980bbd408f1d6893384996e90e0ce2c6fc3432692a0d') example('Echo', 'fadroma-example-echo@HEAD.wasm', 'a4983efece1306aa897651fff74cae18436fc3280fc430d11a4997519659b6fd') example('Legacy', 'fadroma-example-legacy@HEAD.wasm', 'a5d58b42e686d9f5f8443eb055a3ac45018de2d1722985c5f77bad344fc00c3b') + export const tmpDir = () => { let x withTmpDir(dir=>x=dir) diff --git a/ops/build.ts b/ops/build.ts index e18ad08ccbe..ee21704e0c7 100644 --- a/ops/build.ts +++ b/ops/build.ts @@ -13,12 +13,20 @@ import { fileURLToPath, pathToFileURL } from 'node:url' import { dirname, sep } from 'node:path' import { homedir } from 'node:os' import { randomBytes } from 'node:crypto' - -/** Path to this package. Used to find the build script, dockerfile, etc. - * WARNING: Keep the ts-ignore otherwise it might break at publishing the package. */ -const thisPackage = - //@ts-ignore - dirname(dirname(fileURLToPath(import.meta.url))) +import { thisPackage } from './config' + +export function getCompiler ({ + config = new Config(), useContainer = config.getFlag('FADROMA_BUILD_RAW', ()=>false), + ...options +}: |({ useContainer?: false } & Partial) + |({ useContainer: true } & Partial) = {} +) { + if (useContainer) { + return new ContainerizedLocalRustCompiler({ config, ...options }) + } else { + return new RawLocalRustCompiler({ config, ...options }) + } +} export { Compiler } @@ -91,12 +99,7 @@ export abstract class LocalRustCompiler extends ConfiguredCompiler { if (typeof source === 'string') { source = { cargoCrate: source } } - let { - sourceRef = 'HEAD', - cargoWorkspace = this.workspace, - cargoCrate, - } = source - if (cargoWorkspace && cargoCrate) { + if (source.cargoWorkspace && !source.cargoCrate) { throw new Error("missing crate name") } return source @@ -120,7 +123,7 @@ export class RawLocalRustCompiler extends LocalRustCompiler { const env = { FADROMA_BUILD_GID: String(this.buildGid), FADROMA_BUILD_UID: String(this.buildUid), - FADROMA_OUTPUT: $(sourcePath||process.cwd()).in('wasm').path, + FADROMA_OUTPUT: $(process.env.FADROMA_OUTPUT||process.cwd()).in('wasm').path, // FIXME FADROMA_REGISTRY: '', FADROMA_TOOLCHAIN: this.toolchain, } @@ -296,7 +299,7 @@ export class ContainerizedLocalRustCompiler extends LocalRustCompiler { source.cargoWorkspace ??= this.workspace source.sourceRef ??= 'HEAD' // If the source is already built, don't build it again - if (!this.populatePrebuilt(source)) { + if (!this.getCached(this.outputDir.path, source)) { if (!source.sourceRef || (source.sourceRef === HEAD)) { this.log(`Building ${bold(source.cargoCrate)} from working tree`) } else { @@ -310,18 +313,6 @@ export class ContainerizedLocalRustCompiler extends LocalRustCompiler { return [workspaces, revisions] } - protected populatePrebuilt (source: Partial): boolean { - const { cargoWorkspace, sourceRef, cargoCrate } = source - const prebuilt = this.prebuild(this.outputDir.path, cargoCrate, sourceRef) - if (prebuilt) { - new Console(`build ${crate}`).found(prebuilt) - source.codePath = prebuilt.codePath - source.codeHash = prebuilt.codeHash - return true - } - return false - } - protected async buildBatch (inputs: Partial[], path: string, rev: string = HEAD) { this.log.log('Building from', path, '@', rev) let root = $(path) @@ -488,7 +479,7 @@ export class ContainerizedLocalRustCompiler extends LocalRustCompiler { const shouldBuild: Record = {} // Collect cached templates. If any are missing from the cache mark them as source. for (const [index, crate] of crates) { - const prebuilt = this.prebuild(outputDir, crate, revision) + const prebuilt = this.getCached(outputDir, crate, revision) if (prebuilt) { //const location = $(prebuilt.codePath!).shortPath //console.info('Exists, not rebuilding:', bold($(location).shortPath)) @@ -502,9 +493,9 @@ export class ContainerizedLocalRustCompiler extends LocalRustCompiler { /** Check if codePath exists in local artifacts cache directory. * If it does, don't rebuild it but return it from there. */ - protected prebuild (outputDir: string, crate?: string, revision: string = HEAD): CompiledCode|null { - if (this.caching && crate) { - const location = $(outputDir, codePathName(crate, revision)) + protected getCached (outputDir: string, { sourceRef, cargoCrate }: Partial): CompiledCode|null { + if (this.caching && cargoCrate) { + const location = $(outputDir, codePathName(cargoCrate, sourceRef||HEAD)) if (location.exists()) { const codePath = location.url const codeHash = this.hashPath(location) diff --git a/ops/config.ts b/ops/config.ts index c69afd9f265..81e148ae01d 100644 --- a/ops/config.ts +++ b/ops/config.ts @@ -1,8 +1,7 @@ /** Fadroma. Copyright (C) 2023 Hack.bg. License: GNU AGPLv3 or custom. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . **/ -import { DevnetConfig, Devnet } from './devnets' -import type { DevnetPlatform } from './devnets' +import * as Devnets from './devnets' import { Config, Error, ConnectConfig, UploadStore, DeployStore } from '@fadroma/connect' import type { Environment, Class, DeploymentClass } from '@fadroma/connect' import $, { JSONFile } from '@hackbg/file' @@ -23,7 +22,7 @@ export const { version } = $(thisPackage, 'package.json') .load() as { version: string } /** Complete Fadroma configuration. */ -export class FadromaConfig extends Config { +class FadromaConfig extends Config { /** License token. */ license?: string = this.getString('FADROMA_LICENSE', ()=>undefined) /** The topmost directory visible to Fadroma. @@ -39,12 +38,12 @@ export class FadromaConfig extends Config { /** Connect options */ connect: ConnectConfig /** Devnet options. */ - devnet: DevnetConfig + devnet: Devnets.Config constructor ( options: Partial & Partial<{ connect: Partial, - devnet: Partial + devnet: Partial }> = {}, environment?: Environment ) { @@ -52,8 +51,8 @@ export class FadromaConfig extends Config { const { connect, devnet, ...rest } = options this.override(rest) this.connect = new ConnectConfig(connect, environment) - this.devnet = new DevnetConfig(devnet, environment) + this.devnet = new Devnets.Config(devnet, environment) } } -export { FadromaConfig as Config, ConnectConfig, DevnetConfig } +export { FadromaConfig as Config } diff --git a/ops/deploy.test.ts b/ops/deploy.test.ts deleted file mode 100644 index 410fa8dd835..00000000000 --- a/ops/deploy.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** Fadroma. Copyright (C) 2023 Hack.bg. License: GNU AGPLv3 or custom. - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . **/ -import * as assert from 'node:assert' -import { - Deployment, UploadedCode, ContractTemplate, ContractInstance, Stub -} from '@fadroma/connect' -import { Suite } from '@hackbg/ensuite' -import { JSONFileDeployStore } from './stores' -import { fixture } from '../fixtures/fixtures' - -//export new DeploymentBuilder('mydeployment') - //.template('swapPool', { codeId: '1', crate: 'examples/kv' }) - //.contract('swapFactory', { - //codeId: '2', crate: 'examples/kv', label: 'swap factory', async initMsg () { - //const pool = await this('swapPool').upload() - //return { pool: { id: pool.codeId, hash: pool.codeHash } } - //} - //}) - //.contracts('swap/', { codeId: '2', crate: 'examples/kv' }, { - //'a': { label: 'foo', initMsg: {} }, - //'b': { label: 'foo', initMsg: {} }, - //}) - //.command() - -export class MyDeployment extends Deployment { - t = this.template('t', { codeId: '1', sourcePath: fixture("../examples/kv") }) - - // Single template instance with eager and lazy initMsg - a1 = this.t.contract('a1', { initMsg: {} }) - a2 = this.t.contract('a2', { initMsg: () => ({}) }) - a3 = this.t.contract('a3', { initMsg: async () => ({}) }) - - // Multiple contracts from the same template - b = this.t.contracts({ - b1: { initMsg: {} }, - b2: { initMsg: () => ({}) }, - b3: { initMsg: async () => ({}) } - }) -} - -export async function testDeployment () { - const deployment = new MyDeployment() - assert.ok(deployment.t instanceof ContractTemplate) - await deployment.deploy({ - uploader: new Stub.Agent(), - deployer: new Stub.Agent(), - }) - assert.ok([deployment.a1, deployment.a2, deployment.a3, ...Object.values(deployment.b)].every( - c=>c instanceof ContractInstance - )) -} - -class V1Deployment extends Deployment { - kv1 = this.contract('kv1', { sourcePath: fixture("empty.wasm"), initMsg: {} }) - kv2 = this.contract('kv2', { sourcePath: fixture("empty.wasm"), initMsg: {} }) -} - -class V2Deployment extends V1Deployment { - kv3 = this.contract('kv3', { sourcePath: fixture("empty.wasm"), initMsg: {} }) - // simplest client-side migration is to just instantiate - // a new deployment with the data from the old deployment. - static upgrade = (previous: V1Deployment) => new this({ - ...previous - }) -} - -export async function testDeploymentUpgrade () { - let deployment = new V1Deployment() - assert.deepEqual([...deployment.keys()], ['kv1', 'kv2']) - const mainnetAgent: any = { chain: { isMainnet: true } } // mock - const testnetAgent: any = { chain: { isTestnet: true } } // mock - // simplest chain-side migration is to just call default deploy, - // which should reuse kv1 and kv2 and only deploy kv3. - let deployment2 = await V2Deployment.upgrade(deployment).deploy({ - uploader: new Stub.Agent(), - deployer: new Stub.Agent() - }) -} - -export default new Suite([ - ['basic', testDeployment], - ['upgrade', testDeploymentUpgrade], -]) diff --git a/ops/devnets.test.ts b/ops/devnets.test.ts index 5dfe64b6d94..89d6990d1a2 100644 --- a/ops/devnets.test.ts +++ b/ops/devnets.test.ts @@ -5,10 +5,8 @@ import * as assert from 'node:assert' import { getuid, getgid } from 'node:process' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { Project, getDevnet, Devnet, Agent } from '@hackbg/fadroma' -import type { DevnetPlatform } from '@hackbg/fadroma' +import { Agent, Project, Compilers, Devnets } from '@hackbg/fadroma' import $, { TextFile, JSONFile, JSONDirectory } from '@hackbg/file' -import { Image, Container } from '@hackbg/dock' //@ts-ignore export const packageRoot = dirname(resolve(fileURLToPath(import.meta.url))) @@ -29,9 +27,9 @@ export async function testDevnetDocs () { await import('./Devnet.spec.ts.md') } -export async function testDevnetPlatform (platform: DevnetPlatform) { +export async function testDevnetPlatform (platform: Devnets.Platform) { let devnet: any - assert.ok(devnet = new Devnet({ platform }), "construct devnet") + assert.ok(devnet = new Devnets.Container({ platform }), "construct devnet") assert.ok(await devnet.start(), "starting the devnet works") assert.ok(await devnet.assertPresence() || true, "devnet start automatically created container") assert.ok(await devnet.pause(), "pausing the devnet works") @@ -40,7 +38,7 @@ export async function testDevnetPlatform (platform: DevnetPlatform) { } export async function testDevnetChain () { - const devnet = new Devnet({ platform: 'okp4_5.0' }) + const devnet = new Devnets.Container({ platform: 'okp4_5.0' }) const chain = devnet.getChain() assert.ok((chain.chainId||'').match(/fadroma-devnet-[0-9a-f]{8}/)) assert.equal(chain.chainId, chain.devnet!.chainId) @@ -48,34 +46,34 @@ export async function testDevnetChain () { } export async function testDevnetCopyUploads () { - const devnet1 = await new Devnet({ platform: 'okp4_5.0' }).create() + const devnet1 = await new Devnets.Container({ platform: 'okp4_5.0' }).create() const chain1 = devnet1.getChain() const agent1 = await chain1.authenticate({ name: 'Admin' }) const crate = resolve(packageRoot, 'examples', 'cw-null') - const artifact = await getCompiler().build(crate) + const artifact = await Compilers.getCompiler().build(crate) const uploaded1 = await agent1.upload(artifact) const uploaded2 = await agent1.upload(artifact) - const devnet2 = new Devnet({ platform: 'okp4_5.0' }) + const devnet2 = new Devnets.Container({ platform: 'okp4_5.0' }) //assert.ok(await devnet2.copyUploads(chain1), "copying uploads") } export async function testDevnetChainId () { - let devnet: Devnet + let devnet: Devnets.Container //assert.throws( - //() => { devnet = new Devnet({ chainId: false as any }) }, + //() => { devnet = new Devnets.Container({ chainId: false as any }) }, //"construct must fail if passed falsy chainId" //) - assert.ok(devnet = new Devnet(), "construct must work with no options") + assert.ok(devnet = new Devnets.Container(), "construct must work with no options") assert.ok(typeof devnet.chainId === 'string', "chain id must be auto populated when not passed") // TODO: can't delete before creating assert.ok(await devnet.save(), "can save") assert.ok( - devnet = new Devnet({ chainId: devnet.chainId }), + devnet = new Devnets.Container({ chainId: devnet.chainId }), "can construct when passing chainId" ) assert.ok(await devnet.delete(), "can delete") assert.ok( - devnet = new Devnet({ chainId: devnet.chainId }), + devnet = new Devnets.Container({ chainId: devnet.chainId }), "after deletion, can construct new devnet with same chainId" ) // TODO: devnet with same chainid points to same resource @@ -83,23 +81,23 @@ export async function testDevnetChainId () { } export async function testDevnetStateFile () { - let devnet = new Devnet() + let devnet = new Devnets.Container() $(devnet.stateFile).as(TextFile).save("invalidjson") assert.throws( - ()=>{ devnet = new Devnet({ chainId: devnet.chainId }) }, + ()=>{ devnet = new Devnets.Container({ chainId: devnet.chainId }) }, "can't construct if state is invalid json" ) $(devnet.stateFile).as(TextFile).save("null") assert.ok( - devnet = new Devnet({ chainId: devnet.chainId }), + devnet = new Devnets.Container({ chainId: devnet.chainId }), "can construct if state is valid json but empty" ) assert.ok(await devnet.delete(), "can delete if state is valid json but empty") } export async function testDevnetUrl () { - let devnet: Devnet - assert.ok(devnet = new Devnet(), "can construct") + let devnet: Devnets.Container + assert.ok(devnet = new Devnets.Container(), "can construct") assert.equal( devnet.url.toString(), `http://${devnet.host}:${devnet.port}/`, "devnet url generated from host and port properties" @@ -108,8 +106,8 @@ export async function testDevnetUrl () { } export async function testDevnetContainer () { - let devnet: any - assert.ok(devnet = new Devnet(), "can construct") + let devnet: Devnets.Container + assert.ok(devnet = new Devnets.Container(), "can construct") assert.equal( devnet.initScriptMount, '/devnet.init.mjs', "devnet init script mounted at default location" diff --git a/ops/devnets.ts b/ops/devnets.ts index 67c09928aed..4821df88040 100644 --- a/ops/devnets.ts +++ b/ops/devnets.ts @@ -1,22 +1,18 @@ /** Fadroma. Copyright (C) 2023 Hack.bg. License: GNU AGPLv3 or custom. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . **/ -import { - Error as BaseError, Console, Config, bold, randomHex, Scrt, CW, Agent -} from '@fadroma/connect' -import type { - CodeId, ChainId, DevnetHandle, Environment, AgentClass -} from '@fadroma/connect' - +import { Config, Error, Console, bold, Agent, Devnet, Scrt, CW, } from '@fadroma/connect' +import type { CodeId, ChainId, Environment, AgentClass } from '@fadroma/connect' import $, { JSONFile, JSONDirectory, OpaqueDirectory } from '@hackbg/file' import type { Path } from '@hackbg/file' -import ports, { waitPort } from '@hackbg/port' +import portManager, { waitPort } from '@hackbg/port' import * as Dock from '@hackbg/dock' - import { resolve, dirname } from 'node:path' import { fileURLToPath } from 'node:url' import { randomBytes } from 'node:crypto' +export { Devnet } + /** Path to this package. Used to find the build script, dockerfile, etc. * WARNING: Keep the ts-ignore otherwise it might break at publishing the package. */ //@ts-ignore @@ -27,16 +23,20 @@ export function getDevnet (options: Partial = {}) { return new DevnetConfig().getDevnet(options) } +export function resetAll (cwd: string|Path, ids: ChainId[]) { + return ContainerDevnet.deleteMany($(cwd).in('state'), ids) +} + /** Supported devnet variants. Add new devnets here first. */ -export type DevnetPlatform = +export type Platform = | `scrt_1.${2|3|4|5|6|7|8|9}` | `okp4_5.0` /** Ports exposed by the devnet. One of these is used by default. */ -export type DevnetPort = 'http'|'rpc'|'grpc'|'grpcWeb' +export type Port = 'http'|'rpc'|'grpc'|'grpcWeb' /** Parameters that define a supported devnet. */ -export type DevnetPlatformInfo = { +export type PlatformInfo = { /** Tag of devnet image to download. */ dockerTag: string /** Path to dockerfile to use to build devnet image if not downloadable. */ @@ -46,24 +46,24 @@ export type DevnetPlatformInfo = { /** Name of node daemon binary to run inside the container. */ daemon: string /** Which port is being used. */ - portMode: DevnetPort + portMode: Port /** Which Chain subclass to return from devnet.getChain. */ Chain: Function & { defaultDenom: string } } /** Mapping of connection type to default port number. */ -export const devnetPorts: Record = { +export const ports: Record = { http: 1317, rpc: 26657, grpc: 9090, grpcWeb: 9091 } /** Mapping of connection type to environment variable * used by devnet.init.mjs to set port number. */ -export const devnetPortEnvVars: Record = { +export const portEnvVars: Record = { http: 'HTTP_PORT', rpc: 'RPC_PORT', grpc: 'GRPC_PORT', grpcWeb: 'GRPC_WEB_PORT' } /** Descriptions of supported devnet variants. */ -export const devnetPlatforms: Record = { +export const platforms: Record = { 'scrt_1.2': { Chain: Scrt.Agent, dockerTag: 'ghcr.io/hackbg/fadroma-devnet-scrt-1.2:master', @@ -140,9 +140,7 @@ export const devnetPlatforms: Record = { /** A private local instance of a network, * running in a container managed by @hackbg/dock. */ -export class Devnet implements DevnetHandle { - /** Is this thing on? */ - running: boolean = false +class ContainerDevnet extends Devnet { /** Containerization engine (Docker or Podman). */ engine?: Dock.Engine /** Path to Dockerfile to build image */ @@ -153,12 +151,8 @@ export class Devnet implements DevnetHandle { containerId?: string /** Whether to use Podman instead of Docker to run the devnet container. */ podman: boolean - /** Which kind of devnet to launch */ - platform: DevnetPlatform /** Which service does the API URL port correspond to. */ - portMode: DevnetPort - /** The chain ID that will be passed to the devnet node. */ - chainId: ChainId + portMode: Port /** Whether to destroy this devnet on exit. */ deleteOnExit: boolean /** Whether the devnet should remain running after the command ends. */ @@ -182,9 +176,6 @@ export class Devnet implements DevnetHandle { launchTimeout: number /** Whether more detailed output is preferred. */ verbose: boolean - /** List of genesis accounts that will be given an initial balance - * when creating the devnet container for the first time. */ - accounts: Array = [ 'Admin', 'Alice', 'Bob', 'Carol', 'Mallory' ] /** Name of node binary. */ daemon: string @@ -200,6 +191,7 @@ export class Devnet implements DevnetHandle { /** Create an object representing a devnet. * Must call the `respawn` method to get it running. */ constructor (options: Partial = {}) { + super(options) // This determines whether generated chain id has random suffix this.deleteOnExit = options.deleteOnExit ?? false // This determines the state directory path @@ -212,7 +204,10 @@ export class Devnet implements DevnetHandle { // Options always override stored state options = { ...state, ...options } } catch (e) { - throw new DevnetError.LoadingFailed(this.stateFile.path, e) + console.error(e) + throw new Error( + `failed to load devnet state from ${this.stateFile.path}: ${e.message}` + ) } } // Apply the rest of the configuration options @@ -227,13 +222,13 @@ export class Devnet implements DevnetHandle { this.accounts = options.accounts ?? this.accounts this.engine = options.engine ?? new Dock[this.podman?'Podman':'Docker'].Engine() this.containerId = options.containerId ?? this.containerId - const { dockerTag, dockerFile, ready, portMode, daemon } = devnetPlatforms[this.platform] + const { dockerTag, dockerFile, ready, portMode, daemon } = platforms[this.platform] this.imageTag = options.imageTag ?? this.imageTag ?? dockerTag this.dockerfile = options.dockerfile ?? this.dockerfile ?? dockerFile this.readyPhrase = options.readyPhrase ?? ready this.daemon = options.daemon ?? daemon this.portMode = options.portMode ?? portMode - this.port = options.port ?? devnetPorts[this.portMode] + this.port = options.port ?? ports[this.portMode] this.protocol = options.protocol ?? 'http' this.host = options.host ?? 'localhost' } @@ -274,8 +269,8 @@ export class Devnet implements DevnetHandle { /** Environment variables in the container. */ get spawnEnv () { const env: Record = { - DAEMON: devnetPlatforms[this.platform].daemon, - TOKEN: devnetPlatforms[this.platform].Chain.defaultDenom, + DAEMON: platforms[this.platform].daemon, + TOKEN: platforms[this.platform].Chain.defaultDenom, CHAIN_ID: this.chainId, ACCOUNTS: this.accounts.join(' '), STATE_UID: String((process.getuid!)()), @@ -284,7 +279,7 @@ export class Devnet implements DevnetHandle { if (this.verbose) { env['VERBOSE'] = 'yes' } - const portVar = devnetPortEnvVars[this.portMode] + const portVar = portEnvVars[this.portMode] if (portVar) { env[portVar] = String(this.port) } else { @@ -341,13 +336,13 @@ export class Devnet implements DevnetHandle { // ensure we have image and chain id const image = await this.image if (!this.image) { - throw new DevnetError("missing devnet container image") + throw new Error("missing devnet container image") } if (!this.chainId) { - throw new DevnetError("can't create devnet without chain ID") + throw new Error("can't create devnet without chain ID") } // if port is unspecified or taken, increment - this.port = await ports.getFreePort(this.port) + this.port = await portManager.getFreePort(this.port) // create container this.log.creating(this) const init = this.initScript ? [this.initScriptMount] : [] @@ -394,7 +389,7 @@ export class Devnet implements DevnetHandle { } this.running = true await this.save() - await container.waitLog(this.readyPhrase, Devnet.logFilter, true) + await container.waitLog(this.readyPhrase, ContainerDevnet.logFilter, true) await Dock.Docker.waitSeconds(this.postLaunchWait) await this.waitPort({ host: this.host, port: Number(this.port) }) } @@ -424,7 +419,9 @@ export class Devnet implements DevnetHandle { /** Export the state of the devnet as a container image. */ export = async (repository?: string, tag?: string) => { const container = await this.container - if (!container) throw new DevnetError.CantExport("no container") + if (!container) { + throw new Error("can't export: no container") + } return container.export(repository, tag) } @@ -489,7 +486,7 @@ export class Devnet implements DevnetHandle { /** Get a Chain object wrapping this devnet. */ getChain = > ( - $C: C = (devnetPlatforms[this.platform].Chain || Agent) as unknown as C, + $C: C = (platforms[this.platform].Chain || Agent) as unknown as C, options?: Partial ): A => { return new $C({ ...options, devnet: this }) @@ -498,7 +495,9 @@ export class Devnet implements DevnetHandle { /** Get the info for a genesis account, including the mnemonic */ getAccount = async (name: string): Promise> => { if (this.dontMountState) { - if (!this.container) throw new DevnetError.ContainerNotSet() + if (!this.container) { + throw new Error('missing devnet container') + } const path = `/state/${this.chainId}/wallet/${name}.json` const [identity] = await (await this.container).exec('cat', path) return JSON.parse(identity) @@ -535,27 +534,27 @@ export class Devnet implements DevnetHandle { } /** Delete multiple devnets. */ - static deleteMany = (path: string|Path, ids?: ChainId[]): Promise => { + static deleteMany = (path: string|Path, ids?: ChainId[]): Promise => { const state = $(path).as(OpaqueDirectory) const chains = (state.exists()&&state.list()||[]) .map(name => $(state, name)) .filter(path => path.isDirectory()) - .map(path => path.at(Devnet.stateFile).as(JSONFile)) + .map(path => path.at(ContainerDevnet.stateFile).as(JSONFile)) .filter(path => path.isFile()) .map(path => $(path, '..')) - return Promise.all(chains.map(dir=>Devnet.load(dir, true).delete())) + return Promise.all(chains.map(dir=>ContainerDevnet.load(dir, true).delete())) } /** Restore a Devnet from the info stored in the state file */ - static load (dir: string|Path, allowInvalid: boolean = false): Devnet { + static load (dir: string|Path, allowInvalid: boolean = false): ContainerDevnet { const console = new DevnetConsole('devnet') dir = $(dir) if (!dir.isDirectory()) { - throw new DevnetError.NotADirectory(dir.path) + throw new Error(`not a directory: ${dir.path}`) } - const stateFile = dir.at(Devnet.stateFile) - if (!dir.at(Devnet.stateFile).isFile()) { - throw new DevnetError.NotAFile(stateFile.path) + const stateFile = dir.at(ContainerDevnet.stateFile) + if (!dir.at(ContainerDevnet.stateFile).isFile()) { + throw new Error(`not a file: ${stateFile.path}`) } let state: Partial = {} try { @@ -563,11 +562,11 @@ export class Devnet implements DevnetHandle { } catch (e) { console.warn(e) if (!allowInvalid) { - throw new DevnetError.LoadingFailed(stateFile.path) + throw new Error(`failed to load devnet state from ${stateFile.path}`) } } console.missingValues(state, stateFile.path) - return new Devnet(state) + return new ContainerDevnet(state) } /** Name of the file containing devnet state. */ @@ -594,7 +593,7 @@ export class Devnet implements DevnetHandle { static RE_NON_PRINTABLE = /[\x00-\x1F]/ } -export class DevnetConfig extends Config { +class DevnetConfig extends Config { constructor ( options: Partial = {}, environment?: Environment @@ -699,29 +698,8 @@ class DevnetConsole extends Console { } } -/** An error emitted by the devnet. */ -export class DevnetError extends BaseError { - static PortMode = this.define('PortMode', - (mode?: string) => `devnet.portMode must be either 'lcp' or 'grpcWeb', found: ${mode}`) - static NoChainId = this.define('NoChainId', - ()=>'refusing to create directories for devnet with empty chain id') - static NoContainerId = this.define('NoContainerId', - ()=>'missing container id in devnet state') - static ContainerNotSet = this.define('ContainerNotSet', - ()=>'devnet.container is not set') - static NoGenesisAccount = this.define('NoGenesisAccount', - (name: string, error: any)=>`genesis account not found: ${name} (${error})`) - static NotADirectory = this.define('NotADirectory', - (path: string) => `not a directory: ${path}`) - static NotAFile = this.define('NotAFile', - (path: string) => `not a file: ${path}`) - static CantExport = this.define('CantExport', - (reason: string) => `can't export: ${reason}`) - static LoadingFailed = this.define('LoadingFailed', - (path: string, cause?: Error) => - `failed restoring devnet state from ${path}; ` + - `try deleting ${dirname(path)}` + - (cause ? ` ${cause.message}` : ``), - (error: any, path: string, cause?: Error) => - Object.assign(error, { path, cause })) +export { + ContainerDevnet as Container, + DevnetConfig as Config, + DevnetConsole as Console, } diff --git a/ops/project.test.ts b/ops/project.test.ts index 4c33904035f..1bced528e91 100644 --- a/ops/project.test.ts +++ b/ops/project.test.ts @@ -1,27 +1,140 @@ /** Fadroma. Copyright (C) 2023 Hack.bg. License: GNU AGPLv3 or custom. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . **/ -import * as assert from 'node:assert' -import type { Project } from './project' -import { UploadedCode } from '@fadroma/connect' - -export default async function testProject () { - const { Project } = await import('@hackbg/fadroma') - const { tmpDir } = await import('../fixtures/fixtures') - const root = tmpDir() - let project: Project = new Project({ - root: `${root}/test-project-1`, - name: 'test-project-1', - }) - .create() - .status() - .cargoUpdate() +import assert from 'node:assert' +import { + Deployment, UploadedCode, ContractTemplate, ContractInstance, Stub +} from '@fadroma/connect' +import { JSONFileDeployStore } from './stores' +import { getCompiler } from './build' +import { fixture, tmpDir } from '../fixtures/fixtures' +import { Project, ProjectCommands } from './project' +import { withTmpDir } from '@hackbg/file' + +import { Suite } from '@hackbg/ensuite' +export default new Suite([ + ["commands", testProjectCommands], + ["wizard", testProjectWizard], + ['deployment', testDeployment], + ['upgrade', testDeploymentUpgrade], +]) + +export async function testProjectCommands () { + const root = `${tmpDir()}/test-project-1` + const name = 'test-project-1' + const project = createProject({ root, name }) + const commands = new ProjectCommands(project) + + project.status() + project.cargoUpdate() await project.build() await project.build('test1') + await project.upload() await project.upload('test1') + await project.deploy(/* any deploy arguments, if you've overridden the deploy procedure */) await project.redeploy(/* ... */) await project.exportDeployment('state') } + +export async function testProjectWizard () { + //const wizard = new ProjectWizard({ + //interactive: false, + //cwd: tmpDir() + //}) + //assert.ok(await wizard.createProject( + //Project, + //'test-project-2', + //'test3', + //'test4' + //) instanceof Project) +} + +//export function tmpDir () { + //let x + //withTmpDir(dir=>x=dir) + //return x +//} + +//export new DeploymentBuilder('mydeployment') + //.template('swapPool', { codeId: '1', crate: 'examples/kv' }) + //.contract('swapFactory', { + //codeId: '2', crate: 'examples/kv', label: 'swap factory', async initMsg () { + //const pool = await this('swapPool').upload() + //return { pool: { id: pool.codeId, hash: pool.codeHash } } + //} + //}) + //.contracts('swap/', { codeId: '2', crate: 'examples/kv' }, { + //'a': { label: 'foo', initMsg: {} }, + //'b': { label: 'foo', initMsg: {} }, + //}) + //.command() + +export class MyDeployment extends Deployment { + t = this.template('t', { codeId: '1', sourcePath: fixture("../examples/kv") }) + + // Single template instance with eager and lazy initMsg + a1 = this.t.contract('a1', { initMsg: {} }) + a2 = this.t.contract('a2', { initMsg: () => ({}) }) + a3 = this.t.contract('a3', { initMsg: async () => ({}) }) + + // Multiple contracts from the same template + b = this.t.contracts({ + b1: { initMsg: {} }, + b2: { initMsg: () => ({}) }, + b3: { initMsg: async () => ({}) } + }) +} + +export async function testDeployment () { + const deployment = new MyDeployment() + assert.ok(deployment.t instanceof ContractTemplate) + await deployment.deploy({ + uploader: new Stub.Agent(), + deployer: new Stub.Agent(), + }) + assert.ok([deployment.a1, deployment.a2, deployment.a3, ...Object.values(deployment.b)].every( + c=>c instanceof ContractInstance + )) +} + +export async function testDeploymentUpgrade () { + + class V1Deployment extends Deployment { + kv1 = this.contract('kv1', { + sourcePath: fixture("../examples/kv"), + initMsg: {} + }) + kv2 = this.contract('kv2', { + sourcePath: fixture("../examples/kv"), + initMsg: {} + }) + } + + let deployment = new V1Deployment() + assert.deepEqual([...deployment.keys()], ['kv1', 'kv2']) + const mainnetAgent: any = { chain: { isMainnet: true } } // mock + const testnetAgent: any = { chain: { isTestnet: true } } // mock + + // simplest chain-side migration is to just call default deploy, + // which should reuse kv1 and kv2 and only deploy kv3. + + class V2Deployment extends V1Deployment { + kv3 = this.contract('kv3', { + sourcePath: fixture("../examples/kv"), + initMsg: {} + }) + // simplest client-side migration is to just instantiate + // a new deployment with the data from the old deployment. + static upgrade = (previous: V1Deployment) => new this({ + ...previous + }) + } + let deployment2 = await V2Deployment.upgrade(deployment).deploy({ + compiler: getCompiler(), + uploader: new Stub.Agent(), + deployer: new Stub.Agent(), + }) +} diff --git a/ops/project.ts b/ops/project.ts index d031bcf9baf..d9cfec5b15d 100644 --- a/ops/project.ts +++ b/ops/project.ts @@ -8,9 +8,8 @@ import { bold, timestamp, } from '@fadroma/connect' import type { - CompiledCode, ChainId, + Agent, CompiledCode, ChainId, ContractCode } from '@fadroma/connect' - import $, { TextFile, OpaqueDirectory, YAMLFile, @@ -19,498 +18,539 @@ import $, { } from '@hackbg/file' import type { Path } from '@hackbg/file' import { CommandContext } from '@hackbg/cmds' - -import { Compiler } from './build' +import * as Compilers from './build' import { Config, version } from './config' -import { Devnet } from './devnets' -import { ProjectWizard, toolVersions } from './wizard' -import { writeProject } from './scaffold' -import { JSONFileUploadStore, JSONFileDeployStore } from './stores' - +import * as Stores from './stores' +import * as Devnets from './devnets' +import * as Prompts from './prompts' +import * as Tools from './tools' import { execSync } from 'node:child_process' +import Case from 'case' const console = new Console(`@hackbg/fadroma ${version}`) -export type ProjectOptions = Omit, 'root'|'templates'|'uploadStore'|'deployStore'> & { - root?: OpaqueDirectory|string, - templates?: Record> - uploadStore?: string|UploadStore - deployStore?: string|DeployStore -} +export class ProjectCommands extends CommandContext { + constructor ( + readonly project?: Project, + readonly root: string = process.env.FADROMA_PROJECT || process.cwd() + ) { + super() -export class Project extends CommandContext { - log = new Console(`Fadroma ${version}`) as any - /** Fadroma settings. */ - config: Config - /** Name of the project. */ - name: string - /** Root directory of the project. */ - root: OpaqueDirectory - /** Compiler to compile the contracts. */ - compiler: Compiler - /** Stores the upload receipts. */ - uploadStore: UploadStore - /** Stores the deploy receipts. */ - deployStore: DeployStore - /** Default deployment class. */ - Deployment = Deployment - - static wizard = (...args: any[]) => new ProjectWizard().createProject(this, ...args) - - static load = (path: string|OpaqueDirectory = process.cwd()): Project|null => { - const configFile = $(path, 'fadroma.yml').as(YAMLFile) - if (configFile.exists()) { - return new Project(configFile.load() as ProjectOptions) - } else { - return null + this + .addCommand( + 'run', 'execute a script', + (...args: string[]) => runScript(this.project, ...args)) + .addCommand( + 'repl', 'open a project REPL (optionally executing a script first)', + (...args: string[]) => runRepl(this.project, ...args)) + .addCommand( + 'status', 'show the status of the project', + () => Tools.logProjectStatus(this.project)) + .addCommand( + 'create', 'create a new project', + Project.create) + + if (this.project) { + this + .addCommand('build', 'build the project or specific contracts from it', + (...names: string[]) => this.getProject().getDeployment().build({ + compiler: Compilers.getCompiler(), })) + .addCommand('rebuild', 'rebuild the project or specific contracts from it', + (...names: string[]) => this.getProject().getDeployment().build({ + rebuild: true, + compiler: Compilers.getCompiler(), })) + .addCommand('upload', 'upload the project or specific contracts from it', + (...names: string[]) => this.getProject().getDeployment().upload({ + compiler: Compilers.getCompiler(), + uploadStore: Stores.getUploadStore(), uploader: this.getAgent(), })) + .addCommand('reupload', 'reupload the project or specific contracts from it', + (...names: string[]) => this.getProject().getDeployment().upload({ + compiler: Compilers.getCompiler(), + uploadStore: Stores.getUploadStore(), uploader: this.getAgent(), reupload: true })) + .addCommand('deploy', 'deploy this project or continue an interrupted deployment', + (...args: string[]) => this.getProject().getDeployment().deploy({ + compiler: Compilers.getCompiler(), + uploadStore: Stores.getUploadStore(), uploader: this.getAgent(), + deployStore: Stores.getDeployStore(), deployer: this.getAgent(), + deployment: this.getProject().getDeployment() })) + .addCommand('redeploy', 'redeploy this project from scratch', + (...args: string[]) => this.getProject().getDeployment().deploy({ + compiler: this.getCompiler(), + uploadStore: Stores.getUploadStore(), uploader: this.getAgent(), + deployStore: Stores.getDeployStore(), deployer: this.getAgent(), + deployment: this.getProject().createDeployment() })) + .addCommand('select', `activate another deployment`, + async (name?: string): Promise => selectDeployment( + this.root, name)) + .addCommand('export', `export current deployment to JSON`, + async (path?: string) => exportDeployment( + this.root, await this.getProject().getDeployment(), path)) + .addCommand('reset', 'stop and erase running devnets', + (...ids: ChainId[]) => Devnets.resetAll( + this.root, ids)) } } - constructor (options?: ProjectOptions) { - super() - this.config = options?.config ?? new Config() - this.root = $(options?.root || this.config.root || process.cwd()).as(OpaqueDirectory) - this.name = options?.name || this.root.name - this.log.label = this.exists() ? this.name : `@hackbg/fadroma ${version}` + getProject (): Project {} - if (this.exists()) this.log - .info('at', bold(this.root.path)) - .info(`on`, bold(this.config.connect.chainId)) + getAgent (): Agent {} +} - if (options?.compiler instanceof Compiler) { - this.compiler = options.compiler - } else { - this.compiler = getCompiler({ - outputDir: this.dirs.wasm.path - }) - } +export class ProjectRoot { - if (options?.uploadStore instanceof UploadStore) { - this.uploadStore = options.uploadStore - } else if (typeof options?.uploadStore === 'string') { - this.uploadStore = new JSONFileUploadStore(options.uploadStore) - } else { - this.uploadStore = new UploadStore() - } + readonly root: Path - if (options?.deployStore instanceof DeployStore) { - this.deployStore = options.deployStore - } else if (typeof options?.deployStore === 'string') { - this.deployStore = new JSONFileDeployStore(options.deployStore) - } else { - this.deployStore = new DeployStore() + constructor (readonly name: string, root: string|Path) { + if (!name) { + throw new Error('missing project name') + } + if (!root) { + throw new Error('missing project root directory') } + this.root = $(root) } - /** Load and execute the default export of an ES module, - * passing this Project instance as first argument. */ - runScript = this.command( - 'run', 'execute a script', - async (script?: string, ...args: string[]) => { - if (!script) { - throw new Error(`Usage: fadroma run SCRIPT [...ARGS]`) - } - if (!$(script).exists()) { - throw new Error(`${script} doesn't exist`) - } - this.log.log(`Running ${script}`) - const path = $(script).path - //@ts-ignore - const { default: main } = await import(path) - if (typeof main === 'function') { - return main(this, ...args) - } else { - this.log.info(`${$(script).shortPath} does not have a default export.`) - } - }) - - /** Print the current status of Fadroma, the active devnet, project, and deployment. - * @returns this */ - status = this.command( - 'status', 'show the status of the project', - () => { - toolVersions() - const agent = this.config.connect.authenticate() - this.log.info('Project name: ', bold(this.name)) - this.log.info('Project root: ', bold(this.root.path)) - this.log.info('Optimized contracts at: ', bold(this.dirs.wasm.shortPath)) - this.log.info('Contract checksums at: ', bold(this.dirs.wasm.shortPath)) - this.log.br() - this.log.info('Chain type: ', bold(agent?.constructor.name)) - this.log.info('Chain mode: ', bold(agent?.mode)) - this.log.info('Chain ID: ', bold(agent?.chainId)) - if (!agent?.isMocknet) { - this.log.info('Chain URL: ', bold(agent?.url.toString())) - } - this.log.info('Agent address: ', bold(agent.address)) - this.log.br() - if (this.dirs.state.exists()) { - this.log.info('Chain-specific state at:', bold(this.dirs.state.shortPath)) - const states = this.dirs.state.list() - if (states && states.length > 0) { - this.log.info('Recorded state for: ', bold(this.dirs.state.list()?.join(', '))) - } else { - this.log.info('No transactions recorded.') - } - const deployment = this.getDeployment() - if (deployment) { - this.log.br() - this.log.deployment(deployment) - } else { - this.log.info('No active deployment.') - } - } else { - this.log.info('No active project.') - } - this.log.br() - return this - }) - - createProject = this.command( - 'create', 'create a new project', - Project.wizard) - - /** Write the files representing the described project to the root directory. - * @returns this */ - create () { - writeProject(this) - this.log("created at", this.root.shortPath) - return this + logStatus () { + return console.br() + .info('Project name: ', bold(this.name)) + .info('Project root: ', bold(this.root.path)) } - /** Builds one or more named templates, or all templates if no arguments are passed. */ - build = this.command( - 'build', 'build the project or specific contracts from it', - async (...names: string[]): Promise => { - if (names.length < 1) { - names = Object.keys(this.templates) - if (names.length > 0) { - this.log.log('Building all:', names.join(', ')) - return this.build(...names) - } - this.log.warn('This would build all contracts, but no contracts are defined.') - return [] - } - const sources = names.map(name=>this.getTemplate(name)).filter((template, i)=>{ - if (!template) this.log.warn(`No such template in project: ${names[i]}`) - return !!template - }) - if (sources.length < 1) { - this.log.warn('Nothing to build.') - return [] - } - return await this.compiler.buildMany(sources) - }) - - rebuild = this.command( - 'rebuild', 'rebuild the project or specific contracts from it', - (...names: string[]): Promise => { - this.compiler.caching = false - return this.build(...names) - }) - - /** Upload one or more named templates, or all templates if no arguments are passed. - * Build templates with missing artifacts if sources are available. */ - upload = this.command( - 'upload', 'upload the project or specific contracts from it', - async (...names: string[]): Promise => { - let sources: Partial[] = await this.getSources(names) - if (this.compiler) sources = await this.compiler.buildMany(sources) - const options = { uploadStore: this.uploadStore, reupload: false } - const agent = this.config.connect.authenticate() - return Object.values(await agent.uploadMany(sources, options)) - }) - - reupload = this.command( - 'reupload', 'reupload the project or specific contracts from it', - async (...names: string[]): Promise => { - let sources: Partial[] = await this.getSources(names) - if (this.compiler) sources = await this.compiler.buildMany(sources) - const options = { uploadStore: this.uploadStore, reupload: true } - const agent = this.config.connect.authenticate() - return Object.values(await agent.uploadMany(sources, options)) - }) - - protected async getSources (names: string[]) { - if (names.length < 1) { - names = Object.keys(this.templates) - if (names.length > 0) { - this.log.log('Uploading all:', names.join(', ')) - return await this.upload(...names) - } - this.log.warn('Uploading 0 contracts.') - return [] - } - const sources = names.map(name=>this.getTemplate(name)).filter((template, i)=>{ - if (!template) this.log.warn(`No such template in project: ${names[i]}`) - return !!template - }) as UploadedCode[] - if (sources.length < 1) { - this.log.warn('Nothing to upload.') - return [] - } - return sources - } - - deploy = this.command( - 'deploy', 'deploy this project or continue an interrupted deployment', - async (...args: string[]) => { - const deployment: Deployment = this.getDeployment() || await this.createDeployment() - this.log.info(`deployment:`, bold(deployment.name), `(${deployment.constructor?.name})`) - const agent = this.config.connect.authenticate() - await deployment.deploy({ - compiler: this.config.build.getCompiler(), - uploader: agent, - deployer: agent, - }) - await this.log.deployment(deployment) - if (!agent.isMocknet) { - await this.selectDeployment(deployment.name) - } - return deployment - }) - - redeploy = this.command( - 'redeploy', 'redeploy this project from scratch', - async (...args: string[]) => { - await this.createDeployment() - return await this.deploy(...args) - }) - - createDeployment (name: string = timestamp()) { - const deployment = new this.Deployment({ name }) - this.deployStore.set(name, deployment) - return this.selectDeployment(name) + getDeployment (): Deployment { } - selectDeployment = this.command( - 'select', `activate another deployment`, - async (name?: string): Promise => { - const store = this.deployStore - if (!store) { - this.log.error('No deployment store.') - return - } - if ([...store.keys()].length < 1) { - throw new Error('No deployments in this store') - } - let deployment: Deployment - if (name) { - return new this.Deployment(store.get(name)) - } else if (process.stdout.isTTY) { - name = await ProjectWizard.selectDeploymentFromStore(store) - if (name) { - return new this.Deployment(store.get(name)) - } else { - throw new Error(`No such deployment: ${name}`) - } - } - }) - - exportDeployment = this.command( - 'export', `export current deployment to JSON`, - async (path?: string) => { - const deployment = await this.selectDeployment() - if (!deployment) { - throw new Error("deployment not found") - } - if (!path) path = process.cwd() - // If passed a directory, generate file name - let file = $(path) - if (file.isDirectory()) file = file.in(`${name}_@_${timestamp()}.json`) - // Serialize and write the deployment. - const state = deployment.toReceipt() - file.as(JSONFile).makeParent().save(state) - this.log.info('saved', Object.keys(state).length, 'contracts to', bold(file.shortPath)) - }) - - resetDevnets = this.command( - 'reset', 'stop and erase running devnets', - (...ids: ChainId[]) => { - return Devnet.deleteMany(this.root.in('state'), ids) - }) - - /** Get the active deployment or a named deployment. - * @returns Deployment|null */ - getDeployment ( - name?: string, - templates: Record> = {}, - contracts: Record> = {}, - ): InstanceType { - if (!name) { - throw new Error("missing deployment name") - } - if (this.deployStore.has(name)) { - return this.Deployment.fromReceipt(this.deployStore.get(name)!) - } else { - throw new Error(`deployment not found: ${name}`) - } + createDeployment (): Deployment { } - /** @returns stateless handles for the subdirectories of the project. */ - get dirs () { - return { - src: this.root.in('src').as(OpaqueDirectory), - wasm: this.root.in('wasm').as(OpaqueDirectory), - state: this.root.in('state').as(OpaqueDirectory) - } + static async create ( // do not convert to arrow function + tools = new Tools.SystemTools(), + name: string|Path|Promise|undefined, + root: string|Path|Promise|undefined, + ) { + tools ??= new Tools.SystemTools() + name = await Promise.resolve(tools.interactive ? this.askName() : undefined) + root = await Promise.resolve(tools.interactive ? this.askRoot(name) : $(tools.cwd, name as string)) + const project = new this(name!, root) + console.log(`Creating project`, bold(name), `in`, bold(project.root.path)) + project.root.make() + Tools.createGitRepo(project.root.path, tools) + return project } - /** @returns stateless handles for various config files that are part of the project. */ - get files () { - const { src, wasm, state } = this.dirs - return { - cargoToml: this.root.at('Cargo.toml').as(TOMLFile), - dockerfile: null, - droneWorkflow: null, - envfile: this.root.at('.env').as(TextFile), - fadromaYaml: this.root.at('fadroma.yml').as(YAMLFile), - githubWorkflow: null, - gitignore: this.root.at('.gitignore').as(TextFile), - packageJson: this.root.at('package.json').as(JSONFile), - apiIndex: this.root.at('index.ts').as(TextFile), - projectIndex: this.root.at('fadroma.config.ts').as(TextFile), - testIndex: this.root.at('test.ts').as(TextFile), - readme: this.root.at('README.md').as(TextFile), - shellNix: this.root.at('shell.nix').as(TextFile), - } + static async askName (): Promise { + let value + do { + value = await Prompts.askText('Enter a project name (a-z, 0-9, dash/underscore)')??'' + value = value.trim() + if (!isNaN(value[0] as any)) { + console.info('Project name cannot start with a digit.') + value = '' + } + } while (value === '') + return value } - /** @returns stateless handles for the contract crates - * corresponding to templates in fadroma.yml */ - get crates () { - const crates: Record = {} - for (const [name, template] of Object.entries(this.templates)) { - if (template.crate) crates[name] = new ProjectCrate(this, template.crate) - } - return crates + static async askRoot (name: string|Promise|undefined): Promise { + name = await Promise.resolve(name) as string + const cwd = $(process.cwd()).as(OpaqueDirectory) + const exists = cwd.in(name).exists() + const empty = (cwd.list()?.length||0) === 0 + const inSub = `Subdirectory (${exists?'overwrite: ':''}${cwd.name}/${name})` + const inCwd = `Current directory (${cwd.name})` + const choice = [ + { title: inSub, value: cwd.in(name) }, + { title: inCwd, value: cwd }, + ] + if (empty) choice.reverse() + return Prompts.askSelect( + `Create project ${name} in current directory or subdirectory?`, choice + ) } - /** @returns Boolean whether the project (as defined by fadroma.yml in root) exists */ - exists () { - return this.files.fadromaYaml.exists() +} + +export class Project extends ProjectRoot { + stateDir = $(this.root, 'state') + .as(OpaqueDirectory) + wasmDir = $(this.root, 'wasm') + .as(OpaqueDirectory) + envFile = $(this.root, '.env') + .as(TextFile) + gitIgnore = $(this.root, '.gitignore') + .as(TextFile) + packageJson = $(this.root, 'package.json') + .as(JSONFile) + readme = $(this.root, 'README.md') + .as(TextFile) + shellNix = $(this.root, 'shell.nix') + .as(TextFile) + apiIndex = $(this.root, 'index.ts') + .as(TextFile) + projectIndex = $(this.root, 'fadroma.config.ts') + .as(TextFile) + testIndex = $(this.root, 'test.ts') + .as(TextFile) + + logStatus () { + return super.logStatus().br() + .info('Project state: ', bold(this.stateDir.shortPath)) + .info('Build results: ', bold(this.wasmDir.shortPath)) + .br().info('Deployment units: ') + .warn('(TODO)') } - listDeployments () { - return this.log.deploy.deploymentList( - this.config.connect.chainId??'(unspecified)', this.deployStore + static async create ( // do not convert to arrow function + tools = new Tools.SystemTools(), name: string, root: string|Path + ) { + const project = await super.create(tools, name, root) as Project + project.readme.save( + Tools.generateReadme(name) + ) + project.packageJson.save( + Tools.generatePackageJson(name) + ) + project.gitIgnore.save( + Tools.generateGitIgnore() ) + project.envFile.save( + Tools.generateEnvFile() + ) + project.shellNix.save( + Tools.generateShellNix(name) + ) + project.apiIndex.save( + Tools.generateApiIndex(name, {}) + ) + project.projectIndex.save( + Tools.generateProjectIndex(name) + ) + project.testIndex.save( + Tools.generateTestIndex(name) + ) + Tools.runNPMInstall(project, tools) + return project } - /** Create a Git repository in the project directory and make an initial commit. - * @returns this */ - gitSetup () { - this.runShellCommands( - 'git --no-pager init', - 'git --no-pager add .', - 'git --no-pager status', - 'git --no-pager commit -m "Project created by @hackbg/fadroma (https://fadroma.tech)"', - "git --no-pager log", - ) - return this + static async askTemplates (name: string): Promise>> { + + return Prompts.askUntilDone({}, (state) => Prompts.askSelect([ + `Project ${name} contains ${Object.keys(state).length} contract(s):\n`, + ` ${Object.keys(state).join(',\n ')}` + ].join(''), [ + { title: `Add contract template to the project`, value: defineContract }, + { title: `Remove contract template`, value: undefineContract }, + { title: `Rename contract template`, value: renameContract }, + { title: `(done)`, value: null }, + ])) + + async function defineContract (state: Record) { + let crate + crate = await Prompts.askText('Enter a name for the new contract (lowercase a-z, 0-9, dash, underscore):')??'' + if (!isNaN(crate[0] as any)) { + console.info('Contract name cannot start with a digit.') + crate = '' + } + if (crate) { + state[crate] = { crate } + } + } + + async function undefineContract (state: Record) { + const name = await Prompts.askSelect(`Select contract to remove from project scope:`, [ + ...Object.keys(state).map(contract=>({ title: contract, value: contract })), + { title: `(done)`, value: null }, + ]) + if (name === null) return + delete state[name] + } + + async function renameContract (state: Record) { + const name = await Prompts.askSelect(`Select contract to rename:`, [ + ...Object.keys(state).map(contract=>({ title: contract, value: contract })), + { title: `(done)`, value: null }, + ]) + if (name === null) return + const newName = await Prompts.askText(`Enter a new name for ${name} (a-z, 0-9, dash/underscore):`) + if (newName) { + state[newName] = Object.assign(state[name], { name: newName }) + delete state[name] + } + } + } +} - /** @returns this */ - gitCommit (message: string = "") { - this.runShellCommands( - 'git --no-pager add .', - 'git --no-pager status', - `git --no-pager commit -m ${message}`, - ) - return this +export class ScriptProject extends Project { + logStatus () { + return super.logStatus().br() + .info('This project contains no crates.') } +} - /** @returns this */ - npmInstall ({ npm, yarn, pnpm }: any = toolVersions()) { - if (pnpm) { - this.runShellCommands('pnpm i') - } else if (yarn) { - this.runShellCommands('yarn') - } else { - this.runShellCommands('npm i') +export class CargoProject extends Project { + + static async create ( // do not convert to arrow function + tools = new Tools.SystemTools(), name: string, root: string|Path + ) { + const project = await super.create(tools, name, root) as Project + if (tools.interactive) { + switch (await this.askCompiler(tools)) { + case 'podman': + project.envFile.save(`${project.envFile.load()}\nFADROMA_BUILD_PODMAN=1`) + break + case 'raw': + project.envFile.save(`${project.envFile.load()}\nFADROMA_BUILD_RAW=1`) + break + } } - return this + Tools.runCargoUpdate(project, tools) + Prompts.logInstallRust(tools) + Prompts.logInstallSha256Sum(tools) + Prompts.logInstallWasmOpt(tools) + Tools.gitCommitUpdatedLockfiles(project, tools, changed) + return project } - /** @returns this */ - cargoUpdate () { - this.runShellCommands('cargo update') - return this + static async askCompiler ({ + isLinux, + cargo = Tools.NOT_INSTALLED, + docker = Tools.NOT_INSTALLED, + podman = Tools.NOT_INSTALLED + }: Partial): Promise<'raw'|'docker'|'podman'> { + const variant = (value: string, title: string) => + ({ value, title }) + const buildRaw = variant('raw', + `No isolation, build with local toolchain (${cargo||'cargo: not found!'})`) + const buildDocker = variant('docker', + `Isolate builds in a Docker container (${docker||'docker: not found!'})`) + /* TODO: podman is currently disabled + const buildPodman = variant('podman', + `Isolate builds in a Podman container (experimental; ${podman||'podman: not found!'})`) + const hasPodman = podman && (podman !== NOT_INSTALLED) + const engines = hasPodman ? [ buildPodman, buildDocker ] : [ buildDocker, buildPodman ] */ + const engines = [ buildDocker ] + const options = isLinux ? [ ...engines, buildRaw ] : [ buildRaw, ...engines ] + return await Prompts.askSelect(`Select build isolation mode:`, options) } - /** Run one or more external commands in the project root. */ - runShellCommands (...cmds: string[]) { - cmds.map(cmd=>execSync(cmd, { cwd: this.root.path, stdio: 'inherit' })) + static writeCrate (path: string|Path, name: string, features: string[]) { + $(path, 'Cargo.toml') + .as(TextFile) + .save([ + `[package]`, `name = "${this.name}"`, `version = "0.0.0"`, `edition = "2021"`, + `authors = []`, `keywords = ["fadroma"]`, `description = ""`, `readme = "README.md"`, ``, + `[lib]`, `crate-type = ["cdylib", "rlib"]`, ``, + `[dependencies]`, + `fadroma = { version = "0.8.7", features = ${JSON.stringify(features)} }`, + `serde = { version = "1.0.114", default-features = false, features = ["derive"] }` + ].join('\n')) + + $(path, 'src') + .make() + + $(path, 'src/lib.rs') + .as(TextFile) + .save([ + `//! Created by [Fadroma](https://fadroma.tech).`, ``, + `#[fadroma::dsl::contract] pub mod contract {`, + ` use fadroma::{*, dsl::*, prelude::*};`, + ` impl Contract {`, + ` #[init(entry_wasm)]`, + ` pub fn new () -> Result {`, + ` Ok(Response::default())`, + ` }`, + ` // #[execute]`, + ` // pub fn my_tx_1 (arg1: String, arg2: Uint128) -> Result {`, + ` // Ok(Response::default())`, + ` // }`, + ` // #[execute]`, + ` // pub fn my_tx_2 (arg1: String, arg2: Uint128) -> Result {`, + ` // Ok(Response::default())`, + ` // }`, + ` // #[query]`, + ` // pub fn my_query_1 (arg1: String, arg2: Uint128) -> Result<(), StdError> {`, + ` // Ok(())`, '', + ` // }`, + ` // #[query]`, + ` // pub fn my_query_2 (arg1: String, arg2: Uint128) -> Result<(), StdError> {`, + ` // Ok(())`, '', + ` // }`, + ` }`, + `}`, + ].join('\n')) } } -/** Represents a crate containing a contract. */ -export class ProjectCrate { - /** Root directory of crate. */ - readonly dir: OpaqueDirectory - /** Crate manifest. */ - readonly cargoToml: TextFile - /** Directory containing crate sources. */ - readonly srcDir: OpaqueDirectory - /** Root module of Rust crate. */ - readonly libRs: TextFile +//[>* Represents a crate containing a contract. <] +//export class ProjectCrate { + //[>* Root directory of crate. <] + //readonly dir: OpaqueDirectory + //[>* Crate manifest. <] + //readonly cargoToml: TextFile + //[>* Directory containing crate sources. <] + //readonly srcDir: OpaqueDirectory + //[>* Root module of Rust crate. <] + //readonly libRs: TextFile + + //constructor ( + //project: { dirs: { src: Path } }, + //[>* Name of crate <] + //readonly name: string, + //[>* Features of the 'fadroma' dependency to enable. <] + //readonly features: string[] = ['scrt'] + //) { + //this.dir = project.dirs.src.in(name).as(OpaqueDirectory) + //this.cargoToml = this.dir.at('Cargo.toml').as(TextFile) + //this.srcDir = this.dir.in('src').as(OpaqueDirectory) + //this.libRs = this.srcDir.at('lib.rs').as(TextFile) + //} + + //create () { + //} + +//} + +export class CrateProject extends CargoProject { + cargoToml = $(this.root, 'Cargo.toml') + .as(TOMLFile) + srcDir = $(this.root, 'lib') + .as(TOMLFile) + + logStatus () { + return super.logStatus().br() + .info('This project contains a single source crate:') + .warn('TODO') + } - constructor ( - project: { dirs: { src: Path } }, - /** Name of crate */ - readonly name: string, - /** Features of the 'fadroma' dependency to enable. */ - readonly features: string[] = ['scrt'] + static async create ( // do not convert to arrow function + tools = new Tools.SystemTools(), name: string, root: string|Path ) { - this.dir = project.dirs.src.in(name).as(OpaqueDirectory) - this.cargoToml = this.dir.at('Cargo.toml').as(TextFile) - this.srcDir = this.dir.in('src').as(OpaqueDirectory) - this.libRs = this.srcDir.at('lib.rs').as(TextFile) + const project = await super.create(tools, name, root) as CrateProject + this.writeCrate(root, name) + return project } +} - create () { +export class WorkspaceProject extends CargoProject { + cargoToml = $(this.root, 'Cargo.toml') + .as(TOMLFile) + contractsDir = $(this.root, 'contracts') + .as(OpaqueDirectory) + librariesDir = $(this.root, 'libraries') + .as(OpaqueDirectory) + + logStatus () { + return console.br() + .info('This project contains the following source crates:') + .warn('TODO') + } - this.cargoToml.save([ - `[package]`, `name = "${this.name}"`, `version = "0.0.0"`, `edition = "2021"`, - `authors = []`, `keywords = ["fadroma"]`, `description = ""`, `readme = "README.md"`, ``, - `[lib]`, `crate-type = ["cdylib", "rlib"]`, ``, - `[dependencies]`, - `fadroma = { version = "0.8.7", features = ${JSON.stringify(this.features)} }`, - `serde = { version = "1.0.114", default-features = false, features = ["derive"] }` - ].join('\n')) + static async create ( // do not convert to arrow function + tools = new Tools.SystemTools(), name: string, root: string|Path + ) { + const project = await super.create(tools, name, root) as Project + return project + } - this.srcDir.make() - - this.libRs.save([ - `//! Created by [Fadroma](https://fadroma.tech).`, ``, - `#[fadroma::dsl::contract] pub mod contract {`, - ` use fadroma::{*, dsl::*, prelude::*};`, - ` impl Contract {`, - ` #[init(entry_wasm)]`, - ` pub fn new () -> Result {`, - ` Ok(Response::default())`, - ` }`, - ` // #[execute]`, - ` // pub fn my_tx_1 (arg1: String, arg2: Uint128) -> Result {`, - ` // Ok(Response::default())`, - ` // }`, - ` // #[execute]`, - ` // pub fn my_tx_2 (arg1: String, arg2: Uint128) -> Result {`, - ` // Ok(Response::default())`, - ` // }`, - ` // #[query]`, - ` // pub fn my_query_1 (arg1: String, arg2: Uint128) -> Result<(), StdError> {`, - ` // Ok(())`, '', - ` // }`, - ` // #[query]`, - ` // pub fn my_query_2 (arg1: String, arg2: Uint128) -> Result<(), StdError> {`, - ` // Ok(())`, '', - ` // }`, - ` }`, - `}`, + static writeCrates ({ cargoToml, wasmDir, crates }: { + cargoToml: Path, + wasmDir: Path, + crates: Record + }) { + // Populate root Cargo.toml + cargoToml.as(TextFile).save([ + `[workspace]`, `resolver = "2"`, `members = [`, + Object.values(crates).map(crate=>` "src/${crate.name}"`).sort().join(',\n'), + `]` ].join('\n')) + // Create each crate and store a null checksum for it + const sha256 = '000000000000000000000000000000000000000000000000000000000000000' + for (const crate of Object.values(crates)) { + crate.create() + const name = `${crate.name}@HEAD.wasm` + $(wasmDir, `${name}.sha256`) + .as(TextFile) + .save(`${sha256} *${name}`) + } + } +} + +export async function runScript ( + project?: Project, script?: string, ...args: string[] +) { + if (!script) { + throw new Error(`Usage: fadroma run SCRIPT [...ARGS]`) + } + if (!$(script).exists()) { + throw new Error(`${script} doesn't exist`) + } + console.log(`Running ${script}`) + const path = $(script).path + //@ts-ignore + const { default: main } = await import(path) + if (typeof main === 'function') { + return main(project, ...args) + } else { + console.info(`${$(script).shortPath} does not have a default export.`) + } +} + +export async function runRepl ( + project: Project, script?: string, ...args: string[] +) { + let start + try { + const repl = await import('node:repl') + start = repl.start + } catch (e) { + console.error('Node REPL unavailable.') + throw e + } + const context = start() || project.getDeployment() +} +export async function selectDeployment ( + cwd: string|Path, name?: string, store: string|DeployStore = Stores.getDeployStore() +): Promise { + if (typeof store === 'string') { + store = Stores.getDeployStore(store) + } + if (!name) { + if (process.stdout.isTTY) { + name = await Prompts.askDeployment(store) + } else { + throw new Error('pass deployment name') + } + } + const state = store.get(name!) + if (!state) { + throw new Error(`no deployment ${name} in store`) } + return Deployment.fromReceipt(state) +} +export function exportDeployment ( + cwd: string|Path, deployment?: Deployment, path?: string|Path +) { + if (!deployment) { + throw new Error("deployment not found") + } + if (!path) { + path = process.cwd() + } + // If passed a directory, generate file name + let file = $(path) + if (file.isDirectory()) { + file = file.in(`${name}_@_${timestamp()}.json`) + } + // Serialize and write the deployment. + const state = deployment.toReceipt() + file.as(JSONFile).makeParent().save(state) + console.info( + 'saved', + Object.keys(state).length, + 'contracts to', + bold(file.shortPath) + ) } diff --git a/ops/prompts.test.ts b/ops/prompts.test.ts new file mode 100644 index 00000000000..376a24bdd80 --- /dev/null +++ b/ops/prompts.test.ts @@ -0,0 +1,22 @@ +import * as Prompts from './prompts' +export default async function testPrompts () { +} + +//export async function testProjectWizard () { + //const wizard = new ProjectWizard({ + //interactive: false, + //cwd: tmpDir() + //}) + //assert.ok(await wizard.createProject( + //Project, + //'test-project-2', + //'test3', + //'test4' + //) instanceof Project) +//} + +//export function tmpDir () { + //let x + //withTmpDir(dir=>x=dir) + //return x +//} diff --git a/ops/prompts.ts b/ops/prompts.ts new file mode 100644 index 00000000000..30e0f4f1ab9 --- /dev/null +++ b/ops/prompts.ts @@ -0,0 +1,133 @@ +/** Fadroma. Copyright (C) 2023 Hack.bg. License: GNU AGPLv3 or custom. + You should have received a copy of the GNU Affero General Public License + along with tools program. If not, see . **/ +import type { Class, DeployStore, UploadedCode } from '@fadroma/connect' +import type { Project } from './project' +import { SystemTools, NOT_INSTALLED } from './tools' +import { Console, bold, colors, Scrt } from '@fadroma/connect' +import $, { Path, OpaqueDirectory, TextFile } from '@hackbg/file' +import prompts from 'prompts' +import * as dotenv from 'dotenv' +import { execSync } from 'node:child_process' +import { platform } from 'node:os' +import { version } from './config' +const console = new Console(`@hackbg/fadroma ${version}`) + +export async function askText ( + message: string, + valid = (x: string) => clean(x).length > 0, + clean = (x: string) => x.trim() +) { + while (true) { + const input = await prompts.prompt({ type: 'text', name: 'value', message }) + if ('value' in input) { + if (valid(input.value)) return clean(input.value) + } else { + console.error('Input cancelled.') + process.exit(1) + } + } +} + +export async function askSelect (message: string, choices: any[]) { + const input = await prompts.prompt({ type: 'select', name: 'value', message, choices }) + if ('value' in input) return input.value + console.error('Input cancelled.') + process.exit(1) +} + +export async function askUntilDone ( + state: S, selector: (state: S)=>Promise|Function|null +) { + let action = null + while (typeof (action = await Promise.resolve(selector(state))) === 'function') { + await Promise.resolve(action(state)) + } + return state +} + +export function logInstallRust ({ isMac, homebrew, cargo, rust }: SystemTools) { + if (!cargo || !rust) { + console.warn('Tool not available: cargo or rustc.') + console.warn('Building contract without container will fail.') + if (isMac && !homebrew) { + console.info('Install homebrew (https://docs.brew.sh/Installation), then:') + } else { + console.info('You can install it with:') + console.info(' $ brew install rustup') + console.info(' $ rustup target add wasm32-unknown-unknown') + } + } +} + +export function logInstallSha256Sum ({ isMac, homebrew, sha256sum }: SystemTools) { + if (!sha256sum) { + console.warn('Tool not available: sha256sum. Building contract without container will fail.') + if (isMac && !homebrew) { + console.info('Install homebrew (https://docs.brew.sh/Installation), then:') + } else { + console.info('You can install it with:') + console.info(' $ brew install coreutils') + } + } +} + +export function logInstallWasmOpt ({ isMac, homebrew, wasmOpt }: SystemTools) { + if (!wasmOpt) { + console.warn('Tool not available: wasm-opt. Building contract without container will fail.') + if (isMac && !homebrew) { + console.info('Install homebrew (https://docs.brew.sh/Installation), then:') + } else { + console.info('You can install it with:') + console.info(' $ brew install binaryen') + } + } +} + +export function logNonFatal (nonfatal?: boolean) { + if (nonfatal) { + console.warn('One or more convenience operations failed.') + console.warn('You can retry them manually later.') + } +} + +export async function logProjectCreated ({ root }: Project) { + + console.log("Project created at", bold(root.shortPath)) + .info() + .info(`To compile your contracts:`) + .info(` $ ${bold('npm run build')}`) + .info(`To spin up a local deployment:`) + .info(` $ ${bold('npm run devnet deploy')}`) + .info(`To deploy to testnet:`) + .info(` $ ${bold('npm run testnet deploy')}`) + + const envFile = root.at('.env').as(TextFile).load() + + const { FADROMA_TESTNET_MNEMONIC: mnemonic } = dotenv.parse(envFile) + + console.info(`Your testnet mnemonic:`) + .info(` ${bold(mnemonic)}`) + + const testnetAgent = await Scrt.testnet().authenticate({ mnemonic }) + + Object.assign(testnetAgent, { log: { log () {} } }) + + console.info(`Your testnet address:`) + .info(` ${bold(testnetAgent.address)}`) + .info(`Fund your testnet wallet at:`) + .info(` ${bold('https://faucet.pulsar.scrttestnet.com')}`) + +} + +export async function askDeployment (store: DeployStore & { + root?: Path +}): Promise { + const label = store.root + ? `Select a deployment from ${store.root.shortPath}:` + : `Select a deployment:` + return await askSelect(label, [ + [...store.keys()].map(title=>({ title, value: title })), + { title: '(cancel)', value: undefined } + ]) +} diff --git a/ops/scaffold.ts b/ops/scaffold.ts deleted file mode 100644 index 982a9fba5ce..00000000000 --- a/ops/scaffold.ts +++ /dev/null @@ -1,207 +0,0 @@ -/** Fadroma. Copyright (C) 2023 Hack.bg. License: GNU AGPLv3 or custom. - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . **/ -import { bip39, bip39EN } from '@fadroma/connect' -import $, { TextFile } from '@hackbg/file' -import Case from 'case' -import type { Project } from './project' - -export function writeProject ({ - name, root, dirs, files, crates -}: Project) { - - // Create project root - root.make() - - // Create project directories - Object.values(dirs).forEach(dir=>dir.make()) - - // Project files that we will populate: - const { - readme, packageJson, cargoToml, - gitignore, envfile, shellNix, - apiIndex, projectIndex, testIndex, - } = files - - // Populate readme - readme.save([ - `# ${name}\n---\n`, - `Powered by [Fadroma](https://fadroma.tech) `, - `by [Hack.bg](https://hack.bg) `, - `under [AGPL3](https://www.gnu.org/licenses/agpl-3.0.en.html).` - ].join('')) - - // Populate NPM dependencies - packageJson.save({ - name: `${name}`, - main: `index.ts`, - type: "module", - version: "0.1.0", - dependencies: { - "@fadroma/agent": "1.1.2", - "@fadroma/scrt": "10.1.6", - "secretjs": "1.9.3" - }, - devDependencies: { - "@hackbg/fadroma": `1.5.9`, - "@hackbg/ganesha": "4.2.0", - //"@hackbg/ubik": "^2.0.0", - "typescript": "^5.1.6", - }, - scripts: { - "build": "fadroma build", - "rebuild": "fadroma rebuild", - "status": "fadroma status", - "mocknet": `FADROMA_PROJECT=./fadroma.config.ts FADROMA_CHAIN=Mocknet fadroma`, - "devnet": `FADROMA_PROJECT=./fadroma.config.ts FADROMA_CHAIN=ScrtDevnet fadroma`, - "testnet": `FADROMA_PROJECT=./fadroma.config.ts FADROMA_CHAIN=ScrtTestnet fadroma`, - "mainnet": `FADROMA_PROJECT=./fadroma.config.ts FADROMA_CHAIN=ScrtMainnet fadroma`, - "test": `FADROMA_PROJECT=./fadroma.config.ts fadroma run test.ts`, - "test:mocknet": `FADROMA_PROJECT=./fadroma.config.ts FADROMA_CHAIN=Mocknet fadroma run test.ts`, - "test:devnet": `FADROMA_PROJECT=./fadroma.config.ts FADROMA_CHAIN=ScrtDevnet fadroma run test.ts`, - "test:testnet": `FADROMA_PROJECT=./fadroma.config.ts FADROMA_CHAIN=ScrtTestnet fadroma run test.ts`, - }, - }) - - // Define api module - let deploymentClassName = - (Object.keys(templates).includes(name)) - ? `${Case.pascal(name)}Deployment` - : Case.pascal(name) - - apiIndex.save([ - `import { Client, Deployment } from '@fadroma/agent'`, - [ - `export default class ${deploymentClassName} extends Deployment {`, - ...Object.keys(templates).map(name => [ - ``, ` ${Case.camel(name)} = this.contract({`, - ` name: "${name}",`, - ` crate: "${name}",`, - ` client: ${Case.pascal(name)},`, - ` initMsg: async () => ({})`, - ` })` - ].join('\n')), - '', - ` // Define your contract roles here with:`, - ` // contract = this.contract({...})`, ` //`, - ` // See https://fadroma.tech/deploy.html`, - ` // for more info about how to populate this section.`, - '', - '}', - ].join('\n'), - ...Object.keys(templates).map(x=>Case.pascal(x)).map(Contract => [ - `export class ${Contract} extends Client {`, - ` // Implement methods calling the contract here:`, ` //`, - ` // myTx = (arg1, arg2) => this.execute({my_tx:{arg1, arg2}})`, - ` // myQuery = (arg1, arg2) => this.query({my_query:{arg1, arg2}})`, ` //`, - ` // See https://fadroma.tech/agent.html#client`, - ` // for more info about how to populate this section.`, - `}\n` - ].join('\n')) - ].join('\n\n')) - - // Define ops module - projectIndex.save([ - [ - `import ${Case.pascal(name)} from './api'`, - `import Project from '@hackbg/fadroma'`, - ].join('\n'), - [ - `export default class ${Case.pascal(name)}Project extends Project {`, ``, - ` Deployment = ${Case.pascal(name)}`, ``, - ` // Override to customize the build command:`, ` //`, - ` // build = async (...contracts: string[]) => { `, - ` // await super.build(...contracts)`, - ` // }`, ``, - ` // Override to customize the upload command:`, ` //`, - ` // upload = async (...contracts: string[]) => {`, - ` // await super.upload(...contracts)`, - ` // }`, ``, - ` // Override to customize the deploy command:`, - ` //`, - ` // deploy = async (...args: string[]) => {`, - ` // await super.deploy(...args)`, - ` // }`, ``, - ` // Override to customize the status command:`, ` //`, - ` // status = async (...args: string[]) => {`, - ` // await super.status()`, - ` // }`, ``, - ` // Define custom commands using \`this.command\`:`, ` //`, - ` // custom = this.command('custom', 'run a custom procedure', async () => {`, - ` // // ...`, - ` // })`, - ``, `}` - ].join('\n') - ].join('\n\n')) - - // Define test module - testIndex.save([ - `import * as assert from 'node:assert'`, - `import ${Case.pascal(name)} from './api'`, - `import { getDeployment } from '@hackbg/fadroma'`, - `const deployment = await getDeployment(${Case.pascal(name)}).deploy()`, - `// add your assertions here` - ].join('\n')) - - // Populate gitignore - gitignore.save([ - '.env', - '*.swp', - 'node_modules', - 'target', - 'state/*', - '!state/secret-1', - '!state/secret-2', - '!state/secret-3', - '!state/secret-4', - '!state/pulsar-1', - '!state/pulsar-2', - '!state/pulsar-3', - '!state/okp4-nemeton-1', - 'wasm/*', - '!wasm/*.sha256', - ].join('\n')) - - // Populate env config - envfile.save([ - '# FADROMA_MNEMONIC=your mainnet mnemonic', - `FADROMA_TESTNET_MNEMONIC=${bip39.generateMnemonic(bip39EN)}`, - ``, - `# Just remove these two when pulsar-3 is ready:`, - `FADROMA_SCRT_TESTNET_CHAIN_ID=pulsar-2`, - `FADROMA_SCRT_TESTNET_URL=https://lcd.testnet.secretsaturn.net`, - ``, - `# Other settings:`, - ].join('\n')) - - // Populate Nix shell - shellNix.save([ - `{ pkgs ? import {}, ... }: let name = "${name}"; in pkgs.mkShell {`, - ` inherit name;`, - ` nativeBuildInputs = with pkgs; [`, - ` git nodejs nodePackages_latest.pnpm rustup`, - ` binaryen wabt wasm-pack wasm-bindgen-cli`, - ` ];`, - ` shellHook = ''`, - ` export PS1="$PS1[\${name}] "`, - ` export PATH="$PATH:$HOME/.cargo/bin:\${./.}/node_modules/.bin"`, - ` '';`, - `}`, - ].join('\n')) - - // Populate root Cargo.toml - cargoToml.as(TextFile).save([ - `[workspace]`, `resolver = "2"`, `members = [`, - Object.values(crates).map(crate=>` "src/${crate.name}"`).sort().join(',\n'), - `]` - ].join('\n')) - - // Create each crate and store a null checksum for it - const sha256 = '000000000000000000000000000000000000000000000000000000000000000' - Object.values(crates).forEach(crate=>{ - crate.create() - const name = `${crate.name}@HEAD.wasm` - dirs.wasm.at(`${name}.sha256`).as(TextFile).save(`${sha256} *${name}`) - }) - -} diff --git a/ops/stores.test.ts b/ops/stores.test.ts index f08f05ffb97..c59a0a4cd64 100644 --- a/ops/stores.test.ts +++ b/ops/stores.test.ts @@ -1,12 +1,12 @@ /** Fadroma. Copyright (C) 2023 Hack.bg. License: GNU AGPLv3 or custom. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . **/ -import { MyDeployment } from './deploy.test' +import { MyDeployment } from './project.test' import { JSONFileUploadStore } from './stores' import { Stub } from '@fadroma/connect' import { withTmpDir } from '@hackbg/file' -export default async function testJSONFileUploadStore () { +export default async function testJSONFileStores () { await withTmpDir(async dir=>{ const deployment = new MyDeployment() await deployment.upload({ diff --git a/ops/stores.ts b/ops/stores.ts index d6aa5d37b6a..841a3840959 100644 --- a/ops/stores.ts +++ b/ops/stores.ts @@ -2,24 +2,22 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see . **/ import { - Console, Error, bold, timestamp, UploadStore, DeployStore, ContractInstance, Deployment + Console, Error, bold, timestamp, + UploadStore, DeployStore, ContractInstance, Deployment } from '@fadroma/connect' -import type { - CodeHash, UploadedCode, DeploymentState, Name -} from '@fadroma/connect' -import $, { - OpaqueDirectory, BinaryFile, TextFile, - JSONDirectory, JSONFile, -} from '@hackbg/file' -import type { - Path -} from '@hackbg/file' -import { - fileURLToPath -} from 'node:url' -import { - basename -} from 'node:path' +import type { CodeHash, UploadedCode, DeploymentState, Name } from '@fadroma/connect' +import $, { OpaqueDirectory, BinaryFile, TextFile, JSONDirectory, JSONFile } from '@hackbg/file' +import type { Path } from '@hackbg/file' +import { fileURLToPath } from 'node:url' +import { basename } from 'node:path' + +export function getUploadStore (path?: string|Path): UploadStore { + if (path) { + return new JSONFileUploadStore(path) + } else { + return new UploadStore() + } +} /** Directory containing upload receipts, e.g. `state/$CHAIN/upload`. */ export class JSONFileUploadStore extends UploadStore { @@ -27,7 +25,7 @@ export class JSONFileUploadStore extends UploadStore { dir: JSONDirectory> - constructor (dir: string) { + constructor (dir: string|Path) { super() this.dir = $(dir).as(JSONDirectory>) } @@ -70,6 +68,14 @@ export class JSONFileUploadStore extends UploadStore { } } +export function getDeployStore (path?: string): DeployStore { + if (path) { + return new JSONFileDeployStore(path) + } else { + return new DeployStore() + } +} + /** Directory containing deploy receipts, e.g. `state/$CHAIN/deploy`. */ export class JSONFileDeployStore extends DeployStore { log = new Console('DeployStore_v1') @@ -78,7 +84,7 @@ export class JSONFileDeployStore extends DeployStore { /** Name of symlink pointing to active deployment, without extension. */ KEY = '.active' - constructor (dir: string,) { + constructor (dir: string|Path) { super() this.dir = $(dir).as(JSONDirectory) } diff --git a/ops/tools.test.ts b/ops/tools.test.ts new file mode 100644 index 00000000000..2a1a6e7f1e5 --- /dev/null +++ b/ops/tools.test.ts @@ -0,0 +1,3 @@ +import * as Tools from './tools' +export default async function testTools () { +} diff --git a/ops/tools.ts b/ops/tools.ts new file mode 100644 index 00000000000..b4fbf131ac5 --- /dev/null +++ b/ops/tools.ts @@ -0,0 +1,350 @@ +import { Console, bold, colors } from '@fadroma/connect' +import { execSync } from 'node:child_process' +import { platform } from 'node:os' +import { cwd } from 'node:process' +import Case from 'case' +import { version } from './config' +import type { Project } from './project' +import $, { Path, TextFile, JSONFile, TOMLFile, OpaqueDirectory } from '@hackbg/file' +import { bip39, bip39EN } from '@fadroma/connect' +const console = new Console(`@hackbg/fadroma ${version}`) + +export const NOT_INSTALLED = 'not installed' + +export class SystemTools { + + constructor (readonly verbose: boolean = true) {} + + /** Check and report a variable */ + protected check (name: string|null, value: T): T { + if (name) { + console.info(bold(name), value) + } + return value + } + + /** Check and report if an external binary is available. */ + protected checkTool (dependency: string|null, command: string): string|null { + let version = null + try { + version = String(execSync(command)).trim().split('\n')[0] + if (this.verbose && dependency) { + console.info(bold(dependency), version) + } + } catch (e) { + if (this.verbose && dependency) { + console.warn(bold(dependency), colors.yellow('(not found)')) + } + } finally { + return version + } + } + + cwd = cwd() + isLinux = platform() === 'linux' + isMac = platform() === 'darwin' + isWin = platform() === 'win32' + ttyIn = this.check('TTY in: ', !!process.stdin.isTTY) + ttyOut = this.check('TTY out: ', !!process.stdout.isTTY) + interactive = this.ttyIn && this.ttyOut + git = this.checkTool('Git ', 'git --no-pager --version') + node = this.checkTool('Node ', 'node --version') + npm = this.checkTool('NPM ', 'npm --version') + yarn = this.checkTool('Yarn ', 'yarn --version') + pnpm = this.checkTool('PNPM ', 'pnpm --version') + corepack = this.checkTool('corepack ', 'corepack --version') + cargo = this.checkTool('Cargo ', 'cargo --version') + rust = this.checkTool('Rust ', 'rustc --version') + sha256sum = this.checkTool('sha256sum', 'sha256sum --version') + wasmOpt = this.checkTool('wasm-opt ', 'wasm-opt --version') + docker = this.checkTool('Docker ', 'docker --version') + podman = this.checkTool('Podman ', 'podman --version') + nix = this.checkTool('Nix ', 'nix --version') + secretcli = this.checkTool('secretcli', 'secretcli version') + homebrew = this.isMac ? this.checkTool('homebrew ', 'brew --version') : undefined +} + +export function cargoUpdate (cwd: string) { + return runShellCommands(cwd, ['cargo update']) +} + +/** Run one or more external commands in the project root. */ +export function runShellCommands (cwd: string, cmds: string[]) { + return cmds.map(cmd=>execSync(cmd, { cwd, stdio: 'inherit' })) +} + +export function createGitRepo (cwd: string, tools: SystemTools): { + nonfatal: boolean +} { + let nonfatal = false + if (tools.git) { + try { + runShellCommands(cwd, [ + 'git --no-pager init', + ]) + writeGitIgnore($(cwd).at('.gitignore')) + runShellCommands(cwd, [ + 'git --no-pager add .', + 'git --no-pager status', + 'git --no-pager commit -m "Project created by @hackbg/fadroma (https://fadroma.tech)"', + "git --no-pager log", + ]) + } catch (e) { + console.warn('Non-fatal: Failed to create Git repo.') + tools.git = null // disable git + nonfatal = true + } + } else { + console.warn('Git not found. Not creating repo.') + } + return { nonfatal } +} + +export function generateGitIgnore () { + return [ + '.env', + '*.swp', + 'node_modules', + 'target', + 'state/*', + '!state/secret-1', '!state/secret-2', '!state/secret-3', '!state/secret-4', + '!state/pulsar-1', '!state/pulsar-2', '!state/pulsar-3', + '!state/okp4-nemeton-1', + 'wasm/*', + '!wasm/*.sha256', + ].join('\n') +} + +export function runNPMInstall (project: Project, tools: SystemTools): { + changed?: true + nonfatal?: true +} { + let changed: true|undefined = undefined + let nonfatal: true|undefined = undefined + const { pnpm, yarn, npm, corepack } = tools + if (pnpm || yarn || npm) { + if (!pnpm && corepack) { + console.info('Try PNPM! To enable it, just run:') + console.info(' $ corepack enable') + } + try { + if (pnpm) { + runShellCommands(project.root.path, ['pnpm i']) + } else if (yarn) { + runShellCommands(project.root.path, ['yarn']) + } else { + runShellCommands(project.root.path, ['npm i']) + } + } catch (e) { + console.warn('Non-fatal: NPM install failed:', e) + nonfatal = true + } + changed = true + } else { + console.warn('NPM/Yarn/PNPM not found. Not creating lockfile.') + } + return { changed, nonfatal } +} + +export function runCargoUpdate (project: Project, tools: SystemTools): { + changed?: true + nonfatal?: true +} { + let changed: true|undefined = undefined + let nonfatal: true|undefined = undefined + if (tools.cargo) { + try { + cargoUpdate(project.root.path) + } catch (e) { + console.warn('Non-fatal: Cargo update failed:', e) + nonfatal = true + } + changed = true + } else { + console.warn('Cargo not found. Not creating lockfile.') + } + return { changed, nonfatal } +} + +export function gitCommitUpdatedLockfiles (project: Project, tools: SystemTools, changed?: boolean): { + nonfatal?: true +} { + let nonfatal: true|undefined = undefined + if (changed && tools.git) { + try { + gitCommit(project.root.path, '"Updated lockfiles."') + } catch (e) { + console.warn('Non-fatal: Git status failed:', e) + nonfatal = true + } + } + return { nonfatal } +} + +export function gitCommit (cwd: string, message: string) { + if (!message) { + throw new Error("specify commit message") + } + return runShellCommands(cwd, [ + 'git --no-pager add .', + 'git --no-pager status', + `git --no-pager commit -m ${message}`, + ]) +} + +export function generateApiIndex (name: string, crates: {}) { + const deploymentClassName = + (Object.keys(crates).includes(name)) + ? `${Case.pascal(name)}Deployment` + : Case.pascal(name) + return [ + `import { Client, Deployment } from '@fadroma/agent'`, + [ + `export default class ${deploymentClassName} extends Deployment {`, + ...Object.keys(crates).map(name => [ + ``, ` ${Case.camel(name)} = this.contract({`, + ` name: "${name}",`, + ` crate: "${name}",`, + ` client: ${Case.pascal(name)},`, + ` initMsg: async () => ({})`, + ` })` + ].join('\n')), + '', + ` // Define your contract roles here with:`, + ` // contract = this.contract({...})`, ` //`, + ` // See https://fadroma.tech/deploy.html`, + ` // for more info about how to populate this section.`, + '', + '}', + ].join('\n'), + ...Object.keys(crates).map(x=>Case.pascal(x)).map(Contract => [ + `export class ${Contract} extends Client {`, + ` // Implement methods calling the contract here:`, ` //`, + ` // myTx = (arg1, arg2) => this.execute({my_tx:{arg1, arg2}})`, + ` // myQuery = (arg1, arg2) => this.query({my_query:{arg1, arg2}})`, ` //`, + ` // See https://fadroma.tech/agent.html#client`, + ` // for more info about how to populate this section.`, + `}\n` + ].join('\n')) + ].join('\n\n') +} + +export function generateProjectIndex (name: string) { + return [ + [ + `import ${Case.pascal(name)} from './api'`, + `import Project from '@hackbg/fadroma'`, + ].join('\n'), + [ + `export default class ${Case.pascal(name)}Project extends Project {`, ``, + ` Deployment = ${Case.pascal(name)}`, ``, + ` // Override to customize the build command:`, ` //`, + ` // build = async (...contracts: string[]) => { `, + ` // await super.build(...contracts)`, + ` // }`, ``, + ` // Override to customize the upload command:`, ` //`, + ` // upload = async (...contracts: string[]) => {`, + ` // await super.upload(...contracts)`, + ` // }`, ``, + ` // Override to customize the deploy command:`, + ` //`, + ` // deploy = async (...args: string[]) => {`, + ` // await super.deploy(...args)`, + ` // }`, ``, + ` // Override to customize the status command:`, ` //`, + ` // status = async (...args: string[]) => {`, + ` // await super.status()`, + ` // }`, ``, + ` // Define custom commands using \`this.command\`:`, ` //`, + ` // custom = this.command('custom', 'run a custom procedure', async () => {`, + ` // // ...`, + ` // })`, + ``, `}` + ].join('\n') + ].join('\n\n') +} + +export function generateTestIndex (name: string) { + return [ + `import * as assert from 'node:assert'`, + `import ${Case.pascal(name)} from './api'`, + `import { getDeployment } from '@hackbg/fadroma'`, + `const deployment = await getDeployment(${Case.pascal(name)}).deploy()`, + `// add your assertions here` + ].join('\n') +} + +export function generateReadme (name: string) { + return [ + `# ${name}\n---\n`, + `Powered by [Fadroma](https://fadroma.tech) `, + `as provided by [Hack.bg](https://hack.bg) `, + `under [AGPL3](https://www.gnu.org/licenses/agpl-3.0.en.html).` + ].join('\n') +} + +export function generatePackageJson (name: string) { + return { + name: `${name}`, + main: `index.ts`, + type: "module", + version: "0.1.0", + dependencies: { + "@fadroma/agent": "1.1.2", + "@fadroma/scrt": "10.1.6", + "secretjs": "1.9.3" + }, + devDependencies: { + "@hackbg/fadroma": `1.5.9`, + "@hackbg/ganesha": "4.2.0", + //"@hackbg/ubik": "^2.0.0", + "typescript": "^5.1.6", + }, + scripts: { + "build": "fadroma build", + "rebuild": "fadroma rebuild", + "status": "fadroma status", + "mocknet": `FADROMA_PROJECT=./fadroma.config.ts FADROMA_CHAIN=Mocknet fadroma`, + "devnet": `FADROMA_PROJECT=./fadroma.config.ts FADROMA_CHAIN=ScrtDevnet fadroma`, + "testnet": `FADROMA_PROJECT=./fadroma.config.ts FADROMA_CHAIN=ScrtTestnet fadroma`, + "mainnet": `FADROMA_PROJECT=./fadroma.config.ts FADROMA_CHAIN=ScrtMainnet fadroma`, + "test": `FADROMA_PROJECT=./fadroma.config.ts fadroma run test.ts`, + "test:mocknet": `FADROMA_PROJECT=./fadroma.config.ts FADROMA_CHAIN=Mocknet fadroma run test.ts`, + "test:devnet": `FADROMA_PROJECT=./fadroma.config.ts FADROMA_CHAIN=ScrtDevnet fadroma run test.ts`, + "test:testnet": `FADROMA_PROJECT=./fadroma.config.ts FADROMA_CHAIN=ScrtTestnet fadroma run test.ts`, + }, + } +} + +export function generateEnvFile ({ + mnemonic = bip39.generateMnemonic(bip39EN) +}: { + mnemonic?: string +} = {}) { + return [ + '# FADROMA_MNEMONIC=your mainnet mnemonic', + `FADROMA_TESTNET_MNEMONIC=${mnemonic}`, + ``, + `# Just remove these two when pulsar-3 is ready:`, + `FADROMA_SCRT_TESTNET_CHAIN_ID=pulsar-2`, + `FADROMA_SCRT_TESTNET_URL=https://lcd.testnet.secretsaturn.net`, + ``, + `# Other settings:`, + ].join('\n') +} + +export function generateShellNix (name: string) { + return [ + `{ pkgs ? import {}, ... }: let name = "${name}"; in pkgs.mkShell {`, + ` inherit name;`, + ` nativeBuildInputs = with pkgs; [`, + ` git nodejs nodePackages_latest.pnpm rustup`, + ` binaryen wabt wasm-pack wasm-bindgen-cli`, + ` ];`, + ` shellHook = ''`, + ` export PS1="$PS1[\${name}] "`, + ` export PATH="$PATH:$HOME/.cargo/bin:\${./.}/node_modules/.bin"`, + ` '';`, + `}`, + ].join('\n') +} diff --git a/ops/wizard.test.ts b/ops/wizard.test.ts deleted file mode 100644 index e06ebd05867..00000000000 --- a/ops/wizard.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** Fadroma. Copyright (C) 2023 Hack.bg. License: GNU AGPLv3 or custom. - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . **/ -import * as assert from 'node:assert' -import { ProjectWizard } from './wizard' -import { Project } from './project' -import { withTmpDir } from '@hackbg/file' - -export default async function testProjectWizard () { - - const wizard = new ProjectWizard({ - interactive: false, - cwd: tmpDir() - }) - - assert.ok(await wizard.createProject( - Project, - 'test-project-2', - 'test3', - 'test4' - ) instanceof Project) - -} - -export function tmpDir () { - let x - withTmpDir(dir=>x=dir) - return x -} diff --git a/ops/wizard.ts b/ops/wizard.ts deleted file mode 100644 index 41962b43733..00000000000 --- a/ops/wizard.ts +++ /dev/null @@ -1,365 +0,0 @@ -/** Fadroma. Copyright (C) 2023 Hack.bg. License: GNU AGPLv3 or custom. - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . **/ -import type { Class, DeployStore, UploadedCode } from '@fadroma/connect' -import type { Project } from './project' -import { version } from './config' - -import { Console, bold, colors, Scrt } from '@fadroma/connect' - -import $, { Path, OpaqueDirectory, TextFile } from '@hackbg/file' -import prompts from 'prompts' -import * as dotenv from 'dotenv' - -import { execSync } from 'node:child_process' -import { platform } from 'node:os' - -const console = new Console(`@hackbg/fadroma ${version}`) - -/** Interactive project creation CLI. - * TODO: single crate option - * TODO: `shared` crate option */ -export class ProjectWizard { - - log = new Console(`@hackbg/fadroma ${version}`) - cwd: string = process.cwd() - - isLinux: boolean = platform() === 'linux' - isMac: boolean = platform() === 'darwin' - isWin: boolean = platform() === 'win32' - - tools: ReturnType = toolVersions({ - isMac: this.isMac - }) - - interactive: boolean = !!process.stdin.isTTY && process.stdout.isTTY - - constructor (options: Partial = {}) { - this.cwd = options.cwd ?? this.cwd - this.tools = options.tools ?? this.tools - this.interactive = options.interactive ?? this.interactive - } - - async createProject (_P: typeof Project, ...args: any[]): Promise { - - let { - git, pnpm, yarn, npm, cargo, rust, docker, podman, corepack, sha256sum, wasmOpt, homebrew - } = this.tools - - const name = args[0] ?? (this.interactive ? await this.askName() : undefined) - if (name === 'undefined') throw new Error('missing project name') - console.log(`Creating project`, name) - - const root = (this.interactive - ? $(await this.askRoot(name)) - : $(this.cwd, name)).as(OpaqueDirectory) - console.log(`Creating in`, root.shortPath) - - const templates = args.slice(1).length > 0 - ? args.slice(1).reduce((templates, crate)=>Object.assign(templates, { [crate]: crate }), {}) - : this.interactive - ? await this.askTemplates(name) - : {} - console.log(`Defining`, Object.keys(templates).length, `template(s) in project`) - - const options = { name, root, templates: templates as any } - const project = new _P(options) - project.create() - - if (this.interactive) { - switch (await this.selectBuilder()) { - case 'podman': project.files.envfile.save( - `${project.files.envfile.load()}\nFADROMA_BUILD_PODMAN=1` - ); break - case 'raw': project.files.envfile.save( - `${project.files.envfile.load()}\nFADROMA_BUILD_RAW=1` - ); break - default: break - } - } - - let changed = false - let nonfatal = false - if (git) { - try { - project.gitSetup() - } catch (e) { - console.warn('Non-fatal: Failed to create Git repo.') - nonfatal = true - git = null - } - } else { - console.warn('Git not found. Not creating repo.') - } - - if (pnpm || yarn || npm) { - if (!pnpm && corepack) { - console.info('Try PNPM! To enable it, just run:') - console.info(' $ corepack enable') - } - try { - project.npmInstall(this.tools) - changed = true - } catch (e) { - console.warn('Non-fatal: NPM install failed:', e) - nonfatal = true - } - } else { - console.warn('NPM/Yarn/PNPM not found. Not creating lockfile.') - } - - if (cargo) { - try { - project.cargoUpdate() - changed = true - } catch (e) { - console.warn('Non-fatal: Cargo update failed:', e) - nonfatal = true - } - } else { - console.warn('Cargo not found. Not creating lockfile.') - } - - if (changed && git) { - try { - project.gitCommit('"Updated lockfiles."') - } catch (e) { - console.warn('Non-fatal: Git status failed:', e) - nonfatal = true - } - } - - if (!cargo || !rust) { - console.warn('Tool not available: cargo or rustc.') - console.warn('Building contract without container will fail.') - if (this.isMac && !homebrew) { - console.info('Install homebrew (https://docs.brew.sh/Installation), then:') - } else { - console.info('You can install it with:') - console.info(' $ brew install rustup') - console.info(' $ rustup target add wasm32-unknown-unknown') - } - } - - if (!sha256sum) { - console.warn('Tool not available: sha256sum. Building contract without container will fail.') - if (this.isMac && !homebrew) { - console.info('Install homebrew (https://docs.brew.sh/Installation), then:') - } else { - console.info('You can install it with:') - console.info(' $ brew install coreutils') - } - } - - if (!wasmOpt) { - console.warn('Tool not available: wasm-opt. Building contract without container will fail.') - if (this.isMac && !homebrew) { - console.info('Install homebrew (https://docs.brew.sh/Installation), then:') - } else { - console.info('You can install it with:') - console.info(' $ brew install binaryen') - } - } - - if (nonfatal) { - console.warn('One or more convenience operations failed.') - console.warn('You can retry them manually later.') - } - - console.log("Project created at", bold(project.root.shortPath)) - console.info() - console.info(`To compile your contracts:`) - console.info(` $ ${bold('npm run build')}`) - console.info(`To spin up a local deployment:`) - console.info(` $ ${bold('npm run devnet deploy')}`) - console.info(`To deploy to testnet:`) - console.info(` $ ${bold('npm run testnet deploy')}`) - const envFile = project.root.at('.env').as(TextFile).load() - const { FADROMA_TESTNET_MNEMONIC: mnemonic } = dotenv.parse(envFile) - console.info(`Your testnet mnemonic:`) - console.info(` ${bold(mnemonic)}`) - const testnetAgent = await Scrt.testnet().authenticate({ mnemonic }) - Object.assign(testnetAgent, { log: { log () {} } }) - console.info(`Your testnet address:`) - console.info(` ${bold(testnetAgent.address)}`) - console.info(`Fund your testnet wallet at:`) - console.info(` ${bold('https://faucet.pulsar.scrttestnet.com')}`) - return project - } - - async askName (): Promise { - let value - do { - value = await askText('Enter a project name (a-z, 0-9, dash/underscore)')??'' - value = value.trim() - if (!isNaN(value[0] as any)) { - console.info('Project name cannot start with a digit.') - value = '' - } - } while (value === '') - return value - } - askRoot (name: string): Promise { - const cwd = $(process.cwd()).as(OpaqueDirectory) - const exists = cwd.in(name).exists() - const empty = (cwd.list()?.length||0) === 0 - const inSub = `Subdirectory (${exists?'overwrite: ':''}${cwd.name}/${name})` - const inCwd = `Current directory (${cwd.name})` - const choice = [ - { title: inSub, value: cwd.in(name) }, - { title: inCwd, value: cwd }, - ] - if (empty) choice.reverse() - return askSelect(`Create project ${name} in current directory or subdirectory?`, choice) - } - askTemplates (name: string): - Promise>> - { - return askUntilDone({}, (state) => askSelect([ - `Project ${name} contains ${Object.keys(state).length} contract(s):\n`, - ` ${Object.keys(state).join(',\n ')}` - ].join(''), [ - { title: `Add contract template to the project`, value: defineContract }, - { title: `Remove contract template`, value: undefineContract }, - { title: `Rename contract template`, value: renameContract }, - { title: `(done)`, value: null }, - ])) - async function defineContract (state: Record) { - let crate - crate = await askText('Enter a name for the new contract (lowercase a-z, 0-9, dash, underscore):')??'' - if (!isNaN(crate[0] as any)) { - console.info('Contract name cannot start with a digit.') - crate = '' - } - if (crate) { - state[crate] = { crate } - } - } - async function undefineContract (state: Record) { - const name = await askSelect(`Select contract to remove from project scope:`, [ - ...Object.keys(state).map(contract=>({ title: contract, value: contract })), - { title: `(done)`, value: null }, - ]) - if (name === null) return - delete state[name] - } - async function renameContract (state: Record) { - const name = await askSelect(`Select contract to rename:`, [ - ...Object.keys(state).map(contract=>({ title: contract, value: contract })), - { title: `(done)`, value: null }, - ]) - if (name === null) return - const newName = await askText(`Enter a new name for ${name} (a-z, 0-9, dash/underscore):`) - if (newName) { - state[newName] = Object.assign(state[name], { name: newName }) - delete state[name] - } - } - } - selectBuilder (): 'podman'|'raw'|any { - let { cargo = NOT_INSTALLED, docker = NOT_INSTALLED, podman = NOT_INSTALLED } = this.tools - // FIXME: podman is currently disabled - podman = NOT_INSTALLED - const variant = (value: string, title: string) => - ({ value, title }) - const buildRaw = variant('raw', - `No isolation, build with local toolchain (${cargo||'cargo: not found!'})`) - const buildDocker = variant('docker', - `Isolate builds in a Docker container (${docker||'docker: not found!'})`) - const buildPodman = variant('podman', - `Isolate builds in a Podman container (experimental; ${podman||'podman: not found!'})`) - const hasPodman = podman && (podman !== NOT_INSTALLED) - const engines = [ buildDocker ] - // const engines = hasPodman ? [ buildPodman, buildDocker ] : [ buildDocker, buildPodman ] - const choices = this.isLinux ? [ ...engines, buildRaw ] : [ buildRaw, ...engines ] - return askSelect(`Select build isolation mode:`, choices) - } - static selectDeploymentFromStore = async (store: DeployStore & { - root?: Path - }): Promise => { - const label = store.root - ? `Select a deployment from ${store.root.shortPath}:` - : `Select a deployment:` - return await askSelect(label, [ - [...store.keys()].map(title=>({ title, value: title })), - { title: '(cancel)', value: undefined } - ]) - } -} - -const NOT_INSTALLED = 'not installed' - -export const toolVersions = ({ isMac }: { isMac?: boolean } = {}) => ({ - ttyIn: check('TTY in: ', !!process.stdin.isTTY), - ttyOut: check('TTY out: ', !!process.stdout.isTTY), - //console.log(' ', bold('Fadroma:'), String(pkg.version).trim()) - git: tool('Git ', 'git --no-pager --version'), - node: tool('Node ', 'node --version'), - npm: tool('NPM ', 'npm --version'), - yarn: tool('Yarn ', 'yarn --version'), - pnpm: tool('PNPM ', 'pnpm --version'), - corepack: tool('corepack ', 'corepack --version'), - tsc: undefined,//tool('TSC ', 'tsc --version'), - cargo: tool('Cargo ', 'cargo --version'), - rust: tool('Rust ', 'rustc --version'), - sha256sum: tool('sha256sum', 'sha256sum --version'), - wasmOpt: tool('wasm-opt ', 'wasm-opt --version'), - docker: tool('Docker ', 'docker --version'), - podman: tool('Podman ', 'podman --version'), - nix: tool('Nix ', 'nix --version'), - secretcli: tool('secretcli', 'secretcli version'), - homebrew: isMac ? tool('homebrew ', 'brew --version') : undefined, - _: console.br() || undefined - }) - -/** Check a variable */ -export const check = (name: string|null, value: T): T => { - if (name) console.info(bold(name), value) - return value -} - -/** Check if an external binary is on the PATH. */ -export const tool = (dependency: string|null, command: string): string|null => { - let version = null - try { - version = String(execSync(command)).trim().split('\n')[0] - if (dependency) console.info(bold(dependency), version) - } catch (e) { - if (dependency) console.warn(bold(dependency), colors.yellow('(not found)')) - } finally { - return version - } -} - -export async function askText ( - message: string, - valid = (x: string) => clean(x).length > 0, - clean = (x: string) => x.trim() -) { - while (true) { - const input = await prompts.prompt({ type: 'text', name: 'value', message }) - if ('value' in input) { - if (valid(input.value)) return clean(input.value) - } else { - console.error('Input cancelled.') - process.exit(1) - } - } -} - -export async function askSelect (message: string, choices: any[]) { - const input = await prompts.prompt({ type: 'select', name: 'value', message, choices }) - if ('value' in input) return input.value - console.error('Input cancelled.') - process.exit(1) -} - -export async function askUntilDone ( - state: S, selector: (state: S)=>Promise|Function|null -) { - let action = null - while (typeof (action = await Promise.resolve(selector(state))) === 'function') { - await Promise.resolve(action(state)) - } - return state -}