pi-freeflow 1.26.0 → 1.27.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 +17 -0
- package/package.json +1 -1
- package/src/cline-accounts.ts +962 -891
- package/src/commands.ts +1888 -1738
- package/src/deploy.ts +658 -576
- package/src/proxy.ts +138 -49
- package/src/relay-state.ts +1140 -1133
- package/src/relay.ts +422 -399
- package/src/responses.ts +425 -264
package/src/commands.ts
CHANGED
|
@@ -1,1738 +1,1888 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Interactive CLI slash commands and status bar manager for pi-freeflow
|
|
3
|
-
*
|
|
4
|
-
* log viewing, debug level configuration, and live catalog refreshing.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { spawn } from "node:child_process";
|
|
8
|
-
import { execSync } from "node:child_process";
|
|
9
|
-
import fs from "node:fs";
|
|
10
|
-
import os from "node:os";
|
|
11
|
-
import path from "node:path";
|
|
12
|
-
import { fileURLToPath } from "node:url";
|
|
13
|
-
import { getClientPort, isJsRuntimeExecutable, resolveDaemonRuntime } from "./client.ts";
|
|
14
|
-
import { refreshCatalog, setAliveCatalog } from "./catalog.ts";
|
|
15
|
-
import { DAEMON_SPAWN_ENV, DEBUG_STATE_FILE, HOST, LOG_FILE, PORT, RELAY_STATE_FILE } from "./config.ts";
|
|
16
|
-
import {
|
|
17
|
-
compareVersions,
|
|
18
|
-
fetchLatestVersion,
|
|
19
|
-
getCachedUpdate,
|
|
20
|
-
isLinkedInstall,
|
|
21
|
-
getLocalVersion,
|
|
22
|
-
} from "./update-checker.ts";
|
|
23
|
-
import { isCommandAvailable, selectGlobalUpdatePlan, selectHostUpdateSteps } from "./updater.ts";
|
|
24
|
-
import {
|
|
25
|
-
deployCloudflareWorker,
|
|
26
|
-
deployDenoRelay,
|
|
27
|
-
deployVercelRelay,
|
|
28
|
-
type DeployPlatform,
|
|
29
|
-
} from "./deploy.ts";
|
|
30
|
-
import {
|
|
31
|
-
LOG_LEVEL_ORDER,
|
|
32
|
-
getMinLogLevel,
|
|
33
|
-
isDebugEnabled,
|
|
34
|
-
loadDebugState,
|
|
35
|
-
readRecentLogs,
|
|
36
|
-
saveDebugState,
|
|
37
|
-
} from "./logger.ts";
|
|
38
|
-
import { probeRelay } from "./probe.ts";
|
|
39
|
-
import { getUpstreamHealth, resetUpstreamGate } from "./upstream-health.ts";
|
|
40
|
-
import {
|
|
41
|
-
buildRelayExport,
|
|
42
|
-
ensureRelay,
|
|
43
|
-
EXPORT_DEFAULT_FILENAME,
|
|
44
|
-
EXPORT_MAX_BYTES,
|
|
45
|
-
findRelay,
|
|
46
|
-
getActiveRelayState,
|
|
47
|
-
getRelayHealth,
|
|
48
|
-
loadRelayState,
|
|
49
|
-
parseRelayImport,
|
|
50
|
-
planRelayImport,
|
|
51
|
-
removeRelay,
|
|
52
|
-
setActiveRelayState,
|
|
53
|
-
setRelayLabel,
|
|
54
|
-
setStatusUi,
|
|
55
|
-
shortRelayLabel,
|
|
56
|
-
withRelayState,
|
|
57
|
-
formatRelayFlash,
|
|
58
|
-
formatRelayPickerItem,
|
|
59
|
-
formatRelayStatusLabel,
|
|
60
|
-
} from "./relay-state.ts";
|
|
61
|
-
import { addAccount, findClineAccountSlot, loadPool, redactedToken, removeAccount } from "./cline-accounts.ts";
|
|
62
|
-
import type { ClinePoolState } from "./cline-accounts.ts";
|
|
63
|
-
import { CLINE_BROWSER_SIGNOUT_URL, pollDeviceToken, registerClineToken, startDeviceAuth, toApiKey } from "./cline-device-auth.ts";
|
|
64
|
-
import { isCertError } from "./system-ca-fetch.ts";
|
|
65
|
-
import type {
|
|
66
|
-
ExtensionAPI,
|
|
67
|
-
ExtensionContext,
|
|
68
|
-
ExtensionUIContext,
|
|
69
|
-
KnownRelay,
|
|
70
|
-
LogLevel,
|
|
71
|
-
RegisteredCommand,
|
|
72
|
-
RegisteredModel,
|
|
73
|
-
RelayState,
|
|
74
|
-
} from "./types.ts";
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Update the TUI status bar widget with active relay state.
|
|
78
|
-
*/
|
|
79
|
-
export function updateStatusBar(ui?: ExtensionUIContext): void {
|
|
80
|
-
if (!ui) return;
|
|
81
|
-
ui.setStatus("freeflow", formatRelayStatusLabel(getActiveRelayState()) ?? undefined);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export const STARTUP_TASK_NAME = "pi-freeflow-daemon";
|
|
85
|
-
export const STARTUP_SERVICE_NAME = "pi-freeflow.service";
|
|
86
|
-
|
|
87
|
-
export interface StartupPlan {
|
|
88
|
-
platform: string;
|
|
89
|
-
taskName: string;
|
|
90
|
-
command: string;
|
|
91
|
-
filePath: string;
|
|
92
|
-
fileContent: string;
|
|
93
|
-
undo: string;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function daemonEntryForStartup(scriptOverride?: string): string {
|
|
97
|
-
if (scriptOverride) return scriptOverride;
|
|
98
|
-
try {
|
|
99
|
-
return path.join(path.dirname(fileURLToPath(import.meta.url)), "daemon.ts");
|
|
100
|
-
} catch {
|
|
101
|
-
return path.join("src", "daemon.ts");
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Build the single-shot login-startup plan without touching the system.
|
|
107
|
-
* Windows → logon Scheduled Task (schtasks /sc onlogon, single run at logon).
|
|
108
|
-
* Linux/macOS → user systemd unit (Type=oneshot, WantedBy=default.target).
|
|
109
|
-
* Pure (testable): all paths/commands are derived from the overrides.
|
|
110
|
-
*/
|
|
111
|
-
export function getStartupPlan(opts?: {
|
|
112
|
-
platform?: string;
|
|
113
|
-
homeDir?: string;
|
|
114
|
-
execPath?: string;
|
|
115
|
-
scriptPath?: string;
|
|
116
|
-
}): StartupPlan {
|
|
117
|
-
const platform = opts?.platform ?? process.platform;
|
|
118
|
-
const home = opts?.homeDir ?? os.homedir();
|
|
119
|
-
const script = daemonEntryForStartup(opts?.scriptPath);
|
|
120
|
-
let quoted: string;
|
|
121
|
-
if (opts?.execPath) {
|
|
122
|
-
const type = isJsRuntimeExecutable(opts.execPath);
|
|
123
|
-
if (type === "bun") {
|
|
124
|
-
quoted = `"${opts.execPath}" "${script}"`;
|
|
125
|
-
} else {
|
|
126
|
-
quoted = `"${opts.execPath}" --experimental-strip-types "${script}"`;
|
|
127
|
-
}
|
|
128
|
-
} else {
|
|
129
|
-
const runtime = resolveDaemonRuntime({ scriptPath: script });
|
|
130
|
-
if (runtime) {
|
|
131
|
-
if (runtime.runtimeType === "bun") {
|
|
132
|
-
quoted = `"${runtime.execPath}" "${script}"`;
|
|
133
|
-
} else {
|
|
134
|
-
quoted = `"${runtime.execPath}" --experimental-strip-types "${script}"`;
|
|
135
|
-
}
|
|
136
|
-
} else {
|
|
137
|
-
quoted = `"node" --experimental-strip-types "${script}"`;
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
if (platform === "win32") {
|
|
141
|
-
const command = `schtasks /create /tn "${STARTUP_TASK_NAME}" /tr "${quoted.replace(/"/g, "'")}" /sc onlogon /rl limited /f`;
|
|
142
|
-
return {
|
|
143
|
-
platform,
|
|
144
|
-
taskName: STARTUP_TASK_NAME,
|
|
145
|
-
command,
|
|
146
|
-
filePath: `Scheduled Task \\${STARTUP_TASK_NAME} (logon trigger, single-shot)`,
|
|
147
|
-
fileContent: command,
|
|
148
|
-
undo: `schtasks /delete /tn "${STARTUP_TASK_NAME}" /f`,
|
|
149
|
-
};
|
|
150
|
-
}
|
|
151
|
-
const filePath = path.join(home, ".config", "systemd", "user", STARTUP_SERVICE_NAME);
|
|
152
|
-
const fileContent = [
|
|
153
|
-
"[Unit]",
|
|
154
|
-
"Description=pi-freeflow proxy daemon (single-shot at login)",
|
|
155
|
-
"After=default.target",
|
|
156
|
-
"",
|
|
157
|
-
"[Service]",
|
|
158
|
-
"Type=oneshot",
|
|
159
|
-
`ExecStart=${quoted}`,
|
|
160
|
-
"RemainAfterExit=yes",
|
|
161
|
-
"",
|
|
162
|
-
"[Install]",
|
|
163
|
-
"WantedBy=default.target",
|
|
164
|
-
"",
|
|
165
|
-
].join("\n");
|
|
166
|
-
return {
|
|
167
|
-
platform,
|
|
168
|
-
taskName: STARTUP_SERVICE_NAME,
|
|
169
|
-
command: `systemctl --user enable ${STARTUP_SERVICE_NAME}`,
|
|
170
|
-
filePath,
|
|
171
|
-
fileContent,
|
|
172
|
-
undo: `systemctl --user disable ${STARTUP_SERVICE_NAME} && rm "${filePath}"`,
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Install the login-startup hook (single-shot, idempotent). Returns whether a
|
|
178
|
-
* change was made plus the exact undo text for the caller to display.
|
|
179
|
-
* Refuses when DAEMON_SPAWN=0. Never throws — failures are returned.
|
|
180
|
-
*/
|
|
181
|
-
export function installStartupHook(opts?: {
|
|
182
|
-
platform?: string;
|
|
183
|
-
homeDir?: string;
|
|
184
|
-
execPath?: string;
|
|
185
|
-
scriptPath?: string;
|
|
186
|
-
}): { ok: boolean; changed: boolean; undo: string; detail: string } {
|
|
187
|
-
if (process.env[DAEMON_SPAWN_ENV] === "0") {
|
|
188
|
-
return { ok: false, changed: false, undo: "", detail: "refused: daemon spawn is disabled (DAEMON_SPAWN=0)" };
|
|
189
|
-
}
|
|
190
|
-
const plan = getStartupPlan(opts);
|
|
191
|
-
try {
|
|
192
|
-
if (plan.platform === "win32") {
|
|
193
|
-
try {
|
|
194
|
-
const q = execSync(`schtasks /query /tn "${STARTUP_TASK_NAME}"`, { encoding: "utf8", timeout: 5000, windowsHide: true }) as string;
|
|
195
|
-
if (q && q.includes(STARTUP_TASK_NAME)) {
|
|
196
|
-
execSync(plan.command, { timeout: 10_000, stdio: "ignore", windowsHide: true });
|
|
197
|
-
return { ok: true, changed: false, undo: plan.undo, detail: `already installed — refreshed logon task "${STARTUP_TASK_NAME}" (undo: ${plan.undo})` };
|
|
198
|
-
}
|
|
199
|
-
} catch { }
|
|
200
|
-
execSync(plan.command, { timeout: 10_000, stdio: "ignore", windowsHide: true });
|
|
201
|
-
return { ok: true, changed: true, undo: plan.undo, detail: `installed logon task "${STARTUP_TASK_NAME}" (undo: ${plan.undo})` };
|
|
202
|
-
}
|
|
203
|
-
const dir = path.dirname(plan.filePath);
|
|
204
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
205
|
-
let existing = "";
|
|
206
|
-
try {
|
|
207
|
-
existing = fs.readFileSync(plan.filePath, "utf8");
|
|
208
|
-
} catch { }
|
|
209
|
-
if (existing === plan.fileContent) {
|
|
210
|
-
return { ok: true, changed: false, undo: plan.undo, detail: `already installed — ${plan.filePath} up to date (undo: ${plan.undo})` };
|
|
211
|
-
}
|
|
212
|
-
fs.writeFileSync(plan.filePath, plan.fileContent, "utf8");
|
|
213
|
-
try {
|
|
214
|
-
execSync("systemctl --user daemon-reload", { timeout: 10_000, stdio: "ignore" });
|
|
215
|
-
execSync(plan.command, { timeout: 10_000, stdio: "ignore" });
|
|
216
|
-
} catch { }
|
|
217
|
-
return { ok: true, changed: true, undo: plan.undo, detail: `installed ${plan.filePath} (undo: ${plan.undo})` };
|
|
218
|
-
} catch (e) {
|
|
219
|
-
return { ok: false, changed: false, undo: plan.undo, detail: `install failed: ${String(e)} (undo: ${plan.undo})` };
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* Remove the login-startup hook (idempotent). Returns the undo/redo text.
|
|
225
|
-
* Refuses when DAEMON_SPAWN=0, matching install.
|
|
226
|
-
*/
|
|
227
|
-
export function uninstallStartupHook(opts?: {
|
|
228
|
-
platform?: string;
|
|
229
|
-
homeDir?: string;
|
|
230
|
-
execPath?: string;
|
|
231
|
-
scriptPath?: string;
|
|
232
|
-
}): { ok: boolean; changed: boolean; undo: string; detail: string } {
|
|
233
|
-
if (process.env[DAEMON_SPAWN_ENV] === "0") {
|
|
234
|
-
return { ok: false, changed: false, undo: "", detail: "refused: daemon spawn is disabled (DAEMON_SPAWN=0)" };
|
|
235
|
-
}
|
|
236
|
-
const plan = getStartupPlan(opts);
|
|
237
|
-
try {
|
|
238
|
-
if (plan.platform === "win32") {
|
|
239
|
-
try {
|
|
240
|
-
execSync(plan.undo, { timeout: 10_000, stdio: "ignore", windowsHide: true });
|
|
241
|
-
return { ok: true, changed: true, undo: plan.command, detail: `removed logon task "${STARTUP_TASK_NAME}" (re-install: ${plan.command})` };
|
|
242
|
-
} catch {
|
|
243
|
-
return { ok: true, changed: false, undo: plan.command, detail: `already removed — no logon task "${STARTUP_TASK_NAME}" found` };
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
let existed = false;
|
|
247
|
-
try {
|
|
248
|
-
fs.accessSync(plan.filePath, fs.constants.F_OK);
|
|
249
|
-
existed = true;
|
|
250
|
-
} catch { }
|
|
251
|
-
if (!existed) {
|
|
252
|
-
return { ok: true, changed: false, undo: plan.command, detail: `already removed — no unit file at ${plan.filePath}` };
|
|
253
|
-
}
|
|
254
|
-
try {
|
|
255
|
-
execSync(`systemctl --user disable ${STARTUP_SERVICE_NAME}`, { timeout: 10_000, stdio: "ignore" });
|
|
256
|
-
} catch { }
|
|
257
|
-
fs.rmSync(plan.filePath, { force: true });
|
|
258
|
-
return { ok: true, changed: true, undo: plan.command, detail: `removed ${plan.filePath} (re-install: ${plan.command})` };
|
|
259
|
-
} catch (e) {
|
|
260
|
-
return { ok: false, changed: false, undo: plan.undo, detail: `uninstall failed: ${String(e)}` };
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
function spawnWithProgress(
|
|
265
|
-
cmd: string,
|
|
266
|
-
args: string[],
|
|
267
|
-
ctx: ExtensionContext,
|
|
268
|
-
timeoutMs = 120_000,
|
|
269
|
-
): Promise<number> {
|
|
270
|
-
const { promise, resolve } = Promise.withResolvers<number>();
|
|
271
|
-
try {
|
|
272
|
-
const child = spawn(cmd, args, {
|
|
273
|
-
shell: process.platform === "win32",
|
|
274
|
-
stdio: "pipe",
|
|
275
|
-
windowsHide: true,
|
|
276
|
-
});
|
|
277
|
-
const timer = setTimeout(() => {
|
|
278
|
-
try { child.kill(); } catch { }
|
|
279
|
-
ctx.ui.notify(`spawn ${cmd} timed out after ${timeoutMs}ms — killed`, "warning");
|
|
280
|
-
resolve(1);
|
|
281
|
-
}, timeoutMs);
|
|
282
|
-
try { timer.unref?.(); } catch { }
|
|
283
|
-
const done = (code: number | null): void => {
|
|
284
|
-
clearTimeout(timer);
|
|
285
|
-
resolve(code ?? 0);
|
|
286
|
-
};
|
|
287
|
-
child.stdout?.on("data", (d: Buffer) => {
|
|
288
|
-
const s = String(d).trim();
|
|
289
|
-
if (s) ctx.ui.notify(s, "info");
|
|
290
|
-
});
|
|
291
|
-
child.stderr?.on("data", (d: Buffer) => {
|
|
292
|
-
const s = String(d).trim();
|
|
293
|
-
if (s) ctx.ui.notify(s, "info");
|
|
294
|
-
});
|
|
295
|
-
child.on("error", (err: Error) => {
|
|
296
|
-
clearTimeout(timer);
|
|
297
|
-
ctx.ui.notify(`spawn ${cmd} failed: ${err.message}`, "warning");
|
|
298
|
-
resolve(1);
|
|
299
|
-
});
|
|
300
|
-
child.on("close", done);
|
|
301
|
-
} catch (e) {
|
|
302
|
-
ctx.ui.notify(`spawn ${cmd} failed: ${(e as Error).message}`, "warning");
|
|
303
|
-
resolve(1);
|
|
304
|
-
}
|
|
305
|
-
return promise;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/**
|
|
309
|
-
* Follow-mode log poller: tracks the count of matched lines already printed
|
|
310
|
-
* and notifies only lines beyond it, so repeated ticks never re-notify the
|
|
311
|
-
* same tail. One poller at a time — a re-run clears the previous interval.
|
|
312
|
-
* Stop with stopLogsFollow (session shutdown / non-follow commands).
|
|
313
|
-
*/
|
|
314
|
-
let logsFollowTimer: NodeJS.Timeout | null = null;
|
|
315
|
-
export function stopLogsFollow(): void {
|
|
316
|
-
if (logsFollowTimer) {
|
|
317
|
-
clearInterval(logsFollowTimer);
|
|
318
|
-
logsFollowTimer = null;
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
function startLogsFollow(
|
|
322
|
-
ctx: ExtensionContext,
|
|
323
|
-
filterLevel: LogLevel | null,
|
|
324
|
-
filterReqId: string | null,
|
|
325
|
-
count: number,
|
|
326
|
-
filterText: string | null,
|
|
327
|
-
baselineMatched: number,
|
|
328
|
-
): void {
|
|
329
|
-
stopLogsFollow();
|
|
330
|
-
let lastPrinted = baselineMatched;
|
|
331
|
-
logsFollowTimer = setInterval(() => {
|
|
332
|
-
try {
|
|
333
|
-
const tail = readRecentLogs(filterLevel, filterReqId, count, filterText);
|
|
334
|
-
if (tail.totalMatched < lastPrinted) {
|
|
335
|
-
// Log rotated/rewritten below the baseline — re-baseline silently.
|
|
336
|
-
lastPrinted = tail.totalMatched;
|
|
337
|
-
return;
|
|
338
|
-
}
|
|
339
|
-
const newCount = tail.totalMatched - lastPrinted;
|
|
340
|
-
if (newCount <= 0) return;
|
|
341
|
-
const newLines = tail.lines.slice(
|
|
342
|
-
tail.lines.length - Math.min(newCount, tail.lines.length),
|
|
343
|
-
).slice(-50);
|
|
344
|
-
if (newLines.length > 0) {
|
|
345
|
-
ctx.ui.notify(newLines.join("\n"), "info");
|
|
346
|
-
}
|
|
347
|
-
lastPrinted = tail.totalMatched;
|
|
348
|
-
} catch { }
|
|
349
|
-
}, 1000);
|
|
350
|
-
// @ts-ignore allow unref to not block process exit in CLI
|
|
351
|
-
logsFollowTimer.unref?.();
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
/**
|
|
355
|
-
* Pick the login slot when the user gives none: "default" for the first
|
|
356
|
-
* login, then the smallest free "slot-2", "slot-3", … — never overwriting
|
|
357
|
-
* a saved login. Pure — takes the pool, returns a name.
|
|
358
|
-
*/
|
|
359
|
-
function nextClineSlot(pool: ClinePoolState): string {
|
|
360
|
-
if (!pool.accounts.some((a) => a.slot === "default")) return "default";
|
|
361
|
-
let n = 2;
|
|
362
|
-
while (pool.accounts.some((a) => a.slot === `slot-${n}`)) n += 1;
|
|
363
|
-
return `slot-${n}`;
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/**
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
);
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
const
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
);
|
|
686
|
-
const
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
);
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
const
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
const
|
|
759
|
-
|
|
760
|
-
`
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
const
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
const
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
if (
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
if (
|
|
947
|
-
ctx.ui.notify(
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
.
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
}
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
);
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
ctx.ui.notify(
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
)
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
)
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
"
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
if (
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
if (
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
}
|
|
1454
|
-
return;
|
|
1455
|
-
}
|
|
1456
|
-
|
|
1457
|
-
const
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
}
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
ctx.ui.notify(
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
}
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
}
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
)
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
);
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
}
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
}
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
}
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Interactive CLI slash commands and status bar manager for pi-freeflow
|
|
3
|
+
*
|
|
4
|
+
* log viewing, debug level configuration, and live catalog refreshing.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { execSync } from "node:child_process";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { getClientPort, isJsRuntimeExecutable, resolveDaemonRuntime } from "./client.ts";
|
|
14
|
+
import { refreshCatalog, setAliveCatalog } from "./catalog.ts";
|
|
15
|
+
import { DAEMON_SPAWN_ENV, DEBUG_STATE_FILE, HOST, LOG_FILE, PORT, RELAY_STATE_FILE } from "./config.ts";
|
|
16
|
+
import {
|
|
17
|
+
compareVersions,
|
|
18
|
+
fetchLatestVersion,
|
|
19
|
+
getCachedUpdate,
|
|
20
|
+
isLinkedInstall,
|
|
21
|
+
getLocalVersion,
|
|
22
|
+
} from "./update-checker.ts";
|
|
23
|
+
import { isCommandAvailable, selectGlobalUpdatePlan, selectHostUpdateSteps } from "./updater.ts";
|
|
24
|
+
import {
|
|
25
|
+
deployCloudflareWorker,
|
|
26
|
+
deployDenoRelay,
|
|
27
|
+
deployVercelRelay,
|
|
28
|
+
type DeployPlatform,
|
|
29
|
+
} from "./deploy.ts";
|
|
30
|
+
import {
|
|
31
|
+
LOG_LEVEL_ORDER,
|
|
32
|
+
getMinLogLevel,
|
|
33
|
+
isDebugEnabled,
|
|
34
|
+
loadDebugState,
|
|
35
|
+
readRecentLogs,
|
|
36
|
+
saveDebugState,
|
|
37
|
+
} from "./logger.ts";
|
|
38
|
+
import { probeRelay } from "./probe.ts";
|
|
39
|
+
import { getUpstreamHealth, resetUpstreamGate } from "./upstream-health.ts";
|
|
40
|
+
import {
|
|
41
|
+
buildRelayExport,
|
|
42
|
+
ensureRelay,
|
|
43
|
+
EXPORT_DEFAULT_FILENAME,
|
|
44
|
+
EXPORT_MAX_BYTES,
|
|
45
|
+
findRelay,
|
|
46
|
+
getActiveRelayState,
|
|
47
|
+
getRelayHealth,
|
|
48
|
+
loadRelayState,
|
|
49
|
+
parseRelayImport,
|
|
50
|
+
planRelayImport,
|
|
51
|
+
removeRelay,
|
|
52
|
+
setActiveRelayState,
|
|
53
|
+
setRelayLabel,
|
|
54
|
+
setStatusUi,
|
|
55
|
+
shortRelayLabel,
|
|
56
|
+
withRelayState,
|
|
57
|
+
formatRelayFlash,
|
|
58
|
+
formatRelayPickerItem,
|
|
59
|
+
formatRelayStatusLabel,
|
|
60
|
+
} from "./relay-state.ts";
|
|
61
|
+
import { addAccount, findClineAccountSlot, loadPool, redactedToken, removeAccount } from "./cline-accounts.ts";
|
|
62
|
+
import type { ClinePoolState } from "./cline-accounts.ts";
|
|
63
|
+
import { CLINE_BROWSER_SIGNOUT_URL, pollDeviceToken, registerClineToken, startDeviceAuth, toApiKey } from "./cline-device-auth.ts";
|
|
64
|
+
import { isCertError } from "./system-ca-fetch.ts";
|
|
65
|
+
import type {
|
|
66
|
+
ExtensionAPI,
|
|
67
|
+
ExtensionContext,
|
|
68
|
+
ExtensionUIContext,
|
|
69
|
+
KnownRelay,
|
|
70
|
+
LogLevel,
|
|
71
|
+
RegisteredCommand,
|
|
72
|
+
RegisteredModel,
|
|
73
|
+
RelayState,
|
|
74
|
+
} from "./types.ts";
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Update the TUI status bar widget with active relay state.
|
|
78
|
+
*/
|
|
79
|
+
export function updateStatusBar(ui?: ExtensionUIContext): void {
|
|
80
|
+
if (!ui) return;
|
|
81
|
+
ui.setStatus("freeflow", formatRelayStatusLabel(getActiveRelayState()) ?? undefined);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const STARTUP_TASK_NAME = "pi-freeflow-daemon";
|
|
85
|
+
export const STARTUP_SERVICE_NAME = "pi-freeflow.service";
|
|
86
|
+
|
|
87
|
+
export interface StartupPlan {
|
|
88
|
+
platform: string;
|
|
89
|
+
taskName: string;
|
|
90
|
+
command: string;
|
|
91
|
+
filePath: string;
|
|
92
|
+
fileContent: string;
|
|
93
|
+
undo: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function daemonEntryForStartup(scriptOverride?: string): string {
|
|
97
|
+
if (scriptOverride) return scriptOverride;
|
|
98
|
+
try {
|
|
99
|
+
return path.join(path.dirname(fileURLToPath(import.meta.url)), "daemon.ts");
|
|
100
|
+
} catch {
|
|
101
|
+
return path.join("src", "daemon.ts");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Build the single-shot login-startup plan without touching the system.
|
|
107
|
+
* Windows → logon Scheduled Task (schtasks /sc onlogon, single run at logon).
|
|
108
|
+
* Linux/macOS → user systemd unit (Type=oneshot, WantedBy=default.target).
|
|
109
|
+
* Pure (testable): all paths/commands are derived from the overrides.
|
|
110
|
+
*/
|
|
111
|
+
export function getStartupPlan(opts?: {
|
|
112
|
+
platform?: string;
|
|
113
|
+
homeDir?: string;
|
|
114
|
+
execPath?: string;
|
|
115
|
+
scriptPath?: string;
|
|
116
|
+
}): StartupPlan {
|
|
117
|
+
const platform = opts?.platform ?? process.platform;
|
|
118
|
+
const home = opts?.homeDir ?? os.homedir();
|
|
119
|
+
const script = daemonEntryForStartup(opts?.scriptPath);
|
|
120
|
+
let quoted: string;
|
|
121
|
+
if (opts?.execPath) {
|
|
122
|
+
const type = isJsRuntimeExecutable(opts.execPath);
|
|
123
|
+
if (type === "bun") {
|
|
124
|
+
quoted = `"${opts.execPath}" "${script}"`;
|
|
125
|
+
} else {
|
|
126
|
+
quoted = `"${opts.execPath}" --experimental-strip-types "${script}"`;
|
|
127
|
+
}
|
|
128
|
+
} else {
|
|
129
|
+
const runtime = resolveDaemonRuntime({ scriptPath: script });
|
|
130
|
+
if (runtime) {
|
|
131
|
+
if (runtime.runtimeType === "bun") {
|
|
132
|
+
quoted = `"${runtime.execPath}" "${script}"`;
|
|
133
|
+
} else {
|
|
134
|
+
quoted = `"${runtime.execPath}" --experimental-strip-types "${script}"`;
|
|
135
|
+
}
|
|
136
|
+
} else {
|
|
137
|
+
quoted = `"node" --experimental-strip-types "${script}"`;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (platform === "win32") {
|
|
141
|
+
const command = `schtasks /create /tn "${STARTUP_TASK_NAME}" /tr "${quoted.replace(/"/g, "'")}" /sc onlogon /rl limited /f`;
|
|
142
|
+
return {
|
|
143
|
+
platform,
|
|
144
|
+
taskName: STARTUP_TASK_NAME,
|
|
145
|
+
command,
|
|
146
|
+
filePath: `Scheduled Task \\${STARTUP_TASK_NAME} (logon trigger, single-shot)`,
|
|
147
|
+
fileContent: command,
|
|
148
|
+
undo: `schtasks /delete /tn "${STARTUP_TASK_NAME}" /f`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const filePath = path.join(home, ".config", "systemd", "user", STARTUP_SERVICE_NAME);
|
|
152
|
+
const fileContent = [
|
|
153
|
+
"[Unit]",
|
|
154
|
+
"Description=pi-freeflow proxy daemon (single-shot at login)",
|
|
155
|
+
"After=default.target",
|
|
156
|
+
"",
|
|
157
|
+
"[Service]",
|
|
158
|
+
"Type=oneshot",
|
|
159
|
+
`ExecStart=${quoted}`,
|
|
160
|
+
"RemainAfterExit=yes",
|
|
161
|
+
"",
|
|
162
|
+
"[Install]",
|
|
163
|
+
"WantedBy=default.target",
|
|
164
|
+
"",
|
|
165
|
+
].join("\n");
|
|
166
|
+
return {
|
|
167
|
+
platform,
|
|
168
|
+
taskName: STARTUP_SERVICE_NAME,
|
|
169
|
+
command: `systemctl --user enable ${STARTUP_SERVICE_NAME}`,
|
|
170
|
+
filePath,
|
|
171
|
+
fileContent,
|
|
172
|
+
undo: `systemctl --user disable ${STARTUP_SERVICE_NAME} && rm "${filePath}"`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Install the login-startup hook (single-shot, idempotent). Returns whether a
|
|
178
|
+
* change was made plus the exact undo text for the caller to display.
|
|
179
|
+
* Refuses when DAEMON_SPAWN=0. Never throws — failures are returned.
|
|
180
|
+
*/
|
|
181
|
+
export function installStartupHook(opts?: {
|
|
182
|
+
platform?: string;
|
|
183
|
+
homeDir?: string;
|
|
184
|
+
execPath?: string;
|
|
185
|
+
scriptPath?: string;
|
|
186
|
+
}): { ok: boolean; changed: boolean; undo: string; detail: string } {
|
|
187
|
+
if (process.env[DAEMON_SPAWN_ENV] === "0") {
|
|
188
|
+
return { ok: false, changed: false, undo: "", detail: "refused: daemon spawn is disabled (DAEMON_SPAWN=0)" };
|
|
189
|
+
}
|
|
190
|
+
const plan = getStartupPlan(opts);
|
|
191
|
+
try {
|
|
192
|
+
if (plan.platform === "win32") {
|
|
193
|
+
try {
|
|
194
|
+
const q = execSync(`schtasks /query /tn "${STARTUP_TASK_NAME}"`, { encoding: "utf8", timeout: 5000, windowsHide: true }) as string;
|
|
195
|
+
if (q && q.includes(STARTUP_TASK_NAME)) {
|
|
196
|
+
execSync(plan.command, { timeout: 10_000, stdio: "ignore", windowsHide: true });
|
|
197
|
+
return { ok: true, changed: false, undo: plan.undo, detail: `already installed — refreshed logon task "${STARTUP_TASK_NAME}" (undo: ${plan.undo})` };
|
|
198
|
+
}
|
|
199
|
+
} catch { }
|
|
200
|
+
execSync(plan.command, { timeout: 10_000, stdio: "ignore", windowsHide: true });
|
|
201
|
+
return { ok: true, changed: true, undo: plan.undo, detail: `installed logon task "${STARTUP_TASK_NAME}" (undo: ${plan.undo})` };
|
|
202
|
+
}
|
|
203
|
+
const dir = path.dirname(plan.filePath);
|
|
204
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
205
|
+
let existing = "";
|
|
206
|
+
try {
|
|
207
|
+
existing = fs.readFileSync(plan.filePath, "utf8");
|
|
208
|
+
} catch { }
|
|
209
|
+
if (existing === plan.fileContent) {
|
|
210
|
+
return { ok: true, changed: false, undo: plan.undo, detail: `already installed — ${plan.filePath} up to date (undo: ${plan.undo})` };
|
|
211
|
+
}
|
|
212
|
+
fs.writeFileSync(plan.filePath, plan.fileContent, "utf8");
|
|
213
|
+
try {
|
|
214
|
+
execSync("systemctl --user daemon-reload", { timeout: 10_000, stdio: "ignore" });
|
|
215
|
+
execSync(plan.command, { timeout: 10_000, stdio: "ignore" });
|
|
216
|
+
} catch { }
|
|
217
|
+
return { ok: true, changed: true, undo: plan.undo, detail: `installed ${plan.filePath} (undo: ${plan.undo})` };
|
|
218
|
+
} catch (e) {
|
|
219
|
+
return { ok: false, changed: false, undo: plan.undo, detail: `install failed: ${String(e)} (undo: ${plan.undo})` };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Remove the login-startup hook (idempotent). Returns the undo/redo text.
|
|
225
|
+
* Refuses when DAEMON_SPAWN=0, matching install.
|
|
226
|
+
*/
|
|
227
|
+
export function uninstallStartupHook(opts?: {
|
|
228
|
+
platform?: string;
|
|
229
|
+
homeDir?: string;
|
|
230
|
+
execPath?: string;
|
|
231
|
+
scriptPath?: string;
|
|
232
|
+
}): { ok: boolean; changed: boolean; undo: string; detail: string } {
|
|
233
|
+
if (process.env[DAEMON_SPAWN_ENV] === "0") {
|
|
234
|
+
return { ok: false, changed: false, undo: "", detail: "refused: daemon spawn is disabled (DAEMON_SPAWN=0)" };
|
|
235
|
+
}
|
|
236
|
+
const plan = getStartupPlan(opts);
|
|
237
|
+
try {
|
|
238
|
+
if (plan.platform === "win32") {
|
|
239
|
+
try {
|
|
240
|
+
execSync(plan.undo, { timeout: 10_000, stdio: "ignore", windowsHide: true });
|
|
241
|
+
return { ok: true, changed: true, undo: plan.command, detail: `removed logon task "${STARTUP_TASK_NAME}" (re-install: ${plan.command})` };
|
|
242
|
+
} catch {
|
|
243
|
+
return { ok: true, changed: false, undo: plan.command, detail: `already removed — no logon task "${STARTUP_TASK_NAME}" found` };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
let existed = false;
|
|
247
|
+
try {
|
|
248
|
+
fs.accessSync(plan.filePath, fs.constants.F_OK);
|
|
249
|
+
existed = true;
|
|
250
|
+
} catch { }
|
|
251
|
+
if (!existed) {
|
|
252
|
+
return { ok: true, changed: false, undo: plan.command, detail: `already removed — no unit file at ${plan.filePath}` };
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
execSync(`systemctl --user disable ${STARTUP_SERVICE_NAME}`, { timeout: 10_000, stdio: "ignore" });
|
|
256
|
+
} catch { }
|
|
257
|
+
fs.rmSync(plan.filePath, { force: true });
|
|
258
|
+
return { ok: true, changed: true, undo: plan.command, detail: `removed ${plan.filePath} (re-install: ${plan.command})` };
|
|
259
|
+
} catch (e) {
|
|
260
|
+
return { ok: false, changed: false, undo: plan.undo, detail: `uninstall failed: ${String(e)}` };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function spawnWithProgress(
|
|
265
|
+
cmd: string,
|
|
266
|
+
args: string[],
|
|
267
|
+
ctx: ExtensionContext,
|
|
268
|
+
timeoutMs = 120_000,
|
|
269
|
+
): Promise<number> {
|
|
270
|
+
const { promise, resolve } = Promise.withResolvers<number>();
|
|
271
|
+
try {
|
|
272
|
+
const child = spawn(cmd, args, {
|
|
273
|
+
shell: process.platform === "win32",
|
|
274
|
+
stdio: "pipe",
|
|
275
|
+
windowsHide: true,
|
|
276
|
+
});
|
|
277
|
+
const timer = setTimeout(() => {
|
|
278
|
+
try { child.kill(); } catch { }
|
|
279
|
+
ctx.ui.notify(`spawn ${cmd} timed out after ${timeoutMs}ms — killed`, "warning");
|
|
280
|
+
resolve(1);
|
|
281
|
+
}, timeoutMs);
|
|
282
|
+
try { timer.unref?.(); } catch { }
|
|
283
|
+
const done = (code: number | null): void => {
|
|
284
|
+
clearTimeout(timer);
|
|
285
|
+
resolve(code ?? 0);
|
|
286
|
+
};
|
|
287
|
+
child.stdout?.on("data", (d: Buffer) => {
|
|
288
|
+
const s = String(d).trim();
|
|
289
|
+
if (s) ctx.ui.notify(s, "info");
|
|
290
|
+
});
|
|
291
|
+
child.stderr?.on("data", (d: Buffer) => {
|
|
292
|
+
const s = String(d).trim();
|
|
293
|
+
if (s) ctx.ui.notify(s, "info");
|
|
294
|
+
});
|
|
295
|
+
child.on("error", (err: Error) => {
|
|
296
|
+
clearTimeout(timer);
|
|
297
|
+
ctx.ui.notify(`spawn ${cmd} failed: ${err.message}`, "warning");
|
|
298
|
+
resolve(1);
|
|
299
|
+
});
|
|
300
|
+
child.on("close", done);
|
|
301
|
+
} catch (e) {
|
|
302
|
+
ctx.ui.notify(`spawn ${cmd} failed: ${(e as Error).message}`, "warning");
|
|
303
|
+
resolve(1);
|
|
304
|
+
}
|
|
305
|
+
return promise;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Follow-mode log poller: tracks the count of matched lines already printed
|
|
310
|
+
* and notifies only lines beyond it, so repeated ticks never re-notify the
|
|
311
|
+
* same tail. One poller at a time — a re-run clears the previous interval.
|
|
312
|
+
* Stop with stopLogsFollow (session shutdown / non-follow commands).
|
|
313
|
+
*/
|
|
314
|
+
let logsFollowTimer: NodeJS.Timeout | null = null;
|
|
315
|
+
export function stopLogsFollow(): void {
|
|
316
|
+
if (logsFollowTimer) {
|
|
317
|
+
clearInterval(logsFollowTimer);
|
|
318
|
+
logsFollowTimer = null;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
function startLogsFollow(
|
|
322
|
+
ctx: ExtensionContext,
|
|
323
|
+
filterLevel: LogLevel | null,
|
|
324
|
+
filterReqId: string | null,
|
|
325
|
+
count: number,
|
|
326
|
+
filterText: string | null,
|
|
327
|
+
baselineMatched: number,
|
|
328
|
+
): void {
|
|
329
|
+
stopLogsFollow();
|
|
330
|
+
let lastPrinted = baselineMatched;
|
|
331
|
+
logsFollowTimer = setInterval(() => {
|
|
332
|
+
try {
|
|
333
|
+
const tail = readRecentLogs(filterLevel, filterReqId, count, filterText);
|
|
334
|
+
if (tail.totalMatched < lastPrinted) {
|
|
335
|
+
// Log rotated/rewritten below the baseline — re-baseline silently.
|
|
336
|
+
lastPrinted = tail.totalMatched;
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const newCount = tail.totalMatched - lastPrinted;
|
|
340
|
+
if (newCount <= 0) return;
|
|
341
|
+
const newLines = tail.lines.slice(
|
|
342
|
+
tail.lines.length - Math.min(newCount, tail.lines.length),
|
|
343
|
+
).slice(-50);
|
|
344
|
+
if (newLines.length > 0) {
|
|
345
|
+
ctx.ui.notify(newLines.join("\n"), "info");
|
|
346
|
+
}
|
|
347
|
+
lastPrinted = tail.totalMatched;
|
|
348
|
+
} catch { }
|
|
349
|
+
}, 1000);
|
|
350
|
+
// @ts-ignore allow unref to not block process exit in CLI
|
|
351
|
+
logsFollowTimer.unref?.();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Pick the login slot when the user gives none: "default" for the first
|
|
356
|
+
* login, then the smallest free "slot-2", "slot-3", … — never overwriting
|
|
357
|
+
* a saved login. Pure — takes the pool, returns a name.
|
|
358
|
+
*/
|
|
359
|
+
function nextClineSlot(pool: ClinePoolState): string {
|
|
360
|
+
if (!pool.accounts.some((a) => a.slot === "default")) return "default";
|
|
361
|
+
let n = 2;
|
|
362
|
+
while (pool.accounts.some((a) => a.slot === `slot-${n}`)) n += 1;
|
|
363
|
+
return `slot-${n}`;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Per-slot usage row as persisted under pool.usage. Read defensively: pools
|
|
368
|
+
* saved before usage tracking existed carry no such field, so every property
|
|
369
|
+
* is narrowed at use. Never carries identity — callers map slot to identity.
|
|
370
|
+
*/
|
|
371
|
+
interface ClineUsageRow {
|
|
372
|
+
served: number;
|
|
373
|
+
lastAt: number;
|
|
374
|
+
lastModel: string;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Usage map off the pool without depending on the tracking lane's types. */
|
|
378
|
+
function clineUsageRows(pool: ClinePoolState): Record<string, ClineUsageRow> {
|
|
379
|
+
const raw = (pool as unknown as { usage?: unknown }).usage;
|
|
380
|
+
if (!raw || typeof raw !== "object") return {};
|
|
381
|
+
const out: Record<string, ClineUsageRow> = {};
|
|
382
|
+
for (const [slot, entry] of Object.entries(raw as Record<string, unknown>)) {
|
|
383
|
+
if (!entry || typeof entry !== "object") continue;
|
|
384
|
+
const rec = entry as Record<string, unknown>;
|
|
385
|
+
if (typeof rec.served !== "number" || !(rec.served >= 0)) continue;
|
|
386
|
+
out[slot] = {
|
|
387
|
+
served: rec.served,
|
|
388
|
+
lastAt: typeof rec.lastAt === "number" ? rec.lastAt : 0,
|
|
389
|
+
lastModel: typeof rec.lastModel === "string" ? rec.lastModel : "",
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
return out;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Masked identity for NEW display text — the raw email never appears there. */
|
|
396
|
+
function maskClineEmail(email: string): string {
|
|
397
|
+
const at = email.indexOf("@");
|
|
398
|
+
if (at <= 0) return "***";
|
|
399
|
+
const domain = email.slice(at + 1);
|
|
400
|
+
if (!domain) return "***";
|
|
401
|
+
return `${email[0]}***@${domain}`;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Relative age for a usage timestamp, ISO fallback for old/future values. */
|
|
405
|
+
function clineUsageAge(lastAt: number, now = Date.now()): string {
|
|
406
|
+
if (!(lastAt > 0)) return "";
|
|
407
|
+
const diff = now - lastAt;
|
|
408
|
+
if (diff < 0) return new Date(lastAt).toISOString();
|
|
409
|
+
if (diff < 60_000) return "just now";
|
|
410
|
+
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
|
411
|
+
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
|
412
|
+
if (diff < 30 * 86_400_000) return `${Math.floor(diff / 86_400_000)}d ago`;
|
|
413
|
+
return new Date(lastAt).toISOString();
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Usage suffix for one account line; zero-state reads `served 0 · never used`. */
|
|
417
|
+
function formatClineUsageSuffix(slot: string, usage: Record<string, ClineUsageRow>, now = Date.now()): string {
|
|
418
|
+
const row = usage[slot];
|
|
419
|
+
if (!row || row.served <= 0) return " · served 0 · never used";
|
|
420
|
+
const age = clineUsageAge(row.lastAt, now);
|
|
421
|
+
const last = row.lastModel ? ` · last ${row.lastModel}${age ? ` ${age}` : ""}` : "";
|
|
422
|
+
return ` · served ${row.served}${last}`;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Compact Cline block for the status banner. Masked identity only — the raw
|
|
427
|
+
* email never appears here even though account lines still show it.
|
|
428
|
+
*/
|
|
429
|
+
function formatClineStatusSnippet(pool: ClinePoolState, now = Date.now()): string {
|
|
430
|
+
const n = pool.accounts.length;
|
|
431
|
+
if (!n) return "Cline: no logins";
|
|
432
|
+
const usage = clineUsageRows(pool);
|
|
433
|
+
let best: { slot: string; row: ClineUsageRow } | null = null;
|
|
434
|
+
for (const a of pool.accounts) {
|
|
435
|
+
const row = usage[a.slot];
|
|
436
|
+
if (!row || row.served <= 0) continue;
|
|
437
|
+
if (!best || row.lastAt > best.row.lastAt) best = { slot: a.slot, row };
|
|
438
|
+
}
|
|
439
|
+
if (!best) return `Cline: ${n} login(s) · never used`;
|
|
440
|
+
const account = pool.accounts.find((a) => a.slot === best.slot);
|
|
441
|
+
const who = account?.email ? maskClineEmail(account.email) : (account?.accountId || best.slot);
|
|
442
|
+
const model = best.row.lastModel ? ` (${best.row.lastModel})` : "";
|
|
443
|
+
return `Cline: ${n} login(s) · last used: [${best.slot}] ${who}${model}`;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** One notify-ready line per saved login, in pool order, with usage. */
|
|
447
|
+
function formatClineAccountLines(pool: ClinePoolState): string[] {
|
|
448
|
+
const usage = clineUsageRows(pool);
|
|
449
|
+
return pool.accounts.map((a, idx) => {
|
|
450
|
+
const star = a.slot === pool.activeSlot ? "*" : " ";
|
|
451
|
+
const who = a.email || a.accountId;
|
|
452
|
+
const whoPart = who ? ` ${who}` : "";
|
|
453
|
+
// Two slots on one account share one quota, so rotation between them buys
|
|
454
|
+
// nothing. Mark the later one against the earlier slot it duplicates.
|
|
455
|
+
const earlier = { accounts: pool.accounts.slice(0, idx) };
|
|
456
|
+
const dup = findClineAccountSlot(earlier, a);
|
|
457
|
+
const dupPart = dup ? ` — same account as [${dup}]` : "";
|
|
458
|
+
return `${star} [${idx + 1}] [${a.slot}]${whoPart} key ending ${redactedToken(a.token)}${dupPart}${formatClineUsageSuffix(a.slot, usage)}`;
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
/** User-safe one-line message for caught values (never echoes secrets). */
|
|
462
|
+
function clineErrorMessage(e: unknown): string {
|
|
463
|
+
if (e instanceof Error && e.message) return e.message;
|
|
464
|
+
if (typeof e === "string" && e) return e;
|
|
465
|
+
return "unknown error";
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Validate a user-supplied deploy project name before any network call.
|
|
470
|
+
* Mirrors the cleanup the deploy builders apply (lowercase, runs of
|
|
471
|
+
* non-[a-z0-9-] become one dash, edge dashes trimmed): a name with no usable
|
|
472
|
+
* characters would silently fall back to a generic worker name and likely
|
|
473
|
+
* collide, and an over-long Cloudflare/Deno name would silently truncate to
|
|
474
|
+
* something the user did not ask for. Pure: never touches disk or network.
|
|
475
|
+
*/
|
|
476
|
+
export function validateDeployProjectName(
|
|
477
|
+
raw: string,
|
|
478
|
+
platform: DeployPlatform,
|
|
479
|
+
): { ok: true; name: string } | { ok: false; reason: string } {
|
|
480
|
+
const clean = (raw || "")
|
|
481
|
+
.toLowerCase()
|
|
482
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
483
|
+
.replace(/-{2,}/g, "-")
|
|
484
|
+
.replace(/^-+|-+$/g, "");
|
|
485
|
+
if (!clean) {
|
|
486
|
+
return {
|
|
487
|
+
ok: false,
|
|
488
|
+
reason: `Project name '${(raw || "").trim()}' has no usable letters or numbers — use characters a-z, 0-9, '-'`,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
const max = platform === "cloudflare" ? 58 : platform === "deno" ? 32 : 0;
|
|
492
|
+
if (max > 0 && clean.length > max) {
|
|
493
|
+
return {
|
|
494
|
+
ok: false,
|
|
495
|
+
reason: `Project name '${clean}' is ${clean.length} chars — ${platform === "cloudflare" ? "Cloudflare" : "Deno Deploy"} allows max ${max}. Shorten it and retry`,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
return { ok: true, name: clean };
|
|
499
|
+
}
|
|
500
|
+
export function createCommandSpec(
|
|
501
|
+
_pi: ExtensionAPI,
|
|
502
|
+
onCatalogRefreshed?: (models: RegisteredModel[]) => void,
|
|
503
|
+
): Omit<RegisteredCommand, "name"> {
|
|
504
|
+
return {
|
|
505
|
+
description:
|
|
506
|
+
"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|opencode] [--chat] | export [path] [--include-secrets] | import <path> [--merge|--replace] [--dry-run] | logs [level] [n] | debug on|off | refresh | update | deploy vercel | deploy cloudflare | deploy deno | install-startup | uninstall-startup | cline login [--key] [slot] | cline accounts | cline logout [slot]",
|
|
507
|
+
getArgumentCompletions: (prefix: string) =>
|
|
508
|
+
[
|
|
509
|
+
"auto",
|
|
510
|
+
"on",
|
|
511
|
+
"off",
|
|
512
|
+
"hide",
|
|
513
|
+
"widget hide",
|
|
514
|
+
"widget show",
|
|
515
|
+
"status",
|
|
516
|
+
"add",
|
|
517
|
+
"list",
|
|
518
|
+
"use",
|
|
519
|
+
"label",
|
|
520
|
+
"rename",
|
|
521
|
+
"remove",
|
|
522
|
+
"export",
|
|
523
|
+
"import",
|
|
524
|
+
"test",
|
|
525
|
+
"test opencode",
|
|
526
|
+
"url",
|
|
527
|
+
"deploy",
|
|
528
|
+
"deploy vercel",
|
|
529
|
+
"deploy cloudflare",
|
|
530
|
+
"deploy deno",
|
|
531
|
+
"install-startup",
|
|
532
|
+
"uninstall-startup",
|
|
533
|
+
"cline",
|
|
534
|
+
"cline login",
|
|
535
|
+
"cline login --key",
|
|
536
|
+
"cline accounts",
|
|
537
|
+
"cline logout",
|
|
538
|
+
"refresh",
|
|
539
|
+
"models",
|
|
540
|
+
"logs",
|
|
541
|
+
"debug",
|
|
542
|
+
"trace",
|
|
543
|
+
]
|
|
544
|
+
.filter((s) => s.startsWith(prefix))
|
|
545
|
+
.map((s) => ({ value: s, label: s })),
|
|
546
|
+
handler: async (args: string, ctx: ExtensionContext) => {
|
|
547
|
+
const parts = String(args || "")
|
|
548
|
+
.trim()
|
|
549
|
+
.split(/\s+/);
|
|
550
|
+
const sub = parts[0] || "";
|
|
551
|
+
const rest = parts.slice(1).join(" ");
|
|
552
|
+
let relayState = getActiveRelayState();
|
|
553
|
+
|
|
554
|
+
const flash = () => {
|
|
555
|
+
ctx.ui.notify(formatRelayFlash(relayState), "info");
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
// UI refresh only — every disk write flows through CAS
|
|
559
|
+
// (withRelayState) so the operation is re-applied to the freshest
|
|
560
|
+
// on-disk state at write time instead of a stale snapshot.
|
|
561
|
+
const persist = () => {
|
|
562
|
+
setStatusUi(ctx.ui);
|
|
563
|
+
updateStatusBar(ctx.ui);
|
|
564
|
+
};
|
|
565
|
+
/** Re-apply an operation to the freshest on-disk state and persist it. */
|
|
566
|
+
const applyRelayState = (
|
|
567
|
+
updater: (s: RelayState) => RelayState,
|
|
568
|
+
): RelayState => {
|
|
569
|
+
relayState = withRelayState(updater);
|
|
570
|
+
return relayState;
|
|
571
|
+
};
|
|
572
|
+
const setRelay = (
|
|
573
|
+
enabled: boolean,
|
|
574
|
+
url: string,
|
|
575
|
+
addLabel?: string,
|
|
576
|
+
) => {
|
|
577
|
+
applyRelayState((s) => {
|
|
578
|
+
s.enabled = enabled;
|
|
579
|
+
const cleanUrl = (url || "").trim() || "";
|
|
580
|
+
s.url = cleanUrl;
|
|
581
|
+
if (cleanUrl) {
|
|
582
|
+
ensureRelay(s, cleanUrl, addLabel);
|
|
583
|
+
}
|
|
584
|
+
return s;
|
|
585
|
+
});
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
const addRelayInteractive = async () => {
|
|
589
|
+
const inputUrl = (
|
|
590
|
+
await ctx.ui.input(
|
|
591
|
+
"Custom relay URL (e.g. https://my-relay.workers.dev):",
|
|
592
|
+
"",
|
|
593
|
+
)
|
|
594
|
+
)?.trim();
|
|
595
|
+
if (!inputUrl) {
|
|
596
|
+
ctx.ui.notify("Cancelled — no URL provided", "warning");
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
const defaultLabel = shortRelayLabel(inputUrl, relayState.relays);
|
|
600
|
+
const inputLabel = (
|
|
601
|
+
await ctx.ui.input(
|
|
602
|
+
"Short name / alias for TUI (e.g. cf-sg, vercel-1):",
|
|
603
|
+
defaultLabel,
|
|
604
|
+
)
|
|
605
|
+
)?.trim() || defaultLabel;
|
|
606
|
+
|
|
607
|
+
applyRelayState((s) => {
|
|
608
|
+
const added = ensureRelay(s, inputUrl, inputLabel);
|
|
609
|
+
s.enabled = true;
|
|
610
|
+
s.url = added.url;
|
|
611
|
+
return s;
|
|
612
|
+
});
|
|
613
|
+
persist();
|
|
614
|
+
flash();
|
|
615
|
+
ctx.ui.notify(
|
|
616
|
+
`✓ Added & activated relay [${shortRelayLabel(relayState.url, relayState.relays)}]: ${relayState.url}`,
|
|
617
|
+
"info",
|
|
618
|
+
);
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
const editRelayLabelMenu = async () => {
|
|
622
|
+
if (!relayState.relays.length) {
|
|
623
|
+
ctx.ui.notify("No saved relays to rename", "warning");
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
const fmt = (r: KnownRelay, idx: number) =>
|
|
627
|
+
`[${idx + 1}] ${r.label ? `[${r.label}] ` : ""}${r.url}`;
|
|
628
|
+
const opts = relayState.relays.map(fmt);
|
|
629
|
+
const choice = await ctx.ui.select("Select relay to rename / set short name", opts);
|
|
630
|
+
if (!choice) return;
|
|
631
|
+
const match = relayState.relays.find((r, idx) => fmt(r, idx) === choice);
|
|
632
|
+
if (!match) return;
|
|
633
|
+
|
|
634
|
+
const curLabel = match.label || shortRelayLabel(match.url, relayState.relays);
|
|
635
|
+
const newLabel = (
|
|
636
|
+
await ctx.ui.input(
|
|
637
|
+
`New short name for ${match.url}:`,
|
|
638
|
+
curLabel,
|
|
639
|
+
)
|
|
640
|
+
)?.trim();
|
|
641
|
+
if (!newLabel) {
|
|
642
|
+
ctx.ui.notify("Cancelled — short name not changed", "warning");
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
applyRelayState((s) => {
|
|
646
|
+
setRelayLabel(s, match.url, newLabel);
|
|
647
|
+
return s;
|
|
648
|
+
});
|
|
649
|
+
persist();
|
|
650
|
+
flash();
|
|
651
|
+
ctx.ui.notify(
|
|
652
|
+
`✓ Relay short name updated to [${newLabel}] for ${match.url}`,
|
|
653
|
+
"info",
|
|
654
|
+
);
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
const DEPLOY_OPTIONS: Record<string, DeployPlatform> = {
|
|
658
|
+
"Vercel (1M req/mo — recommended)": "vercel",
|
|
659
|
+
"Cloudflare (100k req/day)": "cloudflare",
|
|
660
|
+
"Deno Deploy (100k req/day)": "deno",
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
const parseDeployPlatform = (raw?: string): DeployPlatform | null => {
|
|
664
|
+
const v = (raw || "").trim().toLowerCase();
|
|
665
|
+
if (!v || v === "vercel") return "vercel";
|
|
666
|
+
if (v === "cloudflare" || v === "cf") return "cloudflare";
|
|
667
|
+
if (v === "deno") return "deno";
|
|
668
|
+
return null;
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
const doDeploy = async (platform: DeployPlatform = "vercel") => {
|
|
672
|
+
const defaultName = `relay-${Date.now().toString(36)}`;
|
|
673
|
+
const label =
|
|
674
|
+
platform === "cloudflare"
|
|
675
|
+
? "Cloudflare"
|
|
676
|
+
: platform === "deno"
|
|
677
|
+
? "Deno Deploy"
|
|
678
|
+
: "Vercel";
|
|
679
|
+
const tokenHint =
|
|
680
|
+
platform === "cloudflare"
|
|
681
|
+
? "Cloudflare relay — token: dash.cloudflare.com → API Tokens (Workers Scripts: Edit)"
|
|
682
|
+
: platform === "deno"
|
|
683
|
+
? "Deno Deploy relay — token: dash.deno.com → Access Tokens"
|
|
684
|
+
: "Vercel relay — token: vercel.com/account/tokens";
|
|
685
|
+
ctx.ui.notify(tokenHint, "info");
|
|
686
|
+
const token = (
|
|
687
|
+
await ctx.ui.input(`${label} API token:`, "")
|
|
688
|
+
)?.trim();
|
|
689
|
+
if (!token) {
|
|
690
|
+
ctx.ui.notify("Deploy cancelled: no token", "warning");
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
const entered =
|
|
694
|
+
(
|
|
695
|
+
await ctx.ui.input(
|
|
696
|
+
"Project name (empty = auto):",
|
|
697
|
+
defaultName,
|
|
698
|
+
)
|
|
699
|
+
)?.trim() || defaultName;
|
|
700
|
+
const nameCheck = validateDeployProjectName(entered, platform);
|
|
701
|
+
if (!nameCheck.ok) {
|
|
702
|
+
ctx.ui.notify(`Deploy cancelled: ${nameCheck.reason}`, "warning");
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
const name = entered;
|
|
706
|
+
const cleanName = nameCheck.name;
|
|
707
|
+
// Never display a short token in full: its "last 4" would be the secret.
|
|
708
|
+
const tokenTail = token.length > 8 ? token.slice(-4) : "***";
|
|
709
|
+
|
|
710
|
+
if (typeof ctx.ui.confirm === "function") {
|
|
711
|
+
const ok = await ctx.ui.confirm(
|
|
712
|
+
"Deploy relay",
|
|
713
|
+
cleanName === name
|
|
714
|
+
? `Deploy ${label} relay '${name}' using token ending ${tokenTail}?`
|
|
715
|
+
: `Deploy ${label} relay '${name}' (as '${cleanName}') using token ending ${tokenTail}?`,
|
|
716
|
+
);
|
|
717
|
+
if (!ok) {
|
|
718
|
+
ctx.ui.notify("Deploy cancelled", "warning");
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
ctx.ui.setStatus("freeflow", `deploying ${platform} relay…`);
|
|
724
|
+
try {
|
|
725
|
+
const deployer =
|
|
726
|
+
platform === "cloudflare"
|
|
727
|
+
? deployCloudflareWorker
|
|
728
|
+
: platform === "deno"
|
|
729
|
+
? deployDenoRelay
|
|
730
|
+
: deployVercelRelay;
|
|
731
|
+
const { url, auth } = await deployer(token, name, (m) =>
|
|
732
|
+
ctx.ui.notify(m, "info"),
|
|
733
|
+
);
|
|
734
|
+
const finalUrl = url.trim().replace(/\/+$/, "");
|
|
735
|
+
relayState = withRelayState((s) => {
|
|
736
|
+
const prevLabel = s.relays.find((r) => r.url === finalUrl)?.label;
|
|
737
|
+
const r = ensureRelay(s, finalUrl, `deployed ${cleanName}`);
|
|
738
|
+
if (auth) r.auth = auth;
|
|
739
|
+
else delete r.auth;
|
|
740
|
+
// A re-deploy at an existing address refreshes the secret but keeps a
|
|
741
|
+
// user-chosen short name; only stock `deployed …` labels roll forward.
|
|
742
|
+
if (prevLabel && !prevLabel.startsWith("deployed ")) r.label = prevLabel;
|
|
743
|
+
s.enabled = true;
|
|
744
|
+
s.url = finalUrl;
|
|
745
|
+
return s;
|
|
746
|
+
});
|
|
747
|
+
persist();
|
|
748
|
+
// saveRelayState swallows disk errors, so confirm the pool entry landed:
|
|
749
|
+
// otherwise a live deployment would silently vanish on the next restart.
|
|
750
|
+
let poolSaved = false;
|
|
751
|
+
try {
|
|
752
|
+
poolSaved = loadRelayState().relays.some((r) => r.url === finalUrl);
|
|
753
|
+
} catch {
|
|
754
|
+
poolSaved = false;
|
|
755
|
+
}
|
|
756
|
+
let probeNote = "";
|
|
757
|
+
try {
|
|
758
|
+
const probe = await probeRelay(finalUrl, auth);
|
|
759
|
+
probeNote = probe.ok
|
|
760
|
+
? ` ✓ reachable (HTTP ${probe.status}, ${probe.latencyMs}ms)`
|
|
761
|
+
: ` ⚠ deployed but unreachable (${probe.error || `HTTP ${probe.status}`}) — run /freeflow test ${finalUrl} to retry, or /freeflow remove ${finalUrl} to drop it`;
|
|
762
|
+
} catch {
|
|
763
|
+
// probeRelay never throws, but keep the notify safe regardless
|
|
764
|
+
}
|
|
765
|
+
if (!poolSaved) {
|
|
766
|
+
const secretNote = auth
|
|
767
|
+
? " Note: this relay uses a shared secret, so prefer redeploying if the re-added copy fails its probe."
|
|
768
|
+
: "";
|
|
769
|
+
ctx.ui.notify(`⚠ Relay is live at ${finalUrl} but NOT saved to the pool (disk write failed)${probeNote} — restore it with: /freeflow add ${finalUrl}.${secretNote}`, "error");
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
ctx.ui.notify(`✓ Deployed & active: ${finalUrl}${probeNote}`, "info");
|
|
773
|
+
} catch (e) {
|
|
774
|
+
updateStatusBar(ctx.ui);
|
|
775
|
+
// Deploy errors echo upstream detail; never let the platform API token
|
|
776
|
+
// leak through a message that repeats what the server (or network) said.
|
|
777
|
+
const rawMsg = (e as Error).message;
|
|
778
|
+
const safeMsg = token.length >= 8 ? rawMsg.split(token).join("[redacted]") : rawMsg;
|
|
779
|
+
ctx.ui.notify(
|
|
780
|
+
`Deploy failed: ${safeMsg}`,
|
|
781
|
+
"error",
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
};
|
|
785
|
+
|
|
786
|
+
const switchRelay = async () => {
|
|
787
|
+
if (!relayState.relays.length) {
|
|
788
|
+
ctx.ui.notify("No saved relays yet", "warning");
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
const fmt = (r: KnownRelay, idx: number) => formatRelayPickerItem(r, idx, relayState.url);
|
|
792
|
+
const opts = relayState.relays.map(fmt);
|
|
793
|
+
const choice = await ctx.ui.select("Switch active relay", opts);
|
|
794
|
+
if (!choice) return;
|
|
795
|
+
const match = relayState.relays.find((r, idx) => fmt(r, idx) === choice);
|
|
796
|
+
if (!match) return;
|
|
797
|
+
applyRelayState((s) => {
|
|
798
|
+
const fresh = s.relays.find((r) => r.url === match.url);
|
|
799
|
+
if (!fresh) return s;
|
|
800
|
+
s.enabled = true;
|
|
801
|
+
s.url = fresh.url;
|
|
802
|
+
return s;
|
|
803
|
+
});
|
|
804
|
+
persist();
|
|
805
|
+
flash();
|
|
806
|
+
};
|
|
807
|
+
|
|
808
|
+
const showList = () => {
|
|
809
|
+
if (!relayState.relays.length) {
|
|
810
|
+
ctx.ui.notify("No saved relays (direct upstream mode)", "info");
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
const lines = relayState.relays.map((r, idx) => {
|
|
814
|
+
const star = r.url === relayState.url ? "★" : " ";
|
|
815
|
+
const shortName = r.label ? `[${r.label}]` : `[${shortRelayLabel(r.url, relayState.relays)}]`;
|
|
816
|
+
const paddedName = shortName.padEnd(16, " ");
|
|
817
|
+
const health = getRelayHealth(r.url);
|
|
818
|
+
const isCooling = health && Date.now() < health.cooldownUntil;
|
|
819
|
+
const remainingSec = isCooling ? Math.ceil((health.cooldownUntil - Date.now()) / 1000) : 0;
|
|
820
|
+
const healthBadge = isCooling
|
|
821
|
+
? ` ⚠️ [cooling ${remainingSec}s: ${health.lastStatus ? `HTTP ${health.lastStatus}` : "error"}]`
|
|
822
|
+
: health?.lastLatencyMs != null && Number.isFinite(health.lastLatencyMs)
|
|
823
|
+
? ` ✓ [${health.lastLatencyMs}ms]`
|
|
824
|
+
: " ✓";
|
|
825
|
+
const counterText = health && (health.successCount != null || health.failureCount != null)
|
|
826
|
+
? ` ${health.successCount ?? 0} ok / ${health.failureCount ?? 0} fail`
|
|
827
|
+
: "";
|
|
828
|
+
return `${star} [${idx + 1}] ${paddedName} → ${r.url}${healthBadge}${counterText}`;
|
|
829
|
+
});
|
|
830
|
+
const activeLabel = shortRelayLabel(relayState.url, relayState.relays);
|
|
831
|
+
const activeIdx = Math.max(
|
|
832
|
+
1,
|
|
833
|
+
relayState.relays.findIndex((r) => r.url === relayState.url) + 1,
|
|
834
|
+
);
|
|
835
|
+
const modeStr = (relayState.mode || "auto").toUpperCase();
|
|
836
|
+
const header = `Saved Relays (${relayState.relays.length}):\n${lines.join("\n")}\n\nActive: [${activeIdx}] ${activeLabel} | Mode: ${modeStr} (${relayState.enabled ? "ON" : "OFF"})`;
|
|
837
|
+
ctx.ui.notify(header, "info");
|
|
838
|
+
};
|
|
839
|
+
|
|
840
|
+
const removeRelayMenu = async () => {
|
|
841
|
+
const removable = relayState.relays.filter(
|
|
842
|
+
(r) => r.url !== relayState.url,
|
|
843
|
+
);
|
|
844
|
+
if (!removable.length) {
|
|
845
|
+
ctx.ui.notify(
|
|
846
|
+
"Nothing to remove — the active relay cannot be removed (switch first)",
|
|
847
|
+
"warning",
|
|
848
|
+
);
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
const fmt = (r: KnownRelay, idx: number) => formatRelayPickerItem(r, idx);
|
|
852
|
+
const choice = await ctx.ui.select(
|
|
853
|
+
"Remove relay",
|
|
854
|
+
removable.map(fmt),
|
|
855
|
+
);
|
|
856
|
+
if (!choice) return;
|
|
857
|
+
const match = removable.find((r, idx) => fmt(r, idx) === choice);
|
|
858
|
+
if (!match) return;
|
|
859
|
+
applyRelayState((s) => {
|
|
860
|
+
removeRelay(s, match.url);
|
|
861
|
+
return s;
|
|
862
|
+
});
|
|
863
|
+
persist();
|
|
864
|
+
ctx.ui.notify(`Removed: [${shortRelayLabel(match.url, relayState.relays)}] ${match.url}`, "info");
|
|
865
|
+
};
|
|
866
|
+
const classifyImportFailure = (filePath: string, raw: string): string => {
|
|
867
|
+
let parsed: unknown = null;
|
|
868
|
+
try {
|
|
869
|
+
parsed = JSON.parse(raw);
|
|
870
|
+
} catch (e) {
|
|
871
|
+
return `Relay file ${filePath} is not valid JSON (${(e as Error).message}). Nothing changed.`;
|
|
872
|
+
}
|
|
873
|
+
const version = parsed !== null && typeof parsed === "object" && "version" in parsed
|
|
874
|
+
? parsed.version
|
|
875
|
+
: undefined;
|
|
876
|
+
if (typeof version !== "undefined" && version !== 1) {
|
|
877
|
+
return `Relay file ${filePath} uses version ${String(version)}, this version understands version 1. Update then try again. Nothing changed.`;
|
|
878
|
+
}
|
|
879
|
+
return `Relay file ${filePath} is not a relay export (missing relay list). Nothing changed.`;
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
const runExport = async (pathArg?: string, includeSecrets = false) => {
|
|
883
|
+
let state: RelayState;
|
|
884
|
+
try {
|
|
885
|
+
state = getActiveRelayState();
|
|
886
|
+
} catch {
|
|
887
|
+
state = loadRelayState();
|
|
888
|
+
}
|
|
889
|
+
const artifact = buildRelayExport(state, { includeSecrets });
|
|
890
|
+
const count = artifact.state.relays.length;
|
|
891
|
+
let target = pathArg ? path.resolve(pathArg) : path.resolve(EXPORT_DEFAULT_FILENAME);
|
|
892
|
+
if (!pathArg) {
|
|
893
|
+
const choice = await ctx.ui.select("Share relays (export file)", [
|
|
894
|
+
`Export to ${target}`,
|
|
895
|
+
"Choose another path…",
|
|
896
|
+
"Cancel",
|
|
897
|
+
]);
|
|
898
|
+
if (!choice || choice === "Cancel") return;
|
|
899
|
+
if (choice === "Choose another path…") {
|
|
900
|
+
const picked = (await ctx.ui.input("Export path:", target))?.trim();
|
|
901
|
+
if (!picked) return;
|
|
902
|
+
target = path.resolve(picked);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
if (fs.existsSync(target)) {
|
|
906
|
+
if (typeof ctx.ui.confirm === "function") {
|
|
907
|
+
const ok = await ctx.ui.confirm(
|
|
908
|
+
"Export relays",
|
|
909
|
+
`File already exists: ${target}\nOverwrite it?`,
|
|
910
|
+
);
|
|
911
|
+
if (!ok) {
|
|
912
|
+
ctx.ui.notify("Export cancelled — existing file left alone", "warning");
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
try {
|
|
918
|
+
fs.writeFileSync(target, JSON.stringify(artifact, null, 2));
|
|
919
|
+
} catch (e) {
|
|
920
|
+
ctx.ui.notify(`Could not write relay file ${target}: ${(e as Error).message}.`, "error");
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
updateStatusBar(ctx.ui);
|
|
924
|
+
flash();
|
|
925
|
+
ctx.ui.notify(
|
|
926
|
+
includeSecrets
|
|
927
|
+
? `Exported ${count} relay(s) to ${target} WITH passwords included — keep this file private.`
|
|
928
|
+
: `Exported ${count} relay(s) to ${target} (passwords stripped — file is safe to share).`,
|
|
929
|
+
"info",
|
|
930
|
+
);
|
|
931
|
+
};
|
|
932
|
+
|
|
933
|
+
const runImport = async (
|
|
934
|
+
pathArg: string | undefined,
|
|
935
|
+
mode: "merge" | "replace",
|
|
936
|
+
dryRun: boolean,
|
|
937
|
+
) => {
|
|
938
|
+
const filePath = (pathArg || "").trim();
|
|
939
|
+
if (!filePath) {
|
|
940
|
+
ctx.ui.notify("Usage: /freeflow import <path> [--merge|--replace] [--dry-run] — path is required.", "warning");
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
let byteSize = 0;
|
|
944
|
+
try {
|
|
945
|
+
const st = fs.statSync(filePath);
|
|
946
|
+
if (!st.isFile()) {
|
|
947
|
+
ctx.ui.notify(`Could not read relay file ${filePath}: not a regular file.`, "error");
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
byteSize = st.size;
|
|
951
|
+
} catch (e) {
|
|
952
|
+
const code = e !== null && typeof e === "object" && "code" in e ? e.code : undefined;
|
|
953
|
+
if (code === "ENOENT") {
|
|
954
|
+
ctx.ui.notify(`Relay file not found: ${filePath} — check the path and try again.`, "warning");
|
|
955
|
+
} else {
|
|
956
|
+
ctx.ui.notify(`Could not read relay file ${filePath}: ${(e as Error).message}.`, "error");
|
|
957
|
+
}
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
if (byteSize > EXPORT_MAX_BYTES) {
|
|
961
|
+
ctx.ui.notify(`Relay file ${filePath} is too large (${byteSize} bytes; limit ${EXPORT_MAX_BYTES} bytes). Nothing changed.`, "warning");
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
let raw: string;
|
|
965
|
+
try {
|
|
966
|
+
raw = fs.readFileSync(filePath, "utf8");
|
|
967
|
+
} catch (e) {
|
|
968
|
+
ctx.ui.notify(`Could not read relay file ${filePath}: ${(e as Error).message}.`, "error");
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
let fragment: Pick<RelayState, "mode" | "enabled" | "url" | "relays" | "hideWidget">;
|
|
972
|
+
let parseSkipped: Array<{ index: number; url: string; reason: string }> = [];
|
|
973
|
+
try {
|
|
974
|
+
const parsed = parseRelayImport(raw);
|
|
975
|
+
fragment = parsed.fragment;
|
|
976
|
+
parseSkipped = parsed.skipped;
|
|
977
|
+
} catch {
|
|
978
|
+
ctx.ui.notify(classifyImportFailure(filePath, raw), "warning");
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
if (!fragment || !Array.isArray(fragment.relays) || fragment.relays.length === 0) {
|
|
982
|
+
if (parseSkipped.length > 0) {
|
|
983
|
+
ctx.ui.notify(`Relay file ${filePath} holds no usable relay addresses (${parseSkipped.length} skipped: ${parseSkipped[0].reason}). Nothing changed.`, "warning");
|
|
984
|
+
} else {
|
|
985
|
+
ctx.ui.notify(`Relay file ${filePath} holds no usable relay addresses (0 skipped). Nothing changed.`, "warning");
|
|
986
|
+
}
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
const plan = planRelayImport(loadRelayState(), fragment, { mode });
|
|
990
|
+
const skipped = [...parseSkipped];
|
|
991
|
+
for (const s of plan.skipped) {
|
|
992
|
+
if (!skipped.some((x) => x.index === s.index && x.url === s.url)) skipped.push(s);
|
|
993
|
+
}
|
|
994
|
+
const skippedCount = skipped.length;
|
|
995
|
+
const reasons = skippedCount > 0
|
|
996
|
+
? skipped.map((s) => s.reason).filter((r, i, arr) => arr.indexOf(r) === i).join("; ")
|
|
997
|
+
: "none";
|
|
998
|
+
const plannedActive = plan.next.url
|
|
999
|
+
? `[${shortRelayLabel(plan.next.url, plan.next.relays)}]`
|
|
1000
|
+
: "none";
|
|
1001
|
+
if (dryRun) {
|
|
1002
|
+
if (mode === "replace") {
|
|
1003
|
+
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");
|
|
1004
|
+
} else {
|
|
1005
|
+
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");
|
|
1006
|
+
}
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
if (mode === "replace") {
|
|
1010
|
+
ctx.ui.notify(`Replace from ${filePath}: will install ${plan.added}, remove ${plan.removed}, skip ${skippedCount} (${reasons}). Active relay will be ${plannedActive}.`, "info");
|
|
1011
|
+
if (typeof ctx.ui.confirm === "function") {
|
|
1012
|
+
const ok = await ctx.ui.confirm(
|
|
1013
|
+
"Replace all relays?",
|
|
1014
|
+
`Install ${plan.added} relay(s) from ${filePath} and remove ${plan.removed} existing? This cannot be undone.`,
|
|
1015
|
+
);
|
|
1016
|
+
if (!ok) {
|
|
1017
|
+
ctx.ui.notify("Replace cancelled — relay list unchanged.", "warning");
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
applyRelayState((s) => planRelayImport(s, fragment, { mode }).next);
|
|
1023
|
+
persist();
|
|
1024
|
+
flash();
|
|
1025
|
+
if (mode === "replace") {
|
|
1026
|
+
const activeName = relayState.url
|
|
1027
|
+
? `[${shortRelayLabel(relayState.url, relayState.relays)}]`
|
|
1028
|
+
: "none";
|
|
1029
|
+
ctx.ui.notify(`Replaced relay list from ${filePath}: installed ${plan.added}, skipped ${skippedCount}. Active relay is now ${activeName}.`, "info");
|
|
1030
|
+
} else {
|
|
1031
|
+
ctx.ui.notify(`Merge from ${filePath}: added ${plan.added}, updated short name on ${plan.updated}, skipped ${skippedCount}. Relay list now holds ${relayState.relays.length}.`, "info");
|
|
1032
|
+
}
|
|
1033
|
+
const withoutPassword = relayState.relays.filter((r) => !r.auth).length;
|
|
1034
|
+
if (withoutPassword > 0) {
|
|
1035
|
+
ctx.ui.notify(`${withoutPassword} relay(s) have no password — reconnect each one before use.`, "warning");
|
|
1036
|
+
}
|
|
1037
|
+
for (const s of skipped) {
|
|
1038
|
+
const shown = s.url || `#${s.index + 1}`;
|
|
1039
|
+
const reason = s.reason.endsWith(".") ? s.reason : `${s.reason}.`;
|
|
1040
|
+
ctx.ui.notify(`Skipped ${shown}: ${reason}`, "warning");
|
|
1041
|
+
}
|
|
1042
|
+
};
|
|
1043
|
+
|
|
1044
|
+
if (sub === "auto") {
|
|
1045
|
+
applyRelayState((s) => {
|
|
1046
|
+
s.mode = "auto";
|
|
1047
|
+
s.enabled = true;
|
|
1048
|
+
s.url = s.url || "";
|
|
1049
|
+
if (s.url) {
|
|
1050
|
+
ensureRelay(s, s.url);
|
|
1051
|
+
}
|
|
1052
|
+
return s;
|
|
1053
|
+
});
|
|
1054
|
+
persist();
|
|
1055
|
+
flash();
|
|
1056
|
+
} else if (sub === "on") {
|
|
1057
|
+
applyRelayState((s) => {
|
|
1058
|
+
s.mode = "on";
|
|
1059
|
+
s.enabled = true;
|
|
1060
|
+
s.url = s.url || "";
|
|
1061
|
+
if (s.url) {
|
|
1062
|
+
ensureRelay(s, s.url);
|
|
1063
|
+
}
|
|
1064
|
+
return s;
|
|
1065
|
+
});
|
|
1066
|
+
persist();
|
|
1067
|
+
flash();
|
|
1068
|
+
} else if (sub === "off") {
|
|
1069
|
+
applyRelayState((s) => {
|
|
1070
|
+
s.mode = "off";
|
|
1071
|
+
s.enabled = false;
|
|
1072
|
+
return s;
|
|
1073
|
+
});
|
|
1074
|
+
persist();
|
|
1075
|
+
flash();
|
|
1076
|
+
} else if (sub === "hide" || (sub === "widget" && rest === "hide")) {
|
|
1077
|
+
applyRelayState((s) => ({ ...s, hideWidget: true }));
|
|
1078
|
+
persist();
|
|
1079
|
+
updateStatusBar(ctx.ui);
|
|
1080
|
+
ctx.ui.notify("Widget hidden — use /freeflow show or /freeflow widget show to restore", "info");
|
|
1081
|
+
} else if (sub === "show" || (sub === "widget" && rest === "show")) {
|
|
1082
|
+
applyRelayState((s) => ({ ...s, hideWidget: false }));
|
|
1083
|
+
persist();
|
|
1084
|
+
flash();
|
|
1085
|
+
ctx.ui.notify("Widget shown", "info");
|
|
1086
|
+
} else if (sub === "status") {
|
|
1087
|
+
flash();
|
|
1088
|
+
try {
|
|
1089
|
+
const local = getLocalVersion();
|
|
1090
|
+
let latest: string | null = null;
|
|
1091
|
+
const cached = getCachedUpdate();
|
|
1092
|
+
if (cached && typeof cached.latest === "string") {
|
|
1093
|
+
latest = cached.latest;
|
|
1094
|
+
}
|
|
1095
|
+
if (latest && compareVersions(latest, local) > 0) {
|
|
1096
|
+
ctx.ui.notify(
|
|
1097
|
+
`Update available: ${local} -> ${latest} - run /freeflow update`,
|
|
1098
|
+
"info",
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1101
|
+
} catch {
|
|
1102
|
+
// swallow — status banner is best-effort
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
const modeLine =
|
|
1106
|
+
relayState.mode === "off"
|
|
1107
|
+
? "Mode: off (always direct)"
|
|
1108
|
+
: relayState.mode === "on"
|
|
1109
|
+
? "Mode: on (always relay)"
|
|
1110
|
+
: "Mode: auto (enabled on session, auto-rolls on 429/5xx)";
|
|
1111
|
+
const poolLine = `${relayState.relays.length} relay(s)${relayState.relays.length > 0
|
|
1112
|
+
? ` | active: ${shortRelayLabel(relayState.url, relayState.relays)}`
|
|
1113
|
+
: ""
|
|
1114
|
+
}`;
|
|
1115
|
+
const stateFileLine = `State file: ${RELAY_STATE_FILE}`;
|
|
1116
|
+
const upstreamLine = (["zen", "kilo"] as const)
|
|
1117
|
+
.map((u) => {
|
|
1118
|
+
const h = getUpstreamHealth(u);
|
|
1119
|
+
return h.gated
|
|
1120
|
+
? `${u} gated (${h.consecutiveFreeTier403} fails) — new sessions degraded`
|
|
1121
|
+
: `${u} open`;
|
|
1122
|
+
})
|
|
1123
|
+
.join(" | ");
|
|
1124
|
+
const clineLine = formatClineStatusSnippet(loadPool());
|
|
1125
|
+
ctx.ui.notify(`${modeLine} | ${poolLine}\n${stateFileLine}\nUpstream: ${upstreamLine}\n${clineLine}`, "info");
|
|
1126
|
+
} else if (sub === "kill" || sub === "stop" || sub === "shutdown") {
|
|
1127
|
+
const port = getClientPort() || PORT;
|
|
1128
|
+
try {
|
|
1129
|
+
const res = await fetch(`http://${HOST}:${port}/_shutdown`, {
|
|
1130
|
+
method: "POST",
|
|
1131
|
+
signal: AbortSignal.timeout(1500),
|
|
1132
|
+
});
|
|
1133
|
+
if (res.ok) {
|
|
1134
|
+
ctx.ui.notify("Proxy daemon stopped — next freeflow use restarts it", "info");
|
|
1135
|
+
} else {
|
|
1136
|
+
ctx.ui.notify(`Daemon kill got HTTP ${res.status}`, "warning");
|
|
1137
|
+
}
|
|
1138
|
+
} catch {
|
|
1139
|
+
ctx.ui.notify("Daemon not running or not reachable", "warning");
|
|
1140
|
+
}
|
|
1141
|
+
} else if (sub === "update") {
|
|
1142
|
+
if (isLinkedInstall()) {
|
|
1143
|
+
ctx.ui.notify(
|
|
1144
|
+
"Linked install detected — restart your host app to pick up changes (no update needed).",
|
|
1145
|
+
"info",
|
|
1146
|
+
);
|
|
1147
|
+
} else {
|
|
1148
|
+
try {
|
|
1149
|
+
ctx.ui.notify("Checking for updates…", "info");
|
|
1150
|
+
let latest: string | null = null;
|
|
1151
|
+
try {
|
|
1152
|
+
latest = await fetchLatestVersion();
|
|
1153
|
+
} catch {
|
|
1154
|
+
latest = null;
|
|
1155
|
+
}
|
|
1156
|
+
if (!latest) {
|
|
1157
|
+
const cached = getCachedUpdate();
|
|
1158
|
+
if (cached && typeof cached.latest === "string") {
|
|
1159
|
+
latest = cached.latest;
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
if (!latest) {
|
|
1163
|
+
ctx.ui.notify("Could not check latest version (offline?)", "warning");
|
|
1164
|
+
} else {
|
|
1165
|
+
const local = getLocalVersion();
|
|
1166
|
+
const cmp = compareVersions(latest, local);
|
|
1167
|
+
if (cmp <= 0) {
|
|
1168
|
+
ctx.ui.notify(`Already on latest (v${local})`, "info");
|
|
1169
|
+
} else {
|
|
1170
|
+
ctx.ui.notify(`Update available: v${local} → v${latest} — updating…`, "info");
|
|
1171
|
+
// Host plugin managers first (neither host has an `update` action for
|
|
1172
|
+
// registry plugins: omp updates by reinstall, pi updates the named
|
|
1173
|
+
// package). Global npm/bun is the last resort, not the default.
|
|
1174
|
+
let code = 1;
|
|
1175
|
+
let manual = "npm i -g pi-freeflow@latest";
|
|
1176
|
+
let lastStep = "";
|
|
1177
|
+
for (const step of selectHostUpdateSteps({
|
|
1178
|
+
ompAvailable: isCommandAvailable("omp"),
|
|
1179
|
+
piAvailable: isCommandAvailable("pi"),
|
|
1180
|
+
})) {
|
|
1181
|
+
if (lastStep !== "") ctx.ui.notify(`${lastStep} exited ${code} — trying ${step.manual}…`, "info");
|
|
1182
|
+
code = await spawnWithProgress(step.cmd, step.args, ctx);
|
|
1183
|
+
manual = step.manual;
|
|
1184
|
+
lastStep = step.manual;
|
|
1185
|
+
if (code === 0) break;
|
|
1186
|
+
}
|
|
1187
|
+
if (code !== 0) {
|
|
1188
|
+
const plan = selectGlobalUpdatePlan({
|
|
1189
|
+
npmAvailable: isCommandAvailable("npm"),
|
|
1190
|
+
bunAvailable: isCommandAvailable("bun"),
|
|
1191
|
+
});
|
|
1192
|
+
if (plan !== null) {
|
|
1193
|
+
ctx.ui.notify(
|
|
1194
|
+
`${lastStep === "" ? "no host plugin manager found" : `${lastStep} exited ${code}`} — trying ${plan.manual}…`,
|
|
1195
|
+
"info",
|
|
1196
|
+
);
|
|
1197
|
+
manual = plan.manual;
|
|
1198
|
+
code = await spawnWithProgress(plan.cmd, plan.args, ctx);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
if (code === 0) {
|
|
1202
|
+
ctx.ui.notify(`Updated to ${latest} — restart your host app`, "info");
|
|
1203
|
+
} else {
|
|
1204
|
+
ctx.ui.notify(
|
|
1205
|
+
`Update failed (exit ${code}) — try manually: ${manual}`,
|
|
1206
|
+
"warning",
|
|
1207
|
+
);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
} catch (e) {
|
|
1212
|
+
ctx.ui.notify(`Update failed: ${(e as Error).message} — try manually: npm i -g pi-freeflow@latest`, "warning");
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
} else if (sub === "list") {
|
|
1216
|
+
showList();
|
|
1217
|
+
} else if (sub === "add") {
|
|
1218
|
+
if (!rest.trim()) {
|
|
1219
|
+
await addRelayInteractive();
|
|
1220
|
+
} else {
|
|
1221
|
+
const tokens = rest.trim().split(/\s+/);
|
|
1222
|
+
const targetUrl = tokens[0];
|
|
1223
|
+
const customLabel = tokens.slice(1).join(" ").trim() || undefined;
|
|
1224
|
+
try {
|
|
1225
|
+
applyRelayState((s) => {
|
|
1226
|
+
const added = ensureRelay(s, targetUrl, customLabel);
|
|
1227
|
+
s.enabled = true;
|
|
1228
|
+
s.url = added.url;
|
|
1229
|
+
return s;
|
|
1230
|
+
});
|
|
1231
|
+
} catch (e) {
|
|
1232
|
+
ctx.ui.notify((e as Error).message, "warning");
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
persist();
|
|
1236
|
+
flash();
|
|
1237
|
+
ctx.ui.notify(
|
|
1238
|
+
`✓ Added & activated relay [${shortRelayLabel(relayState.url, relayState.relays)}]: ${relayState.url}`,
|
|
1239
|
+
"info",
|
|
1240
|
+
);
|
|
1241
|
+
}
|
|
1242
|
+
} else if (sub === "use") {
|
|
1243
|
+
if (!rest.trim()) {
|
|
1244
|
+
await switchRelay();
|
|
1245
|
+
} else {
|
|
1246
|
+
const tokens = rest.trim().split(/\s+/);
|
|
1247
|
+
const targetToken = tokens[0];
|
|
1248
|
+
const customLabel = tokens.slice(1).join(" ").trim() || undefined;
|
|
1249
|
+
const matched = findRelay(relayState, targetToken);
|
|
1250
|
+
if (matched) {
|
|
1251
|
+
applyRelayState((s) => {
|
|
1252
|
+
const fresh = s.relays.find((r) => r.url === matched.url);
|
|
1253
|
+
if (!fresh) return s;
|
|
1254
|
+
s.enabled = true;
|
|
1255
|
+
s.url = fresh.url;
|
|
1256
|
+
if (customLabel) {
|
|
1257
|
+
fresh.label = customLabel;
|
|
1258
|
+
}
|
|
1259
|
+
return s;
|
|
1260
|
+
});
|
|
1261
|
+
persist();
|
|
1262
|
+
flash();
|
|
1263
|
+
} else {
|
|
1264
|
+
// Only treat unmatched tokens as new relays when they are absolute
|
|
1265
|
+
// http(s) URLs — typos or bad indices must not become saved junk relays.
|
|
1266
|
+
let parsedUrl = null;
|
|
1267
|
+
try {
|
|
1268
|
+
parsedUrl = new URL(targetToken);
|
|
1269
|
+
} catch { }
|
|
1270
|
+
const looksLikeUrl = parsedUrl && (parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:");
|
|
1271
|
+
if (looksLikeUrl) {
|
|
1272
|
+
try {
|
|
1273
|
+
applyRelayState((s) => {
|
|
1274
|
+
const added = ensureRelay(s, targetToken, customLabel);
|
|
1275
|
+
s.enabled = true;
|
|
1276
|
+
s.url = added.url;
|
|
1277
|
+
return s;
|
|
1278
|
+
});
|
|
1279
|
+
} catch (e) {
|
|
1280
|
+
ctx.ui.notify((e as Error).message, "warning");
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
persist();
|
|
1284
|
+
flash();
|
|
1285
|
+
} else {
|
|
1286
|
+
ctx.ui.notify(`Relay '${targetToken}' not found in saved list`, "warning");
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
} else if (sub === "label" || sub === "rename") {
|
|
1291
|
+
if (!rest.trim()) {
|
|
1292
|
+
await editRelayLabelMenu();
|
|
1293
|
+
} else {
|
|
1294
|
+
const tokens = rest.trim().split(/\s+/);
|
|
1295
|
+
const targetToken = tokens[0];
|
|
1296
|
+
const newLabel = tokens.slice(1).join(" ").trim();
|
|
1297
|
+
const matched = findRelay(relayState, targetToken);
|
|
1298
|
+
if (!matched) {
|
|
1299
|
+
ctx.ui.notify(`Relay '${targetToken}' not found in saved list`, "warning");
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
let finalLabel = newLabel;
|
|
1303
|
+
if (!finalLabel) {
|
|
1304
|
+
finalLabel = (
|
|
1305
|
+
await ctx.ui.input(
|
|
1306
|
+
`New short name / alias for ${matched.url}:`,
|
|
1307
|
+
matched.label || shortRelayLabel(matched.url, relayState.relays),
|
|
1308
|
+
)
|
|
1309
|
+
)?.trim() || "";
|
|
1310
|
+
}
|
|
1311
|
+
applyRelayState((s) => {
|
|
1312
|
+
setRelayLabel(s, matched.url, finalLabel);
|
|
1313
|
+
return s;
|
|
1314
|
+
});
|
|
1315
|
+
persist();
|
|
1316
|
+
flash();
|
|
1317
|
+
ctx.ui.notify(
|
|
1318
|
+
`✓ Relay short name updated to [${shortRelayLabel(matched.url, relayState.relays)}] for ${matched.url}`,
|
|
1319
|
+
"info",
|
|
1320
|
+
);
|
|
1321
|
+
}
|
|
1322
|
+
} else if (sub === "debug") {
|
|
1323
|
+
const arg = rest.trim().toLowerCase();
|
|
1324
|
+
if (arg === "on" || arg === "enable" || arg === "true") {
|
|
1325
|
+
saveDebugState({ debug: true });
|
|
1326
|
+
ctx.ui.notify(
|
|
1327
|
+
"🔍 Debug ON — verbose trace enabled (level=debug). Logs now include request IDs, thinking sniffing, and payload normalize details.",
|
|
1328
|
+
"info",
|
|
1329
|
+
);
|
|
1330
|
+
} else if (
|
|
1331
|
+
arg === "off" ||
|
|
1332
|
+
arg === "disable" ||
|
|
1333
|
+
arg === "false"
|
|
1334
|
+
) {
|
|
1335
|
+
saveDebugState({ debug: false });
|
|
1336
|
+
ctx.ui.notify(
|
|
1337
|
+
`🔇 Debug OFF — level restored to info. File: ${DEBUG_STATE_FILE}`,
|
|
1338
|
+
"info",
|
|
1339
|
+
);
|
|
1340
|
+
} else if (arg.startsWith("level")) {
|
|
1341
|
+
const lvl = arg.split(/\s+/)[1] as LogLevel | undefined;
|
|
1342
|
+
// Allowlist only levels that actually have emitters. 'audit' exists in
|
|
1343
|
+
// LOG_LEVEL_ORDER but nothing ever logs at it, so accepting it here
|
|
1344
|
+
// silently suppressed ALL log output until manually reset.
|
|
1345
|
+
const EMITTED_LEVELS: readonly LogLevel[] = [
|
|
1346
|
+
"debug",
|
|
1347
|
+
"info",
|
|
1348
|
+
"warn",
|
|
1349
|
+
"error",
|
|
1350
|
+
];
|
|
1351
|
+
if (
|
|
1352
|
+
lvl &&
|
|
1353
|
+
(EMITTED_LEVELS as readonly string[]).includes(lvl)
|
|
1354
|
+
) {
|
|
1355
|
+
saveDebugState({ debug: false, level: lvl });
|
|
1356
|
+
ctx.ui.notify(
|
|
1357
|
+
`Log level set to ${lvl} (persisted to ${DEBUG_STATE_FILE})`,
|
|
1358
|
+
"info",
|
|
1359
|
+
);
|
|
1360
|
+
} else {
|
|
1361
|
+
ctx.ui.notify(
|
|
1362
|
+
`Unknown level: ${lvl} (use debug/info/warn/error)`,
|
|
1363
|
+
"warning",
|
|
1364
|
+
);
|
|
1365
|
+
}
|
|
1366
|
+
} else {
|
|
1367
|
+
const st = loadDebugState();
|
|
1368
|
+
const cur = st?.debug
|
|
1369
|
+
? "debug (ON)"
|
|
1370
|
+
: st?.level ||
|
|
1371
|
+
process.env.FREEFLOW_LOG_LEVEL ||
|
|
1372
|
+
"info";
|
|
1373
|
+
ctx.ui.notify(
|
|
1374
|
+
`Debug status: ${cur}\nFile: ${DEBUG_STATE_FILE}\nMinLevel: ${getMinLogLevel()} | isDebug=${isDebugEnabled()}\nUsage: /freeflow debug on|off | /freeflow debug level debug`,
|
|
1375
|
+
"info",
|
|
1376
|
+
);
|
|
1377
|
+
}
|
|
1378
|
+
} else if (sub === "logs" || sub === "log" || sub === "trace") {
|
|
1379
|
+
try {
|
|
1380
|
+
const rawRest = rest.trim();
|
|
1381
|
+
let filterLevel: LogLevel | null = null;
|
|
1382
|
+
let filterReqId: string | null = null;
|
|
1383
|
+
let filterText: string | null = null;
|
|
1384
|
+
let expectRelay = false;
|
|
1385
|
+
let count = 25;
|
|
1386
|
+
const rawTokens = rawRest ? rawRest.split(/\s+/) : [];
|
|
1387
|
+
const isFollow = rawTokens.includes("--follow") || rawTokens.includes("-f");
|
|
1388
|
+
|
|
1389
|
+
if (sub === "trace" && rawRest) {
|
|
1390
|
+
filterReqId = rawTokens.filter((t) => t !== "--follow" && t !== "-f")[0] || null;
|
|
1391
|
+
} else if (rawRest) {
|
|
1392
|
+
for (const t of rawTokens) {
|
|
1393
|
+
if (t === "--follow" || t === "-f") continue;
|
|
1394
|
+
const lower = t.toLowerCase();
|
|
1395
|
+
if (expectRelay) {
|
|
1396
|
+
filterText = t;
|
|
1397
|
+
expectRelay = false;
|
|
1398
|
+
continue;
|
|
1399
|
+
}
|
|
1400
|
+
if (t === "relay") {
|
|
1401
|
+
expectRelay = true;
|
|
1402
|
+
continue;
|
|
1403
|
+
}
|
|
1404
|
+
if (lower in LOG_LEVEL_ORDER) {
|
|
1405
|
+
filterLevel = lower as LogLevel;
|
|
1406
|
+
} else if (/^\d+$/.test(t)) {
|
|
1407
|
+
count = Math.min(
|
|
1408
|
+
200,
|
|
1409
|
+
Math.max(5, Number.parseInt(t, 10)),
|
|
1410
|
+
);
|
|
1411
|
+
} else if (
|
|
1412
|
+
/^[a-f0-9]{6,8}$/i.test(t) ||
|
|
1413
|
+
t.startsWith("req=")
|
|
1414
|
+
) {
|
|
1415
|
+
filterReqId = t.replace(/^req=/, "");
|
|
1416
|
+
} else if (lower === "trace" || lower === "req") {
|
|
1417
|
+
continue;
|
|
1418
|
+
} else {
|
|
1419
|
+
// Unrecognized token: an invalid level must default to info,
|
|
1420
|
+
// not silently become a request-id filter.
|
|
1421
|
+
filterLevel = filterLevel ?? "info";
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
const result = readRecentLogs(
|
|
1427
|
+
filterLevel,
|
|
1428
|
+
filterReqId,
|
|
1429
|
+
count,
|
|
1430
|
+
filterText,
|
|
1431
|
+
);
|
|
1432
|
+
if (result.lines.length === 0) {
|
|
1433
|
+
if (result.totalLines === 0) {
|
|
1434
|
+
ctx.ui.notify(
|
|
1435
|
+
"Log file is empty (no logs yet)",
|
|
1436
|
+
"info",
|
|
1437
|
+
);
|
|
1438
|
+
} else {
|
|
1439
|
+
ctx.ui.notify(
|
|
1440
|
+
`No logs matched (level=${filterLevel || "any"} reqId=${filterReqId || "any"} text=${filterText || "any"} count=${count})`,
|
|
1441
|
+
"warning",
|
|
1442
|
+
);
|
|
1443
|
+
}
|
|
1444
|
+
if (isFollow) {
|
|
1445
|
+
startLogsFollow(
|
|
1446
|
+
ctx,
|
|
1447
|
+
filterLevel,
|
|
1448
|
+
filterReqId,
|
|
1449
|
+
count,
|
|
1450
|
+
filterText,
|
|
1451
|
+
result.totalMatched,
|
|
1452
|
+
);
|
|
1453
|
+
}
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
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}` : ""}):`;
|
|
1458
|
+
ctx.ui.notify(
|
|
1459
|
+
`${header}\n\n${result.lines.join("\n")}`,
|
|
1460
|
+
"info",
|
|
1461
|
+
);
|
|
1462
|
+
if (isFollow) {
|
|
1463
|
+
startLogsFollow(
|
|
1464
|
+
ctx,
|
|
1465
|
+
filterLevel,
|
|
1466
|
+
filterReqId,
|
|
1467
|
+
count,
|
|
1468
|
+
filterText,
|
|
1469
|
+
result.totalMatched,
|
|
1470
|
+
);
|
|
1471
|
+
}
|
|
1472
|
+
} catch (e) {
|
|
1473
|
+
ctx.ui.notify(
|
|
1474
|
+
`Could not read log file: ${(e as Error).message}`,
|
|
1475
|
+
"error",
|
|
1476
|
+
);
|
|
1477
|
+
}
|
|
1478
|
+
} else if (
|
|
1479
|
+
sub === "refresh" ||
|
|
1480
|
+
sub === "reload" ||
|
|
1481
|
+
sub === "models"
|
|
1482
|
+
) {
|
|
1483
|
+
ctx.ui.notify(
|
|
1484
|
+
"Refreshing model catalog from live upstreams…",
|
|
1485
|
+
"info",
|
|
1486
|
+
);
|
|
1487
|
+
const updated = await refreshCatalog(true);
|
|
1488
|
+
setAliveCatalog(updated);
|
|
1489
|
+
resetUpstreamGate("zen");
|
|
1490
|
+
persist();
|
|
1491
|
+
onCatalogRefreshed?.(updated);
|
|
1492
|
+
ctx.ui.notify(
|
|
1493
|
+
`✓ Refreshed ${updated.length} models with full-spec metadata (upstream health reset)!`,
|
|
1494
|
+
"info",
|
|
1495
|
+
);
|
|
1496
|
+
} else if (sub === "remove") {
|
|
1497
|
+
if (!rest.trim()) {
|
|
1498
|
+
await removeRelayMenu();
|
|
1499
|
+
} else {
|
|
1500
|
+
const target = rest.trim();
|
|
1501
|
+
const matched = findRelay(relayState, target);
|
|
1502
|
+
if (!matched) {
|
|
1503
|
+
ctx.ui.notify(`Relay '${target}' not in saved list`, "warning");
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
if (matched.url === relayState.url) {
|
|
1507
|
+
ctx.ui.notify(
|
|
1508
|
+
"Cannot remove the active relay — switch to another relay first",
|
|
1509
|
+
"warning",
|
|
1510
|
+
);
|
|
1511
|
+
return;
|
|
1512
|
+
}
|
|
1513
|
+
applyRelayState((s) => {
|
|
1514
|
+
removeRelay(s, matched.url);
|
|
1515
|
+
return s;
|
|
1516
|
+
});
|
|
1517
|
+
persist();
|
|
1518
|
+
ctx.ui.notify(`Removed: [${shortRelayLabel(matched.url, relayState.relays)}] ${matched.url}`, "info");
|
|
1519
|
+
}
|
|
1520
|
+
} else if (sub === "test") {
|
|
1521
|
+
const tokens = rest.trim() ? rest.trim().split(/\s+/) : [];
|
|
1522
|
+
let target: string | undefined;
|
|
1523
|
+
let chatMode = false;
|
|
1524
|
+
for (const t of tokens) {
|
|
1525
|
+
if (t === "--chat") {
|
|
1526
|
+
chatMode = true;
|
|
1527
|
+
} else if (t.startsWith("--")) {
|
|
1528
|
+
ctx.ui.notify(`Unknown flag ${t} — usage: /freeflow test [target|opencode] [--chat]`, "warning");
|
|
1529
|
+
return;
|
|
1530
|
+
} else if (target === undefined) {
|
|
1531
|
+
target = t;
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
const isDirect = target !== undefined && (target.toLowerCase() === "opencode" || target.toLowerCase() === "direct");
|
|
1536
|
+
if (isDirect) {
|
|
1537
|
+
ctx.ui.notify(`Testing OpenCode upstream endpoint${chatMode ? " (chat probe)" : ""}…`, "info");
|
|
1538
|
+
const probe = await probeRelay("https://opencode.ai", undefined, {
|
|
1539
|
+
endpoint: chatMode ? "chat" : "models",
|
|
1540
|
+
});
|
|
1541
|
+
if (probe.ok) {
|
|
1542
|
+
ctx.ui.notify(`✓ OpenCode endpoint ok (HTTP ${probe.status}, ${probe.latencyMs}ms)`, "info");
|
|
1543
|
+
} else {
|
|
1544
|
+
ctx.ui.notify(`✗ OpenCode endpoint failed: ${probe.error || `HTTP ${probe.status}`}`, "error");
|
|
1545
|
+
}
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
if (!target) {
|
|
1550
|
+
if (relayState.url) {
|
|
1551
|
+
target = relayState.url;
|
|
1552
|
+
} else if (relayState.relays && relayState.relays.length > 0) {
|
|
1553
|
+
target = relayState.relays[0].url;
|
|
1554
|
+
} else {
|
|
1555
|
+
ctx.ui.notify(`Testing OpenCode upstream endpoint${chatMode ? " (chat probe)" : ""}…`, "info");
|
|
1556
|
+
const probe = await probeRelay("https://opencode.ai", undefined, {
|
|
1557
|
+
endpoint: chatMode ? "chat" : "models",
|
|
1558
|
+
});
|
|
1559
|
+
if (probe.ok) {
|
|
1560
|
+
ctx.ui.notify(`✓ OpenCode endpoint ok (HTTP ${probe.status}, ${probe.latencyMs}ms)`, "info");
|
|
1561
|
+
} else {
|
|
1562
|
+
ctx.ui.notify(`✗ OpenCode endpoint failed: ${probe.error || `HTTP ${probe.status}`}`, "error");
|
|
1563
|
+
}
|
|
1564
|
+
return;
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
const matched = findRelay(relayState, target);
|
|
1569
|
+
if (!matched) {
|
|
1570
|
+
ctx.ui.notify(`Relay '${target}' not in saved list`, "warning");
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
1573
|
+
ctx.ui.notify(`Testing ${matched.url}${chatMode ? " (chat probe)" : ""}…`, "info");
|
|
1574
|
+
const probe = await probeRelay(matched.url, matched.auth, {
|
|
1575
|
+
endpoint: chatMode ? "chat" : "models",
|
|
1576
|
+
});
|
|
1577
|
+
if (probe.ok) {
|
|
1578
|
+
ctx.ui.notify(`✓ ${shortRelayLabel(matched.url, relayState.relays)} ok (HTTP ${probe.status}, ${probe.latencyMs}ms)`, "info");
|
|
1579
|
+
} else {
|
|
1580
|
+
ctx.ui.notify(`✗ ${shortRelayLabel(matched.url, relayState.relays)} failed: ${probe.error || `HTTP ${probe.status}`}`, "error");
|
|
1581
|
+
}
|
|
1582
|
+
} else if (sub === "cline") {
|
|
1583
|
+
const tokens = rest.trim() ? rest.trim().split(/\s+/) : [];
|
|
1584
|
+
const action = (tokens[0] || "accounts").toLowerCase();
|
|
1585
|
+
const arg = tokens.slice(1).join(" ").trim();
|
|
1586
|
+
if (action === "login") {
|
|
1587
|
+
const raw = tokens.slice(1);
|
|
1588
|
+
const pasteMode = raw.some((t) => t === "--key");
|
|
1589
|
+
const slotArg = raw.filter((t) => t !== "--key").join(" ").trim();
|
|
1590
|
+
if (pasteMode) {
|
|
1591
|
+
// Manual fallback: paste a workos: API key (never shown back).
|
|
1592
|
+
const slot = slotArg || ((await ctx.ui.input("Cline slot name (empty = default):", "default"))?.trim() || "default");
|
|
1593
|
+
const token = ((await ctx.ui.input(`Paste the API key for Cline slot [${slot}]:`, ""))?.trim() || "");
|
|
1594
|
+
if (!token) {
|
|
1595
|
+
ctx.ui.notify("Cancelled — no API key provided", "warning");
|
|
1596
|
+
} else {
|
|
1597
|
+
try {
|
|
1598
|
+
addAccount(slot, token);
|
|
1599
|
+
ctx.ui.notify(`Saved Cline login [${slot}] (key ending ${redactedToken(token)}) — free Cline models are ready to use`, "info");
|
|
1600
|
+
} catch (e) {
|
|
1601
|
+
ctx.ui.notify((e as Error).message, "warning");
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
// Device login inside the extension: approve in the browser, no paste.
|
|
1607
|
+
const slot = slotArg || nextClineSlot(loadPool());
|
|
1608
|
+
let started;
|
|
1609
|
+
try {
|
|
1610
|
+
started = await startDeviceAuth();
|
|
1611
|
+
} catch (e) {
|
|
1612
|
+
if (isCertError(e)) {
|
|
1613
|
+
ctx.ui.notify("Could not reach Cline: your antivirus or network proxy intercepts TLS and Node does not trust it. The login retries with your OS certificates automatically — if this persists, restart the host with NODE_USE_SYSTEM_CA=1 set, or point NODE_EXTRA_CA_CERTS at your proxy CA file.", "error");
|
|
1614
|
+
return;
|
|
1615
|
+
}
|
|
1616
|
+
ctx.ui.notify(`Could not reach Cline to start login: ${clineErrorMessage(e)}`, "error");
|
|
1617
|
+
return;
|
|
1618
|
+
}
|
|
1619
|
+
const link = started.verificationUriComplete ?? started.verificationUri;
|
|
1620
|
+
const minutes = Math.max(1, Math.round(started.expiresIn / 60));
|
|
1621
|
+
const loginLine = `Cline login [${slot}]\nOpen this link in your browser:\n${link}\nEnter code: ${started.userCode} (expires in ~${minutes} min)\nWaiting for approval — approve or cancel in the browser; this finishes on its own.\nDifferent account? Sign out first: ${CLINE_BROWSER_SIGNOUT_URL} (or use a private window)`;
|
|
1622
|
+
ctx.ui.notify(loginLine, "info");
|
|
1623
|
+
try { ctx.ui.setStatus("cline-login", `Cline login [${slot}] code ${started.userCode}`); } catch { }
|
|
1624
|
+
const progress = setInterval(() => {
|
|
1625
|
+
ctx.ui.notify(loginLine, "info");
|
|
1626
|
+
}, 45000);
|
|
1627
|
+
// @ts-ignore allow unref to not block process exit in CLI
|
|
1628
|
+
progress.unref?.();
|
|
1629
|
+
try {
|
|
1630
|
+
const deviceTokens = await pollDeviceToken(undefined, started.deviceCode, started.interval, { maxWaitMs: Math.max(60_000, started.expiresIn * 1000) });
|
|
1631
|
+
const creds = await registerClineToken(undefined, deviceTokens.accessToken, deviceTokens.refreshToken);
|
|
1632
|
+
const apiKey = toApiKey(creds.access);
|
|
1633
|
+
const dup = findClineAccountSlot(loadPool(), { token: apiKey, accountId: creds.accountId, email: creds.email }, slot);
|
|
1634
|
+
if (dup) {
|
|
1635
|
+
// Same account twice shares one free quota, so it would never add capacity.
|
|
1636
|
+
ctx.ui.notify(
|
|
1637
|
+
`That Cline account${creds.email ? ` (${creds.email})` : ""} is already saved as [${dup}] — nothing added.\nUse a different account: /freeflow cline signout shows the sign-out link. Or drop the existing one first: /freeflow cline logout ${dup}`,
|
|
1638
|
+
"warning",
|
|
1639
|
+
);
|
|
1640
|
+
return;
|
|
1641
|
+
}
|
|
1642
|
+
addAccount(slot, apiKey, {
|
|
1643
|
+
refreshToken: creds.refresh,
|
|
1644
|
+
expiresAt: creds.expires,
|
|
1645
|
+
...(creds.accountId ? { accountId: creds.accountId } : {}),
|
|
1646
|
+
...(creds.email ? { email: creds.email } : {}),
|
|
1647
|
+
});
|
|
1648
|
+
ctx.ui.notify(`Saved Cline login [${slot}] (key ending ${redactedToken(apiKey)}) — free Cline models are ready to use`, "info");
|
|
1649
|
+
const pool = loadPool();
|
|
1650
|
+
ctx.ui.notify(`Cline logins (${pool.accounts.length}):\n${formatClineAccountLines(pool).join("\n")}`, "info");
|
|
1651
|
+
} catch (e) {
|
|
1652
|
+
let code: string | undefined;
|
|
1653
|
+
if (e && typeof e === "object" && "errorCode" in e && typeof e.errorCode === "string") {
|
|
1654
|
+
code = e.errorCode;
|
|
1655
|
+
}
|
|
1656
|
+
if (code === "access_denied") {
|
|
1657
|
+
ctx.ui.notify("Cline login cancelled.", "warning");
|
|
1658
|
+
} else if (code === "expired_token") {
|
|
1659
|
+
ctx.ui.notify("Cline login code expired — run /freeflow cline login again for a fresh code.", "warning");
|
|
1660
|
+
} else {
|
|
1661
|
+
ctx.ui.notify(`Cline login did not finish: ${clineErrorMessage(e)}`, "warning");
|
|
1662
|
+
}
|
|
1663
|
+
} finally {
|
|
1664
|
+
clearInterval(progress);
|
|
1665
|
+
try { ctx.ui.setStatus("cline-login", undefined); } catch { }
|
|
1666
|
+
}
|
|
1667
|
+
} else if (action === "accounts" || action === "list") {
|
|
1668
|
+
const pool = loadPool();
|
|
1669
|
+
if (!pool.accounts.length) {
|
|
1670
|
+
ctx.ui.notify("No Cline logins saved — add one with /freeflow cline login", "info");
|
|
1671
|
+
} else {
|
|
1672
|
+
ctx.ui.notify(`Cline logins (${pool.accounts.length}):\n${formatClineAccountLines(pool).join("\n")}`, "info");
|
|
1673
|
+
}
|
|
1674
|
+
} else if (action === "logout" || action === "remove") {
|
|
1675
|
+
const pool = loadPool();
|
|
1676
|
+
if (!pool.accounts.length) {
|
|
1677
|
+
ctx.ui.notify("No Cline logins saved — nothing to remove", "info");
|
|
1678
|
+
} else {
|
|
1679
|
+
const resolveSlot = (raw: string): string | null => {
|
|
1680
|
+
const clean = (raw || "").trim();
|
|
1681
|
+
if (!clean) return null;
|
|
1682
|
+
const byIndex = Number.parseInt(clean, 10);
|
|
1683
|
+
if (String(byIndex) === clean && byIndex >= 1 && byIndex <= pool.accounts.length) return pool.accounts[byIndex - 1].slot;
|
|
1684
|
+
const exact = pool.accounts.find((a) => a.slot === clean);
|
|
1685
|
+
if (exact) return exact.slot;
|
|
1686
|
+
const folded = pool.accounts.find((a) => a.slot.toLowerCase() === clean.toLowerCase());
|
|
1687
|
+
return folded ? folded.slot : null;
|
|
1688
|
+
};
|
|
1689
|
+
let slot = resolveSlot(arg);
|
|
1690
|
+
if (arg && !slot) {
|
|
1691
|
+
const saved = pool.accounts.map((a, idx) => `${idx + 1}=${a.slot}`).join(", ");
|
|
1692
|
+
ctx.ui.notify(`Cline slot '${arg}' not found. Saved: ${saved}`, "warning");
|
|
1693
|
+
} else {
|
|
1694
|
+
if (!slot) {
|
|
1695
|
+
if (pool.accounts.length === 1) {
|
|
1696
|
+
slot = pool.accounts[0].slot;
|
|
1697
|
+
} else {
|
|
1698
|
+
const opts = formatClineAccountLines(pool);
|
|
1699
|
+
const choice = await ctx.ui.select("Remove Cline login", [...opts, "Cancel"]);
|
|
1700
|
+
if (!choice || choice === "Cancel") {
|
|
1701
|
+
ctx.ui.notify("Cancelled — no slot removed", "warning");
|
|
1702
|
+
return;
|
|
1703
|
+
}
|
|
1704
|
+
const hit = pool.accounts.find((a, idx) => opts[idx] === choice);
|
|
1705
|
+
slot = hit ? hit.slot : null;
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
if (!slot) {
|
|
1709
|
+
ctx.ui.notify("Cancelled — no slot removed", "warning");
|
|
1710
|
+
} else if (!removeAccount(slot)) {
|
|
1711
|
+
ctx.ui.notify(`Cline slot '${slot}' not found`, "warning");
|
|
1712
|
+
} else {
|
|
1713
|
+
ctx.ui.notify(`Removed Cline login [${slot}]`, "info");
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
} else if (action === "signout" || action === "sign-out" || action === "switch") {
|
|
1718
|
+
// Signing out is a browser action; it never touches saved logins here.
|
|
1719
|
+
const saved = loadPool().accounts.length;
|
|
1720
|
+
ctx.ui.notify(
|
|
1721
|
+
`Sign out of Cline in your browser to use a different account:\n${CLINE_BROWSER_SIGNOUT_URL}\nA private window works too, and keeps your current session.\nThen run /freeflow cline login again — each login gets its own slot (${saved ? `you have ${saved}; next is ` : "next is "}${nextClineSlot(loadPool())}).`,
|
|
1722
|
+
"info",
|
|
1723
|
+
);
|
|
1724
|
+
} else {
|
|
1725
|
+
ctx.ui.notify("Usage: /freeflow cline login [--key] [slot] | /freeflow cline accounts | /freeflow cline logout [slot] | /freeflow cline signout", "warning");
|
|
1726
|
+
}
|
|
1727
|
+
} else if (sub === "export") {
|
|
1728
|
+
const tokens = rest.trim() ? rest.trim().split(/\s+/) : [];
|
|
1729
|
+
let target: string | undefined;
|
|
1730
|
+
let includeSecrets = false;
|
|
1731
|
+
for (const t of tokens) {
|
|
1732
|
+
if (t === "--include-secrets") {
|
|
1733
|
+
includeSecrets = true;
|
|
1734
|
+
} else if (t.startsWith("--")) {
|
|
1735
|
+
ctx.ui.notify(`Unknown flag ${t} — usage: /freeflow export [path] [--include-secrets].`, "warning");
|
|
1736
|
+
return;
|
|
1737
|
+
} else if (target === undefined) {
|
|
1738
|
+
target = t;
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
await runExport(target, includeSecrets);
|
|
1742
|
+
} else if (sub === "import") {
|
|
1743
|
+
const tokens = rest.trim() ? rest.trim().split(/\s+/) : [];
|
|
1744
|
+
let target: string | undefined;
|
|
1745
|
+
let sawMerge = false;
|
|
1746
|
+
let sawReplace = false;
|
|
1747
|
+
let dryRun = false;
|
|
1748
|
+
for (const t of tokens) {
|
|
1749
|
+
if (t === "--merge") {
|
|
1750
|
+
sawMerge = true;
|
|
1751
|
+
} else if (t === "--replace") {
|
|
1752
|
+
sawReplace = true;
|
|
1753
|
+
} else if (t === "--dry-run") {
|
|
1754
|
+
dryRun = true;
|
|
1755
|
+
} else if (t.startsWith("--")) {
|
|
1756
|
+
ctx.ui.notify(`Unknown flag ${t} — usage: /freeflow import <path> [--merge|--replace] [--dry-run].`, "warning");
|
|
1757
|
+
return;
|
|
1758
|
+
} else if (target === undefined) {
|
|
1759
|
+
target = t;
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
if (sawMerge && sawReplace) {
|
|
1763
|
+
ctx.ui.notify("Both --merge and --replace given — showing a merge preview. Nothing changed.", "info");
|
|
1764
|
+
await runImport(target, "merge", true);
|
|
1765
|
+
return;
|
|
1766
|
+
}
|
|
1767
|
+
await runImport(target, sawReplace ? "replace" : "merge", dryRun);
|
|
1768
|
+
} else if (sub === "url") {
|
|
1769
|
+
const input =
|
|
1770
|
+
rest ||
|
|
1771
|
+
(await ctx.ui.input(
|
|
1772
|
+
"Relay URL (empty = default):",
|
|
1773
|
+
relayState.url || "",
|
|
1774
|
+
));
|
|
1775
|
+
const cleanInput = (input || "").trim() || "";
|
|
1776
|
+
applyRelayState((s) => {
|
|
1777
|
+
s.url = cleanInput;
|
|
1778
|
+
if (cleanInput) {
|
|
1779
|
+
ensureRelay(s, cleanInput, shortRelayLabel(cleanInput, s.relays));
|
|
1780
|
+
}
|
|
1781
|
+
return s;
|
|
1782
|
+
});
|
|
1783
|
+
persist();
|
|
1784
|
+
flash();
|
|
1785
|
+
} else if (sub === "deploy") {
|
|
1786
|
+
if (!rest.trim()) {
|
|
1787
|
+
const choice = await ctx.ui.select(
|
|
1788
|
+
"Deploy relay on",
|
|
1789
|
+
Object.keys(DEPLOY_OPTIONS),
|
|
1790
|
+
);
|
|
1791
|
+
if (choice) await doDeploy(DEPLOY_OPTIONS[choice]);
|
|
1792
|
+
} else {
|
|
1793
|
+
const pf = parseDeployPlatform(rest);
|
|
1794
|
+
if (!pf) {
|
|
1795
|
+
ctx.ui.notify(
|
|
1796
|
+
"Unknown platform. Use: vercel | cloudflare | deno",
|
|
1797
|
+
"warning",
|
|
1798
|
+
);
|
|
1799
|
+
} else {
|
|
1800
|
+
await doDeploy(pf);
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
} else if (sub === "install-startup") {
|
|
1804
|
+
const r = installStartupHook();
|
|
1805
|
+
ctx.ui.notify(r.detail, r.ok ? "info" : "warning");
|
|
1806
|
+
} else if (sub === "uninstall-startup") {
|
|
1807
|
+
const r = uninstallStartupHook();
|
|
1808
|
+
ctx.ui.notify(r.detail, r.ok ? "info" : "warning");
|
|
1809
|
+
} else {
|
|
1810
|
+
const currentMode = (relayState.mode || "auto").toUpperCase();
|
|
1811
|
+
const activeLabel = shortRelayLabel(relayState.url, relayState.relays);
|
|
1812
|
+
const choice = await ctx.ui.select("freeflow relay", [
|
|
1813
|
+
`Mode: ${currentMode} (${relayState.enabled ? "ON" : "OFF"}) → ${activeLabel}`,
|
|
1814
|
+
"Add custom relay (URL + Short name)…",
|
|
1815
|
+
"Switch active relay…",
|
|
1816
|
+
"Rename / Set relay short name…",
|
|
1817
|
+
"Remove relay…",
|
|
1818
|
+
"Share relays (export file)…",
|
|
1819
|
+
"Load relays from file (import)…",
|
|
1820
|
+
"List saved relays",
|
|
1821
|
+
"Deploy Vercel relay…",
|
|
1822
|
+
"Deploy Cloudflare relay…",
|
|
1823
|
+
"Deploy Deno relay…",
|
|
1824
|
+
"Mode: AUTO (auto-detect on model select)",
|
|
1825
|
+
"Mode: ON (always relay)",
|
|
1826
|
+
"Mode: OFF (always direct)",
|
|
1827
|
+
]);
|
|
1828
|
+
if (choice === `Mode: ${currentMode} (${relayState.enabled ? "ON" : "OFF"}) → ${activeLabel}`) {
|
|
1829
|
+
flash();
|
|
1830
|
+
} else if (choice === "Add custom relay (URL + Short name)…") {
|
|
1831
|
+
await addRelayInteractive();
|
|
1832
|
+
} else if (choice === "Switch active relay…") {
|
|
1833
|
+
await switchRelay();
|
|
1834
|
+
} else if (choice === "Rename / Set relay short name…") {
|
|
1835
|
+
await editRelayLabelMenu();
|
|
1836
|
+
} else if (choice === "Remove relay…") {
|
|
1837
|
+
await removeRelayMenu();
|
|
1838
|
+
} else if (choice === "Share relays (export file)…") {
|
|
1839
|
+
await runExport(undefined, false);
|
|
1840
|
+
} else if (choice === "Load relays from file (import)…") {
|
|
1841
|
+
const picked = (await ctx.ui.input("Relay file path:", EXPORT_DEFAULT_FILENAME))?.trim();
|
|
1842
|
+
if (!picked) return;
|
|
1843
|
+
await runImport(picked, "merge", false);
|
|
1844
|
+
} else if (choice === "List saved relays") {
|
|
1845
|
+
showList();
|
|
1846
|
+
} else if (choice === "Deploy Vercel relay…") {
|
|
1847
|
+
await doDeploy("vercel");
|
|
1848
|
+
} else if (choice === "Deploy Cloudflare relay…") {
|
|
1849
|
+
await doDeploy("cloudflare");
|
|
1850
|
+
} else if (choice === "Deploy Deno relay…") {
|
|
1851
|
+
await doDeploy("deno");
|
|
1852
|
+
} else if (choice === "Mode: AUTO (auto-detect on model select)") {
|
|
1853
|
+
applyRelayState((s) => {
|
|
1854
|
+
s.mode = "auto";
|
|
1855
|
+
s.enabled = true;
|
|
1856
|
+
s.url = s.url || "";
|
|
1857
|
+
if (s.url) {
|
|
1858
|
+
ensureRelay(s, s.url);
|
|
1859
|
+
}
|
|
1860
|
+
return s;
|
|
1861
|
+
});
|
|
1862
|
+
persist();
|
|
1863
|
+
flash();
|
|
1864
|
+
} else if (choice === "Mode: ON (always relay)") {
|
|
1865
|
+
applyRelayState((s) => {
|
|
1866
|
+
s.mode = "on";
|
|
1867
|
+
s.enabled = true;
|
|
1868
|
+
s.url = s.url || "";
|
|
1869
|
+
if (s.url) {
|
|
1870
|
+
ensureRelay(s, s.url);
|
|
1871
|
+
}
|
|
1872
|
+
return s;
|
|
1873
|
+
});
|
|
1874
|
+
persist();
|
|
1875
|
+
flash();
|
|
1876
|
+
} else if (choice === "Mode: OFF (always direct)") {
|
|
1877
|
+
applyRelayState((s) => {
|
|
1878
|
+
s.mode = "off";
|
|
1879
|
+
s.enabled = false;
|
|
1880
|
+
return s;
|
|
1881
|
+
});
|
|
1882
|
+
persist();
|
|
1883
|
+
flash();
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
},
|
|
1887
|
+
};
|
|
1888
|
+
}
|