tmux-ide 2.6.0 → 2.7.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/README.md +14 -9
- package/bin/cli.js +1024 -519
- package/bin/cli.ts +63 -6
- package/bunfig.toml +4 -0
- package/package.json +18 -7
- package/packages/contracts/package.json +22 -0
- package/packages/contracts/src/__tests__/ide-config.test.ts +46 -0
- package/packages/contracts/src/__tests__/terminals.test.ts +87 -0
- package/packages/contracts/src/actions-contract.ts +310 -0
- package/packages/contracts/src/actions-errors.ts +41 -0
- package/packages/contracts/src/domain.ts +36 -0
- package/packages/contracts/src/ide-config.ts +170 -0
- package/packages/contracts/src/index.ts +24 -0
- package/packages/contracts/src/lib-internal/auth.ts +13 -0
- package/packages/contracts/src/lib-internal/hq.ts +38 -0
- package/packages/contracts/src/terminals.ts +116 -0
- package/packages/contracts/src/tmux.ts +60 -0
- package/packages/contracts/src/workspace.ts +67 -0
- package/packages/daemon/dist/agent-explain.d.ts +8 -1
- package/packages/daemon/dist/agent-explain.js +19 -3
- package/packages/daemon/dist/lib/tui-binary.d.ts +57 -0
- package/packages/daemon/dist/lib/tui-binary.js +130 -0
- package/packages/daemon/dist/widgets/explorer/breadcrumbs.d.ts +1 -1
- package/packages/daemon/dist/widgets/explorer/footer.d.ts +1 -1
- package/packages/daemon/dist/widgets/explorer/tree.d.ts +1 -1
- package/packages/daemon/dist/widgets/lib/help-overlay.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/agent-naming.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/config-tree.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/detect-panel.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/field-editor.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/footer.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/layout-picker.d.ts +1 -1
- package/packages/daemon/src/agent-explain.ts +298 -0
- package/packages/daemon/src/attach.ts +20 -0
- package/packages/daemon/src/bin.ts +4 -0
- package/packages/daemon/src/canonical.ts +7 -0
- package/packages/daemon/src/cli.ts +499 -0
- package/packages/daemon/src/command-center/actions/contract.ts +2 -0
- package/packages/daemon/src/command-center/actions/dispatcher.ts +137 -0
- package/packages/daemon/src/command-center/actions/errors.ts +105 -0
- package/packages/daemon/src/command-center/actions/handlers/_project-context.ts +30 -0
- package/packages/daemon/src/command-center/actions/handlers/_resolve-project.ts +78 -0
- package/packages/daemon/src/command-center/actions/handlers/app-set-remote-access.ts +118 -0
- package/packages/daemon/src/command-center/actions/handlers/config-actions.ts +113 -0
- package/packages/daemon/src/command-center/actions/handlers/daemon-shutdown.ts +38 -0
- package/packages/daemon/src/command-center/actions/handlers/project-activate.ts +30 -0
- package/packages/daemon/src/command-center/actions/handlers/project-launch.ts +70 -0
- package/packages/daemon/src/command-center/actions/handlers/project-open-terminal.ts +87 -0
- package/packages/daemon/src/command-center/actions/handlers/project-restart.ts +38 -0
- package/packages/daemon/src/command-center/actions/handlers/project-stop.ts +62 -0
- package/packages/daemon/src/command-center/actions/handlers/terminal-respawn.ts +119 -0
- package/packages/daemon/src/command-center/actions/handlers/terminal-stop.ts +35 -0
- package/packages/daemon/src/command-center/actions/registry.ts +149 -0
- package/packages/daemon/src/command-center/discovery.ts +96 -0
- package/packages/daemon/src/command-center/index.ts +31 -0
- package/packages/daemon/src/command-center/schemas.ts +85 -0
- package/packages/daemon/src/command-center/server.ts +1260 -0
- package/packages/daemon/src/command-center/ws-events.ts +316 -0
- package/packages/daemon/src/config.ts +549 -0
- package/packages/daemon/src/detect.ts +248 -0
- package/packages/daemon/src/doctor.ts +242 -0
- package/packages/daemon/src/embed.ts +5 -0
- package/packages/daemon/src/index.ts +12 -0
- package/packages/daemon/src/init.ts +211 -0
- package/packages/daemon/src/inspect.ts +178 -0
- package/packages/daemon/src/js-yaml.d.ts +10 -0
- package/packages/daemon/src/launch.ts +349 -0
- package/packages/daemon/src/lib/active-projects.ts +49 -0
- package/packages/daemon/src/lib/agent-discovery.ts +121 -0
- package/packages/daemon/src/lib/app-config.ts +427 -0
- package/packages/daemon/src/lib/app-settings.ts +53 -0
- package/packages/daemon/src/lib/auth/auth-service.ts +227 -0
- package/packages/daemon/src/lib/auth/middleware.ts +56 -0
- package/packages/daemon/src/lib/auth/types.ts +2 -0
- package/packages/daemon/src/lib/auth-token.ts +5 -0
- package/packages/daemon/src/lib/authorship.ts +280 -0
- package/packages/daemon/src/lib/canonical-daemon.ts +122 -0
- package/packages/daemon/src/lib/cli-action-bridge.ts +216 -0
- package/packages/daemon/src/lib/daemon-embed.ts +782 -0
- package/packages/daemon/src/lib/daemon-watchdog.ts +111 -0
- package/packages/daemon/src/lib/daemon.ts +79 -0
- package/packages/daemon/src/lib/dot-path.ts +17 -0
- package/packages/daemon/src/lib/errors.ts +67 -0
- package/packages/daemon/src/lib/filesystem-browser.ts +292 -0
- package/packages/daemon/src/lib/launch-plan.ts +90 -0
- package/packages/daemon/src/lib/log.ts +134 -0
- package/packages/daemon/src/lib/output.ts +76 -0
- package/packages/daemon/src/lib/project-init-runner.ts +150 -0
- package/packages/daemon/src/lib/project-inspect.ts +80 -0
- package/packages/daemon/src/lib/project-onboard.ts +149 -0
- package/packages/daemon/src/lib/project-probe.ts +92 -0
- package/packages/daemon/src/lib/project-registry.ts +296 -0
- package/packages/daemon/src/lib/session-monitor.ts +122 -0
- package/packages/daemon/src/lib/session-options.ts +100 -0
- package/packages/daemon/src/lib/shell.ts +8 -0
- package/packages/daemon/src/lib/sizes.ts +36 -0
- package/packages/daemon/src/lib/skill-sync.ts +155 -0
- package/packages/daemon/src/lib/slugify.ts +10 -0
- package/packages/daemon/src/lib/terminals-store.ts +125 -0
- package/packages/daemon/src/lib/tui-binary.ts +165 -0
- package/packages/daemon/src/lib/update-check.ts +298 -0
- package/packages/daemon/src/lib/update.ts +158 -0
- package/packages/daemon/src/lib/workspace-registry.ts +229 -0
- package/packages/daemon/src/lib/worktree.ts +289 -0
- package/packages/daemon/src/lib/yaml-io.ts +27 -0
- package/packages/daemon/src/ls.ts +40 -0
- package/packages/daemon/src/restart.ts +24 -0
- package/packages/daemon/src/restore.ts +514 -0
- package/packages/daemon/src/schemas/domain.ts +2 -0
- package/packages/daemon/src/schemas/filesystem.ts +34 -0
- package/packages/daemon/src/schemas/ide-config.ts +2 -0
- package/packages/daemon/src/schemas/index.ts +59 -0
- package/packages/daemon/src/schemas/inspect.ts +67 -0
- package/packages/daemon/src/schemas/registry.ts +55 -0
- package/packages/daemon/src/schemas/ws-events.ts +135 -0
- package/packages/daemon/src/send.ts +171 -0
- package/packages/daemon/src/server/README.md +15 -0
- package/packages/daemon/src/server/index.ts +74 -0
- package/packages/daemon/src/server/pty-bridge.ts +532 -0
- package/packages/daemon/src/server/standalone.ts +18 -0
- package/packages/daemon/src/server/ws-route.ts +483 -0
- package/packages/daemon/src/status.ts +59 -0
- package/packages/daemon/src/stop.ts +28 -0
- package/packages/daemon/src/terminal/NodePtyAdapter.ts +271 -0
- package/packages/daemon/src/terminal/PtyAdapter.ts +140 -0
- package/packages/daemon/src/terminal/README.md +92 -0
- package/packages/daemon/src/tui/chrome/cheatsheet.ts +260 -0
- package/packages/daemon/src/tui/chrome/chip.ts +30 -0
- package/packages/daemon/src/tui/chrome/events.ts +119 -0
- package/packages/daemon/src/tui/chrome/kitty-keys.ts +55 -0
- package/packages/daemon/src/tui/chrome/menu.ts +289 -0
- package/packages/daemon/src/tui/chrome/notify.ts +382 -0
- package/packages/daemon/src/tui/chrome/panels.ts +111 -0
- package/packages/daemon/src/tui/chrome/sidebar.ts +222 -0
- package/packages/daemon/src/tui/chrome/snapshot.ts +425 -0
- package/packages/daemon/src/tui/chrome/statusline.ts +595 -0
- package/packages/daemon/src/tui/chrome/updater.ts +510 -0
- package/packages/daemon/src/tui/chrome/welcome.ts +124 -0
- package/packages/daemon/src/tui/compiled.ts +121 -0
- package/packages/daemon/src/tui/detect/classify.ts +208 -0
- package/packages/daemon/src/tui/detect/manifest-loader.ts +193 -0
- package/packages/daemon/src/tui/detect/manifest.ts +199 -0
- package/packages/daemon/src/tui/detect/manifests.ts +354 -0
- package/packages/daemon/src/tui/detect/process-tree.ts +217 -0
- package/packages/daemon/src/tui/detect/snapshot.ts +70 -0
- package/packages/daemon/src/tui/integrations/claude.ts +176 -0
- package/packages/daemon/src/tui/integrations/offer.ts +145 -0
- package/packages/daemon/src/tui/main.ts +82 -0
- package/packages/daemon/src/tui/mirror/ack-writer.ts +77 -0
- package/packages/daemon/src/tui/mirror/agent-chip.ts +97 -0
- package/packages/daemon/src/tui/mirror/agent-rows.ts +133 -0
- package/packages/daemon/src/tui/mirror/app-state.ts +179 -0
- package/packages/daemon/src/tui/mirror/app.tsx +5265 -0
- package/packages/daemon/src/tui/mirror/blit.ts +186 -0
- package/packages/daemon/src/tui/mirror/control-client.ts +214 -0
- package/packages/daemon/src/tui/mirror/control.ts +97 -0
- package/packages/daemon/src/tui/mirror/dialog-model.ts +298 -0
- package/packages/daemon/src/tui/mirror/dialog-stack.ts +354 -0
- package/packages/daemon/src/tui/mirror/diff-model.ts +112 -0
- package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
- package/packages/daemon/src/tui/mirror/file-tree.ts +97 -0
- package/packages/daemon/src/tui/mirror/focus-border.ts +57 -0
- package/packages/daemon/src/tui/mirror/folder-picker.ts +124 -0
- package/packages/daemon/src/tui/mirror/home-model.ts +174 -0
- package/packages/daemon/src/tui/mirror/input-coalescer.ts +105 -0
- package/packages/daemon/src/tui/mirror/menu-model.ts +187 -0
- package/packages/daemon/src/tui/mirror/palette.ts +274 -0
- package/packages/daemon/src/tui/mirror/pane-mirror.ts +561 -0
- package/packages/daemon/src/tui/mirror/pane-surface.tsx +415 -0
- package/packages/daemon/src/tui/mirror/perf-tap.ts +160 -0
- package/packages/daemon/src/tui/mirror/resize-model.ts +85 -0
- package/packages/daemon/src/tui/mirror/scrollbar-model.ts +88 -0
- package/packages/daemon/src/tui/mirror/search-model.ts +70 -0
- package/packages/daemon/src/tui/mirror/selection.ts +262 -0
- package/packages/daemon/src/tui/mirror/session-mirror.ts +443 -0
- package/packages/daemon/src/tui/mirror/settings-model.ts +345 -0
- package/packages/daemon/src/tui/mirror/size-truth.ts +77 -0
- package/packages/daemon/src/tui/mirror/spans.ts +46 -0
- package/packages/daemon/src/tui/mirror/status-grammar.ts +32 -0
- package/packages/daemon/src/tui/team/CONTROL.md +50 -0
- package/packages/daemon/src/tui/team/entry.ts +38 -0
- package/packages/daemon/src/tui/team/fuzzy.ts +133 -0
- package/packages/daemon/src/tui/team/home.ts +170 -0
- package/packages/daemon/src/tui/team/index.tsx +1521 -0
- package/packages/daemon/src/tui/team/input.ts +34 -0
- package/packages/daemon/src/tui/team/keymap.ts +127 -0
- package/packages/daemon/src/tui/team/mouse.ts +29 -0
- package/packages/daemon/src/tui/team/nav.ts +31 -0
- package/packages/daemon/src/tui/team/preview.ts +34 -0
- package/packages/daemon/src/tui/team/projects.ts +191 -0
- package/packages/daemon/src/tui/team/report.ts +83 -0
- package/packages/daemon/src/tui/team/sessions.ts +433 -0
- package/packages/daemon/src/tui/team/tree.ts +62 -0
- package/packages/daemon/src/types.ts +13 -0
- package/packages/daemon/src/ui/index.ts +32 -0
- package/packages/daemon/src/ui/terminal/index.ts +9 -0
- package/packages/daemon/src/ui/types.ts +91 -0
- package/packages/daemon/src/ui/web/base.css +80 -0
- package/packages/daemon/src/ui/web/components/Box.tsx +59 -0
- package/packages/daemon/src/ui/web/components/Input.tsx +32 -0
- package/packages/daemon/src/ui/web/components/ScrollBox.tsx +60 -0
- package/packages/daemon/src/ui/web/components/Text.tsx +28 -0
- package/packages/daemon/src/ui/web/hooks.ts +106 -0
- package/packages/daemon/src/ui/web/index.ts +27 -0
- package/packages/daemon/src/ui/web/render.ts +77 -0
- package/packages/daemon/src/ui/web/utils/color.ts +27 -0
- package/packages/daemon/src/validate.ts +217 -0
- package/packages/daemon/src/widgets/changes/README.md +3 -0
- package/packages/daemon/src/widgets/changes/index.tsx +691 -0
- package/packages/daemon/src/widgets/config/README.md +3 -0
- package/packages/daemon/src/widgets/config/index.tsx +481 -0
- package/packages/daemon/src/widgets/explorer/README.md +3 -0
- package/packages/daemon/src/widgets/explorer/breadcrumbs.tsx +77 -0
- package/packages/daemon/src/widgets/explorer/footer.tsx +20 -0
- package/packages/daemon/src/widgets/explorer/header.tsx +23 -0
- package/packages/daemon/src/widgets/explorer/index.tsx +456 -0
- package/packages/daemon/src/widgets/explorer/tree-model.ts +103 -0
- package/packages/daemon/src/widgets/explorer/tree.tsx +165 -0
- package/packages/daemon/src/widgets/lib/config-model.ts +116 -0
- package/packages/daemon/src/widgets/lib/files.ts +88 -0
- package/packages/daemon/src/widgets/lib/git.ts +88 -0
- package/packages/daemon/src/widgets/lib/grammar.ts +126 -0
- package/packages/daemon/src/widgets/lib/help-overlay.tsx +101 -0
- package/packages/daemon/src/widgets/lib/pane-comms.ts +209 -0
- package/packages/daemon/src/widgets/lib/theme.ts +194 -0
- package/packages/daemon/src/widgets/lib/watcher.ts +132 -0
- package/packages/daemon/src/widgets/preview/README.md +3 -0
- package/packages/daemon/src/widgets/preview/index.tsx +416 -0
- package/packages/daemon/src/widgets/resolve.ts +121 -0
- package/packages/daemon/src/widgets/setup/README.md +3 -0
- package/packages/daemon/src/widgets/setup/agent-naming.tsx +112 -0
- package/packages/daemon/src/widgets/setup/config-tree.tsx +246 -0
- package/packages/daemon/src/widgets/setup/detect-panel.tsx +72 -0
- package/packages/daemon/src/widgets/setup/field-editor.tsx +265 -0
- package/packages/daemon/src/widgets/setup/footer.tsx +107 -0
- package/packages/daemon/src/widgets/setup/index.tsx +341 -0
- package/packages/daemon/src/widgets/setup/layout-picker.tsx +96 -0
- package/packages/daemon/src/widgets/setup/orchestrator-panel.tsx +200 -0
- package/packages/daemon/src/widgets/setup/review-panel.tsx +140 -0
- package/packages/daemon/src/widgets/setup/setup-model.ts +188 -0
- package/packages/daemon/src/widgets/sidebar/index.tsx +527 -0
- package/packages/tmux-bridge/package.json +22 -0
- package/packages/tmux-bridge/src/errors.ts +28 -0
- package/packages/tmux-bridge/src/index.ts +31 -0
- package/packages/tmux-bridge/src/monitor.ts +77 -0
- package/packages/tmux-bridge/src/panes.ts +136 -0
- package/packages/tmux-bridge/src/runner.test.ts +501 -0
- package/packages/tmux-bridge/src/runner.ts +91 -0
- package/packages/tmux-bridge/src/sessions.ts +126 -0
- package/packages/tmux-bridge/src/targeting.test.ts +107 -0
- package/packages/tmux-bridge/src/targeting.ts +90 -0
- package/scripts/build-tui.mjs +11 -4
- package/scripts/perf-mirror.mjs +313 -0
- package/scripts/postinstall.js +26 -2
- package/skill/SKILL.md +22 -0
- package/templates/AGENTS.md +14 -7
- package/templates/agent-team-monorepo.yml +8 -0
- package/templates/agent-team-nextjs.yml +8 -0
- package/templates/agent-team.yml +10 -0
- package/templates/convex.yml +2 -0
- package/templates/default.yml +11 -5
- package/templates/go.yml +4 -0
- package/templates/missions.yml +6 -0
- package/templates/nextjs.yml +4 -0
- package/templates/python.yml +4 -0
- package/templates/skills/backend.md +5 -12
- package/templates/skills/frontend.md +5 -12
- package/templates/skills/general-worker.md +5 -12
- package/templates/skills/researcher.md +7 -12
- package/templates/skills/reviewer.md +7 -16
- package/templates/vite.yml +4 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The built-in update check — "you're on an old tmux-ide" surfaced where
|
|
3
|
+
* everyone already looks: the dock.
|
|
4
|
+
*
|
|
5
|
+
* Once a day the chrome updater asks npm for the latest published version, caches
|
|
6
|
+
* the answer in `~/.tmux-ide/update-check.json` (overridable via `TMUX_IDE_HOME`,
|
|
7
|
+
* consistent with {@link ../tui/chrome/welcome.ts}), and — when the cached latest
|
|
8
|
+
* is newer than what's running — every surface reads that ONE cache to decide
|
|
9
|
+
* whether to nudge: the dock's `⬆ v<latest>` segment, a one-time toast per
|
|
10
|
+
* version, `tmux-ide doctor`, the actions menu, and the `tmux-ide` start hint.
|
|
11
|
+
*
|
|
12
|
+
* The split, as elsewhere: {@link compareSemver} / {@link isNewer} /
|
|
13
|
+
* {@link shouldCheck} / {@link parseRegistryResponse} are PURE (unit-tested, never
|
|
14
|
+
* throw); {@link fetchLatestVersion}, the cache read/write, and the throttled
|
|
15
|
+
* {@link runUpdateCheck} are the thin — and strictly OFFLINE-SAFE — io wrappers.
|
|
16
|
+
* Nothing here ever throws into the updater loop: a failed fetch, an unreadable
|
|
17
|
+
* cache, or a garbage registry body all degrade to "no update known".
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
|
|
24
|
+
/** The npm dist-tag endpoint that returns `{ "version": "x.y.z", … }`. */
|
|
25
|
+
export const REGISTRY_URL = "https://registry.npmjs.org/tmux-ide/latest";
|
|
26
|
+
|
|
27
|
+
/** Re-check no more than once per this window (24h). */
|
|
28
|
+
export const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
/** The cheap, cache-derived answer every surface reads. */
|
|
31
|
+
export interface UpdateStatus {
|
|
32
|
+
/** The latest published version the last check saw, or null when unknown. */
|
|
33
|
+
latest: string | null;
|
|
34
|
+
/** True when {@link latest} is strictly newer than the running version. */
|
|
35
|
+
updateAvailable: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The on-disk cache shape (`~/.tmux-ide/update-check.json`). */
|
|
39
|
+
export interface UpdateCache {
|
|
40
|
+
/** Epoch ms of the last network check (throttles {@link shouldCheck}). */
|
|
41
|
+
lastCheckedAt: number | null;
|
|
42
|
+
/** The latest version that check saw, or null. */
|
|
43
|
+
latest: string | null;
|
|
44
|
+
/** Versions we've already toasted about (one-time-per-version debounce). */
|
|
45
|
+
notified?: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Pure
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
interface ParsedSemver {
|
|
53
|
+
nums: [number, number, number];
|
|
54
|
+
/** The prerelease tag (`1.0.0-rc.1` → `rc.1`), or "" for a release. */
|
|
55
|
+
pre: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* PURE — split `[v]MAJOR.MINOR.PATCH[-prerelease][+build]` into its numeric core
|
|
60
|
+
* and prerelease tag. Lenient: a leading `v`, build metadata (`+…`), and missing
|
|
61
|
+
* or non-numeric core parts are all tolerated (coerced to 0), so a malformed
|
|
62
|
+
* version compares as `0.0.0` instead of throwing.
|
|
63
|
+
*/
|
|
64
|
+
function parseSemver(version: string): ParsedSemver {
|
|
65
|
+
const core = version.trim().replace(/^v/i, "").split("+")[0] ?? "";
|
|
66
|
+
const dash = core.indexOf("-");
|
|
67
|
+
const main = dash === -1 ? core : core.slice(0, dash);
|
|
68
|
+
const pre = dash === -1 ? "" : core.slice(dash + 1);
|
|
69
|
+
const parts = main.split(".");
|
|
70
|
+
const num = (i: number): number => {
|
|
71
|
+
const n = Number.parseInt(parts[i] ?? "", 10);
|
|
72
|
+
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
73
|
+
};
|
|
74
|
+
return { nums: [num(0), num(1), num(2)], pre };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* PURE — order two semver strings: `-1` if `a < b`, `1` if `a > b`, else `0`.
|
|
79
|
+
*
|
|
80
|
+
* Numeric core (major, minor, patch) compares field-by-field, so `2.10.0` sorts
|
|
81
|
+
* ABOVE `2.6.0` (numeric, not lexical). Prerelease follows semver's rule: a
|
|
82
|
+
* release outranks a prerelease of the same core (`1.0.0` > `1.0.0-rc`); two
|
|
83
|
+
* prereleases fall back to a plain lexical compare of their tags (a simple, good-
|
|
84
|
+
* enough ordering — we never need to rank `rc.2` vs `rc.10` precisely).
|
|
85
|
+
*/
|
|
86
|
+
export function compareSemver(a: string, b: string): -1 | 0 | 1 {
|
|
87
|
+
const pa = parseSemver(a);
|
|
88
|
+
const pb = parseSemver(b);
|
|
89
|
+
for (let i = 0; i < 3; i++) {
|
|
90
|
+
if (pa.nums[i]! !== pb.nums[i]!) return pa.nums[i]! < pb.nums[i]! ? -1 : 1;
|
|
91
|
+
}
|
|
92
|
+
if (pa.pre === pb.pre) return 0;
|
|
93
|
+
if (pa.pre === "") return 1; // release > prerelease of same core
|
|
94
|
+
if (pb.pre === "") return -1;
|
|
95
|
+
return pa.pre < pb.pre ? -1 : 1;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** PURE — is `latest` strictly newer than `current`? */
|
|
99
|
+
export function isNewer(latest: string, current: string): boolean {
|
|
100
|
+
return compareSemver(latest, current) === 1;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* PURE — should we hit the network again? True when we've never checked
|
|
105
|
+
* (`lastCheckedAt` null) or the last check was at least {@link CHECK_INTERVAL_MS}
|
|
106
|
+
* ago. A future `lastCheckedAt` (clock skew) reads as "recent" and skips — the
|
|
107
|
+
* daily tick self-heals once the clock passes it.
|
|
108
|
+
*/
|
|
109
|
+
export function shouldCheck(lastCheckedAt: number | null, nowMs: number): boolean {
|
|
110
|
+
if (lastCheckedAt === null) return true;
|
|
111
|
+
return nowMs - lastCheckedAt >= CHECK_INTERVAL_MS;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* PURE — pull the `version` string out of a raw npm dist-tag response body.
|
|
116
|
+
* Returns null (never throws) for malformed JSON, a non-object, or a
|
|
117
|
+
* missing/empty/non-string `version`.
|
|
118
|
+
*/
|
|
119
|
+
export function parseRegistryResponse(json: string): string | null {
|
|
120
|
+
try {
|
|
121
|
+
const parsed = JSON.parse(json) as unknown;
|
|
122
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
123
|
+
const version = (parsed as { version?: unknown }).version;
|
|
124
|
+
return typeof version === "string" && version.length > 0 ? version : null;
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* PURE — derive the {@link UpdateStatus} for a running version from a (possibly
|
|
132
|
+
* null) cached latest. The single place "is there an update?" is decided, so
|
|
133
|
+
* every surface agrees.
|
|
134
|
+
*/
|
|
135
|
+
export function deriveStatus(latest: string | null, currentVersion: string): UpdateStatus {
|
|
136
|
+
return {
|
|
137
|
+
latest,
|
|
138
|
+
updateAvailable: latest !== null && isNewer(latest, currentVersion),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// io — cache + network (all offline-safe, never throw)
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Absolute path to the cache file: `<home>/update-check.json`, where `<home>` is
|
|
148
|
+
* `TMUX_IDE_HOME` when set (tests / per-run overrides), else `~/.tmux-ide` — the
|
|
149
|
+
* same home resolution the welcome marker uses.
|
|
150
|
+
*/
|
|
151
|
+
export function updateCachePath(): string {
|
|
152
|
+
const home = process.env.TMUX_IDE_HOME ?? join(homedir(), ".tmux-ide");
|
|
153
|
+
return join(home, "update-check.json");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** io — read + parse the cache, or null when absent/unreadable/malformed. */
|
|
157
|
+
export function readUpdateCache(): UpdateCache | null {
|
|
158
|
+
const path = updateCachePath();
|
|
159
|
+
if (!existsSync(path)) return null;
|
|
160
|
+
try {
|
|
161
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown;
|
|
162
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
163
|
+
const obj = parsed as Record<string, unknown>;
|
|
164
|
+
const lastCheckedAt = typeof obj.lastCheckedAt === "number" ? obj.lastCheckedAt : null;
|
|
165
|
+
const latest = typeof obj.latest === "string" && obj.latest.length > 0 ? obj.latest : null;
|
|
166
|
+
const notified = Array.isArray(obj.notified)
|
|
167
|
+
? obj.notified.filter((v): v is string => typeof v === "string")
|
|
168
|
+
: undefined;
|
|
169
|
+
return { lastCheckedAt, latest, ...(notified ? { notified } : {}) };
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** io — write the cache (creating `<home>` if needed). Best-effort, never throws. */
|
|
176
|
+
export function writeUpdateCache(cache: UpdateCache): void {
|
|
177
|
+
const path = updateCachePath();
|
|
178
|
+
try {
|
|
179
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
180
|
+
writeFileSync(path, JSON.stringify(cache));
|
|
181
|
+
} catch {
|
|
182
|
+
// an unwritable cache just means we re-check next tick — never fatal
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* io — ask npm for the latest published version, or null on ANY failure (offline,
|
|
188
|
+
* timeout, non-200, garbage body). Aborts after `timeoutMs` so a slow registry
|
|
189
|
+
* can never stall the updater tick. This is the ONLY network call in the module.
|
|
190
|
+
*/
|
|
191
|
+
export async function fetchLatestVersion(timeoutMs = 3000): Promise<string | null> {
|
|
192
|
+
const controller = new AbortController();
|
|
193
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
194
|
+
try {
|
|
195
|
+
const res = await fetch(REGISTRY_URL, { signal: controller.signal });
|
|
196
|
+
if (!res.ok) return null;
|
|
197
|
+
return parseRegistryResponse(await res.text());
|
|
198
|
+
} catch {
|
|
199
|
+
return null;
|
|
200
|
+
} finally {
|
|
201
|
+
clearTimeout(timer);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The running tmux-ide version, read from the nearest `package.json`. Handles
|
|
207
|
+
* BOTH the bundled CLI (`bin/cli.js`, so the repo-root package.json is one level
|
|
208
|
+
* up) AND the dev source tree (this file at `packages/daemon/src/lib/`, so the
|
|
209
|
+
* root is four levels up). The workspace `packages/daemon/package.json` is
|
|
210
|
+
* deliberately NOT a candidate — it carries a placeholder version. Falls back to
|
|
211
|
+
* `"0.0.0"` when nothing is readable (so an unknown version simply never shows an
|
|
212
|
+
* update rather than crashing).
|
|
213
|
+
*/
|
|
214
|
+
export function getCurrentVersion(): string {
|
|
215
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
216
|
+
const candidates = [
|
|
217
|
+
join(here, "../package.json"), // bundled bin/cli.js → repo root
|
|
218
|
+
join(here, "../../../../package.json"), // dev src/lib → repo root
|
|
219
|
+
];
|
|
220
|
+
for (const candidate of candidates) {
|
|
221
|
+
try {
|
|
222
|
+
const parsed = JSON.parse(readFileSync(candidate, "utf-8")) as { version?: unknown };
|
|
223
|
+
if (typeof parsed.version === "string" && parsed.version.length > 0) return parsed.version;
|
|
224
|
+
} catch {
|
|
225
|
+
// try the next candidate
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return "0.0.0";
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The cheap, network-FREE status read every surface uses: derive
|
|
233
|
+
* {@link UpdateStatus} from the cache against the current version. `now`/
|
|
234
|
+
* `currentVersion` are injectable for tests; the defaults read the live clock +
|
|
235
|
+
* package version.
|
|
236
|
+
*/
|
|
237
|
+
export function getUpdateStatus({
|
|
238
|
+
currentVersion = getCurrentVersion(),
|
|
239
|
+
}: { now?: number; currentVersion?: string } = {}): UpdateStatus {
|
|
240
|
+
const cache = readUpdateCache();
|
|
241
|
+
return deriveStatus(cache?.latest ?? null, currentVersion);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* io — the throttled network refresh (called fire-and-forget from the updater
|
|
246
|
+
* tick). Does nothing if the last check was inside {@link CHECK_INTERVAL_MS};
|
|
247
|
+
* otherwise fetches the latest version and rewrites the cache with a fresh
|
|
248
|
+
* `lastCheckedAt`. On a failed fetch it still stamps `lastCheckedAt` (so we don't
|
|
249
|
+
* hammer a flaky/offline registry every tick) while KEEPING the previously known
|
|
250
|
+
* `latest`. Never throws.
|
|
251
|
+
*/
|
|
252
|
+
export async function runUpdateCheck({ now = Date.now() }: { now?: number } = {}): Promise<void> {
|
|
253
|
+
const cache = readUpdateCache();
|
|
254
|
+
if (!shouldCheck(cache?.lastCheckedAt ?? null, now)) return;
|
|
255
|
+
const fetched = await fetchLatestVersion();
|
|
256
|
+
writeUpdateCache({
|
|
257
|
+
lastCheckedAt: now,
|
|
258
|
+
latest: fetched ?? cache?.latest ?? null,
|
|
259
|
+
...(cache?.notified ? { notified: cache.notified } : {}),
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* The tick's entry point: return the cheap cache-derived {@link UpdateStatus} NOW,
|
|
265
|
+
* and (when `enabled`) kick off the throttled network refresh in the background so
|
|
266
|
+
* the NEXT tick sees a fresh answer. Disabled (`updates.check: false`) → always
|
|
267
|
+
* "no update", no network. The background refresh is deliberately un-awaited: the
|
|
268
|
+
* tick must never block on the registry.
|
|
269
|
+
*/
|
|
270
|
+
export function maybeCheckForUpdate({
|
|
271
|
+
enabled,
|
|
272
|
+
now = Date.now(),
|
|
273
|
+
currentVersion = getCurrentVersion(),
|
|
274
|
+
}: {
|
|
275
|
+
enabled: boolean;
|
|
276
|
+
now?: number;
|
|
277
|
+
currentVersion?: string;
|
|
278
|
+
}): UpdateStatus {
|
|
279
|
+
if (!enabled) return { latest: null, updateAvailable: false };
|
|
280
|
+
const status = getUpdateStatus({ now, currentVersion });
|
|
281
|
+
void runUpdateCheck({ now }).catch(() => {});
|
|
282
|
+
return status;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* io — record that we've toasted about `version` and report whether this is the
|
|
287
|
+
* FIRST time (so the caller should actually toast). Persisted in the cache file's
|
|
288
|
+
* `notified` list so the one-time guarantee survives updater restarts — the old
|
|
289
|
+
* in-memory notify debounce would re-fire on every respawn. Best-effort: if the
|
|
290
|
+
* cache can't be written we still return the in-memory verdict.
|
|
291
|
+
*/
|
|
292
|
+
export function markUpdateNotified(version: string): boolean {
|
|
293
|
+
const cache = readUpdateCache() ?? { lastCheckedAt: null, latest: null };
|
|
294
|
+
const notified = cache.notified ?? [];
|
|
295
|
+
if (notified.includes(version)) return false;
|
|
296
|
+
writeUpdateCache({ ...cache, notified: [...notified, version] });
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tmux-ide update [--dry-run]` — act on the update the dock surfaces.
|
|
3
|
+
*
|
|
4
|
+
* The dock's `⬆ v<latest>` chip (see {@link ./update-check.ts}) tells you an
|
|
5
|
+
* update is out; this closes the loop with the ONE command that installs it. The
|
|
6
|
+
* catch is that "the command" depends on how tmux-ide was installed — a cloned
|
|
7
|
+
* dev checkout updates with `git pull`, a global install with its package
|
|
8
|
+
* manager (npm/pnpm/bun) — so the command DETECTS the install method from the
|
|
9
|
+
* running CLI's real path before printing (or, without `--dry-run`, running) it.
|
|
10
|
+
*
|
|
11
|
+
* The detection + rendering are PURE ({@link detectPackageManager},
|
|
12
|
+
* {@link planUpdate}, {@link renderPlan}), so they're unit-tested without a live
|
|
13
|
+
* filesystem; {@link findGitCheckoutRoot} and {@link runUpdate} are the io — the
|
|
14
|
+
* latter is the only place a real global install is ever spawned, and only when
|
|
15
|
+
* `--dry-run` is absent.
|
|
16
|
+
*/
|
|
17
|
+
import { execSync } from "node:child_process";
|
|
18
|
+
import { existsSync } from "node:fs";
|
|
19
|
+
import { dirname, join } from "node:path";
|
|
20
|
+
import { getCurrentVersion, getUpdateStatus, isNewer } from "./update-check.ts";
|
|
21
|
+
|
|
22
|
+
/** The package managers a global install can come from. */
|
|
23
|
+
export type PackageManager = "npm" | "pnpm" | "bun";
|
|
24
|
+
|
|
25
|
+
/** The resolved way to update this install. */
|
|
26
|
+
export interface UpdatePlan {
|
|
27
|
+
/** `dev` = a git checkout (`git pull`); otherwise the global package manager. */
|
|
28
|
+
method: "dev" | PackageManager;
|
|
29
|
+
/** The shell command to run, or null for a dev checkout (manual `git pull`). */
|
|
30
|
+
command: string | null;
|
|
31
|
+
/** A short human note on WHY this method was chosen (the detection evidence). */
|
|
32
|
+
reason: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The exact global-install command per package manager. */
|
|
36
|
+
export const UPDATE_COMMANDS: Record<PackageManager, string> = {
|
|
37
|
+
npm: "npm install -g tmux-ide@latest",
|
|
38
|
+
pnpm: "pnpm add -g tmux-ide@latest",
|
|
39
|
+
bun: "bun add -g tmux-ide@latest",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Pure
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* PURE — infer the package manager from a global CLI path. The global bin lives
|
|
48
|
+
* under a manager-specific directory, so the path is the tell: a `bun`/`.bun`
|
|
49
|
+
* segment → bun, a `pnpm` segment → pnpm, else npm (the default global installer,
|
|
50
|
+
* incl. nvm's `.../node/vX/lib/node_modules/...`). Checked bun→pnpm→npm so the
|
|
51
|
+
* more specific segments win.
|
|
52
|
+
*/
|
|
53
|
+
export function detectPackageManager(cliPath: string): PackageManager {
|
|
54
|
+
const p = cliPath.toLowerCase();
|
|
55
|
+
if (/(^|\/)\.?bun(\/|$)/.test(p)) return "bun";
|
|
56
|
+
if (p.includes("pnpm")) return "pnpm";
|
|
57
|
+
return "npm";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* PURE — the {@link UpdatePlan} for a CLI at `cliPath`. When `gitRoot` is set (a
|
|
62
|
+
* `.git` was found at/above the CLI — a cloned checkout) the plan is a manual
|
|
63
|
+
* `git pull`; otherwise it's the detected package manager's global-install
|
|
64
|
+
* command.
|
|
65
|
+
*/
|
|
66
|
+
export function planUpdate(input: { cliPath: string; gitRoot: string | null }): UpdatePlan {
|
|
67
|
+
if (input.gitRoot) {
|
|
68
|
+
return { method: "dev", command: null, reason: `git checkout at ${input.gitRoot}` };
|
|
69
|
+
}
|
|
70
|
+
const pm = detectPackageManager(input.cliPath);
|
|
71
|
+
return {
|
|
72
|
+
method: pm,
|
|
73
|
+
command: UPDATE_COMMANDS[pm],
|
|
74
|
+
reason: `global ${pm} install (${input.cliPath})`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* PURE — the human-readable block the CLI prints for a plan. Leads with the
|
|
80
|
+
* version delta (or "up to date"/"unknown"), then the command to run — for a dev
|
|
81
|
+
* checkout that's the `git pull` hint, for a global install the exact package-
|
|
82
|
+
* manager line. `dryRun` only changes the framing ("would run" vs "run"); the
|
|
83
|
+
* command shown is identical either way. Ends with the re-adopt note: the running
|
|
84
|
+
* updater keeps the OLD code until `_tmux-ide-chrome` is killed and a session
|
|
85
|
+
* re-adopted, so a fresh dock reflects the new version.
|
|
86
|
+
*/
|
|
87
|
+
export function renderPlan(
|
|
88
|
+
plan: UpdatePlan,
|
|
89
|
+
{ current, latest, dryRun }: { current: string; latest: string | null; dryRun: boolean },
|
|
90
|
+
): string {
|
|
91
|
+
const lines: string[] = [];
|
|
92
|
+
// isNewer, not inequality — the registry can lag the checkout (a dev build
|
|
93
|
+
// is AHEAD of the published version), and that must not read as an update.
|
|
94
|
+
if (latest && isNewer(latest, current)) {
|
|
95
|
+
lines.push(`tmux-ide v${current} → v${latest} available`);
|
|
96
|
+
} else if (latest) {
|
|
97
|
+
lines.push(`tmux-ide v${current} is up to date (registry: v${latest})`);
|
|
98
|
+
} else {
|
|
99
|
+
lines.push(`tmux-ide v${current} (latest version unknown — run \`tmux-ide doctor\`)`);
|
|
100
|
+
}
|
|
101
|
+
lines.push("");
|
|
102
|
+
if (plan.method === "dev") {
|
|
103
|
+
lines.push("Detected a cloned checkout — update with git:");
|
|
104
|
+
lines.push(" git pull");
|
|
105
|
+
lines.push(` (${plan.reason})`);
|
|
106
|
+
} else {
|
|
107
|
+
const verb = dryRun ? "Would run" : "Running";
|
|
108
|
+
lines.push(`Detected a global ${plan.method} install — ${verb}:`);
|
|
109
|
+
lines.push(` ${plan.command}`);
|
|
110
|
+
}
|
|
111
|
+
lines.push("");
|
|
112
|
+
lines.push("After updating, refresh the dock so it runs the new code:");
|
|
113
|
+
lines.push(" tmux kill-session -t _tmux-ide-chrome # stop the old updater");
|
|
114
|
+
lines.push(" tmux-ide adopt <session> # re-adopt to relaunch it");
|
|
115
|
+
return lines.join("\n");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// io
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* io — walk up from `startDir` looking for a `.git` entry; return the first
|
|
124
|
+
* directory that has one (a cloned checkout root) or null at the filesystem root.
|
|
125
|
+
* A global install under `node_modules` has no `.git` above it, so this cleanly
|
|
126
|
+
* separates "dev checkout" from "installed package".
|
|
127
|
+
*/
|
|
128
|
+
export function findGitCheckoutRoot(startDir: string): string | null {
|
|
129
|
+
let dir = startDir;
|
|
130
|
+
for (;;) {
|
|
131
|
+
if (existsSync(join(dir, ".git"))) return dir;
|
|
132
|
+
const parent = dirname(dir);
|
|
133
|
+
if (parent === dir) return null;
|
|
134
|
+
dir = parent;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* io — run (or, with `dryRun`, just print) the update.
|
|
140
|
+
*
|
|
141
|
+
* `cliDir` is the directory of the running CLI (`bin/`), the anchor for both the
|
|
142
|
+
* git-checkout probe and the package-manager path heuristic. The plan is printed
|
|
143
|
+
* either way; only a non-dry-run global install actually spawns the installer
|
|
144
|
+
* (`execSync`, output inherited). A dev checkout NEVER auto-runs `git pull` — it
|
|
145
|
+
* only prints the hint. Returns the plan so the CLI/tests can assert on it.
|
|
146
|
+
*/
|
|
147
|
+
export function runUpdate({ cliDir, dryRun }: { cliDir: string; dryRun: boolean }): UpdatePlan {
|
|
148
|
+
const current = getCurrentVersion();
|
|
149
|
+
const { latest } = getUpdateStatus({ currentVersion: current });
|
|
150
|
+
const gitRoot = findGitCheckoutRoot(cliDir);
|
|
151
|
+
const plan = planUpdate({ cliPath: cliDir, gitRoot });
|
|
152
|
+
console.log(renderPlan(plan, { current, latest, dryRun }));
|
|
153
|
+
if (!dryRun && plan.command) {
|
|
154
|
+
console.log("");
|
|
155
|
+
execSync(plan.command, { stdio: "inherit" });
|
|
156
|
+
}
|
|
157
|
+
return plan;
|
|
158
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace registry — runtime list of projects the daemon is serving.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the legacy single-session TMUX_IDE_SESSION env coupling so a
|
|
5
|
+
* single daemon can host N concurrent projects (goal 12, T065).
|
|
6
|
+
*
|
|
7
|
+
* Persisted at ~/.tmux-ide/workspaces.json via atomic temp+rename. On
|
|
8
|
+
* `load()` the registry reconciles itself against `tmux list-sessions`
|
|
9
|
+
* output and drops entries whose session no longer exists.
|
|
10
|
+
*
|
|
11
|
+
* Distinct from project-registry.ts — that one is a long-lived
|
|
12
|
+
* "bookmark list" of projects the user knows about (probed metadata).
|
|
13
|
+
* This one tracks live workspaces and is reconciled with tmux on boot.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { EventEmitter } from "node:events";
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { dirname, join } from "node:path";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { WorkspaceSchemaZ, type Workspace } from "@tmux-ide/contracts";
|
|
22
|
+
|
|
23
|
+
const REGISTRY_DIR_ENV = "TMUX_IDE_REGISTRY_DIR";
|
|
24
|
+
|
|
25
|
+
const RegistryFileSchemaZ = z.object({
|
|
26
|
+
version: z.literal(1),
|
|
27
|
+
workspaces: z.array(WorkspaceSchemaZ),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
type RegistryFile = z.infer<typeof RegistryFileSchemaZ>;
|
|
31
|
+
|
|
32
|
+
export type ListSessionsFn = () => readonly string[];
|
|
33
|
+
|
|
34
|
+
export interface WorkspaceRegistryOptions {
|
|
35
|
+
/** Override registry dir for tests. Defaults to ~/.tmux-ide. */
|
|
36
|
+
dir?: string;
|
|
37
|
+
/** Inject a tmux list-sessions implementation (defaults to bridge). */
|
|
38
|
+
listSessions?: ListSessionsFn;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface AddWorkspaceInput {
|
|
42
|
+
name: string;
|
|
43
|
+
sessionName?: string;
|
|
44
|
+
projectDir: string;
|
|
45
|
+
ideConfigPath?: string | null;
|
|
46
|
+
/** Override Date.now() for deterministic tests. */
|
|
47
|
+
now?: () => Date;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class WorkspaceAlreadyExistsError extends Error {
|
|
51
|
+
readonly code = "ALREADY_EXISTS";
|
|
52
|
+
constructor(name: string) {
|
|
53
|
+
super(`Workspace "${name}" already exists`);
|
|
54
|
+
this.name = "WorkspaceAlreadyExistsError";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class WorkspaceNotFoundError extends Error {
|
|
59
|
+
readonly code = "NOT_FOUND";
|
|
60
|
+
constructor(name: string) {
|
|
61
|
+
super(`Workspace "${name}" not found`);
|
|
62
|
+
this.name = "WorkspaceNotFoundError";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Thin wrapper around the shared module emitter so consumers can subscribe
|
|
68
|
+
* with typed events without depending on EventEmitter directly.
|
|
69
|
+
*/
|
|
70
|
+
export type WorkspaceEvent =
|
|
71
|
+
| { type: "workspace.added"; workspace: Workspace }
|
|
72
|
+
| { type: "workspace.removed"; name: string };
|
|
73
|
+
|
|
74
|
+
export class WorkspaceRegistry {
|
|
75
|
+
private readonly dir: string;
|
|
76
|
+
private readonly listSessions: ListSessionsFn;
|
|
77
|
+
private readonly emitter = new EventEmitter();
|
|
78
|
+
private workspaces: Workspace[] = [];
|
|
79
|
+
private loaded = false;
|
|
80
|
+
|
|
81
|
+
constructor(options: WorkspaceRegistryOptions = {}) {
|
|
82
|
+
this.dir = options.dir ?? process.env[REGISTRY_DIR_ENV] ?? join(homedir(), ".tmux-ide");
|
|
83
|
+
this.listSessions = options.listSessions ?? defaultListSessions;
|
|
84
|
+
this.emitter.setMaxListeners(0);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Load workspaces from disk and reconcile against live tmux sessions.
|
|
89
|
+
* Drops entries whose tmux session is gone (silently — they were
|
|
90
|
+
* persisted by a prior daemon invocation that may have crashed).
|
|
91
|
+
*
|
|
92
|
+
* Safe to call repeatedly; subsequent calls re-reconcile.
|
|
93
|
+
*/
|
|
94
|
+
async load(): Promise<void> {
|
|
95
|
+
const fromDisk = this.readDisk();
|
|
96
|
+
let live: Set<string>;
|
|
97
|
+
try {
|
|
98
|
+
live = new Set(this.listSessions());
|
|
99
|
+
} catch {
|
|
100
|
+
// tmux is unavailable — keep all entries; reconcile when we can.
|
|
101
|
+
live = new Set(fromDisk.map((w) => w.sessionName));
|
|
102
|
+
}
|
|
103
|
+
const reconciled = fromDisk.filter((w) => live.has(w.sessionName));
|
|
104
|
+
this.workspaces = reconciled;
|
|
105
|
+
this.loaded = true;
|
|
106
|
+
if (reconciled.length !== fromDisk.length) {
|
|
107
|
+
this.writeDisk();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
list(): Workspace[] {
|
|
112
|
+
return [...this.workspaces];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
get(name: string): Workspace | null {
|
|
116
|
+
return this.workspaces.find((w) => w.name === name) ?? null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
has(name: string): boolean {
|
|
120
|
+
return this.workspaces.some((w) => w.name === name);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
add(input: AddWorkspaceInput): Workspace {
|
|
124
|
+
if (this.has(input.name)) {
|
|
125
|
+
throw new WorkspaceAlreadyExistsError(input.name);
|
|
126
|
+
}
|
|
127
|
+
const now = (input.now ?? (() => new Date()))();
|
|
128
|
+
const workspace: Workspace = {
|
|
129
|
+
name: input.name,
|
|
130
|
+
sessionName: input.sessionName ?? input.name,
|
|
131
|
+
projectDir: input.projectDir,
|
|
132
|
+
ideConfigPath: input.ideConfigPath ?? null,
|
|
133
|
+
addedAt: now.toISOString(),
|
|
134
|
+
};
|
|
135
|
+
this.workspaces = [...this.workspaces, workspace];
|
|
136
|
+
this.writeDisk();
|
|
137
|
+
this.emitter.emit("workspace.added", workspace);
|
|
138
|
+
return workspace;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
remove(name: string): void {
|
|
142
|
+
if (!this.has(name)) {
|
|
143
|
+
throw new WorkspaceNotFoundError(name);
|
|
144
|
+
}
|
|
145
|
+
this.workspaces = this.workspaces.filter((w) => w.name !== name);
|
|
146
|
+
this.writeDisk();
|
|
147
|
+
this.emitter.emit("workspace.removed", name);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Subscribe to workspace.added | workspace.removed events. */
|
|
151
|
+
on<E extends WorkspaceEvent["type"]>(
|
|
152
|
+
event: E,
|
|
153
|
+
handler: (
|
|
154
|
+
payload: Extract<WorkspaceEvent, { type: E }> extends { workspace: Workspace }
|
|
155
|
+
? Workspace
|
|
156
|
+
: string,
|
|
157
|
+
) => void,
|
|
158
|
+
): () => void {
|
|
159
|
+
this.emitter.on(event, handler as (...args: unknown[]) => void);
|
|
160
|
+
return () => this.emitter.off(event, handler as (...args: unknown[]) => void);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ----------------- io -----------------
|
|
164
|
+
|
|
165
|
+
private filePath(): string {
|
|
166
|
+
return join(this.dir, "workspaces.json");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private readDisk(): Workspace[] {
|
|
170
|
+
const path = this.filePath();
|
|
171
|
+
if (!existsSync(path)) return [];
|
|
172
|
+
let parsed: unknown;
|
|
173
|
+
try {
|
|
174
|
+
parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
175
|
+
} catch {
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
const result = RegistryFileSchemaZ.safeParse(parsed);
|
|
179
|
+
if (!result.success) return [];
|
|
180
|
+
return result.data.workspaces;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private writeDisk(): void {
|
|
184
|
+
const path = this.filePath();
|
|
185
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
186
|
+
const file: RegistryFile = { version: 1, workspaces: this.workspaces };
|
|
187
|
+
const tmp = `${path}.tmp`;
|
|
188
|
+
writeFileSync(tmp, JSON.stringify(file, null, 2) + "\n");
|
|
189
|
+
renameSync(tmp, path);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** @internal Test-only: assert the registry is loaded. */
|
|
193
|
+
_isLoaded(): boolean {
|
|
194
|
+
return this.loaded;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// Default singleton — the daemon process holds one registry; HTTP handlers
|
|
200
|
+
// import it via getDefaultWorkspaceRegistry().
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
let _default: WorkspaceRegistry | null = null;
|
|
204
|
+
|
|
205
|
+
export function getDefaultWorkspaceRegistry(): WorkspaceRegistry {
|
|
206
|
+
if (!_default) _default = new WorkspaceRegistry();
|
|
207
|
+
return _default;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** @internal Test hook: replace the singleton. */
|
|
211
|
+
export function _setDefaultWorkspaceRegistryForTests(registry: WorkspaceRegistry | null): void {
|
|
212
|
+
_default = registry;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function defaultListSessions(): string[] {
|
|
216
|
+
// Lazy import keeps tmux-bridge optional for test environments that
|
|
217
|
+
// don't have tmux available; tests inject a stub instead.
|
|
218
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
219
|
+
const { execFileSync } = require("node:child_process") as typeof import("node:child_process");
|
|
220
|
+
try {
|
|
221
|
+
const raw = execFileSync("tmux", ["list-sessions", "-F", "#{session_name}"], {
|
|
222
|
+
encoding: "utf-8",
|
|
223
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
224
|
+
}) as string;
|
|
225
|
+
return raw.split("\n").filter(Boolean);
|
|
226
|
+
} catch {
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
}
|