diff --git a/CHANGELOG.md b/CHANGELOG.md index 820418ed7..7027631c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index d4e8f6b55..0f3ba79e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index 5345ea46a..9e15a8e0a 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/VERSION b/VERSION index 1d0ba9ea1..267577d47 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.0 +0.4.1 diff --git a/docs/GBRAIN_RECOMMENDED_SCHEMA.md b/docs/GBRAIN_RECOMMENDED_SCHEMA.md index 98988ad41..b1569d2b0 100644 --- a/docs/GBRAIN_RECOMMENDED_SCHEMA.md +++ b/docs/GBRAIN_RECOMMENDED_SCHEMA.md @@ -1,3 +1,5 @@ + + # 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. diff --git a/docs/GBRAIN_SKILLPACK.md b/docs/GBRAIN_SKILLPACK.md index c49700961..747a111a1 100644 --- a/docs/GBRAIN_SKILLPACK.md +++ b/docs/GBRAIN_SKILLPACK.md @@ -1,3 +1,5 @@ + + # 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: + +``` + + +``` + +**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: +``` + +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. diff --git a/openclaw.plugin.json b/openclaw.plugin.json index c86a15dcb..974c34a7e 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -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": { diff --git a/package.json b/package.json index a38135221..d15b0c60b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/skills/migrations/.gitkeep b/skills/migrations/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/skills/setup/SKILL.md b/skills/setup/SKILL.md index 84d4dcecd..50cc62ec8 100644 --- a/skills/setup/SKILL.md +++ b/skills/setup/SKILL.md @@ -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 --no-embed [--workers N]` -- import files - `gbrain search ` -- search brain - `gbrain doctor --json` -- health check +- `gbrain check-update --json` -- check for updates - `gbrain embed refresh` -- generate embeddings diff --git a/src/cli.ts b/src/cli.ts index 48223cc7f..f7c2d4803 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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 ] Create brain (guided wizard) upgrade Self-update + check-update [--json] Check for new versions doctor [--json] Health check (pgvector, RLS, schema, embeddings) PAGES diff --git a/src/commands/check-update.ts b/src/commands/check-update.ts new file mode 100644 index 000000000..cdefde186 --- /dev/null +++ b/src/commands/check-update.ts @@ -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 { + 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.`); + } +} diff --git a/src/commands/upgrade.ts b/src/commands/upgrade.ts index ecdee2f0f..35a7e736a 100644 --- a/src/commands/upgrade.ts +++ b/src/commands/upgrade.ts @@ -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) diff --git a/test/check-update.test.ts b/test/check-update.test.ts new file mode 100644 index 000000000..1f82404ce --- /dev/null +++ b/test/check-update.test.ts @@ -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'); + }); +}); diff --git a/test/e2e/upgrade.test.ts b/test/e2e/upgrade.test.ts new file mode 100644 index 000000000..ecbe31e35 --- /dev/null +++ b/test/e2e/upgrade.test.ts @@ -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 { + 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); + }); +});