pi-freeflow 1.5.0 → 1.7.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/README.md +62 -86
- package/package.json +2 -3
- package/src/catalog.ts +21 -12
- package/src/commands.ts +308 -146
- package/src/config.ts +24 -2
- package/src/deploy.ts +128 -185
- package/src/health.ts +2 -7
- package/src/index.ts +22 -4
- package/src/logger.ts +52 -24
- package/src/models.ts +139 -67
- package/src/probe.ts +49 -0
- package/src/proxy.ts +150 -57
- package/src/relay-state.ts +380 -69
- package/src/relay.ts +72 -26
- package/src/stream-pipe.ts +307 -271
- package/src/types.ts +4 -2
- package/src/update-checker.ts +21 -5
package/src/commands.ts
CHANGED
|
@@ -5,16 +5,14 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
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
8
|
import { refreshCatalog, setAliveCatalog } from "./catalog.ts";
|
|
12
|
-
import { DEBUG_STATE_FILE,
|
|
9
|
+
import { DEBUG_STATE_FILE, LOG_FILE, RELAY_STATE_FILE } from "./config.ts";
|
|
13
10
|
import {
|
|
14
11
|
compareVersions,
|
|
15
12
|
fetchLatestVersion,
|
|
16
13
|
getCachedUpdate,
|
|
17
14
|
isLinkedInstall,
|
|
15
|
+
getLocalVersion,
|
|
18
16
|
} from "./update-checker.ts";
|
|
19
17
|
import {
|
|
20
18
|
deployCloudflareWorker,
|
|
@@ -30,18 +28,21 @@ import {
|
|
|
30
28
|
readRecentLogs,
|
|
31
29
|
saveDebugState,
|
|
32
30
|
} from "./logger.ts";
|
|
31
|
+
import { probeRelay } from "./probe.ts";
|
|
33
32
|
import {
|
|
34
33
|
ensureRelay,
|
|
35
34
|
findRelay,
|
|
36
35
|
getActiveRelayState,
|
|
37
36
|
getRelayHealth,
|
|
38
|
-
isRelayHealthy,
|
|
39
37
|
removeRelay,
|
|
40
|
-
saveRelayState,
|
|
41
38
|
setActiveRelayState,
|
|
42
39
|
setRelayLabel,
|
|
43
40
|
setStatusUi,
|
|
44
41
|
shortRelayLabel,
|
|
42
|
+
withRelayState,
|
|
43
|
+
formatRelayFlash,
|
|
44
|
+
formatRelayPickerItem,
|
|
45
|
+
formatRelayStatusLabel,
|
|
45
46
|
} from "./relay-state.ts";
|
|
46
47
|
import type {
|
|
47
48
|
ExtensionAPI,
|
|
@@ -59,40 +60,7 @@ import type {
|
|
|
59
60
|
*/
|
|
60
61
|
export function updateStatusBar(ui?: ExtensionUIContext): void {
|
|
61
62
|
if (!ui) return;
|
|
62
|
-
|
|
63
|
-
if (relayState.hideWidget) {
|
|
64
|
-
ui.setStatus("freeflow", undefined);
|
|
65
|
-
return;
|
|
66
|
-
}
|
|
67
|
-
if (relayState.enabled && relayState.relays.length > 0) {
|
|
68
|
-
const label = shortRelayLabel(relayState.url);
|
|
69
|
-
const idx = Math.max(
|
|
70
|
-
1,
|
|
71
|
-
relayState.relays.findIndex((r) => r.url === relayState.url) + 1,
|
|
72
|
-
);
|
|
73
|
-
const total = relayState.relays.length;
|
|
74
|
-
const modeLabel = relayState.mode === "on" ? "ON" : "AUTO (ON)";
|
|
75
|
-
ui.setStatus("freeflow", `relay: ${modeLabel} | ${label} ${idx}/${total}`);
|
|
76
|
-
} else if (relayState.mode === "off" || !relayState.enabled) {
|
|
77
|
-
ui.setStatus("freeflow", "relay: OFF (direct)");
|
|
78
|
-
} else {
|
|
79
|
-
ui.setStatus("freeflow", undefined);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
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
|
-
}
|
|
63
|
+
ui.setStatus("freeflow", formatRelayStatusLabel(getActiveRelayState()) ?? undefined);
|
|
96
64
|
}
|
|
97
65
|
|
|
98
66
|
function spawnWithProgress(
|
|
@@ -126,13 +94,55 @@ function spawnWithProgress(
|
|
|
126
94
|
});
|
|
127
95
|
}
|
|
128
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Follow-mode log poller: tracks the count of matched lines already printed
|
|
99
|
+
* and notifies only lines beyond it, so repeated ticks never re-notify the
|
|
100
|
+
* same tail. One poller at a time — a re-run clears the previous interval.
|
|
101
|
+
*/
|
|
102
|
+
let logsFollowTimer: NodeJS.Timeout | null = null;
|
|
103
|
+
function startLogsFollow(
|
|
104
|
+
ctx: ExtensionContext,
|
|
105
|
+
filterLevel: LogLevel | null,
|
|
106
|
+
filterReqId: string | null,
|
|
107
|
+
count: number,
|
|
108
|
+
filterText: string | null,
|
|
109
|
+
baselineMatched: number,
|
|
110
|
+
): void {
|
|
111
|
+
if (logsFollowTimer) {
|
|
112
|
+
clearInterval(logsFollowTimer);
|
|
113
|
+
logsFollowTimer = null;
|
|
114
|
+
}
|
|
115
|
+
let lastPrinted = baselineMatched;
|
|
116
|
+
logsFollowTimer = setInterval(() => {
|
|
117
|
+
try {
|
|
118
|
+
const tail = readRecentLogs(filterLevel, filterReqId, count, filterText);
|
|
119
|
+
if (tail.totalMatched < lastPrinted) {
|
|
120
|
+
// Log rotated/rewritten below the baseline — re-baseline silently.
|
|
121
|
+
lastPrinted = tail.totalMatched;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const newCount = tail.totalMatched - lastPrinted;
|
|
125
|
+
if (newCount <= 0) return;
|
|
126
|
+
const newLines = tail.lines.slice(
|
|
127
|
+
tail.lines.length - Math.min(newCount, tail.lines.length),
|
|
128
|
+
);
|
|
129
|
+
if (newLines.length > 0) {
|
|
130
|
+
ctx.ui.notify(newLines.join("\n"), "info");
|
|
131
|
+
}
|
|
132
|
+
lastPrinted = tail.totalMatched;
|
|
133
|
+
} catch {}
|
|
134
|
+
}, 1000);
|
|
135
|
+
// @ts-ignore allow unref to not block process exit in CLI
|
|
136
|
+
logsFollowTimer.unref?.();
|
|
137
|
+
}
|
|
138
|
+
|
|
129
139
|
export function createCommandSpec(
|
|
130
140
|
_pi: ExtensionAPI,
|
|
131
141
|
onCatalogRefreshed?: (models: RegisteredModel[]) => void,
|
|
132
142
|
): Omit<RegisteredCommand, "name"> {
|
|
133
143
|
return {
|
|
134
144
|
description:
|
|
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",
|
|
145
|
+
"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> | test <target> | logs [level] [n] | debug on|off | refresh | update | deploy vercel | deploy cloudflare | deploy deno",
|
|
136
146
|
getArgumentCompletions: (prefix: string) =>
|
|
137
147
|
[
|
|
138
148
|
"auto",
|
|
@@ -149,6 +159,7 @@ export function createCommandSpec(
|
|
|
149
159
|
"label",
|
|
150
160
|
"rename",
|
|
151
161
|
"remove",
|
|
162
|
+
"test",
|
|
152
163
|
"url",
|
|
153
164
|
"deploy",
|
|
154
165
|
"deploy vercel",
|
|
@@ -168,39 +179,40 @@ export function createCommandSpec(
|
|
|
168
179
|
.split(/\s+/);
|
|
169
180
|
const sub = parts[0] || "";
|
|
170
181
|
const rest = parts.slice(1).join(" ");
|
|
171
|
-
|
|
182
|
+
let relayState = getActiveRelayState();
|
|
172
183
|
|
|
173
184
|
const flash = () => {
|
|
174
|
-
|
|
175
|
-
const activeIdx = Math.max(
|
|
176
|
-
1,
|
|
177
|
-
relayState.relays.findIndex((r) => r.url === relayState.url) + 1,
|
|
178
|
-
);
|
|
179
|
-
const total = relayState.relays.length || 1;
|
|
180
|
-
const modeStr = (relayState.mode || "auto").toUpperCase();
|
|
181
|
-
ctx.ui.notify(
|
|
182
|
-
`Relay mode: ${modeStr} (${relayState.enabled ? "ON" : "OFF"})${relayState.enabled ? ` → ${activeLabel} (${activeIdx}/${total})` : " (direct)"} | saved=${relayState.relays.length} (auto-fallback rolling)`,
|
|
183
|
-
"info",
|
|
184
|
-
);
|
|
185
|
+
ctx.ui.notify(formatRelayFlash(relayState), "info");
|
|
185
186
|
};
|
|
186
187
|
|
|
188
|
+
// UI refresh only — every disk write flows through CAS
|
|
189
|
+
// (withRelayState) so the operation is re-applied to the freshest
|
|
190
|
+
// on-disk state at write time instead of a stale snapshot.
|
|
187
191
|
const persist = () => {
|
|
188
|
-
setActiveRelayState(relayState, true);
|
|
189
192
|
setStatusUi(ctx.ui);
|
|
190
193
|
updateStatusBar(ctx.ui);
|
|
191
194
|
};
|
|
195
|
+
/** Re-apply an operation to the freshest on-disk state and persist it. */
|
|
196
|
+
const applyRelayState = (
|
|
197
|
+
updater: (s: RelayState) => RelayState,
|
|
198
|
+
): RelayState => {
|
|
199
|
+
relayState = withRelayState(updater);
|
|
200
|
+
return relayState;
|
|
201
|
+
};
|
|
192
202
|
const setRelay = (
|
|
193
203
|
enabled: boolean,
|
|
194
204
|
url: string,
|
|
195
205
|
addLabel?: string,
|
|
196
206
|
) => {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
207
|
+
applyRelayState((s) => {
|
|
208
|
+
s.enabled = enabled;
|
|
209
|
+
const cleanUrl = (url || "").trim() || "";
|
|
210
|
+
s.url = cleanUrl;
|
|
211
|
+
if (cleanUrl) {
|
|
212
|
+
ensureRelay(s, cleanUrl, addLabel);
|
|
213
|
+
}
|
|
214
|
+
return s;
|
|
215
|
+
});
|
|
204
216
|
};
|
|
205
217
|
|
|
206
218
|
const addRelayInteractive = async () => {
|
|
@@ -222,12 +234,16 @@ export function createCommandSpec(
|
|
|
222
234
|
)
|
|
223
235
|
)?.trim() || defaultLabel;
|
|
224
236
|
|
|
225
|
-
|
|
226
|
-
|
|
237
|
+
applyRelayState((s) => {
|
|
238
|
+
const added = ensureRelay(s, inputUrl, inputLabel);
|
|
239
|
+
s.enabled = true;
|
|
240
|
+
s.url = added.url;
|
|
241
|
+
return s;
|
|
242
|
+
});
|
|
227
243
|
persist();
|
|
228
244
|
flash();
|
|
229
245
|
ctx.ui.notify(
|
|
230
|
-
`✓ Added & activated relay [${
|
|
246
|
+
`✓ Added & activated relay [${shortRelayLabel(relayState.url, relayState.relays)}]: ${relayState.url}`,
|
|
231
247
|
"info",
|
|
232
248
|
);
|
|
233
249
|
};
|
|
@@ -256,7 +272,10 @@ export function createCommandSpec(
|
|
|
256
272
|
ctx.ui.notify("Cancelled — short name not changed", "warning");
|
|
257
273
|
return;
|
|
258
274
|
}
|
|
259
|
-
|
|
275
|
+
applyRelayState((s) => {
|
|
276
|
+
setRelayLabel(s, match.url, newLabel);
|
|
277
|
+
return s;
|
|
278
|
+
});
|
|
260
279
|
persist();
|
|
261
280
|
flash();
|
|
262
281
|
ctx.ui.notify(
|
|
@@ -265,6 +284,12 @@ export function createCommandSpec(
|
|
|
265
284
|
);
|
|
266
285
|
};
|
|
267
286
|
|
|
287
|
+
const DEPLOY_OPTIONS: Record<string, DeployPlatform> = {
|
|
288
|
+
"Vercel (1M req/mo — recommended)": "vercel",
|
|
289
|
+
"Cloudflare (100k req/day)": "cloudflare",
|
|
290
|
+
"Deno Deploy (100k req/day)": "deno",
|
|
291
|
+
};
|
|
292
|
+
|
|
268
293
|
const parseDeployPlatform = (raw?: string): DeployPlatform | null => {
|
|
269
294
|
const v = (raw || "").trim().toLowerCase();
|
|
270
295
|
if (!v || v === "vercel") return "vercel";
|
|
@@ -281,6 +306,13 @@ export function createCommandSpec(
|
|
|
281
306
|
: platform === "deno"
|
|
282
307
|
? "Deno Deploy"
|
|
283
308
|
: "Vercel";
|
|
309
|
+
const tokenHint =
|
|
310
|
+
platform === "cloudflare"
|
|
311
|
+
? "Cloudflare relay — token: dash.cloudflare.com → API Tokens (Workers Scripts: Edit)"
|
|
312
|
+
: platform === "deno"
|
|
313
|
+
? "Deno Deploy relay — token: dash.deno.com → Access Tokens"
|
|
314
|
+
: "Vercel relay — token: vercel.com/account/tokens";
|
|
315
|
+
ctx.ui.notify(tokenHint, "info");
|
|
284
316
|
const token = (
|
|
285
317
|
await ctx.ui.input(`${label} API token:`, "")
|
|
286
318
|
)?.trim();
|
|
@@ -296,6 +328,17 @@ export function createCommandSpec(
|
|
|
296
328
|
)
|
|
297
329
|
)?.trim() || defaultName;
|
|
298
330
|
|
|
331
|
+
if (typeof ctx.ui.confirm === "function") {
|
|
332
|
+
const ok = await ctx.ui.confirm(
|
|
333
|
+
"Deploy relay",
|
|
334
|
+
`Deploy ${label} relay '${name}' using token ending ${token.slice(-4)}?`,
|
|
335
|
+
);
|
|
336
|
+
if (!ok) {
|
|
337
|
+
ctx.ui.notify("Deploy cancelled", "warning");
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
299
342
|
ctx.ui.setStatus("freeflow", `deploying ${platform} relay…`);
|
|
300
343
|
try {
|
|
301
344
|
const deployer =
|
|
@@ -304,12 +347,26 @@ export function createCommandSpec(
|
|
|
304
347
|
: platform === "deno"
|
|
305
348
|
? deployDenoRelay
|
|
306
349
|
: deployVercelRelay;
|
|
307
|
-
const url = await deployer(token, name, (m) =>
|
|
350
|
+
const { url, auth } = await deployer(token, name, (m) =>
|
|
308
351
|
ctx.ui.notify(m, "info"),
|
|
309
352
|
);
|
|
310
353
|
setRelay(true, url, `deployed ${name}`);
|
|
354
|
+
// Persist the per-deployment shared secret with the relay entry
|
|
355
|
+
// (setRelay wrote without it; this save must include it).
|
|
356
|
+
const deployed = relayState.relays.find((r) => r.url === url);
|
|
357
|
+
if (deployed) deployed.auth = auth;
|
|
358
|
+
setActiveRelayState(relayState);
|
|
311
359
|
persist();
|
|
312
|
-
|
|
360
|
+
let probeNote = "";
|
|
361
|
+
try {
|
|
362
|
+
const probe = await probeRelay(url, auth);
|
|
363
|
+
probeNote = probe.ok
|
|
364
|
+
? ` ✓ reachable (HTTP ${probe.status}, ${probe.latencyMs}ms)`
|
|
365
|
+
: ` ⚠ deployed but unreachable (${probe.error || `HTTP ${probe.status}`}) — verify with /freeflow test`;
|
|
366
|
+
} catch {
|
|
367
|
+
// probeRelay never throws, but keep the notify safe regardless
|
|
368
|
+
}
|
|
369
|
+
ctx.ui.notify(`✓ Deployed & active: ${url}${probeNote}`, "info");
|
|
313
370
|
} catch (e) {
|
|
314
371
|
updateStatusBar(ctx.ui);
|
|
315
372
|
ctx.ui.notify(
|
|
@@ -324,17 +381,19 @@ export function createCommandSpec(
|
|
|
324
381
|
ctx.ui.notify("No saved relays yet", "warning");
|
|
325
382
|
return;
|
|
326
383
|
}
|
|
327
|
-
const fmt = (r: KnownRelay, idx: number) =>
|
|
328
|
-
const isAct = r.url === relayState.url ? "★ " : " ";
|
|
329
|
-
const lbl = r.label ? `[${r.label}] ` : `[${shortRelayLabel(r.url, relayState.relays)}] `;
|
|
330
|
-
return `${isAct}[${idx + 1}] ${lbl}→ ${r.url}`;
|
|
331
|
-
};
|
|
384
|
+
const fmt = (r: KnownRelay, idx: number) => formatRelayPickerItem(r, idx, relayState.url);
|
|
332
385
|
const opts = relayState.relays.map(fmt);
|
|
333
386
|
const choice = await ctx.ui.select("Switch active relay", opts);
|
|
334
387
|
if (!choice) return;
|
|
335
388
|
const match = relayState.relays.find((r, idx) => fmt(r, idx) === choice);
|
|
336
389
|
if (!match) return;
|
|
337
|
-
|
|
390
|
+
applyRelayState((s) => {
|
|
391
|
+
const fresh = s.relays.find((r) => r.url === match.url);
|
|
392
|
+
if (!fresh) return s;
|
|
393
|
+
s.enabled = true;
|
|
394
|
+
s.url = fresh.url;
|
|
395
|
+
return s;
|
|
396
|
+
});
|
|
338
397
|
persist();
|
|
339
398
|
flash();
|
|
340
399
|
};
|
|
@@ -353,8 +412,13 @@ export function createCommandSpec(
|
|
|
353
412
|
const remainingSec = isCooling ? Math.ceil((health.cooldownUntil - Date.now()) / 1000) : 0;
|
|
354
413
|
const healthBadge = isCooling
|
|
355
414
|
? ` ⚠️ [cooling ${remainingSec}s: ${health.lastStatus ? `HTTP ${health.lastStatus}` : "error"}]`
|
|
356
|
-
:
|
|
357
|
-
|
|
415
|
+
: health?.lastLatencyMs != null && Number.isFinite(health.lastLatencyMs)
|
|
416
|
+
? ` ✓ [${health.lastLatencyMs}ms]`
|
|
417
|
+
: " ✓";
|
|
418
|
+
const counterText = health && (health.successCount != null || health.failureCount != null)
|
|
419
|
+
? ` ${health.successCount ?? 0} ok / ${health.failureCount ?? 0} fail`
|
|
420
|
+
: "";
|
|
421
|
+
return `${star} [${idx + 1}] ${paddedName} → ${r.url}${healthBadge}${counterText}`;
|
|
358
422
|
});
|
|
359
423
|
const activeLabel = shortRelayLabel(relayState.url, relayState.relays);
|
|
360
424
|
const activeIdx = Math.max(
|
|
@@ -377,10 +441,7 @@ export function createCommandSpec(
|
|
|
377
441
|
);
|
|
378
442
|
return;
|
|
379
443
|
}
|
|
380
|
-
const fmt = (r: KnownRelay, idx: number) =>
|
|
381
|
-
const lbl = r.label ? `[${r.label}] ` : `[${shortRelayLabel(r.url, relayState.relays)}] `;
|
|
382
|
-
return `[${idx + 1}] ${lbl}→ ${r.url}`;
|
|
383
|
-
};
|
|
444
|
+
const fmt = (r: KnownRelay, idx: number) => formatRelayPickerItem(r, idx);
|
|
384
445
|
const choice = await ctx.ui.select(
|
|
385
446
|
"Remove relay",
|
|
386
447
|
removable.map(fmt),
|
|
@@ -388,35 +449,53 @@ export function createCommandSpec(
|
|
|
388
449
|
if (!choice) return;
|
|
389
450
|
const match = removable.find((r, idx) => fmt(r, idx) === choice);
|
|
390
451
|
if (!match) return;
|
|
391
|
-
|
|
452
|
+
applyRelayState((s) => {
|
|
453
|
+
removeRelay(s, match.url);
|
|
454
|
+
return s;
|
|
455
|
+
});
|
|
392
456
|
persist();
|
|
393
457
|
ctx.ui.notify(`Removed: [${shortRelayLabel(match.url, relayState.relays)}] ${match.url}`, "info");
|
|
394
458
|
};
|
|
395
459
|
|
|
396
460
|
if (sub === "auto") {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
461
|
+
applyRelayState((s) => {
|
|
462
|
+
s.mode = "auto";
|
|
463
|
+
s.enabled = true;
|
|
464
|
+
s.url = s.url || "";
|
|
465
|
+
if (s.url) {
|
|
466
|
+
ensureRelay(s, s.url);
|
|
467
|
+
}
|
|
468
|
+
return s;
|
|
469
|
+
});
|
|
400
470
|
persist();
|
|
401
471
|
flash();
|
|
402
472
|
} else if (sub === "on") {
|
|
403
|
-
|
|
404
|
-
|
|
473
|
+
applyRelayState((s) => {
|
|
474
|
+
s.mode = "on";
|
|
475
|
+
s.enabled = true;
|
|
476
|
+
s.url = s.url || "";
|
|
477
|
+
if (s.url) {
|
|
478
|
+
ensureRelay(s, s.url);
|
|
479
|
+
}
|
|
480
|
+
return s;
|
|
481
|
+
});
|
|
405
482
|
persist();
|
|
406
483
|
flash();
|
|
407
484
|
} else if (sub === "off") {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
485
|
+
applyRelayState((s) => {
|
|
486
|
+
s.mode = "off";
|
|
487
|
+
s.enabled = false;
|
|
488
|
+
return s;
|
|
489
|
+
});
|
|
411
490
|
persist();
|
|
412
491
|
flash();
|
|
413
492
|
} else if (sub === "hide" || (sub === "widget" && rest === "hide")) {
|
|
414
|
-
|
|
493
|
+
applyRelayState((s) => ({ ...s, hideWidget: true }));
|
|
415
494
|
persist();
|
|
416
495
|
updateStatusBar(ctx.ui);
|
|
417
496
|
ctx.ui.notify("Widget hidden — use /freeflow show or /freeflow widget show to restore", "info");
|
|
418
497
|
} else if (sub === "show" || (sub === "widget" && rest === "show")) {
|
|
419
|
-
|
|
498
|
+
applyRelayState((s) => ({ ...s, hideWidget: false }));
|
|
420
499
|
persist();
|
|
421
500
|
flash();
|
|
422
501
|
ctx.ui.notify("Widget shown", "info");
|
|
@@ -438,10 +517,24 @@ export function createCommandSpec(
|
|
|
438
517
|
} catch {
|
|
439
518
|
// swallow — status banner is best-effort
|
|
440
519
|
}
|
|
520
|
+
|
|
521
|
+
const modeLine =
|
|
522
|
+
relayState.mode === "off"
|
|
523
|
+
? "Mode: off (always direct)"
|
|
524
|
+
: relayState.mode === "on"
|
|
525
|
+
? "Mode: on (always relay)"
|
|
526
|
+
: "Mode: auto (enabled on session, auto-rolls on 429/5xx)";
|
|
527
|
+
const poolLine = `${relayState.relays.length} relay(s)${
|
|
528
|
+
relayState.relays.length > 0
|
|
529
|
+
? ` | active: ${shortRelayLabel(relayState.url, relayState.relays)}`
|
|
530
|
+
: ""
|
|
531
|
+
}`;
|
|
532
|
+
const stateFileLine = `State file: ${RELAY_STATE_FILE}`;
|
|
533
|
+
ctx.ui.notify(`${modeLine} | ${poolLine}\n${stateFileLine}`, "info");
|
|
441
534
|
} else if (sub === "update") {
|
|
442
535
|
if (isLinkedInstall()) {
|
|
443
536
|
ctx.ui.notify(
|
|
444
|
-
"
|
|
537
|
+
"Linked install detected — restart your host app to pick up changes (no update needed).",
|
|
445
538
|
"info",
|
|
446
539
|
);
|
|
447
540
|
} else {
|
|
@@ -496,12 +589,16 @@ export function createCommandSpec(
|
|
|
496
589
|
const tokens = rest.trim().split(/\s+/);
|
|
497
590
|
const targetUrl = tokens[0];
|
|
498
591
|
const customLabel = tokens.slice(1).join(" ").trim() || undefined;
|
|
499
|
-
|
|
500
|
-
|
|
592
|
+
applyRelayState((s) => {
|
|
593
|
+
const added = ensureRelay(s, targetUrl, customLabel);
|
|
594
|
+
s.enabled = true;
|
|
595
|
+
s.url = added.url;
|
|
596
|
+
return s;
|
|
597
|
+
});
|
|
501
598
|
persist();
|
|
502
599
|
flash();
|
|
503
600
|
ctx.ui.notify(
|
|
504
|
-
`✓ Added & activated relay [${
|
|
601
|
+
`✓ Added & activated relay [${shortRelayLabel(relayState.url, relayState.relays)}]: ${relayState.url}`,
|
|
505
602
|
"info",
|
|
506
603
|
);
|
|
507
604
|
}
|
|
@@ -514,10 +611,16 @@ export function createCommandSpec(
|
|
|
514
611
|
const customLabel = tokens.slice(1).join(" ").trim() || undefined;
|
|
515
612
|
const matched = findRelay(relayState, targetToken);
|
|
516
613
|
if (matched) {
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
614
|
+
applyRelayState((s) => {
|
|
615
|
+
const fresh = s.relays.find((r) => r.url === matched.url);
|
|
616
|
+
if (!fresh) return s;
|
|
617
|
+
s.enabled = true;
|
|
618
|
+
s.url = fresh.url;
|
|
619
|
+
if (customLabel) {
|
|
620
|
+
fresh.label = customLabel;
|
|
621
|
+
}
|
|
622
|
+
return s;
|
|
623
|
+
});
|
|
521
624
|
persist();
|
|
522
625
|
flash();
|
|
523
626
|
} else {
|
|
@@ -529,8 +632,12 @@ export function createCommandSpec(
|
|
|
529
632
|
} catch {}
|
|
530
633
|
const looksLikeUrl = parsedUrl && (parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:");
|
|
531
634
|
if (looksLikeUrl) {
|
|
532
|
-
|
|
533
|
-
|
|
635
|
+
applyRelayState((s) => {
|
|
636
|
+
const added = ensureRelay(s, targetToken, customLabel);
|
|
637
|
+
s.enabled = true;
|
|
638
|
+
s.url = added.url;
|
|
639
|
+
return s;
|
|
640
|
+
});
|
|
534
641
|
persist();
|
|
535
642
|
flash();
|
|
536
643
|
} else {
|
|
@@ -559,7 +666,10 @@ export function createCommandSpec(
|
|
|
559
666
|
)
|
|
560
667
|
)?.trim() || "";
|
|
561
668
|
}
|
|
562
|
-
|
|
669
|
+
applyRelayState((s) => {
|
|
670
|
+
setRelayLabel(s, matched.url, finalLabel);
|
|
671
|
+
return s;
|
|
672
|
+
});
|
|
563
673
|
persist();
|
|
564
674
|
flash();
|
|
565
675
|
ctx.ui.notify(
|
|
@@ -628,6 +738,8 @@ export function createCommandSpec(
|
|
|
628
738
|
const rawRest = rest.trim();
|
|
629
739
|
let filterLevel: LogLevel | null = null;
|
|
630
740
|
let filterReqId: string | null = null;
|
|
741
|
+
let filterText: string | null = null;
|
|
742
|
+
let expectRelay = false;
|
|
631
743
|
let count = 25;
|
|
632
744
|
const rawTokens = rawRest ? rawRest.split(/\s+/) : [];
|
|
633
745
|
const isFollow = rawTokens.includes("--follow") || rawTokens.includes("-f");
|
|
@@ -638,6 +750,15 @@ export function createCommandSpec(
|
|
|
638
750
|
for (const t of rawTokens) {
|
|
639
751
|
if (t === "--follow" || t === "-f") continue;
|
|
640
752
|
const lower = t.toLowerCase();
|
|
753
|
+
if (expectRelay) {
|
|
754
|
+
filterText = t;
|
|
755
|
+
expectRelay = false;
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
if (t === "relay") {
|
|
759
|
+
expectRelay = true;
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
641
762
|
if (lower in LOG_LEVEL_ORDER) {
|
|
642
763
|
filterLevel = lower as LogLevel;
|
|
643
764
|
} else if (/^\d+$/.test(t)) {
|
|
@@ -653,7 +774,9 @@ export function createCommandSpec(
|
|
|
653
774
|
} else if (lower === "trace" || lower === "req") {
|
|
654
775
|
continue;
|
|
655
776
|
} else {
|
|
656
|
-
|
|
777
|
+
// Unrecognized token: an invalid level must default to info,
|
|
778
|
+
// not silently become a request-id filter.
|
|
779
|
+
filterLevel = filterLevel ?? "info";
|
|
657
780
|
}
|
|
658
781
|
}
|
|
659
782
|
}
|
|
@@ -662,6 +785,7 @@ export function createCommandSpec(
|
|
|
662
785
|
filterLevel,
|
|
663
786
|
filterReqId,
|
|
664
787
|
count,
|
|
788
|
+
filterText,
|
|
665
789
|
);
|
|
666
790
|
if (result.lines.length === 0) {
|
|
667
791
|
if (result.totalLines === 0) {
|
|
@@ -671,41 +795,37 @@ export function createCommandSpec(
|
|
|
671
795
|
);
|
|
672
796
|
} else {
|
|
673
797
|
ctx.ui.notify(
|
|
674
|
-
`No logs matched (level=${filterLevel || "any"} reqId=${filterReqId || "any"} count=${count})`,
|
|
798
|
+
`No logs matched (level=${filterLevel || "any"} reqId=${filterReqId || "any"} text=${filterText || "any"} count=${count})`,
|
|
675
799
|
"warning",
|
|
676
800
|
);
|
|
677
801
|
}
|
|
678
802
|
if (isFollow) {
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
// @ts-ignore allow unref to not block process exit in CLI
|
|
688
|
-
_t.unref?.();
|
|
803
|
+
startLogsFollow(
|
|
804
|
+
ctx,
|
|
805
|
+
filterLevel,
|
|
806
|
+
filterReqId,
|
|
807
|
+
count,
|
|
808
|
+
filterText,
|
|
809
|
+
result.totalMatched,
|
|
810
|
+
);
|
|
689
811
|
}
|
|
690
812
|
return;
|
|
691
813
|
}
|
|
692
814
|
|
|
693
|
-
const header = `pi-freeflow logs (last ${result.lines.length}/${result.totalMatched} matched, total ${result.totalLines} lines, file: ${LOG_FILE}${filterLevel ? ` level=${filterLevel}` : ""}${filterReqId ? ` req=${filterReqId}` : ""}):`;
|
|
815
|
+
const header = `pi-freeflow logs (last ${result.lines.length}/${result.totalMatched} matched, total ${result.totalLines} lines, file: ${LOG_FILE}${filterLevel ? ` level=${filterLevel}` : ""}${filterReqId ? ` req=${filterReqId}` : ""}${filterText ? ` text=${filterText}` : ""}):`;
|
|
694
816
|
ctx.ui.notify(
|
|
695
817
|
`${header}\n\n${result.lines.join("\n")}`,
|
|
696
818
|
"info",
|
|
697
819
|
);
|
|
698
820
|
if (isFollow) {
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
// @ts-ignore allow unref to not block process exit in CLI
|
|
708
|
-
_t2.unref?.();
|
|
821
|
+
startLogsFollow(
|
|
822
|
+
ctx,
|
|
823
|
+
filterLevel,
|
|
824
|
+
filterReqId,
|
|
825
|
+
count,
|
|
826
|
+
filterText,
|
|
827
|
+
result.totalMatched,
|
|
828
|
+
);
|
|
709
829
|
}
|
|
710
830
|
} catch (e) {
|
|
711
831
|
ctx.ui.notify(
|
|
@@ -747,34 +867,61 @@ export function createCommandSpec(
|
|
|
747
867
|
);
|
|
748
868
|
return;
|
|
749
869
|
}
|
|
750
|
-
|
|
870
|
+
applyRelayState((s) => {
|
|
871
|
+
removeRelay(s, matched.url);
|
|
872
|
+
return s;
|
|
873
|
+
});
|
|
751
874
|
persist();
|
|
752
875
|
ctx.ui.notify(`Removed: [${shortRelayLabel(matched.url, relayState.relays)}] ${matched.url}`, "info");
|
|
753
876
|
}
|
|
877
|
+
} else if (sub === "test") {
|
|
878
|
+
const target = rest.trim();
|
|
879
|
+
const matched = findRelay(relayState, target);
|
|
880
|
+
if (!matched) {
|
|
881
|
+
ctx.ui.notify(`Relay '${target}' not in saved list`, "warning");
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
ctx.ui.notify(`Testing ${matched.url}…`, "info");
|
|
885
|
+
const probe = await probeRelay(matched.url, matched.auth);
|
|
886
|
+
if (probe.ok) {
|
|
887
|
+
ctx.ui.notify(`✓ ${shortRelayLabel(matched.url, relayState.relays)} ok (HTTP ${probe.status}, ${probe.latencyMs}ms)`, "info");
|
|
888
|
+
} else {
|
|
889
|
+
ctx.ui.notify(`✗ ${shortRelayLabel(matched.url, relayState.relays)} failed: ${probe.error || `HTTP ${probe.status}`}`, "error");
|
|
890
|
+
}
|
|
754
891
|
} else if (sub === "url") {
|
|
755
892
|
const input =
|
|
756
893
|
rest ||
|
|
757
894
|
(await ctx.ui.input(
|
|
758
895
|
"Relay URL (empty = default):",
|
|
759
|
-
relayState.url ||
|
|
896
|
+
relayState.url || "",
|
|
760
897
|
));
|
|
761
|
-
const cleanInput = (input || "").trim() ||
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
cleanInput
|
|
765
|
-
|
|
766
|
-
|
|
898
|
+
const cleanInput = (input || "").trim() || "";
|
|
899
|
+
applyRelayState((s) => {
|
|
900
|
+
s.url = cleanInput;
|
|
901
|
+
if (cleanInput) {
|
|
902
|
+
ensureRelay(s, cleanInput, shortRelayLabel(cleanInput, s.relays));
|
|
903
|
+
}
|
|
904
|
+
return s;
|
|
905
|
+
});
|
|
767
906
|
persist();
|
|
768
907
|
flash();
|
|
769
908
|
} else if (sub === "deploy") {
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
"warning",
|
|
909
|
+
if (!rest.trim()) {
|
|
910
|
+
const choice = await ctx.ui.select(
|
|
911
|
+
"Deploy relay on",
|
|
912
|
+
Object.keys(DEPLOY_OPTIONS),
|
|
775
913
|
);
|
|
914
|
+
if (choice) await doDeploy(DEPLOY_OPTIONS[choice]);
|
|
776
915
|
} else {
|
|
777
|
-
|
|
916
|
+
const pf = parseDeployPlatform(rest);
|
|
917
|
+
if (!pf) {
|
|
918
|
+
ctx.ui.notify(
|
|
919
|
+
"Unknown platform. Use: vercel | cloudflare | deno",
|
|
920
|
+
"warning",
|
|
921
|
+
);
|
|
922
|
+
} else {
|
|
923
|
+
await doDeploy(pf);
|
|
924
|
+
}
|
|
778
925
|
}
|
|
779
926
|
} else {
|
|
780
927
|
const currentMode = (relayState.mode || "auto").toUpperCase();
|
|
@@ -812,20 +959,35 @@ export function createCommandSpec(
|
|
|
812
959
|
} else if (choice === "Deploy Deno relay…") {
|
|
813
960
|
await doDeploy("deno");
|
|
814
961
|
} else if (choice === "Mode: AUTO (auto-detect on model select)") {
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
962
|
+
applyRelayState((s) => {
|
|
963
|
+
s.mode = "auto";
|
|
964
|
+
s.enabled = true;
|
|
965
|
+
s.url = s.url || "";
|
|
966
|
+
if (s.url) {
|
|
967
|
+
ensureRelay(s, s.url);
|
|
968
|
+
}
|
|
969
|
+
return s;
|
|
970
|
+
});
|
|
818
971
|
persist();
|
|
819
972
|
flash();
|
|
820
973
|
} else if (choice === "Mode: ON (always relay)") {
|
|
821
|
-
|
|
822
|
-
|
|
974
|
+
applyRelayState((s) => {
|
|
975
|
+
s.mode = "on";
|
|
976
|
+
s.enabled = true;
|
|
977
|
+
s.url = s.url || "";
|
|
978
|
+
if (s.url) {
|
|
979
|
+
ensureRelay(s, s.url);
|
|
980
|
+
}
|
|
981
|
+
return s;
|
|
982
|
+
});
|
|
823
983
|
persist();
|
|
824
984
|
flash();
|
|
825
985
|
} else if (choice === "Mode: OFF (always direct)") {
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
986
|
+
applyRelayState((s) => {
|
|
987
|
+
s.mode = "off";
|
|
988
|
+
s.enabled = false;
|
|
989
|
+
return s;
|
|
990
|
+
});
|
|
829
991
|
persist();
|
|
830
992
|
flash();
|
|
831
993
|
}
|