node-tmux-cli 0.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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +178 -0
  3. package/dist/config/schema/schema.json +82 -0
  4. package/dist/config/template/node-tmux-config.json +22 -0
  5. package/dist/index.js +51 -0
  6. package/dist/util/commands/handler/handle-help.js +47 -0
  7. package/dist/util/commands/handler/handle-init.js +29 -0
  8. package/dist/util/commands/parser/is-initializer.js +4 -0
  9. package/dist/util/commands/parser/parse-cli-args.js +32 -0
  10. package/dist/util/commands/parser/parse-config.js +9 -0
  11. package/dist/util/commands/parser/parse-init.js +12 -0
  12. package/dist/util/config/config-paths.js +9 -0
  13. package/dist/util/config/get-config.js +32 -0
  14. package/dist/util/config/init.js +5 -0
  15. package/dist/util/config/list-configuration.js +21 -0
  16. package/dist/util/exit.js +4 -0
  17. package/dist/util/format-compound-path.js +12 -0
  18. package/dist/util/layout/line-separator.js +3 -0
  19. package/dist/util/layout/pad-text.js +3 -0
  20. package/dist/util/layout/print-padded-line.js +4 -0
  21. package/dist/util/state/fresh-state.js +4 -0
  22. package/dist/util/state/handler/handle-no-config-state.js +54 -0
  23. package/dist/util/state/render-state.js +24 -0
  24. package/dist/util/state/transition.js +30 -0
  25. package/dist/util/tmux/attach-session.js +9 -0
  26. package/dist/util/tmux/destroy-session.js +8 -0
  27. package/dist/util/tmux/has-session.js +13 -0
  28. package/dist/util/tmux/is-attached.js +19 -0
  29. package/dist/util/tmux/start/create-pane.js +22 -0
  30. package/dist/util/tmux/start/create-window.js +27 -0
  31. package/dist/util/tmux/start/start-session.js +16 -0
  32. package/dist/util/user-input/get-user-input.js +8 -0
  33. package/dist/util/user-input/user-choice/choices.js +37 -0
  34. package/dist/util/user-input/user-choice/get-parsed-available-options.js +17 -0
  35. package/dist/util/user-input/user-choice/handle-choice-with-confirmation.js +9 -0
  36. package/dist/util/user-input/user-choice/handle-user-choice.js +28 -0
  37. package/dist/util/user-input/user-choice/handler/attach-handler.js +8 -0
  38. package/dist/util/user-input/user-choice/handler/destroy-handler.js +9 -0
  39. package/dist/util/user-input/user-choice/handler/exit-handler.js +3 -0
  40. package/dist/util/user-input/user-choice/handler/other-handler.js +4 -0
  41. package/dist/util/user-input/user-choice/handler/restart-handler.js +13 -0
  42. package/dist/util/user-input/user-choice/list-choice-labels.js +12 -0
  43. package/dist/util/user-input/user-confirmation.js +11 -0
  44. package/package.json +33 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CassianKnoth
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,178 @@
1
+ # configurable `tmux` with `node.js` CLI
2
+
3
+ Create (or interact with) your `tmux` sessions quick and easy, again and again from one simple config file!
4
+
5
+ ## 📚 Contents
6
+
7
+ - [Quick start](#quick-start)
8
+ - [How it works](#how-it-works)
9
+ - [Development](#️-development)
10
+
11
+ ## 👉 Quick start
12
+
13
+ ### Requirements
14
+
15
+ This project was developed with
16
+
17
+ - [node](https://nodejs.org/en) version 22.18.0
18
+ - [tmux](https://github.com/tmux/tmux/wiki) version 3.5a
19
+
20
+ ### Getting started
21
+
22
+ Install the `node-tmux` CLI globally:
23
+
24
+ ```bash
25
+ npm install -g node-tmux
26
+ ```
27
+
28
+ Create a config file:
29
+
30
+ ```bash
31
+ node-tmux init
32
+ ```
33
+
34
+ Edit the config file to your liking and run:
35
+
36
+ ```bash
37
+ node-tmux
38
+ ```
39
+
40
+ ### Default vs custom config file
41
+
42
+ `node-tmux init` will create a `.node-tmux` folder with a `node-tmux-config.json` file in your home directory. Running `node-tmux` will look for that as a default config file (with that name!). You can also run `node-tmux init --local` (with `-l`/`--local` flag) which creates a `node-tmux-config.json` file in your current directory. Whether you create such a config file that way or completely on your own, you can run any such config by passing its path with the `-c`/`--config` flag:
43
+
44
+ ```bash
45
+ node-tmux --config ./some/directory/node-tmux-config.json
46
+ ```
47
+
48
+ These files you can also name whatever you desire:
49
+
50
+ ```bash
51
+ node-tmux --config ./some/directory/myConfig.json
52
+ ```
53
+
54
+ ## 🔬 How it works
55
+
56
+ Look at this simple config template:
57
+
58
+ ```json
59
+ {
60
+ "$schema": "../schema/schema.json",
61
+ "shell": "bash", // choose bash or zsh for running commands
62
+ "sessions": {
63
+ "mySession": [
64
+ {
65
+ "name": "myWindow",
66
+ "workspacePath": "/absolute/path/to/my-awesome-project",
67
+ "additionalPanes": [
68
+ {
69
+ "name": "server"
70
+ },
71
+ {
72
+ "name": "watcher",
73
+ "subPath": "subfolder",
74
+ // results in /absolute/path/to/my-awesome-project/subfolder
75
+ "command": "echo HELLO FROM WATCHER" // runs in the shell you configured above
76
+ }
77
+ ]
78
+ }
79
+ ]
80
+ }
81
+ }
82
+ ```
83
+
84
+ You can list any number of `tmux`-session configs under `sessions`. The porperty key (e. g. `mySession`) is the session name and holds a list of windows which can also hold a list of panes. Each window requires a `name` and a `workspacePath` (where the window's default pane should be initialized). Next to the default pane, the `additionalPanes` require a `name` and _optionally_ a `subPath` (if it should not be initalized at the window's `workspacePath`, which is the default) and also _optionally_ a `command` which should be run at creation (e. g. `echo hello world` or maybe `npm run build:watch`). This command will be run in `bash` or `zsh` depending what you configure as `shell`.
85
+
86
+ If you run the above default config file (e. g. as default config with just `node-tmux` after `node-tmux init`) you will see something like this:
87
+
88
+ <img src="./assets/config-selection.png">
89
+
90
+ If you then provide a valid key, three scenarios are possible:
91
+
92
+ - No session with that config is currently running
93
+ - A session with that config _is_ currently running in a _detached_ state
94
+ - A session with that config _is_ currently running in an _attached_ state
95
+
96
+ If no session is running, you could start it now:
97
+
98
+ <img src="./assets/start-session.png">
99
+
100
+ Every new session starts in _detached_ state.
101
+
102
+ Now you will have more options to deal with the session:
103
+
104
+ <img src="./assets/options.png">
105
+
106
+ > 💡 Notice how the states will be indicated in the session list already
107
+
108
+ - attach --> attaches the session to your current terminal
109
+ - restart --> restarts the session
110
+ - destroy --> kills the session
111
+ - other --> deal with another session
112
+ - exit --> exit CLI
113
+
114
+ > 💡 It is sufficient to type the first letter of each option. Also each option comes with confirmation.
115
+
116
+ Let's attach the session and look at the result in `tmux`:
117
+
118
+ <img src="./assets/tmux.png">
119
+
120
+ Compare this to the config: You can see that the session consists of one window (see `mySession-myWindow` at the bottom left. You can use or ignore the default window next to it). For `myWindow` two `additionalPanes` were configured, thus the window was split two times, resulting in three panes:
121
+
122
+ - A default pane
123
+ - The configured pane named "server"
124
+ - The configured pane named "watcher"
125
+ - The configured command was executed with the configured shell
126
+
127
+ You could actually run the CLI inside the `tmux`-session again:
128
+
129
+ <img src="./assets/attached.png">
130
+
131
+ The session is now correctly marked as _attached_ and the `[a]ttach` option is gone. This is to help keeping things organized – if the session is attached somewhere already, use that.
132
+
133
+ > 💡 You can technically attach a _detached_ session inside a running `tmux`-session, but that might take on confusing inception like nesting. Do as you please.
134
+
135
+ ## 🛠️ Development
136
+
137
+ 1. 📄 Clone the project
138
+
139
+ - HTTPS:
140
+
141
+ ```bash
142
+ git clone https://github.com/CassianKnoth/configurable-tmux-node.git
143
+ ```
144
+
145
+ - SSH:
146
+
147
+ ```bash
148
+ git clone git@github.com:CassianKnoth/configurable-tmux-node.git
149
+ ```
150
+
151
+ 2. ➡️ Navigate into project
152
+
153
+ ```bash
154
+ cd configurable-tmux-node
155
+ ```
156
+
157
+ 3. 🏗️ Build the project
158
+
159
+ ```bash
160
+ npm run build
161
+ ```
162
+
163
+ 4. 🚀 Run CLI
164
+
165
+ ```bash
166
+ npm run dev
167
+ ```
168
+
169
+ > 💡 Or directly `node dist/index.js` with or wothout arguments/flags
170
+
171
+ ## 📈 Improvements?
172
+
173
+ - Reusable config snippets
174
+ - Reference windows or panes from a `snippets` list if you need the same ones in multiple session configs
175
+
176
+ - Configure shell per pane instead of globally
177
+
178
+ - Delete default window
@@ -0,0 +1,82 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "additionalProperties": false,
4
+ "definitions": {
5
+ "SessionConfigurations": {
6
+ "$ref": "#/definitions/__type"
7
+ },
8
+ "Shell": {
9
+ "enum": [
10
+ "bash",
11
+ "zsh"
12
+ ],
13
+ "type": "string"
14
+ },
15
+ "__type": {
16
+ "additionalProperties": {
17
+ "items": {
18
+ "additionalProperties": false,
19
+ "properties": {
20
+ "additionalPanes": {
21
+ "description": "Optional: Besides the given root, what other panes should be horizontally split next to it in the same window.",
22
+ "items": {
23
+ "additionalProperties": false,
24
+ "properties": {
25
+ "command": {
26
+ "description": "Optional: Command that should be run in ths pane",
27
+ "type": "string"
28
+ },
29
+ "name": {
30
+ "description": "This will be used together with the window name",
31
+ "type": "string"
32
+ },
33
+ "subPath": {
34
+ "description": "Optional: Opens the pane at the given relative path which will be appended to the `workSpacePath`",
35
+ "type": "string"
36
+ }
37
+ },
38
+ "required": [
39
+ "name"
40
+ ],
41
+ "type": "object"
42
+ },
43
+ "type": "array"
44
+ },
45
+ "name": {
46
+ "description": "Name of the tmux window. This will be used as the prefix for window and pane names.",
47
+ "type": "string"
48
+ },
49
+ "workspacePath": {
50
+ "description": "Absolute path to workspace root.",
51
+ "type": "string"
52
+ }
53
+ },
54
+ "required": [
55
+ "name",
56
+ "workspacePath"
57
+ ],
58
+ "type": "object"
59
+ },
60
+ "type": "array"
61
+ },
62
+ "type": "object"
63
+ }
64
+ },
65
+ "properties": {
66
+ "$schema": {
67
+ "type": "string"
68
+ },
69
+ "sessions": {
70
+ "$ref": "#/definitions/SessionConfigurations"
71
+ },
72
+ "shell": {
73
+ "$ref": "#/definitions/Shell"
74
+ }
75
+ },
76
+ "required": [
77
+ "sessions",
78
+ "shell"
79
+ ],
80
+ "type": "object"
81
+ }
82
+
@@ -0,0 +1,22 @@
1
+ {
2
+ "$schema": "https://unpkg.com/node-tmux@0.2.0/dist/config/schema/schema.json",
3
+ "shell": "bash",
4
+ "sessions": {
5
+ "mySession": [
6
+ {
7
+ "name": "myWindow",
8
+ "workspacePath": "/absolute/path/to/my-awesome-project",
9
+ "additionalPanes": [
10
+ {
11
+ "name": "server"
12
+ },
13
+ {
14
+ "name": "watcher",
15
+ "subPath": "src",
16
+ "command": "echo HELLO FROM WATCHER"
17
+ }
18
+ ]
19
+ }
20
+ ]
21
+ }
22
+ }
package/dist/index.js ADDED
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+ import { styleText } from 'node:util';
3
+ import { renderState } from './util/state/render-state.js';
4
+ import { exitOut } from './util/exit.js';
5
+ import { printLineSeparator } from './util/layout/line-separator.js';
6
+ import { transition } from './util/state/transition.js';
7
+ import { getConfig } from './util/config/get-config.js';
8
+ import { parseCLiArgs } from './util/commands/parser/parse-cli-args.js';
9
+ import { handleInit } from './util/commands/handler/handle-init.js';
10
+ import { DEFAULT_CONFIG_FILE_PATH } from './util/config/config-paths.js';
11
+ import { handleHelp } from './util/commands/handler/handle-help.js';
12
+ printLineSeparator();
13
+ console.log('🚀 tmux ACTiON!');
14
+ const parsedArgs = parseCLiArgs();
15
+ // handle everything else than 'run'
16
+ switch (parsedArgs.command) {
17
+ case 'init':
18
+ handleInit(parsedArgs.local);
19
+ break;
20
+ case 'help':
21
+ handleHelp();
22
+ break;
23
+ }
24
+ printLineSeparator();
25
+ console.log('⏳ Getting config file path...');
26
+ const configFilePath = parsedArgs.configPath ?? DEFAULT_CONFIG_FILE_PATH;
27
+ const config = getConfig(configFilePath);
28
+ // exit without configurations
29
+ const configuredSessionNames = Object.keys(config.sessions);
30
+ if (configuredSessionNames.length < 1) {
31
+ console.log(styleText('red', '❌ Found no configured sessions'));
32
+ console.log(styleText('cyan', '➡️ Please provide at least 1 configuration in config.ts'));
33
+ exitOut();
34
+ // EXiT
35
+ }
36
+ export const { shell: confiuredShell, sessions: configuredSessions } = config;
37
+ let context = {
38
+ sessionState: 'NO_CONFIG',
39
+ sessionName: '',
40
+ };
41
+ while (true) {
42
+ if (context.sessionState === 'EXIT') {
43
+ exitOut();
44
+ }
45
+ // render state
46
+ renderState(context);
47
+ // handle state transition
48
+ const newContext = await transition(context);
49
+ // set new state
50
+ context = newContext;
51
+ }
@@ -0,0 +1,47 @@
1
+ import { exitOut } from '../../exit.js';
2
+ import { printLineSeparator } from '../../layout/line-separator.js';
3
+ import { printPaddedLine } from '../../layout/print-padded-line.js';
4
+ const validCommands = [
5
+ {
6
+ name: 'node-tmux',
7
+ description: 'Runs the CLI with default config file',
8
+ },
9
+ {
10
+ name: 'init',
11
+ description: 'Initializes a node-tmux-config.json file in the home directory under .node-tmux',
12
+ flags: [
13
+ {
14
+ name: '-l / --local',
15
+ description: 'Creates a node-tmux-config.json in the current directory instead of the home directory',
16
+ },
17
+ ],
18
+ },
19
+ {
20
+ name: '-c / --config',
21
+ description: 'Allows you to pass any path to a config file, e. g. "node-tmux -c ./path/to/config.json"',
22
+ },
23
+ {
24
+ name: '-h / --help',
25
+ description: 'Prints this reference',
26
+ },
27
+ ];
28
+ export const handleHelp = () => {
29
+ printLineSeparator();
30
+ console.log('📚 These references might help you:');
31
+ console.log('\n');
32
+ validCommands.forEach((command) => {
33
+ console.group();
34
+ printPaddedLine(command.name, command.description);
35
+ if (command.flags) {
36
+ console.log('🚩 (flags)');
37
+ command.flags.forEach((flag) => {
38
+ console.group();
39
+ printPaddedLine(flag.name, flag.description);
40
+ console.groupEnd();
41
+ });
42
+ }
43
+ console.groupEnd();
44
+ console.log('\n');
45
+ });
46
+ exitOut();
47
+ };
@@ -0,0 +1,29 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs';
3
+ import { printLineSeparator } from '../../layout/line-separator.js';
4
+ import { styleText } from 'node:util';
5
+ import { exitOut } from '../../exit.js';
6
+ import { DEFAULT_CONFIG_FILE_NAME, DEFAULT_CONFIG_FILE_PATH, DEFAULT_CONFIG_HOME_DIR, TEMPLATE_FILE, } from '../../config/config-paths.js';
7
+ export const handleInit = (local) => {
8
+ printLineSeparator();
9
+ // check local
10
+ const configFilePath = local
11
+ ? path.join(process.cwd(), DEFAULT_CONFIG_FILE_NAME)
12
+ : DEFAULT_CONFIG_FILE_PATH;
13
+ // check if file exists
14
+ console.log(`👀 Looking for config file at ${configFilePath} ...`);
15
+ if (fs.existsSync(configFilePath)) {
16
+ console.log(styleText('yellow',
17
+ // double space due to emoji consisting of two characters
18
+ `⚠️ Config file already exists at ${configFilePath}.`));
19
+ exitOut();
20
+ }
21
+ // double space due to emoji consisting of two characters
22
+ console.log(`ℹ️ Found no config file at ${configFilePath}`);
23
+ console.log(`⏳ Initializing config file at ${configFilePath} ...`);
24
+ if (!local) {
25
+ fs.mkdirSync(DEFAULT_CONFIG_HOME_DIR, { recursive: true });
26
+ }
27
+ fs.copyFileSync(TEMPLATE_FILE, configFilePath);
28
+ exitOut();
29
+ };
@@ -0,0 +1,4 @@
1
+ export const initializers = ['init', '-c', '--config', '-h', '--help'];
2
+ export const isInitializer = (value) => {
3
+ return initializers.includes(value);
4
+ };
@@ -0,0 +1,32 @@
1
+ import { argv } from 'node:process';
2
+ import { isInitializer } from './is-initializer.js';
3
+ import { parseInit } from './parse-init.js';
4
+ import { parseConfig } from './parse-config.js';
5
+ import { styleText } from 'node:util';
6
+ export const parseCLiArgs = () => {
7
+ const rawArgs = argv.slice(2);
8
+ const firstArg = rawArgs[0];
9
+ // ready to run immedeately
10
+ if (!firstArg) {
11
+ return { command: 'run' };
12
+ }
13
+ // catch unknown args
14
+ if (!isInitializer(firstArg)) {
15
+ console.log(styleText('red', `❌ Command not found: ${firstArg}`));
16
+ return { command: 'help' };
17
+ }
18
+ // or with run with (presumably) path argument
19
+ // if (!isInitializer(firstArg)) {
20
+ // return { command: 'run', configPath: firstArg };
21
+ // }
22
+ switch (firstArg) {
23
+ case 'init':
24
+ return parseInit(rawArgs[1]);
25
+ case '-c':
26
+ case '--config':
27
+ return parseConfig(rawArgs[1]);
28
+ default:
29
+ // help is the only option left
30
+ return { command: 'help' };
31
+ }
32
+ };
@@ -0,0 +1,9 @@
1
+ import { styleText } from 'node:util';
2
+ export const parseConfig = (input) => {
3
+ if (!input) {
4
+ console.log(styleText('red', `❌ Missing config file path`));
5
+ console.log(styleText('cyan', `➡️ Please provide a path to a config file or run node-tmux without any flags to use the default config file instead`));
6
+ return { command: 'help' };
7
+ }
8
+ return { command: 'run', configPath: input };
9
+ };
@@ -0,0 +1,12 @@
1
+ import { styleText } from 'node:util';
2
+ export const parseInit = (input) => {
3
+ if (!input) {
4
+ return { command: 'init' };
5
+ }
6
+ const isLocal = ['-l', '--local'].includes(input);
7
+ if (isLocal) {
8
+ return { command: 'init', local: true };
9
+ }
10
+ console.log(styleText('red', `❌ Unknown argument: ${input}`));
11
+ return { command: 'help' };
12
+ };
@@ -0,0 +1,9 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ const __filename = fileURLToPath(import.meta.url);
5
+ const __dirname = path.dirname(__filename);
6
+ export const DEFAULT_CONFIG_HOME_DIR = path.join(os.homedir(), '.node-tmux');
7
+ export const DEFAULT_CONFIG_FILE_NAME = 'node-tmux-config.json';
8
+ export const DEFAULT_CONFIG_FILE_PATH = path.join(DEFAULT_CONFIG_HOME_DIR, DEFAULT_CONFIG_FILE_NAME);
9
+ export const TEMPLATE_FILE = path.join(__dirname, '../../config/template/', DEFAULT_CONFIG_FILE_NAME);
@@ -0,0 +1,32 @@
1
+ import fs from 'fs';
2
+ import schema from '../../config/schema/schema.json' with { type: 'json' };
3
+ import { createRequire } from 'node:module';
4
+ import { printLineSeparator } from '../layout/line-separator.js';
5
+ import { styleText } from 'node:util';
6
+ const require = createRequire(import.meta.url);
7
+ const Ajv = require('ajv');
8
+ export const ajv = new Ajv({ allErrors: true });
9
+ const validate = ajv.compile(schema);
10
+ export const getConfig = (configFilePath) => {
11
+ printLineSeparator();
12
+ console.log(`👀 Looking for config file at ${configFilePath} ...`);
13
+ if (!fs.existsSync(configFilePath)) {
14
+ console.log(styleText('red', `❌ Config file not found: ${configFilePath}`));
15
+ console.log(styleText('cyan',
16
+ // double space due to emoji consisting of two characters
17
+ '➡️ Run `node-tmux init` to create one, or use -c or --config flag to specify a path'));
18
+ process.exit(1);
19
+ }
20
+ const raw = JSON.parse(fs.readFileSync(configFilePath, 'utf-8'));
21
+ if (!validate(raw)) {
22
+ console.error('Invalid node-tmux config:');
23
+ for (const err of validate.errors ?? []) {
24
+ console.error(`- ${err.instancePath} ${err.message}`);
25
+ }
26
+ process.exit(1);
27
+ }
28
+ console.log(
29
+ // double space due to emoji consisting of two characters
30
+ styleText('green', `⚙️ Found valid config file at ${configFilePath}`));
31
+ return raw;
32
+ };
@@ -0,0 +1,5 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ export const init = (creationPath) => {
4
+ fs.copyFileSync(path.join(__dirname, 'templates/config.json'), creationPath);
5
+ };
@@ -0,0 +1,21 @@
1
+ import { styleText } from 'node:util';
2
+ import { configuredSessions } from '../../index.js';
3
+ import { hasTmuxSession } from '../tmux/has-session.js';
4
+ import { isTmuxAttached } from '../tmux/is-attached.js';
5
+ export const listConfigurations = () => {
6
+ const configuredSessionNames = Object.keys(configuredSessions);
7
+ // double space due to emoji consisting of two characters
8
+ console.log('ℹ️ Available configurations:');
9
+ configuredSessionNames.forEach((configuredSessionName) => {
10
+ const hasSession = hasTmuxSession(configuredSessionName);
11
+ if (hasSession) {
12
+ const isAttached = isTmuxAttached(configuredSessionName);
13
+ const color = isAttached ? 'green' : 'yellow';
14
+ const suffix = isAttached ? '(attached)' : '(detached)';
15
+ console.log(styleText(color, ` - ${configuredSessionName} ${suffix}`));
16
+ }
17
+ else {
18
+ console.log(` - ${configuredSessionName}`);
19
+ }
20
+ });
21
+ };
@@ -0,0 +1,4 @@
1
+ export const exitOut = () => {
2
+ console.log('👋 exiting...');
3
+ process.exit(0);
4
+ };
@@ -0,0 +1,12 @@
1
+ const parsePath = (path, pattern, replacement) => {
2
+ const parsedPath = path.replace(pattern, replacement);
3
+ return parsedPath;
4
+ };
5
+ export const formatCompoundPath = (basePath, subPath) => {
6
+ const leadingSlashRegex = /^\/+/;
7
+ const trailingSlashRegex = /\/+$/;
8
+ const parsedBasePath = parsePath(basePath, trailingSlashRegex, '');
9
+ const parsedSubPath = parsePath(subPath, leadingSlashRegex, '');
10
+ const compoundPath = `${parsedBasePath}/${parsedSubPath}`;
11
+ return compoundPath;
12
+ };
@@ -0,0 +1,3 @@
1
+ export const printLineSeparator = () => {
2
+ console.log('----- ------- -----');
3
+ };
@@ -0,0 +1,3 @@
1
+ export const padText = (text) => {
2
+ return text.padEnd(24);
3
+ };
@@ -0,0 +1,4 @@
1
+ import { padText } from './pad-text.js';
2
+ export const printPaddedLine = (left, right) => {
3
+ console.log(`${padText(left)} ${right}`);
4
+ };
@@ -0,0 +1,4 @@
1
+ export const freshState = {
2
+ sessionState: 'NO_CONFIG',
3
+ sessionName: '',
4
+ };
@@ -0,0 +1,54 @@
1
+ import { styleText } from 'node:util';
2
+ import { choices } from '../../user-input/user-choice/choices.js';
3
+ import { getUserInput } from '../../user-input/get-user-input.js';
4
+ import { hasTmuxSession } from '../../tmux/has-session.js';
5
+ import { isTmuxAttached } from '../../tmux/is-attached.js';
6
+ import { tmuxStartSession } from '../../tmux/start/start-session.js';
7
+ import { handleWithConfirmation } from '../../user-input/user-choice/handle-choice-with-confirmation.js';
8
+ import { configuredSessions } from '../../../index.js';
9
+ import { userConfirmation } from '../../user-input/user-confirmation.js';
10
+ export const handleNoConfigState = async (currentContext) => {
11
+ // get user input
12
+ const userInput = await getUserInput('🔑 Session-configuration key or [e]xit: ');
13
+ // handle exit
14
+ if (choices.exit.regex.test(userInput)) {
15
+ const newContext = await handleWithConfirmation(choices.exit, currentContext);
16
+ return newContext;
17
+ }
18
+ // handle other input
19
+ console.log(`👀 Looking for session "${userInput}"...`);
20
+ const configuration = configuredSessions[userInput];
21
+ // - invalid input
22
+ if (!configuration) {
23
+ console.log(styleText('red', `❌ "${userInput}" is not a valid configuration`));
24
+ console.log(styleText('cyan',
25
+ // double space due to emoji consisting of two characters
26
+ `➡️ Check for typos, create it and then try again, or choose another`));
27
+ return { ...currentContext, sessionState: 'NO_CONFIG' };
28
+ }
29
+ const sessionName = userInput;
30
+ console.log(
31
+ // double space due to emoji consisting of two characters
32
+ styleText('green', `⚙️ Found configuration for "${sessionName}"`));
33
+ console.log(`🔍 Checking status of "${sessionName}"...`);
34
+ const hasSession = hasTmuxSession(sessionName);
35
+ // - attached / detached
36
+ if (hasSession) {
37
+ console.log(styleText('yellow', `🏃 Session "${sessionName}" is already running`));
38
+ const isAttached = isTmuxAttached(sessionName);
39
+ if (isAttached) {
40
+ return { sessionState: 'ATTACHED_SESSION', sessionName: sessionName };
41
+ }
42
+ else {
43
+ return { sessionState: 'DETACHED_SESSION', sessionName: sessionName };
44
+ }
45
+ }
46
+ // - start
47
+ console.log(`Status: Session "${sessionName}" is inactive`);
48
+ const confirmed = await userConfirmation(`Start session "${sessionName}?"`);
49
+ if (confirmed) {
50
+ tmuxStartSession(sessionName);
51
+ return { sessionState: 'DETACHED_SESSION', sessionName: sessionName };
52
+ }
53
+ return currentContext;
54
+ };
@@ -0,0 +1,24 @@
1
+ import { styleText } from 'node:util';
2
+ import { listConfigurations } from '../config/list-configuration.js';
3
+ import { printLineSeparator } from '../layout/line-separator.js';
4
+ export const renderState = async ({ sessionState, sessionName }) => {
5
+ printLineSeparator();
6
+ switch (sessionState) {
7
+ case 'NO_CONFIG':
8
+ console.log(styleText('yellow',
9
+ // double space due to emoji consisting of two characters
10
+ '⚠️ Please provide a valid session-configuration key to start or interact with a session'));
11
+ break;
12
+ case 'DETACHED_SESSION':
13
+ console.log(
14
+ // double space due to emoji consisting of two characters
15
+ styleText('yellow', `⚠️ Session "${sessionName}" is running detached`));
16
+ break;
17
+ case 'ATTACHED_SESSION':
18
+ console.log(
19
+ // double space due to emoji consisting of two characters
20
+ styleText('yellow', `⚠️ Session "${sessionName}" is running attached`));
21
+ break;
22
+ }
23
+ listConfigurations();
24
+ };
@@ -0,0 +1,30 @@
1
+ import { printLineSeparator } from '../layout/line-separator.js';
2
+ import { handleUserChoice } from '../user-input/user-choice/handle-user-choice.js';
3
+ import { handleNoConfigState } from './handler/handle-no-config-state.js';
4
+ export const transition = async (currentContext) => {
5
+ printLineSeparator();
6
+ let newContext = { ...currentContext, sessionState: 'EXIT' };
7
+ switch (currentContext.sessionState) {
8
+ case 'NO_CONFIG':
9
+ newContext = await handleNoConfigState(currentContext);
10
+ break;
11
+ case 'DETACHED_SESSION':
12
+ newContext = await handleUserChoice({
13
+ attach: true,
14
+ restart: true,
15
+ destroy: true,
16
+ other: true,
17
+ exit: true,
18
+ }, currentContext);
19
+ break;
20
+ case 'ATTACHED_SESSION':
21
+ newContext = await handleUserChoice({
22
+ restart: true,
23
+ destroy: true,
24
+ other: true,
25
+ exit: true,
26
+ }, currentContext);
27
+ break;
28
+ }
29
+ return newContext;
30
+ };
@@ -0,0 +1,9 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { printLineSeparator } from '../layout/line-separator.js';
3
+ export const tmuxAttach = (sessionName) => {
4
+ printLineSeparator();
5
+ console.log(`📌 Attaching session "${sessionName}"`);
6
+ execFileSync('tmux', ['attach', '-t', sessionName], {
7
+ stdio: 'inherit',
8
+ });
9
+ };
@@ -0,0 +1,8 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { printLineSeparator } from '../layout/line-separator.js';
3
+ export const tmuxDestroySession = (sessionName) => {
4
+ printLineSeparator();
5
+ console.log(`💥 Destroying session "${sessionName}"...`);
6
+ execSync(`tmux kill-session -t ${sessionName}`);
7
+ console.log(`✅ Session "${sessionName}" was destroyed`);
8
+ };
@@ -0,0 +1,13 @@
1
+ import { execSync } from 'node:child_process';
2
+ /** Determines whether a given tmux session is running or not
3
+ * @param sessionName Name of the tmux session
4
+ */
5
+ export const hasTmuxSession = (sessionName) => {
6
+ try {
7
+ execSync(`tmux has-session -t ${sessionName}`, { stdio: 'ignore' });
8
+ return true;
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ };
@@ -0,0 +1,19 @@
1
+ import { execSync } from 'node:child_process';
2
+ /** Determines whether a given tmux session is attached or not
3
+ * @param sessionName Name of the tmux session
4
+ */
5
+ export const isTmuxAttached = (sessionName) => {
6
+ const attached = execSync(`tmux display-message -p -t ${sessionName} "#{session_attached}"`)
7
+ .toString()
8
+ .trim();
9
+ return attached === '1';
10
+ // try {
11
+ // execSync(
12
+ // `tmux list-sessions -F "#{session_name} #{session_attached}" | awk -v name="${sessionName}" '$1 == name {print $2}'`,
13
+ // { stdio: 'ignore' },
14
+ // );
15
+ // return true;
16
+ // } catch {
17
+ // return false;
18
+ // }
19
+ };
@@ -0,0 +1,22 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { formatCompoundPath } from '../../format-compound-path.js';
3
+ import { confiuredShell } from '../../../index.js';
4
+ export const createPane = (pane, paneIndex, windowIdentifier, basePath) => {
5
+ const paneName = pane.name;
6
+ const splitTarget = `${windowIdentifier}.${paneIndex}`;
7
+ const newPaneIdentifier = `${windowIdentifier}.${paneIndex + 1}`;
8
+ const path = pane.subPath
9
+ ? formatCompoundPath(basePath, pane.subPath)
10
+ : basePath;
11
+ console.log(`⏳ 🧩 Creating new Pane "${paneName}"`);
12
+ execSync(`tmux split-window -h -t ${splitTarget} -c ${path}`);
13
+ execSync(`tmux select-pane -t ${newPaneIdentifier} -T ${paneName}`);
14
+ console.log(`✅ Pane "${paneName}" was created`);
15
+ if (pane.command) {
16
+ console.log(`⏳ 🤖 Executing configured command in Pane "${paneName}"...`);
17
+ // replace double quotes because they can mess with shell execution
18
+ execSync(`tmux send-keys -t ${newPaneIdentifier} "${confiuredShell} -c '${pane.command.replace(/"/g, '\\"')}; exec ${confiuredShell}'" C-m`);
19
+ execSync(`tmux send-keys -t ${newPaneIdentifier} Enter`);
20
+ console.log(`✅ Command executed in Pane "${paneName}"`);
21
+ }
22
+ };
@@ -0,0 +1,27 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { printLineSeparator } from '../../layout/line-separator.js';
3
+ import { createPane } from './create-pane.js';
4
+ export const createWindow = (window, windowIndex, sessionName) => {
5
+ printLineSeparator();
6
+ const windowName = `${sessionName}-${window.name}`;
7
+ // double space due to emoji consisting of two characters
8
+ console.log(`🏗️ Building "${windowName}"...`);
9
+ // double space due to emoji consisting of two characters
10
+ console.log(`⏳ 🖼️ Creating new Window "${windowName}"`);
11
+ execSync(`tmux new-window -t ${sessionName}:${windowIndex + 1} -n ${windowName} -c ${window.workspacePath}`);
12
+ console.log(`✅ Window "${windowName}" was created`);
13
+ const windowIdentifier = `${sessionName}:${windowName}`;
14
+ const originPaneIdentifier = `${windowIdentifier}.0`;
15
+ const originPaneName = 'origin';
16
+ console.log(`⏳ 🧩 Creating new Pane "${originPaneName}"...`);
17
+ execSync(`tmux select-pane -t ${originPaneIdentifier} -T ${originPaneName}`);
18
+ console.log(`✅ Pane "${originPaneName}" was created`);
19
+ if (window.additionalPanes) {
20
+ printLineSeparator();
21
+ // double space due to emoji consisting of two characters
22
+ console.log(`🏗️ Building additional Panes...`);
23
+ window.additionalPanes.forEach((pane, paneIndex) => {
24
+ createPane(pane, paneIndex, windowIdentifier, window.workspacePath);
25
+ });
26
+ }
27
+ };
@@ -0,0 +1,16 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { printLineSeparator } from '../../layout/line-separator.js';
3
+ import { createWindow } from './create-window.js';
4
+ import { configuredSessions } from '../../../index.js';
5
+ export const tmuxStartSession = (sessionName) => {
6
+ const configuredWindows = configuredSessions[sessionName];
7
+ if (!configuredWindows) {
8
+ throw new Error(`Internal: No configuration found for: ${sessionName}`);
9
+ }
10
+ printLineSeparator();
11
+ const initialWindowName = 'default';
12
+ console.log(`⏳ Creating new detached session "${sessionName}"...`);
13
+ execSync(`tmux new -d -s ${sessionName} -n ${initialWindowName} -c "$(pwd)"`);
14
+ console.log(`✅ Session "${sessionName}" was created`);
15
+ configuredWindows.forEach((window, windowIndex) => createWindow(window, windowIndex, sessionName));
16
+ };
@@ -0,0 +1,8 @@
1
+ import * as readline from 'node:readline/promises';
2
+ import { stdin as input, stdout as output } from 'node:process';
3
+ export const getUserInput = async (prompt) => {
4
+ const rl = readline.createInterface({ input, output });
5
+ const userInput = await rl.question(prompt);
6
+ rl.close();
7
+ return userInput.trim();
8
+ };
@@ -0,0 +1,37 @@
1
+ import { attachHandler } from './handler/attach-handler.js';
2
+ import { destroyHandler } from './handler/destroy-handler.js';
3
+ import { exitHandler } from './handler/exit-handler.js';
4
+ import { otherHandler } from './handler/other-handler.js';
5
+ import { restartHandler } from './handler/restart-handler.js';
6
+ export const choices = {
7
+ attach: {
8
+ label: 'attach',
9
+ optionLabel: '[a]ttach',
10
+ regex: /a$|attach/i,
11
+ handler: attachHandler,
12
+ },
13
+ destroy: {
14
+ label: 'destroy',
15
+ optionLabel: '[d]estroy',
16
+ regex: /d$|destroy/i,
17
+ handler: destroyHandler,
18
+ },
19
+ restart: {
20
+ label: 'restart',
21
+ optionLabel: '[r]estart',
22
+ regex: /r$|restart/i,
23
+ handler: restartHandler,
24
+ },
25
+ other: {
26
+ label: 'other',
27
+ optionLabel: '[o]ther',
28
+ regex: /o$|other/i,
29
+ handler: otherHandler,
30
+ },
31
+ exit: {
32
+ label: 'exit',
33
+ optionLabel: '[e]xit',
34
+ regex: /e$|exit/i,
35
+ handler: exitHandler,
36
+ },
37
+ };
@@ -0,0 +1,17 @@
1
+ import { choices } from './choices.js';
2
+ function isKeyOf(key, obj) {
3
+ return key in obj;
4
+ }
5
+ export const getParsedAvailableOptions = (availableChoices) => {
6
+ const parsedAvailableOptions = Object.keys(availableChoices)
7
+ .map((choiceKey) => {
8
+ if (isKeyOf(choiceKey, choices)) {
9
+ return choices[choiceKey];
10
+ }
11
+ else {
12
+ return undefined;
13
+ }
14
+ })
15
+ .filter((choice) => choice !== undefined);
16
+ return parsedAvailableOptions;
17
+ };
@@ -0,0 +1,9 @@
1
+ import { getUserInput } from '../get-user-input.js';
2
+ export const handleWithConfirmation = async (choice, currentContext) => {
3
+ const userInput = await getUserInput(`👉 ${choice.label}? [Y/n]: `);
4
+ const confirmed = !/n$|no/i.test(userInput);
5
+ const newContext = confirmed
6
+ ? choice.handler(currentContext)
7
+ : currentContext;
8
+ return newContext;
9
+ };
@@ -0,0 +1,28 @@
1
+ import { listChoiceLabels } from './list-choice-labels.js';
2
+ import { getUserInput } from '../get-user-input.js';
3
+ import { getParsedAvailableOptions } from './get-parsed-available-options.js';
4
+ import { styleText } from 'node:util';
5
+ import { handleWithConfirmation } from './handle-choice-with-confirmation.js';
6
+ /**
7
+ * Present the user with a set of choices and handle the input accordingly.
8
+ *
9
+ * @param availableChoices Opt in parameter for available choices.
10
+ * Value is always `true`, the property just needs to be set. All omitted properties will not be listed as choice.
11
+ *
12
+ * Choices: `attach`, `destroy`, `restart`, `other`, 'exit'
13
+ */
14
+ export const handleUserChoice = async (availableChoices, currentContext) => {
15
+ const parsedAvailableOptions = getParsedAvailableOptions(availableChoices);
16
+ const question = `👉 The choice is yours: ${listChoiceLabels(parsedAvailableOptions)}`;
17
+ const userInput = await getUserInput(question);
18
+ const matchedOption = parsedAvailableOptions.find((option) => {
19
+ return option.regex.test(userInput);
20
+ });
21
+ // loop until valid input
22
+ if (!matchedOption) {
23
+ console.log(styleText('red', '❌ Invalid input, try again...'));
24
+ return currentContext;
25
+ }
26
+ const newContext = await handleWithConfirmation(matchedOption, currentContext);
27
+ return newContext;
28
+ };
@@ -0,0 +1,8 @@
1
+ import { tmuxAttach } from '../../../tmux/attach-session.js';
2
+ export const attachHandler = (currentContext) => {
3
+ if (!currentContext) {
4
+ throw new Error('Internal: No currentContext provided');
5
+ }
6
+ tmuxAttach(currentContext.sessionName);
7
+ return { ...currentContext, sessionState: 'EXIT' };
8
+ };
@@ -0,0 +1,9 @@
1
+ import { freshState } from '../../../state/fresh-state.js';
2
+ import { tmuxDestroySession } from '../../../tmux/destroy-session.js';
3
+ export const destroyHandler = (currentContext) => {
4
+ if (!currentContext) {
5
+ throw new Error('Internal: No currentContext provided');
6
+ }
7
+ tmuxDestroySession(currentContext.sessionName);
8
+ return freshState;
9
+ };
@@ -0,0 +1,3 @@
1
+ export const exitHandler = () => {
2
+ return { sessionState: 'EXIT', sessionName: '' };
3
+ };
@@ -0,0 +1,4 @@
1
+ import { freshState } from '../../../state/fresh-state.js';
2
+ export const otherHandler = () => {
3
+ return freshState;
4
+ };
@@ -0,0 +1,13 @@
1
+ import { tmuxDestroySession } from '../../../tmux/destroy-session.js';
2
+ import { tmuxStartSession } from '../../../tmux/start/start-session.js';
3
+ export const restartHandler = (currentContext) => {
4
+ if (!currentContext) {
5
+ throw new Error('Internal: No currentContext provided');
6
+ }
7
+ const sessionName = currentContext.sessionName;
8
+ // double space due to emoji consisting of two characters
9
+ console.log(`🪃 Restarting session "${sessionName}"`);
10
+ tmuxDestroySession(sessionName);
11
+ tmuxStartSession(sessionName);
12
+ return { ...currentContext, sessionState: 'DETACHED_SESSION' };
13
+ };
@@ -0,0 +1,12 @@
1
+ export const listChoiceLabels = (choiceList) => {
2
+ if (!choiceList[0]) {
3
+ return '';
4
+ }
5
+ let choiceLabelList = choiceList[0].optionLabel;
6
+ const separator = ', ';
7
+ choiceList
8
+ .slice(1)
9
+ .forEach((choice) => (choiceLabelList = choiceLabelList + separator + choice.optionLabel));
10
+ choiceLabelList = choiceLabelList + ': ';
11
+ return choiceLabelList;
12
+ };
@@ -0,0 +1,11 @@
1
+ import { getUserInput } from './get-user-input.js';
2
+ /**
3
+ * Prompts the user for yes/no confirmation
4
+ * @param prompt Will be presented to the user as `👉 ${prompt} [Y/n]: `
5
+ * @example "Confirm?" --> `👉 Confirm? [Y/n]: `
6
+ */
7
+ export const userConfirmation = async (prompt) => {
8
+ const userInput = await getUserInput(`👉 ${prompt} [Y/n]: `);
9
+ const confirmed = !/n$|no/i.test(userInput);
10
+ return confirmed;
11
+ };
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "node-tmux-cli",
3
+ "version": "0.2.0",
4
+ "description": "",
5
+ "main": "index.js",
6
+ "type": "module",
7
+ "files": [
8
+ "dist"
9
+ ],
10
+ "bin": {
11
+ "node-tmux-cli": "dist/index.js"
12
+ },
13
+ "scripts": {
14
+ "schema:generate": "rm -rf ./src/config/schema/schema.json && typescript-json-schema tsconfig.json NodeTmuxConfig --topRef --aliasRefs --noExtraProps --required > ./src/config/schema/schema.json",
15
+ "schema:validate": "node .github/scripts/validate-schema.js",
16
+ "prebuild": "npm run schema:generate",
17
+ "build": "rm -rf dist && tsc && cp -r ./src/config dist/ && npm run build:template",
18
+ "build:template": "node scripts/build-template.js",
19
+ "postbuild": "chmod +x dist/index.js",
20
+ "dev": "node dist/index.js"
21
+ },
22
+ "keywords": [],
23
+ "author": "",
24
+ "license": "MIT",
25
+ "devDependencies": {
26
+ "@types/node": "^25.0.8",
27
+ "typescript": "^5.9.3",
28
+ "typescript-json-schema": "^0.67.1"
29
+ },
30
+ "dependencies": {
31
+ "ajv": "^8.17.1"
32
+ }
33
+ }