mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-13 22:37:29 +00:00
Merge pull request #103 from profullstack/fix/cli-real-update
fix(cli): make `logicsrc update` actually check for updates
This commit is contained in:
commit
2bed70971d
4 changed files with 339 additions and 8 deletions
|
|
@ -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" <<EOF
|
||||
{
|
||||
"ref": "$LOGICSRC_REF",
|
||||
"commit": "$1",
|
||||
"version": "$_version",
|
||||
"installed_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
do_install() {
|
||||
detect_os; check_node
|
||||
need curl; need tar
|
||||
info "fetching logicsrc@$LOGICSRC_REF from GitHub…"
|
||||
mkdir -p "$SRC_DIR"
|
||||
sha="$(resolve_sha)"
|
||||
short_sha="$(printf '%.7s' "$sha")"
|
||||
tmp="$(mktemp -d)"
|
||||
curl -fsSL "$TARBALL_URL" | tar -xz -C "$tmp" --strip-components=1
|
||||
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)…"
|
||||
( 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" "\$@"
|
||||
EOF
|
||||
chmod +x "$WRAPPER"
|
||||
write_manifest "$sha"
|
||||
ok "installed logicsrc → $WRAPPER"
|
||||
|
||||
case ":$PATH:" in
|
||||
|
|
|
|||
|
|
@ -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 <game>", "Alias for --arcade=<game>.")
|
||||
.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.");
|
||||
|
|
|
|||
95
packages/cli/src/update.test.ts
Normal file
95
packages/cli/src/update.test.ts
Normal 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
165
packages/cli/src/update.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue