pi-freeflow 1.4.12 → 1.6.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 +38 -66
- package/package.json +12 -5
- package/src/catalog.ts +97 -13
- package/src/commands.ts +432 -226
- package/src/config.ts +24 -2
- package/src/deploy.ts +128 -185
- package/src/health.ts +101 -0
- package/src/index.ts +78 -4
- package/src/logger.ts +52 -24
- package/src/models.ts +25 -36
- package/src/probe.ts +49 -0
- package/src/proxy.ts +211 -119
- package/src/relay-state.ts +393 -72
- package/src/relay.ts +76 -19
- package/src/stream-pipe.ts +307 -271
- package/src/types.ts +174 -165
- package/src/update-checker.ts +32 -22
package/src/commands.ts
CHANGED
|
@@ -4,17 +4,15 @@
|
|
|
4
4
|
* log viewing, debug level configuration, and live catalog refreshing.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { spawn } from "node:child_process";
|
|
8
|
-
import {
|
|
9
|
-
import
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
getCachedUpdate,
|
|
17
|
-
isLinkedInstall,
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { refreshCatalog, setAliveCatalog } from "./catalog.ts";
|
|
9
|
+
import { DEBUG_STATE_FILE, LOG_FILE, RELAY_STATE_FILE } from "./config.ts";
|
|
10
|
+
import {
|
|
11
|
+
compareVersions,
|
|
12
|
+
fetchLatestVersion,
|
|
13
|
+
getCachedUpdate,
|
|
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,81 +60,98 @@ import type {
|
|
|
59
60
|
*/
|
|
60
61
|
export function updateStatusBar(ui?: ExtensionUIContext): void {
|
|
61
62
|
if (!ui) return;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
63
|
+
ui.setStatus("freeflow", formatRelayStatusLabel(getActiveRelayState()) ?? undefined);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function spawnWithProgress(
|
|
67
|
+
cmd: string,
|
|
68
|
+
args: string[],
|
|
69
|
+
ctx: ExtensionContext,
|
|
70
|
+
): Promise<number> {
|
|
71
|
+
return new Promise((resolve) => {
|
|
72
|
+
try {
|
|
73
|
+
const child = spawn(cmd, args, {
|
|
74
|
+
shell: process.platform === "win32",
|
|
75
|
+
stdio: "pipe",
|
|
76
|
+
});
|
|
77
|
+
child.stdout?.on("data", (d: Buffer) => {
|
|
78
|
+
const s = String(d).trim();
|
|
79
|
+
if (s) ctx.ui.notify(s, "info");
|
|
80
|
+
});
|
|
81
|
+
child.stderr?.on("data", (d: Buffer) => {
|
|
82
|
+
const s = String(d).trim();
|
|
83
|
+
if (s) ctx.ui.notify(s, "info");
|
|
84
|
+
});
|
|
85
|
+
child.on("error", (err: Error) => {
|
|
86
|
+
ctx.ui.notify(`spawn ${cmd} failed: ${err.message}`, "warning");
|
|
87
|
+
resolve(1);
|
|
88
|
+
});
|
|
89
|
+
child.on("close", (code: number | null) => resolve(code ?? 0));
|
|
90
|
+
} catch (e) {
|
|
91
|
+
ctx.ui.notify(`spawn ${cmd} failed: ${(e as Error).message}`, "warning");
|
|
92
|
+
resolve(1);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
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;
|
|
76
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?.();
|
|
77
137
|
}
|
|
78
138
|
|
|
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
|
-
|
|
125
139
|
export function createCommandSpec(
|
|
126
140
|
_pi: ExtensionAPI,
|
|
127
141
|
onCatalogRefreshed?: (models: RegisteredModel[]) => void,
|
|
128
142
|
): Omit<RegisteredCommand, "name"> {
|
|
129
143
|
return {
|
|
130
144
|
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",
|
|
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",
|
|
132
146
|
getArgumentCompletions: (prefix: string) =>
|
|
133
147
|
[
|
|
134
148
|
"auto",
|
|
135
149
|
"on",
|
|
136
150
|
"off",
|
|
151
|
+
"hide",
|
|
152
|
+
"show",
|
|
153
|
+
"widget hide",
|
|
154
|
+
"widget show",
|
|
137
155
|
"status",
|
|
138
156
|
"add",
|
|
139
157
|
"list",
|
|
@@ -141,6 +159,7 @@ export function createCommandSpec(
|
|
|
141
159
|
"label",
|
|
142
160
|
"rename",
|
|
143
161
|
"remove",
|
|
162
|
+
"test",
|
|
144
163
|
"url",
|
|
145
164
|
"deploy",
|
|
146
165
|
"deploy vercel",
|
|
@@ -160,39 +179,40 @@ export function createCommandSpec(
|
|
|
160
179
|
.split(/\s+/);
|
|
161
180
|
const sub = parts[0] || "";
|
|
162
181
|
const rest = parts.slice(1).join(" ");
|
|
163
|
-
|
|
182
|
+
let relayState = getActiveRelayState();
|
|
164
183
|
|
|
165
184
|
const flash = () => {
|
|
166
|
-
|
|
167
|
-
const activeIdx = Math.max(
|
|
168
|
-
1,
|
|
169
|
-
relayState.relays.findIndex((r) => r.url === relayState.url) + 1,
|
|
170
|
-
);
|
|
171
|
-
const total = relayState.relays.length || 1;
|
|
172
|
-
const modeStr = (relayState.mode || "auto").toUpperCase();
|
|
173
|
-
ctx.ui.notify(
|
|
174
|
-
`Relay mode: ${modeStr} (${relayState.enabled ? "ON" : "OFF"})${relayState.enabled ? ` → ${activeLabel} (${activeIdx}/${total})` : " (direct)"} | saved=${relayState.relays.length} (auto-fallback rolling)`,
|
|
175
|
-
"info",
|
|
176
|
-
);
|
|
185
|
+
ctx.ui.notify(formatRelayFlash(relayState), "info");
|
|
177
186
|
};
|
|
178
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.
|
|
179
191
|
const persist = () => {
|
|
180
|
-
setActiveRelayState(relayState, true);
|
|
181
192
|
setStatusUi(ctx.ui);
|
|
182
193
|
updateStatusBar(ctx.ui);
|
|
183
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
|
+
};
|
|
184
202
|
const setRelay = (
|
|
185
203
|
enabled: boolean,
|
|
186
204
|
url: string,
|
|
187
205
|
addLabel?: string,
|
|
188
206
|
) => {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
+
});
|
|
196
216
|
};
|
|
197
217
|
|
|
198
218
|
const addRelayInteractive = async () => {
|
|
@@ -214,12 +234,16 @@ export function createCommandSpec(
|
|
|
214
234
|
)
|
|
215
235
|
)?.trim() || defaultLabel;
|
|
216
236
|
|
|
217
|
-
|
|
218
|
-
|
|
237
|
+
applyRelayState((s) => {
|
|
238
|
+
const added = ensureRelay(s, inputUrl, inputLabel);
|
|
239
|
+
s.enabled = true;
|
|
240
|
+
s.url = added.url;
|
|
241
|
+
return s;
|
|
242
|
+
});
|
|
219
243
|
persist();
|
|
220
244
|
flash();
|
|
221
245
|
ctx.ui.notify(
|
|
222
|
-
`✓ Added & activated relay [${
|
|
246
|
+
`✓ Added & activated relay [${shortRelayLabel(relayState.url, relayState.relays)}]: ${relayState.url}`,
|
|
223
247
|
"info",
|
|
224
248
|
);
|
|
225
249
|
};
|
|
@@ -248,7 +272,10 @@ export function createCommandSpec(
|
|
|
248
272
|
ctx.ui.notify("Cancelled — short name not changed", "warning");
|
|
249
273
|
return;
|
|
250
274
|
}
|
|
251
|
-
|
|
275
|
+
applyRelayState((s) => {
|
|
276
|
+
setRelayLabel(s, match.url, newLabel);
|
|
277
|
+
return s;
|
|
278
|
+
});
|
|
252
279
|
persist();
|
|
253
280
|
flash();
|
|
254
281
|
ctx.ui.notify(
|
|
@@ -257,6 +284,12 @@ export function createCommandSpec(
|
|
|
257
284
|
);
|
|
258
285
|
};
|
|
259
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
|
+
|
|
260
293
|
const parseDeployPlatform = (raw?: string): DeployPlatform | null => {
|
|
261
294
|
const v = (raw || "").trim().toLowerCase();
|
|
262
295
|
if (!v || v === "vercel") return "vercel";
|
|
@@ -273,6 +306,13 @@ export function createCommandSpec(
|
|
|
273
306
|
: platform === "deno"
|
|
274
307
|
? "Deno Deploy"
|
|
275
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");
|
|
276
316
|
const token = (
|
|
277
317
|
await ctx.ui.input(`${label} API token:`, "")
|
|
278
318
|
)?.trim();
|
|
@@ -288,6 +328,17 @@ export function createCommandSpec(
|
|
|
288
328
|
)
|
|
289
329
|
)?.trim() || defaultName;
|
|
290
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
|
+
|
|
291
342
|
ctx.ui.setStatus("freeflow", `deploying ${platform} relay…`);
|
|
292
343
|
try {
|
|
293
344
|
const deployer =
|
|
@@ -296,12 +347,26 @@ export function createCommandSpec(
|
|
|
296
347
|
: platform === "deno"
|
|
297
348
|
? deployDenoRelay
|
|
298
349
|
: deployVercelRelay;
|
|
299
|
-
const url = await deployer(token, name, (m) =>
|
|
350
|
+
const { url, auth } = await deployer(token, name, (m) =>
|
|
300
351
|
ctx.ui.notify(m, "info"),
|
|
301
352
|
);
|
|
302
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);
|
|
303
359
|
persist();
|
|
304
|
-
|
|
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");
|
|
305
370
|
} catch (e) {
|
|
306
371
|
updateStatusBar(ctx.ui);
|
|
307
372
|
ctx.ui.notify(
|
|
@@ -316,17 +381,19 @@ export function createCommandSpec(
|
|
|
316
381
|
ctx.ui.notify("No saved relays yet", "warning");
|
|
317
382
|
return;
|
|
318
383
|
}
|
|
319
|
-
const fmt = (r: KnownRelay, idx: number) =>
|
|
320
|
-
const isAct = r.url === relayState.url ? "★ " : " ";
|
|
321
|
-
const lbl = r.label ? `[${r.label}] ` : `[${shortRelayLabel(r.url, relayState.relays)}] `;
|
|
322
|
-
return `${isAct}[${idx + 1}] ${lbl}→ ${r.url}`;
|
|
323
|
-
};
|
|
384
|
+
const fmt = (r: KnownRelay, idx: number) => formatRelayPickerItem(r, idx, relayState.url);
|
|
324
385
|
const opts = relayState.relays.map(fmt);
|
|
325
386
|
const choice = await ctx.ui.select("Switch active relay", opts);
|
|
326
387
|
if (!choice) return;
|
|
327
388
|
const match = relayState.relays.find((r, idx) => fmt(r, idx) === choice);
|
|
328
389
|
if (!match) return;
|
|
329
|
-
|
|
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
|
+
});
|
|
330
397
|
persist();
|
|
331
398
|
flash();
|
|
332
399
|
};
|
|
@@ -345,8 +412,13 @@ export function createCommandSpec(
|
|
|
345
412
|
const remainingSec = isCooling ? Math.ceil((health.cooldownUntil - Date.now()) / 1000) : 0;
|
|
346
413
|
const healthBadge = isCooling
|
|
347
414
|
? ` ⚠️ [cooling ${remainingSec}s: ${health.lastStatus ? `HTTP ${health.lastStatus}` : "error"}]`
|
|
348
|
-
:
|
|
349
|
-
|
|
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}`;
|
|
350
422
|
});
|
|
351
423
|
const activeLabel = shortRelayLabel(relayState.url, relayState.relays);
|
|
352
424
|
const activeIdx = Math.max(
|
|
@@ -369,10 +441,7 @@ export function createCommandSpec(
|
|
|
369
441
|
);
|
|
370
442
|
return;
|
|
371
443
|
}
|
|
372
|
-
const fmt = (r: KnownRelay, idx: number) =>
|
|
373
|
-
const lbl = r.label ? `[${r.label}] ` : `[${shortRelayLabel(r.url, relayState.relays)}] `;
|
|
374
|
-
return `[${idx + 1}] ${lbl}→ ${r.url}`;
|
|
375
|
-
};
|
|
444
|
+
const fmt = (r: KnownRelay, idx: number) => formatRelayPickerItem(r, idx);
|
|
376
445
|
const choice = await ctx.ui.select(
|
|
377
446
|
"Remove relay",
|
|
378
447
|
removable.map(fmt),
|
|
@@ -380,95 +449,137 @@ export function createCommandSpec(
|
|
|
380
449
|
if (!choice) return;
|
|
381
450
|
const match = removable.find((r, idx) => fmt(r, idx) === choice);
|
|
382
451
|
if (!match) return;
|
|
383
|
-
|
|
452
|
+
applyRelayState((s) => {
|
|
453
|
+
removeRelay(s, match.url);
|
|
454
|
+
return s;
|
|
455
|
+
});
|
|
384
456
|
persist();
|
|
385
457
|
ctx.ui.notify(`Removed: [${shortRelayLabel(match.url, relayState.relays)}] ${match.url}`, "info");
|
|
386
458
|
};
|
|
387
459
|
|
|
388
460
|
if (sub === "auto") {
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
+
});
|
|
392
470
|
persist();
|
|
393
471
|
flash();
|
|
394
472
|
} else if (sub === "on") {
|
|
395
|
-
|
|
396
|
-
|
|
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
|
+
});
|
|
397
482
|
persist();
|
|
398
483
|
flash();
|
|
399
484
|
} else if (sub === "off") {
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
485
|
+
applyRelayState((s) => {
|
|
486
|
+
s.mode = "off";
|
|
487
|
+
s.enabled = false;
|
|
488
|
+
return s;
|
|
489
|
+
});
|
|
490
|
+
persist();
|
|
491
|
+
flash();
|
|
492
|
+
} else if (sub === "hide" || (sub === "widget" && rest === "hide")) {
|
|
493
|
+
applyRelayState((s) => ({ ...s, hideWidget: true }));
|
|
494
|
+
persist();
|
|
495
|
+
updateStatusBar(ctx.ui);
|
|
496
|
+
ctx.ui.notify("Widget hidden — use /freeflow show or /freeflow widget show to restore", "info");
|
|
497
|
+
} else if (sub === "show" || (sub === "widget" && rest === "show")) {
|
|
498
|
+
applyRelayState((s) => ({ ...s, hideWidget: false }));
|
|
403
499
|
persist();
|
|
404
500
|
flash();
|
|
405
|
-
|
|
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
|
-
|
|
501
|
+
ctx.ui.notify("Widget shown", "info");
|
|
502
|
+
} else if (sub === "status") {
|
|
503
|
+
flash();
|
|
504
|
+
try {
|
|
505
|
+
const local = getLocalVersion();
|
|
506
|
+
let latest: string | null = null;
|
|
507
|
+
const cached = getCachedUpdate();
|
|
508
|
+
if (cached && typeof cached.latest === "string") {
|
|
509
|
+
latest = cached.latest;
|
|
510
|
+
}
|
|
511
|
+
if (latest && compareVersions(latest, local) > 0) {
|
|
512
|
+
ctx.ui.notify(
|
|
513
|
+
`Update available: ${local} -> ${latest} - run /freeflow update`,
|
|
514
|
+
"info",
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
} catch {
|
|
518
|
+
// swallow — status banner is best-effort
|
|
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");
|
|
534
|
+
} else if (sub === "update") {
|
|
535
|
+
if (isLinkedInstall()) {
|
|
536
|
+
ctx.ui.notify(
|
|
537
|
+
"Linked install detected — restart your host app to pick up changes (no update needed).",
|
|
538
|
+
"info",
|
|
539
|
+
);
|
|
540
|
+
} else {
|
|
541
|
+
try {
|
|
542
|
+
ctx.ui.notify("Checking for updates…", "info");
|
|
543
|
+
let latest: string | null = null;
|
|
544
|
+
try {
|
|
545
|
+
latest = await fetchLatestVersion();
|
|
546
|
+
} catch {
|
|
547
|
+
latest = null;
|
|
548
|
+
}
|
|
549
|
+
if (!latest) {
|
|
550
|
+
const cached = getCachedUpdate();
|
|
551
|
+
if (cached && typeof cached.latest === "string") {
|
|
552
|
+
latest = cached.latest;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
if (!latest) {
|
|
556
|
+
ctx.ui.notify("Could not check latest version (offline?)", "warning");
|
|
557
|
+
} else {
|
|
558
|
+
const local = getLocalVersion();
|
|
559
|
+
const cmp = compareVersions(latest, local);
|
|
560
|
+
if (cmp <= 0) {
|
|
561
|
+
ctx.ui.notify(`Already on latest (v${local})`, "info");
|
|
562
|
+
} else {
|
|
563
|
+
ctx.ui.notify(`Update available: v${local} → v${latest} — updating…`, "info");
|
|
564
|
+
let code = await spawnWithProgress("omp", ["plugin", "update", "pi-freeflow"], ctx);
|
|
565
|
+
if (code !== 0) {
|
|
566
|
+
ctx.ui.notify(`omp update exited ${code}, trying npm…`, "info");
|
|
567
|
+
code = await spawnWithProgress("npm", ["i", "-g", "pi-freeflow@latest"], ctx);
|
|
568
|
+
}
|
|
569
|
+
if (code === 0) {
|
|
570
|
+
ctx.ui.notify(`Updated to ${latest}, restart OMP`, "info");
|
|
571
|
+
} else {
|
|
572
|
+
ctx.ui.notify(
|
|
573
|
+
`Update failed (exit ${code}) — try manually: npm i -g pi-freeflow@latest`,
|
|
574
|
+
"warning",
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
} catch (e) {
|
|
580
|
+
ctx.ui.notify(`Update failed: ${(e as Error).message} — try manually: npm i -g pi-freeflow@latest`, "warning");
|
|
581
|
+
}
|
|
582
|
+
}
|
|
472
583
|
} else if (sub === "list") {
|
|
473
584
|
showList();
|
|
474
585
|
} else if (sub === "add") {
|
|
@@ -478,12 +589,16 @@ export function createCommandSpec(
|
|
|
478
589
|
const tokens = rest.trim().split(/\s+/);
|
|
479
590
|
const targetUrl = tokens[0];
|
|
480
591
|
const customLabel = tokens.slice(1).join(" ").trim() || undefined;
|
|
481
|
-
|
|
482
|
-
|
|
592
|
+
applyRelayState((s) => {
|
|
593
|
+
const added = ensureRelay(s, targetUrl, customLabel);
|
|
594
|
+
s.enabled = true;
|
|
595
|
+
s.url = added.url;
|
|
596
|
+
return s;
|
|
597
|
+
});
|
|
483
598
|
persist();
|
|
484
599
|
flash();
|
|
485
600
|
ctx.ui.notify(
|
|
486
|
-
`✓ Added & activated relay [${
|
|
601
|
+
`✓ Added & activated relay [${shortRelayLabel(relayState.url, relayState.relays)}]: ${relayState.url}`,
|
|
487
602
|
"info",
|
|
488
603
|
);
|
|
489
604
|
}
|
|
@@ -496,10 +611,16 @@ export function createCommandSpec(
|
|
|
496
611
|
const customLabel = tokens.slice(1).join(" ").trim() || undefined;
|
|
497
612
|
const matched = findRelay(relayState, targetToken);
|
|
498
613
|
if (matched) {
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
+
});
|
|
503
624
|
persist();
|
|
504
625
|
flash();
|
|
505
626
|
} else {
|
|
@@ -511,8 +632,12 @@ export function createCommandSpec(
|
|
|
511
632
|
} catch {}
|
|
512
633
|
const looksLikeUrl = parsedUrl && (parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:");
|
|
513
634
|
if (looksLikeUrl) {
|
|
514
|
-
|
|
515
|
-
|
|
635
|
+
applyRelayState((s) => {
|
|
636
|
+
const added = ensureRelay(s, targetToken, customLabel);
|
|
637
|
+
s.enabled = true;
|
|
638
|
+
s.url = added.url;
|
|
639
|
+
return s;
|
|
640
|
+
});
|
|
516
641
|
persist();
|
|
517
642
|
flash();
|
|
518
643
|
} else {
|
|
@@ -541,7 +666,10 @@ export function createCommandSpec(
|
|
|
541
666
|
)
|
|
542
667
|
)?.trim() || "";
|
|
543
668
|
}
|
|
544
|
-
|
|
669
|
+
applyRelayState((s) => {
|
|
670
|
+
setRelayLabel(s, matched.url, finalLabel);
|
|
671
|
+
return s;
|
|
672
|
+
});
|
|
545
673
|
persist();
|
|
546
674
|
flash();
|
|
547
675
|
ctx.ui.notify(
|
|
@@ -610,14 +738,27 @@ export function createCommandSpec(
|
|
|
610
738
|
const rawRest = rest.trim();
|
|
611
739
|
let filterLevel: LogLevel | null = null;
|
|
612
740
|
let filterReqId: string | null = null;
|
|
741
|
+
let filterText: string | null = null;
|
|
742
|
+
let expectRelay = false;
|
|
613
743
|
let count = 25;
|
|
744
|
+
const rawTokens = rawRest ? rawRest.split(/\s+/) : [];
|
|
745
|
+
const isFollow = rawTokens.includes("--follow") || rawTokens.includes("-f");
|
|
614
746
|
|
|
615
747
|
if (sub === "trace" && rawRest) {
|
|
616
|
-
filterReqId =
|
|
748
|
+
filterReqId = rawTokens.filter((t) => t !== "--follow" && t !== "-f")[0] || null;
|
|
617
749
|
} else if (rawRest) {
|
|
618
|
-
const
|
|
619
|
-
|
|
750
|
+
for (const t of rawTokens) {
|
|
751
|
+
if (t === "--follow" || t === "-f") continue;
|
|
620
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
|
+
}
|
|
621
762
|
if (lower in LOG_LEVEL_ORDER) {
|
|
622
763
|
filterLevel = lower as LogLevel;
|
|
623
764
|
} else if (/^\d+$/.test(t)) {
|
|
@@ -633,7 +774,9 @@ export function createCommandSpec(
|
|
|
633
774
|
} else if (lower === "trace" || lower === "req") {
|
|
634
775
|
continue;
|
|
635
776
|
} else {
|
|
636
|
-
|
|
777
|
+
// Unrecognized token: an invalid level must default to info,
|
|
778
|
+
// not silently become a request-id filter.
|
|
779
|
+
filterLevel = filterLevel ?? "info";
|
|
637
780
|
}
|
|
638
781
|
}
|
|
639
782
|
}
|
|
@@ -642,6 +785,7 @@ export function createCommandSpec(
|
|
|
642
785
|
filterLevel,
|
|
643
786
|
filterReqId,
|
|
644
787
|
count,
|
|
788
|
+
filterText,
|
|
645
789
|
);
|
|
646
790
|
if (result.lines.length === 0) {
|
|
647
791
|
if (result.totalLines === 0) {
|
|
@@ -651,18 +795,38 @@ export function createCommandSpec(
|
|
|
651
795
|
);
|
|
652
796
|
} else {
|
|
653
797
|
ctx.ui.notify(
|
|
654
|
-
`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})`,
|
|
655
799
|
"warning",
|
|
656
800
|
);
|
|
657
801
|
}
|
|
802
|
+
if (isFollow) {
|
|
803
|
+
startLogsFollow(
|
|
804
|
+
ctx,
|
|
805
|
+
filterLevel,
|
|
806
|
+
filterReqId,
|
|
807
|
+
count,
|
|
808
|
+
filterText,
|
|
809
|
+
result.totalMatched,
|
|
810
|
+
);
|
|
811
|
+
}
|
|
658
812
|
return;
|
|
659
813
|
}
|
|
660
814
|
|
|
661
|
-
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}` : ""}):`;
|
|
662
816
|
ctx.ui.notify(
|
|
663
817
|
`${header}\n\n${result.lines.join("\n")}`,
|
|
664
818
|
"info",
|
|
665
819
|
);
|
|
820
|
+
if (isFollow) {
|
|
821
|
+
startLogsFollow(
|
|
822
|
+
ctx,
|
|
823
|
+
filterLevel,
|
|
824
|
+
filterReqId,
|
|
825
|
+
count,
|
|
826
|
+
filterText,
|
|
827
|
+
result.totalMatched,
|
|
828
|
+
);
|
|
829
|
+
}
|
|
666
830
|
} catch (e) {
|
|
667
831
|
ctx.ui.notify(
|
|
668
832
|
`Could not read log file: ${(e as Error).message}`,
|
|
@@ -703,34 +867,61 @@ export function createCommandSpec(
|
|
|
703
867
|
);
|
|
704
868
|
return;
|
|
705
869
|
}
|
|
706
|
-
|
|
870
|
+
applyRelayState((s) => {
|
|
871
|
+
removeRelay(s, matched.url);
|
|
872
|
+
return s;
|
|
873
|
+
});
|
|
707
874
|
persist();
|
|
708
875
|
ctx.ui.notify(`Removed: [${shortRelayLabel(matched.url, relayState.relays)}] ${matched.url}`, "info");
|
|
709
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
|
+
}
|
|
710
891
|
} else if (sub === "url") {
|
|
711
892
|
const input =
|
|
712
893
|
rest ||
|
|
713
894
|
(await ctx.ui.input(
|
|
714
895
|
"Relay URL (empty = default):",
|
|
715
|
-
relayState.url ||
|
|
896
|
+
relayState.url || "",
|
|
716
897
|
));
|
|
717
|
-
const cleanInput = (input || "").trim() ||
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
cleanInput
|
|
721
|
-
|
|
722
|
-
|
|
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
|
+
});
|
|
723
906
|
persist();
|
|
724
907
|
flash();
|
|
725
908
|
} else if (sub === "deploy") {
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
"warning",
|
|
909
|
+
if (!rest.trim()) {
|
|
910
|
+
const choice = await ctx.ui.select(
|
|
911
|
+
"Deploy relay on",
|
|
912
|
+
Object.keys(DEPLOY_OPTIONS),
|
|
731
913
|
);
|
|
914
|
+
if (choice) await doDeploy(DEPLOY_OPTIONS[choice]);
|
|
732
915
|
} else {
|
|
733
|
-
|
|
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
|
+
}
|
|
734
925
|
}
|
|
735
926
|
} else {
|
|
736
927
|
const currentMode = (relayState.mode || "auto").toUpperCase();
|
|
@@ -768,20 +959,35 @@ export function createCommandSpec(
|
|
|
768
959
|
} else if (choice === "Deploy Deno relay…") {
|
|
769
960
|
await doDeploy("deno");
|
|
770
961
|
} else if (choice === "Mode: AUTO (auto-detect on model select)") {
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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
|
+
});
|
|
774
971
|
persist();
|
|
775
972
|
flash();
|
|
776
973
|
} else if (choice === "Mode: ON (always relay)") {
|
|
777
|
-
|
|
778
|
-
|
|
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
|
+
});
|
|
779
983
|
persist();
|
|
780
984
|
flash();
|
|
781
985
|
} else if (choice === "Mode: OFF (always direct)") {
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
986
|
+
applyRelayState((s) => {
|
|
987
|
+
s.mode = "off";
|
|
988
|
+
s.enabled = false;
|
|
989
|
+
return s;
|
|
990
|
+
});
|
|
785
991
|
persist();
|
|
786
992
|
flash();
|
|
787
993
|
}
|