gui/modules/desktop/electron/libs/cli.ts
2023-04-24 09:15:51 +08:00

198 lines
5.4 KiB
TypeScript

import { spawn, exec } from "child_process";
import { getGuiPath } from "./tea-dir";
import fs from "fs";
import path from "path";
import initializeTeaCli from "./initialize";
import { app } from "electron";
import * as log from "electron-log";
import { MainWindowNotifier } from "./types";
const destinationDirectory = getGuiPath();
export const cliBinPath = path.join(destinationDirectory, "tea");
export async function installPackage(
full_name: string,
version: string,
notifyMainWindow: MainWindowNotifier
) {
const teaVersion = await initializeTeaCli();
const progressNotifier = newInstallProgressNotifier(full_name, notifyMainWindow);
if (!teaVersion) throw new Error("no tea");
const qualifedPackage = `${full_name}@${version}`;
log.info(`installing package ${qualifedPackage}`);
let stdout = "";
let stderr = "";
await new Promise((resolve, reject) => {
// tea requires HOME to be set.
const opts = { env: { HOME: app.getPath("home"), NO_COLOR: "1" } };
const child = spawn(
`${destinationDirectory}/tea`,
["--env=false", "--sync", "--json", `+${qualifedPackage}`],
opts
);
child.stdout.on("data", (data) => {
stdout += data.toString().trim();
});
child.stderr.on("data", (data) => {
try {
const msg = JSON.parse(data.toString().trim());
progressNotifier(msg);
} catch (err) {
//swallow it
}
stderr += data.toString().trim();
});
child.on("exit", (code) => {
console.log("stdout:", stdout);
if (code !== 0) {
reject(new Error("tea exited with non-zero code: " + code));
} else {
resolve(null);
}
});
child.on("error", () => {
reject(new Error(stderr));
});
});
}
// This is hacky and kind of complex because of the output we get from the CLI. When the CLI
// gives better output this definitely should get looked at.
function newInstallProgressNotifier(full_name: string, notifyMainWindow: MainWindowNotifier) {
// the install progress is super spammy, only send every 10th update
let counter = 0;
// the totall number of packages to install - this is set by the "resolved" message
let numberOfPackages = 1;
// the current package number - this is incremented by the "installed" or "downloaded" message
let currentPackageNumber = 0;
return function (msg: any) {
if (msg.status !== "downloading") {
log.info("cli:", msg);
}
if (msg.status === "resolved") {
numberOfPackages = msg.pkgs?.length ?? 1;
log.info(`installing ${numberOfPackages} packages`);
} else if (msg.status === "downloading") {
counter++;
if (counter % 10 !== 0) return;
const { received = 0, "content-size": contentSize = 0 } = msg;
if (contentSize > 0) {
// how many total pacakges are completed
const overallProgress = (currentPackageNumber / numberOfPackages) * 100;
// how much of the current package is completed
const packageProgress = (received / contentSize) * 100;
// progress is the total packages completed plus the percentage of the current package
const progress = (overallProgress + packageProgress / numberOfPackages).toFixed(2);
notifyMainWindow("install-progress", { full_name, progress });
}
} else if (msg.status === "installed") {
currentPackageNumber++;
const progress = ((currentPackageNumber / numberOfPackages) * 100).toFixed(2);
notifyMainWindow("install-progress", { full_name, progress });
}
};
}
export async function openTerminal(cmd: string) {
let scriptPath = "";
try {
// TODO SECURITY: escape the cmd if possible or create whitelist of acceptable commands
scriptPath = await createCommandScriptFile(cmd);
if (!scriptPath) throw new Error("unable to create Applse Script");
let stdout = ``;
let stderr = ``;
await new Promise((resolve, reject) => {
const child = spawn("/usr/bin/osascript", [scriptPath]);
child.stdout.on("data", (data) => {
stdout += data.toString().trim();
});
child.stderr.on("data", (data) => {
stderr += data.toString().trim();
});
child.on("exit", () => {
console.log("exit:", stdout);
resolve(stdout);
});
child.on("error", () => {
reject(new Error(stderr));
});
});
} catch (error) {
log.error("openTerminal:", error);
} finally {
if (scriptPath) await fs.unlinkSync(scriptPath);
}
}
const createCommandScriptFile = async (cmd: string): Promise<string> => {
try {
const guiFolder = getGuiPath();
const tmpFilePath = path.join(guiFolder, `${+new Date()}.scpt`);
const command = `"${cmd.replace(/"/g, '\\"')}"`;
const script = `
tell application "iTerm"
activate
if application "iTerm" is running then
try
tell the first window to create tab with default profile
on error
create window with default profile
end try
end if
delay 0.1
tell the first window to tell current session to write text ${command}
end tell
`.trim();
await fs.writeFileSync(tmpFilePath, script, "utf-8");
return tmpFilePath;
} catch (error) {
log.error(error);
return "";
}
};
export async function asyncExec(cmd: string): Promise<string> {
return new Promise((resolve, reject) => {
exec(cmd, (err, stdout) => {
if (err) {
console.log("err:", err);
reject(err);
return;
}
console.log("stdout:", stdout);
resolve(stdout);
});
});
}
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`);
}