tmux-ide 1.0.0 → 1.2.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/src/status.js CHANGED
@@ -1,36 +1,18 @@
1
1
  import { resolve } from "node:path";
2
2
  import { existsSync } from "node:fs";
3
- import { execSync } from "node:child_process";
4
3
  import { getSessionName } from "./lib/yaml-io.js";
4
+ import { getSessionState, listPanes } from "./lib/tmux.js";
5
5
 
6
6
  export async function status(targetDir, { json } = {}) {
7
7
  const dir = resolve(targetDir ?? ".");
8
- const session = getSessionName(dir);
8
+ const { name: session } = getSessionName(dir);
9
9
  const configExists = existsSync(resolve(dir, "ide.yml"));
10
10
 
11
- let running = false;
11
+ const state = getSessionState(session);
12
+ const running = state.running;
12
13
  let panes = [];
13
14
 
14
- try {
15
- execSync(`tmux has-session -t "${session}"`, { stdio: "ignore" });
16
- running = true;
17
-
18
- const raw = execSync(
19
- `tmux list-panes -t "${session}" -F "#{pane_index}|#{pane_title}|#{pane_width}|#{pane_height}|#{pane_active}"`,
20
- { encoding: "utf-8" }
21
- ).trim();
22
-
23
- panes = raw.split("\n").map((line) => {
24
- const [index, title, width, height, active] = line.split("|");
25
- return {
26
- index: parseInt(index),
27
- title,
28
- width: parseInt(width),
29
- height: parseInt(height),
30
- active: active === "1",
31
- };
32
- });
33
- } catch {}
15
+ if (running) panes = listPanes(session);
34
16
 
35
17
  const data = { session, running, configExists, panes };
36
18
 
package/src/stop.js CHANGED
@@ -1,23 +1,25 @@
1
1
  import { resolve } from "node:path";
2
- import { execSync } from "node:child_process";
3
2
  import { getSessionName } from "./lib/yaml-io.js";
3
+ import { outputError } from "./lib/output.js";
4
+ import { killSession, stopSessionMonitor } from "./lib/tmux.js";
4
5
 
5
6
  export async function stop(targetDir, { json } = {}) {
6
7
  const dir = resolve(targetDir ?? ".");
7
- const session = getSessionName(dir);
8
+ const { name: session } = getSessionName(dir);
8
9
 
9
- try {
10
- execSync(`tmux kill-session -t "${session}"`, { stdio: "inherit" });
10
+ // Stop the session monitor before killing the session
11
+ stopSessionMonitor(session);
12
+
13
+ const result = killSession(session);
14
+
15
+ if (result.stopped) {
11
16
  if (json) {
12
17
  console.log(JSON.stringify({ stopped: session }));
13
18
  } else {
14
19
  console.log(`Stopped session "${session}"`);
15
20
  }
16
- } catch {
17
- if (json) {
18
- console.error(JSON.stringify({ error: `No active session "${session}" found`, code: "NOT_RUNNING" }));
19
- } else {
20
- console.error(`No active session "${session}" found`);
21
- }
21
+ return;
22
22
  }
23
+
24
+ outputError(`No active session "${session}" found`, "NOT_RUNNING");
23
25
  }
package/src/validate.js CHANGED
@@ -94,22 +94,6 @@ export function validateConfig(config) {
94
94
  }
95
95
  }
96
96
 
97
- // Cross-validate team roles
98
- if (config.team && Array.isArray(config.rows)) {
99
- let leadCount = 0;
100
- for (const row of config.rows) {
101
- if (!Array.isArray(row.panes)) continue;
102
- for (const pane of row.panes) {
103
- if (pane.role === "lead") leadCount++;
104
- }
105
- }
106
- if (leadCount === 0) {
107
- errors.push("team config requires exactly one pane with role: lead");
108
- } else if (leadCount > 1) {
109
- errors.push("team config allows only one pane with role: lead");
110
- }
111
- }
112
-
113
97
  if (config.theme !== undefined) {
114
98
  if (config.theme == null || typeof config.theme !== "object" || Array.isArray(config.theme)) {
115
99
  errors.push("'theme' must be an object");
@@ -146,7 +130,7 @@ export async function validate(targetDir, { json } = {}) {
146
130
  try {
147
131
  ({ config } = readConfig(dir));
148
132
  } catch (e) {
149
- outputError(`Cannot read ide.yml: ${e.message}`, "READ_ERROR", { json });
133
+ outputError(`Cannot read ide.yml: ${e.message}`, "READ_ERROR");
150
134
  return;
151
135
  }
152
136
 
@@ -1,115 +0,0 @@
1
- import { describe, it } from "node:test";
2
- import assert from "node:assert/strict";
3
- import { suggestConfig } from "./detect.js";
4
-
5
- describe("suggestConfig", () => {
6
- it("creates Next.js config with pnpm", () => {
7
- const config = suggestConfig("/project", {
8
- packageManager: "pnpm",
9
- frameworks: ["next"],
10
- devCommand: "pnpm dev",
11
- language: "javascript",
12
- });
13
- assert.strictEqual(config.rows[0].size, "70%");
14
- assert.strictEqual(config.rows[0].panes.length, 2);
15
- const bottom = config.rows[1].panes;
16
- assert.strictEqual(bottom[0].title, "Next.js");
17
- assert.strictEqual(bottom[0].command, "pnpm dev");
18
- assert.strictEqual(bottom[bottom.length - 1].title, "Shell");
19
- });
20
-
21
- it("creates Next.js + Convex config with 3 Claude panes", () => {
22
- const config = suggestConfig("/project", {
23
- packageManager: "pnpm",
24
- frameworks: ["next", "convex"],
25
- devCommand: "pnpm dev",
26
- language: "javascript",
27
- });
28
- assert.strictEqual(config.rows[0].panes.length, 3);
29
- const bottom = config.rows[1].panes;
30
- const titles = bottom.map((p) => p.title);
31
- assert.ok(titles.includes("Next.js"));
32
- assert.ok(titles.includes("Convex"));
33
- assert.ok(titles.includes("Shell"));
34
- });
35
-
36
- it("creates Go config", () => {
37
- const config = suggestConfig("/project", {
38
- packageManager: null,
39
- frameworks: ["go"],
40
- devCommand: null,
41
- language: "go",
42
- });
43
- const bottom = config.rows[1].panes;
44
- assert.strictEqual(bottom[0].title, "Go");
45
- assert.strictEqual(bottom[0].command, "go run .");
46
- });
47
-
48
- it("creates Cargo config", () => {
49
- const config = suggestConfig("/project", {
50
- packageManager: null,
51
- frameworks: ["cargo"],
52
- devCommand: null,
53
- language: "rust",
54
- });
55
- const bottom = config.rows[1].panes;
56
- assert.strictEqual(bottom[0].title, "Cargo");
57
- assert.strictEqual(bottom[0].command, "cargo watch -x run");
58
- });
59
-
60
- it("falls back to dev command when no framework detected", () => {
61
- const config = suggestConfig("/project", {
62
- packageManager: "npm",
63
- frameworks: [],
64
- devCommand: "npm run dev",
65
- language: "javascript",
66
- });
67
- const bottom = config.rows[1].panes;
68
- assert.strictEqual(bottom[0].title, "Dev Server");
69
- assert.strictEqual(bottom[0].command, "npm run dev");
70
- });
71
-
72
- it("creates minimal config with just shell when nothing detected", () => {
73
- const config = suggestConfig("/project", {
74
- packageManager: null,
75
- frameworks: [],
76
- devCommand: null,
77
- language: null,
78
- });
79
- const bottom = config.rows[1].panes;
80
- assert.strictEqual(bottom.length, 1);
81
- assert.strictEqual(bottom[0].title, "Shell");
82
- });
83
-
84
- it("uses npm run for npm package manager", () => {
85
- const config = suggestConfig("/project", {
86
- packageManager: "npm",
87
- frameworks: ["next"],
88
- devCommand: "npm run dev",
89
- language: "javascript",
90
- });
91
- const bottom = config.rows[1].panes;
92
- assert.strictEqual(bottom[0].command, "npm run dev");
93
- });
94
-
95
- it("uses bun for bun package manager", () => {
96
- const config = suggestConfig("/project", {
97
- packageManager: "bun",
98
- frameworks: ["vite"],
99
- devCommand: "bun dev",
100
- language: "javascript",
101
- });
102
- const bottom = config.rows[1].panes;
103
- assert.strictEqual(bottom[0].command, "bun dev");
104
- });
105
-
106
- it("uses project directory basename as session name", () => {
107
- const config = suggestConfig("/home/user/my-app", {
108
- packageManager: null,
109
- frameworks: [],
110
- devCommand: null,
111
- language: null,
112
- });
113
- assert.strictEqual(config.name, "my-app");
114
- });
115
- });
@@ -1,94 +0,0 @@
1
- import { describe, it, before, after } from "node:test";
2
- import assert from "node:assert/strict";
3
- import { execSync, execFileSync } from "node:child_process";
4
- import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
5
- import { join } from "node:path";
6
- import { tmpdir } from "node:os";
7
-
8
- // Skip entire suite if tmux is not available
9
- let tmuxAvailable = false;
10
- try {
11
- execSync("tmux -V", { stdio: "ignore" });
12
- tmuxAvailable = true;
13
- } catch {}
14
-
15
- describe("integration", { skip: !tmuxAvailable && "tmux not available" }, () => {
16
- let tmpDir;
17
- const session = "tmux-ide-test-integration";
18
- const cli = join(import.meta.dirname, "..", "bin", "cli.js");
19
-
20
- function run(args) {
21
- return execFileSync("node", [cli, ...args], { cwd: tmpDir, encoding: "utf-8" });
22
- }
23
-
24
- function runJSON(args) {
25
- return JSON.parse(run([...args, "--json"]));
26
- }
27
-
28
- function killSession() {
29
- try {
30
- execSync(`tmux kill-session -t "${session}"`, { stdio: "ignore" });
31
- } catch {}
32
- }
33
-
34
- function createSession() {
35
- execSync(`tmux new-session -d -s "${session}" -x 80 -y 24`, { stdio: "ignore" });
36
- }
37
-
38
- before(() => {
39
- tmpDir = mkdtempSync(join(tmpdir(), "tmux-ide-test-"));
40
- writeFileSync(
41
- join(tmpDir, "ide.yml"),
42
- `name: ${session}\nrows:\n - panes:\n - title: Shell\n`
43
- );
44
- });
45
-
46
- after(() => {
47
- killSession();
48
- rmSync(tmpDir, { recursive: true, force: true });
49
- });
50
-
51
- it("status --json reports not running when no session exists", () => {
52
- killSession();
53
- const result = runJSON(["status"]);
54
- assert.strictEqual(result.running, false);
55
- });
56
-
57
- it("status --json reports running after session is created", () => {
58
- killSession();
59
- createSession();
60
- const result = runJSON(["status"]);
61
- assert.strictEqual(result.running, true);
62
- killSession();
63
- });
64
-
65
- it("stop --json kills a running session", () => {
66
- createSession();
67
- run(["stop"]);
68
- // Verify it's gone
69
- const result = runJSON(["status"]);
70
- assert.strictEqual(result.running, false);
71
- });
72
-
73
- it("validate --json reports valid for our test config", () => {
74
- const result = runJSON(["validate"]);
75
- assert.strictEqual(result.valid, true);
76
- assert.deepStrictEqual(result.errors, []);
77
- });
78
-
79
- it("doctor --json passes checks", () => {
80
- const result = runJSON(["doctor"]);
81
- assert.strictEqual(result.ok, true);
82
- });
83
-
84
- it("config --json dumps config", () => {
85
- const result = runJSON(["config"]);
86
- assert.strictEqual(result.name, session);
87
- assert.ok(Array.isArray(result.rows));
88
- });
89
-
90
- it("ls --json returns sessions list", () => {
91
- const result = runJSON(["ls"]);
92
- assert.ok(Array.isArray(result.sessions));
93
- });
94
- });
@@ -1,67 +0,0 @@
1
- import { describe, it } from "node:test";
2
- import assert from "node:assert/strict";
3
- import { getByPath, setByPath } from "./dot-path.js";
4
-
5
- describe("getByPath", () => {
6
- it("gets a top-level key", () => {
7
- assert.strictEqual(getByPath({ name: "test" }, "name"), "test");
8
- });
9
-
10
- it("gets a nested value", () => {
11
- const obj = { rows: [{ panes: [{ title: "Shell" }] }] };
12
- assert.strictEqual(getByPath(obj, "rows.0.panes.0.title"), "Shell");
13
- });
14
-
15
- it("returns undefined for non-existent path", () => {
16
- assert.strictEqual(getByPath({}, "a.b.c"), undefined);
17
- });
18
-
19
- it("returns undefined for partially valid path", () => {
20
- assert.strictEqual(getByPath({ a: { b: 1 } }, "a.b.c"), undefined);
21
- });
22
- });
23
-
24
- describe("setByPath", () => {
25
- it("sets a top-level key", () => {
26
- const obj = {};
27
- setByPath(obj, "name", "test");
28
- assert.deepStrictEqual(obj, { name: "test" });
29
- });
30
-
31
- it("sets a nested value", () => {
32
- const obj = { rows: [{ panes: [{ title: "old" }] }] };
33
- setByPath(obj, "rows.0.panes.0.title", "new");
34
- assert.strictEqual(obj.rows[0].panes[0].title, "new");
35
- });
36
-
37
- it("creates arrays for numeric keys", () => {
38
- const obj = {};
39
- setByPath(obj, "rows.0.title", "test");
40
- assert.ok(Array.isArray(obj.rows));
41
- assert.strictEqual(obj.rows[0].title, "test");
42
- });
43
-
44
- it("creates objects for string keys", () => {
45
- const obj = {};
46
- setByPath(obj, "a.b.c", "val");
47
- assert.deepStrictEqual(obj, { a: { b: { c: "val" } } });
48
- });
49
-
50
- it("handles duplicate segment values (regression)", () => {
51
- // Bug: rows.0.panes.0 — both "0" segments caused indexOf to return
52
- // the same index, breaking the lookahead for array vs object creation
53
- const obj = {};
54
- setByPath(obj, "rows.0.panes.0", "value");
55
- assert.ok(Array.isArray(obj.rows), "rows should be an array");
56
- assert.ok(Array.isArray(obj.rows[0].panes), "panes should be an array");
57
- assert.strictEqual(obj.rows[0].panes[0], "value");
58
- });
59
-
60
- it("handles deeper duplicate segments", () => {
61
- const obj = {};
62
- setByPath(obj, "a.0.b.0.c", "deep");
63
- assert.ok(Array.isArray(obj.a));
64
- assert.ok(Array.isArray(obj.a[0].b));
65
- assert.strictEqual(obj.a[0].b[0].c, "deep");
66
- });
67
- });
@@ -1,74 +0,0 @@
1
- import { describe, it } from "node:test";
2
- import assert from "node:assert/strict";
3
- import { computeSizes, toSplitPercents } from "./sizes.js";
4
-
5
- describe("computeSizes", () => {
6
- it("distributes remaining space to items without size", () => {
7
- const result = computeSizes([{ size: "70%" }, {}]);
8
- assert.deepStrictEqual(result, [70, 30]);
9
- });
10
-
11
- it("passes through all explicit sizes", () => {
12
- const result = computeSizes([{ size: "60%" }, { size: "40%" }]);
13
- assert.deepStrictEqual(result, [60, 40]);
14
- });
15
-
16
- it("splits equally when no sizes specified", () => {
17
- const result = computeSizes([{}, {}, {}]);
18
- const expected = [100 / 3, 100 / 3, 100 / 3];
19
- assert.deepStrictEqual(result, expected);
20
- });
21
-
22
- it("handles 3 items with mixed sizes", () => {
23
- const result = computeSizes([{ size: "60%" }, { size: "25%" }, {}]);
24
- assert.deepStrictEqual(result, [60, 25, 15]);
25
- });
26
-
27
- it("handles single item without size", () => {
28
- const result = computeSizes([{}]);
29
- assert.deepStrictEqual(result, [100]);
30
- });
31
-
32
- it("handles single item with size", () => {
33
- const result = computeSizes([{ size: "50%" }]);
34
- assert.deepStrictEqual(result, [50]);
35
- });
36
-
37
- it("handles empty array", () => {
38
- const result = computeSizes([]);
39
- assert.deepStrictEqual(result, []);
40
- });
41
-
42
- it("clamps remaining to zero when sizes exceed 100", () => {
43
- const result = computeSizes([{ size: "80%" }, { size: "30%" }, {}]);
44
- assert.deepStrictEqual(result, [80, 30, 0]);
45
- });
46
- });
47
-
48
- describe("toSplitPercents", () => {
49
- it("converts 70/30 to [30]", () => {
50
- assert.deepStrictEqual(toSplitPercents([70, 30]), [30]);
51
- });
52
-
53
- it("converts 60/25/15 correctly", () => {
54
- const result = toSplitPercents([60, 25, 15]);
55
- assert.deepStrictEqual(result, [40, 38]);
56
- });
57
-
58
- it("converts equal 50/50 to [50]", () => {
59
- assert.deepStrictEqual(toSplitPercents([50, 50]), [50]);
60
- });
61
-
62
- it("converts equal thirds", () => {
63
- const result = toSplitPercents([100 / 3, 100 / 3, 100 / 3]);
64
- assert.deepStrictEqual(result, [67, 50]);
65
- });
66
-
67
- it("returns empty array for single item", () => {
68
- assert.deepStrictEqual(toSplitPercents([100]), []);
69
- });
70
-
71
- it("returns empty array for empty input", () => {
72
- assert.deepStrictEqual(toSplitPercents([]), []);
73
- });
74
- });
@@ -1,141 +0,0 @@
1
- import { describe, it } from "node:test";
2
- import assert from "node:assert/strict";
3
- import { validateConfig } from "./validate.js";
4
-
5
- describe("validateConfig", () => {
6
- it("accepts a valid minimal config", () => {
7
- const errors = validateConfig({ rows: [{ panes: [{}] }] });
8
- assert.deepStrictEqual(errors, []);
9
- });
10
-
11
- it("accepts a valid full config", () => {
12
- const errors = validateConfig({
13
- name: "my-project",
14
- before: "pnpm install",
15
- rows: [
16
- {
17
- size: "70%",
18
- panes: [
19
- { title: "Editor", command: "vim", dir: "src", size: "60%", focus: true, env: { PORT: 3000, HOST: "localhost" } },
20
- { title: "Shell" },
21
- ],
22
- },
23
- {
24
- panes: [{ title: "Dev", command: "pnpm dev" }],
25
- },
26
- ],
27
- theme: { accent: "colour75", border: "colour238", bg: "colour235", fg: "colour248" },
28
- });
29
- assert.deepStrictEqual(errors, []);
30
- });
31
-
32
- it("rejects null config", () => {
33
- const errors = validateConfig(null);
34
- assert.deepStrictEqual(errors, ["config must be an object"]);
35
- });
36
-
37
- it("rejects string config", () => {
38
- const errors = validateConfig("hello");
39
- assert.deepStrictEqual(errors, ["config must be an object"]);
40
- });
41
-
42
- it("rejects array config", () => {
43
- const errors = validateConfig([]);
44
- assert.deepStrictEqual(errors, ["config must be an object"]);
45
- });
46
-
47
- it("requires rows to be an array", () => {
48
- const errors = validateConfig({ rows: "nope" });
49
- assert.ok(errors.includes("'rows' must be an array"));
50
- });
51
-
52
- it("requires rows to be non-empty", () => {
53
- const errors = validateConfig({ rows: [] });
54
- assert.ok(errors.includes("'rows' must not be empty"));
55
- });
56
-
57
- it("requires rows to have a panes array", () => {
58
- const errors = validateConfig({ rows: [{}] });
59
- assert.ok(errors.includes("rows[0].panes must be an array"));
60
- });
61
-
62
- it("requires panes to be non-empty", () => {
63
- const errors = validateConfig({ rows: [{ panes: [] }] });
64
- assert.ok(errors.includes("rows[0].panes must not be empty"));
65
- });
66
-
67
- it("rejects non-string name", () => {
68
- const errors = validateConfig({ name: 123, rows: [{ panes: [{}] }] });
69
- assert.ok(errors.includes("'name' must be a string"));
70
- });
71
-
72
- it("rejects non-string before", () => {
73
- const errors = validateConfig({ before: true, rows: [{ panes: [{}] }] });
74
- assert.ok(errors.includes("'before' must be a string"));
75
- });
76
-
77
- it("rejects non-string pane.title", () => {
78
- const errors = validateConfig({ rows: [{ panes: [{ title: 42 }] }] });
79
- assert.ok(errors.includes("rows[0].panes[0].title must be a string"));
80
- });
81
-
82
- it("rejects non-string pane.command", () => {
83
- const errors = validateConfig({ rows: [{ panes: [{ command: false }] }] });
84
- assert.ok(errors.includes("rows[0].panes[0].command must be a string"));
85
- });
86
-
87
- it("rejects non-string pane.dir", () => {
88
- const errors = validateConfig({ rows: [{ panes: [{ dir: [] }] }] });
89
- assert.ok(errors.includes("rows[0].panes[0].dir must be a string"));
90
- });
91
-
92
- it("rejects non-boolean pane.focus", () => {
93
- const errors = validateConfig({ rows: [{ panes: [{ focus: "yes" }] }] });
94
- assert.ok(errors.includes("rows[0].panes[0].focus must be a boolean"));
95
- });
96
-
97
- it("rejects non-object pane.env", () => {
98
- const errors = validateConfig({ rows: [{ panes: [{ env: "PORT=3000" }] }] });
99
- assert.ok(errors.includes("rows[0].panes[0].env must be an object"));
100
- });
101
-
102
- it("rejects invalid env values", () => {
103
- const errors = validateConfig({ rows: [{ panes: [{ env: { PORT: true } }] }] });
104
- assert.ok(errors.includes("rows[0].panes[0].env.PORT must be a string or number"));
105
- });
106
-
107
- it("rejects size without % suffix", () => {
108
- const errors = validateConfig({ rows: [{ size: "70", panes: [{}] }] });
109
- assert.ok(errors.some((e) => e.includes('must be a percentage')));
110
- });
111
-
112
- it("rejects 0% size", () => {
113
- const errors = validateConfig({ rows: [{ size: "0%", panes: [{}] }] });
114
- assert.ok(errors.some((e) => e.includes("must not be 0%")));
115
- });
116
-
117
- it("rejects >100% size", () => {
118
- const errors = validateConfig({ rows: [{ panes: [{ size: "150%" }] }] });
119
- assert.ok(errors.some((e) => e.includes("must not exceed 100%")));
120
- });
121
-
122
- it("validates theme fields as strings", () => {
123
- const errors = validateConfig({ rows: [{ panes: [{}] }], theme: { accent: 123 } });
124
- assert.ok(errors.includes("theme.accent must be a string"));
125
- });
126
-
127
- it("rejects non-object theme", () => {
128
- const errors = validateConfig({ rows: [{ panes: [{}] }], theme: "blue" });
129
- assert.ok(errors.includes("'theme' must be an object"));
130
- });
131
-
132
- it("collects multiple errors at once", () => {
133
- const errors = validateConfig({
134
- name: 123,
135
- before: [],
136
- rows: [{ panes: [{ title: 42, focus: "yes", size: "0%" }] }],
137
- theme: "nope",
138
- });
139
- assert.ok(errors.length >= 4);
140
- });
141
- });