@echomem/mcp 1.4.21 → 1.4.23

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/setup.js CHANGED
@@ -1,16 +1,17 @@
1
1
  /**
2
- * `echomem-mcp init | setup | login | unlock | status | logout` — onboarding for the local bridge (spec §8).
2
+ * `echomem-mcp init | setup | login | unlock | status | logout` — local bridge lifecycle (spec §8).
3
3
  *
4
4
  * Design goals from the spec:
5
- * - One command one local browser setup one reload.
5
+ * - `login` establishes the account and trusted device only; it never reads local history.
6
+ * - `init` runs one ordered flow: local-history permission/report, login, plan if needed, then extraction.
6
7
  * - Both secrets (API token + encryption key) ride a single browser flow and land in the local
7
8
  * keystore — never in the client's MCP config, never in the agent's chat context.
8
9
  * - Re-unlock after the key's TTL is one step, not a re-setup.
9
10
  *
10
- * The browser flow stays on a localhost setup page. That local page collects email OTP consent and
11
- * an encryption passphrase, while this process asks the hosted API to send/verify OTP and mint a
12
- * device token. Secrets land in the local keystore — never in the client's MCP config, never in
13
- * the agent's chat context.
11
+ * Both browser flows stay on a localhost page. Login collects email OTP consent and an encryption
12
+ * passphrase; onboarding separately asks to access local coding history. This process asks the
13
+ * hosted API to send/verify OTP and mint a device token. Secrets land in the local keystore —
14
+ * never in the client's MCP config or the agent's chat context.
14
15
  */
15
16
  import http from "node:http";
16
17
  import { randomUUID } from "node:crypto";
@@ -37,6 +38,28 @@ import { checkLatestUpdateStatus, compareSemver, readCachedUpdateStatus } from "
37
38
  // localhost bridge. The hosted API only sends OTP email, verifies the code, and mints a device token.
