Files
gbrain/src/commands/check-update.ts
T
f541f045d2 feat: add gbrain check-update command and auto-update agent workflow (#15)
* feat: add `gbrain check-update` command for auto-update notifications

Deterministic collector that checks GitHub Releases for new versions,
compares semver (minor+ only, skips patches), and fetches changelog diffs.
Exports `detectInstallMethod()` from upgrade.ts for reuse. Includes 15
unit tests covering version comparison, CLI wiring, and error handling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add E2E upgrade tests against real GitHub API

Exercises check-update CLI end-to-end: valid JSON output, human-readable
mode, help text, graceful no-releases handling, and version comparison
wiring. Skips gracefully when network is unavailable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: add SKILLPACK Section 17 — auto-update notifications

Full agent playbook for the update lifecycle: check, notify, consent,
upgrade, skills refresh, schema sync, report. Includes standalone
self-update for skillpack-only users via version markers and raw
GitHub URL fetching. Adds version markers to both SKILLPACK and
RECOMMENDED_SCHEMA headers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add auto-update step 7 to install paste, setup Phase G, migrations dir

Adds step 7 to the OpenClaw install paste (default-on update checks).
Setup skill gets Phase G (conditional offer for manual installs) and
schema state tracking via ~/.gbrain/update-state.json. Creates
skills/migrations/ directory for version-specific upgrade directives.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: update CLAUDE.md with E2E test DB lifecycle, migration conventions

Adds E2E test DB lifecycle instructions (spin up, run, tear down).
Documents version migration convention (skills/migrations/v[version].md)
and schema state tracking (~/.gbrain/update-state.json). Updates test
file counts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: broken semver comparison in extractChangelogBetween

The version range check compared minor versions without guarding on
major being equal, causing incorrect changelog entries to be captured
(e.g., v0.5.0 would match when upgrading from v1.2.0). Extracted
semverGt/semverLte helpers for correct comparisons. Added 5 tests
for extractChangelogBetween covering cross-major, same-version, and
malformed input cases.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v0.4.1)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:25:04 -10:00

180 lines
5.4 KiB
TypeScript

import { VERSION } from '../version.ts';
import { detectInstallMethod } from './upgrade.ts';
interface CheckUpdateResult {
current_version: string;
current_source: 'package-json';
latest_version: string;
update_available: boolean;
upgrade_command: string;
release_url: string;
changelog_diff: string;
published_at: string;
error?: string;
}
export function parseSemver(v: string): [number, number, number] | null {
const clean = v.replace(/^v/, '');
const parts = clean.split('.');
if (parts.length < 3) return null;
const nums = parts.slice(0, 3).map(Number);
if (nums.some(isNaN)) return null;
return nums as [number, number, number];
}
export function isMinorOrMajorBump(current: string, latest: string): boolean {
const cur = parseSemver(current);
const lat = parseSemver(latest);
if (!cur || !lat) return false;
if (lat[0] > cur[0]) return true;
if (lat[0] === cur[0] && lat[1] > cur[1]) return true;
return false;
}
function upgradeCommandForMethod(method: string): string {
switch (method) {
case 'bun': return 'bun update gbrain';
case 'clawhub': return 'clawhub update gbrain';
case 'binary': return 'Download from https://github.com/garrytan/gbrain/releases';
default: return 'gbrain upgrade';
}
}
async function fetchLatestRelease(): Promise<{ tag: string; published_at: string; url: string } | null> {
try {
const res = await fetch('https://api.github.com/repos/garrytan/gbrain/releases/latest', {
headers: { 'User-Agent': `gbrain/${VERSION}` },
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) return null;
const data = await res.json() as any;
return {
tag: data.tag_name || '',
published_at: data.published_at || '',
url: data.html_url || '',
};
} catch {
return null;
}
}
async function fetchChangelog(currentVersion: string, latestVersion: string): Promise<string> {
try {
const res = await fetch('https://raw.githubusercontent.com/garrytan/gbrain/master/CHANGELOG.md', {
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) return '';
const text = await res.text();
return extractChangelogBetween(text, currentVersion, latestVersion);
} catch {
return '';
}
}
function semverGt(a: [number, number, number], b: [number, number, number]): boolean {
if (a[0] !== b[0]) return a[0] > b[0];
if (a[1] !== b[1]) return a[1] > b[1];
return a[2] > b[2];
}
function semverLte(a: [number, number, number], b: [number, number, number]): boolean {
return !semverGt(a, b);
}
export function extractChangelogBetween(changelog: string, from: string, to: string): string {
const lines = changelog.split('\n');
const entries: string[] = [];
let capturing = false;
const fromParsed = parseSemver(from);
if (!fromParsed) return '';
for (const line of lines) {
const versionMatch = line.match(/^## \[(\d+\.\d+\.\d+(?:\.\d+)?)\]/);
if (versionMatch) {
const verParsed = parseSemver(versionMatch[1]);
if (!verParsed) {
if (capturing) entries.push(line);
continue;
}
if (!capturing) {
// Start capturing at any version newer than current
if (semverGt(verParsed, fromParsed)) {
capturing = true;
entries.push(line);
}
} else {
// Stop capturing when we hit the current version or older
if (semverLte(verParsed, fromParsed)) {
break;
}
entries.push(line);
}
} else if (capturing) {
entries.push(line);
}
}
return entries.join('\n').trim();
}
export async function runCheckUpdate(args: string[]) {
if (args.includes('--help') || args.includes('-h')) {
console.log('Usage: gbrain check-update [--json]\n\nCheck for new GBrain versions.\n\nOnly reports minor/major version bumps (v0.X.0), not patches.\nFails silently on network errors.');
return;
}
const json = args.includes('--json');
const method = detectInstallMethod();
const upgradeCmd = upgradeCommandForMethod(method);
const release = await fetchLatestRelease();
if (!release) {
if (json) {
console.log(JSON.stringify({
current_version: VERSION,
current_source: 'package-json',
latest_version: '',
update_available: false,
upgrade_command: upgradeCmd,
release_url: '',
changelog_diff: '',
published_at: '',
error: 'no_releases',
}, null, 2));
} else {
console.log(`GBrain ${VERSION} — could not check for updates (no releases found or network unavailable).`);
}
return;
}
const latestVersion = release.tag.replace(/^v/, '');
const updateAvailable = isMinorOrMajorBump(VERSION, latestVersion);
let changelogDiff = '';
if (updateAvailable) {
changelogDiff = await fetchChangelog(VERSION, latestVersion);
}
const result: CheckUpdateResult = {
current_version: VERSION,
current_source: 'package-json',
latest_version: latestVersion,
update_available: updateAvailable,
upgrade_command: upgradeCmd,
release_url: release.url,
changelog_diff: changelogDiff,
published_at: release.published_at,
};
if (json) {
console.log(JSON.stringify(result, null, 2));
} else if (updateAvailable) {
console.log(`GBrain update available: ${VERSION}${latestVersion}`);
console.log(`Run: ${upgradeCmd}`);
console.log(`Release: ${release.url}`);
} else {
console.log(`GBrain ${VERSION} is up to date.`);
}
}