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:
22
CHANGELOG.md
22
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
|
||||
|
||||
61
CLAUDE.md
61
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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
0
skills/migrations/.gitkeep
Normal file
0
skills/migrations/.gitkeep
Normal 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
|
||||
|
||||
@@ -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
|
||||
|
||||
179
src/commands/check-update.ts
Normal file
179
src/commands/check-update.ts
Normal 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.`);
|
||||
}
|
||||
}
|
||||
@@ -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
161
test/check-update.test.ts
Normal 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
98
test/e2e/upgrade.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user