Skip to content

Commit 2bed709

Browse files
authored
Merge pull request #103 from profullstack/fix/cli-real-update
fix(cli): make `logicsrc update` actually check for updates
2 parents cf475f0 + 8c3c0bb commit 2bed709

4 files changed

Lines changed: 339 additions & 8 deletions

File tree

apps/logicsrc-web/public/install.sh

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,15 +56,40 @@ check_node() {
5656
need npm
5757
}
5858

59+
# Commit the tracked ref currently points at. The .sha media type returns it as
60+
# bare text, so this needs no jq. Empty on failure — never fatal, since a missing
61+
# sha only costs `logicsrc update` its precision.
62+
resolve_sha() {
63+
curl -fsSL -H "Accept: application/vnd.github.sha" \
64+
"https://api.github.com/repos/$GH_REPO/commits/$LOGICSRC_REF" 2>/dev/null || true
65+
}
66+
67+
# Records what we installed so `logicsrc update` can compare against the remote.
68+
# Without this the CLI has no way to know which commit it is running, and can
69+
# only ever guess that it is current.
70+
write_manifest() {
71+
_version="$(node -p "require('$SRC_DIR/packages/cli/package.json').version" 2>/dev/null || echo '')"
72+
cat > "$LOGICSRC_HOME/install.json" <<EOF
73+
{
74+
"ref": "$LOGICSRC_REF",
75+
"commit": "$1",
76+
"version": "$_version",
77+
"installed_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
78+
}
79+
EOF
80+
}
81+
5982
do_install() {
6083
detect_os; check_node
6184
need curl; need tar
6285
info "fetching logicsrc@$LOGICSRC_REF from GitHub…"
6386
mkdir -p "$SRC_DIR"
87+
sha="$(resolve_sha)"
88+
short_sha="$(printf '%.7s' "$sha")"
6489
tmp="$(mktemp -d)"
6590
curl -fsSL "$TARBALL_URL" | tar -xz -C "$tmp" --strip-components=1
6691
rm -rf "$SRC_DIR"; mkdir -p "$(dirname "$SRC_DIR")"; mv "$tmp" "$SRC_DIR"
67-
ok "downloaded to $SRC_DIR"
92+
ok "downloaded to $SRC_DIR${short_sha:+ ($short_sha)}"
6893

6994
info "installing dependencies (this can take a minute)…"
7095
( 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() {
77102
exec node "$SRC_DIR/packages/cli/dist/index.js" "\$@"
78103
EOF
79104
chmod +x "$WRAPPER"
105+
write_manifest "$sha"
80106
ok "installed logicsrc → $WRAPPER"
81107

82108
case ":$PATH:" in

packages/cli/src/index.ts

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#!/usr/bin/env node
2+
import { spawnSync } from "node:child_process";
23
import { readFileSync } from "node:fs";
34
import { evaluateAccountPolicy, scoreAccountActionRisk } from "@logicsrc/account-core";
45
import { Command } from "commander";
@@ -30,6 +31,17 @@ import { exportOpenSpecSummary, importOpenSpec, writeOpenSpecChange } from "./op
3031
import { registerOntologyCommands } from "./ontology.js";
3132
import { registerPrdCommands } from "./prd.js";
3233
import { defaultPluginRegistry } from "./registry.js";
34+
import {
35+
GH_REPO,
36+
INSTALL_URL,
37+
fetchRemoteState,
38+
installHome,
39+
localVersion,
40+
readManifest,
41+
short,
42+
trackedRef,
43+
updateStatus
44+
} from "./update.js";
3345

3446
process.stdout.on("error", (error: NodeJS.ErrnoException) => {
3547
if (error.code === "EPIPE") {
@@ -51,7 +63,7 @@ program
5163
.option("--waiting-arcade", "Alias for --arcade.")
5264
.option("--waiting-game <game>", "Alias for --arcade=<game>.")
5365
.option("--no-arcade", "Disable Waiting Arcade.")
54-
.version("0.1.0");
66+
.version(localVersion());
5567

5668
program.action(async (options) => {
5769
if (!options.yolo) {
@@ -744,12 +756,45 @@ program.command("tui").description("Launch the tmux-friendly TUI.").action(() =>
744756
console.log("\nPlugin status:\n" + renderPluginStatus());
745757
});
746758

747-
program.command("update").alias("upgrade").description("Update the local LogicSRC CLI.").action(() => {
748-
console.log("Current version: 0.1.0");
749-
console.log("Latest version: 0.1.0");
750-
console.log("LogicSRC CLI is already up to date.");
751-
console.log("Config preserved at $HOME/.logicsrc");
752-
});
759+
program
760+
.command("update")
761+
.alias("upgrade")
762+
.description("Update the local LogicSRC CLI.")
763+
.option("--check", "Report whether an update is available without installing it")
764+
.action(async (options) => {
765+
const manifest = readManifest();
766+
const ref = trackedRef(manifest);
767+
const local = { version: localVersion(), commit: manifest?.commit ?? null };
768+
769+
console.log(`Tracking: ${GH_REPO}@${ref}`);
770+
const remote = await fetchRemoteState(ref);
771+
const status = updateStatus(local, remote);
772+
773+
console.log(`Current version: ${status.currentVersion}${local.commit ? ` (${short(local.commit)})` : ""}`);
774+
console.log(
775+
`Latest version: ${status.latestVersion ?? "unknown"}${status.latestCommit ? ` (${short(status.latestCommit)})` : ""}`
776+
);
777+
778+
if (status.upToDate) {
779+
console.log(`LogicSRC CLI is already up to date — ${status.reason}.`);
780+
return;
781+
}
782+
783+
console.log(`Update available — ${status.reason}.`);
784+
if (options.check) {
785+
console.log(`Run 'logicsrc update' (or: curl -fsSL ${INSTALL_URL} | sh -s -- update) to install it.`);
786+
return;
787+
}
788+
789+
console.log(`Reinstalling from ${INSTALL_URL}…`);
790+
const result = spawnSync("sh", ["-c", `curl -fsSL ${INSTALL_URL} | sh -s -- update`], { stdio: "inherit" });
791+
if (result.status !== 0) {
792+
console.error(`Update failed (exit ${result.status ?? "signal"}). Re-run by hand: curl -fsSL ${INSTALL_URL} | sh -s -- update`);
793+
process.exitCode = 1;
794+
return;
795+
}
796+
console.log(`Updated. Install root: ${installHome()} — config preserved at ~/.logicsrc`);
797+
});
753798

754799
program.command("remove").alias("uninstall").option("--purge", "Remove config and auth tokens").description("Remove local LogicSRC CLI.").action((options) => {
755800
console.log("Removed LogicSRC CLI.");

packages/cli/src/update.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { compareVersions, parseManifest, trackedRef, updateStatus } from "./update.js";
4+
5+
describe("compareVersions", () => {
6+
it("orders releases numerically, not lexically", () => {
7+
expect(compareVersions("0.2.0", "0.1.0")).toBe(1);
8+
expect(compareVersions("0.1.0", "0.2.0")).toBe(-1);
9+
expect(compareVersions("0.1.0", "0.1.0")).toBe(0);
10+
// "10" > "9" numerically but sorts lower as a string.
11+
expect(compareVersions("0.10.0", "0.9.0")).toBe(1);
12+
expect(compareVersions("1.0.0", "0.99.99")).toBe(1);
13+
});
14+
15+
it("tolerates v-prefixes, prereleases, and short versions", () => {
16+
expect(compareVersions("v1.2.3", "1.2.3")).toBe(0);
17+
expect(compareVersions("1.2.0-beta.1", "1.2.0")).toBe(0);
18+
expect(compareVersions("1.2", "1.2.0")).toBe(0);
19+
expect(compareVersions("garbage", "0.0.0")).toBe(0);
20+
});
21+
});
22+
23+
describe("parseManifest", () => {
24+
it("reads a manifest written by install.sh", () => {
25+
const m = parseManifest(
26+
JSON.stringify({ ref: "master", commit: "abc1234def", version: "0.1.0", installed_at: "2026-07-28T00:00:00Z" })
27+
);
28+
expect(m).toEqual({ ref: "master", commit: "abc1234def", version: "0.1.0", installed_at: "2026-07-28T00:00:00Z" });
29+
});
30+
31+
it("defaults the ref and nulls empty or missing fields", () => {
32+
// install.sh writes empty strings when the sha lookup or version read fails.
33+
expect(parseManifest(JSON.stringify({ commit: "", version: "" }))).toEqual({
34+
ref: "master",
35+
commit: null,
36+
version: null,
37+
installed_at: null
38+
});
39+
});
40+
41+
it("returns null for junk rather than throwing", () => {
42+
expect(parseManifest("not json")).toBeNull();
43+
expect(parseManifest("[]")).toBeNull();
44+
expect(parseManifest("null")).toBeNull();
45+
});
46+
});
47+
48+
describe("trackedRef", () => {
49+
it("prefers the environment, then the manifest, then master", () => {
50+
const manifest = { ref: "next", commit: null, version: null, installed_at: null };
51+
expect(trackedRef(manifest, { LOGICSRC_REF: "experiment" })).toBe("experiment");
52+
expect(trackedRef(manifest, {})).toBe("next");
53+
expect(trackedRef(null, {})).toBe("master");
54+
});
55+
});
56+
57+
describe("updateStatus", () => {
58+
const local = { version: "0.1.0", commit: "aaaaaaaaaaaa" };
59+
60+
it("reports up to date only when the commit actually matches", () => {
61+
const s = updateStatus(local, { version: "0.1.0", commit: "aaaaaaaaaaaa" });
62+
expect(s.upToDate).toBe(true);
63+
expect(s.reason).toContain("current commit");
64+
});
65+
66+
it("matches a short sha against a full one", () => {
67+
expect(updateStatus({ version: "0.1.0", commit: "aaaaaaa" }, { version: "0.1.0", commit: "aaaaaaaaaaaa" }).upToDate).toBe(true);
68+
});
69+
70+
it("detects a moved branch even when the version is unchanged", () => {
71+
// The bug this replaces: version-only comparison called this "up to date"
72+
// forever, because the installer ships a branch tarball, not a release.
73+
const s = updateStatus(local, { version: "0.1.0", commit: "bbbbbbbbbbbb" });
74+
expect(s.upToDate).toBe(false);
75+
expect(s.reason).toContain("moved on");
76+
});
77+
78+
it("detects a newer published version", () => {
79+
const s = updateStatus(local, { version: "0.2.0", commit: "aaaaaaaaaaaa" });
80+
expect(s.upToDate).toBe(false);
81+
expect(s.reason).toContain("0.1.0 → 0.2.0");
82+
});
83+
84+
it("never claims to be current when the local commit is unknown", () => {
85+
const s = updateStatus({ version: "0.1.0", commit: null }, { version: "0.1.0", commit: "bbbbbbbbbbbb" });
86+
expect(s.upToDate).toBe(false);
87+
expect(s.reason).toContain("predates update tracking");
88+
});
89+
90+
it("does not invent an update when GitHub is unreachable", () => {
91+
const s = updateStatus(local, { version: null, commit: null });
92+
expect(s.upToDate).toBe(true);
93+
expect(s.reason).toContain("could not reach GitHub");
94+
});
95+
});

packages/cli/src/update.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { readFileSync } from "node:fs";
2+
import { homedir } from "node:os";
3+
import { dirname, join } from "node:path";
4+
import { fileURLToPath } from "node:url";
5+
6+
export const GH_REPO = "profullstack/logicsrc";
7+
export const INSTALL_URL = "https://logicsrc.com/install.sh";
8+
9+
/** Written by install.sh so the CLI can tell which commit it was built from. */
10+
export type InstallManifest = {
11+
ref: string;
12+
commit: string | null;
13+
version: string | null;
14+
installed_at: string | null;
15+
};
16+
17+
export type RemoteState = { version: string | null; commit: string | null };
18+
19+
export type UpdateStatus = {
20+
upToDate: boolean;
21+
/** Why we reached that verdict — shown to the user so it's never a bare claim. */
22+
reason: string;
23+
currentVersion: string;
24+
latestVersion: string | null;
25+
currentCommit: string | null;
26+
latestCommit: string | null;
27+
};
28+
29+
/** Install root the installer uses (not the config dir, which is ~/.logicsrc). */
30+
export function installHome(env: NodeJS.ProcessEnv = process.env): string {
31+
return env.LOGICSRC_HOME || join(env.HOME || homedir(), ".logicsrc-cli");
32+
}
33+
34+
/** Git ref this install tracks; install.sh defaults to master. */
35+
export function trackedRef(manifest: InstallManifest | null, env: NodeJS.ProcessEnv = process.env): string {
36+
return env.LOGICSRC_REF || manifest?.ref || "master";
37+
}
38+
39+
/**
40+
* The version of the CLI actually running, read from its own package.json
41+
* rather than hardcoded — a literal here goes stale the moment anyone bumps
42+
* the package and lies to every user who runs `logicsrc update`.
43+
*/
44+
export function localVersion(moduleUrl: string = import.meta.url): string {
45+
// dist/update.js and src/update.ts are both one level under the package root.
46+
const pkgPath = join(dirname(dirname(fileURLToPath(moduleUrl))), "package.json");
47+
try {
48+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { version?: unknown };
49+
return typeof pkg.version === "string" ? pkg.version : "unknown";
50+
} catch {
51+
return "unknown";
52+
}
53+
}
54+
55+
/** Parses $LOGICSRC_HOME/install.json; malformed or absent manifests are just "unknown". */
56+
export function parseManifest(raw: string): InstallManifest | null {
57+
let parsed: unknown;
58+
try { parsed = JSON.parse(raw); } catch { return null; }
59+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
60+
const m = parsed as Record<string, unknown>;
61+
return {
62+
ref: typeof m.ref === "string" && m.ref ? m.ref : "master",
63+
commit: typeof m.commit === "string" && m.commit ? m.commit : null,
64+
version: typeof m.version === "string" && m.version ? m.version : null,
65+
installed_at: typeof m.installed_at === "string" && m.installed_at ? m.installed_at : null
66+
};
67+
}
68+
69+
export function readManifest(home: string = installHome()): InstallManifest | null {
70+
try {
71+
return parseManifest(readFileSync(join(home, "install.json"), "utf8"));
72+
} catch {
73+
return null;
74+
}
75+
}
76+
77+
/** Semver-ish compare. Returns -1 if a < b, 0 if equal, 1 if a > b. */
78+
export function compareVersions(a: string, b: string): number {
79+
const parts = (v: string) =>
80+
v.replace(/^v/, "").split("-")[0]!.split(".").map((n) => Number.parseInt(n, 10) || 0);
81+
const [x, y] = [parts(a), parts(b)];
82+
for (let i = 0; i < Math.max(x.length, y.length); i++) {
83+
const d = (x[i] ?? 0) - (y[i] ?? 0);
84+
if (d !== 0) return d > 0 ? 1 : -1;
85+
}
86+
return 0;
87+
}
88+
89+
/**
90+
* Decides whether an update is available.
91+
*
92+
* The installer ships a tarball of a branch, not a tagged release, so the
93+
* version alone can't answer this: master moves constantly while
94+
* packages/cli/package.json sits on the same number for months. The commit is
95+
* the real signal, and the version is only a fallback for installs predating
96+
* the manifest.
97+
*/
98+
export function updateStatus(local: { version: string; commit: string | null }, remote: RemoteState): UpdateStatus {
99+
const base = {
100+
currentVersion: local.version,
101+
latestVersion: remote.version,
102+
currentCommit: local.commit,
103+
latestCommit: remote.commit
104+
};
105+
106+
if (remote.version && compareVersions(remote.version, local.version) > 0) {
107+
return { ...base, upToDate: false, reason: `a newer release is published (${local.version}${remote.version})` };
108+
}
109+
if (local.commit && remote.commit) {
110+
const same = local.commit.startsWith(remote.commit) || remote.commit.startsWith(local.commit);
111+
return same
112+
? { ...base, upToDate: true, reason: "installed from the current commit" }
113+
: { ...base, upToDate: false, reason: `the tracked branch has moved on (${short(local.commit)}${short(remote.commit)})` };
114+
}
115+
if (!remote.version && !remote.commit) {
116+
return { ...base, upToDate: true, reason: "could not reach GitHub — assuming no update rather than guessing" };
117+
}
118+
if (!local.commit) {
119+
return {
120+
...base,
121+
upToDate: false,
122+
reason: "this install predates update tracking, so its commit is unknown — reinstalling is the only way to be sure"
123+
};
124+
}
125+
return { ...base, upToDate: true, reason: "already on the latest published version" };
126+
}
127+
128+
export function short(commit: string): string {
129+
return commit.slice(0, 7);
130+
}
131+
132+
/** Latest commit sha for a ref. The .sha media type returns it as bare text. */
133+
export async function fetchRemoteCommit(ref: string, repo = GH_REPO): Promise<string | null> {
134+
try {
135+
const res = await fetch(`https://api.github.com/repos/${repo}/commits/${encodeURIComponent(ref)}`, {
136+
headers: { accept: "application/vnd.github.sha", "user-agent": "logicsrc-cli" },
137+
signal: AbortSignal.timeout(10_000)
138+
});
139+
if (!res.ok) return null;
140+
const sha = (await res.text()).trim();
141+
return /^[0-9a-f]{7,40}$/i.test(sha) ? sha : null;
142+
} catch {
143+
return null;
144+
}
145+
}
146+
147+
/** CLI version declared on the tracked ref. */
148+
export async function fetchRemoteVersion(ref: string, repo = GH_REPO): Promise<string | null> {
149+
try {
150+
const res = await fetch(
151+
`https://raw.githubusercontent.com/${repo}/${encodeURIComponent(ref)}/packages/cli/package.json`,
152+
{ headers: { "user-agent": "logicsrc-cli" }, signal: AbortSignal.timeout(10_000) }
153+
);
154+
if (!res.ok) return null;
155+
const pkg = JSON.parse(await res.text()) as { version?: unknown };
156+
return typeof pkg.version === "string" ? pkg.version : null;
157+
} catch {
158+
return null;
159+
}
160+
}
161+
162+
export async function fetchRemoteState(ref: string, repo = GH_REPO): Promise<RemoteState> {
163+
const [version, commit] = await Promise.all([fetchRemoteVersion(ref, repo), fetchRemoteCommit(ref, repo)]);
164+
return { version, commit };
165+
}

0 commit comments

Comments
 (0)