From 4818b2a0a5fcb1c98dfa4a37c4011c59fda06b42 Mon Sep 17 00:00:00 2001 From: Joshua Date: Thu, 19 Feb 2026 12:45:51 -0800 Subject: [PATCH] Add resilient entitlement seeding and control-plane e2e smoke --- README.md | 20 ++ deploy/README.md | 6 + deploy/entitlement-deploy.sepolia.json | 13 + deploy/membership-deploy.sepolia.json | 11 +- deploy/runtime-addresses.base-sepolia.json | 13 + package.json | 3 +- scripts/deploy-entitlement.cjs | 140 ++++++++- scripts/e2e-control-plane-flow.cjs | 333 +++++++++++++++++++++ 8 files changed, 520 insertions(+), 19 deletions(-) create mode 100644 deploy/entitlement-deploy.sepolia.json create mode 100644 deploy/runtime-addresses.base-sepolia.json create mode 100644 scripts/e2e-control-plane-flow.cjs diff --git a/README.md b/README.md index 579a1f1..9b505bf 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,10 @@ Copy `.env.example` values into your shell/session before deploy: 11. `ENTITLEMENT_DEPLOY_OUTPUT_PATH` (optional) 12. `OFFERS_JSON` (optional path to per-offer seed config JSON) 13. `OFFERS_INLINE_JSON` (optional inline JSON array alternative to `OFFERS_JSON`) +14. `SEED_RETRIES` (optional, default `2`) +15. `SEED_RETRY_DELAY_MS` (optional, default `1200`) +16. `SEED_ONLY` (optional, `true` attaches to an existing entitlement contract and only seeds offers) +17. `ENTITLEMENT_CONTRACT_ADDRESS` (required when `SEED_ONLY=true`) `update:membership:price:*` requires: @@ -78,6 +82,14 @@ Smoke flow optional vars: 1. `E2E_IDENTITY_ASSURANCE_LEVEL` 2. `E2E_IDENTITY_ATTESTED_BY` 3. `E2E_IDENTITY_ATTESTATION_ID` +4. `E2E_OFFER_ID` +5. `E2E_ORG_ROOT_ID` +6. `E2E_PRINCIPAL_ID` +7. `E2E_PRINCIPAL_ROLE` +8. `E2E_WORKSPACE_ID` +9. `E2E_DEVICE_ID` +10. `E2E_PLATFORM` +11. `E2E_LAUNCHER_VERSION` Example (Sepolia): @@ -87,6 +99,14 @@ export $(grep -v '^#' .env | xargs) npm run deploy:sepolia ``` +Full control-plane smoke (membership + marketplace + governance install/status): + +```bash +cd /Users/vsg/Documents/VSG\ Codex/contracts +export $(grep -v '^#' .env | xargs) +npm run smoke:e2e:controlplane:sepolia +``` + ## Boundary Contracts are settlement primitives. Runtime execution remains off-chain and fail-closed by entitlement state. diff --git a/deploy/README.md b/deploy/README.md index 1a0bcf6..e61beb5 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -13,6 +13,7 @@ Template: - `membership-deploy.template.json` - `entitlement-deploy.template.json` - `offers.template.json` +- `runtime-addresses.base-sepolia.json` (runtime-wired snapshot for cross-repo address parity) Recommended process: @@ -22,3 +23,8 @@ Recommended process: 3. Offer override files may include non-contract metadata (for example `execution_profile`) for downstream catalog parity; deploy script ignores unknown keys and only applies on-chain fields. 3. Fill all deployment fields from script output and explorer links. 4. If you need per-offer pricing, copy `offers.template.json`, edit values, and pass it via `OFFERS_JSON=/path/to/file.json`. + +Address parity rule: + +1. Keep `runtime-addresses.base-sepolia.json` synchronized with `/Users/vsg/Documents/VSG Codex/web/docs/deployment/contract-addresses.base-sepolia.json`. +2. Any runtime address change must update both repos in the same change set. diff --git a/deploy/entitlement-deploy.sepolia.json b/deploy/entitlement-deploy.sepolia.json new file mode 100644 index 0000000..c6bbe35 --- /dev/null +++ b/deploy/entitlement-deploy.sepolia.json @@ -0,0 +1,13 @@ +{ + "network": "baseSepolia", + "chainId": 84532, + "deployer": "0xD148d4dFA882007e5226C90287622b3Af6eB56D7", + "treasury": "0xD148d4dFA882007e5226C90287622b3Af6eB56D7", + "paymentToken": "0x0000000000000000000000000000000000000000", + "membershipContract": "0x3EEb3342751D1Cfc0F90C9393e0B1cd5AcE6FfD8", + "offerPriceAtomic": "1000000000", + "entitlementContract": "0xA1c06066206d0ea63a77A093FD38327Fd5663a43", + "txHash": null, + "source": "web/docs/deployment/contract-addresses.base-sepolia.json", + "notes": "Offer seeding must be verified with deploy + readback smoke before checkout is enabled." +} diff --git a/deploy/membership-deploy.sepolia.json b/deploy/membership-deploy.sepolia.json index b1705b6..9b39fe3 100644 --- a/deploy/membership-deploy.sepolia.json +++ b/deploy/membership-deploy.sepolia.json @@ -3,9 +3,10 @@ "chainId": 84532, "deployer": "0xD148d4dFA882007e5226C90287622b3Af6eB56D7", "treasury": "0xD148d4dFA882007e5226C90287622b3Af6eB56D7", - "mintCurrency": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", - "mintAmountAtomic": "100000000", - "membershipContract": "0x51C3c35532f395B1555CF5b9d9850909365d0a4f", - "txHash": "0x048ef7fd5444b3fb66a063aa24702c6fbbaea8e3469d618971c51a0ceb36af86", - "mintPriceUpdatedTxHash": "0x5df32570f674e6e46d9a2907e26b70205c01ad1d68899ddd75102bfc9764f69c" + "mintCurrency": "0x0000000000000000000000000000000000000000", + "mintAmountAtomic": "1", + "membershipContract": "0x3EEb3342751D1Cfc0F90C9393e0B1cd5AcE6FfD8", + "txHash": null, + "source": "api.edut.dev runtime environment", + "notes": "Runtime-wired Sepolia snapshot for cross-repo parity; regenerate from deploy output when redeploying." } diff --git a/deploy/runtime-addresses.base-sepolia.json b/deploy/runtime-addresses.base-sepolia.json new file mode 100644 index 0000000..6b390e5 --- /dev/null +++ b/deploy/runtime-addresses.base-sepolia.json @@ -0,0 +1,13 @@ +{ + "network": "base-sepolia", + "chain_id": 84532, + "membership_contract": "0x3EEb3342751D1Cfc0F90C9393e0B1cd5AcE6FfD8", + "entitlement_contract": "0xA1c06066206d0ea63a77A093FD38327Fd5663a43", + "offer_registry_contract": "0xA1c06066206d0ea63a77A093FD38327Fd5663a43", + "treasury_wallet": "0xD148d4dFA882007e5226C90287622b3Af6eB56D7", + "mint_currency_mode": "ETH_TEST", + "mint_amount_atomic": "1", + "usdc_contract": "0x0000000000000000000000000000000000000000", + "source": "api.edut.dev runtime environment", + "version": "v1" +} diff --git a/package.json b/package.json index 3a3a529..aed9d79 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "update:membership:price:mainnet": "hardhat run scripts/update-membership-price.cjs --network base", "deploy:entitlement:sepolia": "hardhat run scripts/deploy-entitlement.cjs --network baseSepolia", "deploy:entitlement:mainnet": "hardhat run scripts/deploy-entitlement.cjs --network base", - "smoke:e2e:sepolia": "node scripts/e2e-membership-flow.cjs" + "smoke:e2e:sepolia": "node scripts/e2e-membership-flow.cjs", + "smoke:e2e:controlplane:sepolia": "node scripts/e2e-control-plane-flow.cjs" }, "devDependencies": { "@nomiclabs/hardhat-ethers": "^2.2.3", diff --git a/scripts/deploy-entitlement.cjs b/scripts/deploy-entitlement.cjs index 5241b3f..bd24846 100644 --- a/scripts/deploy-entitlement.cjs +++ b/scripts/deploy-entitlement.cjs @@ -18,6 +18,26 @@ function requiredEnv(name) { return value.trim(); } +function envInt(name, fallback) { + const raw = (process.env[name] || "").trim(); + if (!raw) { + return fallback; + } + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error(`Invalid integer for ${name}: ${raw}`); + } + return parsed; +} + +function envBool(name, fallback = false) { + const raw = (process.env[name] || "").trim().toLowerCase(); + if (!raw) { + return fallback; + } + return raw === "1" || raw === "true" || raw === "yes" || raw === "on"; +} + function requireAddress(name, value, { allowZero = false } = {}) { if (!hre.ethers.utils.isAddress(value)) { throw new Error(`Invalid address for ${name}: ${value}`); @@ -96,6 +116,65 @@ function buildOfferPlan(defaultPriceAtomic) { }); } +async function sleep(ms) { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +function normalizeOfferConfig(configResult) { + return { + priceAtomic: configResult.priceAtomic.toString(), + active: Boolean(configResult.active), + membershipRequired: Boolean(configResult.membershipRequired), + exists: Boolean(configResult.exists), + }; +} + +function offerConfigMatches(current, expected) { + return ( + current.exists && + current.priceAtomic === expected.priceAtomic.toString() && + current.active === expected.active && + current.membershipRequired === expected.membershipRequired + ); +} + +async function upsertOfferWithRetry(contract, offer, retries, retryDelayMs) { + let lastError = null; + for (let attempt = 1; attempt <= retries + 1; attempt += 1) { + try { + const tx = await contract.upsertOffer( + offer.offerID, + offer.priceAtomic, + offer.active, + offer.membershipRequired + ); + const receipt = await tx.wait(); + const cfg = normalizeOfferConfig(await contract.offerConfig(offer.offerID)); + if (!offerConfigMatches(cfg, offer)) { + throw new Error( + `post-upsert verification mismatch for ${offer.offerID}: got=${JSON.stringify(cfg)}` + ); + } + return { + status: "seeded", + attempts: attempt, + txHash: receipt.transactionHash, + }; + } catch (err) { + lastError = err; + if (attempt > retries) { + break; + } + const delay = retryDelayMs * attempt; + console.warn( + `offer_seed_retry: ${offer.offerID} attempt=${attempt} delay_ms=${delay} err=${err.message}` + ); + await sleep(delay); + } + } + throw new Error(`failed to seed ${offer.offerID}: ${lastError?.message || "unknown error"}`); +} + async function main() { const treasury = requireAddress( "ENTITLEMENT_TREASURY_WALLET", @@ -114,6 +193,10 @@ async function main() { "OFFER_PRICE_ATOMIC", (process.env.OFFER_PRICE_ATOMIC || "1000000000").trim(), ); + const seedRetries = envInt("SEED_RETRIES", 2); + const seedRetryDelayMs = envInt("SEED_RETRY_DELAY_MS", 1200); + const seedOnly = envBool("SEED_ONLY", false); + const existingEntitlementContract = (process.env.ENTITLEMENT_CONTRACT_ADDRESS || "").trim(); const [deployer] = await hre.ethers.getSigners(); console.log("deployer:", deployer.address); @@ -124,26 +207,53 @@ async function main() { console.log("offer_price_atomic:", offerPriceAtomic.toString()); const factory = await hre.ethers.getContractFactory("EdutOfferEntitlement"); - const contract = await factory.deploy(treasury, paymentToken, membershipContract); - await contract.deployed(); - console.log("entitlement_contract:", contract.address); + let contract; + let deployTxHash = null; + if (seedOnly) { + if (!existingEntitlementContract) { + throw new Error("SEED_ONLY=true requires ENTITLEMENT_CONTRACT_ADDRESS"); + } + requireAddress("ENTITLEMENT_CONTRACT_ADDRESS", existingEntitlementContract); + contract = factory.attach(existingEntitlementContract); + console.log("entitlement_contract_attached:", contract.address); + } else { + contract = await factory.deploy(treasury, paymentToken, membershipContract); + await contract.deployed(); + deployTxHash = contract.deployTransaction.hash; + console.log("entitlement_contract:", contract.address); + } const offerPlan = buildOfferPlan(offerPriceAtomic); const seededOffers = []; for (const offer of offerPlan) { - const tx = await contract.upsertOffer( - offer.offerID, - offer.priceAtomic, - offer.active, - offer.membershipRequired - ); - const receipt = await tx.wait(); + const current = normalizeOfferConfig(await contract.offerConfig(offer.offerID)); + if (offerConfigMatches(current, offer)) { + seededOffers.push({ + offerID: offer.offerID, + priceAtomic: offer.priceAtomic.toString(), + active: offer.active, + membershipRequired: offer.membershipRequired, + status: "already_aligned", + attempts: 0, + }); + console.log( + "offer_already_aligned:", + offer.offerID, + offer.priceAtomic.toString(), + offer.active, + offer.membershipRequired + ); + continue; + } + const seeded = await upsertOfferWithRetry(contract, offer, seedRetries, seedRetryDelayMs); seededOffers.push({ offerID: offer.offerID, priceAtomic: offer.priceAtomic.toString(), active: offer.active, membershipRequired: offer.membershipRequired, - txHash: receipt.transactionHash, + status: seeded.status, + attempts: seeded.attempts, + txHash: seeded.txHash, }); console.log( "offer_seeded:", @@ -151,7 +261,8 @@ async function main() { offer.priceAtomic.toString(), offer.active, offer.membershipRequired, - receipt.transactionHash + seeded.txHash, + `attempts=${seeded.attempts}` ); } @@ -165,7 +276,10 @@ async function main() { offerPriceAtomic: offerPriceAtomic.toString(), defaultOfferPriceAtomic: offerPriceAtomic.toString(), entitlementContract: contract.address, - txHash: contract.deployTransaction.hash, + txHash: deployTxHash, + seedOnly, + seedRetries, + seedRetryDelayMs, seededOffers, }; diff --git a/scripts/e2e-control-plane-flow.cjs b/scripts/e2e-control-plane-flow.cjs new file mode 100644 index 0000000..13953aa --- /dev/null +++ b/scripts/e2e-control-plane-flow.cjs @@ -0,0 +1,333 @@ +const { ethers } = require("ethers"); + +const API_BASE = (process.env.SECRET_API_BASE_URL || "https://api.edut.dev").trim(); +const ORIGIN = (process.env.SECRET_API_ORIGIN || "https://edut.ai").trim(); +const LOCALE = (process.env.SECRET_API_LOCALE || "en-US").trim(); +const CHAIN_ID = Number((process.env.SECRET_API_CHAIN_ID || "84532").trim()); +const PRIVATE_KEY = (process.env.DEPLOYER_PRIVATE_KEY || "").trim(); +const RPC_URL = (process.env.BASE_SEPOLIA_RPC_URL || "").trim(); +const GAS_PRICE_WEI = (process.env.E2E_GAS_PRICE_WEI || "").trim(); + +const OFFER_ID = (process.env.E2E_OFFER_ID || "edut.workspace.core").trim(); +const ORG_ROOT_ID = (process.env.E2E_ORG_ROOT_ID || "org.e2e.workspace").trim(); +const PRINCIPAL_ID = (process.env.E2E_PRINCIPAL_ID || "principal.e2e.owner").trim(); +const PRINCIPAL_ROLE = (process.env.E2E_PRINCIPAL_ROLE || "org_root_owner").trim(); +const WORKSPACE_ID = (process.env.E2E_WORKSPACE_ID || "workspace.e2e.alpha").trim(); +const DEVICE_ID = (process.env.E2E_DEVICE_ID || "device-e2e-001").trim(); +const PLATFORM = (process.env.E2E_PLATFORM || "macos").trim(); +const LAUNCHER_VERSION = (process.env.E2E_LAUNCHER_VERSION || "0.0.0-e2e").trim(); + +const IDENTITY_ASSURANCE = (process.env.E2E_IDENTITY_ASSURANCE_LEVEL || "").trim(); +const IDENTITY_ATTESTED_BY = (process.env.E2E_IDENTITY_ATTESTED_BY || "").trim(); +const IDENTITY_ATTESTATION_ID = (process.env.E2E_IDENTITY_ATTESTATION_ID || "").trim(); + +function required(name, value) { + if (!value) { + throw new Error(`Missing required env: ${name}`); + } + return value; +} + +function requestHeaders(sessionToken) { + const headers = { "content-type": "application/json" }; + if (sessionToken) { + headers.authorization = `Bearer ${sessionToken}`; + headers["x-edut-session"] = sessionToken; + } + return headers; +} + +async function requestJSON(method, path, payload, sessionToken) { + const response = await fetch(`${API_BASE}${path}`, { + method, + headers: requestHeaders(sessionToken), + body: payload === undefined ? undefined : JSON.stringify(payload), + }); + const text = await response.text(); + let data; + try { + data = text ? JSON.parse(text) : {}; + } catch (err) { + throw new Error(`${method} ${path} returned non-json response: ${text}`); + } + return { + ok: response.ok, + status: response.status, + data, + }; +} + +async function expectOK(method, path, payload, sessionToken) { + const result = await requestJSON(method, path, payload, sessionToken); + if (!result.ok) { + throw new Error(`${method} ${path} failed (${result.status}): ${JSON.stringify(result.data)}`); + } + return result.data; +} + +async function submitTransaction(signer, txLike) { + const txRequest = { + to: txLike.to, + data: txLike.data, + value: ethers.BigNumber.from(txLike.value || "0x0"), + }; + if (GAS_PRICE_WEI) { + txRequest.gasPrice = ethers.BigNumber.from(GAS_PRICE_WEI); + } + const tx = await signer.sendTransaction(txRequest); + await tx.wait(1); + return tx.hash; +} + +function summarizeSkip(reasonCode, httpStatus, detail) { + return { + status: "skipped", + reason_code: reasonCode || "unknown", + http_status: httpStatus, + detail, + }; +} + +async function main() { + required("DEPLOYER_PRIVATE_KEY", PRIVATE_KEY); + required("BASE_SEPOLIA_RPC_URL", RPC_URL); + + const provider = new ethers.providers.JsonRpcProvider(RPC_URL); + const signer = new ethers.Wallet(PRIVATE_KEY, provider); + const wallet = await signer.getAddress(); + + const result = { + api_base: API_BASE, + wallet, + chain_id: CHAIN_ID, + stages: {}, + }; + + const statusBefore = await expectOK( + "GET", + `/secret/membership/status?wallet=${encodeURIComponent(wallet)}`, + undefined, + null + ); + result.stages.membership_status_before = statusBefore; + + const intent = await expectOK("POST", "/secret/wallet/intent", { + address: wallet, + origin: ORIGIN, + locale: LOCALE, + chain_id: CHAIN_ID, + }); + + const domain = { + name: intent.domain_name, + version: "1", + chainId: intent.chain_id, + verifyingContract: intent.verifying_contract, + }; + const types = { + DesignationIntent: [ + { name: "designationCode", type: "string" }, + { name: "designationToken", type: "string" }, + { name: "nonce", type: "string" }, + { name: "issuedAt", type: "string" }, + { name: "origin", type: "string" }, + ], + }; + const message = { + designationCode: intent.designation_code, + designationToken: intent.display_token, + nonce: intent.nonce, + issuedAt: intent.issued_at, + origin: ORIGIN, + }; + const signature = await signer._signTypedData(domain, types, message); + + const verify = await expectOK("POST", "/secret/wallet/verify", { + intent_id: intent.intent_id, + address: wallet, + chain_id: CHAIN_ID, + signature, + }); + const sessionToken = verify.session_token; + result.stages.wallet_session = { + status: verify.status, + expires_at: verify.session_expires_at, + }; + + if ((statusBefore.status || "").toLowerCase() !== "active") { + const quote = await expectOK("POST", "/secret/membership/quote", { + designation_code: intent.designation_code, + address: wallet, + chain_id: CHAIN_ID, + }); + + const membershipTxHash = await submitTransaction(signer, quote.tx || {}); + const confirmPayload = { + designation_code: intent.designation_code, + quote_id: quote.quote_id, + tx_hash: membershipTxHash, + address: wallet, + chain_id: CHAIN_ID, + }; + if (IDENTITY_ASSURANCE) { + confirmPayload.identity_assurance_level = IDENTITY_ASSURANCE; + } + if (IDENTITY_ATTESTED_BY) { + confirmPayload.identity_attested_by = IDENTITY_ATTESTED_BY; + } + if (IDENTITY_ATTESTATION_ID) { + confirmPayload.identity_attestation_id = IDENTITY_ATTESTATION_ID; + } + + const confirm = await expectOK("POST", "/secret/membership/confirm", confirmPayload); + result.stages.membership_activation = { + status: confirm.status, + tx_hash: membershipTxHash, + identity_assurance_level: confirm.identity_assurance_level || null, + }; + } else { + result.stages.membership_activation = { + status: "skipped_already_active", + }; + } + + const statusAfter = await expectOK( + "GET", + `/secret/membership/status?wallet=${encodeURIComponent(wallet)}`, + undefined, + null + ); + result.stages.membership_status_after = statusAfter; + + const offers = await expectOK("GET", "/marketplace/offers", undefined, sessionToken); + const targetOffer = (offers.offers || []).find((offer) => offer.offer_id === OFFER_ID); + if (!targetOffer) { + throw new Error(`marketplace offer not found: ${OFFER_ID}`); + } + + const checkoutQuoteRes = await requestJSON( + "POST", + "/marketplace/checkout/quote", + { + wallet, + offer_id: OFFER_ID, + org_root_id: ORG_ROOT_ID, + principal_id: PRINCIPAL_ID, + principal_role: PRINCIPAL_ROLE, + workspace_id: WORKSPACE_ID, + include_membership_if_missing: false, + }, + sessionToken + ); + + if (!checkoutQuoteRes.ok) { + result.stages.marketplace_checkout = summarizeSkip( + checkoutQuoteRes.data.code, + checkoutQuoteRes.status, + checkoutQuoteRes.data.error + ); + } else { + const checkoutQuote = checkoutQuoteRes.data; + const txData = String((checkoutQuote.tx || {}).data || "").toLowerCase(); + if (!txData || txData === "0x") { + result.stages.marketplace_checkout = summarizeSkip( + "entitlement_transaction_unconfigured", + 200, + "checkout quote returned empty calldata" + ); + } else { + const checkoutTxHash = await submitTransaction(signer, checkoutQuote.tx || {}); + const checkoutConfirm = await expectOK( + "POST", + "/marketplace/checkout/confirm", + { + quote_id: checkoutQuote.quote_id, + wallet, + offer_id: OFFER_ID, + org_root_id: ORG_ROOT_ID, + principal_id: PRINCIPAL_ID, + principal_role: PRINCIPAL_ROLE, + workspace_id: WORKSPACE_ID, + tx_hash: checkoutTxHash, + chain_id: CHAIN_ID, + }, + sessionToken + ); + const entitlements = await expectOK( + "GET", + `/marketplace/entitlements?wallet=${encodeURIComponent(wallet)}`, + undefined, + sessionToken + ); + result.stages.marketplace_checkout = { + status: checkoutConfirm.status, + entitlement_id: checkoutConfirm.entitlement_id, + tx_hash: checkoutTxHash, + entitlement_count: Array.isArray(entitlements.entitlements) + ? entitlements.entitlements.length + : 0, + }; + } + } + + const installTokenRes = await requestJSON( + "POST", + "/governance/install/token", + { + wallet, + org_root_id: ORG_ROOT_ID, + principal_id: PRINCIPAL_ID, + principal_role: PRINCIPAL_ROLE, + device_id: DEVICE_ID, + launcher_version: LAUNCHER_VERSION, + platform: PLATFORM, + }, + sessionToken + ); + + if (!installTokenRes.ok) { + result.stages.governance_install = summarizeSkip( + installTokenRes.data.code, + installTokenRes.status, + installTokenRes.data.error + ); + } else { + const installToken = installTokenRes.data; + const installConfirm = await expectOK( + "POST", + "/governance/install/confirm", + { + install_token: installToken.install_token, + wallet, + device_id: DEVICE_ID, + entitlement_id: installToken.entitlement_id, + package_hash: installToken.package.package_hash, + runtime_version: installToken.package.runtime_version, + installed_at: new Date().toISOString(), + }, + sessionToken + ); + + const installStatus = await expectOK( + "GET", + `/governance/install/status?wallet=${encodeURIComponent(wallet)}&device_id=${encodeURIComponent( + DEVICE_ID + )}`, + undefined, + sessionToken + ); + result.stages.governance_install = { + status: installConfirm.status, + runtime_version: installConfirm.runtime_version, + activation_status: installStatus.activation_status, + availability_state: installStatus.availability_state, + }; + } + + console.log(JSON.stringify(result, null, 2)); +} + +main().catch((err) => { + console.error(err.message || err); + process.exit(1); +});