Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions desktop/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "hqterm-desktop",
"productName": "hqterm",
"version": "0.2.2",
"version": "0.2.3",
"description": "hqterm desktop: a terminal window that shows hqtui/qc images and HD emoji (xterm.js + node-pty).",
"license": "MIT",
"author": "Profullstack, Inc. <hello@profullstack.com>",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hqterm",
"version": "0.2.2",
"version": "0.2.3",
"description": "Terminal session manager: persistent hqsh sessions with images and HD emoji, built on hqtui.",
"license": "MIT",
"private": true,
Expand Down
6 changes: 6 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { emojiFontStatus, installEmojiFont, removeEmojiFont } from "@profullstack/hqtui";
import { desktop } from "./desktop.ts";
import { doctor } from "./doctor.ts";
import { update } from "./update.ts";
import { addUserHost, loadUserHosts, mergeHosts, validHostName } from "./hosts.ts";
import { connect, findHqsh, installArgs, INSTALL_URL, pressEnter, runInteractive, connectArgs } from "./run.ts";
import { sshConfigHosts } from "./sshconfig.ts";
Expand All @@ -25,6 +26,8 @@ Usage:
what this terminal supports, with a test image
hqterm fonts install|status|remove
the OpenEmoji colour font for this terminal
hqterm update [--check] [--force]
update hqterm, hqsh and the desktop app
hqterm --version | --help

Keys in the manager:
Expand Down Expand Up @@ -129,6 +132,9 @@ export async function main(argv: string[]): Promise<number> {
return doctor(rest);
case "fonts":
return fonts(rest[0]);
case "update":
case "upgrade":
return update(rest);
default:
console.error(`hqterm: unknown command "${cmd}"\n`);
process.stderr.write(HELP);
Expand Down
86 changes: 86 additions & 0 deletions src/update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* `hqterm update`: fetch the current installer from hqterm.sh and run it, so
* hqterm, hqsh (and the desktop app, when it is installed) move to the latest
* releases. `hqterm update --check` only reports whether one is waiting.
*/
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { findDesktop } from "./desktop.ts";
import { INSTALL_URL, runInteractive } from "./run.ts";
import { VERSION } from "./version.ts";

export const RELEASES_API = "https://api.github.com/repos/profullstack/hqterm/releases/latest";

/** -1, 0 or 1 comparing dotted versions ("0.2.10" > "0.2.9"). */
export function compareVersions(a: string, b: string): number {
const pa = a.replace(/^v/, "").split(".").map((n) => Number.parseInt(n, 10) || 0);
const pb = b.replace(/^v/, "").split(".").map((n) => Number.parseInt(n, 10) || 0);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
if (d) return d > 0 ? 1 : -1;
}
return 0;
}

/** The installer's arguments for this machine: same bin dir, desktop app if installed. */
export function installerArgs(execPath: string, hasDesktop: boolean, extra: string[] = []): string[] {
const args: string[] = [];
// A compiled binary updates in place; under `bun src/main.ts` use the default dir.
if (!/\bbun(\.exe)?$/.test(execPath)) args.push(`--bin=${dirname(execPath)}`);
if (hasDesktop) args.push("--desktop");
return [...args, ...extra];
}

export async function latestVersion(fetchImpl: typeof fetch = fetch): Promise<string | null> {
try {
const res = await fetchImpl(RELEASES_API, { headers: { Accept: "application/vnd.github+json", "User-Agent": "hqterm" } });
if (!res.ok) return null;
const body = (await res.json()) as { tag_name?: string };
return body.tag_name ? body.tag_name.replace(/^v/, "") : null;
} catch {
return null;
}
}

export async function update(args: string[]): Promise<number> {
const check = args.includes("--check");
const latest = await latestVersion();
if (check) {
if (!latest) {
console.error("hqterm: could not reach GitHub to check for updates.");
return 1;
}
if (compareVersions(latest, VERSION) > 0) {
console.log(`hqterm ${VERSION} -> ${latest} is available. Run: hqterm update`);
return 0;
}
console.log(`hqterm ${VERSION} is the latest.`);
return 0;
}
if (latest && compareVersions(latest, VERSION) <= 0 && !args.includes("--force")) {
console.log(`hqterm ${VERSION} is the latest. (hqterm update --force reinstalls, and also updates hqsh and the desktop app.)`);
return 0;
}

const res = await fetch(INSTALL_URL).catch(() => null);
if (!res?.ok) {
console.error(`hqterm: could not download ${INSTALL_URL}.`);
return 1;
}
const script = await res.text();
if (!script.startsWith("#!")) {
console.error(`hqterm: ${INSTALL_URL} did not return an installer.`);
return 1;
}
const dir = mkdtempSync(join(tmpdir(), "hqterm-update-"));
const file = join(dir, "install.sh");
writeFileSync(file, script, { mode: 0o700 });
try {
const extra = args.filter((a) => a !== "--force");
console.log(`Updating hqterm ${VERSION}${latest ? ` -> ${latest}` : ""}...`);
return await runInteractive(["sh", file, ...installerArgs(process.execPath, !!findDesktop(), extra)]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
24 changes: 24 additions & 0 deletions test/update.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, test } from "bun:test";
import { compareVersions, installerArgs, latestVersion } from "../src/update.ts";

describe("hqterm update", () => {
test("compares versions numerically", () => {
expect(compareVersions("0.2.10", "0.2.9")).toBe(1);
expect(compareVersions("v0.2.2", "0.2.2")).toBe(0);
expect(compareVersions("0.1.0", "0.2.2")).toBe(-1);
});

test("updates a compiled binary in place and keeps the desktop app", () => {
expect(installerArgs("/home/a/.local/bin/hqterm", true)).toEqual(["--bin=/home/a/.local/bin", "--desktop"]);
expect(installerArgs("/usr/local/bin/hqterm", false)).toEqual(["--bin=/usr/local/bin"]);
// Running from source under bun: the installer's default directory.
expect(installerArgs("/home/a/.bun/bin/bun", false)).toEqual([]);
});

test("reads the latest release tag, and says nothing when GitHub fails", async () => {
const ok = (async () => new Response(JSON.stringify({ tag_name: "v0.2.3" }))) as unknown as typeof fetch;
expect(await latestVersion(ok)).toBe("0.2.3");
const down = (async () => new Response("", { status: 503 })) as unknown as typeof fetch;
expect(await latestVersion(down)).toBeNull();
});
});
Loading