friend-assistant 0.1.1 → 0.1.3
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
|
@@ -1269,8 +1269,8 @@ function repairDangling(db) {
|
|
|
1269
1269
|
}
|
|
1270
1270
|
function loadHistory(db) {
|
|
1271
1271
|
const msgs = loadAll(db, WINDOW);
|
|
1272
|
-
const
|
|
1273
|
-
return
|
|
1272
|
+
const start2 = msgs.findIndex((m) => m.role === "user" && m.content.some((b) => b.type === "text"));
|
|
1273
|
+
return start2 === -1 ? [] : msgs.slice(start2);
|
|
1274
1274
|
}
|
|
1275
1275
|
|
|
1276
1276
|
// ../server/src/agent/prompt.ts
|
|
@@ -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,16 +3612,16 @@ 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";
|
|
3490
3619
|
|
|
3491
3620
|
// ../server/src/http-error.ts
|
|
3492
3621
|
var HttpError = class extends Error {
|
|
3493
|
-
constructor(
|
|
3622
|
+
constructor(status2, message) {
|
|
3494
3623
|
super(message);
|
|
3495
|
-
this.status =
|
|
3624
|
+
this.status = status2;
|
|
3496
3625
|
}
|
|
3497
3626
|
status;
|
|
3498
3627
|
};
|
|
@@ -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");
|
|
@@ -3928,6 +4075,7 @@ function createGateway(deps) {
|
|
|
3928
4075
|
const fwd = config.trustProxy ? String(req.headers["x-forwarded-for"] ?? "").split(",").at(-1)?.trim() : "";
|
|
3929
4076
|
return fwd || req.socket.remoteAddress || "unknown";
|
|
3930
4077
|
};
|
|
4078
|
+
let activeOwner = null;
|
|
3931
4079
|
function handleClient(ws, req) {
|
|
3932
4080
|
if (!originAllowed(req)) {
|
|
3933
4081
|
audit.log("anonymous", "ws.origin_refused", { origin: req.headers.origin });
|
|
@@ -3936,6 +4084,19 @@ function createGateway(deps) {
|
|
|
3936
4084
|
const token = parseCookies(req.headers.cookie)[COOKIE_NAME];
|
|
3937
4085
|
const session = token ? auth.resolveSession(token) : null;
|
|
3938
4086
|
if (!session) return ws.close(4401, "unauthorized");
|
|
4087
|
+
const params = new URL(req.url ?? "/", "http://localhost").searchParams;
|
|
4088
|
+
const tab = (params.get("tab") ?? "").slice(0, 64);
|
|
4089
|
+
if (tab) {
|
|
4090
|
+
if (activeOwner && activeOwner.ws.readyState === WebSocket.OPEN) {
|
|
4091
|
+
if (params.get("takeover") !== "1" && activeOwner.tab !== tab) return ws.close(4409, "in use elsewhere");
|
|
4092
|
+
activeOwner.ws.close(4410, "replaced");
|
|
4093
|
+
}
|
|
4094
|
+
const mine = { ws, tab };
|
|
4095
|
+
activeOwner = mine;
|
|
4096
|
+
ws.on("close", () => {
|
|
4097
|
+
if (activeOwner === mine) activeOwner = null;
|
|
4098
|
+
});
|
|
4099
|
+
}
|
|
3939
4100
|
const conn = {
|
|
3940
4101
|
ip: clientIp(req),
|
|
3941
4102
|
send: (m) => {
|
|
@@ -3946,6 +4107,7 @@ function createGateway(deps) {
|
|
|
3946
4107
|
const dispose = deps.onClientConnect?.(session, conn);
|
|
3947
4108
|
ws.on("close", () => dispose?.());
|
|
3948
4109
|
ws.on("message", (data) => {
|
|
4110
|
+
if (tab && activeOwner?.ws !== ws) return;
|
|
3949
4111
|
let json;
|
|
3950
4112
|
try {
|
|
3951
4113
|
json = JSON.parse(String(data));
|
|
@@ -4125,8 +4287,8 @@ var ClaudeProvider = class {
|
|
|
4125
4287
|
|
|
4126
4288
|
// ../server/src/provider/claude-code/provider.ts
|
|
4127
4289
|
import { createHash as createHash3 } from "node:crypto";
|
|
4128
|
-
import { chmodSync as chmodSync4, existsSync as
|
|
4129
|
-
import { join as
|
|
4290
|
+
import { chmodSync as chmodSync4, existsSync as existsSync7, mkdirSync as mkdirSync6, readFileSync as readFileSync4, rmSync as rmSync4, writeFileSync as writeFileSync5 } from "node:fs";
|
|
4291
|
+
import { join as join10 } from "node:path";
|
|
4130
4292
|
|
|
4131
4293
|
// ../server/src/provider/claude-code/bridge.ts
|
|
4132
4294
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
@@ -4411,7 +4573,7 @@ var NOT_SIGNED_IN = /not logged in|please run \/login|invalid api key|authentica
|
|
|
4411
4573
|
var ClaudeCodeProvider = class {
|
|
4412
4574
|
constructor(opts) {
|
|
4413
4575
|
this.opts = opts;
|
|
4414
|
-
this.home =
|
|
4576
|
+
this.home = join10(opts.dataDir, "claude");
|
|
4415
4577
|
this.now = opts.now ?? Date.now;
|
|
4416
4578
|
this.bridge = new ToolBridge((call) => this.owner.inbox.push({ kind: "call", call }));
|
|
4417
4579
|
}
|
|
@@ -4641,7 +4803,7 @@ ${item.stderr}`;
|
|
|
4641
4803
|
"local",
|
|
4642
4804
|
"--system-prompt",
|
|
4643
4805
|
req.system,
|
|
4644
|
-
...conv.guest ? ["--no-session-persistence"] : ["--mcp-config",
|
|
4806
|
+
...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
4807
|
];
|
|
4646
4808
|
const proc = new ClaudeProc(
|
|
4647
4809
|
{
|
|
@@ -4672,7 +4834,7 @@ ${item.stderr}`;
|
|
|
4672
4834
|
startBridge() {
|
|
4673
4835
|
this.bridgeReady ??= (async () => {
|
|
4674
4836
|
await this.bridge.start();
|
|
4675
|
-
const file =
|
|
4837
|
+
const file = join10(this.home, "mcp.json");
|
|
4676
4838
|
writeFileSync5(file, JSON.stringify({ mcpServers: { friend: { type: "http", url: this.bridge.url, headers: { Authorization: `Bearer ${this.bridge.token}` } } } }));
|
|
4677
4839
|
try {
|
|
4678
4840
|
chmodSync4(file, 384);
|
|
@@ -4682,10 +4844,10 @@ ${item.stderr}`;
|
|
|
4682
4844
|
return this.bridgeReady;
|
|
4683
4845
|
}
|
|
4684
4846
|
// ---- the session id, kept so a restart of Friend can pick the conversation up again ----
|
|
4685
|
-
sessionFile = () =>
|
|
4847
|
+
sessionFile = () => join10(this.home, "session-id");
|
|
4686
4848
|
readSession() {
|
|
4687
4849
|
try {
|
|
4688
|
-
return
|
|
4850
|
+
return existsSync7(this.sessionFile()) ? readFileSync4(this.sessionFile(), "utf8").trim() || null : null;
|
|
4689
4851
|
} catch {
|
|
4690
4852
|
return null;
|
|
4691
4853
|
}
|
|
@@ -5120,6 +5282,63 @@ var OllamaProvider = class {
|
|
|
5120
5282
|
}
|
|
5121
5283
|
};
|
|
5122
5284
|
|
|
5285
|
+
// ../server/src/tls.ts
|
|
5286
|
+
import { chmodSync as chmodSync5, existsSync as existsSync8, mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync6 } from "node:fs";
|
|
5287
|
+
import { networkInterfaces } from "node:os";
|
|
5288
|
+
import { isIP as isIP2 } from "node:net";
|
|
5289
|
+
import { join as join11 } from "node:path";
|
|
5290
|
+
import { generate } from "selfsigned";
|
|
5291
|
+
var DAYS = 825;
|
|
5292
|
+
var RENEW_BEFORE_MS = 30 * 24 * 36e5;
|
|
5293
|
+
function lanAddresses(ifaces = networkInterfaces) {
|
|
5294
|
+
const out = [];
|
|
5295
|
+
for (const list2 of Object.values(ifaces())) {
|
|
5296
|
+
for (const i of list2 ?? []) if (i.family === "IPv4" && !i.internal && !out.includes(i.address)) out.push(i.address);
|
|
5297
|
+
}
|
|
5298
|
+
return out;
|
|
5299
|
+
}
|
|
5300
|
+
function tlsHosts(extra = [], ifaces) {
|
|
5301
|
+
return [.../* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1", ...lanAddresses(ifaces), ...extra.map((h) => h.toLowerCase()).filter(Boolean)])];
|
|
5302
|
+
}
|
|
5303
|
+
async function ensureTlsCert(dataDir, extraHosts = [], ifaces, now = Date.now) {
|
|
5304
|
+
const dir = join11(dataDir, "tls");
|
|
5305
|
+
const keyFile = join11(dir, "key.pem");
|
|
5306
|
+
const certFile = join11(dir, "cert.pem");
|
|
5307
|
+
const metaFile = join11(dir, "meta.json");
|
|
5308
|
+
const hosts = tlsHosts(extraHosts, ifaces);
|
|
5309
|
+
if (existsSync8(keyFile) && existsSync8(certFile) && existsSync8(metaFile)) {
|
|
5310
|
+
try {
|
|
5311
|
+
const meta = JSON.parse(readFileSync5(metaFile, "utf8"));
|
|
5312
|
+
const covered = hosts.every((h) => meta.hosts.includes(h));
|
|
5313
|
+
if (covered && meta.expires - now() > RENEW_BEFORE_MS) return { key: readFileSync5(keyFile, "utf8"), cert: readFileSync5(certFile, "utf8") };
|
|
5314
|
+
} catch {
|
|
5315
|
+
}
|
|
5316
|
+
}
|
|
5317
|
+
const altNames = hosts.map((h) => isIP2(h) ? { type: 7, ip: h } : { type: 2, value: h });
|
|
5318
|
+
const pems = await generate([{ name: "commonName", value: "Friend" }], {
|
|
5319
|
+
keySize: 2048,
|
|
5320
|
+
notBeforeDate: new Date(now() - 24 * 36e5),
|
|
5321
|
+
// a day back, so a phone whose clock is a little slow still accepts it
|
|
5322
|
+
notAfterDate: new Date(now() + DAYS * 24 * 36e5),
|
|
5323
|
+
algorithm: "sha256",
|
|
5324
|
+
extensions: [
|
|
5325
|
+
{ name: "basicConstraints", cA: false },
|
|
5326
|
+
{ name: "keyUsage", digitalSignature: true, keyEncipherment: true },
|
|
5327
|
+
{ name: "extKeyUsage", serverAuth: true },
|
|
5328
|
+
{ name: "subjectAltName", altNames }
|
|
5329
|
+
]
|
|
5330
|
+
});
|
|
5331
|
+
mkdirSync7(dir, { recursive: true });
|
|
5332
|
+
writeFileSync6(keyFile, pems.private);
|
|
5333
|
+
writeFileSync6(certFile, pems.cert);
|
|
5334
|
+
writeFileSync6(metaFile, JSON.stringify({ hosts, expires: now() + DAYS * 24 * 36e5 }));
|
|
5335
|
+
try {
|
|
5336
|
+
chmodSync5(keyFile, 384);
|
|
5337
|
+
} catch {
|
|
5338
|
+
}
|
|
5339
|
+
return { key: pems.private, cert: pems.cert };
|
|
5340
|
+
}
|
|
5341
|
+
|
|
5123
5342
|
// ../server/src/voice/challenge.ts
|
|
5124
5343
|
import { randomUUID as randomUUID6 } from "node:crypto";
|
|
5125
5344
|
var Challenges = class {
|
|
@@ -5181,22 +5400,22 @@ var Challenges = class {
|
|
|
5181
5400
|
|
|
5182
5401
|
// ../server/src/voice/crypto.ts
|
|
5183
5402
|
import { createCipheriv, createDecipheriv, randomBytes as randomBytes3 } from "node:crypto";
|
|
5184
|
-
import { chmodSync as
|
|
5403
|
+
import { chmodSync as chmodSync6, existsSync as existsSync9, mkdirSync as mkdirSync8, readFileSync as readFileSync6, writeFileSync as writeFileSync7 } from "node:fs";
|
|
5185
5404
|
import { dirname as dirname2 } from "node:path";
|
|
5186
5405
|
var KEY_BYTES = 32;
|
|
5187
5406
|
var IV_BYTES = 12;
|
|
5188
5407
|
var TAG_BYTES = 16;
|
|
5189
5408
|
function loadOrCreateKey(path) {
|
|
5190
|
-
if (
|
|
5191
|
-
const key2 =
|
|
5409
|
+
if (existsSync9(path)) {
|
|
5410
|
+
const key2 = readFileSync6(path);
|
|
5192
5411
|
if (key2.length !== KEY_BYTES) throw new Error(`invalid voice key file (expected ${KEY_BYTES} bytes): ${path}`);
|
|
5193
5412
|
return key2;
|
|
5194
5413
|
}
|
|
5195
|
-
|
|
5414
|
+
mkdirSync8(dirname2(path), { recursive: true });
|
|
5196
5415
|
const key = randomBytes3(KEY_BYTES);
|
|
5197
|
-
|
|
5416
|
+
writeFileSync7(path, key, { mode: 384 });
|
|
5198
5417
|
try {
|
|
5199
|
-
|
|
5418
|
+
chmodSync6(path, 384);
|
|
5200
5419
|
} catch {
|
|
5201
5420
|
}
|
|
5202
5421
|
return key;
|
|
@@ -5614,7 +5833,7 @@ async function createApp(opts) {
|
|
|
5614
5833
|
const camera = new CameraBroker(opts.cameraTimeoutMs);
|
|
5615
5834
|
const memory = new Memory(db);
|
|
5616
5835
|
const tasks = new Tasks(db);
|
|
5617
|
-
const plugins = new Plugins(db,
|
|
5836
|
+
const plugins = new Plugins(db, join12(opts.dataDir, "plugins"));
|
|
5618
5837
|
const hub = new McpHub(new ConnectorStore(opts.dataDir));
|
|
5619
5838
|
const admin = makeAdmin({ hub, plugins, dataDir: opts.dataDir, audit: (what, detail) => audit.log("agent", what, detail) });
|
|
5620
5839
|
function commitSettings(patch, how) {
|
|
@@ -5720,7 +5939,7 @@ async function createApp(opts) {
|
|
|
5720
5939
|
const voice2 = opts.voice;
|
|
5721
5940
|
const speaker = voice2 ? new Speaker(voice2, audit, () => config.voice.ttsVoice) : null;
|
|
5722
5941
|
const voiceAvailable = () => !!voice2 && voice2.available();
|
|
5723
|
-
const voiceprint = voice2 ? new Voiceprint(db, loadOrCreateKey(
|
|
5942
|
+
const voiceprint = voice2 ? new Voiceprint(db, loadOrCreateKey(join12(opts.dataDir, "voice.key"))) : null;
|
|
5724
5943
|
const enrollment = voiceprint ? new Enrollment(voiceprint, SPEAKER_MODEL) : null;
|
|
5725
5944
|
const challenges = new Challenges((id2) => {
|
|
5726
5945
|
broadcast({ type: "voice.challenge.end", id: id2 });
|
|
@@ -5837,7 +6056,7 @@ async function createApp(opts) {
|
|
|
5837
6056
|
if (!isPluginId(id2)) throw new HttpError(400, "That is not a plugin name.");
|
|
5838
6057
|
if (!listCatalog(catalogDir).some((c) => c.id === id2)) throw new HttpError(404, "That plugin is not in the catalog.");
|
|
5839
6058
|
await requirePin(pin, ip);
|
|
5840
|
-
const r = plugins.install(
|
|
6059
|
+
const r = plugins.install(join12(catalogDir, id2));
|
|
5841
6060
|
if (!r.ok) throw new HttpError(400, `That plugin cannot be installed: ${r.error}`);
|
|
5842
6061
|
audit.log("owner", "plugin.installed", { id: r.id, upgraded: r.upgraded, ip });
|
|
5843
6062
|
return { id: r.id, upgraded: r.upgraded };
|
|
@@ -5892,6 +6111,24 @@ async function createApp(opts) {
|
|
|
5892
6111
|
throw new HttpError(404, e.message);
|
|
5893
6112
|
}
|
|
5894
6113
|
},
|
|
6114
|
+
quickCheck: (preset) => {
|
|
6115
|
+
const c = quickCheck(preset, { home: opts.quick?.home });
|
|
6116
|
+
if (!c) throw new HttpError(404, "That is not a one-click connector.");
|
|
6117
|
+
return c;
|
|
6118
|
+
},
|
|
6119
|
+
quickInstall: async (preset, pin, ip) => {
|
|
6120
|
+
if (!isQuickPreset(preset)) throw new HttpError(404, "That is not a one-click connector.");
|
|
6121
|
+
await requirePin(pin, ip);
|
|
6122
|
+
audit.log("owner", "connector.quick_install", { preset, ip });
|
|
6123
|
+
return quickInstall(preset, { hub, run: opts.quick?.run ?? runCommand, home: opts.quick?.home });
|
|
6124
|
+
},
|
|
6125
|
+
quickConnect: async (preset, pin, ip) => {
|
|
6126
|
+
if (!isQuickPreset(preset)) throw new HttpError(404, "That is not a one-click connector.");
|
|
6127
|
+
await requirePin(pin, ip);
|
|
6128
|
+
const result = await quickConnect(preset, { hub, run: opts.quick?.run ?? runCommand, home: opts.quick?.home });
|
|
6129
|
+
audit.log("owner", "connector.quick_connect", { preset, ok: result.ok, extension: result.extension, ip });
|
|
6130
|
+
return result;
|
|
6131
|
+
},
|
|
5895
6132
|
remove: async (id2) => {
|
|
5896
6133
|
try {
|
|
5897
6134
|
await hub.remove(id2);
|
|
@@ -5901,7 +6138,9 @@ async function createApp(opts) {
|
|
|
5901
6138
|
}
|
|
5902
6139
|
}
|
|
5903
6140
|
};
|
|
6141
|
+
const tls = config.https ? await ensureTlsCert(opts.dataDir, [...config.allowedHosts, ...config.publicUrl ? [new URL(config.publicUrl).hostname] : []]) : void 0;
|
|
5904
6142
|
const gateway = createGateway({
|
|
6143
|
+
...tls ? { tls } : {},
|
|
5905
6144
|
connectors: connectorsApi,
|
|
5906
6145
|
voicePreview: async (id2) => {
|
|
5907
6146
|
if (!voice2 || !voiceAvailable()) throw new HttpError(503, "Voice is not available. Run: friend voice setup");
|
|
@@ -6118,11 +6357,10 @@ async function createApp(opts) {
|
|
|
6118
6357
|
camera.cancelAll(broadcast);
|
|
6119
6358
|
agent.cancel();
|
|
6120
6359
|
approvals.denyAll("Shutting down");
|
|
6121
|
-
await gateway.close()
|
|
6122
|
-
|
|
6123
|
-
})
|
|
6124
|
-
|
|
6125
|
-
});
|
|
6360
|
+
await Promise.all([gateway.close().catch(() => {
|
|
6361
|
+
}), provider.close?.().catch(() => {
|
|
6362
|
+
}), hub.close().catch(() => {
|
|
6363
|
+
})]);
|
|
6126
6364
|
db.close();
|
|
6127
6365
|
}
|
|
6128
6366
|
};
|
|
@@ -6250,7 +6488,7 @@ var SidecarProcess = class {
|
|
|
6250
6488
|
import { createInterface as createInterface2 } from "node:readline";
|
|
6251
6489
|
|
|
6252
6490
|
// ../agent-cli/src/commands/config.ts
|
|
6253
|
-
import { isIP as
|
|
6491
|
+
import { isIP as isIP3 } from "node:net";
|
|
6254
6492
|
var list = (v) => v.split(",").map((s) => s.trim()).filter(Boolean);
|
|
6255
6493
|
var bool = (v) => {
|
|
6256
6494
|
if (v !== "true" && v !== "false") throw new Error("Expected true or false");
|
|
@@ -6280,10 +6518,11 @@ var PARSERS = {
|
|
|
6280
6518
|
model: (v) => v,
|
|
6281
6519
|
bind: (v) => {
|
|
6282
6520
|
const t = v.trim();
|
|
6283
|
-
if (t !== "localhost" &&
|
|
6521
|
+
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
6522
|
return t;
|
|
6285
6523
|
},
|
|
6286
6524
|
publicUrl: (v) => v === "" ? null : v,
|
|
6525
|
+
https: bool,
|
|
6287
6526
|
port: (v) => {
|
|
6288
6527
|
const n = Number(v);
|
|
6289
6528
|
if (!Number.isInteger(n) || n < 0 || n > 65535) throw new Error("Port must be a number between 0 and 65535");
|
|
@@ -6336,6 +6575,7 @@ var KEY_HELP = {
|
|
|
6336
6575
|
provider: "which model answers: claude (API key), claude-code (your Claude subscription) or ollama (on this PC)",
|
|
6337
6576
|
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
6577
|
port: "the web port (easier: friend lan port N)",
|
|
6578
|
+
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
6579
|
publicUrl: "the address people use when Friend is behind your own domain, or empty",
|
|
6340
6580
|
roots: "folders Friend may read and write freely, separated by commas",
|
|
6341
6581
|
allowedOrigins: "extra web addresses allowed to connect, separated by commas",
|
|
@@ -6400,8 +6640,8 @@ import { resolve as resolve6 } from "node:path";
|
|
|
6400
6640
|
import { WebSocket as WebSocket2 } from "ws";
|
|
6401
6641
|
|
|
6402
6642
|
// ../agent-cli/src/tools/fs.ts
|
|
6403
|
-
import { closeSync, mkdirSync as
|
|
6404
|
-
import { dirname as dirname5, join as
|
|
6643
|
+
import { closeSync, mkdirSync as mkdirSync9, openSync, readSync, readdirSync as readdirSync2, statSync, writeFileSync as writeFileSync8 } from "node:fs";
|
|
6644
|
+
import { dirname as dirname5, join as join13 } from "node:path";
|
|
6405
6645
|
|
|
6406
6646
|
// ../agent-cli/src/tools/roots.ts
|
|
6407
6647
|
import { lstatSync as lstatSync2, realpathSync } from "node:fs";
|
|
@@ -6463,7 +6703,7 @@ function fsList(args, ctx) {
|
|
|
6463
6703
|
if (e.isDirectory()) return `[dir] ${e.name}`;
|
|
6464
6704
|
let size = 0;
|
|
6465
6705
|
try {
|
|
6466
|
-
size = statSync(
|
|
6706
|
+
size = statSync(join13(g.abs, e.name)).size;
|
|
6467
6707
|
} catch {
|
|
6468
6708
|
}
|
|
6469
6709
|
return `[file] ${e.name} (${size} bytes)`;
|
|
@@ -6504,8 +6744,8 @@ function fsWrite(args, ctx) {
|
|
|
6504
6744
|
if ("error" in g) return { ok: false, error: g.error };
|
|
6505
6745
|
if (Buffer.byteLength(args.content) > MAX_WRITE) return { ok: false, error: "Content too large (5 MB limit)" };
|
|
6506
6746
|
try {
|
|
6507
|
-
|
|
6508
|
-
|
|
6747
|
+
mkdirSync9(dirname5(g.abs), { recursive: true });
|
|
6748
|
+
writeFileSync8(g.abs, args.content);
|
|
6509
6749
|
return { ok: true, output: `Wrote ${Buffer.byteLength(args.content)} bytes to ${g.abs}`, truncated: false };
|
|
6510
6750
|
} catch (e) {
|
|
6511
6751
|
return { ok: false, error: e.message };
|
|
@@ -6514,7 +6754,7 @@ function fsWrite(args, ctx) {
|
|
|
6514
6754
|
|
|
6515
6755
|
// ../agent-cli/src/tools/shell.ts
|
|
6516
6756
|
import { spawn as spawn4, spawnSync } from "node:child_process";
|
|
6517
|
-
function
|
|
6757
|
+
function killTree2(child) {
|
|
6518
6758
|
if (!child.pid) return;
|
|
6519
6759
|
if (process.platform === "win32") {
|
|
6520
6760
|
spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { windowsHide: true });
|
|
@@ -6526,7 +6766,7 @@ function killTree(child) {
|
|
|
6526
6766
|
}
|
|
6527
6767
|
}
|
|
6528
6768
|
}
|
|
6529
|
-
function
|
|
6769
|
+
function runCommand2(command, cwdArg, ctx, argv) {
|
|
6530
6770
|
let cwd = ctx.roots[0] ?? process.cwd();
|
|
6531
6771
|
if (cwdArg) {
|
|
6532
6772
|
const r = resolveInRoots(cwdArg, ctx.roots);
|
|
@@ -6559,7 +6799,7 @@ function runCommand(command, cwdArg, ctx, argv) {
|
|
|
6559
6799
|
child.stderr.on("data", take);
|
|
6560
6800
|
const timer = setTimeout(() => {
|
|
6561
6801
|
timedOut = true;
|
|
6562
|
-
|
|
6802
|
+
killTree2(child);
|
|
6563
6803
|
}, ctx.timeoutMs);
|
|
6564
6804
|
child.on("error", (e) => {
|
|
6565
6805
|
clearTimeout(timer);
|
|
@@ -6617,7 +6857,7 @@ async function systemInfo(sampleMs = 250) {
|
|
|
6617
6857
|
return { ok: true, output: lines.join("\n"), truncated: false };
|
|
6618
6858
|
}
|
|
6619
6859
|
function processList(ctx) {
|
|
6620
|
-
return process.platform === "win32" ?
|
|
6860
|
+
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
6861
|
}
|
|
6622
6862
|
|
|
6623
6863
|
// ../agent-cli/src/tools/index.ts
|
|
@@ -6641,7 +6881,7 @@ async function runTool(name, args, ctx) {
|
|
|
6641
6881
|
case "process.list":
|
|
6642
6882
|
return await processList(ctx);
|
|
6643
6883
|
case "shell.run":
|
|
6644
|
-
return await
|
|
6884
|
+
return await runCommand2(a.command, a.cwd, ctx);
|
|
6645
6885
|
default:
|
|
6646
6886
|
return { ok: false, error: `Unknown tool: ${name}` };
|
|
6647
6887
|
}
|
|
@@ -6671,10 +6911,10 @@ function startDeviceNode(opts) {
|
|
|
6671
6911
|
state = s;
|
|
6672
6912
|
opts.onState?.(s);
|
|
6673
6913
|
};
|
|
6674
|
-
function
|
|
6914
|
+
function connect2() {
|
|
6675
6915
|
if (stopped) return;
|
|
6676
6916
|
setState("connecting");
|
|
6677
|
-
const sock = new WebSocket2(opts.url, { headers: { authorization: `Bearer ${opts.token}` } });
|
|
6917
|
+
const sock = new WebSocket2(opts.url, { headers: { authorization: `Bearer ${opts.token}` }, ...opts.ca ? { ca: opts.ca } : {} });
|
|
6678
6918
|
ws = sock;
|
|
6679
6919
|
sock.on("error", () => {
|
|
6680
6920
|
});
|
|
@@ -6719,10 +6959,10 @@ function startDeviceNode(opts) {
|
|
|
6719
6959
|
if (opts.reconnect === false) return;
|
|
6720
6960
|
const delay = Math.min(base * 2 ** attempt, 1e4);
|
|
6721
6961
|
attempt += 1;
|
|
6722
|
-
timer = setTimeout(
|
|
6962
|
+
timer = setTimeout(connect2, delay);
|
|
6723
6963
|
});
|
|
6724
6964
|
}
|
|
6725
|
-
|
|
6965
|
+
connect2();
|
|
6726
6966
|
return {
|
|
6727
6967
|
connected,
|
|
6728
6968
|
state: () => state,
|
|
@@ -6741,15 +6981,15 @@ function startDeviceNode(opts) {
|
|
|
6741
6981
|
|
|
6742
6982
|
// ../agent-cli/src/commands/start.ts
|
|
6743
6983
|
import { randomBytes as randomBytes4 } from "node:crypto";
|
|
6744
|
-
import { existsSync as
|
|
6745
|
-
import { dirname as dirname7, join as
|
|
6984
|
+
import { existsSync as existsSync11, readFileSync as readFileSync8, writeFileSync as writeFileSync10 } from "node:fs";
|
|
6985
|
+
import { dirname as dirname7, join as join15, resolve as resolve5 } from "node:path";
|
|
6746
6986
|
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
6747
6987
|
|
|
6748
6988
|
// ../agent-cli/src/commands/lan.ts
|
|
6749
|
-
import { networkInterfaces } from "node:os";
|
|
6989
|
+
import { networkInterfaces as networkInterfaces2 } from "node:os";
|
|
6750
6990
|
var LOOPBACK3 = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "::1"]);
|
|
6751
6991
|
var isLoopbackBind = (bind) => LOOPBACK3.has(bind);
|
|
6752
|
-
function
|
|
6992
|
+
function lanAddresses2(ifaces = networkInterfaces2) {
|
|
6753
6993
|
const out = [];
|
|
6754
6994
|
for (const list2 of Object.values(ifaces())) {
|
|
6755
6995
|
for (const i of list2 ?? []) {
|
|
@@ -6758,11 +6998,12 @@ function lanAddresses(ifaces = networkInterfaces) {
|
|
|
6758
6998
|
}
|
|
6759
6999
|
return out;
|
|
6760
7000
|
}
|
|
6761
|
-
function urlsFor(bind, port, ifaces =
|
|
6762
|
-
const
|
|
7001
|
+
function urlsFor(bind, port, ifaces = networkInterfaces2, https = false) {
|
|
7002
|
+
const scheme = https ? "https" : "http";
|
|
7003
|
+
const local = `${scheme}://127.0.0.1:${port}`;
|
|
6763
7004
|
if (isLoopbackBind(bind)) return { local, network: [] };
|
|
6764
|
-
if (bind === "0.0.0.0" || bind === "::") return { local, network:
|
|
6765
|
-
return { local, network: [
|
|
7005
|
+
if (bind === "0.0.0.0" || bind === "::") return { local, network: lanAddresses2(ifaces).map((a) => `${scheme}://${a}:${port}`) };
|
|
7006
|
+
return { local, network: [`${scheme}://${bind}:${port}`] };
|
|
6766
7007
|
}
|
|
6767
7008
|
var PORT_HELP = "The port must be a whole number from 1024 to 65535.";
|
|
6768
7009
|
function parsePort(v) {
|
|
@@ -6770,34 +7011,58 @@ function parsePort(v) {
|
|
|
6770
7011
|
if (v === void 0 || v.trim() === "" || !Number.isInteger(n) || n < 1024 || n > 65535) throw new Error(PORT_HELP);
|
|
6771
7012
|
return n;
|
|
6772
7013
|
}
|
|
6773
|
-
function show(io2, bind, port, ifaces) {
|
|
6774
|
-
const u = urlsFor(bind, port, ifaces);
|
|
7014
|
+
function show(io2, bind, port, ifaces, https = false) {
|
|
7015
|
+
const u = urlsFor(bind, port, ifaces, https);
|
|
6775
7016
|
io2.out(`Listens on: ${bind}:${port}`);
|
|
6776
7017
|
io2.out(`On this PC: ${u.local}`);
|
|
6777
7018
|
if (u.network.length) for (const n of u.network) io2.out(`On your network: ${n}`);
|
|
6778
7019
|
else if (isLoopbackBind(bind)) io2.out("On your network: not reachable (only this PC). Turn it on with: friend lan on");
|
|
6779
7020
|
else io2.out("On your network: no network address found. Check that this PC is connected.");
|
|
6780
7021
|
}
|
|
6781
|
-
async function lanCommand(args, io2, ifaces =
|
|
7022
|
+
async function lanCommand(args, io2, ifaces = networkInterfaces2) {
|
|
6782
7023
|
const [sub, ...rest] = args;
|
|
6783
7024
|
const cfg = loadConfig(io2.dataDir);
|
|
6784
7025
|
switch (sub) {
|
|
6785
7026
|
case void 0:
|
|
6786
7027
|
case "status":
|
|
6787
|
-
show(io2, cfg.bind, cfg.port, ifaces);
|
|
7028
|
+
show(io2, cfg.bind, cfg.port, ifaces, cfg.https);
|
|
7029
|
+
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");
|
|
7030
|
+
return 0;
|
|
7031
|
+
case "https": {
|
|
7032
|
+
if (rest[0] !== "on" && rest[0] !== "off") {
|
|
7033
|
+
io2.err("Usage: friend lan https on | off");
|
|
7034
|
+
return 1;
|
|
7035
|
+
}
|
|
7036
|
+
cfg.https = rest[0] === "on";
|
|
7037
|
+
saveConfig(cfg);
|
|
7038
|
+
show(io2, cfg.bind, cfg.port, ifaces, cfg.https);
|
|
7039
|
+
io2.out("");
|
|
7040
|
+
io2.out("Restart Friend to apply (stop it, then: friend start).");
|
|
7041
|
+
if (cfg.https) {
|
|
7042
|
+
io2.out("Friend makes its own security certificate, so the first time a phone opens the https:// address it shows a warning:");
|
|
7043
|
+
io2.out(" Android Chrome: Advanced, then Proceed. iPhone Safari: Show Details, then visit this website.");
|
|
7044
|
+
io2.out("After that, the microphone and camera work from the phone. Only that address is affected; nothing is installed on the phone.");
|
|
7045
|
+
if (isLoopbackBind(cfg.bind)) io2.out("Friend is still only reachable from this PC. To open it from a phone too: friend lan on");
|
|
7046
|
+
}
|
|
6788
7047
|
return 0;
|
|
7048
|
+
}
|
|
6789
7049
|
case "on": {
|
|
6790
7050
|
const portFlag = rest.indexOf("--port");
|
|
6791
7051
|
if (portFlag >= 0) cfg.port = parsePort(rest[portFlag + 1]);
|
|
6792
7052
|
cfg.bind = "0.0.0.0";
|
|
7053
|
+
if (rest.includes("--https")) cfg.https = true;
|
|
6793
7054
|
saveConfig(cfg);
|
|
6794
|
-
show(io2, cfg.bind, cfg.port, ifaces);
|
|
7055
|
+
show(io2, cfg.bind, cfg.port, ifaces, cfg.https);
|
|
6795
7056
|
io2.out("");
|
|
6796
7057
|
io2.out("Restart Friend to apply (stop it, then: friend start).");
|
|
6797
7058
|
io2.out("Things to know:");
|
|
6798
7059
|
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
|
-
|
|
7060
|
+
if (cfg.https) {
|
|
7061
|
+
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.");
|
|
7062
|
+
} else {
|
|
7063
|
+
io2.out(" - The connection is plain http: other people on the network could read it. Use it only on a network you trust.");
|
|
7064
|
+
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");
|
|
7065
|
+
}
|
|
6801
7066
|
io2.out(" - Windows may ask to allow Node.js through the firewall the first time: allow it on Private networks.");
|
|
6802
7067
|
io2.out(" - Going to a name such as http://my-pc:8787 needs: friend config set allowedHosts my-pc");
|
|
6803
7068
|
return 0;
|
|
@@ -6805,41 +7070,41 @@ async function lanCommand(args, io2, ifaces = networkInterfaces) {
|
|
|
6805
7070
|
case "off":
|
|
6806
7071
|
cfg.bind = "127.0.0.1";
|
|
6807
7072
|
saveConfig(cfg);
|
|
6808
|
-
show(io2, cfg.bind, cfg.port, ifaces);
|
|
7073
|
+
show(io2, cfg.bind, cfg.port, ifaces, cfg.https);
|
|
6809
7074
|
io2.out("Restart Friend to apply.");
|
|
6810
7075
|
return 0;
|
|
6811
7076
|
case "port": {
|
|
6812
7077
|
cfg.port = parsePort(rest[0]);
|
|
6813
7078
|
saveConfig(cfg);
|
|
6814
|
-
show(io2, cfg.bind, cfg.port, ifaces);
|
|
7079
|
+
show(io2, cfg.bind, cfg.port, ifaces, cfg.https);
|
|
6815
7080
|
io2.out("Restart Friend to apply.");
|
|
6816
7081
|
return 0;
|
|
6817
7082
|
}
|
|
6818
7083
|
default:
|
|
6819
|
-
io2.err("Usage: friend lan status | on [--port N] | off | port N");
|
|
7084
|
+
io2.err("Usage: friend lan status | on [--port N] [--https] | off | port N | https on|off");
|
|
6820
7085
|
return 1;
|
|
6821
7086
|
}
|
|
6822
7087
|
}
|
|
6823
7088
|
|
|
6824
7089
|
// ../agent-cli/src/commands/voice.ts
|
|
6825
7090
|
import { spawn as spawn5 } from "node:child_process";
|
|
6826
|
-
import { existsSync as
|
|
6827
|
-
import { dirname as dirname6, join as
|
|
7091
|
+
import { existsSync as existsSync10, mkdirSync as mkdirSync10, readFileSync as readFileSync7, writeFileSync as writeFileSync9 } from "node:fs";
|
|
7092
|
+
import { dirname as dirname6, join as join14, resolve as resolve4 } from "node:path";
|
|
6828
7093
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
6829
7094
|
function voicePaths(dataDir) {
|
|
6830
|
-
const home =
|
|
6831
|
-
const venv =
|
|
7095
|
+
const home = join14(dataDir, "voice");
|
|
7096
|
+
const venv = join14(home, "venv");
|
|
6832
7097
|
return {
|
|
6833
7098
|
home,
|
|
6834
7099
|
venv,
|
|
6835
|
-
python: process.platform === "win32" ?
|
|
6836
|
-
models:
|
|
6837
|
-
marker:
|
|
7100
|
+
python: process.platform === "win32" ? join14(venv, "Scripts", "python.exe") : join14(venv, "bin", "python"),
|
|
7101
|
+
models: join14(home, "models"),
|
|
7102
|
+
marker: join14(home, "installed.json")
|
|
6838
7103
|
};
|
|
6839
7104
|
}
|
|
6840
7105
|
function readMarker(dataDir) {
|
|
6841
7106
|
try {
|
|
6842
|
-
return JSON.parse(
|
|
7107
|
+
return JSON.parse(readFileSync7(voicePaths(dataDir).marker, "utf8"));
|
|
6843
7108
|
} catch {
|
|
6844
7109
|
return null;
|
|
6845
7110
|
}
|
|
@@ -6936,8 +7201,8 @@ async function voiceSetupCommand(io2, deps = {}, args = []) {
|
|
|
6936
7201
|
io2.err("Python 3.10 or newer is required. Install it from https://www.python.org/downloads/ and run this again.");
|
|
6937
7202
|
return 1;
|
|
6938
7203
|
}
|
|
6939
|
-
|
|
6940
|
-
if (!
|
|
7204
|
+
mkdirSync10(p.home, { recursive: true });
|
|
7205
|
+
if (!existsSync10(p.python)) {
|
|
6941
7206
|
io2.out("1/3 Creating a private Python environment\u2026");
|
|
6942
7207
|
const r = await run2(py.cmd, [...py.args, "-m", "venv", p.venv], { onLine });
|
|
6943
7208
|
if (r.code !== 0) {
|
|
@@ -6963,7 +7228,7 @@ ${tail(dl.stdout)}`);
|
|
|
6963
7228
|
return 1;
|
|
6964
7229
|
}
|
|
6965
7230
|
const marker = { python: p.python, sttModel: cfg.voice.sttModel, ttsVoice: cfg.voice.ttsVoice, at: Date.now() };
|
|
6966
|
-
|
|
7231
|
+
writeFileSync9(p.marker, JSON.stringify(marker, null, 2));
|
|
6967
7232
|
io2.out("Voice is ready. Start Friend with `friend start`, then hold Space to talk.");
|
|
6968
7233
|
return 0;
|
|
6969
7234
|
}
|
|
@@ -6979,7 +7244,7 @@ async function voiceStatusCommand(io2) {
|
|
|
6979
7244
|
io2.out("Voice engine: not installed. Run: friend voice setup");
|
|
6980
7245
|
return 0;
|
|
6981
7246
|
}
|
|
6982
|
-
if (!
|
|
7247
|
+
if (!existsSync10(marker.python)) {
|
|
6983
7248
|
io2.out("Voice engine: installed, but its Python is missing. Run: friend voice setup");
|
|
6984
7249
|
return 0;
|
|
6985
7250
|
}
|
|
@@ -7052,7 +7317,7 @@ async function voiceCommand(args, io2) {
|
|
|
7052
7317
|
function findWebDir(explicit) {
|
|
7053
7318
|
const here = dirname7(fileURLToPath3(import.meta.url));
|
|
7054
7319
|
const candidates = [explicit, process.env.FRIEND_WEB_DIR, resolve5(here, "../../../web/dist"), resolve5(here, "web")];
|
|
7055
|
-
return candidates.find((c) => !!c &&
|
|
7320
|
+
return candidates.find((c) => !!c && existsSync11(join15(c, "index.html")));
|
|
7056
7321
|
}
|
|
7057
7322
|
async function startFriend(opts) {
|
|
7058
7323
|
const stored = loadConfig(opts.dataDir);
|
|
@@ -7086,18 +7351,20 @@ async function startFriend(opts) {
|
|
|
7086
7351
|
}
|
|
7087
7352
|
});
|
|
7088
7353
|
const { port } = await app.start();
|
|
7089
|
-
const tokenFile =
|
|
7090
|
-
let token =
|
|
7354
|
+
const tokenFile = join15(opts.dataDir, "local-device.token");
|
|
7355
|
+
let token = existsSync11(tokenFile) ? readFileSync8(tokenFile, "utf8").trim() : "";
|
|
7091
7356
|
if (!token || !app.auth.resolveDevice(token)) {
|
|
7092
7357
|
token = app.auth.redeemPairCode(app.auth.createPairCode(), "this-pc");
|
|
7093
|
-
|
|
7358
|
+
writeFileSync10(tokenFile, token, { mode: 384 });
|
|
7094
7359
|
}
|
|
7095
7360
|
const node = startDeviceNode({
|
|
7096
|
-
url:
|
|
7361
|
+
url: `${app.config.https ? "wss" : "ws"}://127.0.0.1:${port}/device`,
|
|
7097
7362
|
token,
|
|
7098
|
-
roots: app.config.roots
|
|
7363
|
+
roots: app.config.roots,
|
|
7364
|
+
...app.config.https ? { ca: readFileSync8(join15(opts.dataDir, "tls", "cert.pem"), "utf8") } : {}
|
|
7365
|
+
// this PC trusts only its own Friend's certificate
|
|
7099
7366
|
});
|
|
7100
|
-
const urls = urlsFor(app.config.bind, port);
|
|
7367
|
+
const urls = urlsFor(app.config.bind, port, void 0, app.config.https);
|
|
7101
7368
|
opts.out(`Friend is running at ${urls.local}`);
|
|
7102
7369
|
for (const n of urls.network) opts.out(`Also on your network: ${n}`);
|
|
7103
7370
|
if (!app.auth.hasOwner()) opts.out("No owner yet. Run: friend onboard (guided), or friend setup (just the account)");
|
|
@@ -7127,8 +7394,8 @@ async function startFriend(opts) {
|
|
|
7127
7394
|
async stop() {
|
|
7128
7395
|
if (watcher) clearInterval(watcher);
|
|
7129
7396
|
node.stop();
|
|
7130
|
-
|
|
7131
|
-
await app.stop();
|
|
7397
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms).unref?.());
|
|
7398
|
+
await Promise.race([Promise.allSettled([sidecar?.stop(), app.stop()]), wait(6e3)]);
|
|
7132
7399
|
}
|
|
7133
7400
|
};
|
|
7134
7401
|
}
|
|
@@ -7322,8 +7589,8 @@ async function memoryCommand(args, io2) {
|
|
|
7322
7589
|
}
|
|
7323
7590
|
|
|
7324
7591
|
// ../agent-cli/src/commands/plugin.ts
|
|
7325
|
-
import { existsSync as
|
|
7326
|
-
import { dirname as dirname8, join as
|
|
7592
|
+
import { existsSync as existsSync12, readdirSync as readdirSync3, statSync as statSync2 } from "node:fs";
|
|
7593
|
+
import { dirname as dirname8, join as join16, resolve as resolve7 } from "node:path";
|
|
7327
7594
|
import { fileURLToPath as fileURLToPath4 } from "node:url";
|
|
7328
7595
|
var USAGE2 = "Usage: friend plugin catalog | list | install <name|folder> [--yes] | enable <id> | disable <id> | remove <id>";
|
|
7329
7596
|
function defaultCatalog() {
|
|
@@ -7337,10 +7604,10 @@ async function pluginCommand(args, io2, deps = {}) {
|
|
|
7337
7604
|
const flags = rest.filter((a) => a.startsWith("--"));
|
|
7338
7605
|
const arg = rest.find((a) => !a.startsWith("--"));
|
|
7339
7606
|
if (sub === "catalog") {
|
|
7340
|
-
const dirs =
|
|
7607
|
+
const dirs = existsSync12(catalogDir) ? readdirSync3(catalogDir).filter((d) => statSync2(join16(catalogDir, d)).isDirectory()) : [];
|
|
7341
7608
|
if (dirs.length === 0) io2.out("The catalog is empty.");
|
|
7342
7609
|
for (const d of dirs.sort()) {
|
|
7343
|
-
const r = inspectPlugin(
|
|
7610
|
+
const r = inspectPlugin(join16(catalogDir, d));
|
|
7344
7611
|
if (!r.ok) {
|
|
7345
7612
|
io2.out(`${d} (invalid: ${r.error})`);
|
|
7346
7613
|
continue;
|
|
@@ -7356,7 +7623,7 @@ async function pluginCommand(args, io2, deps = {}) {
|
|
|
7356
7623
|
}
|
|
7357
7624
|
const db = openDb(io2.dataDir);
|
|
7358
7625
|
try {
|
|
7359
|
-
const plugins = new Plugins(db,
|
|
7626
|
+
const plugins = new Plugins(db, join16(io2.dataDir, "plugins"));
|
|
7360
7627
|
if (sub === "list") {
|
|
7361
7628
|
const all = plugins.list();
|
|
7362
7629
|
if (all.length === 0) io2.out("No plugins installed. See: friend plugin catalog");
|
|
@@ -7370,8 +7637,8 @@ async function pluginCommand(args, io2, deps = {}) {
|
|
|
7370
7637
|
io2.err(USAGE2);
|
|
7371
7638
|
return 1;
|
|
7372
7639
|
}
|
|
7373
|
-
const inCatalog = !/[\\/]/.test(arg) &&
|
|
7374
|
-
const source = inCatalog ?
|
|
7640
|
+
const inCatalog = !/[\\/]/.test(arg) && existsSync12(join16(catalogDir, arg));
|
|
7641
|
+
const source = inCatalog ? join16(catalogDir, arg) : existsSync12(resolve7(arg)) ? resolve7(arg) : null;
|
|
7375
7642
|
if (!source) {
|
|
7376
7643
|
io2.err(`"${arg}" is not in the catalog and there is no such folder. See: friend plugin catalog`);
|
|
7377
7644
|
return 1;
|
|
@@ -7425,8 +7692,8 @@ async function pluginCommand(args, io2, deps = {}) {
|
|
|
7425
7692
|
}
|
|
7426
7693
|
|
|
7427
7694
|
// ../agent-cli/src/commands/onboard/index.ts
|
|
7428
|
-
import
|
|
7429
|
-
import { mkdirSync as
|
|
7695
|
+
import crossSpawn4 from "cross-spawn";
|
|
7696
|
+
import { mkdirSync as mkdirSync12 } from "node:fs";
|
|
7430
7697
|
import { resolve as resolve8 } from "node:path";
|
|
7431
7698
|
|
|
7432
7699
|
// ../agent-cli/src/commands/ollama.ts
|
|
@@ -7594,13 +7861,13 @@ async function ollamaCommand(args, io2, deps = {}) {
|
|
|
7594
7861
|
}
|
|
7595
7862
|
|
|
7596
7863
|
// ../agent-cli/src/commands/onboard/exec.ts
|
|
7597
|
-
import
|
|
7864
|
+
import crossSpawn3 from "cross-spawn";
|
|
7598
7865
|
var CHECK_TIMEOUT_MS = 2e4;
|
|
7599
7866
|
var capture = (cmd, args, opts) => new Promise((resolve10) => {
|
|
7600
7867
|
let stdout = "";
|
|
7601
7868
|
let child;
|
|
7602
7869
|
try {
|
|
7603
|
-
child =
|
|
7870
|
+
child = crossSpawn3(cmd, args, { cwd: opts?.cwd, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
7604
7871
|
} catch (e) {
|
|
7605
7872
|
resolve10({ code: 127, stdout: e.message });
|
|
7606
7873
|
return;
|
|
@@ -7627,7 +7894,7 @@ var capture = (cmd, args, opts) => new Promise((resolve10) => {
|
|
|
7627
7894
|
var interactive = (cmd, args) => new Promise((resolve10) => {
|
|
7628
7895
|
let child;
|
|
7629
7896
|
try {
|
|
7630
|
-
child =
|
|
7897
|
+
child = crossSpawn3(cmd, args, { stdio: "inherit" });
|
|
7631
7898
|
} catch {
|
|
7632
7899
|
resolve10(127);
|
|
7633
7900
|
return;
|
|
@@ -7670,14 +7937,14 @@ async function checkDocker(d) {
|
|
|
7670
7937
|
async function checkOllama(d) {
|
|
7671
7938
|
const base = { id: "ollama", label: "Ollama", need: "optional", unlocks: "a free, private model that runs on this PC" };
|
|
7672
7939
|
const v = await d.run("ollama", ["--version"]);
|
|
7673
|
-
let
|
|
7940
|
+
let running2 = false;
|
|
7674
7941
|
try {
|
|
7675
|
-
|
|
7942
|
+
running2 = (await d.fetch("http://127.0.0.1:11434/api/version", { signal: AbortSignal.timeout(2500) })).ok;
|
|
7676
7943
|
} catch {
|
|
7677
|
-
|
|
7944
|
+
running2 = false;
|
|
7678
7945
|
}
|
|
7679
|
-
if (v.code === 0) return { ...base, ok: true, detail: `${first(v.stdout).replace(/^.*version is /i, "Ollama ")}${
|
|
7680
|
-
if (
|
|
7946
|
+
if (v.code === 0) return { ...base, ok: true, detail: `${first(v.stdout).replace(/^.*version is /i, "Ollama ")}${running2 ? ", running" : ", installed (start the Ollama app if it is not running)"}` };
|
|
7947
|
+
if (running2) return { ...base, ok: true, detail: "an Ollama server is running at 127.0.0.1:11434" };
|
|
7681
7948
|
return { ...base, ok: false, detail: "not installed" };
|
|
7682
7949
|
}
|
|
7683
7950
|
async function checkClaude(d, command = "claude") {
|
|
@@ -7749,16 +8016,16 @@ function installPlan(id2, platform, have) {
|
|
|
7749
8016
|
}
|
|
7750
8017
|
|
|
7751
8018
|
// ../agent-cli/src/commands/onboard/state.ts
|
|
7752
|
-
import { existsSync as
|
|
7753
|
-
import { dirname as dirname9, join as
|
|
8019
|
+
import { existsSync as existsSync13, mkdirSync as mkdirSync11, readFileSync as readFileSync9, renameSync as renameSync3, rmSync as rmSync5, writeFileSync as writeFileSync11 } from "node:fs";
|
|
8020
|
+
import { dirname as dirname9, join as join17 } from "node:path";
|
|
7754
8021
|
var FEATURES = ["claude-code", "claude-api", "ollama", "voice", "sandbox"];
|
|
7755
8022
|
var emptyState = () => ({ version: 1, done: {}, features: [], restart: [] });
|
|
7756
|
-
var statePath = (dataDir) =>
|
|
8023
|
+
var statePath = (dataDir) => join17(dataDir, "onboarding.json");
|
|
7757
8024
|
function loadState(dataDir) {
|
|
7758
8025
|
const f = statePath(dataDir);
|
|
7759
|
-
if (!
|
|
8026
|
+
if (!existsSync13(f)) return emptyState();
|
|
7760
8027
|
try {
|
|
7761
|
-
const raw = JSON.parse(
|
|
8028
|
+
const raw = JSON.parse(readFileSync9(f, "utf8"));
|
|
7762
8029
|
const done = raw.done && typeof raw.done === "object" ? Object.fromEntries(Object.entries(raw.done).filter(([, v]) => typeof v === "string")) : {};
|
|
7763
8030
|
const features2 = Array.isArray(raw.features) ? raw.features.filter((x) => FEATURES.includes(x)) : [];
|
|
7764
8031
|
const restart = Array.isArray(raw.restart) ? raw.restart.filter((x) => typeof x === "string") : [];
|
|
@@ -7769,9 +8036,9 @@ function loadState(dataDir) {
|
|
|
7769
8036
|
}
|
|
7770
8037
|
function saveState(dataDir, state) {
|
|
7771
8038
|
const f = statePath(dataDir);
|
|
7772
|
-
|
|
8039
|
+
mkdirSync11(dirname9(f), { recursive: true });
|
|
7773
8040
|
const tmp = `${f}.tmp`;
|
|
7774
|
-
|
|
8041
|
+
writeFileSync11(tmp, JSON.stringify(state, null, 2));
|
|
7775
8042
|
renameSync3(tmp, f);
|
|
7776
8043
|
}
|
|
7777
8044
|
function resetState(dataDir) {
|
|
@@ -7860,7 +8127,7 @@ function defaultOnboardDeps() {
|
|
|
7860
8127
|
openUrl: async (url) => {
|
|
7861
8128
|
const [cmd, args] = process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
|
|
7862
8129
|
try {
|
|
7863
|
-
|
|
8130
|
+
crossSpawn4(cmd, args, { stdio: "ignore", detached: true }).unref();
|
|
7864
8131
|
} catch {
|
|
7865
8132
|
}
|
|
7866
8133
|
},
|
|
@@ -8127,7 +8394,7 @@ async function workspace(c) {
|
|
|
8127
8394
|
const answer = await text(c.ui, current ? "Workspace folder" : "Workspace folder (Enter to skip)", current ?? "");
|
|
8128
8395
|
if (answer) {
|
|
8129
8396
|
const abs = resolve8(answer);
|
|
8130
|
-
|
|
8397
|
+
mkdirSync12(abs, { recursive: true });
|
|
8131
8398
|
cfg.roots = [abs];
|
|
8132
8399
|
saveConfig(cfg);
|
|
8133
8400
|
io2.out(`Workspace: ${abs}`);
|
|
@@ -8161,11 +8428,12 @@ async function network(c) {
|
|
|
8161
8428
|
}
|
|
8162
8429
|
cfg.port = port;
|
|
8163
8430
|
cfg.bind = where === "lan" ? "0.0.0.0" : "127.0.0.1";
|
|
8431
|
+
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
8432
|
saveConfig(cfg);
|
|
8165
|
-
const u = urlsFor(cfg.bind, cfg.port);
|
|
8433
|
+
const u = urlsFor(cfg.bind, cfg.port, void 0, cfg.https);
|
|
8166
8434
|
io2.out(`Friend will be at ${u.local}`);
|
|
8167
8435
|
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.");
|
|
8436
|
+
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
8437
|
return "done";
|
|
8170
8438
|
}
|
|
8171
8439
|
async function look(c) {
|
|
@@ -8184,7 +8452,7 @@ async function look(c) {
|
|
|
8184
8452
|
return "done";
|
|
8185
8453
|
}
|
|
8186
8454
|
function summary(cfg, c) {
|
|
8187
|
-
const u = urlsFor(cfg.bind, cfg.port);
|
|
8455
|
+
const u = urlsFor(cfg.bind, cfg.port, void 0, cfg.https);
|
|
8188
8456
|
const db = openDb(c.io.dataDir);
|
|
8189
8457
|
let owner = "not created yet";
|
|
8190
8458
|
try {
|
|
@@ -8214,7 +8482,7 @@ async function finish(c) {
|
|
|
8214
8482
|
io2.out("Start it whenever you like with: friend start");
|
|
8215
8483
|
return "done";
|
|
8216
8484
|
}
|
|
8217
|
-
const url = urlsFor(cfg.bind, cfg.port).local;
|
|
8485
|
+
const url = urlsFor(cfg.bind, cfg.port, void 0, cfg.https).local;
|
|
8218
8486
|
if (await yesNo(c.ui, "Open it in your browser when it is up?", true)) setTimeout(() => void deps.openUrl(url), 2500);
|
|
8219
8487
|
io2.out("Starting Friend. Press Ctrl+C to stop it.");
|
|
8220
8488
|
c.state.done.finish = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -8296,6 +8564,175 @@ async function doctorCommand(io2, partial = {}) {
|
|
|
8296
8564
|
return requiredMissing ? 1 : 0;
|
|
8297
8565
|
}
|
|
8298
8566
|
|
|
8567
|
+
// ../agent-cli/src/commands/daemon.ts
|
|
8568
|
+
import { spawn as spawn6, spawnSync as spawnSync2 } from "node:child_process";
|
|
8569
|
+
import { closeSync as closeSync2, existsSync as existsSync14, mkdirSync as mkdirSync13, openSync as openSync2, readFileSync as readFileSync10, renameSync as renameSync4, rmSync as rmSync6, statSync as statSync3, writeFileSync as writeFileSync12 } from "node:fs";
|
|
8570
|
+
import { connect } from "node:net";
|
|
8571
|
+
import { join as join18 } from "node:path";
|
|
8572
|
+
var USAGE3 = "Usage: friend daemon start | stop | restart | status";
|
|
8573
|
+
var MAX_LOG_BYTES = 5 * 1024 * 1024;
|
|
8574
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
8575
|
+
var realDeps = {
|
|
8576
|
+
launch(logFile) {
|
|
8577
|
+
const fd = openSync2(logFile, "a");
|
|
8578
|
+
try {
|
|
8579
|
+
const child = spawn6(process.execPath, [process.argv[1], "start"], { detached: true, stdio: ["ignore", fd, fd], windowsHide: true });
|
|
8580
|
+
child.unref();
|
|
8581
|
+
return child.pid;
|
|
8582
|
+
} finally {
|
|
8583
|
+
closeSync2(fd);
|
|
8584
|
+
}
|
|
8585
|
+
},
|
|
8586
|
+
alive(pid) {
|
|
8587
|
+
try {
|
|
8588
|
+
process.kill(pid, 0);
|
|
8589
|
+
return true;
|
|
8590
|
+
} catch (e) {
|
|
8591
|
+
return e.code === "EPERM";
|
|
8592
|
+
}
|
|
8593
|
+
},
|
|
8594
|
+
async kill(pid) {
|
|
8595
|
+
if (process.platform === "win32") {
|
|
8596
|
+
spawnSync2("taskkill", ["/pid", String(pid), "/T", "/F"], { windowsHide: true });
|
|
8597
|
+
} else {
|
|
8598
|
+
try {
|
|
8599
|
+
process.kill(pid, "SIGTERM");
|
|
8600
|
+
} catch {
|
|
8601
|
+
return;
|
|
8602
|
+
}
|
|
8603
|
+
for (let i = 0; i < 40 && realDeps.alive(pid); i++) await sleep(200);
|
|
8604
|
+
if (realDeps.alive(pid)) {
|
|
8605
|
+
try {
|
|
8606
|
+
process.kill(pid, "SIGKILL");
|
|
8607
|
+
} catch {
|
|
8608
|
+
}
|
|
8609
|
+
}
|
|
8610
|
+
}
|
|
8611
|
+
for (let i = 0; i < 25 && realDeps.alive(pid); i++) await sleep(200);
|
|
8612
|
+
},
|
|
8613
|
+
portOpen(port, withinMs) {
|
|
8614
|
+
return new Promise((resolve10) => {
|
|
8615
|
+
const s = connect({ port, host: "127.0.0.1" });
|
|
8616
|
+
const done = (ok) => {
|
|
8617
|
+
s.destroy();
|
|
8618
|
+
resolve10(ok);
|
|
8619
|
+
};
|
|
8620
|
+
s.setTimeout(withinMs, () => done(false));
|
|
8621
|
+
s.once("connect", () => done(true));
|
|
8622
|
+
s.once("error", () => done(false));
|
|
8623
|
+
});
|
|
8624
|
+
},
|
|
8625
|
+
now: Date.now
|
|
8626
|
+
};
|
|
8627
|
+
var pidFile = (dataDir) => join18(dataDir, "friend.pid");
|
|
8628
|
+
var logFileOf = (dataDir) => join18(dataDir, "friend.log");
|
|
8629
|
+
function readPid(dataDir) {
|
|
8630
|
+
try {
|
|
8631
|
+
const r = JSON.parse(readFileSync10(pidFile(dataDir), "utf8"));
|
|
8632
|
+
return Number.isInteger(r.pid) && r.pid > 0 ? r : null;
|
|
8633
|
+
} catch {
|
|
8634
|
+
return null;
|
|
8635
|
+
}
|
|
8636
|
+
}
|
|
8637
|
+
function running(dataDir, deps) {
|
|
8638
|
+
const r = readPid(dataDir);
|
|
8639
|
+
if (r && deps.alive(r.pid)) return r;
|
|
8640
|
+
if (r) rmSync6(pidFile(dataDir), { force: true });
|
|
8641
|
+
return null;
|
|
8642
|
+
}
|
|
8643
|
+
function since(ms) {
|
|
8644
|
+
const s = Math.max(0, Math.round(ms / 1e3));
|
|
8645
|
+
if (s < 90) return `${s} seconds`;
|
|
8646
|
+
if (s < 5400) return `${Math.round(s / 60)} minutes`;
|
|
8647
|
+
if (s < 129600) return `${Math.round(s / 3600)} hours`;
|
|
8648
|
+
return `${Math.round(s / 86400)} days`;
|
|
8649
|
+
}
|
|
8650
|
+
async function start(io2, deps) {
|
|
8651
|
+
const existing = running(io2.dataDir, deps);
|
|
8652
|
+
const cfg = loadConfig(io2.dataDir);
|
|
8653
|
+
const urls = urlsFor(cfg.bind, cfg.port, void 0, cfg.https);
|
|
8654
|
+
if (existing) {
|
|
8655
|
+
io2.out(`Friend is already running in the background (pid ${existing.pid}) at ${urls.local}`);
|
|
8656
|
+
return 0;
|
|
8657
|
+
}
|
|
8658
|
+
if (await deps.portOpen(cfg.port, 500)) {
|
|
8659
|
+
io2.err(`Port ${cfg.port} is already in use. Friend may be running in a terminal (stop it with Ctrl+C), or another program uses that port (friend lan port N).`);
|
|
8660
|
+
return 1;
|
|
8661
|
+
}
|
|
8662
|
+
mkdirSync13(io2.dataDir, { recursive: true });
|
|
8663
|
+
const log = logFileOf(io2.dataDir);
|
|
8664
|
+
try {
|
|
8665
|
+
if (existsSync14(log) && statSync3(log).size > MAX_LOG_BYTES) renameSync4(log, `${log}.old`);
|
|
8666
|
+
} catch {
|
|
8667
|
+
}
|
|
8668
|
+
const pid = deps.launch(log);
|
|
8669
|
+
if (!pid) {
|
|
8670
|
+
io2.err("Could not start Friend in the background.");
|
|
8671
|
+
return 1;
|
|
8672
|
+
}
|
|
8673
|
+
writeFileSync12(pidFile(io2.dataDir), JSON.stringify({ pid, startedAt: deps.now() }));
|
|
8674
|
+
for (let i = 0; i < 60; i++) {
|
|
8675
|
+
if (await deps.portOpen(cfg.port, 300)) {
|
|
8676
|
+
io2.out(`Friend is running in the background (pid ${pid}) at ${urls.local}`);
|
|
8677
|
+
for (const n of urls.network) io2.out(`Also on your network: ${n}`);
|
|
8678
|
+
io2.out(`Stop it with: friend daemon stop (log: ${log})`);
|
|
8679
|
+
return 0;
|
|
8680
|
+
}
|
|
8681
|
+
if (!deps.alive(pid)) break;
|
|
8682
|
+
await sleep(250);
|
|
8683
|
+
}
|
|
8684
|
+
rmSync6(pidFile(io2.dataDir), { force: true });
|
|
8685
|
+
io2.err(`Friend did not start. See the log: ${log}`);
|
|
8686
|
+
return 1;
|
|
8687
|
+
}
|
|
8688
|
+
async function stop(io2, deps) {
|
|
8689
|
+
const r = running(io2.dataDir, deps);
|
|
8690
|
+
if (!r) {
|
|
8691
|
+
io2.out("Friend is not running in the background.");
|
|
8692
|
+
return 0;
|
|
8693
|
+
}
|
|
8694
|
+
await deps.kill(r.pid);
|
|
8695
|
+
if (deps.alive(r.pid)) {
|
|
8696
|
+
io2.err(`Could not stop Friend (pid ${r.pid}). End that process yourself.`);
|
|
8697
|
+
return 1;
|
|
8698
|
+
}
|
|
8699
|
+
rmSync6(pidFile(io2.dataDir), { force: true });
|
|
8700
|
+
io2.out("Friend stopped.");
|
|
8701
|
+
return 0;
|
|
8702
|
+
}
|
|
8703
|
+
async function status(io2, deps) {
|
|
8704
|
+
const cfg = loadConfig(io2.dataDir);
|
|
8705
|
+
const urls = urlsFor(cfg.bind, cfg.port, void 0, cfg.https);
|
|
8706
|
+
const r = running(io2.dataDir, deps);
|
|
8707
|
+
if (r) {
|
|
8708
|
+
io2.out(`Friend is running in the background (pid ${r.pid}, for ${since(deps.now() - r.startedAt)}).`);
|
|
8709
|
+
io2.out(`Address: ${urls.local}`);
|
|
8710
|
+
for (const n of urls.network) io2.out(`Also on your network: ${n}`);
|
|
8711
|
+
io2.out(`Log: ${logFileOf(io2.dataDir)}`);
|
|
8712
|
+
return 0;
|
|
8713
|
+
}
|
|
8714
|
+
if (await deps.portOpen(cfg.port, 500)) io2.out(`Friend is not running in the background, but port ${cfg.port} is in use. It may be running in a terminal.`);
|
|
8715
|
+
else io2.out("Friend is not running. Start it in the background with: friend daemon start");
|
|
8716
|
+
return 1;
|
|
8717
|
+
}
|
|
8718
|
+
async function daemonCommand(args, io2, deps = realDeps) {
|
|
8719
|
+
switch (args[0]) {
|
|
8720
|
+
case "start":
|
|
8721
|
+
return start(io2, deps);
|
|
8722
|
+
case "stop":
|
|
8723
|
+
return stop(io2, deps);
|
|
8724
|
+
case "restart": {
|
|
8725
|
+
const code = await stop(io2, deps);
|
|
8726
|
+
return code === 0 ? start(io2, deps) : code;
|
|
8727
|
+
}
|
|
8728
|
+
case "status":
|
|
8729
|
+
return status(io2, deps);
|
|
8730
|
+
default:
|
|
8731
|
+
io2.err(USAGE3);
|
|
8732
|
+
return 1;
|
|
8733
|
+
}
|
|
8734
|
+
}
|
|
8735
|
+
|
|
8299
8736
|
// ../agent-cli/src/commands/claude.ts
|
|
8300
8737
|
async function check2(run2, command) {
|
|
8301
8738
|
const v = await run2(command, ["--version"]);
|
|
@@ -8364,7 +8801,7 @@ async function claudeCommand(args, io2, deps = {}) {
|
|
|
8364
8801
|
}
|
|
8365
8802
|
|
|
8366
8803
|
// ../agent-cli/src/commands/setup.ts
|
|
8367
|
-
import { mkdirSync as
|
|
8804
|
+
import { mkdirSync as mkdirSync14 } from "node:fs";
|
|
8368
8805
|
import { resolve as resolve9 } from "node:path";
|
|
8369
8806
|
async function setupCommand3(io2) {
|
|
8370
8807
|
const db = openDb(io2.dataDir);
|
|
@@ -8390,7 +8827,7 @@ async function setupCommand3(io2) {
|
|
|
8390
8827
|
const cfg = loadConfig(io2.dataDir);
|
|
8391
8828
|
if (workspace2) {
|
|
8392
8829
|
const abs = resolve9(workspace2);
|
|
8393
|
-
|
|
8830
|
+
mkdirSync14(abs, { recursive: true });
|
|
8394
8831
|
cfg.roots = [abs];
|
|
8395
8832
|
}
|
|
8396
8833
|
if (apiKey) cfg.apiKey = apiKey;
|
|
@@ -8417,7 +8854,7 @@ async function statusCommand3(io2) {
|
|
|
8417
8854
|
io2.out(`Data folder: ${cfg.dataDir}`);
|
|
8418
8855
|
io2.out(`Owner set up: ${owner}`);
|
|
8419
8856
|
io2.out(`Listens on: ${cfg.bind}:${cfg.port}`);
|
|
8420
|
-
for (const n of urlsFor(cfg.bind, cfg.port).network) io2.out(`Network URL: ${n}`);
|
|
8857
|
+
for (const n of urlsFor(cfg.bind, cfg.port, void 0, cfg.https).network) io2.out(`Network URL: ${n}`);
|
|
8421
8858
|
io2.out(`Public URL: ${cfg.publicUrl ?? "(none)"}`);
|
|
8422
8859
|
io2.out(`Workspace: ${cfg.roots.length ? cfg.roots.join(", ") : "(none, every file action asks first)"}`);
|
|
8423
8860
|
io2.out(`Model: ${cfg.model}`);
|
|
@@ -8426,12 +8863,13 @@ async function statusCommand3(io2) {
|
|
|
8426
8863
|
}
|
|
8427
8864
|
|
|
8428
8865
|
// ../agent-cli/src/cli.ts
|
|
8429
|
-
var
|
|
8866
|
+
var USAGE4 = `Usage: friend <command>
|
|
8430
8867
|
|
|
8431
8868
|
onboard guided setup, step by step: checks this PC, installs what is missing, asks what you want
|
|
8432
8869
|
doctor check what this PC has and what is missing (changes nothing)
|
|
8433
8870
|
setup create the owner account (password, PIN, assistant name)
|
|
8434
8871
|
start run the gateway and a local device node
|
|
8872
|
+
daemon start|stop|restart|status run Friend in the background (no terminal needed), stop it, or see if it is running
|
|
8435
8873
|
pair print a one-time code to connect another PC
|
|
8436
8874
|
node --url <ws-url> --code <code> [--roots a,b] [--name pc]
|
|
8437
8875
|
connect this PC to a Friend gateway
|
|
@@ -8456,6 +8894,8 @@ async function run(argv, io2) {
|
|
|
8456
8894
|
return await setupCommand3(io2);
|
|
8457
8895
|
case "start":
|
|
8458
8896
|
return await startCommand(io2);
|
|
8897
|
+
case "daemon":
|
|
8898
|
+
return await daemonCommand(rest, io2);
|
|
8459
8899
|
case "pair":
|
|
8460
8900
|
return await pairCommand(io2);
|
|
8461
8901
|
case "node":
|
|
@@ -8471,7 +8911,7 @@ async function run(argv, io2) {
|
|
|
8471
8911
|
case "version":
|
|
8472
8912
|
case "--version":
|
|
8473
8913
|
case "-v":
|
|
8474
|
-
io2.out(true ? "0.1.
|
|
8914
|
+
io2.out(true ? "0.1.3" : "development");
|
|
8475
8915
|
return 0;
|
|
8476
8916
|
case "lan":
|
|
8477
8917
|
return await lanCommand(rest, io2);
|
|
@@ -8490,7 +8930,7 @@ async function run(argv, io2) {
|
|
|
8490
8930
|
case "config":
|
|
8491
8931
|
return await configCommand(rest, io2);
|
|
8492
8932
|
default:
|
|
8493
|
-
io2.err(
|
|
8933
|
+
io2.err(USAGE4);
|
|
8494
8934
|
return 1;
|
|
8495
8935
|
}
|
|
8496
8936
|
} catch (e) {
|
|
@@ -8551,4 +8991,5 @@ var io = {
|
|
|
8551
8991
|
};
|
|
8552
8992
|
run(process.argv.slice(2), io).then((code) => {
|
|
8553
8993
|
if (code !== 0) process.exitCode = code;
|
|
8994
|
+
if (process.argv[2] === "start") setTimeout(() => process.exit(), 1e3).unref();
|
|
8554
8995
|
});
|