tandem-editor 0.6.3 → 0.7.1

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/cli/index.js CHANGED
@@ -10,7 +10,7 @@ var __export = (target, all) => {
10
10
  };
11
11
 
12
12
  // src/shared/constants.ts
13
- var DEFAULT_MCP_PORT, MAX_FILE_SIZE, MAX_WS_PAYLOAD, IDLE_TIMEOUT, SESSION_MAX_AGE, CHANNEL_MAX_RETRIES, CHANNEL_RETRY_DELAY_MS;
13
+ var DEFAULT_MCP_PORT, MAX_FILE_SIZE, MAX_WS_PAYLOAD, IDLE_TIMEOUT, SESSION_MAX_AGE, CHANNEL_MAX_RETRIES, CHANNEL_RETRY_DELAY_MS, TOKEN_FILE_NAME;
14
14
  var init_constants = __esm({
15
15
  "src/shared/constants.ts"() {
16
16
  "use strict";
@@ -21,6 +21,7 @@ var init_constants = __esm({
21
21
  SESSION_MAX_AGE = 30 * 24 * 60 * 60 * 1e3;
22
22
  CHANNEL_MAX_RETRIES = 5;
23
23
  CHANNEL_RETRY_DELAY_MS = 2e3;
24
+ TOKEN_FILE_NAME = "auth-token";
24
25
  }
25
26
  });
26
27
 
@@ -42,6 +43,7 @@ var init_skill_content = __esm({
42
43
  var setup_exports = {};
43
44
  __export(setup_exports, {
44
45
  applyConfig: () => applyConfig,
46
+ applyConfigWithToken: () => applyConfigWithToken,
45
47
  buildMcpEntries: () => buildMcpEntries,
46
48
  detectTargets: () => detectTargets,
47
49
  installSkill: () => installSkill,
@@ -49,20 +51,40 @@ __export(setup_exports, {
49
51
  validateChannelShimPrereq: () => validateChannelShimPrereq
50
52
  });
51
53
  import { randomUUID } from "crypto";
52
- import { existsSync, readFileSync as readFileSync2 } from "fs";
54
+ import { existsSync, readdirSync, readFileSync as readFileSync2 } from "fs";
53
55
  import { copyFile, mkdir, rename, unlink, writeFile } from "fs/promises";
54
56
  import { homedir } from "os";
55
57
  import { basename, dirname as dirname2, join, resolve as resolve2 } from "path";
56
58
  import { fileURLToPath as fileURLToPath2 } from "url";
57
59
  function buildMcpEntries(channelPath, opts = {}) {
58
- const entries = {
59
- tandem: { type: "http", url: `${MCP_URL}/mcp` }
60
- };
60
+ const isDesktop = opts.targetKind === "claude-desktop";
61
+ let tandemEntry;
62
+ if (isDesktop) {
63
+ const env = { TANDEM_URL: MCP_URL };
64
+ if (opts.token) {
65
+ env.TANDEM_AUTH_TOKEN = opts.token;
66
+ }
67
+ tandemEntry = {
68
+ command: "npx",
69
+ args: ["-y", "tandem-editor", "mcp-stdio"],
70
+ env
71
+ };
72
+ } else {
73
+ tandemEntry = { type: "http", url: `${MCP_URL}/mcp` };
74
+ if (opts.token) {
75
+ tandemEntry.headers = { Authorization: `Bearer ${opts.token}` };
76
+ }
77
+ }
78
+ const entries = { tandem: tandemEntry };
61
79
  if (opts.withChannelShim) {
80
+ const shimEnv = { TANDEM_URL: MCP_URL };
81
+ if (opts.token) {
82
+ shimEnv.TANDEM_AUTH_TOKEN = opts.token;
83
+ }
62
84
  entries["tandem-channel"] = {
63
85
  command: opts.nodeBinary ?? "node",
64
86
  args: [channelPath],
65
- env: { TANDEM_URL: MCP_URL }
87
+ env: shimEnv
66
88
  };
67
89
  }
68
90
  return entries;
@@ -73,7 +95,7 @@ function detectTargets(opts = {}) {
73
95
  const claudeCodeConfig = join(home, ".claude.json");
74
96
  const claudeCodeDir = join(home, ".claude");
75
97
  if (opts.force || existsSync(claudeCodeConfig) || existsSync(claudeCodeDir)) {
76
- targets.push({ label: "Claude Code", configPath: claudeCodeConfig });
98
+ targets.push({ label: "Claude Code", configPath: claudeCodeConfig, kind: "claude-code" });
77
99
  }
78
100
  let desktopConfig = null;
79
101
  if (process.platform === "win32") {
@@ -91,7 +113,33 @@ function detectTargets(opts = {}) {
91
113
  desktopConfig = join(home, ".config", "claude", "claude_desktop_config.json");
92
114
  }
93
115
  if (desktopConfig && (opts.force || existsSync(desktopConfig))) {
94
- targets.push({ label: "Claude Desktop", configPath: desktopConfig });
116
+ targets.push({ label: "Claude Desktop", configPath: desktopConfig, kind: "claude-desktop" });
117
+ }
118
+ if (process.platform === "win32") {
119
+ const localAppData = opts.localAppDataOverride ?? process.env.LOCALAPPDATA ?? join(home, "AppData", "Local");
120
+ const packagesDir = join(localAppData, "Packages");
121
+ try {
122
+ const entries = readdirSync(packagesDir);
123
+ for (const pkg of entries.filter((n) => n.startsWith("Claude_"))) {
124
+ const msixConfig = join(
125
+ packagesDir,
126
+ pkg,
127
+ "LocalCache",
128
+ "Roaming",
129
+ "Claude",
130
+ "claude_desktop_config.json"
131
+ );
132
+ if (opts.force || existsSync(msixConfig)) {
133
+ const suffix = entries.filter((n) => n.startsWith("Claude_")).length > 1 ? ` (${pkg.slice(0, 12)}\u2026)` : "";
134
+ targets.push({
135
+ label: `Claude Desktop MSIX${suffix}`,
136
+ configPath: msixConfig,
137
+ kind: "claude-desktop"
138
+ });
139
+ }
140
+ }
141
+ } catch {
142
+ }
95
143
  }
96
144
  return targets;
97
145
  }
@@ -157,6 +205,25 @@ async function installSkill(opts = {}) {
157
205
  function validateChannelShimPrereq(channelPath) {
158
206
  return existsSync(channelPath);
159
207
  }
208
+ async function applyConfigWithToken(token, opts = {}) {
209
+ const targets = detectTargets({ force: opts.force });
210
+ let updated = 0;
211
+ const errors = [];
212
+ for (const t of targets) {
213
+ const entries = buildMcpEntries(CHANNEL_DIST, {
214
+ withChannelShim: opts.withChannelShim,
215
+ token: token ?? void 0,
216
+ targetKind: t.kind
217
+ });
218
+ try {
219
+ await applyConfig(t.configPath, entries);
220
+ updated++;
221
+ } catch (err) {
222
+ errors.push(`${t.label}: ${err instanceof Error ? err.message : String(err)}`);
223
+ }
224
+ }
225
+ return { updated, errors };
226
+ }
160
227
  async function runSetup(opts = {}) {
161
228
  console.error("\nTandem Setup\n");
162
229
  if (opts.withChannelShim && !validateChannelShimPrereq(CHANNEL_DIST)) {
@@ -178,9 +245,12 @@ Run 'npm run build' first, or drop --with-channel-shim to use the plugin monitor
178
245
  console.error(` Found: ${t.label} (${t.configPath})`);
179
246
  }
180
247
  console.error("\nWriting MCP configuration...");
181
- const entries = buildMcpEntries(CHANNEL_DIST, { withChannelShim: opts.withChannelShim });
182
248
  let failures = 0;
183
249
  for (const t of targets) {
250
+ const entries = buildMcpEntries(CHANNEL_DIST, {
251
+ withChannelShim: opts.withChannelShim,
252
+ targetKind: t.kind
253
+ });
184
254
  try {
185
255
  await applyConfig(t.configPath, entries);
186
256
  console.error(` \x1B[32m\u2713\x1B[0m ${t.label}`);
@@ -249,10 +319,30 @@ function resolveTandemUrl(override) {
249
319
  const raw = override ?? process.env.TANDEM_URL ?? `http://localhost:${DEFAULT_MCP_PORT}`;
250
320
  return raw.replace(/\/$/, "");
251
321
  }
322
+ async function authFetch(url, init) {
323
+ const token = process.env.TANDEM_AUTH_TOKEN;
324
+ if (token !== void 0 && token.trim() !== "") {
325
+ if (VALID_TOKEN_RE.test(token.trim())) {
326
+ const headers = new Headers(init?.headers);
327
+ headers.set("Authorization", `Bearer ${token.trim()}`);
328
+ return fetch(url, { ...init, headers });
329
+ }
330
+ if (!_warnedInvalidToken) {
331
+ _warnedInvalidToken = true;
332
+ console.error(
333
+ "[tandem] authFetch: TANDEM_AUTH_TOKEN is set but invalid (must be 32+ alphanumeric chars [A-Za-z0-9_-]); sending without Authorization header"
334
+ );
335
+ }
336
+ }
337
+ return fetch(url, init);
338
+ }
339
+ var VALID_TOKEN_RE, _warnedInvalidToken;
252
340
  var init_cli_runtime = __esm({
253
341
  "src/shared/cli-runtime.ts"() {
254
342
  "use strict";
255
343
  init_constants();
344
+ VALID_TOKEN_RE = /^[A-Za-z0-9_\-]{32,}$/;
345
+ _warnedInvalidToken = false;
256
346
  }
257
347
  });
258
348
 
@@ -310,15 +400,52 @@ var mcp_stdio_exports = {};
310
400
  __export(mcp_stdio_exports, {
311
401
  getRequestId: () => getRequestId,
312
402
  getResponseId: () => getResponseId,
403
+ parseTimeoutMs: () => parseTimeoutMs,
404
+ readAndValidateAuthToken: () => readAndValidateAuthToken,
313
405
  runMcpStdio: () => runMcpStdio
314
406
  });
315
407
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
316
408
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
409
+ function parseTimeoutMs(raw) {
410
+ if (raw !== void 0) {
411
+ const parsed = parseInt(raw, 10);
412
+ if (Number.isFinite(parsed) && parsed > 0 && parsed <= MAX_TIMEOUT_MS) {
413
+ return parsed;
414
+ }
415
+ process.stderr.write(
416
+ `[tandem mcp-stdio] TANDEM_REQUEST_TIMEOUT_MS must be a positive integer \u2264 ${MAX_TIMEOUT_MS}; ignoring "${raw}", using 30000ms default
417
+ `
418
+ );
419
+ }
420
+ return 3e4;
421
+ }
422
+ function readAndValidateAuthToken() {
423
+ const raw = process.env.TANDEM_AUTH_TOKEN;
424
+ if (raw === void 0) return null;
425
+ const trimmed = raw.trim();
426
+ if (trimmed === "") return null;
427
+ if (trimmed.startsWith("Bearer ")) {
428
+ process.stderr.write(
429
+ "[tandem mcp-stdio] TANDEM_AUTH_TOKEN is invalid (double-prefix: do not include 'Bearer ' prefix \u2014 supply the raw token only)\n"
430
+ );
431
+ process.exit(1);
432
+ }
433
+ if (!VALID_TOKEN_RE2.test(trimmed)) {
434
+ process.stderr.write(
435
+ "[tandem mcp-stdio] TANDEM_AUTH_TOKEN is malformed (must be 32+ URL-safe characters: [A-Za-z0-9_-])\n"
436
+ );
437
+ process.exit(1);
438
+ }
439
+ return trimmed;
440
+ }
317
441
  async function runMcpStdio() {
318
442
  const baseUrl = resolveTandemUrl();
319
- const http = new StreamableHTTPClientTransport(new URL(`${baseUrl}/mcp`));
443
+ const authToken = readAndValidateAuthToken();
444
+ const http = new StreamableHTTPClientTransport(new URL(`${baseUrl}/mcp`), {
445
+ requestInit: authToken ? { headers: { Authorization: `Bearer ${authToken}` } } : void 0
446
+ });
320
447
  const stdio = new StdioServerTransport();
321
- const pendingIds = /* @__PURE__ */ new Set();
448
+ const pendingRequests = /* @__PURE__ */ new Map();
322
449
  const preReadyBuffer = [];
323
450
  let shuttingDown = false;
324
451
  let httpReady = false;
@@ -346,14 +473,32 @@ async function runMcpStdio() {
346
473
  }
347
474
  }
348
475
  function forwardToUpstream(msg) {
476
+ if (shuttingDown) return;
349
477
  const requestId = getRequestId(msg);
350
- if (requestId !== void 0) pendingIds.add(requestId);
478
+ if (requestId !== void 0) {
479
+ const existing = pendingRequests.get(requestId);
480
+ if (existing) clearTimeout(existing);
481
+ const timeoutHandle = setTimeout(() => {
482
+ if (!pendingRequests.delete(requestId)) return;
483
+ void sendErrorResponse(
484
+ requestId,
485
+ "Tandem HTTP upstream not responding (half-open)",
486
+ `No response after ${STDIO_REQUEST_TIMEOUT_MS}ms`
487
+ );
488
+ }, STDIO_REQUEST_TIMEOUT_MS);
489
+ pendingRequests.set(requestId, timeoutHandle);
490
+ }
351
491
  http.send(msg).catch((err) => {
352
492
  const detail = err instanceof Error ? err.message : String(err);
353
493
  process.stderr.write(`[tandem mcp-stdio] upstream send failed: ${detail}
354
494
  `);
355
- if (requestId !== void 0 && pendingIds.delete(requestId)) {
356
- void sendErrorResponse(requestId, "Tandem HTTP upstream unreachable", detail);
495
+ if (requestId !== void 0) {
496
+ const handle = pendingRequests.get(requestId);
497
+ if (handle !== void 0) {
498
+ pendingRequests.delete(requestId);
499
+ clearTimeout(handle);
500
+ void sendErrorResponse(requestId, "Tandem HTTP upstream unreachable", detail);
501
+ }
357
502
  }
358
503
  });
359
504
  }
@@ -365,14 +510,17 @@ async function runMcpStdio() {
365
510
  }
366
511
  }
367
512
  async function synthesizePending(message, detail) {
368
- if (pendingIds.size === 0) return;
369
- const ids = [...pendingIds];
370
- pendingIds.clear();
513
+ if (pendingRequests.size === 0) return;
514
+ const ids = [...pendingRequests.keys()];
515
+ for (const handle of pendingRequests.values()) clearTimeout(handle);
516
+ pendingRequests.clear();
371
517
  await Promise.all(ids.map((id) => sendErrorResponse(id, message, detail)));
372
518
  }
373
519
  const shutdown = async (code = 0, synth) => {
374
520
  if (!shuttingDown) {
375
521
  shuttingDown = true;
522
+ setTimeout(() => process.exit(code), 2e3).unref();
523
+ for (const handle of pendingRequests.values()) clearTimeout(handle);
376
524
  if (synth) {
377
525
  await synthesizeBuffered(synth.message, synth.detail);
378
526
  await synthesizePending(synth.message, synth.detail);
@@ -401,23 +549,34 @@ async function runMcpStdio() {
401
549
  forwardToUpstream(msg);
402
550
  };
403
551
  http.onmessage = (msg) => {
552
+ if (shuttingDown) return;
404
553
  const responseId = getResponseId(msg);
405
- stdio.send(msg).then(
406
- () => {
407
- if (responseId !== void 0) pendingIds.delete(responseId);
408
- },
409
- (err) => {
410
- const detail = err instanceof Error ? err.message : String(err);
411
- process.stderr.write(
412
- `[tandem mcp-stdio] stdio write failed for id ${responseId ?? "<notification>"}: ${detail}
554
+ if (responseId !== void 0) {
555
+ const handle = pendingRequests.get(responseId);
556
+ if (handle !== void 0) {
557
+ clearTimeout(handle);
558
+ pendingRequests.delete(responseId);
559
+ }
560
+ }
561
+ const sendHandler = (err) => {
562
+ const detail = err instanceof Error ? err.message : String(err);
563
+ process.stderr.write(
564
+ `[tandem mcp-stdio] stdio write failed for id ${responseId ?? "<notification>"}: ${detail}
413
565
  `
414
- );
415
- void shutdown(1, {
416
- message: "Tandem stdio write failed",
417
- detail
418
- });
566
+ );
567
+ if (responseId !== void 0) {
568
+ void sendErrorResponse(responseId, "Tandem stdio write failed", detail);
419
569
  }
420
- );
570
+ void shutdown(1, {
571
+ message: "Tandem stdio write failed",
572
+ detail
573
+ });
574
+ };
575
+ try {
576
+ stdio.send(msg).catch(sendHandler);
577
+ } catch (err) {
578
+ sendHandler(err);
579
+ }
421
580
  };
422
581
  stdio.onerror = (err) => {
423
582
  process.stderr.write(`[tandem mcp-stdio] stdio error: ${err.message}
@@ -444,6 +603,9 @@ cause: ${cause}` : ""}
444
603
  });
445
604
  };
446
605
  await stdio.start();
606
+ process.stdin.once("end", () => {
607
+ void shutdown(0);
608
+ });
447
609
  const probe = await probeTandemServer({ url: baseUrl });
448
610
  if (!probe.ok) {
449
611
  const guidance = probe.kind === "unreachable" ? "Start the Tauri app or run `tandem start` on the host, then retry." : "The Tandem server is running but unhealthy \u2014 check the host logs.";
@@ -481,7 +643,7 @@ function getResponseId(msg) {
481
643
  if (typeof m.id === "string" || typeof m.id === "number") return m.id;
482
644
  return void 0;
483
645
  }
484
- var PREFLIGHT_GRACE_MS;
646
+ var PREFLIGHT_GRACE_MS, MAX_TIMEOUT_MS, STDIO_REQUEST_TIMEOUT_MS, VALID_TOKEN_RE2;
485
647
  var init_mcp_stdio = __esm({
486
648
  "src/cli/mcp-stdio.ts"() {
487
649
  "use strict";
@@ -489,6 +651,8 @@ var init_mcp_stdio = __esm({
489
651
  init_preflight();
490
652
  redirectConsoleToStderr();
491
653
  PREFLIGHT_GRACE_MS = 1500;
654
+ MAX_TIMEOUT_MS = 2147483647;
655
+ STDIO_REQUEST_TIMEOUT_MS = parseTimeoutMs(process.env.TANDEM_REQUEST_TIMEOUT_MS);
492
656
  process.once("uncaughtException", (err) => {
493
657
  process.stderr.write(
494
658
  `[tandem mcp-stdio] uncaughtException: ${err.message}
@@ -503,6 +667,7 @@ ${err.stack ?? ""}
503
667
  `);
504
668
  process.exit(1);
505
669
  });
670
+ VALID_TOKEN_RE2 = /^[A-Za-z0-9_\-]{32,}$/;
506
671
  }
507
672
  });
508
673
 
@@ -634,7 +799,7 @@ async function startEventBridge(mcp, tandemUrl) {
634
799
  if (retries >= CHANNEL_MAX_RETRIES) {
635
800
  console.error("[Channel] SSE connection exhausted, reporting error and exiting");
636
801
  try {
637
- await fetch(`${tandemUrl}/api/channel-error`, {
802
+ await authFetch(`${tandemUrl}/api/channel-error`, {
638
803
  method: "POST",
639
804
  headers: { "Content-Type": "application/json" },
640
805
  body: JSON.stringify({
@@ -657,7 +822,7 @@ async function startEventBridge(mcp, tandemUrl) {
657
822
  async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
658
823
  const headers = { Accept: "text/event-stream" };
659
824
  if (lastEventId) headers["Last-Event-ID"] = lastEventId;
660
- const res = await fetch(`${tandemUrl}/api/events`, { headers });
825
+ const res = await authFetch(`${tandemUrl}/api/events`, { headers });
661
826
  if (!res.ok) throw new Error(`SSE endpoint returned ${res.status}`);
662
827
  if (!res.body) throw new Error("SSE endpoint returned no body");
663
828
  const reader = res.body.getReader();
@@ -668,7 +833,7 @@ async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
668
833
  let pendingAwareness = null;
669
834
  const AWARENESS_CLEAR_MS = 3e3;
670
835
  function clearAwareness(documentId) {
671
- fetch(`${tandemUrl}/api/channel-awareness`, {
836
+ authFetch(`${tandemUrl}/api/channel-awareness`, {
672
837
  method: "POST",
673
838
  headers: { "Content-Type": "application/json" },
674
839
  body: JSON.stringify({
@@ -683,7 +848,7 @@ async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
683
848
  if (!pendingAwareness) return;
684
849
  const event = pendingAwareness;
685
850
  pendingAwareness = null;
686
- fetch(`${tandemUrl}/api/channel-awareness`, {
851
+ authFetch(`${tandemUrl}/api/channel-awareness`, {
687
852
  method: "POST",
688
853
  headers: { "Content-Type": "application/json" },
689
854
  body: JSON.stringify({
@@ -758,7 +923,7 @@ async function getCachedMode(tandemUrl) {
758
923
  const now = Date.now();
759
924
  if (now - cachedModeAt < MODE_CACHE_TTL_MS) return cachedMode;
760
925
  try {
761
- const res = await fetch(`${tandemUrl}/api/mode`);
926
+ const res = await authFetch(`${tandemUrl}/api/mode`);
762
927
  if (res.ok) {
763
928
  const { mode } = await res.json();
764
929
  cachedMode = mode;
@@ -780,6 +945,7 @@ var init_event_bridge = __esm({
780
945
  "src/channel/event-bridge.ts"() {
781
946
  "use strict";
782
947
  init_types();
948
+ init_cli_runtime();
783
949
  init_constants();
784
950
  AWARENESS_DEBOUNCE_MS = 500;
785
951
  MODE_CACHE_TTL_MS = 2e3;
@@ -847,7 +1013,7 @@ async function runChannel(opts = {}) {
847
1013
  if (req.params.name === "tandem_reply") {
848
1014
  const args2 = req.params.arguments;
849
1015
  try {
850
- const res = await fetch(`${tandemUrl}/api/channel-reply`, {
1016
+ const res = await authFetch(`${tandemUrl}/api/channel-reply`, {
851
1017
  method: "POST",
852
1018
  headers: { "Content-Type": "application/json" },
853
1019
  body: JSON.stringify(args2)
@@ -895,7 +1061,7 @@ async function runChannel(opts = {}) {
895
1061
  });
896
1062
  mcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {
897
1063
  try {
898
- const res = await fetch(`${tandemUrl}/api/channel-permission`, {
1064
+ const res = await authFetch(`${tandemUrl}/api/channel-permission`, {
899
1065
  method: "POST",
900
1066
  headers: { "Content-Type": "application/json" },
901
1067
  body: JSON.stringify({
@@ -984,6 +1150,163 @@ var init_channel = __esm({
984
1150
  }
985
1151
  });
986
1152
 
1153
+ // src/server/auth/token-store.ts
1154
+ import envPaths from "env-paths";
1155
+ import fs from "fs";
1156
+ import path from "path";
1157
+ function getTokenFilePath() {
1158
+ return path.join(envPaths("tandem", { suffix: "" }).data, TOKEN_FILE_NAME);
1159
+ }
1160
+ async function readTokenFromFile() {
1161
+ const filePath = getTokenFilePath();
1162
+ try {
1163
+ const content = await fs.promises.readFile(filePath, "utf8");
1164
+ if (process.platform !== "win32") {
1165
+ try {
1166
+ const stat = await fs.promises.stat(filePath);
1167
+ if ((stat.mode & 63) !== 0) {
1168
+ console.error("[tandem] auth token file has insecure permissions; attempting chmod 0600");
1169
+ await fs.promises.chmod(filePath, 384);
1170
+ }
1171
+ } catch {
1172
+ }
1173
+ }
1174
+ const trimmed = content.trim();
1175
+ return trimmed.length > 0 ? trimmed : null;
1176
+ } catch (err) {
1177
+ if (err.code === "ENOENT") return null;
1178
+ throw err;
1179
+ }
1180
+ }
1181
+ var init_token_store = __esm({
1182
+ "src/server/auth/token-store.ts"() {
1183
+ "use strict";
1184
+ init_constants();
1185
+ }
1186
+ });
1187
+
1188
+ // src/cli/rotate-token.ts
1189
+ var rotate_token_exports = {};
1190
+ __export(rotate_token_exports, {
1191
+ rotateToken: () => rotateToken
1192
+ });
1193
+ import { createHash, randomBytes } from "crypto";
1194
+ import { promises as fsPromises } from "fs";
1195
+ import path2 from "path";
1196
+ function fingerprint(token) {
1197
+ return createHash("sha256").update(token, "utf8").digest("hex").slice(0, 8);
1198
+ }
1199
+ function generateToken() {
1200
+ return randomBytes(32).toString("base64url");
1201
+ }
1202
+ async function rotateToken() {
1203
+ console.error("\n[tandem] Rotating auth token...\n");
1204
+ if (process.env.TANDEM_AUTH_TOKEN) {
1205
+ console.error(
1206
+ "[tandem] Error: TANDEM_AUTH_TOKEN is set in the environment.\n Token rotation is not supported in env-token mode (used by Tauri).\n Unset the variable and let Tandem manage the token file, or rotate\n via your Tauri app's token management instead."
1207
+ );
1208
+ process.exit(1);
1209
+ }
1210
+ const oldToken = await readTokenFromFile();
1211
+ if (!oldToken) {
1212
+ console.error(
1213
+ "[tandem] Error: no token file found. Run `tandem setup` first to initialize the token."
1214
+ );
1215
+ process.exit(1);
1216
+ }
1217
+ const newToken = generateToken();
1218
+ const tokenPath = getTokenFilePath();
1219
+ const dir = path2.dirname(tokenPath);
1220
+ const tmpPath = path2.join(dir, `.auth-token-tmp-${randomBytes(4).toString("hex")}`);
1221
+ try {
1222
+ await fsPromises.writeFile(tmpPath, newToken, { encoding: "utf8", mode: 384 });
1223
+ await fsPromises.rename(tmpPath, tokenPath);
1224
+ } catch (err) {
1225
+ await fsPromises.unlink(tmpPath).catch(() => {
1226
+ });
1227
+ throw err;
1228
+ }
1229
+ const serverUrl = `http://localhost:${DEFAULT_MCP_PORT}`;
1230
+ let graceWindowActive = false;
1231
+ let serverRejected = false;
1232
+ let serverRejectedStatus = 0;
1233
+ try {
1234
+ const resp = await fetch(`${serverUrl}/api/rotate-token`, {
1235
+ method: "POST",
1236
+ headers: {
1237
+ "Content-Type": "application/json",
1238
+ Authorization: `Bearer ${oldToken}`
1239
+ },
1240
+ body: JSON.stringify({}),
1241
+ signal: AbortSignal.timeout(5e3)
1242
+ });
1243
+ if (resp.ok) {
1244
+ graceWindowActive = true;
1245
+ } else {
1246
+ serverRejected = true;
1247
+ serverRejectedStatus = resp.status;
1248
+ }
1249
+ } catch {
1250
+ console.error(
1251
+ "[tandem] Warning: server is not reachable. The new token is written to disk.\n Restart the server to activate the grace window; reconnect Claude Code after."
1252
+ );
1253
+ }
1254
+ let updatedCount = 0;
1255
+ let configErrors = [];
1256
+ try {
1257
+ const result = await applyConfigWithToken(newToken);
1258
+ updatedCount = result.updated;
1259
+ configErrors = result.errors;
1260
+ } catch (err) {
1261
+ console.error(
1262
+ `[tandem] Warning: failed to update MCP configs: ${err instanceof Error ? err.message : String(err)}`
1263
+ );
1264
+ }
1265
+ if (serverRejected) {
1266
+ console.error(
1267
+ `[tandem] WARNING: server rejected the rotation request (status: ${serverRejectedStatus}).`
1268
+ );
1269
+ if (updatedCount > 0) {
1270
+ console.error(
1271
+ ` ${updatedCount} config file(s) updated to the new token, but the server still
1272
+ holds the old token. Restart the server to complete rotation.`
1273
+ );
1274
+ }
1275
+ console.error(` Old fingerprint: ${fingerprint(oldToken)}`);
1276
+ console.error(` New fingerprint: ${fingerprint(newToken)}`);
1277
+ for (const e of configErrors) {
1278
+ console.error(` Warning: could not update config \u2014 ${e}`);
1279
+ }
1280
+ console.error("");
1281
+ return;
1282
+ }
1283
+ console.error("[tandem] Rotated auth token.");
1284
+ console.error(` Old fingerprint: ${fingerprint(oldToken)}`);
1285
+ console.error(` New fingerprint: ${fingerprint(newToken)}`);
1286
+ console.error(` Updated ${updatedCount} config file(s).`);
1287
+ for (const e of configErrors) {
1288
+ console.error(` Warning: could not update config \u2014 ${e}`);
1289
+ }
1290
+ if (graceWindowActive) {
1291
+ console.error(
1292
+ " Old token remains valid for 60 seconds; reconnect Claude Code within that window."
1293
+ );
1294
+ } else {
1295
+ console.error(
1296
+ " Server was not running \u2014 start it with `tandem` and reconnect Claude Code with the new token."
1297
+ );
1298
+ }
1299
+ console.error("");
1300
+ }
1301
+ var init_rotate_token = __esm({
1302
+ "src/cli/rotate-token.ts"() {
1303
+ "use strict";
1304
+ init_token_store();
1305
+ init_constants();
1306
+ init_setup();
1307
+ }
1308
+ });
1309
+
987
1310
  // src/cli/start.ts
988
1311
  var start_exports = {};
989
1312
  __export(start_exports, {
@@ -1026,7 +1349,22 @@ var init_start = __esm({
1026
1349
 
1027
1350
  // src/cli/index.ts
1028
1351
  import updateNotifier from "update-notifier";
1029
- var version = true ? "0.6.3" : "0.0.0-dev";
1352
+ process.once("uncaughtException", (err) => {
1353
+ const msg = err instanceof Error ? err.stack ?? err.message : String(err);
1354
+ try {
1355
+ process.stderr.write(`[tandem cli] uncaughtException: ${msg}
1356
+ `);
1357
+ } catch {
1358
+ }
1359
+ process.exit(1);
1360
+ });
1361
+ process.once("unhandledRejection", (reason) => {
1362
+ const detail = reason instanceof Error ? reason.message : String(reason);
1363
+ process.stderr.write(`[tandem cli] unhandledRejection: ${detail}
1364
+ `);
1365
+ process.exit(1);
1366
+ });
1367
+ var version = true ? "0.7.1" : "0.0.0-dev";
1030
1368
  var args = process.argv.slice(2);
1031
1369
  var isStdioMode = args[0] === "mcp-stdio" || args[0] === "channel";
1032
1370
  if (!isStdioMode) {
@@ -1040,6 +1378,7 @@ Usage:
1040
1378
  tandem setup Register MCP tools with Claude Code / Claude Desktop
1041
1379
  tandem setup --force Register to default paths regardless of detection
1042
1380
  tandem setup --with-channel-shim Also register the stdio channel shim (legacy opt-in)
1381
+ tandem rotate-token Rotate the auth token with a 60-second grace window
1043
1382
  tandem mcp-stdio Run as a stdio MCP server proxying to local HTTP
1044
1383
  (used by the plugin's Cowork bridge; requires
1045
1384
  tandem server running on the host)
@@ -1067,6 +1406,9 @@ try {
1067
1406
  } else if (args[0] === "channel") {
1068
1407
  const { runChannelCli: runChannelCli2 } = await Promise.resolve().then(() => (init_channel(), channel_exports));
1069
1408
  await runChannelCli2();
1409
+ } else if (args[0] === "rotate-token") {
1410
+ const { rotateToken: rotateToken2 } = await Promise.resolve().then(() => (init_rotate_token(), rotate_token_exports));
1411
+ await rotateToken2();
1070
1412
  } else if (!args[0] || args[0] === "start") {
1071
1413
  const { runStart: runStart2 } = await Promise.resolve().then(() => (init_start(), start_exports));
1072
1414
  runStart2();