Merge pull request #103 from profullstack/fix/cli-real-update
Some checks failed
CI / build (push) Has been cancelled
test / test (push) Has been cancelled

fix(cli): make `logicsrc update` actually check for updates
This commit is contained in:
Anthony Ettinger 2026-07-28 23:18:47 -07:00 committed by GitHub
commit 2bed70971d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 339 additions and 8 deletions

View file

@ -56,15 +56,40 @@ check_node() {
need npm 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" <<EOF
{
"ref": "$LOGICSRC_REF",
"commit": "$1",
"version": "$_version",
"installed_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
}
EOF
}
do_install() { do_install() {
detect_os; check_node detect_os; check_node
need curl; need tar need curl; need tar
info "fetching logicsrc@$LOGICSRC_REF from GitHub…" info "fetching logicsrc@$LOGICSRC_REF from GitHub…"
mkdir -p "$SRC_DIR" mkdir -p "$SRC_DIR"
sha="$(resolve_sha)"
short_sha="$(printf '%.7s' "$sha")"
tmp="$(mktemp -d)" tmp="$(mktemp -d)"
curl -fsSL "$TARBALL_URL" | tar -xz -C "$tmp" --strip-components=1 curl -fsSL "$TARBALL_URL" | tar -xz -C "$tmp" --strip-components=1
rm -rf "$SRC_DIR"; mkdir -p "$(dirname "$SRC_DIR")"; mv "$tmp" "$SRC_DIR" rm -rf "$SRC_DIR"; mkdir -p "$(dirname "$SRC_DIR")"; mv "$tmp" "$SRC_DIR"
ok "downloaded to $SRC_DIR" ok "downloaded to $SRC_DIR${short_sha:+ ($short_sha)}"
info "installing dependencies (this can take a minute)…" info "installing dependencies (this can take a minute)…"
( cd "$SRC_DIR" && npm install --no-audit --no-fund --ignore-scripts >/dev/null 2>&1 ) || fail "npm install failed — run it by hand in $SRC_DIR" ( cd "$SRC_DIR" && npm install --no-audit --no-fund --ignore-scripts >/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" "\$@" exec node "$SRC_DIR/packages/cli/dist/index.js" "\$@"
EOF EOF
chmod +x "$WRAPPER" chmod +x "$WRAPPER"
write_manifest "$sha"
ok "installed logicsrc → $WRAPPER" ok "installed logicsrc → $WRAPPER"
case ":$PATH:" in case ":$PATH:" in

View file

@ -1,4 +1,5 @@
#!/usr/bin/env node #!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { evaluateAccountPolicy, scoreAccountActionRisk } from "@logicsrc/account-core"; import { evaluateAccountPolicy, scoreAccountActionRisk } from "@logicsrc/account-core";
import { Command } from "commander"; import { Command } from "commander";
@ -30,6 +31,17 @@ import { exportOpenSpecSummary, importOpenSpec, writeOpenSpecChange } from "./op
import { registerOntologyCommands } from "./ontology.js"; import { registerOntologyCommands } from "./ontology.js";
import { registerPrdCommands } from "./prd.js"; import { registerPrdCommands } from "./prd.js";
import { defaultPluginRegistry } from "./registry.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) => { process.stdout.on("error", (error: NodeJS.ErrnoException) => {
if (error.code === "EPIPE") { if (error.code === "EPIPE") {
@ -51,7 +63,7 @@ program
.option("--waiting-arcade", "Alias for --arcade.") .option("--waiting-arcade", "Alias for --arcade.")
.option("--waiting-game <game>", "Alias for --arcade=<game>.") .option("--waiting-game <game>", "Alias for --arcade=<game>.")
.option("--no-arcade", "Disable Waiting Arcade.") .option("--no-arcade", "Disable Waiting Arcade.")
.version("0.1.0"); .version(localVersion());
program.action(async (options) => { program.action(async (options) => {
if (!options.yolo) { if (!options.yolo) {
@ -744,12 +756,45 @@ program.command("tui").description("Launch the tmux-friendly TUI.").action(() =>
console.log("\nPlugin status:\n" + renderPluginStatus()); console.log("\nPlugin status:\n" + renderPluginStatus());
}); });
program.command("update").alias("upgrade").description("Update the local LogicSRC CLI.").action(() => { program
console.log("Current version: 0.1.0"); .command("update")
console.log("Latest version: 0.1.0"); .alias("upgrade")
console.log("LogicSRC CLI is already up to date."); .description("Update the local LogicSRC CLI.")
console.log("Config preserved at $HOME/.logicsrc"); .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) => { program.command("remove").alias("uninstall").option("--purge", "Remove config and auth tokens").description("Remove local LogicSRC CLI.").action((options) => {
console.log("Removed LogicSRC CLI."); console.log("Removed LogicSRC CLI.");

View file

@ -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");
});
});

165
packages/cli/src/update.ts Normal file
View file

@ -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<string, unknown>;
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<string | null> {
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<string | null> {
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<RemoteState> {
const [version, commit] = await Promise.all([fetchRemoteVersion(ref, repo), fetchRemoteCommit(ref, repo)]);
return { version, commit };
}