tandem-editor 0.16.0 → 0.17.0
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/.claude-plugin/plugin.json +3 -3
- package/CHANGELOG.md +24 -1
- package/dist/cli/index.js +310 -9
- package/dist/cli/index.js.map +1 -1
- package/dist/client/assets/{CoworkSettings-B4LapjhG.js → CoworkSettings-Brg8F0cb.js} +1 -1
- package/dist/client/assets/{dist-CJtsfiU2.js → dist-CHppK3gQ.js} +1 -1
- package/dist/client/assets/index-Boow3rxN.css +1 -0
- package/dist/client/assets/index-BymYYAwj.js +271 -0
- package/dist/client/assets/{prod-p35ShryZ.js → prod-Pu_njNLv.js} +1 -1
- package/dist/client/index.html +21 -5
- package/dist/server/index.js +308 -8
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
- package/dist/client/assets/index-BIEOrpU_.js +0 -270
- package/dist/client/assets/index-DdjGUhNd.css +0 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tandem",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Edit and iterate on documents with any MCP-capable AI. Claude is the default and best-supported client today.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Tandem"
|
|
@@ -11,14 +11,14 @@
|
|
|
11
11
|
"mcpServers": {
|
|
12
12
|
"tandem": {
|
|
13
13
|
"command": "npx",
|
|
14
|
-
"args": ["-y", "tandem-editor@0.
|
|
14
|
+
"args": ["-y", "tandem-editor@0.17.0", "mcp-stdio"],
|
|
15
15
|
"env": {
|
|
16
16
|
"TANDEM_URL": "http://127.0.0.1:3479"
|
|
17
17
|
}
|
|
18
18
|
},
|
|
19
19
|
"tandem-channel": {
|
|
20
20
|
"command": "npx",
|
|
21
|
-
"args": ["-y", "tandem-editor@0.
|
|
21
|
+
"args": ["-y", "tandem-editor@0.17.0", "channel"],
|
|
22
22
|
"env": {
|
|
23
23
|
"TANDEM_URL": "http://127.0.0.1:3479"
|
|
24
24
|
}
|
package/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,30 @@ All notable changes to Tandem will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),\
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
-
## [
|
|
8
|
+
## [0.17.0] - 2026-07-17
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- **New-tab search is now fuzzy and ranked by match quality (#1186).** The new-tab menu's file search converges on the same fuzzy-match stack the command palette uses: results rank by match quality rather than pure recency (equal scores keep recency order), name matches outrank directory matches, and subsequence matches are now included (`chp` finds `chapter.md`). A directory-only match keeps its row but leaves the filename unhighlighted.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **The editor no longer throws an unhandled error when focus leaves the document (#1195).** Four surfaces — the status pill's word count, the formatting toolbar, the formatting bar, and find/replace's match count — kept themselves in sync with the editor by writing Svelte state directly from a Tiptap event callback. Tiptap emits those callbacks synchronously from ProseMirror's dispatch, and a native `blur` can arrive while the UI is mid-render, so the write landed inside an active Svelte reaction and threw. Despite the error text naming only `$derived`/`$inspect`/template expressions, an ordinary `{#if}` block is enough to trigger it, and the throw is not dev-only — it escapes through ProseMirror's dispatch in packaged builds too. All four now bridge through a shared microtask hop, which also collapses a burst of transactions into a single update instead of recomputing on every cursor move.
|
|
17
|
+
|
|
18
|
+
- **Settings controls no longer lie about being editable when the store is read-only (#1186).** When the on-disk settings `schemaVersion` is newer than the running build, Tandem opens settings read-only and silently drops writes — but the toggles and radios still flipped visually and then went stale, so the UI disagreed with what was saved. Every settings control across all tabs and the popover now shows a genuine disabled state, with a surface-wide banner explaining why, instead of accepting input it can't persist. (Display-name and actions that don't write settings stay live.)
|
|
19
|
+
|
|
20
|
+
- **Keyboard activation works again in the Cowork "admin declined" dialog (#1186).** The dialog sits inside its backdrop, whose unguarded keydown handler called `preventDefault()` on Enter/Space as they bubbled up from the dialog's own buttons and the Learn-more link — so every control in that dialog was dead to the keyboard, and pressing a key dismissed the dialog out from under you. The backdrop now only acts on events that originate on itself.
|
|
21
|
+
|
|
22
|
+
- **The text selection popup no longer paints a stray frosted-glass rectangle behind itself (#1189).** Selecting text in the desktop app drew a blurred rectangle spanning the popup's whole bounding box — including the empty space beside the narrower Annotate row, where no chrome is actually visible. The shared `.tandem-floating-pill` recipe carried a `backdrop-filter: blur(8px)` that every other pill hides behind its opaque background; the selection popup's shell is deliberately transparent (the two-capsule design), so there the blur had nothing to hide behind. The blur is now gone from the recipe, where it was invisible dead weight on every other surface anyway. The popup's capsules keep their background, border, shadow, and the gap the editor shows through — unchanged.
|
|
23
|
+
|
|
24
|
+
- **The text selection popup no longer paints a stray frosted-glass rectangle behind itself (#1189).** Selecting text in the desktop app drew a blurred rectangle spanning the popup's whole bounding box — including the empty space beside the narrower Annotate row, where no chrome is actually visible. The shared `.tandem-floating-pill` recipe carried a `backdrop-filter: blur(8px)` that every other pill hides behind its opaque background; the selection popup's shell is deliberately transparent (the two-capsule design), so there the blur had nothing to hide behind. The blur is now gone from the recipe, where it was invisible dead weight on every other surface anyway. The popup's capsules keep their background, border, shadow, and the gap the editor shows through — unchanged.
|
|
25
|
+
|
|
26
|
+
Only production builds were affected, which is why it survived since the two-capsule popup landed in #798: that redesign shipped with a `backdrop-filter: none` reset on the shell meant to cancel the recipe's blur, and the reset never survived minification. lightningcss (Vite's default CSS minifier) collapses a `backdrop-filter` + `-webkit-backdrop-filter` pair into the `-webkit-` form alone, and Chromium does not honour the prefixed form, so the reset silently evaporated in the packaged app while working fine in dev. That minifier hazard is tracked separately in #1188; a regression test now forbids both the blur on the recipe and the prefixed-pair pattern that hid it.
|
|
27
|
+
|
|
28
|
+
### Internal
|
|
29
|
+
|
|
30
|
+
- **`tandem doctor` gains two dev-checkout diagnostics (#1192).** A staleness check compares `package.json` / `package-lock.json` against the tree npm actually installed and warns (fix: `npm install`) when they disagree — but only reports PASS after a comparison that compared something, recording an explicit "skipped" result for lockfile shapes it can't compare (v1/v2 lockfiles, root-only maps) rather than an unearned green. An orphaned-Vite check warns when :5173 is serving while both backend ports are down — the "editor loads but nothing works" state a crashed `dev:standalone` leaves behind — after confirming the port really is Vite. Both self-gate to the `tandem-editor` checkout and never appear for an end user running `tandem doctor` in their own project.
|
|
31
|
+
- **A `release` skill codifies the six-surface version bump (#1191).** `.claude/skills/release/SKILL.md` records the full cut sequence: the six version surfaces (four CI-guarded, the two lockfiles not), the changelog and tag steps, and the draft-then-publish GitHub Release flow. The `dev-server` skill gains an orphaned-Vite recovery step, and three Windows/cross-platform gotchas were promoted into `CLAUDE.md`.
|
|
9
32
|
|
|
10
33
|
## [0.16.0] - 2026-07-13
|
|
11
34
|
|
package/dist/cli/index.js
CHANGED
|
@@ -290,7 +290,7 @@ function resolveChannelDist(env = process.env, exists = existsSync) {
|
|
|
290
290
|
return resolve2(PACKAGE_ROOT, "dist/channel/index.js");
|
|
291
291
|
}
|
|
292
292
|
function resolveCliVersion() {
|
|
293
|
-
if (true) return "0.
|
|
293
|
+
if (true) return "0.17.0";
|
|
294
294
|
if (typeof __APP_VERSION__ !== "undefined") return __APP_VERSION__;
|
|
295
295
|
for (const rel of ["../../package.json", "../../../package.json"]) {
|
|
296
296
|
try {
|
|
@@ -2447,8 +2447,12 @@ var init_lockfile = __esm({
|
|
|
2447
2447
|
// src/cli/doctor.ts
|
|
2448
2448
|
var doctor_exports = {};
|
|
2449
2449
|
__export(doctor_exports, {
|
|
2450
|
+
evaluateNpmStaleness: () => evaluateNpmStaleness,
|
|
2451
|
+
evaluateOrphanedVite: () => evaluateOrphanedVite,
|
|
2450
2452
|
evaluateStaleGlobal: () => evaluateStaleGlobal,
|
|
2451
2453
|
globalTandemEditorVersion: () => globalTandemEditorVersion,
|
|
2454
|
+
isTandemEditorRepo: () => isTandemEditorRepo,
|
|
2455
|
+
probeTandemEditorRepo: () => probeTandemEditorRepo,
|
|
2452
2456
|
runDoctor: () => runDoctor,
|
|
2453
2457
|
runDoctorCli: () => runDoctorCli,
|
|
2454
2458
|
summarizeDoctorResults: () => summarizeDoctorResults
|
|
@@ -2459,6 +2463,10 @@ import { request } from "http";
|
|
|
2459
2463
|
import { createConnection as createConnection2 } from "net";
|
|
2460
2464
|
import { homedir as homedir3, platform } from "os";
|
|
2461
2465
|
import { join as join5 } from "path";
|
|
2466
|
+
function errorClass(err) {
|
|
2467
|
+
if (err instanceof Error) return err.name;
|
|
2468
|
+
return typeof err;
|
|
2469
|
+
}
|
|
2462
2470
|
function checkNodeVersion(r) {
|
|
2463
2471
|
const version2 = process.version;
|
|
2464
2472
|
const major = Number.parseInt(version2.slice(1), 10);
|
|
@@ -2478,6 +2486,204 @@ function checkNodeModules(r) {
|
|
|
2478
2486
|
r.fail("node_modules/ not found", "npm install");
|
|
2479
2487
|
}
|
|
2480
2488
|
}
|
|
2489
|
+
function recordEvaluation(r, result) {
|
|
2490
|
+
if (!result) return;
|
|
2491
|
+
if (result.status === "warn") {
|
|
2492
|
+
r.warn(result.message, result.fix, result.data);
|
|
2493
|
+
return;
|
|
2494
|
+
}
|
|
2495
|
+
if (result.status === "skip") {
|
|
2496
|
+
r.pass(`skipped \u2014 ${result.message}`, result.fix, { ...result.data, skipped: true });
|
|
2497
|
+
return;
|
|
2498
|
+
}
|
|
2499
|
+
r.pass(result.message, result.fix, result.data);
|
|
2500
|
+
}
|
|
2501
|
+
function probeTandemEditorRepo(dir) {
|
|
2502
|
+
const read = readJson(join5(dir, "package.json"));
|
|
2503
|
+
if (read.kind === "absent") return "no";
|
|
2504
|
+
if (read.kind === "unreadable") return "unreadable";
|
|
2505
|
+
const parsed = read.value;
|
|
2506
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
2507
|
+
return "unreadable";
|
|
2508
|
+
}
|
|
2509
|
+
return parsed.name === "tandem-editor" ? "yes" : "no";
|
|
2510
|
+
}
|
|
2511
|
+
function isTandemEditorRepo(dir) {
|
|
2512
|
+
return probeTandemEditorRepo(dir) === "yes";
|
|
2513
|
+
}
|
|
2514
|
+
function readJson(path8) {
|
|
2515
|
+
let raw;
|
|
2516
|
+
try {
|
|
2517
|
+
raw = readFileSync3(path8, "utf-8");
|
|
2518
|
+
} catch (err) {
|
|
2519
|
+
const code = err.code;
|
|
2520
|
+
if (code === "ENOENT") return { kind: "absent" };
|
|
2521
|
+
return { kind: "unreadable", reason: code ?? errorClass(err) };
|
|
2522
|
+
}
|
|
2523
|
+
try {
|
|
2524
|
+
return { kind: "ok", value: JSON.parse(raw) };
|
|
2525
|
+
} catch {
|
|
2526
|
+
return { kind: "unreadable", reason: "not valid JSON" };
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
function parseLockfileEntry(value) {
|
|
2530
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
2531
|
+
const entry = value;
|
|
2532
|
+
if (entry.version !== void 0 && typeof entry.version !== "string") return null;
|
|
2533
|
+
if (entry.optional !== void 0 && typeof entry.optional !== "boolean") return null;
|
|
2534
|
+
return {
|
|
2535
|
+
version: entry.version,
|
|
2536
|
+
optional: entry.optional
|
|
2537
|
+
};
|
|
2538
|
+
}
|
|
2539
|
+
function parseLockfileJson(value) {
|
|
2540
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
2541
|
+
const obj = value;
|
|
2542
|
+
if (obj.version !== void 0 && typeof obj.version !== "string") return null;
|
|
2543
|
+
const version2 = obj.version;
|
|
2544
|
+
if (obj.packages === void 0) return { version: version2 };
|
|
2545
|
+
if (typeof obj.packages !== "object" || obj.packages === null || Array.isArray(obj.packages)) {
|
|
2546
|
+
return null;
|
|
2547
|
+
}
|
|
2548
|
+
const packages = {};
|
|
2549
|
+
for (const [path8, raw] of Object.entries(obj.packages)) {
|
|
2550
|
+
const entry = parseLockfileEntry(raw);
|
|
2551
|
+
if (!entry) return null;
|
|
2552
|
+
packages[path8] = entry;
|
|
2553
|
+
}
|
|
2554
|
+
return { version: version2, packages };
|
|
2555
|
+
}
|
|
2556
|
+
function evaluateNpmStaleness(pkgInput, lockInput, hiddenLockInput) {
|
|
2557
|
+
if (!pkgInput || !lockInput || !hiddenLockInput) return null;
|
|
2558
|
+
const lock = parseLockfileJson(lockInput);
|
|
2559
|
+
if (!lock) {
|
|
2560
|
+
return {
|
|
2561
|
+
status: "skip",
|
|
2562
|
+
message: "cannot compare (package-lock.json has an unexpected structure)",
|
|
2563
|
+
fix: "Restore it from git: git checkout package-lock.json",
|
|
2564
|
+
data: { reason: "malformed-lock" }
|
|
2565
|
+
};
|
|
2566
|
+
}
|
|
2567
|
+
const hiddenLock = parseLockfileJson(hiddenLockInput);
|
|
2568
|
+
if (!hiddenLock) {
|
|
2569
|
+
return {
|
|
2570
|
+
status: "skip",
|
|
2571
|
+
message: "cannot compare (node_modules/.package-lock.json has an unexpected structure)",
|
|
2572
|
+
fix: "npm install",
|
|
2573
|
+
data: { reason: "malformed-hidden-lock" }
|
|
2574
|
+
};
|
|
2575
|
+
}
|
|
2576
|
+
const pkg = typeof pkgInput === "object" && !Array.isArray(pkgInput) ? pkgInput : {};
|
|
2577
|
+
const pkgVersion = typeof pkg.version === "string" ? pkg.version : void 0;
|
|
2578
|
+
if (pkgVersion && lock.version && pkgVersion !== lock.version) {
|
|
2579
|
+
return {
|
|
2580
|
+
status: "warn",
|
|
2581
|
+
message: `package-lock.json (v${lock.version}) is out of date with package.json (v${pkgVersion})`,
|
|
2582
|
+
fix: "npm install",
|
|
2583
|
+
data: { packageVersion: pkgVersion, lockVersion: lock.version }
|
|
2584
|
+
};
|
|
2585
|
+
}
|
|
2586
|
+
if (lock.version && hiddenLock.version && lock.version !== hiddenLock.version) {
|
|
2587
|
+
return {
|
|
2588
|
+
status: "warn",
|
|
2589
|
+
message: `node_modules was installed from a different lockfile (v${hiddenLock.version} installed, v${lock.version} expected)`,
|
|
2590
|
+
fix: "npm install",
|
|
2591
|
+
data: { lockVersion: lock.version, installedVersion: hiddenLock.version }
|
|
2592
|
+
};
|
|
2593
|
+
}
|
|
2594
|
+
const wanted = lock.packages ?? {};
|
|
2595
|
+
const installed = hiddenLock.packages ?? {};
|
|
2596
|
+
const wantedCount = Object.keys(wanted).filter((path8) => path8 !== "").length;
|
|
2597
|
+
if (wantedCount === 0) {
|
|
2598
|
+
return {
|
|
2599
|
+
status: "skip",
|
|
2600
|
+
message: "cannot compare (package-lock.json lists no packages \u2014 a lockfileVersion 1 file, or one written by something other than npm)",
|
|
2601
|
+
fix: "npm install",
|
|
2602
|
+
// No inferred `lockfileVersion` here: we did not read that field, and
|
|
2603
|
+
// guessing it from the presence of `packages` would put a fabricated
|
|
2604
|
+
// value under a real npm field name.
|
|
2605
|
+
data: { reason: "no-comparable-packages" }
|
|
2606
|
+
};
|
|
2607
|
+
}
|
|
2608
|
+
const drifted = [];
|
|
2609
|
+
for (const [path8, entry] of Object.entries(wanted)) {
|
|
2610
|
+
if (path8 === "") continue;
|
|
2611
|
+
const got = installed[path8];
|
|
2612
|
+
if (!got) {
|
|
2613
|
+
if (!entry.optional) drifted.push(path8);
|
|
2614
|
+
} else if (entry.version !== got.version) {
|
|
2615
|
+
drifted.push(path8);
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
for (const path8 of Object.keys(installed)) {
|
|
2619
|
+
if (path8 !== "" && !(path8 in wanted)) drifted.push(path8);
|
|
2620
|
+
}
|
|
2621
|
+
if (drifted.length > 0) {
|
|
2622
|
+
return {
|
|
2623
|
+
status: "warn",
|
|
2624
|
+
message: `node_modules is stale \u2014 ${drifted.length} package(s) differ from package-lock.json (e.g. after a pull or branch switch)`,
|
|
2625
|
+
fix: "npm install",
|
|
2626
|
+
data: { driftCount: drifted.length, sample: drifted.slice(0, 5) }
|
|
2627
|
+
};
|
|
2628
|
+
}
|
|
2629
|
+
const missingVersions = [];
|
|
2630
|
+
if (!pkgVersion) missingVersions.push("package.json");
|
|
2631
|
+
if (!lock.version) missingVersions.push("package-lock.json");
|
|
2632
|
+
if (!hiddenLock.version) missingVersions.push("node_modules/.package-lock.json");
|
|
2633
|
+
if (missingVersions.length > 0) {
|
|
2634
|
+
return {
|
|
2635
|
+
status: "skip",
|
|
2636
|
+
message: `node_modules matches package-lock.json, but the version check could not run \u2014 no "version" field in ${missingVersions.join(", ")}`,
|
|
2637
|
+
data: { reason: "no-comparable-version", missingVersions }
|
|
2638
|
+
};
|
|
2639
|
+
}
|
|
2640
|
+
return {
|
|
2641
|
+
status: "pass",
|
|
2642
|
+
message: "node_modules matches package-lock.json",
|
|
2643
|
+
data: { packageCount: Object.keys(installed).length }
|
|
2644
|
+
};
|
|
2645
|
+
}
|
|
2646
|
+
function readLockfileOrReport(r, path8, label) {
|
|
2647
|
+
const read = readJson(path8);
|
|
2648
|
+
if (read.kind === "absent") {
|
|
2649
|
+
r.pass(`skipped \u2014 cannot compare (${label} not found)`, void 0, {
|
|
2650
|
+
skipped: true,
|
|
2651
|
+
reason: "absent",
|
|
2652
|
+
path: label
|
|
2653
|
+
});
|
|
2654
|
+
return { ok: false };
|
|
2655
|
+
}
|
|
2656
|
+
if (read.kind === "ok") {
|
|
2657
|
+
if (typeof read.value === "object" && read.value !== null && !Array.isArray(read.value)) {
|
|
2658
|
+
return { ok: true, value: read.value };
|
|
2659
|
+
}
|
|
2660
|
+
r.warn(
|
|
2661
|
+
`${label} is not a JSON object \u2014 npm install staleness cannot be checked`,
|
|
2662
|
+
"Check for a truncated or hand-edited file, then: npm install",
|
|
2663
|
+
{ reason: "not-an-object", path: label }
|
|
2664
|
+
);
|
|
2665
|
+
return { ok: false };
|
|
2666
|
+
}
|
|
2667
|
+
r.warn(
|
|
2668
|
+
`${label} could not be read (${read.reason}) \u2014 npm install staleness cannot be checked`,
|
|
2669
|
+
"Check for merge-conflict markers or a truncated file, then: npm install",
|
|
2670
|
+
{ reason: read.reason, path: label }
|
|
2671
|
+
);
|
|
2672
|
+
return { ok: false };
|
|
2673
|
+
}
|
|
2674
|
+
function checkNpmStaleness(r, repoDir) {
|
|
2675
|
+
const pkgRead = readJson(join5(repoDir, "package.json"));
|
|
2676
|
+
const pkg = pkgRead.kind === "ok" ? pkgRead.value : null;
|
|
2677
|
+
const lock = readLockfileOrReport(r, join5(repoDir, "package-lock.json"), "package-lock.json");
|
|
2678
|
+
if (!lock.ok) return;
|
|
2679
|
+
const hiddenLock = readLockfileOrReport(
|
|
2680
|
+
r,
|
|
2681
|
+
join5(repoDir, "node_modules", ".package-lock.json"),
|
|
2682
|
+
"node_modules/.package-lock.json"
|
|
2683
|
+
);
|
|
2684
|
+
if (!hiddenLock.ok) return;
|
|
2685
|
+
recordEvaluation(r, evaluateNpmStaleness(pkg, lock.value, hiddenLock.value));
|
|
2686
|
+
}
|
|
2481
2687
|
function checkMcpJson(r) {
|
|
2482
2688
|
const mcpPath = join5(process.cwd(), ".mcp.json");
|
|
2483
2689
|
if (!existsSync3(mcpPath)) {
|
|
@@ -2603,14 +2809,64 @@ async function checkPorts(r, wsPort, mcpPort) {
|
|
|
2603
2809
|
}
|
|
2604
2810
|
return { ws, mcp };
|
|
2605
2811
|
}
|
|
2812
|
+
function evaluateOrphanedVite(input) {
|
|
2813
|
+
const { viteUp, viteConfirmed, wsUp, mcpUp, wsPort, mcpPort, vitePort } = input;
|
|
2814
|
+
if (!viteUp) return null;
|
|
2815
|
+
if (!viteConfirmed) {
|
|
2816
|
+
return {
|
|
2817
|
+
status: "skip",
|
|
2818
|
+
message: `cannot identify the process on :${vitePort} \u2014 it is listening but did not answer /@vite/client, so it is probably not a Vite dev server`,
|
|
2819
|
+
data: { vite: false, ws: wsUp, mcp: mcpUp, reason: "unconfirmed-vite" }
|
|
2820
|
+
};
|
|
2821
|
+
}
|
|
2822
|
+
if (!wsUp && !mcpUp) {
|
|
2823
|
+
return {
|
|
2824
|
+
status: "warn",
|
|
2825
|
+
message: `Vite dev server on :${vitePort} is running but the backend (:${wsPort} + :${mcpPort}) is down \u2014 likely orphaned by a crashed dev session`,
|
|
2826
|
+
fix: `If you meant to run the client alone (npm run dev:client), this is expected. Otherwise kill the process on :${vitePort} and restart: npm run dev:standalone`,
|
|
2827
|
+
data: { vite: true, ws: wsUp, mcp: mcpUp }
|
|
2828
|
+
};
|
|
2829
|
+
}
|
|
2830
|
+
if (wsUp !== mcpUp) {
|
|
2831
|
+
return {
|
|
2832
|
+
status: "skip",
|
|
2833
|
+
message: `cannot tell whether the Vite dev server on :${vitePort} is orphaned \u2014 the backend is only partially up (:${wsPort} ${wsUp ? "up" : "down"}, :${mcpPort} ${mcpUp ? "up" : "down"}); see the ports check`,
|
|
2834
|
+
data: { vite: true, ws: wsUp, mcp: mcpUp, reason: "partial-backend" }
|
|
2835
|
+
};
|
|
2836
|
+
}
|
|
2837
|
+
return {
|
|
2838
|
+
status: "pass",
|
|
2839
|
+
message: `Vite dev server (:${vitePort}) running alongside the backend`,
|
|
2840
|
+
data: { vite: true, ws: wsUp, mcp: mcpUp }
|
|
2841
|
+
};
|
|
2842
|
+
}
|
|
2843
|
+
async function checkOrphanedVite(r, wsUp, mcpUp, wsPort, mcpPort, vitePort) {
|
|
2844
|
+
const viteUp = await probePort(vitePort);
|
|
2845
|
+
const viteConfirmed = viteUp ? await isViteDevServer(vitePort) : false;
|
|
2846
|
+
recordEvaluation(
|
|
2847
|
+
r,
|
|
2848
|
+
evaluateOrphanedVite({ viteUp, viteConfirmed, wsUp, mcpUp, wsPort, mcpPort, vitePort })
|
|
2849
|
+
);
|
|
2850
|
+
}
|
|
2606
2851
|
function httpGet(url, timeoutMs = 3e3) {
|
|
2607
2852
|
return new Promise((resolve4) => {
|
|
2608
2853
|
const req = request(url, { timeout: timeoutMs }, (res) => {
|
|
2609
2854
|
let body = "";
|
|
2855
|
+
let bytes = 0;
|
|
2856
|
+
let truncated = false;
|
|
2610
2857
|
res.on("data", (chunk) => {
|
|
2858
|
+
if (truncated) return;
|
|
2859
|
+
bytes += Buffer.byteLength(chunk);
|
|
2860
|
+
if (bytes > HTTP_MAX_BYTES) {
|
|
2861
|
+
truncated = true;
|
|
2862
|
+
res.destroy();
|
|
2863
|
+
resolve4({ status: res.statusCode, data: null });
|
|
2864
|
+
return;
|
|
2865
|
+
}
|
|
2611
2866
|
body += chunk;
|
|
2612
2867
|
});
|
|
2613
2868
|
res.on("end", () => {
|
|
2869
|
+
if (truncated) return;
|
|
2614
2870
|
try {
|
|
2615
2871
|
resolve4({ status: res.statusCode, data: JSON.parse(body) });
|
|
2616
2872
|
} catch {
|
|
@@ -2626,6 +2882,10 @@ function httpGet(url, timeoutMs = 3e3) {
|
|
|
2626
2882
|
req.end();
|
|
2627
2883
|
});
|
|
2628
2884
|
}
|
|
2885
|
+
async function isViteDevServer(port) {
|
|
2886
|
+
const result = await httpGet(`http://127.0.0.1:${port}/@vite/client`, 2e3);
|
|
2887
|
+
return result?.status === 200;
|
|
2888
|
+
}
|
|
2629
2889
|
async function checkHealth(r, mcpPort) {
|
|
2630
2890
|
const result = await httpGet(`http://127.0.0.1:${mcpPort}/health`);
|
|
2631
2891
|
if (!result) {
|
|
@@ -2865,7 +3125,7 @@ function evaluateStaleGlobal(bundled, globalVersion) {
|
|
|
2865
3125
|
};
|
|
2866
3126
|
}
|
|
2867
3127
|
async function checkStaleGlobal(r) {
|
|
2868
|
-
const bundled = true ? "0.
|
|
3128
|
+
const bundled = true ? "0.17.0" : null;
|
|
2869
3129
|
if (!bundled) return;
|
|
2870
3130
|
let globalVersion;
|
|
2871
3131
|
try {
|
|
@@ -2884,16 +3144,37 @@ async function checkStaleGlobal(r) {
|
|
|
2884
3144
|
async function runDoctor(opts = {}) {
|
|
2885
3145
|
const wsPort = opts.wsPort ?? DEFAULT_WS_PORT;
|
|
2886
3146
|
const mcpPort = opts.mcpPort ?? DEFAULT_MCP_PORT;
|
|
3147
|
+
const vitePort = opts.vitePort ?? VITE_DEV_PORT;
|
|
2887
3148
|
const r = new Recorder();
|
|
3149
|
+
const cwd = process.cwd();
|
|
3150
|
+
const repo = probeTandemEditorRepo(cwd);
|
|
3151
|
+
const devRepo = repo === "yes";
|
|
2888
3152
|
await r.check("node-version", () => checkNodeVersion(r));
|
|
2889
3153
|
await r.check("node-modules", () => checkNodeModules(r));
|
|
3154
|
+
if (repo === "unreadable") {
|
|
3155
|
+
await r.check(
|
|
3156
|
+
"dev-repo",
|
|
3157
|
+
() => r.warn(
|
|
3158
|
+
"package.json in the current directory could not be read \u2014 if this is the tandem-editor checkout, the npm-staleness and orphaned-Vite checks are being skipped",
|
|
3159
|
+
"Check for merge-conflict markers or a truncated file: git checkout package.json"
|
|
3160
|
+
)
|
|
3161
|
+
);
|
|
3162
|
+
}
|
|
3163
|
+
if (devRepo) {
|
|
3164
|
+
await r.check("npm-staleness", () => checkNpmStaleness(r, cwd));
|
|
3165
|
+
}
|
|
2890
3166
|
await r.check("mcp-json", () => checkMcpJson(r));
|
|
2891
3167
|
await r.check("user-mcp-config", () => checkUserMcpConfig(r));
|
|
2892
3168
|
await r.check("annotation-store", () => checkAnnotationStore(r));
|
|
2893
3169
|
await r.check("stale-global", () => checkStaleGlobal(r));
|
|
2894
|
-
const
|
|
3170
|
+
const ports = await r.check("ports", () => checkPorts(r, wsPort, mcpPort));
|
|
3171
|
+
const ws = ports?.ws ?? false;
|
|
3172
|
+
const mcp = ports?.mcp ?? false;
|
|
3173
|
+
if (devRepo) {
|
|
3174
|
+
await r.check("orphaned-vite", () => checkOrphanedVite(r, ws, mcp, wsPort, mcpPort, vitePort));
|
|
3175
|
+
}
|
|
2895
3176
|
if (mcp) {
|
|
2896
|
-
const healthy = await r.check("health", () => checkHealth(r, mcpPort));
|
|
3177
|
+
const healthy = await r.check("health", () => checkHealth(r, mcpPort)) ?? false;
|
|
2897
3178
|
if (healthy) {
|
|
2898
3179
|
await r.check("sse", () => checkSseEndpoint(r, mcpPort));
|
|
2899
3180
|
}
|
|
@@ -2976,7 +3257,7 @@ async function runDoctorCli(opts = {}) {
|
|
|
2976
3257
|
out("");
|
|
2977
3258
|
return exitCode;
|
|
2978
3259
|
}
|
|
2979
|
-
var Recorder;
|
|
3260
|
+
var Recorder, VITE_DEV_PORT, HTTP_MAX_BYTES;
|
|
2980
3261
|
var init_doctor = __esm({
|
|
2981
3262
|
"src/cli/doctor.ts"() {
|
|
2982
3263
|
"use strict";
|
|
@@ -2987,11 +3268,29 @@ var init_doctor = __esm({
|
|
|
2987
3268
|
warnings = 0;
|
|
2988
3269
|
results = [];
|
|
2989
3270
|
currentCheck = "";
|
|
3271
|
+
/**
|
|
3272
|
+
* Run one check under `name`. A throwing check is contained here and
|
|
3273
|
+
* recorded as a `fail` rather than taking the whole report down.
|
|
3274
|
+
*
|
|
3275
|
+
* Returns `T | undefined` — NOT `T`. The `undefined` is load-bearing and
|
|
3276
|
+
* must not be cast away: a crashed check produced no value, and the two
|
|
3277
|
+
* callers that consume one (`ports` destructures `{ws, mcp}`; `health`
|
|
3278
|
+
* gates the `sse` check) have to say what they want instead. Returning
|
|
3279
|
+
* `undefined as T` would satisfy the compiler and then throw a TypeError
|
|
3280
|
+
* on the destructure — converting a clear crash into a confusing one on
|
|
3281
|
+
* the exact path this containment exists to protect.
|
|
3282
|
+
*/
|
|
2990
3283
|
async check(name, fn) {
|
|
2991
3284
|
const prev = this.currentCheck;
|
|
2992
3285
|
this.currentCheck = name;
|
|
2993
3286
|
try {
|
|
2994
3287
|
return await fn();
|
|
3288
|
+
} catch (err) {
|
|
3289
|
+
this.fail(
|
|
3290
|
+
`${name} check crashed (${errorClass(err)}) \u2014 the rest of the report is still valid`,
|
|
3291
|
+
"Please report this at https://github.com/bloknayrb/tandem/issues"
|
|
3292
|
+
);
|
|
3293
|
+
return void 0;
|
|
2995
3294
|
} finally {
|
|
2996
3295
|
this.currentCheck = prev;
|
|
2997
3296
|
}
|
|
@@ -3014,6 +3313,8 @@ var init_doctor = __esm({
|
|
|
3014
3313
|
this.record("fail", msg, fix, fields);
|
|
3015
3314
|
}
|
|
3016
3315
|
};
|
|
3316
|
+
VITE_DEV_PORT = 5173;
|
|
3317
|
+
HTTP_MAX_BYTES = 256 * 1024;
|
|
3017
3318
|
}
|
|
3018
3319
|
});
|
|
3019
3320
|
|
|
@@ -3696,7 +3997,7 @@ import fs4 from "fs";
|
|
|
3696
3997
|
function knownVersion(v) {
|
|
3697
3998
|
return typeof v === "string" && KNOWN_VERSION_MAJORS.has(v.split(".")[0]);
|
|
3698
3999
|
}
|
|
3699
|
-
function
|
|
4000
|
+
function readJson2(filePath) {
|
|
3700
4001
|
try {
|
|
3701
4002
|
return JSON.parse(fs4.readFileSync(filePath, "utf-8"));
|
|
3702
4003
|
} catch {
|
|
@@ -3709,7 +4010,7 @@ function resolveLicenseState(deps) {
|
|
|
3709
4010
|
return { gateActive: false };
|
|
3710
4011
|
}
|
|
3711
4012
|
const nowMs = now();
|
|
3712
|
-
const lf =
|
|
4013
|
+
const lf = readJson2(licenseFilePath(appDataDir));
|
|
3713
4014
|
if (lf?.blob) {
|
|
3714
4015
|
try {
|
|
3715
4016
|
const meta = verify(lf.blob);
|
|
@@ -3726,7 +4027,7 @@ function resolveLicenseState(deps) {
|
|
|
3726
4027
|
} catch {
|
|
3727
4028
|
}
|
|
3728
4029
|
}
|
|
3729
|
-
const tf =
|
|
4030
|
+
const tf = readJson2(trialFilePath(appDataDir));
|
|
3730
4031
|
const firstRunAt = tf?.firstRunAt ? new Date(tf.firstRunAt).getTime() : nowMs;
|
|
3731
4032
|
const expiresAt = firstRunAt + TRIAL_MS;
|
|
3732
4033
|
if (nowMs < expiresAt) {
|
|
@@ -3917,7 +4218,7 @@ process.once("unhandledRejection", (reason) => {
|
|
|
3917
4218
|
`);
|
|
3918
4219
|
process.exit(1);
|
|
3919
4220
|
});
|
|
3920
|
-
var version = true ? "0.
|
|
4221
|
+
var version = true ? "0.17.0" : "0.0.0-dev";
|
|
3921
4222
|
var args = process.argv.slice(2);
|
|
3922
4223
|
if (args.includes("--help") || args.includes("-h")) {
|
|
3923
4224
|
console.log(`tandem v${version}
|