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>
This commit is contained in:
Garry Tan
2026-04-09 12:25:04 -10:00
committed by GitHub
parent 00217feda3
commit f541f045d2
15 changed files with 689 additions and 13 deletions

View File

@@ -2,6 +2,28 @@
All notable changes to GBrain will be documented in this file.
## [0.4.1] - 2026-04-09
### Added
- `gbrain check-update` command with `--json` output. Checks GitHub Releases for new versions, compares semver (minor+ only, skips patches), fetches and parses changelog diffs. Fail-silent on network errors.
- SKILLPACK Section 17: Auto-Update Notifications. Full agent playbook for the update lifecycle: check, notify, consent, upgrade, skills refresh, schema sync, report. Never auto-upgrades without user permission.
- Standalone SKILLPACK self-update for users who load the skillpack directly without the gbrain CLI. Version markers in SKILLPACK and RECOMMENDED_SCHEMA headers, with raw GitHub URL fetching.
- Step 7 in the OpenClaw install paste: daily update checks, default-on. User opts into being notified about updates, not into automatic installs.
- Setup skill Phase G: conditional auto-update offer for manual install users.
- Schema state tracking via `~/.gbrain/update-state.json`. Tracks which recommended schema directories the user adopted, declined, or added custom. Future upgrades suggest new additions without re-suggesting declined items.
- `skills/migrations/` directory convention for version-specific post-upgrade agent directives.
- 20 unit tests and 5 E2E tests for the check-update command, covering version comparison, changelog extraction, CLI wiring, and real GitHub API interaction.
- E2E test DB lifecycle documentation in CLAUDE.md: spin up, run tests, tear down. No orphaned containers.
### Changed
- `detectInstallMethod()` exported from `upgrade.ts` for reuse by `check-update`.
### Fixed
- Semver comparison in changelog extraction was missing major-version guard, causing incorrect changelog entries to appear when crossing major version boundaries.
## [0.4.0] - 2026-04-09
### Added

View File

