@versdotsh/reef 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -0
- package/examples/services/board/README.md +36 -0
- package/examples/services/commits/README.md +12 -0
- package/examples/services/feed/README.md +29 -0
- package/examples/services/journal/README.md +15 -0
- package/examples/services/log/README.md +16 -0
- package/examples/services/registry/README.md +26 -0
- package/examples/services/reports/README.md +12 -0
- package/examples/services/ui/README.md +22 -0
- package/examples/services/usage/README.md +25 -0
- package/package.json +1 -1
- package/services/updater/README.md +40 -0
- package/services/updater/index.ts +289 -0
- package/services/updater/updater.test.ts +64 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.4
|
|
4
|
+
|
|
5
|
+
- Add updater service — auto-update reef from npm
|
|
6
|
+
- `GET /updater/status` — current version, latest available, update history
|
|
7
|
+
- `POST /updater/check` — check npm for newer version
|
|
8
|
+
- `POST /updater/apply` — apply update and restart
|
|
9
|
+
- Optional polling with `UPDATE_POLL_INTERVAL` (minutes)
|
|
10
|
+
- Optional auto-apply with `UPDATE_AUTO_APPLY=true`
|
|
11
|
+
- 126 tests, 324 assertions
|
|
12
|
+
|
|
13
|
+
## 0.1.3
|
|
14
|
+
|
|
15
|
+
- Add READMEs to all example services (board, commits, feed, journal, log, registry, reports, ui, usage)
|
|
16
|
+
|
|
17
|
+
## 0.1.2
|
|
18
|
+
|
|
19
|
+
- Switch to npm trusted publishing via OIDC — no tokens needed
|
|
20
|
+
- Scope package under `@versdotsh/reef`
|
|
21
|
+
|
|
22
|
+
## 0.1.1
|
|
23
|
+
|
|
24
|
+
- Add GitHub Actions CI (test & publish on push to main)
|
|
25
|
+
- Add `.gitignore` for `data/` directory
|
|
26
|
+
- Add test harness (`src/core/testing.ts`) — `createTestHarness()` for isolated service testing
|
|
27
|
+
- Add tests for all example services (board, commits, feed, journal, log, registry, reports, usage)
|
|
28
|
+
- Add UI panels section and testing section to create-service skill
|
|
29
|
+
- 122 tests, 304 assertions
|
|
30
|
+
|
|
31
|
+
## 0.1.0
|
|
32
|
+
|
|
33
|
+
- Initial release
|
|
34
|
+
- Core: dynamic dispatch server, module discovery with topo-sort, bearer token auth
|
|
35
|
+
- Infrastructure services: agent, docs, installer, services manager
|
|
36
|
+
- Agent service: fire-and-forget tasks via `pi -p`, interactive sessions via `pi --mode rpc`, SSE streaming
|
|
37
|
+
- Installer: git clone, local symlink, fleet-to-fleet tarball install/update
|
|
38
|
+
- Docs service: auto-generated API docs from `routeDocs` metadata
|
|
39
|
+
- Services manager: list, reload, unload modules at runtime
|
|
40
|
+
- Example services: board, commits, feed, journal, log, registry, reports, ui, usage
|
|
41
|
+
- Dynamic panel system: services serve `GET /_panel` HTML fragments, UI discovers and injects them
|
|
42
|
+
- UI service: web dashboard with magic link auth, API proxy, chat interface
|
|
43
|
+
- Pi extension: `filterClientModules()` for client-side tool/behavior registration
|
|
44
|
+
- Create-service skill for teaching agents to write new modules
|
|
45
|
+
- 60 core tests
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# board
|
|
2
|
+
|
|
3
|
+
Shared task tracking for agent fleets. Tasks move through a review workflow: `open` → `in_progress` → `in_review` → `done`. Agents claim tasks, add notes and artifacts, bump priority, and submit work for review.
|
|
4
|
+
|
|
5
|
+
## Routes
|
|
6
|
+
|
|
7
|
+
| Method | Path | Description |
|
|
8
|
+
|--------|------|-------------|
|
|
9
|
+
| `POST` | `/board/tasks` | Create a task |
|
|
10
|
+
| `GET` | `/board/tasks` | List tasks (filter: `?status=`, `?assignee=`, `?tag=`) |
|
|
11
|
+
| `GET` | `/board/tasks/:id` | Get a task |
|
|
12
|
+
| `PATCH` | `/board/tasks/:id` | Update a task |
|
|
13
|
+
| `DELETE` | `/board/tasks/:id` | Delete a task |
|
|
14
|
+
| `POST` | `/board/tasks/:id/bump` | Bump priority score |
|
|
15
|
+
| `POST` | `/board/tasks/:id/notes` | Add a note |
|
|
16
|
+
| `GET` | `/board/tasks/:id/notes` | List notes |
|
|
17
|
+
| `POST` | `/board/tasks/:id/artifacts` | Attach an artifact |
|
|
18
|
+
| `POST` | `/board/tasks/:id/review` | Submit for review |
|
|
19
|
+
| `POST` | `/board/tasks/:id/approve` | Approve (sets status to `done`) |
|
|
20
|
+
| `POST` | `/board/tasks/:id/reject` | Reject (sets status back to `open`) |
|
|
21
|
+
| `GET` | `/board/review` | List tasks awaiting review |
|
|
22
|
+
| `GET` | `/board/_panel` | UI panel (HTML fragment) |
|
|
23
|
+
|
|
24
|
+
## Tools
|
|
25
|
+
|
|
26
|
+
- `board_create_task` — create a task with title, description, assignee, tags
|
|
27
|
+
- `board_list_tasks` — list/filter tasks
|
|
28
|
+
- `board_update_task` — update status, assignee, title, tags
|
|
29
|
+
- `board_add_note` — add a finding, question, or update note
|
|
30
|
+
|
|
31
|
+
## Events
|
|
32
|
+
|
|
33
|
+
Emits to the server-side event bus:
|
|
34
|
+
- `board:task_created` — `{ task }`
|
|
35
|
+
- `board:task_updated` — `{ task, changes }`
|
|
36
|
+
- `board:task_deleted` — `{ taskId }`
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# commits
|
|
2
|
+
|
|
3
|
+
VM snapshot ledger. Records which VMs were committed, when, by whom, and with what labels. Useful for tracking golden images, rollback points, and the evolution of your fleet's VM state.
|
|
4
|
+
|
|
5
|
+
## Routes
|
|
6
|
+
|
|
7
|
+
| Method | Path | Description |
|
|
8
|
+
|--------|------|-------------|
|
|
9
|
+
| `POST` | `/commits` | Record a commit (`{ commitId, vmId, label?, agent, tags? }`) |
|
|
10
|
+
| `GET` | `/commits` | List commits (filter: `?tag=`, `?agent=`, `?label=`, `?vmId=`) |
|
|
11
|
+
| `GET` | `/commits/:commitId` | Get a commit by commitId |
|
|
12
|
+
| `DELETE` | `/commits/:commitId` | Delete a commit record |
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# feed
|
|
2
|
+
|
|
3
|
+
Activity event stream for coordination and observability. Services publish events, agents and dashboards consume them. Supports SSE streaming for real-time updates.
|
|
4
|
+
|
|
5
|
+
Automatically listens for server-side events from other modules:
|
|
6
|
+
- `board:task_created` → feed event `task_started`
|
|
7
|
+
- `board:task_updated` → feed event `task_completed` (when status becomes `done`)
|
|
8
|
+
|
|
9
|
+
## Routes
|
|
10
|
+
|
|
11
|
+
| Method | Path | Description |
|
|
12
|
+
|--------|------|-------------|
|
|
13
|
+
| `POST` | `/feed/events` | Publish an event |
|
|
14
|
+
| `GET` | `/feed/events` | List events (filter: `?agent=`, `?type=`, `?since=`, `?limit=`) |
|
|
15
|
+
| `GET` | `/feed/events/:id` | Get an event |
|
|
16
|
+
| `DELETE` | `/feed/events` | Clear all events |
|
|
17
|
+
| `GET` | `/feed/stats` | Event count statistics |
|
|
18
|
+
| `GET` | `/feed/stream` | SSE stream of new events |
|
|
19
|
+
| `GET` | `/feed/_panel` | UI panel (HTML fragment) |
|
|
20
|
+
|
|
21
|
+
## Tools
|
|
22
|
+
|
|
23
|
+
- `feed_publish` — publish an event with agent, type, summary, and optional detail
|
|
24
|
+
- `feed_list` — list/filter recent events
|
|
25
|
+
- `feed_stats` — get summary statistics
|
|
26
|
+
|
|
27
|
+
## Behaviors
|
|
28
|
+
|
|
29
|
+
Auto-publishes feed events when board tasks are created or completed.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# journal
|
|
2
|
+
|
|
3
|
+
Personal narrative log for agents. Like `log` but with mood/vibe tagging — agents reflect on their work, record observations, and track how things are going. Useful for building agent personality and self-awareness over time.
|
|
4
|
+
|
|
5
|
+
## Routes
|
|
6
|
+
|
|
7
|
+
| Method | Path | Description |
|
|
8
|
+
|--------|------|-------------|
|
|
9
|
+
| `POST` | `/journal` | Write an entry (`{ text, author, mood?, tags? }`) |
|
|
10
|
+
| `GET` | `/journal` | List entries |
|
|
11
|
+
| `GET` | `/journal/raw` | Plain text format |
|
|
12
|
+
|
|
13
|
+
## Tools
|
|
14
|
+
|
|
15
|
+
- `journal_entry` — write a journal entry with optional mood and tags
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# log
|
|
2
|
+
|
|
3
|
+
Append-only work log. Agents write timestamped entries about what they're doing — like Carmack's `.plan` file. Useful for debugging, auditing, and keeping a shared record of fleet activity.
|
|
4
|
+
|
|
5
|
+
## Routes
|
|
6
|
+
|
|
7
|
+
| Method | Path | Description |
|
|
8
|
+
|--------|------|-------------|
|
|
9
|
+
| `POST` | `/log` | Append an entry (`{ text, agent }`) |
|
|
10
|
+
| `GET` | `/log` | List entries (filter: `?last=1h`, `?last=7d`) |
|
|
11
|
+
| `GET` | `/log/raw` | Plain text format |
|
|
12
|
+
|
|
13
|
+
## Tools
|
|
14
|
+
|
|
15
|
+
- `log_append` — append a work log entry
|
|
16
|
+
- `log_query` — query entries by time range (`since`, `until`, `last`)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# registry
|
|
2
|
+
|
|
3
|
+
VM service discovery for agent fleets. Agents register themselves with a role, address, and capabilities. Other agents discover peers by role. Heartbeats keep registrations alive.
|
|
4
|
+
|
|
5
|
+
## Routes
|
|
6
|
+
|
|
7
|
+
| Method | Path | Description |
|
|
8
|
+
|--------|------|-------------|
|
|
9
|
+
| `POST` | `/registry/vms` | Register a VM |
|
|
10
|
+
| `GET` | `/registry/vms` | List VMs (filter: `?role=`, `?status=`) |
|
|
11
|
+
| `GET` | `/registry/vms/:id` | Get a VM |
|
|
12
|
+
| `PATCH` | `/registry/vms/:id` | Update a VM |
|
|
13
|
+
| `DELETE` | `/registry/vms/:id` | Deregister a VM |
|
|
14
|
+
| `POST` | `/registry/vms/:id/heartbeat` | Send heartbeat |
|
|
15
|
+
| `GET` | `/registry/discover/:role` | Discover VMs by role |
|
|
16
|
+
|
|
17
|
+
## Tools
|
|
18
|
+
|
|
19
|
+
- `registry_list` — list VMs, optionally filter by role or status
|
|
20
|
+
- `registry_register` — register a VM with id, name, role, address, services
|
|
21
|
+
- `registry_discover` — find VMs by role (worker, lieutenant, etc.)
|
|
22
|
+
- `registry_heartbeat` — keep a registration alive
|
|
23
|
+
|
|
24
|
+
## Behaviors
|
|
25
|
+
|
|
26
|
+
Auto-registers and auto-heartbeats when running as part of a fleet.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# reports
|
|
2
|
+
|
|
3
|
+
Markdown reports. Agents write structured reports — sprint summaries, investigation findings, status updates. Stored with title, author, tags, and timestamp.
|
|
4
|
+
|
|
5
|
+
## Routes
|
|
6
|
+
|
|
7
|
+
| Method | Path | Description |
|
|
8
|
+
|--------|------|-------------|
|
|
9
|
+
| `POST` | `/reports` | Create a report (`{ title, content, author, tags? }`) |
|
|
10
|
+
| `GET` | `/reports` | List reports |
|
|
11
|
+
| `GET` | `/reports/:id` | Get a report |
|
|
12
|
+
| `DELETE` | `/reports/:id` | Delete a report |
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# ui
|
|
2
|
+
|
|
3
|
+
Web dashboard with magic link auth, dynamic panel discovery, and chat interface. Mounts at root — serves `/ui/*` and `/auth/*`.
|
|
4
|
+
|
|
5
|
+
The UI discovers panels from other services at runtime. Any service that serves `GET /_panel` gets a tab in the dashboard. The chat tab connects to the agent service via SSE for streaming conversations with pi.
|
|
6
|
+
|
|
7
|
+
## Routes
|
|
8
|
+
|
|
9
|
+
| Method | Path | Description |
|
|
10
|
+
|--------|------|-------------|
|
|
11
|
+
| `POST` | `/auth/magic-link` | Generate a one-time login URL |
|
|
12
|
+
| `GET` | `/ui/login` | Magic link landing page (sets session cookie) |
|
|
13
|
+
| `GET` | `/ui/` | Dashboard shell |
|
|
14
|
+
| `GET` | `/ui/static/:file` | Static assets (JS, CSS) |
|
|
15
|
+
| `ALL` | `/ui/api/*` | Auth proxy — injects bearer token so the browser never needs it |
|
|
16
|
+
|
|
17
|
+
## How it works
|
|
18
|
+
|
|
19
|
+
1. **Auth**: `POST /auth/magic-link` with bearer token → returns a URL. Opening it sets a 24h session cookie.
|
|
20
|
+
2. **Panel discovery**: The dashboard polls `GET /services` every 30s, fetches `GET /<service>/_panel` for each, and injects the HTML as tabs.
|
|
21
|
+
3. **Chat**: Creates a pi RPC session via the agent service, streams responses via SSE, renders markdown with collapsible tool calls.
|
|
22
|
+
4. **API proxy**: All requests to `/ui/api/*` are forwarded with the bearer token from the session, so panel scripts and chat can call any service endpoint without exposing the token to the browser.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# usage
|
|
2
|
+
|
|
3
|
+
Cost and token tracking for agent fleets. Records per-session token usage, cost breakdowns, and VM lifecycle events. Provides summaries by agent and time range.
|
|
4
|
+
|
|
5
|
+
Depends on `feed` — publishes `agent_stopped` events when sessions end.
|
|
6
|
+
|
|
7
|
+
## Routes
|
|
8
|
+
|
|
9
|
+
| Method | Path | Description |
|
|
10
|
+
|--------|------|-------------|
|
|
11
|
+
| `GET` | `/usage` | Usage summary (filter: `?range=7d`) |
|
|
12
|
+
| `POST` | `/usage/sessions` | Record a session |
|
|
13
|
+
| `GET` | `/usage/sessions` | List sessions (filter: `?agent=`, `?range=`) |
|
|
14
|
+
| `POST` | `/usage/vms` | Record a VM lifecycle event |
|
|
15
|
+
| `GET` | `/usage/vms` | List VM records |
|
|
16
|
+
|
|
17
|
+
## Tools
|
|
18
|
+
|
|
19
|
+
- `usage_summary` — get cost & token totals by agent and time range
|
|
20
|
+
- `usage_sessions` — list session records with tokens, cost, turns, tool calls
|
|
21
|
+
- `usage_vms` — list VM lifecycle records
|
|
22
|
+
|
|
23
|
+
## Behaviors
|
|
24
|
+
|
|
25
|
+
Auto-records usage data from feed events.
|
package/package.json
CHANGED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# updater
|
|
2
|
+
|
|
3
|
+
Auto-update the reef server from npm. Checks for new versions of `@versdotsh/reef`, downloads updates, and restarts the process.
|
|
4
|
+
|
|
5
|
+
## Routes
|
|
6
|
+
|
|
7
|
+
| Method | Path | Description |
|
|
8
|
+
|--------|------|-------------|
|
|
9
|
+
| `GET` | `/updater/status` | Current version, latest available, poll config, update history |
|
|
10
|
+
| `POST` | `/updater/check` | Check npm for a newer version |
|
|
11
|
+
| `POST` | `/updater/apply` | Apply the update and restart the server |
|
|
12
|
+
|
|
13
|
+
## Configuration
|
|
14
|
+
|
|
15
|
+
| Env var | Default | Description |
|
|
16
|
+
|---------|---------|-------------|
|
|
17
|
+
| `UPDATE_POLL_INTERVAL` | `0` | Check interval in minutes (0 = manual only) |
|
|
18
|
+
| `UPDATE_AUTO_APPLY` | `false` | Automatically apply updates when found |
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
**Manual update:**
|
|
23
|
+
```bash
|
|
24
|
+
# Check for updates
|
|
25
|
+
curl -X POST http://localhost:3000/updater/check -H "Authorization: Bearer $TOKEN"
|
|
26
|
+
|
|
27
|
+
# Apply if available
|
|
28
|
+
curl -X POST http://localhost:3000/updater/apply -H "Authorization: Bearer $TOKEN"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**Auto-update every 30 minutes:**
|
|
32
|
+
```bash
|
|
33
|
+
UPDATE_POLL_INTERVAL=30 UPDATE_AUTO_APPLY=true bun run start
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## How it works
|
|
37
|
+
|
|
38
|
+
1. **Check** — fetches `https://registry.npmjs.org/@versdotsh/reef/latest` and compares versions
|
|
39
|
+
2. **Apply** — runs `bun update @versdotsh/reef`, then spawns a new server process and exits the current one
|
|
40
|
+
3. **Poll** — if `UPDATE_POLL_INTERVAL` is set, checks on a timer; if `UPDATE_AUTO_APPLY` is also set, applies automatically
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Updater service module — auto-update the reef server.
|
|
3
|
+
*
|
|
4
|
+
* Checks npm for new versions of @versdotsh/reef and applies updates.
|
|
5
|
+
* Can run on a schedule (poll interval) or be triggered manually via API.
|
|
6
|
+
*
|
|
7
|
+
* Routes:
|
|
8
|
+
* GET /updater/status — current version, latest available, update history
|
|
9
|
+
* POST /updater/check — check for updates now
|
|
10
|
+
* POST /updater/apply — download and apply the latest version, then restart
|
|
11
|
+
*
|
|
12
|
+
* Env vars:
|
|
13
|
+
* UPDATE_POLL_INTERVAL — check interval in minutes (default: 0 = disabled)
|
|
14
|
+
* UPDATE_AUTO_APPLY — automatically apply updates when found (default: false)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Hono } from "hono";
|
|
18
|
+
import { execSync, spawn } from "node:child_process";
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import type { ServiceModule, ServiceContext } from "../../src/core/types.js";
|
|
22
|
+
|
|
23
|
+
interface UpdateRecord {
|
|
24
|
+
from: string;
|
|
25
|
+
to: string;
|
|
26
|
+
timestamp: string;
|
|
27
|
+
status: "applied" | "failed";
|
|
28
|
+
error?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const PACKAGE_NAME = "@versdotsh/reef";
|
|
32
|
+
|
|
33
|
+
let currentVersion: string = "unknown";
|
|
34
|
+
let latestVersion: string | null = null;
|
|
35
|
+
let lastChecked: string | null = null;
|
|
36
|
+
let updateAvailable = false;
|
|
37
|
+
let checking = false;
|
|
38
|
+
let applying = false;
|
|
39
|
+
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
40
|
+
let history: UpdateRecord[] = [];
|
|
41
|
+
|
|
42
|
+
function loadCurrentVersion(): string {
|
|
43
|
+
try {
|
|
44
|
+
// Walk up from services/updater to find package.json
|
|
45
|
+
const paths = [
|
|
46
|
+
join(import.meta.dir, "..", "..", "package.json"),
|
|
47
|
+
join(process.cwd(), "package.json"),
|
|
48
|
+
];
|
|
49
|
+
for (const p of paths) {
|
|
50
|
+
try {
|
|
51
|
+
const pkg = JSON.parse(readFileSync(p, "utf-8"));
|
|
52
|
+
if (pkg.name === PACKAGE_NAME || pkg.name === "reef") {
|
|
53
|
+
return pkg.version;
|
|
54
|
+
}
|
|
55
|
+
} catch {}
|
|
56
|
+
}
|
|
57
|
+
// Fallback: ask bun
|
|
58
|
+
const out = execSync("bun pm ls 2>/dev/null | grep reef || true", {
|
|
59
|
+
encoding: "utf-8",
|
|
60
|
+
timeout: 5000,
|
|
61
|
+
}).trim();
|
|
62
|
+
const match = out.match(/@[\d.]+/);
|
|
63
|
+
return match ? match[0].slice(1) : "unknown";
|
|
64
|
+
} catch {
|
|
65
|
+
return "unknown";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function checkForUpdate(): Promise<{
|
|
70
|
+
current: string;
|
|
71
|
+
latest: string;
|
|
72
|
+
updateAvailable: boolean;
|
|
73
|
+
}> {
|
|
74
|
+
checking = true;
|
|
75
|
+
try {
|
|
76
|
+
const res = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
|
|
77
|
+
headers: { Accept: "application/json" },
|
|
78
|
+
signal: AbortSignal.timeout(10000),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (!res.ok) {
|
|
82
|
+
throw new Error(`npm registry returned ${res.status}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const data = (await res.json()) as { version: string };
|
|
86
|
+
latestVersion = data.version;
|
|
87
|
+
lastChecked = new Date().toISOString();
|
|
88
|
+
updateAvailable = latestVersion !== currentVersion;
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
current: currentVersion,
|
|
92
|
+
latest: latestVersion,
|
|
93
|
+
updateAvailable,
|
|
94
|
+
};
|
|
95
|
+
} finally {
|
|
96
|
+
checking = false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function applyUpdate(): Promise<UpdateRecord> {
|
|
101
|
+
if (!latestVersion || !updateAvailable) {
|
|
102
|
+
throw new Error("No update available — run check first");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
applying = true;
|
|
106
|
+
const from = currentVersion;
|
|
107
|
+
const to = latestVersion;
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
// Update the package
|
|
111
|
+
execSync(`bun update ${PACKAGE_NAME}`, {
|
|
112
|
+
encoding: "utf-8",
|
|
113
|
+
timeout: 60000,
|
|
114
|
+
cwd: process.cwd(),
|
|
115
|
+
stdio: "pipe",
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const record: UpdateRecord = {
|
|
119
|
+
from,
|
|
120
|
+
to,
|
|
121
|
+
timestamp: new Date().toISOString(),
|
|
122
|
+
status: "applied",
|
|
123
|
+
};
|
|
124
|
+
history.push(record);
|
|
125
|
+
|
|
126
|
+
// Schedule restart — give time for the response to be sent
|
|
127
|
+
setTimeout(() => {
|
|
128
|
+
console.log(` [updater] restarting after update ${from} → ${to}`);
|
|
129
|
+
|
|
130
|
+
// Spawn a new process with the same args, then exit
|
|
131
|
+
const child = spawn(process.argv[0], process.argv.slice(1), {
|
|
132
|
+
cwd: process.cwd(),
|
|
133
|
+
env: process.env,
|
|
134
|
+
stdio: "inherit",
|
|
135
|
+
detached: true,
|
|
136
|
+
});
|
|
137
|
+
child.unref();
|
|
138
|
+
|
|
139
|
+
// Give the child a moment to start, then exit
|
|
140
|
+
setTimeout(() => process.exit(0), 500);
|
|
141
|
+
}, 1000);
|
|
142
|
+
|
|
143
|
+
return record;
|
|
144
|
+
} catch (err) {
|
|
145
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
146
|
+
const record: UpdateRecord = {
|
|
147
|
+
from,
|
|
148
|
+
to,
|
|
149
|
+
timestamp: new Date().toISOString(),
|
|
150
|
+
status: "failed",
|
|
151
|
+
error: msg,
|
|
152
|
+
};
|
|
153
|
+
history.push(record);
|
|
154
|
+
throw new Error(`Update failed: ${msg}`);
|
|
155
|
+
} finally {
|
|
156
|
+
applying = false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Routes
|
|
161
|
+
const routes = new Hono();
|
|
162
|
+
|
|
163
|
+
routes.get("/status", (c) =>
|
|
164
|
+
c.json({
|
|
165
|
+
package: PACKAGE_NAME,
|
|
166
|
+
current: currentVersion,
|
|
167
|
+
latest: latestVersion,
|
|
168
|
+
updateAvailable,
|
|
169
|
+
lastChecked,
|
|
170
|
+
checking,
|
|
171
|
+
applying,
|
|
172
|
+
pollInterval: parseInt(process.env.UPDATE_POLL_INTERVAL || "0", 10),
|
|
173
|
+
autoApply: process.env.UPDATE_AUTO_APPLY === "true",
|
|
174
|
+
history,
|
|
175
|
+
}),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
routes.post("/check", async (c) => {
|
|
179
|
+
if (checking) {
|
|
180
|
+
return c.json({ error: "Already checking" }, 409);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const result = await checkForUpdate();
|
|
185
|
+
return c.json(result);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
188
|
+
return c.json({ error: msg }, 502);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
routes.post("/apply", async (c) => {
|
|
193
|
+
if (applying) {
|
|
194
|
+
return c.json({ error: "Already applying an update" }, 409);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!updateAvailable) {
|
|
198
|
+
// Check first
|
|
199
|
+
try {
|
|
200
|
+
await checkForUpdate();
|
|
201
|
+
} catch (err) {
|
|
202
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
203
|
+
return c.json({ error: `Check failed: ${msg}` }, 502);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (!updateAvailable) {
|
|
207
|
+
return c.json({
|
|
208
|
+
message: "Already up to date",
|
|
209
|
+
current: currentVersion,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
const record = await applyUpdate();
|
|
216
|
+
return c.json({
|
|
217
|
+
message: `Updated ${record.from} → ${record.to} — restarting...`,
|
|
218
|
+
...record,
|
|
219
|
+
});
|
|
220
|
+
} catch (err) {
|
|
221
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
222
|
+
return c.json({ error: msg }, 500);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// Module definition
|
|
227
|
+
const updater: ServiceModule = {
|
|
228
|
+
name: "updater",
|
|
229
|
+
description: "Auto-update reef from npm",
|
|
230
|
+
routes,
|
|
231
|
+
|
|
232
|
+
routeDocs: {
|
|
233
|
+
"GET /status": {
|
|
234
|
+
description: "Current version, latest available, update history",
|
|
235
|
+
response: "{ package, current, latest, updateAvailable, lastChecked, checking, applying, pollInterval, autoApply, history }",
|
|
236
|
+
},
|
|
237
|
+
"POST /check": {
|
|
238
|
+
description: "Check npm for a newer version",
|
|
239
|
+
response: "{ current, latest, updateAvailable }",
|
|
240
|
+
},
|
|
241
|
+
"POST /apply": {
|
|
242
|
+
description: "Apply the latest update and restart the server",
|
|
243
|
+
response: "{ message, from, to, timestamp, status }",
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
init(ctx: ServiceContext) {
|
|
248
|
+
currentVersion = loadCurrentVersion();
|
|
249
|
+
|
|
250
|
+
const pollMinutes = parseInt(process.env.UPDATE_POLL_INTERVAL || "0", 10);
|
|
251
|
+
const autoApply = process.env.UPDATE_AUTO_APPLY === "true";
|
|
252
|
+
|
|
253
|
+
if (pollMinutes > 0) {
|
|
254
|
+
console.log(
|
|
255
|
+
` [updater] polling every ${pollMinutes}m${autoApply ? " (auto-apply)" : ""}`,
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
pollTimer = setInterval(async () => {
|
|
259
|
+
try {
|
|
260
|
+
const result = await checkForUpdate();
|
|
261
|
+
if (result.updateAvailable) {
|
|
262
|
+
console.log(
|
|
263
|
+
` [updater] new version available: ${result.current} → ${result.latest}`,
|
|
264
|
+
);
|
|
265
|
+
if (autoApply) {
|
|
266
|
+
console.log(` [updater] auto-applying update...`);
|
|
267
|
+
await applyUpdate();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
} catch (err) {
|
|
271
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
272
|
+
console.error(` [updater] check failed: ${msg}`);
|
|
273
|
+
}
|
|
274
|
+
}, pollMinutes * 60 * 1000);
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
store: {
|
|
279
|
+
close() {
|
|
280
|
+
if (pollTimer) {
|
|
281
|
+
clearInterval(pollTimer);
|
|
282
|
+
pollTimer = null;
|
|
283
|
+
}
|
|
284
|
+
return Promise.resolve();
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
export default updater;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, test, expect, afterAll } from "bun:test";
|
|
2
|
+
import { createTestHarness, type TestHarness } from "../../src/core/testing.js";
|
|
3
|
+
import updater from "./index.js";
|
|
4
|
+
|
|
5
|
+
let t: TestHarness;
|
|
6
|
+
const setup = (async () => {
|
|
7
|
+
t = await createTestHarness({ services: [updater] });
|
|
8
|
+
})();
|
|
9
|
+
afterAll(() => t?.cleanup());
|
|
10
|
+
|
|
11
|
+
describe("updater", () => {
|
|
12
|
+
test("returns status", async () => {
|
|
13
|
+
await setup;
|
|
14
|
+
const { status, data } = await t.json<any>("/updater/status", { auth: true });
|
|
15
|
+
expect(status).toBe(200);
|
|
16
|
+
expect(data.package).toBe("@versdotsh/reef");
|
|
17
|
+
expect(data.current).toBeDefined();
|
|
18
|
+
expect(data.updateAvailable).toBe(false);
|
|
19
|
+
expect(data.checking).toBe(false);
|
|
20
|
+
expect(data.applying).toBe(false);
|
|
21
|
+
expect(data.history).toEqual([]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("checks for updates from npm", async () => {
|
|
25
|
+
await setup;
|
|
26
|
+
const { status, data } = await t.json<any>("/updater/check", {
|
|
27
|
+
method: "POST",
|
|
28
|
+
auth: true,
|
|
29
|
+
});
|
|
30
|
+
expect(status).toBe(200);
|
|
31
|
+
expect(data.current).toBeDefined();
|
|
32
|
+
expect(data.latest).toBeDefined();
|
|
33
|
+
expect(typeof data.updateAvailable).toBe("boolean");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("apply when already up to date returns message", async () => {
|
|
37
|
+
await setup;
|
|
38
|
+
// After check, if versions match, apply should say "already up to date"
|
|
39
|
+
const { data: checkData } = await t.json<any>("/updater/check", {
|
|
40
|
+
method: "POST",
|
|
41
|
+
auth: true,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Only test the "already up to date" path — don't actually apply
|
|
45
|
+
if (!checkData.updateAvailable) {
|
|
46
|
+
const { status, data } = await t.json<any>("/updater/apply", {
|
|
47
|
+
method: "POST",
|
|
48
|
+
auth: true,
|
|
49
|
+
});
|
|
50
|
+
expect(status).toBe(200);
|
|
51
|
+
expect(data.message).toContain("up to date");
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("requires auth", async () => {
|
|
56
|
+
await setup;
|
|
57
|
+
const { status: s1 } = await t.json("/updater/status");
|
|
58
|
+
expect(s1).toBe(401);
|
|
59
|
+
const { status: s2 } = await t.json("/updater/check", { method: "POST" });
|
|
60
|
+
expect(s2).toBe(401);
|
|
61
|
+
const { status: s3 } = await t.json("/updater/apply", { method: "POST" });
|
|
62
|
+
expect(s3).toBe(401);
|
|
63
|
+
});
|
|
64
|
+
});
|