pi-freeflow 1.4.12 → 1.5.0
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/package.json +11 -3
- package/src/catalog.ts +80 -5
- package/src/commands.ts +172 -128
- package/src/health.ts +106 -0
- package/src/index.ts +57 -1
- package/src/models.ts +58 -64
- package/src/proxy.ts +63 -64
- package/src/relay-state.ts +19 -9
- package/src/relay.ts +23 -12
- package/src/types.ts +172 -165
- package/src/update-checker.ts +12 -18
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-freeflow",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.5.0",
|
|
5
5
|
"description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
|
|
6
6
|
"main": "extensions/index.ts",
|
|
7
7
|
"types": "src/index.ts",
|
|
@@ -45,11 +45,19 @@
|
|
|
45
45
|
"scripts": {
|
|
46
46
|
"test": "node --experimental-strip-types --test --test-concurrency=1 test/**/*.test.ts",
|
|
47
47
|
"typecheck": "tsc --noEmit",
|
|
48
|
-
"smoke": "node --experimental-strip-types -e \"import('./extensions/index.ts').then(() => console.log('✓ Smoke test passed: extensions/index.ts loaded successfully')).catch(err => { console.error(err); process.exit(1); })\""
|
|
48
|
+
"smoke": "node --experimental-strip-types -e \"import('./extensions/index.ts').then(() => console.log('✓ Smoke test passed: extensions/index.ts loaded successfully')).catch(err => { console.error(err); process.exit(1); })\"",
|
|
49
|
+
"changeset": "changeset",
|
|
50
|
+
"docs:dev": "vitepress dev docs",
|
|
51
|
+
"docs:build": "vitepress build docs"
|
|
49
52
|
},
|
|
50
53
|
"devDependencies": {
|
|
54
|
+
"@changesets/cli": "^2.27.0",
|
|
51
55
|
"@earendil-works/pi-coding-agent": "^0.84.3",
|
|
52
56
|
"@types/node": "^22.13.9",
|
|
53
|
-
"typescript": "^5.8.2"
|
|
57
|
+
"typescript": "^5.8.2",
|
|
58
|
+
"vitepress": "^1.6.4"
|
|
59
|
+
},
|
|
60
|
+
"dependencies": {
|
|
61
|
+
"undici": "^8.10.0"
|
|
54
62
|
}
|
|
55
63
|
}
|
package/src/catalog.ts
CHANGED
|
@@ -169,6 +169,7 @@ export function enrichModelDef(raw: RawModelItem, source: Upstream): RegisteredM
|
|
|
169
169
|
/**
|
|
170
170
|
* Read cached catalog data from disk if valid and unexpired.
|
|
171
171
|
* Filters out pruned dead IDs so stale disk entries never repopulate the catalog.
|
|
172
|
+
* Preserves etag for conditional If-None-Match requests.
|
|
172
173
|
*/
|
|
173
174
|
export function readCatalogCache(): CatalogCacheData | null {
|
|
174
175
|
try {
|
|
@@ -191,6 +192,7 @@ export function readCatalogCache(): CatalogCacheData | null {
|
|
|
191
192
|
|
|
192
193
|
/**
|
|
193
194
|
* Atomically write catalog cache data to disk using temporary file + rename.
|
|
195
|
+
* Persists etag alongside models for subsequent If-None-Match conditional requests.
|
|
194
196
|
*/
|
|
195
197
|
export function writeCatalogCache(data: CatalogCacheData): void {
|
|
196
198
|
try {
|
|
@@ -208,12 +210,13 @@ export function writeCatalogCache(data: CatalogCacheData): void {
|
|
|
208
210
|
|
|
209
211
|
/**
|
|
210
212
|
* Refresh free model catalog from OpenCode Zen and KiloCode Gateway endpoints.
|
|
211
|
-
*
|
|
213
|
+
* Uses ETag conditional requests (If-None-Match) to avoid re-merging unchanged
|
|
214
|
+
* catalogs; a 304 Not Modified response skips merge and returns the in-memory
|
|
215
|
+
* catalog unchanged. Falls back gracefully to cached or static models if network
|
|
216
|
+
* requests fail.
|
|
212
217
|
*/
|
|
213
218
|
export async function refreshCatalog(force = false): Promise<RegisteredModel[]> {
|
|
214
|
-
// Thin provider:
|
|
215
|
-
// (proxy-only). Host Pi/OMP owns dynamic discovery via fetchDynamicModels (24h).
|
|
216
|
-
// We only serve disk cache if fresh, otherwise static 21-model aliveCatalog.
|
|
219
|
+
// Thin provider: serve fresh disk cache instantly when valid
|
|
217
220
|
const disk = readCatalogCache();
|
|
218
221
|
if (disk && Array.isArray(disk.models) && disk.models.length > 0) {
|
|
219
222
|
const age = Date.now() - (disk.timestamp ?? 0);
|
|
@@ -221,7 +224,79 @@ export async function refreshCatalog(force = false): Promise<RegisteredModel[]>
|
|
|
221
224
|
aliveCatalog = disk.models.filter((m) => !DEAD_MODEL_IDS.has(m.id));
|
|
222
225
|
return aliveCatalog;
|
|
223
226
|
}
|
|
224
|
-
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Resolve etag from fresh or stale cache for conditional request
|
|
230
|
+
let cachedEtag: string | undefined = disk?.etag;
|
|
231
|
+
let staleForEtag: CatalogCacheData | null = disk;
|
|
232
|
+
if (!cachedEtag) {
|
|
233
|
+
try {
|
|
234
|
+
if (fs.existsSync(CATALOG_CACHE_FILE)) {
|
|
235
|
+
const raw = fs.readFileSync(CATALOG_CACHE_FILE, "utf8");
|
|
236
|
+
const stale = JSON.parse(raw) as CatalogCacheData;
|
|
237
|
+
cachedEtag = stale.etag;
|
|
238
|
+
staleForEtag = stale;
|
|
239
|
+
}
|
|
240
|
+
} catch (err) {
|
|
241
|
+
logDebug("Failed reading stale catalog cache for etag", { error: String(err) });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Attempt conditional fetch with If-None-Match when we have an etag
|
|
246
|
+
if (cachedEtag || force) {
|
|
247
|
+
try {
|
|
248
|
+
const headers: Record<string, string> = { ...opencodeHeaders() };
|
|
249
|
+
if (cachedEtag) {
|
|
250
|
+
headers["If-None-Match"] = cachedEtag;
|
|
251
|
+
}
|
|
252
|
+
const res = await fetch(`${OPENCODE_API_URL}/models`, { headers });
|
|
253
|
+
if (res.status === 304) {
|
|
254
|
+
// Not modified — skip merge, extend timestamp to avoid tight loop
|
|
255
|
+
if (staleForEtag && Array.isArray(staleForEtag.models)) {
|
|
256
|
+
try {
|
|
257
|
+
writeCatalogCache({ ...staleForEtag, timestamp: Date.now() });
|
|
258
|
+
} catch {}
|
|
259
|
+
}
|
|
260
|
+
return aliveCatalog;
|
|
261
|
+
}
|
|
262
|
+
if (res.ok) {
|
|
263
|
+
const newEtag = res.headers.get("etag") ?? res.headers.get("ETag") ?? cachedEtag;
|
|
264
|
+
const body: unknown = await res.json();
|
|
265
|
+
let rawList: RawModelItem[] = [];
|
|
266
|
+
if (Array.isArray(body)) {
|
|
267
|
+
rawList = body as RawModelItem[];
|
|
268
|
+
} else if (body !== null && typeof body === "object" && "data" in body) {
|
|
269
|
+
const dataVal = body.data as unknown;
|
|
270
|
+
if (Array.isArray(dataVal)) {
|
|
271
|
+
rawList = dataVal as RawModelItem[];
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (rawList.length > 0) {
|
|
275
|
+
const fresh = rawList.map((r) => enrichModelDef(r, "opencode"));
|
|
276
|
+
const merged = mergeCatalog(aliveCatalog, fresh);
|
|
277
|
+
aliveCatalog = merged;
|
|
278
|
+
writeCatalogCache({
|
|
279
|
+
timestamp: Date.now(),
|
|
280
|
+
opencode: fresh.map((m) => m.id),
|
|
281
|
+
kilo: staleForEtag?.kilo ?? [],
|
|
282
|
+
models: merged,
|
|
283
|
+
etag: newEtag ?? cachedEtag,
|
|
284
|
+
});
|
|
285
|
+
return aliveCatalog;
|
|
286
|
+
}
|
|
287
|
+
// Empty payload but 200 — treat as no-op, return current
|
|
288
|
+
if (newEtag && newEtag !== cachedEtag && staleForEtag) {
|
|
289
|
+
writeCatalogCache({ ...staleForEtag, timestamp: Date.now(), etag: newEtag });
|
|
290
|
+
}
|
|
291
|
+
return aliveCatalog;
|
|
292
|
+
}
|
|
293
|
+
} catch (err) {
|
|
294
|
+
logDebug("Conditional catalog fetch failed, falling back to cache", { error: String(err) });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Stale cache still better than empty — return it without network (filtered)
|
|
299
|
+
if (disk && Array.isArray(disk.models) && disk.models.length > 0) {
|
|
225
300
|
const filtered = disk.models.filter((m) => !DEAD_MODEL_IDS.has(m.id));
|
|
226
301
|
if (filtered.length >= 21) {
|
|
227
302
|
aliveCatalog = filtered;
|
package/src/commands.ts
CHANGED
|
@@ -4,17 +4,17 @@
|
|
|
4
4
|
* log viewing, debug level configuration, and live catalog refreshing.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { spawn } from "node:child_process";
|
|
8
|
-
import { readFileSync } from "node:fs";
|
|
9
|
-
import path from "node:path";
|
|
10
|
-
import { fileURLToPath } from "node:url";
|
|
11
|
-
import { refreshCatalog, setAliveCatalog } from "./catalog.ts";
|
|
12
|
-
import { DEBUG_STATE_FILE, DEFAULT_RELAY_URL, LOG_FILE } from "./config.ts";
|
|
13
|
-
import {
|
|
14
|
-
compareVersions,
|
|
15
|
-
fetchLatestVersion,
|
|
16
|
-
getCachedUpdate,
|
|
17
|
-
isLinkedInstall,
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { refreshCatalog, setAliveCatalog } from "./catalog.ts";
|
|
12
|
+
import { DEBUG_STATE_FILE, DEFAULT_RELAY_URL, LOG_FILE } from "./config.ts";
|
|
13
|
+
import {
|
|
14
|
+
compareVersions,
|
|
15
|
+
fetchLatestVersion,
|
|
16
|
+
getCachedUpdate,
|
|
17
|
+
isLinkedInstall,
|
|
18
18
|
} from "./update-checker.ts";
|
|
19
19
|
import {
|
|
20
20
|
deployCloudflareWorker,
|
|
@@ -60,6 +60,10 @@ import type {
|
|
|
60
60
|
export function updateStatusBar(ui?: ExtensionUIContext): void {
|
|
61
61
|
if (!ui) return;
|
|
62
62
|
const relayState = getActiveRelayState();
|
|
63
|
+
if (relayState.hideWidget) {
|
|
64
|
+
ui.setStatus("freeflow", undefined);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
63
67
|
if (relayState.enabled && relayState.relays.length > 0) {
|
|
64
68
|
const label = shortRelayLabel(relayState.url);
|
|
65
69
|
const idx = Math.max(
|
|
@@ -76,64 +80,68 @@ export function updateStatusBar(ui?: ExtensionUIContext): void {
|
|
|
76
80
|
}
|
|
77
81
|
}
|
|
78
82
|
|
|
79
|
-
function getLocalVersion(): string {
|
|
80
|
-
try {
|
|
81
|
-
const thisDir = path.dirname(fileURLToPath(import.meta.url));
|
|
82
|
-
const pkgPath = path.join(thisDir, "..", "package.json");
|
|
83
|
-
const raw = readFileSync(pkgPath, "utf8");
|
|
84
|
-
const pkg = JSON.parse(raw) as { version?: string };
|
|
85
|
-
if (typeof pkg.version === "string" && pkg.version.trim().length > 0) {
|
|
86
|
-
return pkg.version.trim();
|
|
87
|
-
}
|
|
88
|
-
return "0.0.0";
|
|
89
|
-
} catch {
|
|
90
|
-
return "0.0.0";
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function spawnWithProgress(
|
|
95
|
-
cmd: string,
|
|
96
|
-
args: string[],
|
|
97
|
-
ctx: ExtensionContext,
|
|
98
|
-
): Promise<number> {
|
|
99
|
-
return new Promise((resolve) => {
|
|
100
|
-
try {
|
|
101
|
-
const child = spawn(cmd, args, {
|
|
102
|
-
shell: process.platform === "win32",
|
|
103
|
-
stdio: "pipe",
|
|
104
|
-
});
|
|
105
|
-
child.stdout?.on("data", (d: Buffer) => {
|
|
106
|
-
const s = String(d).trim();
|
|
107
|
-
if (s) ctx.ui.notify(s, "info");
|
|
108
|
-
});
|
|
109
|
-
child.stderr?.on("data", (d: Buffer) => {
|
|
110
|
-
const s = String(d).trim();
|
|
111
|
-
if (s) ctx.ui.notify(s, "info");
|
|
112
|
-
});
|
|
113
|
-
child.on("error", (err: Error) => {
|
|
114
|
-
ctx.ui.notify(`spawn ${cmd} failed: ${err.message}`, "warning");
|
|
115
|
-
resolve(1);
|
|
116
|
-
});
|
|
117
|
-
child.on("close", (code: number | null) => resolve(code ?? 0));
|
|
118
|
-
} catch (e) {
|
|
119
|
-
ctx.ui.notify(`spawn ${cmd} failed: ${(e as Error).message}`, "warning");
|
|
120
|
-
resolve(1);
|
|
121
|
-
}
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
|
|
83
|
+
function getLocalVersion(): string {
|
|
84
|
+
try {
|
|
85
|
+
const thisDir = path.dirname(fileURLToPath(import.meta.url));
|
|
86
|
+
const pkgPath = path.join(thisDir, "..", "package.json");
|
|
87
|
+
const raw = readFileSync(pkgPath, "utf8");
|
|
88
|
+
const pkg = JSON.parse(raw) as { version?: string };
|
|
89
|
+
if (typeof pkg.version === "string" && pkg.version.trim().length > 0) {
|
|
90
|
+
return pkg.version.trim();
|
|
91
|
+
}
|
|
92
|
+
return "0.0.0";
|
|
93
|
+
} catch {
|
|
94
|
+
return "0.0.0";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function spawnWithProgress(
|
|
99
|
+
cmd: string,
|
|
100
|
+
args: string[],
|
|
101
|
+
ctx: ExtensionContext,
|
|
102
|
+
): Promise<number> {
|
|
103
|
+
return new Promise((resolve) => {
|
|
104
|
+
try {
|
|
105
|
+
const child = spawn(cmd, args, {
|
|
106
|
+
shell: process.platform === "win32",
|
|
107
|
+
stdio: "pipe",
|
|
108
|
+
});
|
|
109
|
+
child.stdout?.on("data", (d: Buffer) => {
|
|
110
|
+
const s = String(d).trim();
|
|
111
|
+
if (s) ctx.ui.notify(s, "info");
|
|
112
|
+
});
|
|
113
|
+
child.stderr?.on("data", (d: Buffer) => {
|
|
114
|
+
const s = String(d).trim();
|
|
115
|
+
if (s) ctx.ui.notify(s, "info");
|
|
116
|
+
});
|
|
117
|
+
child.on("error", (err: Error) => {
|
|
118
|
+
ctx.ui.notify(`spawn ${cmd} failed: ${err.message}`, "warning");
|
|
119
|
+
resolve(1);
|
|
120
|
+
});
|
|
121
|
+
child.on("close", (code: number | null) => resolve(code ?? 0));
|
|
122
|
+
} catch (e) {
|
|
123
|
+
ctx.ui.notify(`spawn ${cmd} failed: ${(e as Error).message}`, "warning");
|
|
124
|
+
resolve(1);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
125
129
|
export function createCommandSpec(
|
|
126
130
|
_pi: ExtensionAPI,
|
|
127
131
|
onCatalogRefreshed?: (models: RegisteredModel[]) => void,
|
|
128
132
|
): Omit<RegisteredCommand, "name"> {
|
|
129
133
|
return {
|
|
130
134
|
description:
|
|
131
|
-
"Relay egress: auto | on | off | status | add <URL> [name] | list | use <URL|name|index> [name] | label <target> <name> | remove <target> | logs [level] [n] | debug on|off | refresh | update | deploy vercel | deploy cloudflare | deploy deno",
|
|
135
|
+
"Relay egress: auto | on | off | hide | show | widget hide/show | status | add <URL> [name] | list | use <URL|name|index> [name] | label <target> <name> | remove <target> | logs [level] [n] | debug on|off | refresh | update | deploy vercel | deploy cloudflare | deploy deno",
|
|
132
136
|
getArgumentCompletions: (prefix: string) =>
|
|
133
137
|
[
|
|
134
138
|
"auto",
|
|
135
139
|
"on",
|
|
136
140
|
"off",
|
|
141
|
+
"hide",
|
|
142
|
+
"show",
|
|
143
|
+
"widget hide",
|
|
144
|
+
"widget show",
|
|
137
145
|
"status",
|
|
138
146
|
"add",
|
|
139
147
|
"list",
|
|
@@ -402,73 +410,83 @@ export function createCommandSpec(
|
|
|
402
410
|
setActiveRelayState(relayState);
|
|
403
411
|
persist();
|
|
404
412
|
flash();
|
|
405
|
-
} else if (sub === "
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
413
|
+
} else if (sub === "hide" || (sub === "widget" && rest === "hide")) {
|
|
414
|
+
relayState.hideWidget = true;
|
|
415
|
+
persist();
|
|
416
|
+
updateStatusBar(ctx.ui);
|
|
417
|
+
ctx.ui.notify("Widget hidden — use /freeflow show or /freeflow widget show to restore", "info");
|
|
418
|
+
} else if (sub === "show" || (sub === "widget" && rest === "show")) {
|
|
419
|
+
relayState.hideWidget = false;
|
|
420
|
+
persist();
|
|
421
|
+
flash();
|
|
422
|
+
ctx.ui.notify("Widget shown", "info");
|
|
423
|
+
} else if (sub === "status") {
|
|
424
|
+
flash();
|
|
425
|
+
try {
|
|
426
|
+
const local = getLocalVersion();
|
|
427
|
+
let latest: string | null = null;
|
|
428
|
+
const cached = getCachedUpdate();
|
|
429
|
+
if (cached && typeof cached.latest === "string") {
|
|
430
|
+
latest = cached.latest;
|
|
431
|
+
}
|
|
432
|
+
if (latest && compareVersions(latest, local) > 0) {
|
|
433
|
+
ctx.ui.notify(
|
|
434
|
+
`Update available: ${local} -> ${latest} - run /freeflow update`,
|
|
435
|
+
"info",
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
} catch {
|
|
439
|
+
// swallow — status banner is best-effort
|
|
440
|
+
}
|
|
441
|
+
} else if (sub === "update") {
|
|
442
|
+
if (isLinkedInstall()) {
|
|
443
|
+
ctx.ui.notify(
|
|
444
|
+
"LINK install (D:/github_repo/pi-freeflow) is live - just omp restart, no npm update needed.",
|
|
445
|
+
"info",
|
|
446
|
+
);
|
|
447
|
+
} else {
|
|
448
|
+
try {
|
|
449
|
+
ctx.ui.notify("Checking for updates…", "info");
|
|
450
|
+
let latest: string | null = null;
|
|
451
|
+
try {
|
|
452
|
+
latest = await fetchLatestVersion();
|
|
453
|
+
} catch {
|
|
454
|
+
latest = null;
|
|
455
|
+
}
|
|
456
|
+
if (!latest) {
|
|
457
|
+
const cached = getCachedUpdate();
|
|
458
|
+
if (cached && typeof cached.latest === "string") {
|
|
459
|
+
latest = cached.latest;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
if (!latest) {
|
|
463
|
+
ctx.ui.notify("Could not check latest version (offline?)", "warning");
|
|
464
|
+
} else {
|
|
465
|
+
const local = getLocalVersion();
|
|
466
|
+
const cmp = compareVersions(latest, local);
|
|
467
|
+
if (cmp <= 0) {
|
|
468
|
+
ctx.ui.notify(`Already on latest (v${local})`, "info");
|
|
469
|
+
} else {
|
|
470
|
+
ctx.ui.notify(`Update available: v${local} → v${latest} — updating…`, "info");
|
|
471
|
+
let code = await spawnWithProgress("omp", ["plugin", "update", "pi-freeflow"], ctx);
|
|
472
|
+
if (code !== 0) {
|
|
473
|
+
ctx.ui.notify(`omp update exited ${code}, trying npm…`, "info");
|
|
474
|
+
code = await spawnWithProgress("npm", ["i", "-g", "pi-freeflow@latest"], ctx);
|
|
475
|
+
}
|
|
476
|
+
if (code === 0) {
|
|
477
|
+
ctx.ui.notify(`Updated to ${latest}, restart OMP`, "info");
|
|
478
|
+
} else {
|
|
479
|
+
ctx.ui.notify(
|
|
480
|
+
`Update failed (exit ${code}) — try manually: npm i -g pi-freeflow@latest`,
|
|
481
|
+
"warning",
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
} catch (e) {
|
|
487
|
+
ctx.ui.notify(`Update failed: ${(e as Error).message} — try manually: npm i -g pi-freeflow@latest`, "warning");
|
|
488
|
+
}
|
|
489
|
+
}
|
|
472
490
|
} else if (sub === "list") {
|
|
473
491
|
showList();
|
|
474
492
|
} else if (sub === "add") {
|
|
@@ -611,12 +629,14 @@ export function createCommandSpec(
|
|
|
611
629
|
let filterLevel: LogLevel | null = null;
|
|
612
630
|
let filterReqId: string | null = null;
|
|
613
631
|
let count = 25;
|
|
632
|
+
const rawTokens = rawRest ? rawRest.split(/\s+/) : [];
|
|
633
|
+
const isFollow = rawTokens.includes("--follow") || rawTokens.includes("-f");
|
|
614
634
|
|
|
615
635
|
if (sub === "trace" && rawRest) {
|
|
616
|
-
filterReqId =
|
|
636
|
+
filterReqId = rawTokens.filter((t) => t !== "--follow" && t !== "-f")[0] || null;
|
|
617
637
|
} else if (rawRest) {
|
|
618
|
-
const
|
|
619
|
-
|
|
638
|
+
for (const t of rawTokens) {
|
|
639
|
+
if (t === "--follow" || t === "-f") continue;
|
|
620
640
|
const lower = t.toLowerCase();
|
|
621
641
|
if (lower in LOG_LEVEL_ORDER) {
|
|
622
642
|
filterLevel = lower as LogLevel;
|
|
@@ -655,6 +675,18 @@ export function createCommandSpec(
|
|
|
655
675
|
"warning",
|
|
656
676
|
);
|
|
657
677
|
}
|
|
678
|
+
if (isFollow) {
|
|
679
|
+
const _t = setInterval(() => {
|
|
680
|
+
try {
|
|
681
|
+
const tail = readRecentLogs(filterLevel, filterReqId, count);
|
|
682
|
+
if (tail.lines.length > 0) {
|
|
683
|
+
ctx.ui.notify(tail.lines.join("\n"), "info");
|
|
684
|
+
}
|
|
685
|
+
} catch {}
|
|
686
|
+
}, 1000);
|
|
687
|
+
// @ts-ignore allow unref to not block process exit in CLI
|
|
688
|
+
_t.unref?.();
|
|
689
|
+
}
|
|
658
690
|
return;
|
|
659
691
|
}
|
|
660
692
|
|
|
@@ -663,6 +695,18 @@ export function createCommandSpec(
|
|
|
663
695
|
`${header}\n\n${result.lines.join("\n")}`,
|
|
664
696
|
"info",
|
|
665
697
|
);
|
|
698
|
+
if (isFollow) {
|
|
699
|
+
const _t2 = setInterval(() => {
|
|
700
|
+
try {
|
|
701
|
+
const tail = readRecentLogs(filterLevel, filterReqId, count);
|
|
702
|
+
if (tail.lines.length > 0) {
|
|
703
|
+
ctx.ui.notify(tail.lines.join("\n"), "info");
|
|
704
|
+
}
|
|
705
|
+
} catch {}
|
|
706
|
+
}, 1000);
|
|
707
|
+
// @ts-ignore allow unref to not block process exit in CLI
|
|
708
|
+
_t2.unref?.();
|
|
709
|
+
}
|
|
666
710
|
} catch (e) {
|
|
667
711
|
ctx.ui.notify(
|
|
668
712
|
`Could not read log file: ${(e as Error).message}`,
|
package/src/health.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health endpoint for pi-freeflow proxy
|
|
3
|
+
* Loopback-only GET /_health (and alias /health) returning relay health snapshot.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type * as http from "node:http";
|
|
7
|
+
import { ALL_MODELS } from "./models.ts";
|
|
8
|
+
import { getActiveRelayState, getRelayHealth, isRelayHealthy } from "./relay-state.ts";
|
|
9
|
+
import { PORT } from "./config.ts";
|
|
10
|
+
|
|
11
|
+
export interface HealthRelayInfo {
|
|
12
|
+
url: string;
|
|
13
|
+
label?: string;
|
|
14
|
+
healthy: boolean;
|
|
15
|
+
cooldownUntil: number;
|
|
16
|
+
consecutiveFailures: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface HealthData {
|
|
20
|
+
port: number;
|
|
21
|
+
active: string;
|
|
22
|
+
mode: string;
|
|
23
|
+
enabled: boolean;
|
|
24
|
+
relays: HealthRelayInfo[];
|
|
25
|
+
catalog: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Collect current health snapshot.
|
|
30
|
+
* @param portOverride - actual listening port (defaults to config PORT)
|
|
31
|
+
*/
|
|
32
|
+
export function getHealthData(portOverride?: number): HealthData {
|
|
33
|
+
const state = getActiveRelayState();
|
|
34
|
+
const relays: HealthRelayInfo[] = (state.relays || []).map((r) => {
|
|
35
|
+
const h = getRelayHealth(r.url);
|
|
36
|
+
const healthy = isRelayHealthy(r.url);
|
|
37
|
+
return {
|
|
38
|
+
url: r.url,
|
|
39
|
+
label: r.label,
|
|
40
|
+
healthy,
|
|
41
|
+
cooldownUntil: h?.cooldownUntil ?? 0,
|
|
42
|
+
consecutiveFailures: h?.consecutiveFailures ?? 0,
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
return {
|
|
46
|
+
port: portOverride ?? PORT,
|
|
47
|
+
active: state.url || "",
|
|
48
|
+
mode: (state.mode as string) ?? "auto",
|
|
49
|
+
enabled: Boolean(state.enabled),
|
|
50
|
+
relays,
|
|
51
|
+
catalog: ALL_MODELS.length,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isLoopbackIP(ip: string): boolean {
|
|
56
|
+
if (!ip) return false;
|
|
57
|
+
const clean = ip.startsWith("::ffff:") ? ip.slice(7) : ip;
|
|
58
|
+
return clean === "127.0.0.1" || clean === "::1" || clean === "localhost";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getClientIPFromReq(req: http.IncomingMessage): string {
|
|
62
|
+
const addr = (req.socket as unknown as { remoteAddress?: string })?.remoteAddress;
|
|
63
|
+
if (!addr) return "unknown";
|
|
64
|
+
return addr.startsWith("::ffff:") ? addr.slice(7) : addr;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Handle loopback health requests.
|
|
69
|
+
* Returns true if request was a health endpoint (handled, response already sent).
|
|
70
|
+
* Returns false if not a health path (caller should continue).
|
|
71
|
+
*/
|
|
72
|
+
export function handleHealthRequest(
|
|
73
|
+
req: http.IncomingMessage,
|
|
74
|
+
res: http.ServerResponse,
|
|
75
|
+
portOverride?: number,
|
|
76
|
+
): boolean {
|
|
77
|
+
let pathname: string | null = null;
|
|
78
|
+
try {
|
|
79
|
+
pathname = new URL(req.url ?? "/", "http://127.0.0.1").pathname;
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const isHealthPath =
|
|
85
|
+
pathname === "/_health" || pathname === "/health" || pathname.endsWith("/health");
|
|
86
|
+
if (req.method !== "GET" || !isHealthPath) {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const clientIP = getClientIPFromReq(req);
|
|
91
|
+
if (!isLoopbackIP(clientIP)) {
|
|
92
|
+
const body = JSON.stringify({ error: "forbidden" });
|
|
93
|
+
res.writeHead(403, { "content-type": "application/json", "content-length": Buffer.byteLength(body) });
|
|
94
|
+
res.end(body);
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const data = getHealthData(portOverride);
|
|
99
|
+
const body = JSON.stringify(data);
|
|
100
|
+
res.writeHead(200, {
|
|
101
|
+
"content-type": "application/json",
|
|
102
|
+
"content-length": Buffer.byteLength(body),
|
|
103
|
+
});
|
|
104
|
+
res.end(body);
|
|
105
|
+
return true;
|
|
106
|
+
}
|