@@ -32,7 +32,7 @@ Run `gbrain --help` or `gbrain --tools-json` for full command reference.
## Testing
`bun test` runs all tests (19 unit test files + 3 E2E test files). Unit tests run
`bun test` runs all tests (20 unit test files + 4 E2E test files). Unit tests run
without a database. E2E tests skip gracefully when `DATABASE_URL` is not set.
Unit tests: `test/markdown.test.ts` (frontmatter parsing), `test/chunkers/recursive.test.ts`
@@ -44,13 +44,42 @@ parity), `test/cli.test.ts` (CLI structure), `test/config.test.ts` (config redac
`test/import-resume.test.ts` (import checkpoints), `test/migrate.test.ts` (migration),
`test/setup-branching.test.ts` (setup flow), `test/slug-validation.test.ts` (slug validation),
`test/storage.test.ts` (storage backends), `test/supabase-admin.test.ts` (Supabase admin),
`test/yaml-lite.test.ts` (YAML parsing).
`test/yaml-lite.test.ts` (YAML parsing), `test/check-update.test.ts` (version check + update CLI).
E2E tests (`test/e2e/`): Run against real Postgres+pgvector. Require `DATABASE_URL`.
- `bun run test:e2e` runs Tier 1 (mechanical, all operations, no API keys)
- `test/e2e/upgrade.test.ts` runs check-update E2E against real GitHub API (network required)
- Tier 2 (`skills.test.ts`) requires OpenClaw + API keys, runs nightly in CI
- Local setup: `docker compose -f docker-compose.test.yml up -d` then
`DATABASE_URL=postgresql://postgres:postgres@localhost:5434/gbrain_test bun run test:e2e`
- If `.env.testing` doesn't exist in this directory, check sibling worktrees for one:
`find ../ -maxdepth 2 -name .env.testing -print -quit` and copy it here if found.
- Always run E2E tests when they exist. Do not skip them just because DATABASE_URL
is not set. Start the test DB, run the tests, then tear it down.
### E2E test DB lifecycle (ALWAYS follow this)
You are responsible for spinning up and tearing down the test Postgres container.
Do not leave containers running after tests. Do not skip E2E tests.
1. **Check for `.env.testing`** — if missing, copy from sibling worktree.
Read it to get the DATABASE_URL (it has the port number).
2. **Check if the port is free:**
`docker ps --filter "publish=PORT"` — if another container is on that port,
pick a different port (try 5435, 5436, 5437) and start on that one instead.
3. **Start the test DB:**
```bash
docker run -d --name gbrain-test-pg \
-e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=gbrain_test \
-p PORT:5432 pgvector/pgvector:pg16
```
Wait for ready: `docker exec gbrain-test-pg pg_isready -U postgres`
4. **Run E2E tests:**
`DATABASE_URL=postgresql://postgres:postgres@localhost:PORT/gbrain_test bun run test:e2e`
5. **Tear down immediately after tests finish (pass or fail):**
`docker stop gbrain-test-pg && docker rm gbrain-test-pg`
Never leave `gbrain-test-pg` running. If you find a stale one from a previous run,
stop and remove it before starting a new one.
## Skills
@@ -67,11 +96,27 @@ migrate, setup.
Before shipping (/ship) or reviewing (/review), always run the full test suite:
- `bun test` — unit tests (no database required)
- `docker compose -f docker-compose.test.yml up -d` then
`DATABASE_URL=postgresql://postgres:postgres@localhost:5434/gbrain_test bun run test:e2e`
— E2E tests against real Postgres+pgvector
- Follow the "E2E test DB lifecycle" steps above to spin up the test DB,
run `bun run test:e2e`, then tear it down.
Both must pass. Do not ship with failing E2E tests.
Both must pass. Do not ship with failing E2E tests. Do not skip E2E tests.
## Version migrations
When shipping a GBrain version that requires agent action after upgrade (schema
changes, changed defaults, deprecated commands), create a migration file at
`skills/migrations/v[version].md`. The auto-update agent reads these files
post-upgrade and executes the directives. See GBRAIN_SKILLPACK.md Section 17.
If a release only has bug fixes with no behavior changes, no migration file is needed.
## Schema state tracking
`~/.gbrain/update-state.json` tracks which recommended schema directories the user
adopted, declined, or added custom. The auto-update agent (SKILLPACK Section 17)
reads this during upgrades to suggest new schema additions without re-suggesting
things the user already declined. The setup skill writes the initial state during
Phase C/E. Never modify a user's custom directories or re-suggest declined ones.
## Skill routing

View File

@@ -221,6 +221,14 @@ knowledge brain.
with the production agent patterns: brain-agent loop,
entity detection, source attribution, iron law back-linking,
and the enrichment pipeline
7. Set up daily GBrain update checks. Run `gbrain check-update`
to verify it works, then register a daily cron that checks
for new versions and messages me when there's something
worth upgrading to. I'll always be asked before anything
is installed — just let me know when updates are available.
Only notify me for meaningful updates (new features),
not small patches.
```
OpenClaw will install gbrain, walk through Supabase setup, discover your markdown files, import them, and prove search works with a query from your data.

View File

@@ -1 +1 @@
0.4.0
0.4.1

View File

@@ -1,3 +1,5 @@
<!-- schema-version: 0.4.1 -->
<!-- source: https://raw.githubusercontent.com/garrytan/gbrain/master/docs/GBRAIN_RECOMMENDED_SCHEMA.md -->
# Brain: The LLM-Maintained Knowledge Base
A system prompt for any AI agent that wants to build and maintain a personal knowledge base. This describes the pattern, the architecture, and the operational discipline that makes it work.

View File

@@ -1,3 +1,5 @@
<!-- skillpack-version: 0.4.1 -->
<!-- source: https://raw.githubusercontent.com/garrytan/gbrain/master/docs/GBRAIN_SKILLPACK.md -->
# GBrain Skillpack: Reference Architecture for AI Agents
## 1. What This Document Is
@@ -1091,3 +1093,127 @@ When an LLM keeps failing at a mechanical task despite repeated prompt fixes:
This is not about the LLM being bad. It's about using the right tool for the right
job. Code is 100% reliable at string concatenation. LLMs are 90% reliable at string
concatenation but 10x better at understanding what an email means. Use both.
---
## 17. Auto-Update Notifications
GBrain ships updates frequently. The auto-update cron keeps users current by
checking for new versions and messaging them when there's something worth upgrading to.
The check is automatic. The upgrade is always manual — never install without
the user's explicit permission.
### The Check
Run `gbrain check-update --json`. If `update_available` is false, stay completely
silent — do nothing. If true, message the user on their preferred channel.
### The Message
Summarize the `changelog_diff` into 2-3 human-friendly bullets. No raw markdown.
Lead with the most impactful change.
> **GBrain v0.5.0 is available** (you're on v0.4.0)
>
> What's new:
> - Semantic chunking now 3x faster with batched embedding
> - New `gbrain files mirror` command for cloud storage migration
> - Doctor command catches RLS misconfigurations
>
> Want me to upgrade? I'll update everything and refresh my playbook.
>
> (Reply **yes** to upgrade, **not now** to skip, **weekly** to check
> less often, or **stop** to turn off update checks)
### Handling Responses
| User says | Action |
|-----------|--------|
| yes / y / sure / ok / do it / upgrade / go ahead | Run the **full upgrade flow** (see below) |
| not now / later / skip / snooze | Acknowledge, check again next cycle |
| weekly | Store preference in agent memory, switch cron to weekly |
| daily | Store preference, switch cron back to daily |
| stop / unsubscribe / no more | Store preference, disable the cron. Tell user: "Update checks disabled. Say 'resume gbrain updates' or run `gbrain check-update` anytime." |
Acceptable "yes": any clearly affirmative response. When in doubt, ask again.
**Never auto-upgrade.** Always wait for explicit confirmation.
### The Full Upgrade Flow (after user says yes)
**Step 1: Update the binary/package.**
Run `gbrain upgrade`. This updates the CLI and all shipped files (skills, docs, migrations).
**Step 2: Re-read all updated skills.**
Re-read every skill file in `skills/*/SKILL.md` to pick up new patterns and workflows.
Updated skills = better agent behavior. The user gets this for free.
**Step 3: Re-read the production reference docs.**
Read `docs/GBRAIN_SKILLPACK.md` and `docs/GBRAIN_RECOMMENDED_SCHEMA.md` fresh.
**Step 4: Check for version-specific migration directives.**
Look for `skills/migrations/v[version].md` files between the old and new version.
If they exist, read and execute them in order.
**Step 5: Schema sync — suggest new recommendations without undoing user choices.**
Read `~/.gbrain/update-state.json` to see what the user previously adopted, declined,
and customized. For each NEW recommendation in the updated schema:
- If the user never saw it before, suggest it
- If the user previously declined it, do NOT re-suggest
- Never modify or remove the user's custom directories
Update `~/.gbrain/update-state.json` with new choices and bump `schema_version_applied`.
**Step 6: Report what changed.**
Tell the user what was upgraded and what actions were taken.
### Frequency Preferences
Default: daily. Store in agent memory as `gbrain_update_frequency: daily|weekly|off`.
Also persist in `~/.gbrain/update-state.json` so it survives agent context resets.
### Quiet Hours
Standard quiet hours gate (Section 9). Update notifications are never urgent.
Hold for morning briefing.
### Cron Registration
Name: `gbrain-update-check`
Default schedule: `0 9 * * *` (daily 9 AM)
Weekly schedule: `0 9 * * 1` (Monday 9 AM)
Prompt: "Run `gbrain check-update --json`. If update_available is true, summarize
the changelog and message me asking if I'd like to upgrade. If false, stay silent."
### Standalone Skillpack Users (no gbrain CLI)
If you loaded this SKILLPACK directly (copied the file or read from GitHub) without
installing gbrain, you can still stay current. Both this file and
GBRAIN_RECOMMENDED_SCHEMA.md have version markers in their first lines:
```
<!-- skillpack-version: 0.4.1 -->
<!-- source: https://raw.githubusercontent.com/garrytan/gbrain/master/docs/GBRAIN_SKILLPACK.md -->
```
**Self-update check:** Periodically fetch the first 3 lines of the source URL and
compare the version marker against your local copy:
```bash
curl -s https://raw.githubusercontent.com/garrytan/gbrain/master/docs/GBRAIN_SKILLPACK.md | head -1
# Returns: <!-- skillpack-version: X.Y.Z -->
```
If the remote version is newer (minor or major bump, same rule as the CLI), fetch
the full file and replace your local copy. Do the same for GBRAIN_RECOMMENDED_SCHEMA.md.
**Cron for standalone users:**
Name: `gbrain-skillpack-update`
Schedule: `0 9 * * 1` (weekly Monday 9 AM)
Prompt: "Fetch the first line of https://raw.githubusercontent.com/garrytan/gbrain/master/docs/GBRAIN_SKILLPACK.md
and compare the skillpack-version against my local copy. If newer, fetch the full
file, re-read it, and tell the user what changed. Do the same for GBRAIN_RECOMMENDED_SCHEMA.md.
If both are current, stay silent."
This way standalone users get new patterns, workflows, and schema recommendations
without installing the gbrain CLI. The agent fetches, diffs, and updates its own playbook.

View File

@@ -1,6 +1,6 @@
{
"name": "gbrain",
"version": "0.4.0",
"version": "0.4.1",
"description": "Personal knowledge brain with Postgres + pgvector hybrid search",
"family": "bundle-plugin",
"configSchema": {

View File

@@ -1,6 +1,6 @@
{
"name": "gbrain",
"version": "0.4.0",
"version": "0.4.1",
"description": "Postgres-native personal knowledge brain with hybrid RAG search",
"type": "module",
"main": "src/core/index.ts",

View File

View File

@@ -208,10 +208,39 @@ output. It checks connection, pgvector, RLS, schema version, and embeddings.
| No pages found | Query before import | Import files into gbrain first |
| RLS not enabled | Security gap | Run `gbrain init` again (auto-enables RLS) |
## Phase G: Auto-Update Check (if not already configured)
If the user's install did NOT include setting up auto-update checks (e.g., they
used the manual install path or an older version of the OpenClaw paste), offer it:
> "Would you like daily GBrain update checks? I'll let you know when there's a
> new version worth upgrading to — including new skills and schema recommendations.
> You'll always be asked before anything is installed."
If they agree:
1. Test: `gbrain check-update --json`
2. Register daily cron (see GBRAIN_SKILLPACK.md Section 17)
If already configured or user declines, skip.
## Schema State Tracking
After presenting the recommended directories (Phase C/E) and the user selects which
ones to create, write `~/.gbrain/update-state.json` recording:
- `schema_version_applied`: current gbrain version
- `skillpack_version_applied`: current gbrain version
- `schema_choices.adopted`: directories the user created
- `schema_choices.declined`: directories the user explicitly skipped
- `schema_choices.custom`: directories the user added that aren't in the recommended schema
This file enables future upgrades to suggest new schema additions without
re-suggesting things the user already declined.
## Tools Used
- `gbrain init --non-interactive --url ...` -- create brain
- `gbrain import <dir> --no-embed [--workers N]` -- import files
- `gbrain search <query>` -- search brain
- `gbrain doctor --json` -- health check
- `gbrain check-update --json` -- check for updates
- `gbrain embed refresh` -- generate embeddings

View File

@@ -19,7 +19,7 @@ for (const op of operations) {
}
// CLI-only commands that bypass the operation layer
const CLI_ONLY = new Set(['init', 'upgrade', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor']);
const CLI_ONLY = new Set(['init', 'upgrade', 'check-update', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor']);
async function main() {
const args = process.argv.slice(2);
@@ -233,6 +233,11 @@ async function handleCliOnly(command: string, args: string[]) {
await runUpgrade(args);
return;
}
if (command === 'check-update') {
const { runCheckUpdate } = await import('./commands/check-update.ts');
await runCheckUpdate(args);
return;
}
// All remaining CLI-only commands need a DB connection
const engine = await connectEngine();
@@ -325,6 +330,7 @@ USAGE
SETUP
init [--supabase|--url <conn>] Create brain (guided wizard)
upgrade Self-update
check-update [--json] Check for new versions
doctor [--json] Health check (pgvector, RLS, schema, embeddings)
PAGES

View File

@@ -0,0 +1,179 @@
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.`);
}
}

View File

@@ -55,7 +55,7 @@ function verifyUpgrade() {
}
}
function detectInstallMethod(): 'bun' | 'binary' | 'clawhub' | 'unknown' {
export function detectInstallMethod(): 'bun' | 'binary' | 'clawhub' | 'unknown' {
const execPath = process.execPath || '';
// Check if running from node_modules (bun/npm install)

161
test/check-update.test.ts Normal file
View File

@@ -0,0 +1,161 @@
import { describe, test, expect } from 'bun:test';
import { parseSemver, isMinorOrMajorBump, extractChangelogBetween } from '../src/commands/check-update.ts';
describe('parseSemver', () => {
test('parses standard version', () => {
expect(parseSemver('0.4.0')).toEqual([0, 4, 0]);
});
test('strips v prefix', () => {
expect(parseSemver('v0.5.0')).toEqual([0, 5, 0]);
});
test('returns null for malformed version', () => {
expect(parseSemver('0.4')).toBeNull();
expect(parseSemver('abc')).toBeNull();
expect(parseSemver('')).toBeNull();
});
test('handles 4-part versions (takes first 3)', () => {
expect(parseSemver('0.2.0.1')).toEqual([0, 2, 0]);
});
});
describe('isMinorOrMajorBump', () => {
test('0.4.0 vs 0.5.0 → update available (minor bump)', () => {
expect(isMinorOrMajorBump('0.4.0', '0.5.0')).toBe(true);
});
test('0.4.0 vs 0.4.1 → NOT available (patch only)', () => {
expect(isMinorOrMajorBump('0.4.0', '0.4.1')).toBe(false);
});
test('0.4.0 vs 1.0.0 → update available (major bump)', () => {
expect(isMinorOrMajorBump('0.4.0', '1.0.0')).toBe(true);
});
test('0.4.0 vs 0.4.0 → NOT available (same version)', () => {
expect(isMinorOrMajorBump('0.4.0', '0.4.0')).toBe(false);
});
test('0.4.0 vs 0.3.0 → NOT available (older)', () => {
expect(isMinorOrMajorBump('0.4.0', '0.3.0')).toBe(false);
});
test('0.4.1 vs 0.5.0 → update available (minor bump, different patch)', () => {
expect(isMinorOrMajorBump('0.4.1', '0.5.0')).toBe(true);
});
test('malformed version → returns false', () => {
expect(isMinorOrMajorBump('0.4.0', 'abc')).toBe(false);
expect(isMinorOrMajorBump('bad', '0.5.0')).toBe(false);
});
test('handles v prefix on latest', () => {
expect(isMinorOrMajorBump('0.4.0', 'v0.5.0')).toBe(true);
});
});
describe('extractChangelogBetween', () => {
const changelog = `# Changelog
## [0.5.0] - 2026-05-01
### Added
- Feature X
## [0.4.1] - 2026-04-15
### Fixed
- Bug Y
## [0.4.0] - 2026-04-09
### Added
- Feature Z
## [0.3.0] - 2026-04-08
### Added
- Feature W
`;
test('extracts entries between 0.4.0 and 0.5.0', () => {
const result = extractChangelogBetween(changelog, '0.4.0', '0.5.0');
expect(result).toContain('Feature X');
expect(result).toContain('Bug Y');
expect(result).not.toContain('Feature Z');
expect(result).not.toContain('Feature W');
});
test('extracts only 0.5.0 when upgrading from 0.4.1', () => {
const result = extractChangelogBetween(changelog, '0.4.1', '0.5.0');
expect(result).toContain('Feature X');
expect(result).not.toContain('Bug Y');
});
test('returns empty for same version', () => {
const result = extractChangelogBetween(changelog, '0.5.0', '0.5.0');
expect(result).toBe('');
});
test('returns empty for malformed from version', () => {
const result = extractChangelogBetween(changelog, 'bad', '0.5.0');
expect(result).toBe('');
});
test('does not capture older major versions incorrectly', () => {
const crossMajor = `# Changelog
## [2.0.0] - 2026-06-01
### Added
- Major 2
## [0.5.0] - 2026-05-01
### Added
- Minor 5
`;
const result = extractChangelogBetween(crossMajor, '1.2.0', '2.0.0');
expect(result).toContain('Major 2');
expect(result).not.toContain('Minor 5');
});
});
describe('check-update CLI', () => {
test('check-update is in CLI_ONLY set', async () => {
const source = await Bun.file(
new URL('../src/cli.ts', import.meta.url).pathname
).text();
expect(source).toContain("'check-update'");
});
test('--help prints usage and exits 0', async () => {
const proc = Bun.spawn(['bun', 'run', 'src/cli.ts', 'check-update', '--help'], {
cwd: new URL('..', import.meta.url).pathname,
stdout: 'pipe',
stderr: 'pipe',
});
const stdout = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
expect(stdout).toContain('check-update');
expect(exitCode).toBe(0);
});
test('--json returns valid JSON with required fields', async () => {
const proc = Bun.spawn(['bun', 'run', 'src/cli.ts', 'check-update', '--json'], {
cwd: new URL('..', import.meta.url).pathname,
stdout: 'pipe',
stderr: 'pipe',
});
const stdout = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
expect(exitCode).toBe(0);
const output = JSON.parse(stdout);
expect(output).toHaveProperty('current_version');
expect(output).toHaveProperty('update_available');
expect(output).toHaveProperty('upgrade_command');
expect(output).toHaveProperty('current_source', 'package-json');
expect(typeof output.update_available).toBe('boolean');
});
});

98
test/e2e/upgrade.test.ts Normal file
View File

@@ -0,0 +1,98 @@
/**
* E2E Upgrade Tests — Tier 1 (no API keys required, needs network)
*
* Tests the check-update command against the real GitHub API.
* Skips gracefully if network is unavailable.
*
* Run: bun test test/e2e/upgrade.test.ts
*/
import { describe, test, expect } from 'bun:test';
import { VERSION } from '../../src/version.ts';
import { isMinorOrMajorBump } from '../../src/commands/check-update.ts';
// Check if we can reach GitHub
async function hasNetwork(): Promise<boolean> {
try {
const res = await fetch('https://api.github.com', { signal: AbortSignal.timeout(5_000) });
return res.ok;
} catch {
return false;
}
}
const skip = !(await hasNetwork());
const describeE2E = skip ? describe.skip : describe;
if (skip) {
console.log('Skipping E2E upgrade tests (network unavailable)');
}
describeE2E('E2E: Check-Update', () => {
test('check-update --json returns valid JSON with current version', async () => {
const proc = Bun.spawn(['bun', 'run', 'src/cli.ts', 'check-update', '--json'], {
cwd: new URL('../..', import.meta.url).pathname,
stdout: 'pipe',
stderr: 'pipe',
});
const stdout = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
expect(exitCode).toBe(0);
const output = JSON.parse(stdout);
expect(output.current_version).toBe(VERSION);
expect(output.current_source).toBe('package-json');
expect(typeof output.update_available).toBe('boolean');
expect(typeof output.upgrade_command).toBe('string');
});
test('check-update without --json prints human-readable output', async () => {
const proc = Bun.spawn(['bun', 'run', 'src/cli.ts', 'check-update'], {
cwd: new URL('../..', import.meta.url).pathname,
stdout: 'pipe',
stderr: 'pipe',
});
const stdout = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
expect(exitCode).toBe(0);
expect(stdout).toContain('GBrain');
});
test('check-update --help prints usage', async () => {
const proc = Bun.spawn(['bun', 'run', 'src/cli.ts', 'check-update', '--help'], {
cwd: new URL('../..', import.meta.url).pathname,
stdout: 'pipe',
stderr: 'pipe',
});
const stdout = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
expect(exitCode).toBe(0);
expect(stdout).toContain('check-update');
expect(stdout).toContain('--json');
});
test('handles no-releases gracefully (current repo state)', async () => {
const proc = Bun.spawn(['bun', 'run', 'src/cli.ts', 'check-update', '--json'], {
cwd: new URL('../..', import.meta.url).pathname,
stdout: 'pipe',
stderr: 'pipe',
});
const stdout = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
expect(exitCode).toBe(0);
const output = JSON.parse(stdout);
// With no releases, should return false and an error
expect(output.update_available).toBe(false);
});
test('version comparison wiring works end-to-end', () => {
// Smoke test that the exported function works correctly
expect(isMinorOrMajorBump('0.4.0', '0.5.0')).toBe(true);
expect(isMinorOrMajorBump('0.4.0', '0.4.1')).toBe(false);
expect(isMinorOrMajorBump('0.4.0', '1.0.0')).toBe(true);
expect(isMinorOrMajorBump('0.4.0', '0.4.0')).toBe(false);
});
});