pi-freeflow 1.44.0 → 1.45.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/CHANGELOG.md +29 -0
- package/README.md +11 -13
- package/package.json +1 -1
- package/src/catalog.ts +38 -14
- package/src/client-recovery.ts +238 -0
- package/src/client-staging.ts +316 -0
- package/src/client.ts +117 -472
- package/src/cline-accounts.ts +26 -1223
- package/src/cline-device-auth.ts +5 -3
- package/src/cline-pool.ts +691 -0
- package/src/cline-roll.ts +642 -0
- package/src/commands-cline.ts +283 -0
- package/src/commands-relay.ts +808 -0
- package/src/commands-startup.ts +264 -0
- package/src/commands.ts +119 -1202
- package/src/config-paths.ts +254 -0
- package/src/config.ts +6 -687
- package/src/daemon.ts +16 -1
- package/src/deploy.ts +3 -1
- package/src/fingerprint-env.ts +334 -0
- package/src/fingerprint-tools.ts +348 -0
- package/src/index.ts +10 -6
- package/src/lane-translation.ts +263 -0
- package/src/models.ts +70 -112
- package/src/opencode-fingerprint.ts +29 -1310
- package/src/opencode-identity.ts +464 -0
- package/src/proxy-lanes.ts +1125 -0
- package/src/proxy-ports.ts +317 -0
- package/src/proxy.ts +74 -1385
- package/src/relay-codec.ts +365 -0
- package/src/relay-state.ts +10 -367
- package/src/relay.ts +78 -31
- package/src/sse-aggregator.ts +659 -0
- package/src/stream-cloak.ts +882 -0
- package/src/stream-pipe.ts +22 -871
- package/src/system-ca-fetch.ts +15 -4
- package/src/token.ts +7 -2
- package/src/tool-args.ts +10 -3
- package/src/tool-translation.ts +1 -236
- package/src/toolcall-audit.ts +20 -7
- package/src/update-checker.ts +17 -1
- package/src/update-core.ts +5 -2
- package/src/updater.ts +8 -3
- package/src/upstream-health.ts +22 -2
- package/src/zen-sidecar.ts +1 -0
- package/src/zen-transport.ts +48 -18
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive CLI slash command handlers and UI workflows for relay pool management.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import type { DeployPlatform } from "./deploy.ts";
|
|
8
|
+
import {
|
|
9
|
+
buildRelayExport,
|
|
10
|
+
EXPORT_DEFAULT_FILENAME,
|
|
11
|
+
EXPORT_MAX_BYTES,
|
|
12
|
+
parseRelayImport,
|
|
13
|
+
planRelayImport,
|
|
14
|
+
type RelayImportFragment,
|
|
15
|
+
} from "./relay-codec.ts";
|
|
16
|
+
import {
|
|
17
|
+
ensureRelay,
|
|
18
|
+
ewmaBadge,
|
|
19
|
+
findRelay,
|
|
20
|
+
formatRelayFlash,
|
|
21
|
+
formatRelayPickerItem,
|
|
22
|
+
getActiveRelayState,
|
|
23
|
+
getRelayHealth,
|
|
24
|
+
loadRelayState,
|
|
25
|
+
removeRelay,
|
|
26
|
+
setRelayLabel,
|
|
27
|
+
setStatusUi,
|
|
28
|
+
shortRelayLabel,
|
|
29
|
+
withRelayState,
|
|
30
|
+
} from "./relay-state.ts";
|
|
31
|
+
import type { ExtensionContext, ExtensionUIContext, KnownRelay, RelayState } from "./types.ts";
|
|
32
|
+
|
|
33
|
+
export interface RelayCommandDeps {
|
|
34
|
+
readonly rest: string;
|
|
35
|
+
readonly updateStatusBar?: (ui?: ExtensionUIContext) => void;
|
|
36
|
+
readonly doDeploy?: (platform: DeployPlatform) => Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function classifyImportFailure(filePath: string, raw: string): string {
|
|
40
|
+
let parsed: unknown = null;
|
|
41
|
+
try {
|
|
42
|
+
parsed = JSON.parse(raw);
|
|
43
|
+
} catch (e) {
|
|
44
|
+
return `Relay file ${filePath} is not valid JSON (${(e as Error).message}). Nothing changed.`;
|
|
45
|
+
}
|
|
46
|
+
const version = parsed !== null && typeof parsed === "object" && "version" in parsed
|
|
47
|
+
? (parsed as Record<string, unknown>).version
|
|
48
|
+
: undefined;
|
|
49
|
+
if (typeof version !== "undefined" && version !== 1) {
|
|
50
|
+
return `Relay file ${filePath} uses version ${String(version)}, this version understands version 1. Update then try again. Nothing changed.`;
|
|
51
|
+
}
|
|
52
|
+
return `Relay file ${filePath} is not a relay export (missing relay list). Nothing changed.`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface RelayActionContext {
|
|
56
|
+
applyRelayState: (updater: (s: RelayState) => RelayState) => RelayState;
|
|
57
|
+
persist: () => void;
|
|
58
|
+
flash: () => void;
|
|
59
|
+
getRelayState: () => RelayState;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function addRelayInteractive(ctx: ExtensionContext, act: RelayActionContext): Promise<void> {
|
|
63
|
+
const relayState = act.getRelayState();
|
|
64
|
+
const inputUrl = (
|
|
65
|
+
await ctx.ui.input(
|
|
66
|
+
"Custom relay URL (e.g. https://my-relay.workers.dev):",
|
|
67
|
+
"",
|
|
68
|
+
)
|
|
69
|
+
)?.trim();
|
|
70
|
+
if (!inputUrl) {
|
|
71
|
+
ctx.ui.notify("Cancelled — no URL provided", "warning");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const defaultLabel = shortRelayLabel(inputUrl, relayState.relays);
|
|
75
|
+
const inputLabel = (
|
|
76
|
+
await ctx.ui.input(
|
|
77
|
+
"Short name / alias for TUI (e.g. cf-sg, vercel-1):",
|
|
78
|
+
defaultLabel,
|
|
79
|
+
)
|
|
80
|
+
)?.trim() || defaultLabel;
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
act.applyRelayState((s) => {
|
|
84
|
+
const added = ensureRelay(s, inputUrl, inputLabel);
|
|
85
|
+
s.enabled = true;
|
|
86
|
+
s.url = added.url;
|
|
87
|
+
return s;
|
|
88
|
+
});
|
|
89
|
+
} catch (e) {
|
|
90
|
+
ctx.ui.notify((e as Error).message, "warning");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
act.persist();
|
|
94
|
+
act.flash();
|
|
95
|
+
const fresh = act.getRelayState();
|
|
96
|
+
ctx.ui.notify(
|
|
97
|
+
`✓ Added & activated relay [${shortRelayLabel(fresh.url, fresh.relays)}]: ${fresh.url}`,
|
|
98
|
+
"info",
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function editRelayLabelMenu(ctx: ExtensionContext, act: RelayActionContext): Promise<void> {
|
|
103
|
+
const relayState = act.getRelayState();
|
|
104
|
+
if (!relayState.relays.length) {
|
|
105
|
+
ctx.ui.notify("No saved relays to rename", "warning");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const fmt = (r: KnownRelay, idx: number): string =>
|
|
109
|
+
`[${idx + 1}] ${r.label ? `[${r.label}] ` : ""}${r.url}`;
|
|
110
|
+
const opts = relayState.relays.map(fmt);
|
|
111
|
+
const choice = await ctx.ui.select("Select relay to rename / set short name", opts);
|
|
112
|
+
if (!choice) return;
|
|
113
|
+
const match = relayState.relays.find((r, idx) => fmt(r, idx) === choice);
|
|
114
|
+
if (!match) return;
|
|
115
|
+
|
|
116
|
+
const curLabel = match.label || shortRelayLabel(match.url, relayState.relays);
|
|
117
|
+
const newLabel = (
|
|
118
|
+
await ctx.ui.input(
|
|
119
|
+
`New short name for ${match.url}:`,
|
|
120
|
+
curLabel,
|
|
121
|
+
)
|
|
122
|
+
)?.trim();
|
|
123
|
+
if (!newLabel) {
|
|
124
|
+
ctx.ui.notify("Cancelled — short name not changed", "warning");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
act.applyRelayState((s) => {
|
|
128
|
+
setRelayLabel(s, match.url, newLabel);
|
|
129
|
+
return s;
|
|
130
|
+
});
|
|
131
|
+
act.persist();
|
|
132
|
+
act.flash();
|
|
133
|
+
ctx.ui.notify(
|
|
134
|
+
`✓ Relay short name updated to [${newLabel}] for ${match.url}`,
|
|
135
|
+
"info",
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function switchRelay(ctx: ExtensionContext, act: RelayActionContext): Promise<void> {
|
|
140
|
+
const relayState = act.getRelayState();
|
|
141
|
+
if (!relayState.relays.length) {
|
|
142
|
+
ctx.ui.notify("No saved relays yet", "warning");
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const fmt = (r: KnownRelay, idx: number): string => formatRelayPickerItem(r, idx, relayState.url);
|
|
146
|
+
const opts = relayState.relays.map(fmt);
|
|
147
|
+
const choice = await ctx.ui.select("Switch active relay", opts);
|
|
148
|
+
if (!choice) return;
|
|
149
|
+
const match = relayState.relays.find((r, idx) => fmt(r, idx) === choice);
|
|
150
|
+
if (!match) return;
|
|
151
|
+
act.applyRelayState((s) => {
|
|
152
|
+
const fresh = s.relays.find((r) => r.url === match.url);
|
|
153
|
+
if (!fresh) return s;
|
|
154
|
+
s.enabled = true;
|
|
155
|
+
s.url = fresh.url;
|
|
156
|
+
return s;
|
|
157
|
+
});
|
|
158
|
+
act.persist();
|
|
159
|
+
act.flash();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function showList(ctx: ExtensionContext, getRelayState: () => RelayState): void {
|
|
163
|
+
const relayState = getRelayState();
|
|
164
|
+
if (!relayState.relays.length) {
|
|
165
|
+
ctx.ui.notify("No saved relays (direct upstream mode)", "info");
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const isDeadParked = (url: string): boolean => {
|
|
169
|
+
const h = getRelayHealth(url);
|
|
170
|
+
return h != null && h.lastStatus === 402 && Date.now() < h.cooldownUntil;
|
|
171
|
+
};
|
|
172
|
+
const lines = relayState.relays.map((r, idx) => {
|
|
173
|
+
const star = r.url === relayState.url ? "★" : " ";
|
|
174
|
+
const shortName = r.label ? `[${r.label}]` : `[${shortRelayLabel(r.url, relayState.relays)}]`;
|
|
175
|
+
const paddedName = shortName.padEnd(16, " ");
|
|
176
|
+
const health = getRelayHealth(r.url);
|
|
177
|
+
const isCooling = health && Date.now() < health.cooldownUntil;
|
|
178
|
+
const remainingSec = isCooling ? Math.ceil((health.cooldownUntil - Date.now()) / 1000) : 0;
|
|
179
|
+
const healthBadge = isDeadParked(r.url)
|
|
180
|
+
? ` ✖ [dead — HTTP 402 disabled deployment, parked ${remainingSec}s]`
|
|
181
|
+
: isCooling
|
|
182
|
+
? ` ⚠️ [cooling ${remainingSec}s: ${health.lastStatus ? `HTTP ${health.lastStatus}` : "error"}]`
|
|
183
|
+
: health?.lastLatencyMs != null && Number.isFinite(health.lastLatencyMs)
|
|
184
|
+
? ` ✓ [${health.lastLatencyMs}ms]${ewmaBadge(health)}`
|
|
185
|
+
: " ✓";
|
|
186
|
+
const counterText = health && (health.successCount != null || health.failureCount != null)
|
|
187
|
+
? ` ${health.successCount ?? 0} ok / ${health.failureCount ?? 0} fail`
|
|
188
|
+
: "";
|
|
189
|
+
return `${star} [${idx + 1}] ${paddedName} → ${r.url}${healthBadge}${counterText}`;
|
|
190
|
+
});
|
|
191
|
+
const activeLabel = shortRelayLabel(relayState.url, relayState.relays);
|
|
192
|
+
const activeIdx = Math.max(
|
|
193
|
+
1,
|
|
194
|
+
relayState.relays.findIndex((r) => r.url === relayState.url) + 1,
|
|
195
|
+
);
|
|
196
|
+
const modeStr = (relayState.mode || "auto").toUpperCase();
|
|
197
|
+
const header = `Saved Relays (${relayState.relays.length}):\n${lines.join("\n")}\n\nActive: [${activeIdx}] ${activeLabel} | Mode: ${modeStr} (${relayState.enabled ? "ON" : "OFF"})`;
|
|
198
|
+
ctx.ui.notify(header, "info");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function removeRelayMenu(ctx: ExtensionContext, act: Pick<RelayActionContext, "applyRelayState" | "persist" | "getRelayState">): Promise<void> {
|
|
202
|
+
const relayState = act.getRelayState();
|
|
203
|
+
const removable = relayState.relays.filter(
|
|
204
|
+
(r) => relayState.relays.length === 1 || r.url !== relayState.url,
|
|
205
|
+
);
|
|
206
|
+
if (!removable.length) {
|
|
207
|
+
ctx.ui.notify(
|
|
208
|
+
"Nothing to remove — the active relay cannot be removed (switch first)",
|
|
209
|
+
"warning",
|
|
210
|
+
);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const fmt = (r: KnownRelay, idx: number): string => formatRelayPickerItem(r, idx);
|
|
214
|
+
const choice = await ctx.ui.select(
|
|
215
|
+
"Remove relay",
|
|
216
|
+
removable.map(fmt),
|
|
217
|
+
);
|
|
218
|
+
if (!choice) return;
|
|
219
|
+
const match = removable.find((r, idx) => fmt(r, idx) === choice);
|
|
220
|
+
if (!match) return;
|
|
221
|
+
act.applyRelayState((s) => {
|
|
222
|
+
removeRelay(s, match.url);
|
|
223
|
+
return s;
|
|
224
|
+
});
|
|
225
|
+
act.persist();
|
|
226
|
+
ctx.ui.notify(`Removed: [${shortRelayLabel(match.url, relayState.relays)}] ${match.url}`, "info");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export async function runExport(
|
|
230
|
+
ctx: ExtensionContext,
|
|
231
|
+
pathArg: string | undefined,
|
|
232
|
+
includeSecrets: boolean,
|
|
233
|
+
act: { flash: () => void; updateStatusBar?: (ui?: ExtensionUIContext) => void },
|
|
234
|
+
): Promise<void> {
|
|
235
|
+
let state: RelayState;
|
|
236
|
+
try {
|
|
237
|
+
state = getActiveRelayState();
|
|
238
|
+
} catch {
|
|
239
|
+
state = loadRelayState();
|
|
240
|
+
}
|
|
241
|
+
const artifact = buildRelayExport(state, { includeSecrets });
|
|
242
|
+
const count = artifact.state.relays.length;
|
|
243
|
+
let target = pathArg ? path.resolve(pathArg) : path.resolve(EXPORT_DEFAULT_FILENAME);
|
|
244
|
+
if (!pathArg) {
|
|
245
|
+
const choice = await ctx.ui.select("Share relays (export file)", [
|
|
246
|
+
`Export to ${target}`,
|
|
247
|
+
"Choose another path…",
|
|
248
|
+
"Cancel",
|
|
249
|
+
]);
|
|
250
|
+
if (!choice || choice === "Cancel") return;
|
|
251
|
+
if (choice === "Choose another path…") {
|
|
252
|
+
const picked = (await ctx.ui.input("Export path:", target))?.trim();
|
|
253
|
+
if (!picked) return;
|
|
254
|
+
target = path.resolve(picked);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (fs.existsSync(target)) {
|
|
258
|
+
if (typeof ctx.ui.confirm === "function") {
|
|
259
|
+
const ok = await ctx.ui.confirm(
|
|
260
|
+
"Export relays",
|
|
261
|
+
`File already exists: ${target}\nOverwrite it?`,
|
|
262
|
+
);
|
|
263
|
+
if (!ok) {
|
|
264
|
+
ctx.ui.notify("Export cancelled — existing file left alone", "warning");
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
fs.writeFileSync(target, JSON.stringify(artifact, null, 2));
|
|
271
|
+
} catch (e) {
|
|
272
|
+
ctx.ui.notify(`Could not write relay file ${target}: ${(e as Error).message}.`, "error");
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
act.updateStatusBar?.(ctx.ui);
|
|
276
|
+
act.flash();
|
|
277
|
+
ctx.ui.notify(
|
|
278
|
+
includeSecrets
|
|
279
|
+
? `Exported ${count} relay(s) to ${target} WITH passwords included — keep this file private.`
|
|
280
|
+
: `Exported ${count} relay(s) to ${target} (passwords stripped — file is safe to share).`,
|
|
281
|
+
"info",
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export async function runImport(
|
|
286
|
+
ctx: ExtensionContext,
|
|
287
|
+
pathArg: string | undefined,
|
|
288
|
+
mode: "merge" | "replace",
|
|
289
|
+
dryRun: boolean,
|
|
290
|
+
act: RelayActionContext,
|
|
291
|
+
): Promise<void> {
|
|
292
|
+
const filePath = (pathArg || "").trim();
|
|
293
|
+
if (!filePath) {
|
|
294
|
+
ctx.ui.notify("Usage: /freeflow import <path> [--merge|--replace] [--dry-run] — path is required.", "warning");
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
let byteSize = 0;
|
|
298
|
+
try {
|
|
299
|
+
const st = fs.statSync(filePath);
|
|
300
|
+
if (!st.isFile()) {
|
|
301
|
+
ctx.ui.notify(`Could not read relay file ${filePath}: not a regular file.`, "error");
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
byteSize = st.size;
|
|
305
|
+
} catch (e) {
|
|
306
|
+
const code = e !== null && typeof e === "object" && "code" in e ? (e as Record<string, unknown>).code : undefined;
|
|
307
|
+
if (code === "ENOENT") {
|
|
308
|
+
ctx.ui.notify(`Relay file not found: ${filePath} — check the path and try again.`, "warning");
|
|
309
|
+
} else {
|
|
310
|
+
ctx.ui.notify(`Could not read relay file ${filePath}: ${(e as Error).message}.`, "error");
|
|
311
|
+
}
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (byteSize > EXPORT_MAX_BYTES) {
|
|
315
|
+
ctx.ui.notify(`Relay file ${filePath} is too large (${byteSize} bytes; limit ${EXPORT_MAX_BYTES} bytes). Nothing changed.`, "warning");
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
let raw: string;
|
|
319
|
+
try {
|
|
320
|
+
raw = fs.readFileSync(filePath, "utf8");
|
|
321
|
+
} catch (e) {
|
|
322
|
+
ctx.ui.notify(`Could not read relay file ${filePath}: ${(e as Error).message}.`, "error");
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
let fragment: RelayImportFragment;
|
|
326
|
+
let parseSkipped: Array<{ index: number; url: string; reason: string }> = [];
|
|
327
|
+
try {
|
|
328
|
+
const parsed = parseRelayImport(raw);
|
|
329
|
+
fragment = parsed.fragment;
|
|
330
|
+
parseSkipped = parsed.skipped;
|
|
331
|
+
} catch {
|
|
332
|
+
ctx.ui.notify(classifyImportFailure(filePath, raw), "warning");
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (!fragment || !Array.isArray(fragment.relays) || fragment.relays.length === 0) {
|
|
336
|
+
if (parseSkipped.length > 0) {
|
|
337
|
+
ctx.ui.notify(`Relay file ${filePath} holds no usable relay addresses (${parseSkipped.length} skipped: ${parseSkipped[0].reason}). Nothing changed.`, "warning");
|
|
338
|
+
} else {
|
|
339
|
+
ctx.ui.notify(`Relay file ${filePath} holds no usable relay addresses (0 skipped). Nothing changed.`, "warning");
|
|
340
|
+
}
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const plan = planRelayImport(loadRelayState(), fragment, { mode });
|
|
344
|
+
const skipped = [...parseSkipped];
|
|
345
|
+
for (const s of plan.skipped) {
|
|
346
|
+
if (!skipped.some((x) => x.index === s.index && x.url === s.url)) skipped.push(s);
|
|
347
|
+
}
|
|
348
|
+
const skippedCount = skipped.length;
|
|
349
|
+
const reasons = skippedCount > 0
|
|
350
|
+
? skipped.map((s) => s.reason).filter((r, i, arr) => arr.indexOf(r) === i).join("; ")
|
|
351
|
+
: "none";
|
|
352
|
+
const plannedActive = plan.next.url
|
|
353
|
+
? `[${shortRelayLabel(plan.next.url, plan.next.relays)}]`
|
|
354
|
+
: "none";
|
|
355
|
+
if (dryRun) {
|
|
356
|
+
if (mode === "replace") {
|
|
357
|
+
ctx.ui.notify(`DRY RUN — replace from ${filePath}: would install ${plan.added}, would remove ${plan.removed}, would skip ${skippedCount} (${reasons}). Active relay would be ${plannedActive}. Nothing changed.`, "info");
|
|
358
|
+
} else {
|
|
359
|
+
ctx.ui.notify(`DRY RUN — merge from ${filePath}: would add ${plan.added}, would update short name on ${plan.updated}, would skip ${skippedCount} (${reasons}). Active and mode unchanged. Nothing changed.`, "info");
|
|
360
|
+
}
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (mode === "replace") {
|
|
364
|
+
ctx.ui.notify(`Replace from ${filePath}: will install ${plan.added}, remove ${plan.removed}, skip ${skippedCount} (${reasons}). Active relay will be ${plannedActive}.`, "info");
|
|
365
|
+
if (typeof ctx.ui.confirm === "function") {
|
|
366
|
+
const ok = await ctx.ui.confirm(
|
|
367
|
+
"Replace all relays?",
|
|
368
|
+
`Install ${plan.added} relay(s) from ${filePath} and remove ${plan.removed} existing? This cannot be undone.`,
|
|
369
|
+
);
|
|
370
|
+
if (!ok) {
|
|
371
|
+
ctx.ui.notify("Replace cancelled — relay list unchanged.", "warning");
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
act.applyRelayState((s) => planRelayImport(s, fragment, { mode }).next);
|
|
377
|
+
act.persist();
|
|
378
|
+
act.flash();
|
|
379
|
+
const currentRelayState = act.getRelayState();
|
|
380
|
+
if (mode === "replace") {
|
|
381
|
+
const activeName = currentRelayState.url
|
|
382
|
+
? `[${shortRelayLabel(currentRelayState.url, currentRelayState.relays)}]`
|
|
383
|
+
: "none";
|
|
384
|
+
ctx.ui.notify(`Replaced relay list from ${filePath}: installed ${plan.added}, skipped ${skippedCount}. Active relay is now ${activeName}.`, "info");
|
|
385
|
+
} else {
|
|
386
|
+
ctx.ui.notify(`Merge from ${filePath}: added ${plan.added}, updated short name on ${plan.updated}, skipped ${skippedCount}. Relay list now holds ${currentRelayState.relays.length}.`, "info");
|
|
387
|
+
}
|
|
388
|
+
const withoutPassword = currentRelayState.relays.filter((r) => !r.auth).length;
|
|
389
|
+
if (withoutPassword > 0) {
|
|
390
|
+
ctx.ui.notify(`${withoutPassword} relay(s) have no password — reconnect each one before use.`, "warning");
|
|
391
|
+
}
|
|
392
|
+
for (const s of skipped) {
|
|
393
|
+
const shown = s.url || `#${s.index + 1}`;
|
|
394
|
+
const reason = s.reason.endsWith(".") ? s.reason : `${s.reason}.`;
|
|
395
|
+
ctx.ui.notify(`Skipped ${shown}: ${reason}`, "warning");
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export async function showRelayInteractiveMenu(
|
|
400
|
+
ctx: ExtensionContext,
|
|
401
|
+
deps: RelayCommandDeps,
|
|
402
|
+
): Promise<void> {
|
|
403
|
+
let relayState = getActiveRelayState();
|
|
404
|
+
const flash = (): void => {
|
|
405
|
+
ctx.ui.notify(formatRelayFlash(relayState), "info");
|
|
406
|
+
};
|
|
407
|
+
const persist = (): void => {
|
|
408
|
+
setStatusUi(ctx.ui);
|
|
409
|
+
deps.updateStatusBar?.(ctx.ui);
|
|
410
|
+
};
|
|
411
|
+
const applyRelayState = (
|
|
412
|
+
updater: (s: RelayState) => RelayState,
|
|
413
|
+
): RelayState => {
|
|
414
|
+
relayState = withRelayState(updater);
|
|
415
|
+
return relayState;
|
|
416
|
+
};
|
|
417
|
+
const act: RelayActionContext = {
|
|
418
|
+
applyRelayState,
|
|
419
|
+
persist,
|
|
420
|
+
flash,
|
|
421
|
+
getRelayState: () => relayState,
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const currentMode = (relayState.mode || "auto").toUpperCase();
|
|
425
|
+
const activeLabel = shortRelayLabel(relayState.url, relayState.relays);
|
|
426
|
+
const choice = await ctx.ui.select("freeflow relay", [
|
|
427
|
+
`Mode: ${currentMode} (${relayState.enabled ? "ON" : "OFF"}) → ${activeLabel}`,
|
|
428
|
+
"Add custom relay (URL + Short name)…",
|
|
429
|
+
"Switch active relay…",
|
|
430
|
+
"Rename / Set relay short name…",
|
|
431
|
+
"Remove relay…",
|
|
432
|
+
"Share relays (export file)…",
|
|
433
|
+
"Load relays from file (import)…",
|
|
434
|
+
"List saved relays",
|
|
435
|
+
"Deploy Cloudflare relay…",
|
|
436
|
+
"Deploy Deno relay…",
|
|
437
|
+
"Deploy Vercel relay (last resort)…",
|
|
438
|
+
"Mode: AUTO (sticky primary, auto-detect on model select)",
|
|
439
|
+
"Mode: ON (always relay, sticky primary)",
|
|
440
|
+
"Mode: OFF (always direct)",
|
|
441
|
+
"Mode: SPREAD (rotate across healthy relays)",
|
|
442
|
+
]);
|
|
443
|
+
if (choice === `Mode: ${currentMode} (${relayState.enabled ? "ON" : "OFF"}) → ${activeLabel}`) {
|
|
444
|
+
flash();
|
|
445
|
+
} else if (choice === "Add custom relay (URL + Short name)…") {
|
|
446
|
+
await addRelayInteractive(ctx, act);
|
|
447
|
+
} else if (choice === "Switch active relay…") {
|
|
448
|
+
await switchRelay(ctx, act);
|
|
449
|
+
} else if (choice === "Rename / Set relay short name…") {
|
|
450
|
+
await editRelayLabelMenu(ctx, act);
|
|
451
|
+
} else if (choice === "Remove relay…") {
|
|
452
|
+
await removeRelayMenu(ctx, act);
|
|
453
|
+
} else if (choice === "Share relays (export file)…") {
|
|
454
|
+
await runExport(ctx, undefined, false, { flash, updateStatusBar: deps.updateStatusBar });
|
|
455
|
+
} else if (choice === "Load relays from file (import)…") {
|
|
456
|
+
const picked = (await ctx.ui.input("Relay file path:", EXPORT_DEFAULT_FILENAME))?.trim();
|
|
457
|
+
if (!picked) return;
|
|
458
|
+
await runImport(ctx, picked, "merge", false, act);
|
|
459
|
+
} else if (choice === "List saved relays") {
|
|
460
|
+
showList(ctx, () => relayState);
|
|
461
|
+
} else if (choice === "Deploy Vercel relay (last resort)…") {
|
|
462
|
+
if (deps.doDeploy) await deps.doDeploy("vercel");
|
|
463
|
+
} else if (choice === "Deploy Cloudflare relay…") {
|
|
464
|
+
if (deps.doDeploy) await deps.doDeploy("cloudflare");
|
|
465
|
+
} else if (choice === "Deploy Deno relay…") {
|
|
466
|
+
if (deps.doDeploy) await deps.doDeploy("deno");
|
|
467
|
+
} else if (choice === "Mode: AUTO (sticky primary, auto-detect on model select)") {
|
|
468
|
+
applyRelayState((s) => {
|
|
469
|
+
s.mode = "auto";
|
|
470
|
+
s.enabled = true;
|
|
471
|
+
s.url = s.url || "";
|
|
472
|
+
if (s.url) {
|
|
473
|
+
ensureRelay(s, s.url);
|
|
474
|
+
}
|
|
475
|
+
return s;
|
|
476
|
+
});
|
|
477
|
+
persist();
|
|
478
|
+
flash();
|
|
479
|
+
} else if (choice === "Mode: ON (always relay, sticky primary)") {
|
|
480
|
+
applyRelayState((s) => {
|
|
481
|
+
s.mode = "on";
|
|
482
|
+
s.enabled = true;
|
|
483
|
+
s.url = s.url || "";
|
|
484
|
+
if (s.url) {
|
|
485
|
+
ensureRelay(s, s.url);
|
|
486
|
+
}
|
|
487
|
+
return s;
|
|
488
|
+
});
|
|
489
|
+
persist();
|
|
490
|
+
flash();
|
|
491
|
+
} else if (choice === "Mode: OFF (always direct)") {
|
|
492
|
+
applyRelayState((s) => {
|
|
493
|
+
s.mode = "off";
|
|
494
|
+
s.enabled = false;
|
|
495
|
+
return s;
|
|
496
|
+
});
|
|
497
|
+
persist();
|
|
498
|
+
flash();
|
|
499
|
+
} else if (choice === "Mode: SPREAD (rotate across healthy relays)") {
|
|
500
|
+
applyRelayState((s) => {
|
|
501
|
+
s.mode = "spread";
|
|
502
|
+
s.enabled = true;
|
|
503
|
+
s.url = s.url || "";
|
|
504
|
+
if (s.url) {
|
|
505
|
+
ensureRelay(s, s.url);
|
|
506
|
+
}
|
|
507
|
+
return s;
|
|
508
|
+
});
|
|
509
|
+
persist();
|
|
510
|
+
flash();
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export async function handleRelaySubcommand(
|
|
515
|
+
sub: string,
|
|
516
|
+
ctx: ExtensionContext,
|
|
517
|
+
deps: RelayCommandDeps,
|
|
518
|
+
): Promise<boolean> {
|
|
519
|
+
const rest = deps.rest;
|
|
520
|
+
let relayState = getActiveRelayState();
|
|
521
|
+
|
|
522
|
+
const flash = (): void => {
|
|
523
|
+
ctx.ui.notify(formatRelayFlash(relayState), "info");
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
const persist = (): void => {
|
|
527
|
+
setStatusUi(ctx.ui);
|
|
528
|
+
deps.updateStatusBar?.(ctx.ui);
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
const applyRelayState = (
|
|
532
|
+
updater: (s: RelayState) => RelayState,
|
|
533
|
+
): RelayState => {
|
|
534
|
+
relayState = withRelayState(updater);
|
|
535
|
+
return relayState;
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
const act: RelayActionContext = {
|
|
539
|
+
applyRelayState,
|
|
540
|
+
persist,
|
|
541
|
+
flash,
|
|
542
|
+
getRelayState: () => relayState,
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
if (sub === "auto" || sub === "sticky") {
|
|
546
|
+
applyRelayState((s) => {
|
|
547
|
+
s.mode = "auto";
|
|
548
|
+
s.enabled = true;
|
|
549
|
+
s.url = s.url || "";
|
|
550
|
+
if (s.url) {
|
|
551
|
+
ensureRelay(s, s.url);
|
|
552
|
+
}
|
|
553
|
+
return s;
|
|
554
|
+
});
|
|
555
|
+
persist();
|
|
556
|
+
flash();
|
|
557
|
+
return true;
|
|
558
|
+
} else if (sub === "spread") {
|
|
559
|
+
applyRelayState((s) => {
|
|
560
|
+
s.mode = "spread";
|
|
561
|
+
s.enabled = true;
|
|
562
|
+
s.url = s.url || "";
|
|
563
|
+
if (s.url) {
|
|
564
|
+
ensureRelay(s, s.url);
|
|
565
|
+
}
|
|
566
|
+
return s;
|
|
567
|
+
});
|
|
568
|
+
persist();
|
|
569
|
+
flash();
|
|
570
|
+
return true;
|
|
571
|
+
} else if (sub === "on") {
|
|
572
|
+
applyRelayState((s) => {
|
|
573
|
+
s.mode = "on";
|
|
574
|
+
s.enabled = true;
|
|
575
|
+
s.url = s.url || "";
|
|
576
|
+
if (s.url) {
|
|
577
|
+
ensureRelay(s, s.url);
|
|
578
|
+
}
|
|
579
|
+
return s;
|
|
580
|
+
});
|
|
581
|
+
persist();
|
|
582
|
+
flash();
|
|
583
|
+
return true;
|
|
584
|
+
} else if (sub === "off") {
|
|
585
|
+
applyRelayState((s) => {
|
|
586
|
+
s.mode = "off";
|
|
587
|
+
s.enabled = false;
|
|
588
|
+
return s;
|
|
589
|
+
});
|
|
590
|
+
persist();
|
|
591
|
+
flash();
|
|
592
|
+
return true;
|
|
593
|
+
} else if (sub === "hide" || (sub === "widget" && rest === "hide")) {
|
|
594
|
+
applyRelayState((s) => ({ ...s, hideWidget: true }));
|
|
595
|
+
persist();
|
|
596
|
+
deps.updateStatusBar?.(ctx.ui);
|
|
597
|
+
ctx.ui.notify("Widget hidden — use /freeflow show or /freeflow widget show to restore", "info");
|
|
598
|
+
return true;
|
|
599
|
+
} else if (sub === "show" || (sub === "widget" && rest === "show")) {
|
|
600
|
+
applyRelayState((s) => ({ ...s, hideWidget: false }));
|
|
601
|
+
persist();
|
|
602
|
+
flash();
|
|
603
|
+
ctx.ui.notify("Widget shown", "info");
|
|
604
|
+
return true;
|
|
605
|
+
} else if (sub === "list") {
|
|
606
|
+
showList(ctx, () => relayState);
|
|
607
|
+
return true;
|
|
608
|
+
} else if (sub === "add") {
|
|
609
|
+
if (!rest.trim()) {
|
|
610
|
+
await addRelayInteractive(ctx, act);
|
|
611
|
+
} else {
|
|
612
|
+
const tokens = rest.trim().split(/\s+/);
|
|
613
|
+
const targetUrl = tokens[0];
|
|
614
|
+
const customLabel = tokens.slice(1).join(" ").trim() || undefined;
|
|
615
|
+
try {
|
|
616
|
+
applyRelayState((s) => {
|
|
617
|
+
const added = ensureRelay(s, targetUrl, customLabel);
|
|
618
|
+
s.enabled = true;
|
|
619
|
+
s.url = added.url;
|
|
620
|
+
return s;
|
|
621
|
+
});
|
|
622
|
+
} catch (e) {
|
|
623
|
+
ctx.ui.notify((e as Error).message, "warning");
|
|
624
|
+
return true;
|
|
625
|
+
}
|
|
626
|
+
persist();
|
|
627
|
+
flash();
|
|
628
|
+
ctx.ui.notify(
|
|
629
|
+
`✓ Added & activated relay [${shortRelayLabel(relayState.url, relayState.relays)}]: ${relayState.url}`,
|
|
630
|
+
"info",
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
return true;
|
|
634
|
+
} else if (sub === "use") {
|
|
635
|
+
if (!rest.trim()) {
|
|
636
|
+
await switchRelay(ctx, act);
|
|
637
|
+
} else {
|
|
638
|
+
const tokens = rest.trim().split(/\s+/);
|
|
639
|
+
const targetToken = tokens[0];
|
|
640
|
+
const customLabel = tokens.slice(1).join(" ").trim() || undefined;
|
|
641
|
+
const matched = findRelay(relayState, targetToken);
|
|
642
|
+
if (matched) {
|
|
643
|
+
applyRelayState((s) => {
|
|
644
|
+
const fresh = s.relays.find((r) => r.url === matched.url);
|
|
645
|
+
if (!fresh) return s;
|
|
646
|
+
s.enabled = true;
|
|
647
|
+
s.url = fresh.url;
|
|
648
|
+
if (customLabel) {
|
|
649
|
+
fresh.label = customLabel;
|
|
650
|
+
}
|
|
651
|
+
return s;
|
|
652
|
+
});
|
|
653
|
+
persist();
|
|
654
|
+
flash();
|
|
655
|
+
} else {
|
|
656
|
+
// Only treat unmatched tokens as new relays when they are absolute
|
|
657
|
+
// http(s) URLs — typos or bad indices must not become saved junk relays.
|
|
658
|
+
let parsedUrl = null;
|
|
659
|
+
try {
|
|
660
|
+
parsedUrl = new URL(targetToken);
|
|
661
|
+
} catch { }
|
|
662
|
+
const looksLikeUrl = parsedUrl && (parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:");
|
|
663
|
+
if (looksLikeUrl) {
|
|
664
|
+
try {
|
|
665
|
+
applyRelayState((s) => {
|
|
666
|
+
const added = ensureRelay(s, targetToken, customLabel);
|
|
667
|
+
s.enabled = true;
|
|
668
|
+
s.url = added.url;
|
|
669
|
+
return s;
|
|
670
|
+
});
|
|
671
|
+
} catch (e) {
|
|
672
|
+
ctx.ui.notify((e as Error).message, "warning");
|
|
673
|
+
return true;
|
|
674
|
+
}
|
|
675
|
+
persist();
|
|
676
|
+
flash();
|
|
677
|
+
} else {
|
|
678
|
+
ctx.ui.notify(`Relay '${targetToken}' not found in saved list`, "warning");
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
return true;
|
|
683
|
+
} else if (sub === "label" || sub === "rename") {
|
|
684
|
+
if (!rest.trim()) {
|
|
685
|
+
await editRelayLabelMenu(ctx, act);
|
|
686
|
+
} else {
|
|
687
|
+
const tokens = rest.trim().split(/\s+/);
|
|
688
|
+
const targetToken = tokens[0];
|
|
689
|
+
const newLabel = tokens.slice(1).join(" ").trim();
|
|
690
|
+
const matched = findRelay(relayState, targetToken);
|
|
691
|
+
if (!matched) {
|
|
692
|
+
ctx.ui.notify(`Relay '${targetToken}' not found in saved list`, "warning");
|
|
693
|
+
return true;
|
|
694
|
+
}
|
|
695
|
+
if (!newLabel) {
|
|
696
|
+
ctx.ui.notify("Usage: /freeflow label <target> <new-name>", "warning");
|
|
697
|
+
return true;
|
|
698
|
+
}
|
|
699
|
+
applyRelayState((s) => {
|
|
700
|
+
setRelayLabel(s, matched.url, newLabel);
|
|
701
|
+
return s;
|
|
702
|
+
});
|
|
703
|
+
persist();
|
|
704
|
+
flash();
|
|
705
|
+
ctx.ui.notify(
|
|
706
|
+
`✓ Relay short name updated to [${newLabel}] for ${matched.url}`,
|
|
707
|
+
"info",
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
return true;
|
|
711
|
+
} else if (sub === "remove") {
|
|
712
|
+
if (!rest.trim()) {
|
|
713
|
+
await removeRelayMenu(ctx, act);
|
|
714
|
+
} else {
|
|
715
|
+
const target = rest.trim();
|
|
716
|
+
const matched = findRelay(relayState, target);
|
|
717
|
+
if (!matched) {
|
|
718
|
+
ctx.ui.notify(`Relay '${target}' not in saved list`, "warning");
|
|
719
|
+
return true;
|
|
720
|
+
}
|
|
721
|
+
if (matched.url === relayState.url && relayState.relays.length > 1) {
|
|
722
|
+
ctx.ui.notify(
|
|
723
|
+
"Cannot remove the active relay — switch to another relay first",
|
|
724
|
+
"warning",
|
|
725
|
+
);
|
|
726
|
+
return true;
|
|
727
|
+
}
|
|
728
|
+
applyRelayState((s) => {
|
|
729
|
+
removeRelay(s, matched.url);
|
|
730
|
+
return s;
|
|
731
|
+
});
|
|
732
|
+
persist();
|
|
733
|
+
ctx.ui.notify(`Removed: [${shortRelayLabel(matched.url, relayState.relays)}] ${matched.url}`, "info");
|
|
734
|
+
}
|
|
735
|
+
return true;
|
|
736
|
+
} else if (sub === "export") {
|
|
737
|
+
const tokens = rest.trim() ? rest.trim().split(/\s+/) : [];
|
|
738
|
+
let target: string | undefined;
|
|
739
|
+
let includeSecrets = false;
|
|
740
|
+
for (const t of tokens) {
|
|
741
|
+
if (t === "--include-secrets") {
|
|
742
|
+
includeSecrets = true;
|
|
743
|
+
} else if (t.startsWith("--")) {
|
|
744
|
+
ctx.ui.notify(`Unknown flag ${t} — usage: /freeflow export [path] [--include-secrets].`, "warning");
|
|
745
|
+
return true;
|
|
746
|
+
} else if (target === undefined) {
|
|
747
|
+
target = t;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
await runExport(ctx, target, includeSecrets, { flash, updateStatusBar: deps.updateStatusBar });
|
|
751
|
+
return true;
|
|
752
|
+
} else if (sub === "import") {
|
|
753
|
+
const tokens = rest.trim() ? rest.trim().split(/\s+/) : [];
|
|
754
|
+
let target: string | undefined;
|
|
755
|
+
let sawMerge = false;
|
|
756
|
+
let sawReplace = false;
|
|
757
|
+
let dryRun = false;
|
|
758
|
+
for (const t of tokens) {
|
|
759
|
+
if (t === "--merge") {
|
|
760
|
+
sawMerge = true;
|
|
761
|
+
} else if (t === "--replace") {
|
|
762
|
+
sawReplace = true;
|
|
763
|
+
} else if (t === "--dry-run") {
|
|
764
|
+
dryRun = true;
|
|
765
|
+
} else if (t.startsWith("--")) {
|
|
766
|
+
ctx.ui.notify(`Unknown flag ${t} — usage: /freeflow import <path> [--merge|--replace] [--dry-run].`, "warning");
|
|
767
|
+
return true;
|
|
768
|
+
} else if (target === undefined) {
|
|
769
|
+
target = t;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
if (sawMerge && sawReplace) {
|
|
773
|
+
ctx.ui.notify("Both --merge and --replace given — showing a merge preview. Nothing changed.", "info");
|
|
774
|
+
await runImport(ctx, target, "merge", true, act);
|
|
775
|
+
return true;
|
|
776
|
+
}
|
|
777
|
+
await runImport(ctx, target, sawReplace ? "replace" : "merge", dryRun, act);
|
|
778
|
+
return true;
|
|
779
|
+
} else if (sub === "url") {
|
|
780
|
+
const input =
|
|
781
|
+
rest ||
|
|
782
|
+
(await ctx.ui.input(
|
|
783
|
+
"Relay URL (empty = default):",
|
|
784
|
+
relayState.url || "",
|
|
785
|
+
));
|
|
786
|
+
const cleanInput = (input || "").trim() || "";
|
|
787
|
+
try {
|
|
788
|
+
applyRelayState((s) => {
|
|
789
|
+
s.url = cleanInput;
|
|
790
|
+
if (cleanInput) {
|
|
791
|
+
ensureRelay(s, cleanInput, shortRelayLabel(cleanInput, s.relays));
|
|
792
|
+
}
|
|
793
|
+
return s;
|
|
794
|
+
});
|
|
795
|
+
} catch (e) {
|
|
796
|
+
ctx.ui.notify((e as Error).message, "warning");
|
|
797
|
+
return true;
|
|
798
|
+
}
|
|
799
|
+
persist();
|
|
800
|
+
flash();
|
|
801
|
+
return true;
|
|
802
|
+
} else if (!sub) {
|
|
803
|
+
await showRelayInteractiveMenu(ctx, deps);
|
|
804
|
+
return true;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
return false;
|
|
808
|
+
}
|