|
| 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