friend-assistant 0.1.1 → 0.1.2
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/dist/friend.mjs
CHANGED
|
@@ -2111,6 +2111,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
|
2111
2111
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
2112
2112
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
2113
2113
|
import { ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
2114
|
+
import crossSpawn from "cross-spawn";
|
|
2114
2115
|
import { createHash } from "node:crypto";
|
|
2115
2116
|
|
|
2116
2117
|
// ../server/src/connectors/store.ts
|
|
@@ -2235,6 +2236,20 @@ function friendlyToolName(original, taken) {
|
|
|
2235
2236
|
taken.add(out);
|
|
2236
2237
|
return out;
|
|
2237
2238
|
}
|
|
2239
|
+
function killTree(pid) {
|
|
2240
|
+
try {
|
|
2241
|
+
if (process.platform === "win32") crossSpawn("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }).on("error", () => {
|
|
2242
|
+
});
|
|
2243
|
+
else process.kill(pid, "SIGKILL");
|
|
2244
|
+
} catch {
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
async function stopLive(l) {
|
|
2248
|
+
const pid = l.transport?.pid ?? null;
|
|
2249
|
+
await Promise.race([l.client.close().catch(() => {
|
|
2250
|
+
}), new Promise((r) => setTimeout(r, 3e3).unref?.())]);
|
|
2251
|
+
if (pid) killTree(pid);
|
|
2252
|
+
}
|
|
2238
2253
|
var messageOf = (e) => (e instanceof Error ? e.message : String(e)).slice(0, 400);
|
|
2239
2254
|
var sleepReject = (ms, what) => new Promise((_, rej) => {
|
|
2240
2255
|
const t = setTimeout(() => rej(new Error(what)), ms);
|
|
@@ -2374,13 +2389,14 @@ ${l.stderr}` : ""}`, isError: true };
|
|
|
2374
2389
|
entry.connecting = (async () => {
|
|
2375
2390
|
try {
|
|
2376
2391
|
const transport = this.transportFor(cfg, entry);
|
|
2392
|
+
entry.transport = transport;
|
|
2377
2393
|
client.onclose = () => {
|
|
2378
2394
|
if (this.live.get(id2) === entry && entry.status === "ready") {
|
|
2379
2395
|
entry.status = "error";
|
|
2380
2396
|
entry.error = "The connection closed.";
|
|
2381
2397
|
}
|
|
2382
2398
|
};
|
|
2383
|
-
await Promise.race([client.connect(transport), sleepReject(this.opts.connectTimeoutMs ??
|
|
2399
|
+
await Promise.race([client.connect(transport), sleepReject(this.opts.connectTimeoutMs ?? 6e4, "It did not answer in time.")]);
|
|
2384
2400
|
await this.readTools(id2, entry);
|
|
2385
2401
|
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
|
|
2386
2402
|
try {
|
|
@@ -2393,10 +2409,7 @@ ${l.stderr}` : ""}`, isError: true };
|
|
|
2393
2409
|
} catch (e) {
|
|
2394
2410
|
entry.status = "error";
|
|
2395
2411
|
entry.error = `${messageOf(e)}${entry.stderr ? ` (${entry.stderr.trim().split("\n").slice(-2).join(" ").slice(0, 200)})` : ""}`;
|
|
2396
|
-
|
|
2397
|
-
await client.close();
|
|
2398
|
-
} catch {
|
|
2399
|
-
}
|
|
2412
|
+
await stopLive(entry);
|
|
2400
2413
|
throw new Error(entry.error);
|
|
2401
2414
|
} finally {
|
|
2402
2415
|
entry.connecting = void 0;
|
|
@@ -2447,10 +2460,7 @@ ${l.stderr}` : ""}`, isError: true };
|
|
|
2447
2460
|
this.forget(id2);
|
|
2448
2461
|
if (!l) return;
|
|
2449
2462
|
l.client.onclose = void 0;
|
|
2450
|
-
|
|
2451
|
-
await l.client.close();
|
|
2452
|
-
} catch {
|
|
2453
|
-
}
|
|
2463
|
+
await stopLive(l);
|
|
2454
2464
|
}
|
|
2455
2465
|
};
|
|
2456
2466
|
function flatten(content) {
|
|
@@ -2473,11 +2483,12 @@ var PRESETS = [
|
|
|
2473
2483
|
id: "chrome",
|
|
2474
2484
|
name: "Chrome control",
|
|
2475
2485
|
description: "Lets Friend open pages, read them, click and type in your own Chrome, through your chrome-control extension.",
|
|
2476
|
-
|
|
2486
|
+
quick: true,
|
|
2487
|
+
connector: { id: "chrome", name: "Chrome control", kind: "stdio", command: "npx", args: ["-y", "chrome-control-mcp"] },
|
|
2477
2488
|
setup: [
|
|
2478
|
-
"
|
|
2479
|
-
"
|
|
2480
|
-
"
|
|
2489
|
+
"Press Connect. Friend checks that Chrome control is installed and, if it is, adds it for you: it runs npx -y chrome-control-mcp, the same as claude mcp add chrome-control -- npx -y chrome-control-mcp.",
|
|
2490
|
+
"If it is not installed yet, Friend says so and can install it for you in the background (npx -y chrome-control-mcp setup).",
|
|
2491
|
+
"The one step nobody can do for you, because Chrome does not allow silent installs: load the extension in chrome://extensions (Developer mode, Load unpacked) and switch it on."
|
|
2481
2492
|
],
|
|
2482
2493
|
warning: "While the extension is on, Friend can act as you on any site it allows. Every action still asks for your approval; in the extension's popup you can limit it to certain sites."
|
|
2483
2494
|
},
|
|
@@ -2496,6 +2507,122 @@ var PRESETS = [
|
|
|
2496
2507
|
}
|
|
2497
2508
|
];
|
|
2498
2509
|
|
|
2510
|
+
// ../server/src/connectors/quick.ts
|
|
2511
|
+
import { existsSync as existsSync4 } from "node:fs";
|
|
2512
|
+
import { homedir } from "node:os";
|
|
2513
|
+
import { join as join6 } from "node:path";
|
|
2514
|
+
var find = (id2) => PRESETS.find((p) => p.id === id2 && p.quick);
|
|
2515
|
+
var isQuickPreset = (id2) => !!find(id2);
|
|
2516
|
+
var MANUAL_EXTENSION = "One step only you can do, because Chrome does not allow programs to install extensions silently: open chrome://extensions, turn on Developer mode (top right), click Load unpacked and choose the folder shown above. Then click the extension's icon and switch it on.";
|
|
2517
|
+
function chromeFolder(home) {
|
|
2518
|
+
return join6(home, ".chrome-control-mcp");
|
|
2519
|
+
}
|
|
2520
|
+
function chromeInstalled(home = homedir()) {
|
|
2521
|
+
const base = chromeFolder(home);
|
|
2522
|
+
const ext = join6(base, "extension");
|
|
2523
|
+
const installed = existsSync4(join6(base, "token")) && existsSync4(join6(ext, "manifest.json")) && existsSync4(join6(base, "native-host"));
|
|
2524
|
+
return { installed, extensionFolder: installed ? ext : null };
|
|
2525
|
+
}
|
|
2526
|
+
function quickCheck(id2, deps = {}) {
|
|
2527
|
+
const preset = find(id2);
|
|
2528
|
+
if (!preset) return null;
|
|
2529
|
+
const c = chromeInstalled(deps.home);
|
|
2530
|
+
if (c.installed) {
|
|
2531
|
+
return { preset: id2, name: preset.name, ready: true, message: "Chrome control is installed on this PC.", installCommand: null, manualStep: null, extensionFolder: c.extensionFolder };
|
|
2532
|
+
}
|
|
2533
|
+
return {
|
|
2534
|
+
preset: id2,
|
|
2535
|
+
name: preset.name,
|
|
2536
|
+
ready: false,
|
|
2537
|
+
message: "Chrome control is not installed on this PC yet. Please install it first, then press Connect again.",
|
|
2538
|
+
installCommand: "npx -y chrome-control-mcp setup",
|
|
2539
|
+
manualStep: MANUAL_EXTENSION,
|
|
2540
|
+
extensionFolder: null
|
|
2541
|
+
};
|
|
2542
|
+
}
|
|
2543
|
+
async function quickInstall(id2, deps, onLine) {
|
|
2544
|
+
if (!find(id2)) return { ok: false, output: "", extensionFolder: null, message: "That is not a one-click connector." };
|
|
2545
|
+
const lines = [];
|
|
2546
|
+
const r = await deps.run("npx", ["-y", "chrome-control-mcp", "setup", "--no-claude", "--no-open", "--no-flag"], {
|
|
2547
|
+
onLine: (l) => {
|
|
2548
|
+
lines.push(l);
|
|
2549
|
+
onLine?.(l);
|
|
2550
|
+
}
|
|
2551
|
+
});
|
|
2552
|
+
const after = chromeInstalled(deps.home);
|
|
2553
|
+
const output = (lines.length ? lines : r.stdout.split(/\r?\n/)).filter((l) => l.trim()).slice(-12).join("\n");
|
|
2554
|
+
if (r.code !== 0 || !after.installed) {
|
|
2555
|
+
return { ok: false, output, extensionFolder: null, message: "The install did not finish. You can run it yourself in a terminal: npx -y chrome-control-mcp setup" };
|
|
2556
|
+
}
|
|
2557
|
+
return { ok: true, output, extensionFolder: after.extensionFolder, message: `Chrome control is installed. ${MANUAL_EXTENSION}` };
|
|
2558
|
+
}
|
|
2559
|
+
function readExtensionState(text2) {
|
|
2560
|
+
try {
|
|
2561
|
+
const o = JSON.parse(text2);
|
|
2562
|
+
if (typeof o.extension === "boolean") return o.extension ? "connected" : "off";
|
|
2563
|
+
if (typeof o.connected === "boolean") return o.connected ? "connected" : "off";
|
|
2564
|
+
} catch {
|
|
2565
|
+
}
|
|
2566
|
+
const t = text2.toLowerCase();
|
|
2567
|
+
if (/not connected|disconnected|no extension|extension is (off|not)|waiting for (the )?extension|not enabled|disabled/.test(t)) return "off";
|
|
2568
|
+
if (/extension[^.\n]*connected|connected[^.\n]*extension|"connected"\s*:\s*true|bridge[^.\n]*connected/.test(t)) return "connected";
|
|
2569
|
+
return "unknown";
|
|
2570
|
+
}
|
|
2571
|
+
async function quickConnect(id2, deps) {
|
|
2572
|
+
const preset = find(id2);
|
|
2573
|
+
if (!preset) return { ok: false, connector: null, extension: "unknown", message: "That is not a one-click connector." };
|
|
2574
|
+
const check3 = quickCheck(id2, deps);
|
|
2575
|
+
if (!check3.ready) return { ok: false, connector: null, extension: "unknown", message: check3.message };
|
|
2576
|
+
const { hub } = deps;
|
|
2577
|
+
const existing = hub.list().find((c) => c.id === preset.connector.id);
|
|
2578
|
+
const view = existing ? existing.enabled ? await hub.reload(existing.id) : await hub.setEnabled(existing.id, true) : await hub.add(preset.connector);
|
|
2579
|
+
const connector = { id: view.id, name: view.name, status: view.status, error: view.error, tools: view.tools };
|
|
2580
|
+
if (view.status !== "ready") {
|
|
2581
|
+
return { ok: false, connector, extension: "unknown", message: `${preset.name} is saved, but it did not start: ${view.error ?? "unknown error"}` };
|
|
2582
|
+
}
|
|
2583
|
+
const statusTool = hub.toolSpecs().find((t) => t.name === `mcp.${view.id}.status`);
|
|
2584
|
+
let extension = "unknown";
|
|
2585
|
+
if (statusTool) {
|
|
2586
|
+
const r = await hub.call(statusTool.name, {});
|
|
2587
|
+
if (!r.isError) extension = readExtensionState(r.text);
|
|
2588
|
+
}
|
|
2589
|
+
const base = `${preset.name} is on with ${view.tools} tools.`;
|
|
2590
|
+
const message = extension === "connected" ? `${base} Your Chrome extension is connected. Friend can use Chrome now (it still asks before each action).` : extension === "off" ? `${base} But the Chrome extension is not connected yet. Click its icon in Chrome and switch it on (if it is not loaded: chrome://extensions, Developer mode, Load unpacked, then choose ${check3.extensionFolder}).` : `${base} Check that the Chrome extension is loaded and switched on (click its icon in Chrome).`;
|
|
2591
|
+
return { ok: true, connector, extension, message };
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
// ../server/src/connectors/run.ts
|
|
2595
|
+
import crossSpawn2 from "cross-spawn";
|
|
2596
|
+
var LIMIT_MS = 5 * 6e4;
|
|
2597
|
+
var runCommand = (cmd, args, opts) => new Promise((resolve10) => {
|
|
2598
|
+
let stdout = "";
|
|
2599
|
+
let child;
|
|
2600
|
+
try {
|
|
2601
|
+
child = crossSpawn2(cmd, args, { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
2602
|
+
} catch (e) {
|
|
2603
|
+
resolve10({ code: 127, stdout: e.message });
|
|
2604
|
+
return;
|
|
2605
|
+
}
|
|
2606
|
+
const timer = setTimeout(() => child.kill(), LIMIT_MS);
|
|
2607
|
+
const feed = (d) => {
|
|
2608
|
+
const s = d.toString();
|
|
2609
|
+
stdout += s;
|
|
2610
|
+
if (opts?.onLine) {
|
|
2611
|
+
for (const line of s.split(/\r?\n/)) if (line.trim()) opts.onLine(line);
|
|
2612
|
+
}
|
|
2613
|
+
};
|
|
2614
|
+
child.stdout?.on("data", feed);
|
|
2615
|
+
child.stderr?.on("data", feed);
|
|
2616
|
+
child.on("error", (e) => {
|
|
2617
|
+
clearTimeout(timer);
|
|
2618
|
+
resolve10({ code: 127, stdout: stdout + e.message });
|
|
2619
|
+
});
|
|
2620
|
+
child.on("close", (code) => {
|
|
2621
|
+
clearTimeout(timer);
|
|
2622
|
+
resolve10({ code: code ?? 1, stdout });
|
|
2623
|
+
});
|
|
2624
|
+
});
|
|
2625
|
+
|
|
2499
2626
|
// ../server/src/approvals.ts
|
|
2500
2627
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
2501
2628
|
|
|
@@ -2927,7 +3054,7 @@ import { spawn } from "node:child_process";
|
|
|
2927
3054
|
import { randomUUID as randomUUID4 } from "node:crypto";
|
|
2928
3055
|
import { chmodSync as chmodSync2, mkdtempSync as mkdtempSync2, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "node:fs";
|
|
2929
3056
|
import { tmpdir } from "node:os";
|
|
2930
|
-
import { join as
|
|
3057
|
+
import { join as join7 } from "node:path";
|
|
2931
3058
|
var MAX_CODE_CHARS = 5e4;
|
|
2932
3059
|
var MAX_OUTPUT_CHARS = 2e4;
|
|
2933
3060
|
var MAX_EXTRA_FILES = 10;
|
|
@@ -2987,15 +3114,15 @@ var CodeBox = class {
|
|
|
2987
3114
|
if (extra.reduce((sum, [, body]) => sum + body.length, 0) > MAX_EXTRA_FILE_CHARS) return { ok: false, error: "The sandbox files are too large." };
|
|
2988
3115
|
const setup = SETUP[input.language];
|
|
2989
3116
|
const timeoutMs = input.timeoutSec && input.timeoutSec > 0 ? Math.min(input.timeoutSec * 1e3, MAX_TIMEOUT_MS) : DEFAULT_TIMEOUT_MS;
|
|
2990
|
-
const dir = mkdtempSync2(
|
|
3117
|
+
const dir = mkdtempSync2(join7(tmpdir(), "friend-code-"));
|
|
2991
3118
|
try {
|
|
2992
|
-
const file =
|
|
3119
|
+
const file = join7(dir, setup.file);
|
|
2993
3120
|
writeFileSync3(file, input.code);
|
|
2994
3121
|
chmodSync2(dir, 493);
|
|
2995
3122
|
chmodSync2(file, 420);
|
|
2996
3123
|
for (const [name, body] of extra) {
|
|
2997
|
-
writeFileSync3(
|
|
2998
|
-
chmodSync2(
|
|
3124
|
+
writeFileSync3(join7(dir, name), body);
|
|
3125
|
+
chmodSync2(join7(dir, name), 420);
|
|
2999
3126
|
}
|
|
3000
3127
|
const args = [
|
|
3001
3128
|
"run",
|
|
@@ -3130,12 +3257,12 @@ var Memory = class {
|
|
|
3130
3257
|
// ../server/src/app.ts
|
|
3131
3258
|
import { dirname as dirname3, resolve as resolve2 } from "node:path";
|
|
3132
3259
|
import { fileURLToPath } from "node:url";
|
|
3133
|
-
import { join as
|
|
3260
|
+
import { join as join12 } from "node:path";
|
|
3134
3261
|
|
|
3135
3262
|
// ../server/src/config.ts
|
|
3136
|
-
import { chmodSync as chmodSync3, existsSync as
|
|
3137
|
-
import { homedir } from "node:os";
|
|
3138
|
-
import { join as
|
|
3263
|
+
import { chmodSync as chmodSync3, existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "node:fs";
|
|
3264
|
+
import { homedir as homedir2 } from "node:os";
|
|
3265
|
+
import { join as join8 } from "node:path";
|
|
3139
3266
|
var DEFAULT_CLAUDE_CODE = { model: "sonnet", command: "claude", fallback: "ollama" };
|
|
3140
3267
|
function sanitizeClaudeCode(raw) {
|
|
3141
3268
|
const r = raw && typeof raw === "object" ? raw : {};
|
|
@@ -3208,12 +3335,13 @@ var DEFAULT_DESTRUCTIVE_PATTERNS = [
|
|
|
3208
3335
|
String.raw`\b(iex|invoke-expression)\b`
|
|
3209
3336
|
];
|
|
3210
3337
|
function defaultDataDir() {
|
|
3211
|
-
return process.env.FRIEND_HOME ??
|
|
3338
|
+
return process.env.FRIEND_HOME ?? join8(homedir2(), ".friend");
|
|
3212
3339
|
}
|
|
3213
3340
|
var defaults = (dataDir) => ({
|
|
3214
3341
|
bind: "127.0.0.1",
|
|
3215
3342
|
port: 8787,
|
|
3216
3343
|
publicUrl: null,
|
|
3344
|
+
https: false,
|
|
3217
3345
|
trustProxy: false,
|
|
3218
3346
|
allowedOrigins: [],
|
|
3219
3347
|
allowedHosts: [],
|
|
@@ -3230,7 +3358,7 @@ var defaults = (dataDir) => ({
|
|
|
3230
3358
|
dataDir
|
|
3231
3359
|
});
|
|
3232
3360
|
function configPath(dataDir) {
|
|
3233
|
-
return
|
|
3361
|
+
return join8(dataDir, "config.json");
|
|
3234
3362
|
}
|
|
3235
3363
|
function saveConfig(cfg) {
|
|
3236
3364
|
const { dataDir, ...rest } = cfg;
|
|
@@ -3245,7 +3373,7 @@ function loadConfig(dataDir = defaultDataDir()) {
|
|
|
3245
3373
|
mkdirSync4(dataDir, { recursive: true });
|
|
3246
3374
|
const file = configPath(dataDir);
|
|
3247
3375
|
const base = defaults(dataDir);
|
|
3248
|
-
if (!
|
|
3376
|
+
if (!existsSync5(file)) {
|
|
3249
3377
|
const { dataDir: _d, ...rest } = base;
|
|
3250
3378
|
saveConfig({ ...rest, dataDir });
|
|
3251
3379
|
return base;
|
|
@@ -3255,6 +3383,7 @@ function loadConfig(dataDir = defaultDataDir()) {
|
|
|
3255
3383
|
return {
|
|
3256
3384
|
...base,
|
|
3257
3385
|
...stored,
|
|
3386
|
+
https: stored.https === true,
|
|
3258
3387
|
destructivePatterns,
|
|
3259
3388
|
ollama: { ...DEFAULT_OLLAMA, ...stored.ollama },
|
|
3260
3389
|
claudeCode: sanitizeClaudeCode(stored.claudeCode),
|
|
@@ -3268,7 +3397,7 @@ function loadConfig(dataDir = defaultDataDir()) {
|
|
|
3268
3397
|
// ../server/src/db.ts
|
|
3269
3398
|
import Database from "better-sqlite3";
|
|
3270
3399
|
import { mkdirSync as mkdirSync5 } from "node:fs";
|
|
3271
|
-
import { join as
|
|
3400
|
+
import { join as join9 } from "node:path";
|
|
3272
3401
|
|
|
3273
3402
|
// ../server/src/migrations.ts
|
|
3274
3403
|
var MIGRATIONS = [
|
|
@@ -3385,7 +3514,7 @@ create table plugins (
|
|
|
3385
3514
|
// ../server/src/db.ts
|
|
3386
3515
|
function openDb(dataDir) {
|
|
3387
3516
|
mkdirSync5(dataDir, { recursive: true });
|
|
3388
|
-
const db = new Database(
|
|
3517
|
+
const db = new Database(join9(dataDir, "friend.db"));
|
|
3389
3518
|
db.pragma("journal_mode = WAL");
|
|
3390
3519
|
db.pragma("foreign_keys = ON");
|
|
3391
3520
|
db.exec("create table if not exists schema_version (id integer primary key, applied_at integer not null)");
|
|
@@ -3483,7 +3612,7 @@ var DeviceHub = class {
|
|
|
3483
3612
|
import fastifyCookie from "@fastify/cookie";
|
|
3484
3613
|
import fastifyStatic from "@fastify/static";
|
|
3485
3614
|
import Fastify from "fastify";
|
|
3486
|
-
import { existsSync as
|
|
3615
|
+
import { existsSync as existsSync6 } from "node:fs";
|
|
3487
3616
|
import { isIP } from "node:net";
|
|
3488
3617
|
import { WebSocket, WebSocketServer } from "ws";
|
|
3489
3618
|
import { z as z10 } from "zod";
|
|
@@ -3558,7 +3687,7 @@ function createGateway(deps) {
|
|
|
3558
3687
|
forceCloseConnections: true
|
|
3559
3688
|
// shutdown must not wait for idle keep-alive connections
|
|
3560
3689
|
};
|
|
3561
|
-
const app = Fastify(options);
|
|
3690
|
+
const app = Fastify(deps.tls ? { ...options, https: { key: deps.tls.key, cert: deps.tls.cert } } : options);
|
|
3562
3691
|
app.register(fastifyCookie);
|
|
3563
3692
|
const hostOf = (header) => {
|
|
3564
3693
|
const h = (header ?? "").trim().toLowerCase();
|
|
@@ -3768,6 +3897,24 @@ function createGateway(deps) {
|
|
|
3768
3897
|
return connectorError(reply, e, "add");
|
|
3769
3898
|
}
|
|
3770
3899
|
});
|
|
3900
|
+
app.get("/api/connectors/quick/:preset", { onRequest: connectorGuard }, async (req, reply) => {
|
|
3901
|
+
try {
|
|
3902
|
+
return deps.connectors.quickCheck(req.params.preset);
|
|
3903
|
+
} catch (e) {
|
|
3904
|
+
return connectorError(reply, e, "quick-check");
|
|
3905
|
+
}
|
|
3906
|
+
});
|
|
3907
|
+
for (const action of ["install", "connect"]) {
|
|
3908
|
+
app.post(`/api/connectors/quick/:preset/${action}`, { onRequest: connectorGuard }, async (req, reply) => {
|
|
3909
|
+
const body = z10.object({ pin: z10.string().min(1).max(32) }).safeParse(req.body);
|
|
3910
|
+
if (!body.success) return reply.code(400).send({ error: "Enter your PIN to continue" });
|
|
3911
|
+
try {
|
|
3912
|
+
return await (action === "install" ? deps.connectors.quickInstall : deps.connectors.quickConnect)(req.params.preset, body.data.pin, req.ip);
|
|
3913
|
+
} catch (e) {
|
|
3914
|
+
return connectorError(reply, e, `quick-${action}`);
|
|
3915
|
+
}
|
|
3916
|
+
});
|
|
3917
|
+
}
|
|
3771
3918
|
app.post("/api/connectors/:id/enabled", { onRequest: connectorGuard }, async (req, reply) => {
|
|
3772
3919
|
const body = z10.object({ enabled: z10.boolean(), pin: z10.string().min(1).max(32).optional() }).safeParse(req.body);
|
|
3773
3920
|
if (!body.success) return reply.code(400).send({ error: "Invalid input" });
|
|
@@ -3903,7 +4050,7 @@ function createGateway(deps) {
|
|
|
3903
4050
|
deps.onLock?.();
|
|
3904
4051
|
return { ok: true };
|
|
3905
4052
|
});
|
|
3906
|
-
if (deps.webDir &&
|
|
4053
|
+
if (deps.webDir && existsSync6(deps.webDir)) {
|
|
3907
4054
|
app.register(fastifyStatic, { root: deps.webDir });
|
|
3908
4055
|
app.setNotFoundHandler((req, reply) => {
|
|
3909
4056
|
if (req.method === "GET" && !req.url.startsWith("/api")) return reply.sendFile("index.html");
|
|
@@ -4125,8 +4272,8 @@ var ClaudeProvider = class {
|
|
|
4125
4272
|
|
|
4126
4273
|
// ../server/src/provider/claude-code/provider.ts
|
|
4127
4274
|
import { createHash as createHash3 } from "node:crypto";
|
|
4128
|
-
import { chmodSync as chmodSync4, existsSync as
|
|
4129
|
-
import { join as
|
|
4275
|
+
import { chmodSync as chmodSync4, existsSync as existsSync7, mkdirSync as mkdirSync6, readFileSync as readFileSync4, rmSync as rmSync4, writeFileSync as writeFileSync5 } from "node:fs";
|
|
4276
|
+
import { join as join10 } from "node:path";
|
|
4130
4277
|
|
|
4131
4278
|
// ../server/src/provider/claude-code/bridge.ts
|
|
4132
4279
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
@@ -4411,7 +4558,7 @@ var NOT_SIGNED_IN = /not logged in|please run \/login|invalid api key|authentica
|
|
|
4411
4558
|
var ClaudeCodeProvider = class {
|
|
4412
4559
|
constructor(opts) {
|
|
4413
4560
|
this.opts = opts;
|
|
4414
|
-
this.home =
|
|
4561
|
+
this.home = join10(opts.dataDir, "claude");
|
|
4415
4562
|
this.now = opts.now ?? Date.now;
|
|
4416
4563
|
this.bridge = new ToolBridge((call) => this.owner.inbox.push({ kind: "call", call }));
|
|
4417
4564
|
}
|
|
@@ -4641,7 +4788,7 @@ ${item.stderr}`;
|
|
|
4641
4788
|
"local",
|
|
4642
4789
|
"--system-prompt",
|
|
4643
4790
|
req.system,
|
|
4644
|
-
...conv.guest ? ["--no-session-persistence"] : ["--mcp-config",
|
|
4791
|
+
...conv.guest ? ["--no-session-persistence"] : ["--mcp-config", join10(this.home, "mcp.json"), "--allowedTools", req.tools.map((t) => `mcp__friend__${encodeToolName(t.name)}`).join(","), ...resume ? ["--resume", resume] : []]
|
|
4645
4792
|
];
|
|
4646
4793
|
const proc = new ClaudeProc(
|
|
4647
4794
|
{
|
|
@@ -4672,7 +4819,7 @@ ${item.stderr}`;
|
|
|
4672
4819
|
startBridge() {
|
|
4673
4820
|
this.bridgeReady ??= (async () => {
|
|
4674
4821
|
await this.bridge.start();
|
|
4675
|
-
const file =
|
|
4822
|
+
const file = join10(this.home, "mcp.json");
|
|
4676
4823
|
writeFileSync5(file, JSON.stringify({ mcpServers: { friend: { type: "http", url: this.bridge.url, headers: { Authorization: `Bearer ${this.bridge.token}` } } } }));
|
|
4677
4824
|
try {
|
|
4678
4825
|
chmodSync4(file, 384);
|
|
@@ -4682,10 +4829,10 @@ ${item.stderr}`;
|
|
|
4682
4829
|
return this.bridgeReady;
|
|
4683
4830
|
}
|
|
4684
4831
|
// ---- the session id, kept so a restart of Friend can pick the conversation up again ----
|
|
4685
|
-
sessionFile = () =>
|
|
4832
|
+
sessionFile = () => join10(this.home, "session-id");
|
|
4686
4833
|
readSession() {
|
|
4687
4834
|
try {
|
|
4688
|
-
return
|
|
4835
|
+
return existsSync7(this.sessionFile()) ? readFileSync4(this.sessionFile(), "utf8").trim() || null : null;
|
|
4689
4836
|
} catch {
|
|
4690
4837
|
return null;
|
|
4691
4838
|
}
|
|
@@ -5120,6 +5267,63 @@ var OllamaProvider = class {
|
|
|
5120
5267
|
}
|
|
5121
5268
|
};
|
|
5122
5269
|
|
|
5270
|
+
// ../server/src/tls.ts
|
|
5271
|
+
import { chmodSync as chmodSync5, existsSync as existsSync8, mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync6 } from "node:fs";
|
|
5272
|
+
import { networkInterfaces } from "node:os";
|
|
5273
|
+
import { isIP as isIP2 } from "node:net";
|
|
5274
|
+
import { join as join11 } from "node:path";
|
|
5275
|
+
import { generate } from "selfsigned";
|
|
5276
|
+
var DAYS = 825;
|
|
5277
|
+
var RENEW_BEFORE_MS = 30 * 24 * 36e5;
|
|
5278
|
+
function lanAddresses(ifaces = networkInterfaces) {
|
|
5279
|
+
const out = [];
|
|
5280
|
+
for (const list2 of Object.values(ifaces())) {
|
|
5281
|
+
for (const i of list2 ?? []) if (i.family === "IPv4" && !i.internal && !out.includes(i.address)) out.push(i.address);
|
|
5282
|
+
}
|
|
5283
|
+
return out;
|
|
5284
|
+
}
|
|
5285
|
+
function tlsHosts(extra = [], ifaces) {
|
|
5286
|
+
return [.../* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1", ...lanAddresses(ifaces), ...extra.map((h) => h.toLowerCase()).filter(Boolean)])];
|
|
5287
|
+
}
|
|
5288
|
+
async function ensureTlsCert(dataDir, extraHosts = [], ifaces, now = Date.now) {
|
|
5289
|
+
const dir = join11(dataDir, "tls");
|
|
5290
|
+
const keyFile = join11(dir, "key.pem");
|
|
5291
|
+
const certFile = join11(dir, "cert.pem");
|
|
5292
|
+
const metaFile = join11(dir, "meta.json");
|
|
5293
|
+
const hosts = tlsHosts(extraHosts, ifaces);
|
|
5294
|
+
if (existsSync8(keyFile) && existsSync8(certFile) && existsSync8(metaFile)) {
|
|
5295
|
+
try {
|
|
5296
|
+
const meta = JSON.parse(readFileSync5(metaFile, "utf8"));
|
|
5297
|
+
const covered = hosts.every((h) => meta.hosts.includes(h));
|
|
5298
|
+
if (covered && meta.expires - now() > RENEW_BEFORE_MS) return { key: readFileSync5(keyFile, "utf8"), cert: readFileSync5(certFile, "utf8") };
|
|
5299
|
+
} catch {
|
|
5300
|
+
}
|
|
5301
|
+
}
|
|
5302
|
+
const altNames = hosts.map((h) => isIP2(h) ? { type: 7, ip: h } : { type: 2, value: h });
|
|
5303
|
+
const pems = await generate([{ name: "commonName", value: "Friend" }], {
|
|
5304
|
+
keySize: 2048,
|
|
5305
|
+
notBeforeDate: new Date(now() - 24 * 36e5),
|
|
5306
|
+
// a day back, so a phone whose clock is a little slow still accepts it
|
|
5307
|
+
notAfterDate: new Date(now() + DAYS * 24 * 36e5),
|
|
5308
|
+
algorithm: "sha256",
|
|
5309
|
+
extensions: [
|
|
5310
|
+
{ name: "basicConstraints", cA: false },
|
|
5311
|
+
{ name: "keyUsage", digitalSignature: true, keyEncipherment: true },
|
|
5312
|
+
{ name: "extKeyUsage", serverAuth: true },
|
|
5313
|
+
{ name: "subjectAltName", altNames }
|
|
5314
|
+
]
|
|
5315
|
+
});
|
|
5316
|
+
mkdirSync7(dir, { recursive: true });
|
|
5317
|
+
writeFileSync6(keyFile, pems.private);
|
|
5318
|
+
writeFileSync6(certFile, pems.cert);
|
|
5319
|
+
writeFileSync6(metaFile, JSON.stringify({ hosts, expires: now() + DAYS * 24 * 36e5 }));
|
|
5320
|
+
try {
|
|
5321
|
+
chmodSync5(keyFile, 384);
|
|
5322
|
+
} catch {
|
|
5323
|
+
}
|
|
5324
|
+
return { key: pems.private, cert: pems.cert };
|
|
5325
|
+
}
|
|
5326
|
+
|
|
5123
5327
|
// ../server/src/voice/challenge.ts
|
|
5124
5328
|
import { randomUUID as randomUUID6 } from "node:crypto";
|
|
5125
5329
|
var Challenges = class {
|
|
@@ -5181,22 +5385,22 @@ var Challenges = class {
|
|
|
5181
5385
|
|
|
5182
5386
|
// ../server/src/voice/crypto.ts
|
|
5183
5387
|
import { createCipheriv, createDecipheriv, randomBytes as randomBytes3 } from "node:crypto";
|
|
5184
|
-
import { chmodSync as
|
|
5388
|
+
import { chmodSync as chmodSync6, existsSync as existsSync9, mkdirSync as mkdirSync8, readFileSync as readFileSync6, writeFileSync as writeFileSync7 } from "node:fs";
|
|
5185
5389
|
import { dirname as dirname2 } from "node:path";
|
|
5186
5390
|
var KEY_BYTES = 32;
|
|
5187
5391
|
var IV_BYTES = 12;
|
|
5188
5392
|
var TAG_BYTES = 16;
|
|
5189
5393
|
function loadOrCreateKey(path) {
|
|
5190
|
-
if (
|
|
5191
|
-
const key2 =
|
|
5394
|
+
if (existsSync9(path)) {
|
|
5395
|
+
const key2 = readFileSync6(path);
|
|
5192
5396
|
if (key2.length !== KEY_BYTES) throw new Error(`invalid voice key file (expected ${KEY_BYTES} bytes): ${path}`);
|
|
5193
5397
|
return key2;
|
|
5194
5398
|
}
|
|
5195
|
-
|
|
5399
|
+
mkdirSync8(dirname2(path), { recursive: true });
|
|
5196
5400
|
const key = randomBytes3(KEY_BYTES);
|
|
5197
|
-
|
|
5401
|
+
writeFileSync7(path, key, { mode: 384 });
|
|
5198
5402
|
try {
|
|
5199
|
-
|
|
5403
|
+
chmodSync6(path, 384);
|
|
5200
5404
|
} catch {
|
|
5201
5405
|
}
|
|
5202
5406
|
return key;
|
|
@@ -5614,7 +5818,7 @@ async function createApp(opts) {
|
|
|
5614
5818
|
const camera = new CameraBroker(opts.cameraTimeoutMs);
|
|
5615
5819
|
const memory = new Memory(db);
|
|
5616
5820
|
const tasks = new Tasks(db);
|
|
5617
|
-
const plugins = new Plugins(db,
|
|
5821
|
+
const plugins = new Plugins(db, join12(opts.dataDir, "plugins"));
|
|
5618
5822
|
const hub = new McpHub(new ConnectorStore(opts.dataDir));
|
|
5619
5823
|
const admin = makeAdmin({ hub, plugins, dataDir: opts.dataDir, audit: (what, detail) => audit.log("agent", what, detail) });
|
|
5620
5824
|
function commitSettings(patch, how) {
|
|
@@ -5720,7 +5924,7 @@ async function createApp(opts) {
|
|
|
5720
5924
|
const voice2 = opts.voice;
|
|
5721
5925
|
const speaker = voice2 ? new Speaker(voice2, audit, () => config.voice.ttsVoice) : null;
|
|
5722
5926
|
const voiceAvailable = () => !!voice2 && voice2.available();
|
|
5723
|
-
const voiceprint = voice2 ? new Voiceprint(db, loadOrCreateKey(
|
|
5927
|
+
const voiceprint = voice2 ? new Voiceprint(db, loadOrCreateKey(join12(opts.dataDir, "voice.key"))) : null;
|
|
5724
5928
|
const enrollment = voiceprint ? new Enrollment(voiceprint, SPEAKER_MODEL) : null;
|
|
5725
5929
|
const challenges = new Challenges((id2) => {
|
|
5726
5930
|
broadcast({ type: "voice.challenge.end", id: id2 });
|
|
@@ -5837,7 +6041,7 @@ async function createApp(opts) {
|
|
|
5837
6041
|
if (!isPluginId(id2)) throw new HttpError(400, "That is not a plugin name.");
|
|
5838
6042
|
if (!listCatalog(catalogDir).some((c) => c.id === id2)) throw new HttpError(404, "That plugin is not in the catalog.");
|
|
5839
6043
|
await requirePin(pin, ip);
|
|
5840
|
-
const r = plugins.install(
|
|
6044
|
+
const r = plugins.install(join12(catalogDir, id2));
|
|
5841
6045
|
if (!r.ok) throw new HttpError(400, `That plugin cannot be installed: ${r.error}`);
|
|
5842
6046
|
audit.log("owner", "plugin.installed", { id: r.id, upgraded: r.upgraded, ip });
|
|
5843
6047
|
return { id: r.id, upgraded: r.upgraded };
|
|
@@ -5892,6 +6096,24 @@ async function createApp(opts) {
|
|
|
5892
6096
|
throw new HttpError(404, e.message);
|
|
5893
6097
|
}
|
|
5894
6098
|
},
|
|
6099
|
+
quickCheck: (preset) => {
|
|
6100
|
+
const c = quickCheck(preset, { home: opts.quick?.home });
|
|
6101
|
+
if (!c) throw new HttpError(404, "That is not a one-click connector.");
|
|
6102
|
+
return c;
|
|
6103
|
+
},
|
|
6104
|
+
quickInstall: async (preset, pin, ip) => {
|
|
6105
|
+
if (!isQuickPreset(preset)) throw new HttpError(404, "That is not a one-click connector.");
|
|
6106
|
+
await requirePin(pin, ip);
|
|
6107
|
+
audit.log("owner", "connector.quick_install", { preset, ip });
|
|
6108
|
+
return quickInstall(preset, { hub, run: opts.quick?.run ?? runCommand, home: opts.quick?.home });
|
|
6109
|
+
},
|
|
6110
|
+
quickConnect: async (preset, pin, ip) => {
|
|
6111
|
+
if (!isQuickPreset(preset)) throw new HttpError(404, "That is not a one-click connector.");
|
|
6112
|
+
await requirePin(pin, ip);
|
|
6113
|
+
const result = await quickConnect(preset, { hub, run: opts.quick?.run ?? runCommand, home: opts.quick?.home });
|
|
6114
|
+
audit.log("owner", "connector.quick_connect", { preset, ok: result.ok, extension: result.extension, ip });
|
|
6115
|
+
return result;
|
|
6116
|
+
},
|
|
5895
6117
|
remove: async (id2) => {
|
|
5896
6118
|
try {
|
|
5897
6119
|
await hub.remove(id2);
|
|
@@ -5901,7 +6123,9 @@ async function createApp(opts) {
|
|
|
5901
6123
|
}
|
|
5902
6124
|
}
|
|
5903
6125
|
};
|
|
6126
|
+
const tls = config.https ? await ensureTlsCert(opts.dataDir, [...config.allowedHosts, ...config.publicUrl ? [new URL(config.publicUrl).hostname] : []]) : void 0;
|
|
5904
6127
|
const gateway = createGateway({
|
|
6128
|
+
...tls ? { tls } : {},
|
|
5905
6129
|
connectors: connectorsApi,
|
|
5906
6130
|
voicePreview: async (id2) => {
|
|
5907
6131
|
if (!voice2 || !voiceAvailable()) throw new HttpError(503, "Voice is not available. Run: friend voice setup");
|
|
@@ -6250,7 +6474,7 @@ var SidecarProcess = class {
|
|
|
6250
6474
|
import { createInterface as createInterface2 } from "node:readline";
|
|
6251
6475
|
|
|
6252
6476
|
// ../agent-cli/src/commands/config.ts
|
|
6253
|
-
import { isIP as
|
|
6477
|
+
import { isIP as isIP3 } from "node:net";
|
|
6254
6478
|
var list = (v) => v.split(",").map((s) => s.trim()).filter(Boolean);
|
|
6255
6479
|
var bool = (v) => {
|
|
6256
6480
|
if (v !== "true" && v !== "false") throw new Error("Expected true or false");
|
|
@@ -6280,10 +6504,11 @@ var PARSERS = {
|
|
|
6280
6504
|
model: (v) => v,
|
|
6281
6505
|
bind: (v) => {
|
|
6282
6506
|
const t = v.trim();
|
|
6283
|
-
if (t !== "localhost" &&
|
|
6507
|
+
if (t !== "localhost" && isIP3(t) === 0) throw new Error("bind must be an IP address such as 127.0.0.1 (this PC only) or 0.0.0.0 (also your network), or localhost. Easier: friend lan on");
|
|
6284
6508
|
return t;
|
|
6285
6509
|
},
|
|
6286
6510
|
publicUrl: (v) => v === "" ? null : v,
|
|
6511
|
+
https: bool,
|
|
6287
6512
|
port: (v) => {
|
|
6288
6513
|
const n = Number(v);
|
|
6289
6514
|
if (!Number.isInteger(n) || n < 0 || n > 65535) throw new Error("Port must be a number between 0 and 65535");
|
|
@@ -6336,6 +6561,7 @@ var KEY_HELP = {
|
|
|
6336
6561
|
provider: "which model answers: claude (API key), claude-code (your Claude subscription) or ollama (on this PC)",
|
|
6337
6562
|
bind: "address Friend listens on: 127.0.0.1 is this PC only, 0.0.0.0 is your whole network (easier: friend lan on)",
|
|
6338
6563
|
port: "the web port (easier: friend lan port N)",
|
|
6564
|
+
https: "true serves a secure https:// address (Friend makes its own certificate), so a phone may use the microphone (easier: friend lan https on)",
|
|
6339
6565
|
publicUrl: "the address people use when Friend is behind your own domain, or empty",
|
|
6340
6566
|
roots: "folders Friend may read and write freely, separated by commas",
|
|
6341
6567
|
allowedOrigins: "extra web addresses allowed to connect, separated by commas",
|
|
@@ -6400,8 +6626,8 @@ import { resolve as resolve6 } from "node:path";
|
|
|
6400
6626
|
import { WebSocket as WebSocket2 } from "ws";
|
|
6401
6627
|
|
|
6402
6628
|
// ../agent-cli/src/tools/fs.ts
|
|
6403
|
-
import { closeSync, mkdirSync as
|
|
6404
|
-
import { dirname as dirname5, join as
|
|
6629
|
+
import { closeSync, mkdirSync as mkdirSync9, openSync, readSync, readdirSync as readdirSync2, statSync, writeFileSync as writeFileSync8 } from "node:fs";
|
|
6630
|
+
import { dirname as dirname5, join as join13 } from "node:path";
|
|
6405
6631
|
|
|
6406
6632
|
// ../agent-cli/src/tools/roots.ts
|
|
6407
6633
|
import { lstatSync as lstatSync2, realpathSync } from "node:fs";
|
|
@@ -6463,7 +6689,7 @@ function fsList(args, ctx) {
|
|
|
6463
6689
|
if (e.isDirectory()) return `[dir] ${e.name}`;
|
|
6464
6690
|
let size = 0;
|
|
6465
6691
|
try {
|
|
6466
|
-
size = statSync(
|
|
6692
|
+
size = statSync(join13(g.abs, e.name)).size;
|
|
6467
6693
|
} catch {
|
|
6468
6694
|
}
|
|
6469
6695
|
return `[file] ${e.name} (${size} bytes)`;
|
|
@@ -6504,8 +6730,8 @@ function fsWrite(args, ctx) {
|
|
|
6504
6730
|
if ("error" in g) return { ok: false, error: g.error };
|
|
6505
6731
|
if (Buffer.byteLength(args.content) > MAX_WRITE) return { ok: false, error: "Content too large (5 MB limit)" };
|
|
6506
6732
|
try {
|
|
6507
|
-
|
|
6508
|
-
|
|
6733
|
+
mkdirSync9(dirname5(g.abs), { recursive: true });
|
|
6734
|
+
writeFileSync8(g.abs, args.content);
|
|
6509
6735
|
return { ok: true, output: `Wrote ${Buffer.byteLength(args.content)} bytes to ${g.abs}`, truncated: false };
|
|
6510
6736
|
} catch (e) {
|
|
6511
6737
|
return { ok: false, error: e.message };
|
|
@@ -6514,7 +6740,7 @@ function fsWrite(args, ctx) {
|
|
|
6514
6740
|
|
|
6515
6741
|
// ../agent-cli/src/tools/shell.ts
|
|
6516
6742
|
import { spawn as spawn4, spawnSync } from "node:child_process";
|
|
6517
|
-
function
|
|
6743
|
+
function killTree2(child) {
|
|
6518
6744
|
if (!child.pid) return;
|
|
6519
6745
|
if (process.platform === "win32") {
|
|
6520
6746
|
spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { windowsHide: true });
|
|
@@ -6526,7 +6752,7 @@ function killTree(child) {
|
|
|
6526
6752
|
}
|
|
6527
6753
|
}
|
|
6528
6754
|
}
|
|
6529
|
-
function
|
|
6755
|
+
function runCommand2(command, cwdArg, ctx, argv) {
|
|
6530
6756
|
let cwd = ctx.roots[0] ?? process.cwd();
|
|
6531
6757
|
if (cwdArg) {
|
|
6532
6758
|
const r = resolveInRoots(cwdArg, ctx.roots);
|
|
@@ -6559,7 +6785,7 @@ function runCommand(command, cwdArg, ctx, argv) {
|
|
|
6559
6785
|
child.stderr.on("data", take);
|
|
6560
6786
|
const timer = setTimeout(() => {
|
|
6561
6787
|
timedOut = true;
|
|
6562
|
-
|
|
6788
|
+
killTree2(child);
|
|
6563
6789
|
}, ctx.timeoutMs);
|
|
6564
6790
|
child.on("error", (e) => {
|
|
6565
6791
|
clearTimeout(timer);
|
|
@@ -6617,7 +6843,7 @@ async function systemInfo(sampleMs = 250) {
|
|
|
6617
6843
|
return { ok: true, output: lines.join("\n"), truncated: false };
|
|
6618
6844
|
}
|
|
6619
6845
|
function processList(ctx) {
|
|
6620
|
-
return process.platform === "win32" ?
|
|
6846
|
+
return process.platform === "win32" ? runCommand2("", void 0, ctx, { file: "tasklist.exe", args: ["/fo", "csv", "/nh"] }) : runCommand2("", void 0, ctx, { file: "ps", args: ["-eo", "pid,comm"] });
|
|
6621
6847
|
}
|
|
6622
6848
|
|
|
6623
6849
|
// ../agent-cli/src/tools/index.ts
|
|
@@ -6641,7 +6867,7 @@ async function runTool(name, args, ctx) {
|
|
|
6641
6867
|
case "process.list":
|
|
6642
6868
|
return await processList(ctx);
|
|
6643
6869
|
case "shell.run":
|
|
6644
|
-
return await
|
|
6870
|
+
return await runCommand2(a.command, a.cwd, ctx);
|
|
6645
6871
|
default:
|
|
6646
6872
|
return { ok: false, error: `Unknown tool: ${name}` };
|
|
6647
6873
|
}
|
|
@@ -6674,7 +6900,7 @@ function startDeviceNode(opts) {
|
|
|
6674
6900
|
function connect() {
|
|
6675
6901
|
if (stopped) return;
|
|
6676
6902
|
setState("connecting");
|
|
6677
|
-
const sock = new WebSocket2(opts.url, { headers: { authorization: `Bearer ${opts.token}` } });
|
|
6903
|
+
const sock = new WebSocket2(opts.url, { headers: { authorization: `Bearer ${opts.token}` }, ...opts.ca ? { ca: opts.ca } : {} });
|
|
6678
6904
|
ws = sock;
|
|
6679
6905
|
sock.on("error", () => {
|
|
6680
6906
|
});
|
|
@@ -6741,15 +6967,15 @@ function startDeviceNode(opts) {
|
|
|
6741
6967
|
|
|
6742
6968
|
// ../agent-cli/src/commands/start.ts
|
|
6743
6969
|
import { randomBytes as randomBytes4 } from "node:crypto";
|
|
6744
|
-
import { existsSync as
|
|
6745
|
-
import { dirname as dirname7, join as
|
|
6970
|
+
import { existsSync as existsSync11, readFileSync as readFileSync8, writeFileSync as writeFileSync10 } from "node:fs";
|
|
6971
|
+
import { dirname as dirname7, join as join15, resolve as resolve5 } from "node:path";
|
|
6746
6972
|
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
6747
6973
|
|
|
6748
6974
|
// ../agent-cli/src/commands/lan.ts
|
|
6749
|
-
import { networkInterfaces } from "node:os";
|
|
6975
|
+
import { networkInterfaces as networkInterfaces2 } from "node:os";
|
|
6750
6976
|
var LOOPBACK3 = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "::1"]);
|
|
6751
6977
|
var isLoopbackBind = (bind) => LOOPBACK3.has(bind);
|
|
6752
|
-
function
|
|
6978
|
+
function lanAddresses2(ifaces = networkInterfaces2) {
|
|
6753
6979
|
const out = [];
|
|
6754
6980
|
for (const list2 of Object.values(ifaces())) {
|
|
6755
6981
|
for (const i of list2 ?? []) {
|
|
@@ -6758,11 +6984,12 @@ function lanAddresses(ifaces = networkInterfaces) {
|
|
|
6758
6984
|
}
|
|
6759
6985
|
return out;
|
|
6760
6986
|
}
|
|
6761
|
-
function urlsFor(bind, port, ifaces =
|
|
6762
|
-
const
|
|
6987
|
+
function urlsFor(bind, port, ifaces = networkInterfaces2, https = false) {
|
|
6988
|
+
const scheme = https ? "https" : "http";
|
|
6989
|
+
const local = `${scheme}://127.0.0.1:${port}`;
|
|
6763
6990
|
if (isLoopbackBind(bind)) return { local, network: [] };
|
|
6764
|
-
if (bind === "0.0.0.0" || bind === "::") return { local, network:
|
|
6765
|
-
return { local, network: [
|
|
6991
|
+
if (bind === "0.0.0.0" || bind === "::") return { local, network: lanAddresses2(ifaces).map((a) => `${scheme}://${a}:${port}`) };
|
|
6992
|
+
return { local, network: [`${scheme}://${bind}:${port}`] };
|
|
6766
6993
|
}
|
|
6767
6994
|
var PORT_HELP = "The port must be a whole number from 1024 to 65535.";
|
|
6768
6995
|
function parsePort(v) {
|
|
@@ -6770,34 +6997,58 @@ function parsePort(v) {
|
|
|
6770
6997
|
if (v === void 0 || v.trim() === "" || !Number.isInteger(n) || n < 1024 || n > 65535) throw new Error(PORT_HELP);
|
|
6771
6998
|
return n;
|
|
6772
6999
|
}
|
|
6773
|
-
function show(io2, bind, port, ifaces) {
|
|
6774
|
-
const u = urlsFor(bind, port, ifaces);
|
|
7000
|
+
function show(io2, bind, port, ifaces, https = false) {
|
|
7001
|
+
const u = urlsFor(bind, port, ifaces, https);
|
|
6775
7002
|
io2.out(`Listens on: ${bind}:${port}`);
|
|
6776
7003
|
io2.out(`On this PC: ${u.local}`);
|
|
6777
7004
|
if (u.network.length) for (const n of u.network) io2.out(`On your network: ${n}`);
|
|
6778
7005
|
else if (isLoopbackBind(bind)) io2.out("On your network: not reachable (only this PC). Turn it on with: friend lan on");
|
|
6779
7006
|
else io2.out("On your network: no network address found. Check that this PC is connected.");
|
|
6780
7007
|
}
|
|
6781
|
-
async function lanCommand(args, io2, ifaces =
|
|
7008
|
+
async function lanCommand(args, io2, ifaces = networkInterfaces2) {
|
|
6782
7009
|
const [sub, ...rest] = args;
|
|
6783
7010
|
const cfg = loadConfig(io2.dataDir);
|
|
6784
7011
|
switch (sub) {
|
|
6785
7012
|
case void 0:
|
|
6786
7013
|
case "status":
|
|
6787
|
-
show(io2, cfg.bind, cfg.port, ifaces);
|
|
7014
|
+
show(io2, cfg.bind, cfg.port, ifaces, cfg.https);
|
|
7015
|
+
io2.out(cfg.https ? "Secure (https): on. A phone can use the microphone and camera." : "Secure (https): off. A phone cannot use the microphone or camera. Turn it on with: friend lan https on");
|
|
6788
7016
|
return 0;
|
|
7017
|
+
case "https": {
|
|
7018
|
+
if (rest[0] !== "on" && rest[0] !== "off") {
|
|
7019
|
+
io2.err("Usage: friend lan https on | off");
|
|
7020
|
+
return 1;
|
|
7021
|
+
}
|
|
7022
|
+
cfg.https = rest[0] === "on";
|
|
7023
|
+
saveConfig(cfg);
|
|
7024
|
+
show(io2, cfg.bind, cfg.port, ifaces, cfg.https);
|
|
7025
|
+
io2.out("");
|
|
7026
|
+
io2.out("Restart Friend to apply (stop it, then: friend start).");
|
|
7027
|
+
if (cfg.https) {
|
|
7028
|
+
io2.out("Friend makes its own security certificate, so the first time a phone opens the https:// address it shows a warning:");
|
|
7029
|
+
io2.out(" Android Chrome: Advanced, then Proceed. iPhone Safari: Show Details, then visit this website.");
|
|
7030
|
+
io2.out("After that, the microphone and camera work from the phone. Only that address is affected; nothing is installed on the phone.");
|
|
7031
|
+
if (isLoopbackBind(cfg.bind)) io2.out("Friend is still only reachable from this PC. To open it from a phone too: friend lan on");
|
|
7032
|
+
}
|
|
7033
|
+
return 0;
|
|
7034
|
+
}
|
|
6789
7035
|
case "on": {
|
|
6790
7036
|
const portFlag = rest.indexOf("--port");
|
|
6791
7037
|
if (portFlag >= 0) cfg.port = parsePort(rest[portFlag + 1]);
|
|
6792
7038
|
cfg.bind = "0.0.0.0";
|
|
7039
|
+
if (rest.includes("--https")) cfg.https = true;
|
|
6793
7040
|
saveConfig(cfg);
|
|
6794
|
-
show(io2, cfg.bind, cfg.port, ifaces);
|
|
7041
|
+
show(io2, cfg.bind, cfg.port, ifaces, cfg.https);
|
|
6795
7042
|
io2.out("");
|
|
6796
7043
|
io2.out("Restart Friend to apply (stop it, then: friend start).");
|
|
6797
7044
|
io2.out("Things to know:");
|
|
6798
7045
|
io2.out(" - Anyone on your network can open the sign-in page. Your password and PIN still guard everything; use a strong password.");
|
|
6799
|
-
|
|
6800
|
-
|
|
7046
|
+
if (cfg.https) {
|
|
7047
|
+
io2.out(" - Secure (https) is on. The first time a phone opens the address it shows a certificate warning (Advanced, then Proceed); after that the microphone works.");
|
|
7048
|
+
} else {
|
|
7049
|
+
io2.out(" - The connection is plain http: other people on the network could read it. Use it only on a network you trust.");
|
|
7050
|
+
io2.out(" - Browsers allow the microphone and camera only on localhost or HTTPS, so voice and camera will not work from a phone over plain http. Fix: friend lan https on");
|
|
7051
|
+
}
|
|
6801
7052
|
io2.out(" - Windows may ask to allow Node.js through the firewall the first time: allow it on Private networks.");
|
|
6802
7053
|
io2.out(" - Going to a name such as http://my-pc:8787 needs: friend config set allowedHosts my-pc");
|
|
6803
7054
|
return 0;
|
|
@@ -6805,41 +7056,41 @@ async function lanCommand(args, io2, ifaces = networkInterfaces) {
|
|
|
6805
7056
|
case "off":
|
|
6806
7057
|
cfg.bind = "127.0.0.1";
|
|
6807
7058
|
saveConfig(cfg);
|
|
6808
|
-
show(io2, cfg.bind, cfg.port, ifaces);
|
|
7059
|
+
show(io2, cfg.bind, cfg.port, ifaces, cfg.https);
|
|
6809
7060
|
io2.out("Restart Friend to apply.");
|
|
6810
7061
|
return 0;
|
|
6811
7062
|
case "port": {
|
|
6812
7063
|
cfg.port = parsePort(rest[0]);
|
|
6813
7064
|
saveConfig(cfg);
|
|
6814
|
-
show(io2, cfg.bind, cfg.port, ifaces);
|
|
7065
|
+
show(io2, cfg.bind, cfg.port, ifaces, cfg.https);
|
|
6815
7066
|
io2.out("Restart Friend to apply.");
|
|
6816
7067
|
return 0;
|
|
6817
7068
|
}
|
|
6818
7069
|
default:
|
|
6819
|
-
io2.err("Usage: friend lan status | on [--port N] | off | port N");
|
|
7070
|
+
io2.err("Usage: friend lan status | on [--port N] [--https] | off | port N | https on|off");
|
|
6820
7071
|
return 1;
|
|
6821
7072
|
}
|
|
6822
7073
|
}
|
|
6823
7074
|
|
|
6824
7075
|
// ../agent-cli/src/commands/voice.ts
|
|
6825
7076
|
import { spawn as spawn5 } from "node:child_process";
|
|
6826
|
-
import { existsSync as
|
|
6827
|
-
import { dirname as dirname6, join as
|
|
7077
|
+
import { existsSync as existsSync10, mkdirSync as mkdirSync10, readFileSync as readFileSync7, writeFileSync as writeFileSync9 } from "node:fs";
|
|
7078
|
+
import { dirname as dirname6, join as join14, resolve as resolve4 } from "node:path";
|
|
6828
7079
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
6829
7080
|
function voicePaths(dataDir) {
|
|
6830
|
-
const home =
|
|
6831
|
-
const venv =
|
|
7081
|
+
const home = join14(dataDir, "voice");
|
|
7082
|
+
const venv = join14(home, "venv");
|
|
6832
7083
|
return {
|
|
6833
7084
|
home,
|
|
6834
7085
|
venv,
|
|
6835
|
-
python: process.platform === "win32" ?
|
|
6836
|
-
models:
|
|
6837
|
-
marker:
|
|
7086
|
+
python: process.platform === "win32" ? join14(venv, "Scripts", "python.exe") : join14(venv, "bin", "python"),
|
|
7087
|
+
models: join14(home, "models"),
|
|
7088
|
+
marker: join14(home, "installed.json")
|
|
6838
7089
|
};
|
|
6839
7090
|
}
|
|
6840
7091
|
function readMarker(dataDir) {
|
|
6841
7092
|
try {
|
|
6842
|
-
return JSON.parse(
|
|
7093
|
+
return JSON.parse(readFileSync7(voicePaths(dataDir).marker, "utf8"));
|
|
6843
7094
|
} catch {
|
|
6844
7095
|
return null;
|
|
6845
7096
|
}
|
|
@@ -6936,8 +7187,8 @@ async function voiceSetupCommand(io2, deps = {}, args = []) {
|
|
|
6936
7187
|
io2.err("Python 3.10 or newer is required. Install it from https://www.python.org/downloads/ and run this again.");
|
|
6937
7188
|
return 1;
|
|
6938
7189
|
}
|
|
6939
|
-
|
|
6940
|
-
if (!
|
|
7190
|
+
mkdirSync10(p.home, { recursive: true });
|
|
7191
|
+
if (!existsSync10(p.python)) {
|
|
6941
7192
|
io2.out("1/3 Creating a private Python environment\u2026");
|
|
6942
7193
|
const r = await run2(py.cmd, [...py.args, "-m", "venv", p.venv], { onLine });
|
|
6943
7194
|
if (r.code !== 0) {
|
|
@@ -6963,7 +7214,7 @@ ${tail(dl.stdout)}`);
|
|
|
6963
7214
|
return 1;
|
|
6964
7215
|
}
|
|
6965
7216
|
const marker = { python: p.python, sttModel: cfg.voice.sttModel, ttsVoice: cfg.voice.ttsVoice, at: Date.now() };
|
|
6966
|
-
|
|
7217
|
+
writeFileSync9(p.marker, JSON.stringify(marker, null, 2));
|
|
6967
7218
|
io2.out("Voice is ready. Start Friend with `friend start`, then hold Space to talk.");
|
|
6968
7219
|
return 0;
|
|
6969
7220
|
}
|
|
@@ -6979,7 +7230,7 @@ async function voiceStatusCommand(io2) {
|
|
|
6979
7230
|
io2.out("Voice engine: not installed. Run: friend voice setup");
|
|
6980
7231
|
return 0;
|
|
6981
7232
|
}
|
|
6982
|
-
if (!
|
|
7233
|
+
if (!existsSync10(marker.python)) {
|
|
6983
7234
|
io2.out("Voice engine: installed, but its Python is missing. Run: friend voice setup");
|
|
6984
7235
|
return 0;
|
|
6985
7236
|
}
|
|
@@ -7052,7 +7303,7 @@ async function voiceCommand(args, io2) {
|
|
|
7052
7303
|
function findWebDir(explicit) {
|
|
7053
7304
|
const here = dirname7(fileURLToPath3(import.meta.url));
|
|
7054
7305
|
const candidates = [explicit, process.env.FRIEND_WEB_DIR, resolve5(here, "../../../web/dist"), resolve5(here, "web")];
|
|
7055
|
-
return candidates.find((c) => !!c &&
|
|
7306
|
+
return candidates.find((c) => !!c && existsSync11(join15(c, "index.html")));
|
|
7056
7307
|
}
|
|
7057
7308
|
async function startFriend(opts) {
|
|
7058
7309
|
const stored = loadConfig(opts.dataDir);
|
|
@@ -7086,18 +7337,20 @@ async function startFriend(opts) {
|
|
|
7086
7337
|
}
|
|
7087
7338
|
});
|
|
7088
7339
|
const { port } = await app.start();
|
|
7089
|
-
const tokenFile =
|
|
7090
|
-
let token =
|
|
7340
|
+
const tokenFile = join15(opts.dataDir, "local-device.token");
|
|
7341
|
+
let token = existsSync11(tokenFile) ? readFileSync8(tokenFile, "utf8").trim() : "";
|
|
7091
7342
|
if (!token || !app.auth.resolveDevice(token)) {
|
|
7092
7343
|
token = app.auth.redeemPairCode(app.auth.createPairCode(), "this-pc");
|
|
7093
|
-
|
|
7344
|
+
writeFileSync10(tokenFile, token, { mode: 384 });
|
|
7094
7345
|
}
|
|
7095
7346
|
const node = startDeviceNode({
|
|
7096
|
-
url:
|
|
7347
|
+
url: `${app.config.https ? "wss" : "ws"}://127.0.0.1:${port}/device`,
|
|
7097
7348
|
token,
|
|
7098
|
-
roots: app.config.roots
|
|
7349
|
+
roots: app.config.roots,
|
|
7350
|
+
...app.config.https ? { ca: readFileSync8(join15(opts.dataDir, "tls", "cert.pem"), "utf8") } : {}
|
|
7351
|
+
// this PC trusts only its own Friend's certificate
|
|
7099
7352
|
});
|
|
7100
|
-
const urls = urlsFor(app.config.bind, port);
|
|
7353
|
+
const urls = urlsFor(app.config.bind, port, void 0, app.config.https);
|
|
7101
7354
|
opts.out(`Friend is running at ${urls.local}`);
|
|
7102
7355
|
for (const n of urls.network) opts.out(`Also on your network: ${n}`);
|
|
7103
7356
|
if (!app.auth.hasOwner()) opts.out("No owner yet. Run: friend onboard (guided), or friend setup (just the account)");
|
|
@@ -7322,8 +7575,8 @@ async function memoryCommand(args, io2) {
|
|
|
7322
7575
|
}
|
|
7323
7576
|
|
|
7324
7577
|
// ../agent-cli/src/commands/plugin.ts
|
|
7325
|
-
import { existsSync as
|
|
7326
|
-
import { dirname as dirname8, join as
|
|
7578
|
+
import { existsSync as existsSync12, readdirSync as readdirSync3, statSync as statSync2 } from "node:fs";
|
|
7579
|
+
import { dirname as dirname8, join as join16, resolve as resolve7 } from "node:path";
|
|
7327
7580
|
import { fileURLToPath as fileURLToPath4 } from "node:url";
|
|
7328
7581
|
var USAGE2 = "Usage: friend plugin catalog | list | install <name|folder> [--yes] | enable <id> | disable <id> | remove <id>";
|
|
7329
7582
|
function defaultCatalog() {
|
|
@@ -7337,10 +7590,10 @@ async function pluginCommand(args, io2, deps = {}) {
|
|
|
7337
7590
|
const flags = rest.filter((a) => a.startsWith("--"));
|
|
7338
7591
|
const arg = rest.find((a) => !a.startsWith("--"));
|
|
7339
7592
|
if (sub === "catalog") {
|
|
7340
|
-
const dirs =
|
|
7593
|
+
const dirs = existsSync12(catalogDir) ? readdirSync3(catalogDir).filter((d) => statSync2(join16(catalogDir, d)).isDirectory()) : [];
|
|
7341
7594
|
if (dirs.length === 0) io2.out("The catalog is empty.");
|
|
7342
7595
|
for (const d of dirs.sort()) {
|
|
7343
|
-
const r = inspectPlugin(
|
|
7596
|
+
const r = inspectPlugin(join16(catalogDir, d));
|
|
7344
7597
|
if (!r.ok) {
|
|
7345
7598
|
io2.out(`${d} (invalid: ${r.error})`);
|
|
7346
7599
|
continue;
|
|
@@ -7356,7 +7609,7 @@ async function pluginCommand(args, io2, deps = {}) {
|
|
|
7356
7609
|
}
|
|
7357
7610
|
const db = openDb(io2.dataDir);
|
|
7358
7611
|
try {
|
|
7359
|
-
const plugins = new Plugins(db,
|
|
7612
|
+
const plugins = new Plugins(db, join16(io2.dataDir, "plugins"));
|
|
7360
7613
|
if (sub === "list") {
|
|
7361
7614
|
const all = plugins.list();
|
|
7362
7615
|
if (all.length === 0) io2.out("No plugins installed. See: friend plugin catalog");
|
|
@@ -7370,8 +7623,8 @@ async function pluginCommand(args, io2, deps = {}) {
|
|
|
7370
7623
|
io2.err(USAGE2);
|
|
7371
7624
|
return 1;
|
|
7372
7625
|
}
|
|
7373
|
-
const inCatalog = !/[\\/]/.test(arg) &&
|
|
7374
|
-
const source = inCatalog ?
|
|
7626
|
+
const inCatalog = !/[\\/]/.test(arg) && existsSync12(join16(catalogDir, arg));
|
|
7627
|
+
const source = inCatalog ? join16(catalogDir, arg) : existsSync12(resolve7(arg)) ? resolve7(arg) : null;
|
|
7375
7628
|
if (!source) {
|
|
7376
7629
|
io2.err(`"${arg}" is not in the catalog and there is no such folder. See: friend plugin catalog`);
|
|
7377
7630
|
return 1;
|
|
@@ -7425,8 +7678,8 @@ async function pluginCommand(args, io2, deps = {}) {
|
|
|
7425
7678
|
}
|
|
7426
7679
|
|
|
7427
7680
|
// ../agent-cli/src/commands/onboard/index.ts
|
|
7428
|
-
import
|
|
7429
|
-
import { mkdirSync as
|
|
7681
|
+
import crossSpawn4 from "cross-spawn";
|
|
7682
|
+
import { mkdirSync as mkdirSync12 } from "node:fs";
|
|
7430
7683
|
import { resolve as resolve8 } from "node:path";
|
|
7431
7684
|
|
|
7432
7685
|
// ../agent-cli/src/commands/ollama.ts
|
|
@@ -7594,13 +7847,13 @@ async function ollamaCommand(args, io2, deps = {}) {
|
|
|
7594
7847
|
}
|
|
7595
7848
|
|
|
7596
7849
|
// ../agent-cli/src/commands/onboard/exec.ts
|
|
7597
|
-
import
|
|
7850
|
+
import crossSpawn3 from "cross-spawn";
|
|
7598
7851
|
var CHECK_TIMEOUT_MS = 2e4;
|
|
7599
7852
|
var capture = (cmd, args, opts) => new Promise((resolve10) => {
|
|
7600
7853
|
let stdout = "";
|
|
7601
7854
|
let child;
|
|
7602
7855
|
try {
|
|
7603
|
-
child =
|
|
7856
|
+
child = crossSpawn3(cmd, args, { cwd: opts?.cwd, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
7604
7857
|
} catch (e) {
|
|
7605
7858
|
resolve10({ code: 127, stdout: e.message });
|
|
7606
7859
|
return;
|
|
@@ -7627,7 +7880,7 @@ var capture = (cmd, args, opts) => new Promise((resolve10) => {
|
|
|
7627
7880
|
var interactive = (cmd, args) => new Promise((resolve10) => {
|
|
7628
7881
|
let child;
|
|
7629
7882
|
try {
|
|
7630
|
-
child =
|
|
7883
|
+
child = crossSpawn3(cmd, args, { stdio: "inherit" });
|
|
7631
7884
|
} catch {
|
|
7632
7885
|
resolve10(127);
|
|
7633
7886
|
return;
|
|
@@ -7749,16 +8002,16 @@ function installPlan(id2, platform, have) {
|
|
|
7749
8002
|
}
|
|
7750
8003
|
|
|
7751
8004
|
// ../agent-cli/src/commands/onboard/state.ts
|
|
7752
|
-
import { existsSync as
|
|
7753
|
-
import { dirname as dirname9, join as
|
|
8005
|
+
import { existsSync as existsSync13, mkdirSync as mkdirSync11, readFileSync as readFileSync9, renameSync as renameSync3, rmSync as rmSync5, writeFileSync as writeFileSync11 } from "node:fs";
|
|
8006
|
+
import { dirname as dirname9, join as join17 } from "node:path";
|
|
7754
8007
|
var FEATURES = ["claude-code", "claude-api", "ollama", "voice", "sandbox"];
|
|
7755
8008
|
var emptyState = () => ({ version: 1, done: {}, features: [], restart: [] });
|
|
7756
|
-
var statePath = (dataDir) =>
|
|
8009
|
+
var statePath = (dataDir) => join17(dataDir, "onboarding.json");
|
|
7757
8010
|
function loadState(dataDir) {
|
|
7758
8011
|
const f = statePath(dataDir);
|
|
7759
|
-
if (!
|
|
8012
|
+
if (!existsSync13(f)) return emptyState();
|
|
7760
8013
|
try {
|
|
7761
|
-
const raw = JSON.parse(
|
|
8014
|
+
const raw = JSON.parse(readFileSync9(f, "utf8"));
|
|
7762
8015
|
const done = raw.done && typeof raw.done === "object" ? Object.fromEntries(Object.entries(raw.done).filter(([, v]) => typeof v === "string")) : {};
|
|
7763
8016
|
const features2 = Array.isArray(raw.features) ? raw.features.filter((x) => FEATURES.includes(x)) : [];
|
|
7764
8017
|
const restart = Array.isArray(raw.restart) ? raw.restart.filter((x) => typeof x === "string") : [];
|
|
@@ -7769,9 +8022,9 @@ function loadState(dataDir) {
|
|
|
7769
8022
|
}
|
|
7770
8023
|
function saveState(dataDir, state) {
|
|
7771
8024
|
const f = statePath(dataDir);
|
|
7772
|
-
|
|
8025
|
+
mkdirSync11(dirname9(f), { recursive: true });
|
|
7773
8026
|
const tmp = `${f}.tmp`;
|
|
7774
|
-
|
|
8027
|
+
writeFileSync11(tmp, JSON.stringify(state, null, 2));
|
|
7775
8028
|
renameSync3(tmp, f);
|
|
7776
8029
|
}
|
|
7777
8030
|
function resetState(dataDir) {
|
|
@@ -7860,7 +8113,7 @@ function defaultOnboardDeps() {
|
|
|
7860
8113
|
openUrl: async (url) => {
|
|
7861
8114
|
const [cmd, args] = process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
|
|
7862
8115
|
try {
|
|
7863
|
-
|
|
8116
|
+
crossSpawn4(cmd, args, { stdio: "ignore", detached: true }).unref();
|
|
7864
8117
|
} catch {
|
|
7865
8118
|
}
|
|
7866
8119
|
},
|
|
@@ -8127,7 +8380,7 @@ async function workspace(c) {
|
|
|
8127
8380
|
const answer = await text(c.ui, current ? "Workspace folder" : "Workspace folder (Enter to skip)", current ?? "");
|
|
8128
8381
|
if (answer) {
|
|
8129
8382
|
const abs = resolve8(answer);
|
|
8130
|
-
|
|
8383
|
+
mkdirSync12(abs, { recursive: true });
|
|
8131
8384
|
cfg.roots = [abs];
|
|
8132
8385
|
saveConfig(cfg);
|
|
8133
8386
|
io2.out(`Workspace: ${abs}`);
|
|
@@ -8161,11 +8414,12 @@ async function network(c) {
|
|
|
8161
8414
|
}
|
|
8162
8415
|
cfg.port = port;
|
|
8163
8416
|
cfg.bind = where === "lan" ? "0.0.0.0" : "127.0.0.1";
|
|
8417
|
+
cfg.https = where === "lan" ? await yesNo(c.ui, "Use a secure (https) address so a phone can use the microphone? Your phone will show a one-time certificate warning.", true) : false;
|
|
8164
8418
|
saveConfig(cfg);
|
|
8165
|
-
const u = urlsFor(cfg.bind, cfg.port);
|
|
8419
|
+
const u = urlsFor(cfg.bind, cfg.port, void 0, cfg.https);
|
|
8166
8420
|
io2.out(`Friend will be at ${u.local}`);
|
|
8167
8421
|
for (const n of u.network) io2.out(`and on your network at ${n}`);
|
|
8168
|
-
if (where === "lan") io2.out("Note: the microphone and camera only work on this PC (or over HTTPS). Everything else works from the other devices.");
|
|
8422
|
+
if (where === "lan" && !cfg.https) io2.out("Note: the microphone and camera only work on this PC (or over HTTPS). Everything else works from the other devices. Later: friend lan https on");
|
|
8169
8423
|
return "done";
|
|
8170
8424
|
}
|
|
8171
8425
|
async function look(c) {
|
|
@@ -8184,7 +8438,7 @@ async function look(c) {
|
|
|
8184
8438
|
return "done";
|
|
8185
8439
|
}
|
|
8186
8440
|
function summary(cfg, c) {
|
|
8187
|
-
const u = urlsFor(cfg.bind, cfg.port);
|
|
8441
|
+
const u = urlsFor(cfg.bind, cfg.port, void 0, cfg.https);
|
|
8188
8442
|
const db = openDb(c.io.dataDir);
|
|
8189
8443
|
let owner = "not created yet";
|
|
8190
8444
|
try {
|
|
@@ -8214,7 +8468,7 @@ async function finish(c) {
|
|
|
8214
8468
|
io2.out("Start it whenever you like with: friend start");
|
|
8215
8469
|
return "done";
|
|
8216
8470
|
}
|
|
8217
|
-
const url = urlsFor(cfg.bind, cfg.port).local;
|
|
8471
|
+
const url = urlsFor(cfg.bind, cfg.port, void 0, cfg.https).local;
|
|
8218
8472
|
if (await yesNo(c.ui, "Open it in your browser when it is up?", true)) setTimeout(() => void deps.openUrl(url), 2500);
|
|
8219
8473
|
io2.out("Starting Friend. Press Ctrl+C to stop it.");
|
|
8220
8474
|
c.state.done.finish = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -8364,7 +8618,7 @@ async function claudeCommand(args, io2, deps = {}) {
|
|
|
8364
8618
|
}
|
|
8365
8619
|
|
|
8366
8620
|
// ../agent-cli/src/commands/setup.ts
|
|
8367
|
-
import { mkdirSync as
|
|
8621
|
+
import { mkdirSync as mkdirSync13 } from "node:fs";
|
|
8368
8622
|
import { resolve as resolve9 } from "node:path";
|
|
8369
8623
|
async function setupCommand3(io2) {
|
|
8370
8624
|
const db = openDb(io2.dataDir);
|
|
@@ -8390,7 +8644,7 @@ async function setupCommand3(io2) {
|
|
|
8390
8644
|
const cfg = loadConfig(io2.dataDir);
|
|
8391
8645
|
if (workspace2) {
|
|
8392
8646
|
const abs = resolve9(workspace2);
|
|
8393
|
-
|
|
8647
|
+
mkdirSync13(abs, { recursive: true });
|
|
8394
8648
|
cfg.roots = [abs];
|
|
8395
8649
|
}
|
|
8396
8650
|
if (apiKey) cfg.apiKey = apiKey;
|
|
@@ -8417,7 +8671,7 @@ async function statusCommand3(io2) {
|
|
|
8417
8671
|
io2.out(`Data folder: ${cfg.dataDir}`);
|
|
8418
8672
|
io2.out(`Owner set up: ${owner}`);
|
|
8419
8673
|
io2.out(`Listens on: ${cfg.bind}:${cfg.port}`);
|
|
8420
|
-
for (const n of urlsFor(cfg.bind, cfg.port).network) io2.out(`Network URL: ${n}`);
|
|
8674
|
+
for (const n of urlsFor(cfg.bind, cfg.port, void 0, cfg.https).network) io2.out(`Network URL: ${n}`);
|
|
8421
8675
|
io2.out(`Public URL: ${cfg.publicUrl ?? "(none)"}`);
|
|
8422
8676
|
io2.out(`Workspace: ${cfg.roots.length ? cfg.roots.join(", ") : "(none, every file action asks first)"}`);
|
|
8423
8677
|
io2.out(`Model: ${cfg.model}`);
|
|
@@ -8471,7 +8725,7 @@ async function run(argv, io2) {
|
|
|
8471
8725
|
case "version":
|
|
8472
8726
|
case "--version":
|
|
8473
8727
|
case "-v":
|
|
8474
|
-
io2.out(true ? "0.1.
|
|
8728
|
+
io2.out(true ? "0.1.2" : "development");
|
|
8475
8729
|
return 0;
|
|
8476
8730
|
case "lan":
|
|
8477
8731
|
return await lanCommand(rest, io2);
|