import axios from "axios"; import { conventionalAppSubpath, DeviceParams, reverseModelMap, } from "@ledgerhq/speculos-transport"; import { SpeculosDevice } from "./speculos"; import https from "https"; import { sanitizeError } from "./index"; import { v4 as uuid } from "uuid"; const { GITHUB_TOKEN, SPECULOS_IMAGE_TAG } = process.env; const GIT_API_URL = "https://api.github.com/repos/LedgerHQ/actions/actions/"; const START_WORKFLOW_ID = "workflows/161487603/dispatches"; const STOP_WORKFLOW_ID = "workflows/161487604/dispatches"; const GITHUB_REF = "main"; const getSpeculosAddress = (runId: string) => `https://${runId}.speculos.aws.stg.ldg-tech.com`; const speculosPort = 443; function uniqueId(): string { return uuid(); } function slugify(name: string): string { return name .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); } /** * Helper function to make API requests with error handling */ async function githubApiRequest({ method = "POST", urlSuffix, data, params, }: { method?: "GET" | "POST"; urlSuffix: string; data?: Record; params?: Record; }): Promise { const url = `${GIT_API_URL}${urlSuffix}`; try { const response = await axios({ method, url, headers: { Authorization: `Bearer ${GITHUB_TOKEN}`, Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", }, data, params, }); return response.data; } catch (error) { console.warn(`API Request failed: ${method} ${url}`, sanitizeError(error)); throw sanitizeError(error); } } export function waitForSpeculosReady( deviceId: string, { interval = 2_000, timeout = 150_000 } = {}, ) { return new Promise((resolve, reject) => { const startTime = Date.now(); let currentRequest: ReturnType | null = null; const url = getSpeculosAddress(deviceId); function cleanup() { if (currentRequest) { currentRequest.destroy(); currentRequest = null; } } function check() { cleanup(); currentRequest = https.get(url, { timeout: 10000 }, res => { if (res.statusCode && res.statusCode >= 200 && res.statusCode < 400) { process.env.SPECULOS_ADDRESS = url; cleanup(); console.warn(`Speculos is ready at ${url}`); resolve(true); } else { console.warn(`Speculos not ready yet, status: ${res.statusCode}`); retry(); } }); currentRequest.on("error", error => { console.error(`Request error: ${error.message}`); retry(); }); currentRequest.on("timeout", () => { console.error("Request timeout"); retry(); }); } function retry() { if (Date.now() - startTime >= timeout) { cleanup(); reject(new Error(`Timeout: ${url} did not become available within ${timeout}ms`)); } else { setTimeout(check, interval); } } check(); }); } function createStartPayload(deviceParams: DeviceParams, runId: string) { const { model, firmware, appName, appVersion, dependencies } = deviceParams; let additional_args = "-p"; if (dependencies?.length) { additional_args = [ additional_args, ...new Set( dependencies.map( dep => `-l ${dep.name}:/apps/${conventionalAppSubpath( model, firmware, dep.name, dep.appVersion ?? appVersion, )}`, ), ), ].join(" "); } return { ref: GITHUB_REF, inputs: { speculos_version: SPECULOS_IMAGE_TAG?.split(":")[1] || "master", coin_app: appName, coin_app_version: appVersion, device: reverseModelMap[model], device_os_version: firmware, run_id: runId, additional_args, }, }; } export async function createSpeculosDeviceCI( deviceParams: DeviceParams, ): Promise { const runId = `${slugify(deviceParams.appName)}-${uniqueId()}`; try { const data = createStartPayload(deviceParams, runId); await githubApiRequest({ urlSuffix: START_WORKFLOW_ID, data }); return { id: runId, port: speculosPort, appName: deviceParams.appName, appVersion: deviceParams.appVersion, dependencies: deviceParams.dependencies, }; } catch (error) { console.warn( `Failed to create remote Speculos ${deviceParams.appName}:${deviceParams.appVersion}:`, sanitizeError(error), ); return { id: runId, port: 0, appName: deviceParams.appName, appVersion: deviceParams.appVersion, dependencies: deviceParams.dependencies, }; } } export async function releaseSpeculosDeviceCI(runId: string) { const data = { ref: GITHUB_REF, inputs: { run_id: runId.toString(), }, }; try { await githubApiRequest({ urlSuffix: STOP_WORKFLOW_ID, data }); } catch (error) { console.warn(`Failed to release remote Speculos ${runId}:`, sanitizeError(error)); } }