@echomem/mcp 1.4.21 → 1.4.22

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));
@@ -1201,6 +1270,11 @@ export function startCallbackServer(opts = {}) {
1201
1270
  const dashboardTimeoutMs = 4 * 60 * 60 * 1000;
1202
1271
  const expectedNonce = opts.nonce;
1203
1272
  const scanId = opts.scanId ?? randomUUID();
1273
+ const flow = opts.flow ?? "onboarding";
1274
+ const isLoginFlow = flow === "login";
1275
+ // A login screen must not be blocked by a local-history permission. That permission belongs to
1276
+ // onboarding and is intentionally enforced separately below.
1277
+ const requiresReportConsent = opts.requireReportConsent === true && !isLoginFlow;
1204
1278
  return new Promise((resolveOuter, rejectOuter) => {
1205
1279
  const onToken = deferred();
1206
1280
  const decision = deferred();
@@ -1208,9 +1282,10 @@ export function startCallbackServer(opts = {}) {
1208
1282
  let stats = null;
1209
1283
  let authUrl = "";
1210
1284
  let switchAccountUrl = "";
1211
- let connected = false;
1285
+ let connected = Boolean(opts.initialToken?.token);
1286
+ let activeDeviceToken = opts.initialToken?.token || "";
1212
1287
  let pendingLocalAuth = null;
1213
- let reportConsentGranted = opts.requireReportConsent !== true;
1288
+ let reportConsentGranted = !requiresReportConsent;
1214
1289
  let progress = { status: "idle", total: 0, completed: 0, running: 0, queued: 0, failed: 0, extracted: 0 };
1215
1290
  let migrateStarted = false;
1216
1291
  let tokenRefreshHandler = null;
@@ -1277,7 +1352,7 @@ export function startCallbackServer(opts = {}) {
1277
1352
  const handleCallback = (res, token, key, nonce) => {
1278
1353
  if (!checkNonce(nonce))
1279
1354
  return void text(res, 403, "bad nonce");
1280
- if (opts.requireReportConsent === true && !reportConsentGranted) {
1355
+ if (requiresReportConsent && !reportConsentGranted) {
1281
1356
  return void json(res, 403, {
1282
1357
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1283
1358
  message: "Allow local history access in the setup page before connecting EchoMem.",
@@ -1288,6 +1363,7 @@ export function startCallbackServer(opts = {}) {
1288
1363
  console.log(`[${new Date().toISOString()}] Device token callback received.`);
1289
1364
  const firstToken = !onToken.settled();
1290
1365
  connected = true;
1366
+ activeDeviceToken = token;
1291
1367
  const setupPath = `/setup?nonce=${encodeURIComponent(nonce || "")}&connected=1`;
1292
1368
  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
1369
  const callbackToken = { token, key };
@@ -1306,7 +1382,7 @@ export function startCallbackServer(opts = {}) {
1306
1382
  text(res, 403, "bad nonce");
1307
1383
  return true;
1308
1384
  }
1309
- if (opts.requireReportConsent === true && !reportConsentGranted) {
1385
+ if (requiresReportConsent && !reportConsentGranted) {
1310
1386
  json(res, 403, {
1311
1387
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1312
1388
  message: "Allow local history access in the setup page before connecting EchoMem.",
@@ -1318,6 +1394,7 @@ export function startCallbackServer(opts = {}) {
1318
1394
  const resolveLocalToken = (token, key) => {
1319
1395
  const firstToken = !onToken.settled();
1320
1396
  connected = true;
1397
+ activeDeviceToken = token;
1321
1398
  pendingLocalAuth = null;
1322
1399
  const callbackToken = { token, key };
1323
1400
  if (firstToken) {
@@ -1330,6 +1407,17 @@ export function startCallbackServer(opts = {}) {
1330
1407
  }
1331
1408
  armTimeout();
1332
1409
  };
1410
+ const isOnboardingOnlyRoute = (route) => [
1411
+ "/report-consent",
1412
+ "/report",
1413
+ "/stats",
1414
+ "/billing-status",
1415
+ "/billing-checkout",
1416
+ "/billing-portal",
1417
+ "/progress",
1418
+ "/migrate",
1419
+ "/skip",
1420
+ ].includes(route);
1333
1421
  const handleLocalSendOtp = async (res, body) => {
1334
1422
  if (rejectIfLocalAuthBlocked(res, asString(body.nonce)))
1335
1423
  return;
@@ -1472,6 +1560,15 @@ export function startCallbackServer(opts = {}) {
1472
1560
  const url = new URL(req.url || "/", "http://127.0.0.1");
1473
1561
  const route = url.pathname;
1474
1562
  const run = async () => {
1563
+ // Defense in depth: the login bridge never exposes the routes that can inspect or move
1564
+ // local conversation history. The browser UI also routes around them, but the server is
1565
+ // the authority for this privacy boundary.
1566
+ if (isLoginFlow && isOnboardingOnlyRoute(route)) {
1567
+ return void json(res, 404, {
1568
+ error: "ONBOARDING_REQUIRED",
1569
+ message: "Run `echomem-mcp init` to access local-history onboarding.",
1570
+ });
1571
+ }
1475
1572
  if ((route === "/city" || route.startsWith("/city/")) && req.method === "GET") {
1476
1573
  serveRepoCityAsset(route, res);
1477
1574
  return;
@@ -1507,12 +1604,13 @@ export function startCallbackServer(opts = {}) {
1507
1604
  return void text(res, 403, "bad nonce");
1508
1605
  json(res, 200, {
1509
1606
  connected,
1607
+ flow,
1510
1608
  authUrl,
1511
1609
  switchAccountUrl: switchAccountUrl || authUrl,
1512
1610
  localOnly: true,
1513
1611
  localAuth: true,
1514
1612
  workspacePath: process.cwd(),
1515
- consentRequired: opts.requireReportConsent === true,
1613
+ consentRequired: requiresReportConsent,
1516
1614
  consentGranted: reportConsentGranted,
1517
1615
  });
1518
1616
  return;
@@ -1605,7 +1703,7 @@ export function startCallbackServer(opts = {}) {
1605
1703
  if (route === "/stats" && req.method === "GET") {
1606
1704
  if (!checkNonce(url.searchParams.get("nonce") || undefined))
1607
1705
  return void text(res, 403, "bad nonce");
1608
- if (opts.requireReportConsent === true && !reportConsentGranted) {
1706
+ if (requiresReportConsent && !reportConsentGranted) {
1609
1707
  return void json(res, 403, {
1610
1708
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1611
1709
  message: "Allow local history access before continuing setup.",
@@ -1620,13 +1718,13 @@ export function startCallbackServer(opts = {}) {
1620
1718
  if (route === "/billing-status" && req.method === "GET") {
1621
1719
  if (!checkNonce(url.searchParams.get("nonce") || undefined))
1622
1720
  return void text(res, 403, "bad nonce");
1623
- if (opts.requireReportConsent === true && !reportConsentGranted) {
1721
+ if (requiresReportConsent && !reportConsentGranted) {
1624
1722
  return void json(res, 403, {
1625
1723
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1626
1724
  message: "Allow local history access before continuing setup.",
1627
1725
  });
1628
1726
  }
1629
- const token = new KeyStore().getToken();
1727
+ const token = activeDeviceToken || new KeyStore().getToken();
1630
1728
  const pricingUrl = `${PRICING_URL}?source=mcp_onboarding`;
1631
1729
  if (!token) {
1632
1730
  json(res, 200, { plan: "free", paid: false, trialAvailable: true, trialUsed: false, pricingUrl });
@@ -1662,12 +1760,84 @@ export function startCallbackServer(opts = {}) {
1662
1760
  }
1663
1761
  return;
1664
1762
  }
1763
+ if ((route === "/billing-checkout" || route === "/billing-portal") && req.method === "POST") {
1764
+ let body;
1765
+ try {
1766
+ body = await readJsonBody(req);
1767
+ }
1768
+ catch {
1769
+ text(res, 400, "bad json");
1770
+ return;
1771
+ }
1772
+ if (!checkNonce(asString(body.nonce)))
1773
+ return void text(res, 403, "bad nonce");
1774
+ if (requiresReportConsent && !reportConsentGranted) {
1775
+ return void json(res, 403, {
1776
+ error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1777
+ message: "Allow local history access before managing an onboarding plan.",
1778
+ });
1779
+ }
1780
+ const token = activeDeviceToken || new KeyStore().getToken();
1781
+ if (!connected || !token) {
1782
+ return void json(res, 401, {
1783
+ error: "ECHOMEM_LOGIN_REQUIRED",
1784
+ message: "Connect your EchoMem account before continuing to billing.",
1785
+ });
1786
+ }
1787
+ const isCheckout = route === "/billing-checkout";
1788
+ const requestBody = { source: "mcp_onboarding" };
1789
+ if (isCheckout) {
1790
+ const plan = asString(body.plan)?.toLowerCase();
1791
+ if (plan !== "pro" && plan !== "power") {
1792
+ return void json(res, 400, {
1793
+ error: "INVALID_PLAN",
1794
+ message: "Choose Pro or Power to continue to checkout.",
1795
+ });
1796
+ }
1797
+ requestBody.plan = plan;
1798
+ requestBody.trial = body.trial === true;
1799
+ }
1800
+ else {
1801
+ const plan = asString(body.plan)?.toLowerCase();
1802
+ if (plan) {
1803
+ if (plan !== "pro" && plan !== "power") {
1804
+ return void json(res, 400, {
1805
+ error: "INVALID_PLAN",
1806
+ message: "Choose Pro or Power to change plans.",
1807
+ });
1808
+ }
1809
+ requestBody.plan = plan;
1810
+ }
1811
+ }
1812
+ try {
1813
+ const response = await axios.post(hostedBillingEndpoint(isCheckout ? "/api/billing/checkout" : "/api/billing/portal"), requestBody, {
1814
+ timeout: 15_000,
1815
+ headers: {
1816
+ "Content-Type": "application/json",
1817
+ Authorization: `Bearer ${token}`,
1818
+ },
1819
+ });
1820
+ const hostedUrl = response.data?.url;
1821
+ if (!isExpectedStripeUrl(hostedUrl, isCheckout ? "checkout" : "portal")) {
1822
+ return void json(res, 502, {
1823
+ error: "INVALID_BILLING_DESTINATION",
1824
+ message: "Echo billing returned an unexpected destination.",
1825
+ });
1826
+ }
1827
+ json(res, 200, { url: hostedUrl });
1828
+ }
1829
+ catch (error) {
1830
+ const detail = publicAxiosError(error, isCheckout ? "Could not start secure checkout." : "Could not open secure plan management.");
1831
+ json(res, detail.status, { error: "BILLING_REQUEST_FAILED", message: detail.message });
1832
+ }
1833
+ return;
1834
+ }
1665
1835
  if (route === "/report" && req.method === "GET") {
1666
1836
  // Local forensic "Context Doctor" report — computed locally, served BEFORE auth (scan-first).
1667
1837
  res.setHeader("Cache-Control", "no-store");
1668
1838
  if (!checkNonce(url.searchParams.get("nonce") || undefined))
1669
1839
  return void text(res, 403, "bad nonce");
1670
- if (opts.requireReportConsent === true && !reportConsentGranted) {
1840
+ if (requiresReportConsent && !reportConsentGranted) {
1671
1841
  return void json(res, 403, {
1672
1842
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1673
1843
  message: "Allow local history access before starting the local scan.",
@@ -1768,7 +1938,7 @@ export function startCallbackServer(opts = {}) {
1768
1938
  if (route === "/progress" && req.method === "GET") {
1769
1939
  if (!checkNonce(url.searchParams.get("nonce") || undefined))
1770
1940
  return void text(res, 403, "bad nonce");
1771
- if (opts.requireReportConsent === true && !reportConsentGranted) {
1941
+ if (requiresReportConsent && !reportConsentGranted) {
1772
1942
  return void json(res, 403, {
1773
1943
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1774
1944
  message: "Allow local history access before continuing setup.",
@@ -1816,6 +1986,7 @@ export function startCallbackServer(opts = {}) {
1816
1986
  /* already logged out locally */
1817
1987
  }
1818
1988
  connected = false;
1989
+ activeDeviceToken = "";
1819
1990
  const revokedPendingCredential = await revokePendingLocalAuth();
1820
1991
  stats = null;
1821
1992
  migrateStarted = false;
@@ -1845,7 +2016,7 @@ export function startCallbackServer(opts = {}) {
1845
2016
  }
1846
2017
  if (!checkNonce(asString(body.nonce)))
1847
2018
  return void text(res, 403, "bad nonce");
1848
- if (opts.requireReportConsent === true && !reportConsentGranted) {
2019
+ if (requiresReportConsent && !reportConsentGranted) {
1849
2020
  return void json(res, 403, {
1850
2021
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1851
2022
  message: "Allow local history access before starting extraction.",
@@ -1906,6 +2077,8 @@ export function startCallbackServer(opts = {}) {
1906
2077
  sockets.add(socket);
1907
2078
  socket.on("close", () => sockets.delete(socket));
1908
2079
  });
2080
+ if (opts.initialToken)
2081
+ onToken.resolve(opts.initialToken);
1909
2082
  armTimeout();
1910
2083
  server.on("error", (e) => rejectOuter(e));
1911
2084
  server.listen(opts.port ?? 0, "127.0.0.1", () => {
@@ -2140,9 +2313,9 @@ async function cmdSetup(flags) {
2140
2313
  /**
2141
2314
  * `echomem-mcp init` — the one-command install. Configures EVERY coding agent installed on this
2142
2315
  * 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.
2316
+ * Codex skills, writes the AGENTS.md memory guidance, and launches the context HUD. One browser
2317
+ * bridge then runs permission report login plan if needed extraction in that order.
2318
+ * `setup`/`login`/`update` remain granular primitives; init picks the full product defaults.
2146
2319
  */
2147
2320
  async function cmdInit(flags) {
2148
2321
  console.log("Setting up EchoMem — shared memory for all your coding agents, plus the live context HUD.\n");
@@ -2151,10 +2324,10 @@ async function cmdInit(flags) {
2151
2324
  // 2. Bring the HUD up NOW (non-blocking) so everything is already running while onboarding proceeds.
2152
2325
  if (!flags["no-hud"])
2153
2326
  await cmdSetupHud(flags);
2154
- // 3. Start onboarding opens the browser dashboard (scan connect extraction) and waits there.
2327
+ // 3. Start one ordered onboarding bridge. A fresh device logs in only after consent + report.
2155
2328
  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.");
2329
+ if (!flags["skip-login"] && !flags["no-login"] && !await cmdOnboarding(flags)) {
2330
+ console.log("\nEchoMem is configured, but onboarding did not finish. Run `echomem-mcp init` again when you are ready.");
2158
2331
  return;
2159
2332
  }
2160
2333
  console.log("");
@@ -2215,6 +2388,8 @@ function writeCodexSkillsForTargets(targets) {
2215
2388
  }
2216
2389
  }
2217
2390
  async function cmdUpdate(flags) {
2391
+ if (!flags.dev)
2392
+ installDurableGlobalUpdate();
2218
2393
  await cmdSetup({ ...flags, "skip-login": true });
2219
2394
  console.log(`Update config complete. Start a new MCP session to load ${MCP_PACKAGE_LABEL}.`);
2220
2395
  }
@@ -2279,6 +2454,19 @@ function parseHudClient(value) {
2279
2454
  ? value
2280
2455
  : "auto";
2281
2456
  }
2457
+ function localBridgeOptions(flags) {
2458
+ const port = typeof flags["dev-port"] === "string" ? Number(flags["dev-port"]) : undefined;
2459
+ if (port !== undefined && (!Number.isInteger(port) || port < 1024 || port > 65535)) {
2460
+ throw new Error("--dev-port must be an integer between 1024 and 65535");
2461
+ }
2462
+ const requestedNonce = typeof flags["dev-nonce"] === "string" ? flags["dev-nonce"].trim() : undefined;
2463
+ if (requestedNonce && port === undefined)
2464
+ throw new Error("--dev-nonce requires --dev-port");
2465
+ if (requestedNonce && !/^[A-Za-z0-9-]{16,128}$/.test(requestedNonce)) {
2466
+ throw new Error("--dev-nonce must contain 16-128 letters, numbers, or hyphens");
2467
+ }
2468
+ return { port, nonce: requestedNonce || randomUUID() };
2469
+ }
2282
2470
  async function cmdLogin(flags) {
2283
2471
  // Manual path (also the headless path): secrets supplied as flags.
2284
2472
  if (typeof flags.token === "string") {
@@ -2291,20 +2479,51 @@ async function cmdLogin(flags) {
2291
2479
  process.exitCode = 1;
2292
2480
  return ok;
2293
2481
  }
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.
2482
+ const store = new KeyStore();
2483
+ if (!flags.force && store.getToken() && store.getKey()) {
2484
+ console.log("This device is already connected. Run `echomem-mcp login --force` to sign in with a different account.");
2485
+ return true;
2486
+ }
2487
+ // Browser path: this bridge does only account/device authentication. It intentionally exposes
2488
+ // no local-history routes; `init` owns scan consent, reporting, and optional extraction.
2296
2489
  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");
2490
+ const { port, nonce } = localBridgeOptions(flags);
2491
+ const srv = await startCallbackServer({ port, nonce, flow: "login" });
2492
+ const localLoginUrl = `http://127.0.0.1:${srv.port}/setup?nonce=${nonce}`;
2493
+ openBrowser(localLoginUrl);
2494
+ console.log(`If it didn't open, visit:\n ${localLoginUrl}\n`);
2495
+ console.log("Waiting for local login for up to 15 minutes…");
2496
+ try {
2497
+ const credentials = await srv.onToken;
2498
+ const ok = await verifyAndPrint(credentials);
2499
+ srv.close();
2500
+ if (!ok) {
2501
+ process.exitCode = 1;
2502
+ return false;
2503
+ }
2504
+ console.log("✅ This device is connected. Run `echomem-mcp init` to begin local-history onboarding.");
2505
+ return true;
2300
2506
  }
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");
2507
+ catch (error) {
2508
+ srv.close();
2509
+ console.error(`❌ ${error instanceof Error ? error.message : String(error)}. Run \`echomem-mcp login\` to retry.`);
2510
+ process.exitCode = 1;
2511
+ return false;
2306
2512
  }
2307
- const nonce = devNonce || randomUUID();
2513
+ }
2514
+ /**
2515
+ * The local-history onboarding flow. Existing device credentials are reused when available; a
2516
+ * fresh device stays in this same bridge and asks for login only after permission and report.
2517
+ */
2518
+ async function cmdOnboarding(flags) {
2519
+ const store = new KeyStore();
2520
+ const savedToken = store.getToken();
2521
+ const savedKey = store.getKey();
2522
+ const initialToken = savedToken && savedKey
2523
+ ? { token: savedToken, key: savedKey }
2524
+ : undefined;
2525
+ console.log("Opening your browser for EchoMem onboarding…");
2526
+ const { port, nonce } = localBridgeOptions(flags);
2308
2527
  let stats = null;
2309
2528
  let forensicReport = null;
2310
2529
  let forensicConsent = "pending";
@@ -2323,8 +2542,10 @@ async function cmdLogin(flags) {
2323
2542
  updatedAt: forensicStartedAt,
2324
2543
  };
2325
2544
  const srv = await startCallbackServer({
2326
- port: devPortRaw,
2545
+ port,
2327
2546
  nonce,
2547
+ flow: "onboarding",
2548
+ initialToken,
2328
2549
  requireReportConsent: true,
2329
2550
  getStats: () => stats,
2330
2551
  getReport: () => forensicReport,
@@ -2368,7 +2589,7 @@ async function cmdLogin(flags) {
2368
2589
  const localSetupUrl = `http://127.0.0.1:${srv.port}/setup?nonce=${nonce}`;
2369
2590
  openBrowser(localSetupUrl);
2370
2591
  console.log(`If it didn't open, visit:\n ${localSetupUrl}\n`);
2371
- console.log("Waiting for local setup for up to 15 minutes…");
2592
+ console.log("Waiting for local-history onboarding for up to 15 minutes…");
2372
2593
  const startForensicScan = () => {
2373
2594
  if (forensicScanStarted || forensicConsent !== "allowed")
2374
2595
  return;
@@ -3116,15 +3337,16 @@ function cmdLogout() {
3116
3337
  const HELP = `EchoMem MCP — local memory bridge
3117
3338
 
3118
3339
  Usage:
3119
- echomem-mcp init One command: configure every installed agent + HUD + log in
3340
+ echomem-mcp init One command: configure agents + HUD + login + local-history onboarding
3120
3341
  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
3342
+ echomem-mcp setup [--client X] Detect editor, write its MCP config, then connect this device
3122
3343
  echomem-mcp setup --skip-login Write MCP config without opening login/browser
3123
3344
  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
3345
+ echomem-mcp update --all Install this bridge durably + repoint detected clients; no login/browser
3125
3346
  echomem-mcp update --client X Repoint one MCP client; no login/browser
3126
3347
  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)
3348
+ echomem-mcp login Connect this device only; never scans or imports local history
3349
+ echomem-mcp login --force Reconnect this device with a different account
3128
3350
  echomem-mcp unlock Privately unlock the vault on this trusted device
3129
3351
  echomem-mcp lock Remove the local vault key while keeping the device login
3130
3352
  echomem-mcp status Show token/key/clients
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@echomem/mcp",
3
- "version": "1.4.21",
3
+ "version": "1.4.22",
4
4
  "description": "EchoMem MCP bridge: cloud-first memory tools, local context HUD, and the Agent Doctor workspace forensics report (cost ledger + 3D repo city)",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",