@versdotsh/reef 0.1.3 → 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/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
|
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
|
+
});
|