diff --git a/apps/logicsrc-web/public/install.sh b/apps/logicsrc-web/public/install.sh index d6642db..649c381 100755 --- a/apps/logicsrc-web/public/install.sh +++ b/apps/logicsrc-web/public/install.sh @@ -56,15 +56,40 @@ check_node() { need npm } +# Commit the tracked ref currently points at. The .sha media type returns it as +# bare text, so this needs no jq. Empty on failure — never fatal, since a missing +# sha only costs `logicsrc update` its precision. +resolve_sha() { + curl -fsSL -H "Accept: application/vnd.github.sha" \ + "https://api.github.com/repos/$GH_REPO/commits/$LOGICSRC_REF" 2>/dev/null || true +} + +# Records what we installed so `logicsrc update` can compare against the remote. +# Without this the CLI has no way to know which commit it is running, and can +# only ever guess that it is current. +write_manifest() { + _version="$(node -p "require('$SRC_DIR/packages/cli/package.json').version" 2>/dev/null || echo '')" + cat > "$LOGICSRC_HOME/install.json" </dev/null 2>&1 ) || fail "npm install failed — run it by hand in $SRC_DIR" @@ -77,6 +102,7 @@ do_install() { exec node "$SRC_DIR/packages/cli/dist/index.js" "\$@" EOF chmod +x "$WRAPPER" + write_manifest "$sha" ok "installed logicsrc → $WRAPPER" case ":$PATH:" in diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 2f61bd5..bf14294 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { evaluateAccountPolicy, scoreAccountActionRisk } from "@logicsrc/account-core"; import { Command } from "commander"; @@ -30,6 +31,17 @@ import { exportOpenSpecSummary, importOpenSpec, writeOpenSpecChange } from "./op import { registerOntologyCommands } from "./ontology.js"; import { registerPrdCommands } from "./prd.js"; import { defaultPluginRegistry } from "./registry.js"; +import { + GH_REPO, + INSTALL_URL, + fetchRemoteState, + installHome, + localVersion, + readManifest, + short, + trackedRef, + updateStatus +} from "./update.js"; process.stdout.on("error", (error: NodeJS.ErrnoException) => { if (error.code === "EPIPE") { @@ -51,7 +63,7 @@ program .option("--waiting-arcade", "Alias for --arcade.") .option("--waiting-game ", "Alias for --arcade=.") .option("--no-arcade", "Disable Waiting Arcade.") - .version("0.1.0"); + .version(localVersion()); program.action(async (options) => { if (!options.yolo) { @@ -744,12 +756,45 @@ program.command("tui").description("Launch the tmux-friendly TUI.").action(() => console.log("\nPlugin status:\n" + renderPluginStatus()); }); -program.command("update").alias("upgrade").description("Update the local LogicSRC CLI.").action(() => { - console.log("Current version: 0.1.0"); - console.log("Latest version: 0.1.0"); - console.log("LogicSRC CLI is already up to date."); - console.log("Config preserved at $HOME/.logicsrc"); -}); +program + .command("update") + .alias("upgrade") + .description("Update the local LogicSRC CLI.") + .option("--check", "Report whether an update is available without installing it") + .action(async (options) => { + const manifest = readManifest(); + const ref = trackedRef(manifest); + const local = { version: localVersion(), commit: manifest?.commit ?? null }; + + console.log(`Tracking: ${GH_REPO}@${ref}`); + const remote = await fetchRemoteState(ref); + const status = updateStatus(local, remote); + + console.log(`Current version: ${status.currentVersion}${local.commit ? ` (${short(local.commit)})` : ""}`); + console.log( + `Latest version: ${status.latestVersion ?? "unknown"}${status.latestCommit ? ` (${short(status.latestCommit)})` : ""}` + ); + + if (status.upToDate) { + console.log(`LogicSRC CLI is already up to date — ${status.reason}.`); + return; + } + + console.log(`Update available — ${status.reason}.`); + if (options.check) { + console.log(`Run 'logicsrc update' (or: curl -fsSL ${INSTALL_URL} | sh -s -- update) to install it.`); + return; + } + + console.log(`Reinstalling from ${INSTALL_URL}…`); + const result = spawnSync("sh", ["-c", `curl -fsSL ${INSTALL_URL} | sh -s -- update`], { stdio: "inherit" }); + if (result.status !== 0) { + console.error(`Update failed (exit ${result.status ?? "signal"}). Re-run by hand: curl -fsSL ${INSTALL_URL} | sh -s -- update`); + process.exitCode = 1; + return; + } + console.log(`Updated. Install root: ${installHome()} — config preserved at ~/.logicsrc`); + }); program.command("remove").alias("uninstall").option("--purge", "Remove config and auth tokens").description("Remove local LogicSRC CLI.").action((options) => { console.log("Removed LogicSRC CLI."); diff --git a/packages/cli/src/update.test.ts b/packages/cli/src/update.test.ts new file mode 100644 index 0000000..a6bb14f --- /dev/null +++ b/packages/cli/src/update.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; + +import { compareVersions, parseManifest, trackedRef, updateStatus } from "./update.js"; + +describe("compareVersions", () => { + it("orders releases numerically, not lexically", () => { + expect(compareVersions("0.2.0", "0.1.0")).toBe(1); + expect(compareVersions("0.1.0", "0.2.0")).toBe(-1); + expect(compareVersions("0.1.0", "0.1.0")).toBe(0); + // "10" > "9" numerically but sorts lower as a string. + expect(compareVersions("0.10.0", "0.9.0")).toBe(1); + expect(compareVersions("1.0.0", "0.99.99")).toBe(1); + }); + + it("tolerates v-prefixes, prereleases, and short versions", () => { + expect(compareVersions("v1.2.3", "1.2.3")).toBe(0); + expect(compareVersions("1.2.0-beta.1", "1.2.0")).toBe(0); + expect(compareVersions("1.2", "1.2.0")).toBe(0); + expect(compareVersions("garbage", "0.0.0")).toBe(0); + }); +}); + +describe("parseManifest", () => { + it("reads a manifest written by install.sh", () => { + const m = parseManifest( + JSON.stringify({ ref: "master", commit: "abc1234def", version: "0.1.0", installed_at: "2026-07-28T00:00:00Z" }) + ); + expect(m).toEqual({ ref: "master", commit: "abc1234def", version: "0.1.0", installed_at: "2026-07-28T00:00:00Z" }); + }); + + it("defaults the ref and nulls empty or missing fields", () => { + // install.sh writes empty strings when the sha lookup or version read fails. + expect(parseManifest(JSON.stringify({ commit: "", version: "" }))).toEqual({ + ref: "master", + commit: null, + version: null, + installed_at: null + }); + }); + + it("returns null for junk rather than throwing", () => { + expect(parseManifest("not json")).toBeNull(); + expect(parseManifest("[]")).toBeNull(); + expect(parseManifest("null")).toBeNull(); + }); +}); + +describe("trackedRef", () => { + it("prefers the environment, then the manifest, then master", () => { + const manifest = { ref: "next", commit: null, version: null, installed_at: null }; + expect(trackedRef(manifest, { LOGICSRC_REF: "experiment" })).toBe("experiment"); + expect(trackedRef(manifest, {})).toBe("next"); + expect(trackedRef(null, {})).toBe("master"); + }); +}); + +describe("updateStatus", () => { + const local = { version: "0.1.0", commit: "aaaaaaaaaaaa" }; + + it("reports up to date only when the commit actually matches", () => { + const s = updateStatus(local, { version: "0.1.0", commit: "aaaaaaaaaaaa" }); + expect(s.upToDate).toBe(true); + expect(s.reason).toContain("current commit"); + }); + + it("matches a short sha against a full one", () => { + expect(updateStatus({ version: "0.1.0", commit: "aaaaaaa" }, { version: "0.1.0", commit: "aaaaaaaaaaaa" }).upToDate).toBe(true); + }); + + it("detects a moved branch even when the version is unchanged", () => { + // The bug this replaces: version-only comparison called this "up to date" + // forever, because the installer ships a branch tarball, not a release. + const s = updateStatus(local, { version: "0.1.0", commit: "bbbbbbbbbbbb" }); + expect(s.upToDate).toBe(false); + expect(s.reason).toContain("moved on"); + }); + + it("detects a newer published version", () => { + const s = updateStatus(local, { version: "0.2.0", commit: "aaaaaaaaaaaa" }); + expect(s.upToDate).toBe(false); + expect(s.reason).toContain("0.1.0 → 0.2.0"); + }); + + it("never claims to be current when the local commit is unknown", () => { + const s = updateStatus({ version: "0.1.0", commit: null }, { version: "0.1.0", commit: "bbbbbbbbbbbb" }); + expect(s.upToDate).toBe(false); + expect(s.reason).toContain("predates update tracking"); + }); + + it("does not invent an update when GitHub is unreachable", () => { + const s = updateStatus(local, { version: null, commit: null }); + expect(s.upToDate).toBe(true); + expect(s.reason).toContain("could not reach GitHub"); + }); +}); diff --git a/packages/cli/src/update.ts b/packages/cli/src/update.ts new file mode 100644 index 0000000..ae956f3 --- /dev/null +++ b/packages/cli/src/update.ts @@ -0,0 +1,165 @@ +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const GH_REPO = "profullstack/logicsrc"; +export const INSTALL_URL = "https://logicsrc.com/install.sh"; + +/** Written by install.sh so the CLI can tell which commit it was built from. */ +export type InstallManifest = { + ref: string; + commit: string | null; + version: string | null; + installed_at: string | null; +}; + +export type RemoteState = { version: string | null; commit: string | null }; + +export type UpdateStatus = { + upToDate: boolean; + /** Why we reached that verdict — shown to the user so it's never a bare claim. */ + reason: string; + currentVersion: string; + latestVersion: string | null; + currentCommit: string | null; + latestCommit: string | null; +}; + +/** Install root the installer uses (not the config dir, which is ~/.logicsrc). */ +export function installHome(env: NodeJS.ProcessEnv = process.env): string { + return env.LOGICSRC_HOME || join(env.HOME || homedir(), ".logicsrc-cli"); +} + +/** Git ref this install tracks; install.sh defaults to master. */ +export function trackedRef(manifest: InstallManifest | null, env: NodeJS.ProcessEnv = process.env): string { + return env.LOGICSRC_REF || manifest?.ref || "master"; +} + +/** + * The version of the CLI actually running, read from its own package.json + * rather than hardcoded — a literal here goes stale the moment anyone bumps + * the package and lies to every user who runs `logicsrc update`. + */ +export function localVersion(moduleUrl: string = import.meta.url): string { + // dist/update.js and src/update.ts are both one level under the package root. + const pkgPath = join(dirname(dirname(fileURLToPath(moduleUrl))), "package.json"); + try { + const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { version?: unknown }; + return typeof pkg.version === "string" ? pkg.version : "unknown"; + } catch { + return "unknown"; + } +} + +/** Parses $LOGICSRC_HOME/install.json; malformed or absent manifests are just "unknown". */ +export function parseManifest(raw: string): InstallManifest | null { + let parsed: unknown; + try { parsed = JSON.parse(raw); } catch { return null; } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + const m = parsed as Record; + return { + ref: typeof m.ref === "string" && m.ref ? m.ref : "master", + commit: typeof m.commit === "string" && m.commit ? m.commit : null, + version: typeof m.version === "string" && m.version ? m.version : null, + installed_at: typeof m.installed_at === "string" && m.installed_at ? m.installed_at : null + }; +} + +export function readManifest(home: string = installHome()): InstallManifest | null { + try { + return parseManifest(readFileSync(join(home, "install.json"), "utf8")); + } catch { + return null; + } +} + +/** Semver-ish compare. Returns -1 if a < b, 0 if equal, 1 if a > b. */ +export function compareVersions(a: string, b: string): number { + const parts = (v: string) => + v.replace(/^v/, "").split("-")[0]!.split(".").map((n) => Number.parseInt(n, 10) || 0); + const [x, y] = [parts(a), parts(b)]; + for (let i = 0; i < Math.max(x.length, y.length); i++) { + const d = (x[i] ?? 0) - (y[i] ?? 0); + if (d !== 0) return d > 0 ? 1 : -1; + } + return 0; +} + +/** + * Decides whether an update is available. + * + * The installer ships a tarball of a branch, not a tagged release, so the + * version alone can't answer this: master moves constantly while + * packages/cli/package.json sits on the same number for months. The commit is + * the real signal, and the version is only a fallback for installs predating + * the manifest. + */ +export function updateStatus(local: { version: string; commit: string | null }, remote: RemoteState): UpdateStatus { + const base = { + currentVersion: local.version, + latestVersion: remote.version, + currentCommit: local.commit, + latestCommit: remote.commit + }; + + if (remote.version && compareVersions(remote.version, local.version) > 0) { + return { ...base, upToDate: false, reason: `a newer release is published (${local.version} → ${remote.version})` }; + } + if (local.commit && remote.commit) { + const same = local.commit.startsWith(remote.commit) || remote.commit.startsWith(local.commit); + return same + ? { ...base, upToDate: true, reason: "installed from the current commit" } + : { ...base, upToDate: false, reason: `the tracked branch has moved on (${short(local.commit)} → ${short(remote.commit)})` }; + } + if (!remote.version && !remote.commit) { + return { ...base, upToDate: true, reason: "could not reach GitHub — assuming no update rather than guessing" }; + } + if (!local.commit) { + return { + ...base, + upToDate: false, + reason: "this install predates update tracking, so its commit is unknown — reinstalling is the only way to be sure" + }; + } + return { ...base, upToDate: true, reason: "already on the latest published version" }; +} + +export function short(commit: string): string { + return commit.slice(0, 7); +} + +/** Latest commit sha for a ref. The .sha media type returns it as bare text. */ +export async function fetchRemoteCommit(ref: string, repo = GH_REPO): Promise { + try { + const res = await fetch(`https://api.github.com/repos/${repo}/commits/${encodeURIComponent(ref)}`, { + headers: { accept: "application/vnd.github.sha", "user-agent": "logicsrc-cli" }, + signal: AbortSignal.timeout(10_000) + }); + if (!res.ok) return null; + const sha = (await res.text()).trim(); + return /^[0-9a-f]{7,40}$/i.test(sha) ? sha : null; + } catch { + return null; + } +} + +/** CLI version declared on the tracked ref. */ +export async function fetchRemoteVersion(ref: string, repo = GH_REPO): Promise { + try { + const res = await fetch( + `https://raw.githubusercontent.com/${repo}/${encodeURIComponent(ref)}/packages/cli/package.json`, + { headers: { "user-agent": "logicsrc-cli" }, signal: AbortSignal.timeout(10_000) } + ); + if (!res.ok) return null; + const pkg = JSON.parse(await res.text()) as { version?: unknown }; + return typeof pkg.version === "string" ? pkg.version : null; + } catch { + return null; + } +} + +export async function fetchRemoteState(ref: string, repo = GH_REPO): Promise { + const [version, commit] = await Promise.all([fetchRemoteVersion(ref, repo), fetchRemoteCommit(ref, repo)]); + return { version, commit }; +}