38
39
  const API_BASE_URL = (process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app").replace(/\/$/, "");
39
40
  const PRICING_URL = (process.env.ECHO_PRICING_URL || "https://echoknows.com/pricing").replace(/\/$/, "");
41
+ function hostedBillingEndpoint(pathname) {
42
+ const url = new URL(PRICING_URL);
43
+ url.pathname = pathname;
44
+ url.search = "";
45
+ url.hash = "";
46
+ return url.toString();
47
+ }
48
+ function isExpectedStripeUrl(value, kind) {
49
+ if (typeof value !== "string")
50
+ return false;
51
+ try {
52
+ const url = new URL(value);
53
+ if (url.protocol !== "https:")
54
+ return false;
55
+ return kind === "checkout"
56
+ ? url.hostname === "checkout.stripe.com"
57
+ : url.hostname === "billing.stripe.com";
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ }
40
63
  const CODEX_SKILL_NAMES = [
41
64
  "echomem-search",
42
65
  "echomem-save",
@@ -266,6 +289,52 @@ function resolveGlobalEntry() {
266
289
  }
267
290
  return null;
268
291
  }
292
+ export function needsDurableGlobalUpdate(runningEntry, durableEntry, targetVersion = MCP_PACKAGE_VERSION) {
293
+ if (!isEphemeralNpxPath(runningEntry))
294
+ return false;
295
+ const installedVersion = durableEntry
296
+ ? packageVersionFromPath(durableEntry)?.version
297
+ : undefined;
298
+ return installedVersion !== targetVersion;
299
+ }
300
+ function installDurableGlobalUpdate() {
301
+ const runningEntry = (() => {
302
+ try {
303
+ return fs.realpathSync(process.argv[1] || "");
304
+ }
305
+ catch {
306
+ return process.argv[1] || "";
307
+ }
308
+ })();
309
+ const currentGlobalEntry = resolveGlobalEntry();
310
+ if (!needsDurableGlobalUpdate(runningEntry, currentGlobalEntry))
311
+ return;
312
+ const packageSpec = `${MCP_PACKAGE_NAME}@${MCP_PACKAGE_VERSION}`;
313
+ console.log(`Installing durable ${packageSpec} before updating client configs…`);
314
+ const npmExecPath = process.env.npm_execpath;
315
+ try {
316
+ if (npmExecPath && fs.existsSync(npmExecPath)) {
317
+ execFileSync(process.execPath, [npmExecPath, "install", "-g", packageSpec], {
318
+ stdio: "inherit",
319
+ });
320
+ }
321
+ else {
322
+ execFileSync(process.platform === "win32" ? "npm.cmd" : "npm", ["install", "-g", packageSpec], {
323
+ stdio: "inherit",
324
+ });
325
+ }
326
+ }
327
+ catch (error) {
328
+ throw new Error(`Could not install ${packageSpec} globally: ${error instanceof Error ? error.message : String(error)}`);
329
+ }
330
+ const installedEntry = resolveGlobalEntry();
331
+ const installedVersion = installedEntry
332
+ ? packageVersionFromPath(installedEntry)?.version
333
+ : undefined;
334
+ if (installedVersion !== MCP_PACKAGE_VERSION) {
335
+ throw new Error(`Global EchoMem bridge is ${installedVersion || "missing"} after update; expected ${MCP_PACKAGE_VERSION}.`);
336
+ }
337
+ }
269
338
  /** The TOML block EchoMem adds to ~/.codex/config.toml. No secret — the bridge reads the keystore. */
270
339
  export function codexTomlBlock(entry) {
271
340
  const command = JSON.stringify(String(entry.command));
@@ -322,6 +391,15 @@ function echomemGuidanceBlock() {
322
391
  "- Before re-deriving past decisions, project context, or anything the user likely settled before: call `search_memories` first.",
323
392
  "- When meaningful work wraps up (a decision, a fix, a milestone) or the user asks to remember something: call `save_conversation`.",
324
393
  "- If the user pastes a session carryover/checkpoint: it may reference `get_checkpoint_by_context` — use it to pull the checkpoint/decision trail when you need more than the snapshot.",
394
+ "",
395
+ "### Company group memory",
396
+ "- Use `get_group_context` when the user asks who is in their company group, what teammates are responsible for, or what work is already covered. Treat declared participant fields as facts and published-memory conclusions as evidence or inference.",
397
+ "- A group publication is separate from a globally public memory: publishing to a group creates a group-scoped snapshot and must not change the encrypted original or its global `is_public` setting.",
398
+ "- If a user asks to create a group, call `create_memory_group`; if they ask for a code to share, call `create_group_invite` and return the secret invite code only to that user. Never save the invite code to memory or include it in logs, analytics, summaries, or unrelated output.",
399
+ "- If a user supplies an `echo_grp_...` code and explicitly asks to join, call `join_memory_group`. Joining never authorizes publishing by itself and must not move a user out of another group.",
400
+ "- For requests such as “prepare my recent work memories,” “upload work from this ticket,” or “publish work since my last sync,” call `prepare_group_publication` first. This is a no-write preview. For encrypted accounts, tell the user to run `echomem-mcp unlock` locally if the tool reports that the key is required.",
401
+ "- After preparing, select only exact candidate memory IDs that match the user's stated scope, exclude already-published or exact-content duplicates, summarize what would be shared, and ask the user to confirm. Do not call `complete_group_publication` until the user explicitly confirms that preview.",
402
+ "- On confirmation, call `complete_group_publication` with the exact `scanId`, selected memory IDs, and `confirmed: true`. An explicit request to join and upload still requires the publication preview and confirmation after joining.",
325
403
  "- If the user asks to show, reopen, restart, or bring back the EchoMem HUD (the context-health overlay), run the shell command `echomem-hud app --client auto`.",
326
404
  "- If the user wants the HUD to come back after a computer restart, run the shell command `echomem-hud autostart on --client auto`.",
327
405
  AGENTS_MD_END,
@@ -1201,6 +1279,11 @@ export function startCallbackServer(opts = {}) {
1201
1279
  const dashboardTimeoutMs = 4 * 60 * 60 * 1000;
1202
1280
  const expectedNonce = opts.nonce;
1203
1281
  const scanId = opts.scanId ?? randomUUID();
1282
+ const flow = opts.flow ?? "onboarding";
1283
+ const isLoginFlow = flow === "login";
1284
+ // A login screen must not be blocked by a local-history permission. That permission belongs to
1285
+ // onboarding and is intentionally enforced separately below.
1286
+ const requiresReportConsent = opts.requireReportConsent === true && !isLoginFlow;
1204
1287
  return new Promise((resolveOuter, rejectOuter) => {
1205
1288
  const onToken = deferred();
1206
1289
  const decision = deferred();
@@ -1208,9 +1291,10 @@ export function startCallbackServer(opts = {}) {
1208
1291
  let stats = null;
1209
1292
  let authUrl = "";
1210
1293
  let switchAccountUrl = "";
1211
- let connected = false;
1294
+ let connected = Boolean(opts.initialToken?.token);
1295
+ let activeDeviceToken = opts.initialToken?.token || "";
1212
1296
  let pendingLocalAuth = null;
1213
- let reportConsentGranted = opts.requireReportConsent !== true;
1297
+ let reportConsentGranted = !requiresReportConsent;
1214
1298
  let progress = { status: "idle", total: 0, completed: 0, running: 0, queued: 0, failed: 0, extracted: 0 };
1215
1299
  let migrateStarted = false;
1216
1300
  let tokenRefreshHandler = null;
@@ -1277,7 +1361,7 @@ export function startCallbackServer(opts = {}) {
1277
1361
  const handleCallback = (res, token, key, nonce) => {
1278
1362
  if (!checkNonce(nonce))
1279
1363
  return void text(res, 403, "bad nonce");
1280
- if (opts.requireReportConsent === true && !reportConsentGranted) {
1364
+ if (requiresReportConsent && !reportConsentGranted) {
1281
1365
  return void json(res, 403, {
1282
1366
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1283
1367
  message: "Allow local history access in the setup page before connecting EchoMem.",
@@ -1288,6 +1372,7 @@ export function startCallbackServer(opts = {}) {
1288
1372
  console.log(`[${new Date().toISOString()}] Device token callback received.`);
1289
1373
  const firstToken = !onToken.settled();
1290
1374
  connected = true;
1375
+ activeDeviceToken = token;
1291
1376
  const setupPath = `/setup?nonce=${encodeURIComponent(nonce || "")}&connected=1`;
1292
1377
  res.writeHead(200, { "Content-Type": "text/html" }).end(`<!doctype html><html><head><meta charset="utf-8"></head><body style="font-family:system-ui;padding:3rem;text-align:center"><h2>EchoMem connected</h2><p>Returning to the local setup page...</p><p><a href="${setupPath}">Continue</a></p><script>(function(){var target=${JSON.stringify(setupPath)};try{if(window.opener&&!window.opener.closed){window.opener.postMessage({type:"echomem:connected",nonce:${JSON.stringify(nonce || "")}},window.location.origin);window.close();setTimeout(function(){window.location.href=target;},500);return;}}catch(_){}window.location.href=target;})();</script></body></html>`);
1293
1378
  const callbackToken = { token, key };
@@ -1306,7 +1391,7 @@ export function startCallbackServer(opts = {}) {
1306
1391
  text(res, 403, "bad nonce");
1307
1392
  return true;
1308
1393
  }
1309
- if (opts.requireReportConsent === true && !reportConsentGranted) {
1394
+ if (requiresReportConsent && !reportConsentGranted) {
1310
1395
  json(res, 403, {
1311
1396
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1312
1397
  message: "Allow local history access in the setup page before connecting EchoMem.",
@@ -1318,6 +1403,7 @@ export function startCallbackServer(opts = {}) {
1318
1403
  const resolveLocalToken = (token, key) => {
1319
1404
  const firstToken = !onToken.settled();
1320
1405
  connected = true;
1406
+ activeDeviceToken = token;
1321
1407
  pendingLocalAuth = null;
1322
1408
  const callbackToken = { token, key };
1323
1409
  if (firstToken) {
@@ -1330,6 +1416,17 @@ export function startCallbackServer(opts = {}) {
1330
1416
  }
1331
1417
  armTimeout();
1332
1418
  };
1419
+ const isOnboardingOnlyRoute = (route) => [
1420
+ "/report-consent",
1421
+ "/report",
1422
+ "/stats",
1423
+ "/billing-status",
1424
+ "/billing-checkout",
1425
+ "/billing-portal",
1426
+ "/progress",
1427
+ "/migrate",
1428
+ "/skip",
1429
+ ].includes(route);
1333
1430
  const handleLocalSendOtp = async (res, body) => {
1334
1431
  if (rejectIfLocalAuthBlocked(res, asString(body.nonce)))
1335
1432
  return;
@@ -1472,6 +1569,15 @@ export function startCallbackServer(opts = {}) {
1472
1569
  const url = new URL(req.url || "/", "http://127.0.0.1");
1473
1570
  const route = url.pathname;
1474
1571
  const run = async () => {
1572
+ // Defense in depth: the login bridge never exposes the routes that can inspect or move
1573
+ // local conversation history. The browser UI also routes around them, but the server is
1574
+ // the authority for this privacy boundary.
1575
+ if (isLoginFlow && isOnboardingOnlyRoute(route)) {
1576
+ return void json(res, 404, {
1577
+ error: "ONBOARDING_REQUIRED",
1578
+ message: "Run `echomem-mcp init` to access local-history onboarding.",
1579
+ });
1580
+ }
1475
1581
  if ((route === "/city" || route.startsWith("/city/")) && req.method === "GET") {
1476
1582
  serveRepoCityAsset(route, res);
1477
1583
  return;
@@ -1507,12 +1613,13 @@ export function startCallbackServer(opts = {}) {
1507
1613
  return void text(res, 403, "bad nonce");
1508
1614
  json(res, 200, {
1509
1615
  connected,
1616
+ flow,
1510
1617
  authUrl,
1511
1618
  switchAccountUrl: switchAccountUrl || authUrl,
1512
1619
  localOnly: true,
1513
1620
  localAuth: true,
1514
1621
  workspacePath: process.cwd(),
1515
- consentRequired: opts.requireReportConsent === true,
1622
+ consentRequired: requiresReportConsent,
1516
1623
  consentGranted: reportConsentGranted,
1517
1624
  });
1518
1625
  return;
@@ -1605,7 +1712,7 @@ export function startCallbackServer(opts = {}) {
1605
1712
  if (route === "/stats" && req.method === "GET") {
1606
1713
  if (!checkNonce(url.searchParams.get("nonce") || undefined))
1607
1714
  return void text(res, 403, "bad nonce");
1608
- if (opts.requireReportConsent === true && !reportConsentGranted) {
1715
+ if (requiresReportConsent && !reportConsentGranted) {
1609
1716
  return void json(res, 403, {
1610
1717
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1611
1718
  message: "Allow local history access before continuing setup.",
@@ -1618,15 +1725,16 @@ export function startCallbackServer(opts = {}) {
1618
1725
  return;
1619
1726
  }
1620
1727
  if (route === "/billing-status" && req.method === "GET") {
1728
+ res.setHeader("Cache-Control", "no-store, max-age=0");
1621
1729
  if (!checkNonce(url.searchParams.get("nonce") || undefined))
1622
1730
  return void text(res, 403, "bad nonce");
1623
- if (opts.requireReportConsent === true && !reportConsentGranted) {
1731
+ if (requiresReportConsent && !reportConsentGranted) {
1624
1732
  return void json(res, 403, {
1625
1733
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1626
1734
  message: "Allow local history access before continuing setup.",
1627
1735
  });
1628
1736
  }
1629
- const token = new KeyStore().getToken();
1737
+ const token = activeDeviceToken || new KeyStore().getToken();
1630
1738
  const pricingUrl = `${PRICING_URL}?source=mcp_onboarding`;
1631
1739
  if (!token) {
1632
1740
  json(res, 200, { plan: "free", paid: false, trialAvailable: true, trialUsed: false, pricingUrl });
@@ -1662,12 +1770,84 @@ export function startCallbackServer(opts = {}) {
1662
1770
  }
1663
1771
  return;
1664
1772
  }
1773
+ if ((route === "/billing-checkout" || route === "/billing-portal") && req.method === "POST") {
1774
+ let body;
1775
+ try {
1776
+ body = await readJsonBody(req);
1777
+ }
1778
+ catch {
1779
+ text(res, 400, "bad json");
1780
+ return;
1781
+ }
1782
+ if (!checkNonce(asString(body.nonce)))
1783
+ return void text(res, 403, "bad nonce");
1784
+ if (requiresReportConsent && !reportConsentGranted) {
1785
+ return void json(res, 403, {
1786
+ error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1787
+ message: "Allow local history access before managing an onboarding plan.",
1788
+ });
1789
+ }
1790
+ const token = activeDeviceToken || new KeyStore().getToken();
1791
+ if (!connected || !token) {
1792
+ return void json(res, 401, {
1793
+ error: "ECHOMEM_LOGIN_REQUIRED",
1794
+ message: "Connect your EchoMem account before continuing to billing.",
1795
+ });
1796
+ }
1797
+ const isCheckout = route === "/billing-checkout";
1798
+ const requestBody = { source: "mcp_onboarding" };
1799
+ if (isCheckout) {
1800
+ const plan = asString(body.plan)?.toLowerCase();
1801
+ if (plan !== "pro" && plan !== "power") {
1802
+ return void json(res, 400, {
1803
+ error: "INVALID_PLAN",
1804
+ message: "Choose Pro or Power to continue to checkout.",
1805
+ });
1806
+ }
1807
+ requestBody.plan = plan;
1808
+ requestBody.trial = body.trial === true;
1809
+ }
1810
+ else {
1811
+ const plan = asString(body.plan)?.toLowerCase();
1812
+ if (plan) {
1813
+ if (plan !== "pro" && plan !== "power") {
1814
+ return void json(res, 400, {
1815
+ error: "INVALID_PLAN",
1816
+ message: "Choose Pro or Power to change plans.",
1817
+ });
1818
+ }
1819
+ requestBody.plan = plan;
1820
+ }
1821
+ }
1822
+ try {
1823
+ const response = await axios.post(hostedBillingEndpoint(isCheckout ? "/api/billing/checkout" : "/api/billing/portal"), requestBody, {
1824
+ timeout: 15_000,
1825
+ headers: {
1826
+ "Content-Type": "application/json",
1827
+ Authorization: `Bearer ${token}`,
1828
+ },
1829
+ });
1830
+ const hostedUrl = response.data?.url;
1831
+ if (!isExpectedStripeUrl(hostedUrl, isCheckout ? "checkout" : "portal")) {
1832
+ return void json(res, 502, {
1833
+ error: "INVALID_BILLING_DESTINATION",
1834
+ message: "Echo billing returned an unexpected destination.",
1835
+ });
1836
+ }
1837
+ json(res, 200, { url: hostedUrl });
1838
+ }
1839
+ catch (error) {
1840
+ const detail = publicAxiosError(error, isCheckout ? "Could not start secure checkout." : "Could not open secure plan management.");
1841
+ json(res, detail.status, { error: "BILLING_REQUEST_FAILED", message: detail.message });
1842
+ }
1843
+ return;
1844
+ }
1665
1845
  if (route === "/report" && req.method === "GET") {
1666
1846
  // Local forensic "Context Doctor" report — computed locally, served BEFORE auth (scan-first).
1667
1847
  res.setHeader("Cache-Control", "no-store");
1668
1848
  if (!checkNonce(url.searchParams.get("nonce") || undefined))
1669
1849
  return void text(res, 403, "bad nonce");
1670
- if (opts.requireReportConsent === true && !reportConsentGranted) {
1850
+ if (requiresReportConsent && !reportConsentGranted) {
1671
1851
  return void json(res, 403, {
1672
1852
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1673
1853
  message: "Allow local history access before starting the local scan.",
@@ -1768,7 +1948,7 @@ export function startCallbackServer(opts = {}) {
1768
1948
  if (route === "/progress" && req.method === "GET") {
1769
1949
  if (!checkNonce(url.searchParams.get("nonce") || undefined))
1770
1950
  return void text(res, 403, "bad nonce");
1771
- if (opts.requireReportConsent === true && !reportConsentGranted) {
1951
+ if (requiresReportConsent && !reportConsentGranted) {
1772
1952
  return void json(res, 403, {
1773
1953
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1774
1954
  message: "Allow local history access before continuing setup.",
@@ -1816,6 +1996,7 @@ export function startCallbackServer(opts = {}) {
1816
1996
  /* already logged out locally */
1817
1997
  }
1818
1998
  connected = false;
1999
+ activeDeviceToken = "";
1819
2000
  const revokedPendingCredential = await revokePendingLocalAuth();
1820
2001
  stats = null;
1821
2002
  migrateStarted = false;
@@ -1845,7 +2026,7 @@ export function startCallbackServer(opts = {}) {
1845
2026
  }
1846
2027
  if (!checkNonce(asString(body.nonce)))
1847
2028
  return void text(res, 403, "bad nonce");
1848
- if (opts.requireReportConsent === true && !reportConsentGranted) {
2029
+ if (requiresReportConsent && !reportConsentGranted) {
1849
2030
  return void json(res, 403, {
1850
2031
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1851
2032
  message: "Allow local history access before starting extraction.",
@@ -1906,6 +2087,8 @@ export function startCallbackServer(opts = {}) {
1906
2087
  sockets.add(socket);
1907
2088
  socket.on("close", () => sockets.delete(socket));
1908
2089
  });
2090
+ if (opts.initialToken)
2091
+ onToken.resolve(opts.initialToken);
1909
2092
  armTimeout();
1910
2093
  server.on("error", (e) => rejectOuter(e));
1911
2094
  server.listen(opts.port ?? 0, "127.0.0.1", () => {
@@ -2140,9 +2323,9 @@ async function cmdSetup(flags) {
2140
2323
  /**
2141
2324
  * `echomem-mcp init` — the one-command install. Configures EVERY coding agent installed on this
2142
2325
  * machine (Codex + Claude Code + Claude Desktop, not just auto-detected ones), installs EchoMem's
2143
- * Codex skills, writes the AGENTS.md memory guidance, logs in via the browser, and launches the
2144
- * context HUD the whole product in a single command. `setup`/`update` remain the granular
2145
- * primitives; init just picks the "do everything" defaults and frames the result.
2326
+ * Codex skills, writes the AGENTS.md memory guidance, and launches the context HUD. One browser
2327
+ * bridge then runs permission report login plan if needed extraction in that order.
2328
+ * `setup`/`login`/`update` remain granular primitives; init picks the full product defaults.
2146
2329
  */
2147
2330
  async function cmdInit(flags) {
2148
2331
  console.log("Setting up EchoMem — shared memory for all your coding agents, plus the live context HUD.\n");
@@ -2151,10 +2334,10 @@ async function cmdInit(flags) {
2151
2334
  // 2. Bring the HUD up NOW (non-blocking) so everything is already running while onboarding proceeds.
2152
2335
  if (!flags["no-hud"])
2153
2336
  await cmdSetupHud(flags);
2154
- // 3. Start onboarding opens the browser dashboard (scan connect extraction) and waits there.
2337
+ // 3. Start one ordered onboarding bridge. A fresh device logs in only after consent + report.
2155
2338
  console.log("");
2156
- if (!flags["skip-login"] && !flags["no-login"] && !await cmdLogin(flags)) {
2157
- console.log("\nEchoMem is configured, but this device was not connected. Restart EchoMem and use the new page it opens.");
2339
+ if (!flags["skip-login"] && !flags["no-login"] && !await cmdOnboarding(flags)) {
2340
+ console.log("\nEchoMem is configured, but onboarding did not finish. Run `echomem-mcp init` again when you are ready.");
2158
2341
  return;
2159
2342
  }
2160
2343
  console.log("");
@@ -2215,6 +2398,8 @@ function writeCodexSkillsForTargets(targets) {
2215
2398
  }
2216
2399
  }
2217
2400
  async function cmdUpdate(flags) {
2401
+ if (!flags.dev)
2402
+ installDurableGlobalUpdate();
2218
2403
  await cmdSetup({ ...flags, "skip-login": true });
2219
2404
  console.log(`Update config complete. Start a new MCP session to load ${MCP_PACKAGE_LABEL}.`);
2220
2405
  }
@@ -2279,6 +2464,19 @@ function parseHudClient(value) {
2279
2464
  ? value
2280
2465
  : "auto";
2281
2466
  }
2467
+ function localBridgeOptions(flags) {
2468
+ const port = typeof flags["dev-port"] === "string" ? Number(flags["dev-port"]) : undefined;
2469
+ if (port !== undefined && (!Number.isInteger(port) || port < 1024 || port > 65535)) {
2470
+ throw new Error("--dev-port must be an integer between 1024 and 65535");
2471
+ }
2472
+ const requestedNonce = typeof flags["dev-nonce"] === "string" ? flags["dev-nonce"].trim() : undefined;
2473
+ if (requestedNonce && port === undefined)
2474
+ throw new Error("--dev-nonce requires --dev-port");
2475
+ if (requestedNonce && !/^[A-Za-z0-9-]{16,128}$/.test(requestedNonce)) {
2476
+ throw new Error("--dev-nonce must contain 16-128 letters, numbers, or hyphens");
2477
+ }
2478
+ return { port, nonce: requestedNonce || randomUUID() };
2479
+ }
2282
2480
  async function cmdLogin(flags) {
2283
2481
  // Manual path (also the headless path): secrets supplied as flags.
2284
2482
  if (typeof flags.token === "string") {
@@ -2291,20 +2489,51 @@ async function cmdLogin(flags) {
2291
2489
  process.exitCode = 1;
2292
2490
  return ok;
2293
2491
  }
2294
- // Browser path: open a localhost dashboard. Login, legal confirmation, and encryption
2295
- // passphrase entry all happen through this local bridge. The nonce gates every local route.
2492
+ const store = new KeyStore();
2493
+ if (!flags.force && store.getToken() && store.getKey()) {
2494
+ console.log("This device is already connected. Run `echomem-mcp login --force` to sign in with a different account.");
2495
+ return true;
2496
+ }
2497
+ // Browser path: this bridge does only account/device authentication. It intentionally exposes
2498
+ // no local-history routes; `init` owns scan consent, reporting, and optional extraction.
2296
2499
  console.log("Opening your browser to connect this device locally…");
2297
- const devPortRaw = typeof flags["dev-port"] === "string" ? Number(flags["dev-port"]) : undefined;
2298
- if (devPortRaw !== undefined && (!Number.isInteger(devPortRaw) || devPortRaw < 1024 || devPortRaw > 65535)) {
2299
- throw new Error("--dev-port must be an integer between 1024 and 65535");
2500
+ const { port, nonce } = localBridgeOptions(flags);
2501
+ const srv = await startCallbackServer({ port, nonce, flow: "login" });
2502
+ const localLoginUrl = `http://127.0.0.1:${srv.port}/setup?nonce=${nonce}`;
2503
+ openBrowser(localLoginUrl);
2504
+ console.log(`If it didn't open, visit:\n ${localLoginUrl}\n`);
2505
+ console.log("Waiting for local login for up to 15 minutes…");
2506
+ try {
2507
+ const credentials = await srv.onToken;
2508
+ const ok = await verifyAndPrint(credentials);
2509
+ srv.close();
2510
+ if (!ok) {
2511
+ process.exitCode = 1;
2512
+ return false;
2513
+ }
2514
+ console.log("✅ This device is connected. Run `echomem-mcp init` to begin local-history onboarding.");
2515
+ return true;
2300
2516
  }
2301
- const devNonce = typeof flags["dev-nonce"] === "string" ? flags["dev-nonce"].trim() : undefined;
2302
- if (devNonce && devPortRaw === undefined)
2303
- throw new Error("--dev-nonce requires --dev-port");
2304
- if (devNonce && !/^[A-Za-z0-9-]{16,128}$/.test(devNonce)) {
2305
- throw new Error("--dev-nonce must contain 16-128 letters, numbers, or hyphens");
2517
+ catch (error) {
2518
+ srv.close();
2519
+ console.error(`❌ ${error instanceof Error ? error.message : String(error)}. Run \`echomem-mcp login\` to retry.`);
2520
+ process.exitCode = 1;
2521
+ return false;
2306
2522
  }
2307
- const nonce = devNonce || randomUUID();
2523
+ }
2524
+ /**
2525
+ * The local-history onboarding flow. Existing device credentials are reused when available; a
2526
+ * fresh device stays in this same bridge and asks for login only after permission and report.
2527
+ */
2528
+ async function cmdOnboarding(flags) {
2529
+ const store = new KeyStore();
2530
+ const savedToken = store.getToken();
2531
+ const savedKey = store.getKey();
2532
+ const initialToken = savedToken && savedKey
2533
+ ? { token: savedToken, key: savedKey }
2534
+ : undefined;
2535
+ console.log("Opening your browser for EchoMem onboarding…");
2536
+ const { port, nonce } = localBridgeOptions(flags);
2308
2537
  let stats = null;
2309
2538
  let forensicReport = null;
2310
2539
  let forensicConsent = "pending";
@@ -2323,8 +2552,10 @@ async function cmdLogin(flags) {
2323
2552
  updatedAt: forensicStartedAt,
2324
2553
  };
2325
2554
  const srv = await startCallbackServer({
2326
- port: devPortRaw,
2555
+ port,
2327
2556
  nonce,
2557
+ flow: "onboarding",
2558
+ initialToken,
2328
2559
  requireReportConsent: true,
2329
2560
  getStats: () => stats,
2330
2561
  getReport: () => forensicReport,
@@ -2368,7 +2599,7 @@ async function cmdLogin(flags) {
2368
2599
  const localSetupUrl = `http://127.0.0.1:${srv.port}/setup?nonce=${nonce}`;
2369
2600
  openBrowser(localSetupUrl);
2370
2601
  console.log(`If it didn't open, visit:\n ${localSetupUrl}\n`);
2371
- console.log("Waiting for local setup for up to 15 minutes…");
2602
+ console.log("Waiting for local-history onboarding for up to 15 minutes…");
2372
2603
  const startForensicScan = () => {
2373
2604
  if (forensicScanStarted || forensicConsent !== "allowed")
2374
2605
  return;
@@ -3116,15 +3347,16 @@ function cmdLogout() {
3116
3347
  const HELP = `EchoMem MCP — local memory bridge
3117
3348
 
3118
3349
  Usage:
3119
- echomem-mcp init One command: configure every installed agent + HUD + log in
3350
+ echomem-mcp init One command: configure agents + HUD + login + local-history onboarding
3120
3351
  echomem-mcp Run the MCP server (stdio; default — used by your editor)
3121
- echomem-mcp setup [--client X] Detect editor, write its MCP config, then log in
3352
+ echomem-mcp setup [--client X] Detect editor, write its MCP config, then connect this device
3122
3353
  echomem-mcp setup --skip-login Write MCP config without opening login/browser
3123
3354
  echomem-mcp setup --no-codex-skills Skip installing the bundled EchoMem Codex skills
3124
- echomem-mcp update --all Repoint detected clients to this installed bridge; no login/browser
3355
+ echomem-mcp update --all Install this bridge durably + repoint detected clients; no login/browser
3125
3356
  echomem-mcp update --client X Repoint one MCP client; no login/browser
3126
3357
  echomem-mcp setup --with-hud Configure MCP, then launch the EchoMem context HUD
3127
- echomem-mcp login Connect this device in the local browser page (or --token/--passphrase)
3358
+ echomem-mcp login Connect this device only; never scans or imports local history
3359
+ echomem-mcp login --force Reconnect this device with a different account
3128
3360
  echomem-mcp unlock Privately unlock the vault on this trusted device
3129
3361
  echomem-mcp lock Remove the local vault key while keeping the device login
3130
3362
  echomem-mcp status Show token/key/clients