diff --git a/modules/desktop/electron/libs/cli.ts b/modules/desktop/electron/libs/cli.ts index 4cc3ebb..34193d0 100644 --- a/modules/desktop/electron/libs/cli.ts +++ b/modules/desktop/electron/libs/cli.ts @@ -1,16 +1,14 @@ import { spawn, exec } from "child_process"; -import { getGuiPath } from "./tea-dir"; +import { getGuiPath, getTeaPath } from "./tea-dir"; import fs from "fs"; import path from "path"; -import initializeTeaCli from "./initialize"; +import { initializeTeaCli } from "./initialize"; import { app } from "electron"; import log from "./logger"; import { MainWindowNotifier } from "./types"; -const destinationDirectory = getGuiPath(); - -export const cliBinPath = path.join(destinationDirectory, "tea"); +export const cliBinPath = path.join(getTeaPath(), "tea.xyz/v*/bin/tea"); export async function installPackage( full_name: string, @@ -34,7 +32,7 @@ export async function installPackage( const opts = { env: { HOME: app.getPath("home"), NO_COLOR: "1" } }; const child = spawn( - `${destinationDirectory}/tea`, + cliBinPath, ["--env=false", "--sync", "--json", `+${qualifedPackage}`], opts ); @@ -200,6 +198,6 @@ export async function syncPantry() { const teaVersion = await initializeTeaCli(); if (!teaVersion) throw new Error("no tea"); - log.info("Syncing pantry"); - await asyncExec(`cd '${destinationDirectory}' && ./tea -S`); + log.info("Syncing pantry", teaVersion); + await asyncExec(`DEBUG=1 "${cliBinPath}" --sync --env=false`); } diff --git a/modules/desktop/electron/libs/initialize.ts b/modules/desktop/electron/libs/initialize.ts index d9c70da..d784403 100644 --- a/modules/desktop/electron/libs/initialize.ts +++ b/modules/desktop/electron/libs/initialize.ts @@ -1,78 +1,162 @@ import fs from "fs"; -import { getGuiPath } from "./tea-dir"; +import { getGuiPath, getTeaPath } from "./tea-dir"; import log from "./logger"; -import { cliBinPath, asyncExec } from "./cli"; +// import { cliBinPath, asyncExec } from "./cli"; import { createInitialSessionFile } from "./auth"; -import { SemVer, isValidSemVer } from "@tea/libtea"; +import * as https from "https"; +import { spawn } from "child_process"; +import path from "path"; +import { parse as semverParse } from "@tea/libtea"; -const MINIMUM_TEA_VERSION = "0.31.2"; +type InitState = "NOT_INITIALIZED" | "PENDING" | "INITIALIZED"; -const destinationDirectory = getGuiPath(); +class InitWatcher { + private initState: InitState; + private initFunction: () => Promise; + private initialValue: T | undefined; + private initializationPromise: Promise | undefined; -// TODO: move device_id generation here - -// Get the binary path from the current app directory -const binaryUrl = "https://tea.xyz/$(uname)/$(uname -m)"; - -let initializePromise: Promise | null = null; - -export async function initializeTeaCli(): Promise { - if (initializePromise) { - return initializePromise; + constructor(initFunction: () => Promise) { + this.initState = "NOT_INITIALIZED"; + this.initFunction = initFunction; + this.initialValue = undefined; + this.initializationPromise = undefined; } - log.info("Initializing tea cli"); - initializePromise = initializeTeaCliInternal(); + async initialize(): Promise { + if (this.initState === "NOT_INITIALIZED") { + this.initState = "PENDING"; + this.initializationPromise = this.retryFunction(this.initFunction, 3) + .then((value) => { + this.initialValue = value; + this.initState = "INITIALIZED"; + return value; + }) + .catch((error) => { + this.initState = "NOT_INITIALIZED"; + this.initializationPromise = undefined; + throw error; + }); + } - initializePromise.catch((error) => { - log.info("Error initializing tea cli, resetting promise:", error); - initializePromise = null; - }); - - return initializePromise; -} - -async function initializeTeaCliInternal(): Promise { - let binCheck = ""; - let needsUpdate = false; - - // Create the destination directory if it doesn't exist - if (!fs.existsSync(destinationDirectory)) { - fs.mkdirSync(destinationDirectory, { recursive: true }); + return this.initializationPromise as Promise; } - // replace this with max's pr - const curlCommand = `curl --insecure -L -o "${cliBinPath}" "${binaryUrl}"`; - - const exists = fs.existsSync(cliBinPath); - if (exists) { - log.info("binary tea already exists at", cliBinPath); + async retryFunction(func: () => Promise, retries: number, currentAttempt = 1): Promise { try { - binCheck = await asyncExec(`cd ${destinationDirectory} && ./tea --version`); - const teaVersion = binCheck.toString().split(" ")[1].trim(); - if (new SemVer(teaVersion).compare(new SemVer(MINIMUM_TEA_VERSION)) < 0) { - log.info("binary tea version is too old, updating"); - needsUpdate = true; - } + const result = await func(); + return result; } catch (error) { - // probably binary is not executable or no permission - log.error("Error checking tea binary version:", error); - needsUpdate = true; - await asyncExec(`cd ${destinationDirectory} && rm tea`); + if (currentAttempt < retries) { + return this.retryFunction(func, retries, currentAttempt + 1); + } else { + throw error; + } } } - if (!exists || needsUpdate) { - await asyncExec(curlCommand); - log.info("Binary downloaded and saved to", cliBinPath); - await asyncExec("chmod u+x " + cliBinPath); - log.info("Binary is now ready for use at", cliBinPath); - binCheck = await asyncExec(`cd ${destinationDirectory} && ./tea --version`); + reset(): void { + this.initState = "NOT_INITIALIZED"; + this.initializationPromise = undefined; } - const version = binCheck.toString().split(" ")[1]; - log.info("binary tea version:", version); - return isValidSemVer(version.trim()) ? version : ""; + async observe(): Promise { + return await this.initialize(); + } + + getState(): InitState { + return this.initState; + } +} + +const teaCliPrefix = path.join(getTeaPath(), "tea.xyz/v*"); + +export const cliInitializationState = new InitWatcher(async () => { + if (!fs.existsSync(path.join(teaCliPrefix, "bin/tea"))) { + return installTeaCli(); + } else { + const dir = fs.readlinkSync(teaCliPrefix); + const v = semverParse(dir)?.toString(); + if (!v) throw new Error(`couldn't parse to semver: ${dir}`); + return v; + } +}); + +cliInitializationState.initialize(); + +export async function initializeTeaCli(): Promise { + if ( + cliInitializationState.getState() === "INITIALIZED" && + !fs.existsSync(path.join(teaCliPrefix, "bin/tea")) + ) { + cliInitializationState.reset(); + } + return cliInitializationState.observe(); +} + +//NOTE copy pasta from https://github.com/teaxyz/setup/blob/main/action.js +//FIXME ideally we'd not copy pasta this +//NOTE using `tar` is not ideal ∵ Windows and even though tar is POSIX it's still not guaranteed to be available +async function installTeaCli() { + const PREFIX = `${process.env.HOME}/.tea`; + + const midfix = (() => { + switch (process.arch) { + case "arm64": + return `${process.platform}/aarch64`; + case "x64": + return `${process.platform}/x86-64`; + default: + throw new Error(`unsupported platform: ${process.platform}/${process.arch}`); + } + })(); + + /// versions.txt is guaranteed semver-sorted + const v: string | undefined = await new Promise((resolve, reject) => { + https + .get(`https://dist.tea.xyz/tea.xyz/${midfix}/versions.txt`, (rsp) => { + if (rsp.statusCode != 200) return reject(rsp.statusCode); + rsp.setEncoding("utf8"); + const chunks: string[] = []; + rsp.on("data", (x) => chunks.push(x)); + rsp.on("end", () => { + resolve(chunks.join("").trim().split("\n").at(-1)); + }); + }) + .on("error", reject); + }); + + if (!v) throw new Error(`invalid versions.txt for tea/cli`); + + fs.mkdirSync(PREFIX, { recursive: true }); + + const exitcode = await new Promise((resolve, reject) => { + https + .get(`https://dist.tea.xyz/tea.xyz/${midfix}/v${v}.tar.gz`, (rsp) => { + if (rsp.statusCode != 200) return reject(rsp.statusCode); + const tar = spawn("tar", ["xzf", "-"], { + stdio: ["pipe", "inherit", "inherit"], + cwd: PREFIX + }); + rsp.pipe(tar.stdin); + tar.on("close", resolve); + }) + .on("error", reject); + }); + + if (exitcode != 0) { + throw new Error(`tar: ${exitcode}`); + } + + const oldwd = process.cwd(); + process.chdir(`${PREFIX}/tea.xyz`); + if (fs.existsSync(`v*`)) fs.unlinkSync(`v*`); + fs.symlinkSync(`v${v}`, `v*`, "dir"); + if (fs.existsSync(`v0`)) fs.unlinkSync(`v0`); + fs.symlinkSync(`v${v}`, `v0`, "dir"); //FIXME + process.chdir(oldwd); + + return v; } export default async function initialize(): Promise { diff --git a/modules/desktop/electron/libs/ipc.ts b/modules/desktop/electron/libs/ipc.ts index 5d7ab2d..cdf7ebd 100644 --- a/modules/desktop/electron/libs/ipc.ts +++ b/modules/desktop/electron/libs/ipc.ts @@ -6,7 +6,7 @@ import log from "./logger"; import { syncLogsAt } from "./v1-client"; import { installPackage, openPackageEntrypointInTerminal, syncPantry } from "./cli"; -import initializeTeaCli from "./initialize"; +import { initializeTeaCli, cliInitializationState } from "./initialize"; import { getAutoUpdateStatus, getUpdater } from "./auto-updater"; @@ -142,7 +142,10 @@ export default function initializeHandlers({ notifyMainWindow }: HandlerOptions) await deletePackageFolder(fullName, version); } catch (e) { log.error(e); - return e; + } finally { + if (fullName === "tea.xyz") { + cliInitializationState.reset(); + } } } ); diff --git a/modules/libtea/src/index.ts b/modules/libtea/src/index.ts index 09789d3..20d06fc 100644 --- a/modules/libtea/src/index.ts +++ b/modules/libtea/src/index.ts @@ -1 +1 @@ -export { default as SemVer, isValidSemVer } from "./semver"; +export { default as SemVer, isValidSemVer, parse } from "./semver";