pomitu 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,6 +22,25 @@ pomitu start <config-file>
22
22
 
23
23
  This command starts and daemonizes an app based on the configuration file provided.
24
24
 
25
+ #### Interactive Mode (TUI)
26
+
27
+ ```sh
28
+ pomitu start <config-file> --no-daemon
29
+ ```
30
+
31
+ Run in interactive mode with a Terminal User Interface (TUI) that allows you to:
32
+ - View the status of all processes in real-time
33
+ - Start, stop, and restart individual apps
34
+ - Navigate with arrow keys
35
+ - Press `q` to quit
36
+
37
+ The interactive mode is perfect for development when you want to manage multiple processes and see their status at a glance.
38
+
39
+ #### Options
40
+
41
+ - `--no-daemon` - Run in interactive TUI mode instead of daemonizing
42
+ - `--clear-logs` - Clear log files before starting the app
43
+
25
44
  ### Stop an application
26
45
 
27
46
  ```sh
@@ -30,6 +49,8 @@ pomitu stop <name>
30
49
 
31
50
  This command stops a running app. Use `pomitu stop all` to stop all running apps.
32
51
 
52
+ **Note:** In interactive TUI mode (`--no-daemon`), you can stop apps directly from the interface instead of using this command.
53
+
33
54
  ### List running applications
34
55
 
35
56
  ```sh
@@ -74,6 +95,12 @@ If you want to contribute to Pomitu or run it in development mode:
74
95
  npm run dev
75
96
  ```
76
97
 
98
+ Dev installation test:
99
+
100
+ ```sh
101
+ npm run build && npm pack && npm install -g pomitu-1.1.1.tgz && rm pomitu-1.1.1.tgz
102
+ ```
103
+
77
104
  ### Linting
78
105
 
79
106
  To lint the code:
@@ -1,20 +1,22 @@
1
1
  import { Command } from 'commander';
2
- import { getPomituLogsDirectory } from '../helpers.js';
3
- import * as fs from 'node:fs';
4
- import * as path from 'node:path';
5
- const pomituLogsDirectory = getPomituLogsDirectory();
2
+ import { LogManager } from '../services/index.js';
6
3
  export const flush = new Command('flush')
7
4
  .description('flush logs')
8
5
  .argument('[name]', 'name of the app whose logs you want to flush')
9
6
  .action((name) => {
10
- let logs = fs.readdirSync(pomituLogsDirectory);
11
- if (name) {
12
- logs = logs.filter((log) => log.startsWith(name));
7
+ try {
8
+ const logManager = new LogManager();
9
+ const flushedFiles = logManager.flushLogs(name);
10
+ if (flushedFiles.length === 0) {
11
+ console.log('No log files found to flush');
12
+ }
13
+ else {
14
+ console.log('Logs flushed');
15
+ }
13
16
  }
14
- const fullLogPaths = logs.map((log) => path.join(pomituLogsDirectory, log));
15
- for (const logFilePath of fullLogPaths) {
16
- console.log(`Flushing ${logFilePath}`);
17
- fs.unlinkSync(logFilePath);
17
+ catch (error) {
18
+ const err = error;
19
+ console.error(`Error: ${err.message}`);
20
+ process.exit(1);
18
21
  }
19
- console.log('Logs flushed');
20
22
  });
@@ -1,25 +1,28 @@
1
1
  import { Command } from 'commander';
2
- import * as fs from 'node:fs';
3
- import { getPomituPidsDirectory, pidIsRunning } from '../helpers.js';
2
+ import { ProcessManager } from '../services/index.js';
4
3
  export const ls = new Command('ls')
5
4
  .description('list all running apps')
6
5
  .action(() => {
7
- const pidsDirectory = getPomituPidsDirectory();
8
- const pidFiles = fs.readdirSync(pidsDirectory);
9
- if (pidFiles.length === 0) {
10
- console.log('No running processes found');
11
- return;
12
- }
13
- console.log('Running processes:');
14
- for (const pidFile of pidFiles) {
15
- const pidFilePath = `${pidsDirectory}/${pidFile}`;
16
- const pid = parseInt(fs.readFileSync(pidFilePath, 'utf-8'));
17
- const appName = pidFile.replace('.pid', '');
18
- if (pidIsRunning(pid)) {
19
- console.log(`- ${appName} (pid: ${pid})`);
6
+ try {
7
+ const processManager = new ProcessManager();
8
+ const processes = processManager.listRunningProcesses();
9
+ if (processes.length === 0) {
10
+ console.log('No running processes found');
11
+ return;
20
12
  }
21
- else {
22
- console.warn(`- ${appName} (pid: ${pid}) is not running`);
13
+ console.log('Running processes:');
14
+ for (const process of processes) {
15
+ if (process.isRunning) {
16
+ console.log(`- ${process.name} (pid: ${process.pid})`);
17
+ }
18
+ else {
19
+ console.warn(`- ${process.name} (pid: ${process.pid}) is not running`);
20
+ }
23
21
  }
24
22
  }
23
+ catch (error) {
24
+ const err = error;
25
+ console.error(`Error: ${err.message}`);
26
+ process.exit(1);
27
+ }
25
28
  });
@@ -0,0 +1,65 @@
1
+ import { Command } from 'commander';
2
+ import { ProcessManager, ConfigManager } from '../services/index.js';
3
+ export const restart = new Command('restart')
4
+ .description('restart a running app or all apps')
5
+ .argument('<name>', 'name of the app to restart, "all" to restart all apps, or path to config file')
6
+ .option('--no-daemon', 'do not daemonize the app')
7
+ .option('--clear-logs', 'clear log files before restarting the app')
8
+ .action(async (name, options) => {
9
+ try {
10
+ const processManager = new ProcessManager();
11
+ const configManager = new ConfigManager();
12
+ if (name === 'all') {
13
+ // Get all running processes
14
+ const runningProcesses = processManager.listRunningProcesses();
15
+ if (runningProcesses.length === 0) {
16
+ console.warn('No running processes found');
17
+ return;
18
+ }
19
+ // Stop all processes
20
+ const stoppedCount = await processManager.stopAllApps();
21
+ console.log(`Stopped ${stoppedCount} process(es)`);
22
+ // We need the config to restart, but we don't have it for individual apps
23
+ console.warn('Note: Cannot restart apps without config file. Use "pomitu start <config-file>" to start them again.');
24
+ }
25
+ else {
26
+ // Check if it's a config file path
27
+ let isConfigFile = false;
28
+ try {
29
+ const config = configManager.readConfig(name);
30
+ if (config && config.apps) {
31
+ isConfigFile = true;
32
+ // Restart all apps from the config
33
+ for (const app of config.apps) {
34
+ const success = await processManager.stopApp(app.name);
35
+ if (success) {
36
+ console.log(`Stopped ${app.name}`);
37
+ }
38
+ // Start the app again
39
+ await processManager.startApp(app, {
40
+ daemon: options.daemon,
41
+ clearLogs: options.clearLogs
42
+ });
43
+ }
44
+ }
45
+ }
46
+ catch (error) {
47
+ // Not a config file, treat as app name
48
+ }
49
+ if (!isConfigFile) {
50
+ // Treat as app name - stop it
51
+ const success = await processManager.stopApp(name);
52
+ if (!success) {
53
+ console.warn(`No running process found for ${name}`);
54
+ return;
55
+ }
56
+ console.warn(`Stopped ${name}. To restart, you need to use "pomitu start <config-file>" as app config is not stored.`);
57
+ }
58
+ }
59
+ }
60
+ catch (error) {
61
+ const err = error;
62
+ console.error(`Error: ${err.message}`);
63
+ process.exit(1);
64
+ }
65
+ });
@@ -1,56 +1,36 @@
1
1
  import { Command } from 'commander';
2
- import { spawn } from 'node:child_process';
3
- import { parse } from 'shell-quote';
4
- import { readConfig, getProcessLogOutFilePath, getProcessLogErrorFilePath, getProcessPidFilePath, pidIsRunning, getFileNameFriendlyName, } from '../helpers.js';
5
- import * as fs from 'node:fs';
2
+ import { ProcessManager, ConfigManager } from '../services/index.js';
3
+ import React from 'react';
4
+ import { render } from 'ink';
5
+ import { ProcessTUI } from '../components/ProcessTUI.js';
6
6
  export const start = new Command('start')
7
7
  .description('start and daemonize an app')
8
8
  .argument('<name>', '[name|namespace|file|ecosystem|id...]')
9
- .action((name) => {
10
- const config = readConfig(name);
11
- for (const app of config.apps) {
12
- console.log(`Starting: ${app.name} (${app.cwd})`);
13
- if (!fs.existsSync(app.cwd)) {
14
- console.error(`Directory ${app.cwd} does not exist`);
15
- process.exit(1);
9
+ .option('--no-daemon', 'do not daemonize the app and show interactive TUI')
10
+ .option('--clear-logs', 'clear log files before starting the app')
11
+ .action(async (name, options) => {
12
+ try {
13
+ const configManager = new ConfigManager();
14
+ const processManager = new ProcessManager();
15
+ const config = configManager.readConfig(name);
16
+ configManager.validateConfig(config);
17
+ const runInteractive = options.daemon === false;
18
+ for (const app of config.apps) {
19
+ processManager.startApp(app, {
20
+ daemon: options.daemon,
21
+ clearLogs: options.clearLogs
22
+ });
16
23
  }
17
- const run = parse(app.run);
18
- if (!run.length) {
19
- console.error(`Invalid run command for ${app.name}: ${app.run}`);
20
- process.exit(1);
24
+ if (runInteractive) {
25
+ render(React.createElement(ProcessTUI, {
26
+ configPath: name,
27
+ clearLogs: options.clearLogs
28
+ }));
21
29
  }
22
- const fileNameFriendAppName = getFileNameFriendlyName(app.name);
23
- const existingPidFilePath = getProcessPidFilePath(fileNameFriendAppName);
24
- if (fs.existsSync(existingPidFilePath)) {
25
- const existingPid = parseInt(fs.readFileSync(existingPidFilePath, 'utf-8'));
26
- if (pidIsRunning(existingPid)) {
27
- console.warn(`Process ${app.name} is already running with pid ${existingPid}`);
28
- console.log(`Stopping ${app.name} at pid ${existingPid}`);
29
- try {
30
- process.kill(existingPid);
31
- }
32
- catch (e) {
33
- const error = e;
34
- console.error(`Error stopping ${app.name}: ${error.message}`);
35
- }
36
- }
37
- fs.unlinkSync(existingPidFilePath);
38
- }
39
- const stdout = fs.openSync(getProcessLogOutFilePath(fileNameFriendAppName), 'a');
40
- const stderr = fs.openSync(getProcessLogErrorFilePath(fileNameFriendAppName), 'a');
41
- const startedProcess = spawn(run[0], run.slice(1), {
42
- cwd: app.cwd,
43
- stdio: ['ignore', stdout, stderr],
44
- detached: true,
45
- });
46
- startedProcess.on('error', (error) => {
47
- console.error(`Error starting ${app.name}: ${error.message}`);
48
- process.exit(1);
49
- });
50
- startedProcess.on('spawn', () => {
51
- console.log(`Started: ${app.name} with pid ${startedProcess.pid}`);
52
- });
53
- startedProcess.unref();
54
- fs.writeFileSync(getProcessPidFilePath(fileNameFriendAppName), startedProcess.pid.toString());
30
+ }
31
+ catch (error) {
32
+ const err = error;
33
+ console.error(`Error: ${err.message}`);
34
+ process.exit(1);
55
35
  }
56
36
  });
@@ -1,49 +1,30 @@
1
1
  import { Command } from 'commander';
2
- import * as fs from 'node:fs';
3
- import { getPomituPidsDirectory, getFileNameFriendlyName, pidIsRunning, } from '../helpers.js';
2
+ import { ProcessManager } from '../services/index.js';
4
3
  export const stop = new Command('stop')
5
4
  .description('stop a running app or all apps')
6
5
  .argument('<name>', 'name of the app to stop or "all" to stop all apps')
7
- .action((name) => {
8
- const pidsDirectory = getPomituPidsDirectory();
9
- const pidFiles = fs.readdirSync(pidsDirectory);
10
- if (name === 'all') {
11
- if (pidFiles.length === 0) {
12
- console.warn('No running processes found');
13
- return;
14
- }
15
- for (const pidFile of pidFiles) {
16
- stopProcess(pidFile, name);
17
- }
18
- }
19
- else {
20
- const fileNameFriendlyName = getFileNameFriendlyName(name);
21
- const pidFile = `${fileNameFriendlyName}.pid`;
22
- if (pidFiles.includes(pidFile)) {
23
- stopProcess(pidFile, name);
6
+ .action(async (name) => {
7
+ try {
8
+ const processManager = new ProcessManager();
9
+ if (name === 'all') {
10
+ const stoppedCount = await processManager.stopAllApps();
11
+ if (stoppedCount === 0) {
12
+ console.warn('No running processes found');
13
+ }
14
+ else {
15
+ console.log(`Stopped ${stoppedCount} process(es)`);
16
+ }
24
17
  }
25
18
  else {
26
- console.warn(`No running process found for ${name}`);
27
- }
28
- }
29
- });
30
- function stopProcess(pidFile, appName) {
31
- const pidFilePath = `${getPomituPidsDirectory()}/${pidFile}`;
32
- const pid = parseInt(fs.readFileSync(pidFilePath, 'utf-8'));
33
- if (pidIsRunning(pid)) {
34
- console.log(`Stopping ${appName} with pid ${pid}`);
35
- try {
36
- process.kill(pid);
37
- fs.unlinkSync(pidFilePath);
38
- console.log(`${appName} with pid ${pid} stopped`);
39
- }
40
- catch (e) {
41
- const error = e;
42
- console.error(`Error stopping ${appName} with pid ${pid}: ${error.message}`);
19
+ const success = await processManager.stopApp(name);
20
+ if (!success) {
21
+ console.warn(`No running process found for ${name}`);
22
+ }
43
23
  }
44
24
  }
45
- else {
46
- console.warn(`${appName} with pid ${pid} is not running`);
47
- fs.unlinkSync(pidFilePath);
25
+ catch (error) {
26
+ const err = error;
27
+ console.error(`Error: ${err.message}`);
28
+ process.exit(1);
48
29
  }
49
- }
30
+ });
@@ -0,0 +1,212 @@
1
+ import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
2
+ import readline from 'node:readline';
3
+ import { Box, Text, useApp, useStdin } from 'ink';
4
+ import SelectInput from 'ink-select-input';
5
+ import { ProcessManager, ConfigManager } from '../services/index.js';
6
+ import { getFileNameFriendlyName } from '../helpers.js';
7
+ export function ProcessTUI({ configPath, clearLogs }) {
8
+ const { exit } = useApp();
9
+ const { stdin, setRawMode } = useStdin();
10
+ const [processes, setProcesses] = useState([]);
11
+ const [apps, setApps] = useState([]);
12
+ const [message, setMessage] = useState('');
13
+ const [messageColor, setMessageColor] = useState('green');
14
+ const [isProcessing, setIsProcessing] = useState(false);
15
+ const previousRawModeRef = useRef(false);
16
+ const rawModeCapturedRef = useRef(false);
17
+ // Create managers only once
18
+ const processManager = useMemo(() => new ProcessManager(), []);
19
+ const configManager = useMemo(() => new ConfigManager(), []);
20
+ const cleanExit = useCallback(() => {
21
+ if (setRawMode) {
22
+ setRawMode(previousRawModeRef.current);
23
+ }
24
+ exit();
25
+ process.exit(0);
26
+ }, [exit, setRawMode]);
27
+ const computeStatuses = useCallback(() => {
28
+ if (apps.length === 0) {
29
+ return [];
30
+ }
31
+ const runningProcesses = processManager.listRunningProcesses();
32
+ return apps.map(app => {
33
+ const fileNameFriendlyName = getFileNameFriendlyName(app.name);
34
+ const running = runningProcesses.find(p => p.name === fileNameFriendlyName);
35
+ return {
36
+ name: app.name,
37
+ pid: running?.pid ?? null,
38
+ isRunning: running?.isRunning ?? false
39
+ };
40
+ });
41
+ }, [apps, processManager]);
42
+ // Load config on mount
43
+ useEffect(() => {
44
+ try {
45
+ const config = configManager.readConfig(configPath);
46
+ configManager.validateConfig(config);
47
+ setApps(config.apps);
48
+ }
49
+ catch (error) {
50
+ const err = error;
51
+ setMessage(`Error loading config: ${err.message}`);
52
+ setMessageColor('red');
53
+ }
54
+ }, [configPath, configManager]);
55
+ // Update process status
56
+ useEffect(() => {
57
+ setProcesses(computeStatuses());
58
+ }, [computeStatuses]);
59
+ // Handle keyboard input - use keypress events to intercept BEFORE SelectInput
60
+ useEffect(() => {
61
+ if (!stdin)
62
+ return;
63
+ // Enable keypress events and raw mode for immediate key detection
64
+ readline.emitKeypressEvents(stdin);
65
+ const stream = stdin;
66
+ if (!rawModeCapturedRef.current) {
67
+ const previousRawMode = typeof stream.isRaw === 'boolean' ? stream.isRaw : false;
68
+ previousRawModeRef.current = previousRawMode;
69
+ rawModeCapturedRef.current = true;
70
+ }
71
+ if (setRawMode) {
72
+ setRawMode(true);
73
+ }
74
+ const handleKeypress = (str, key) => {
75
+ if (str === 'q') {
76
+ cleanExit();
77
+ }
78
+ if (key?.ctrl && key.name === 'c') {
79
+ cleanExit();
80
+ }
81
+ };
82
+ // Use prependListener to capture keys before other handlers
83
+ stdin.prependListener('keypress', handleKeypress);
84
+ return () => {
85
+ stdin.removeListener('keypress', handleKeypress);
86
+ if (setRawMode) {
87
+ setRawMode(previousRawModeRef.current);
88
+ }
89
+ };
90
+ }, [stdin, setRawMode, cleanExit]);
91
+ // Handle Ctrl+C signal directly - use prependListener to be first
92
+ useEffect(() => {
93
+ const handleSigInt = () => {
94
+ cleanExit();
95
+ };
96
+ // Use prependListener to ensure we handle SIGINT before Ink does
97
+ process.prependListener('SIGINT', handleSigInt);
98
+ return () => {
99
+ process.removeListener('SIGINT', handleSigInt);
100
+ };
101
+ }, [cleanExit]);
102
+ const handleSelect = useCallback(async (item) => {
103
+ if (isProcessing)
104
+ return; // Prevent multiple simultaneous operations
105
+ const [action, appName] = item.value.split(':');
106
+ const app = apps.find(a => a.name === appName);
107
+ if (!app) {
108
+ setMessage(`App ${appName} not found`);
109
+ setMessageColor('red');
110
+ return;
111
+ }
112
+ setIsProcessing(true);
113
+ try {
114
+ if (action === 'start') {
115
+ await processManager.startApp(app, {
116
+ daemon: false,
117
+ clearLogs: clearLogs ?? false
118
+ });
119
+ setMessage(`Started ${appName}`);
120
+ setMessageColor('green');
121
+ }
122
+ else if (action === 'stop') {
123
+ const success = await processManager.stopApp(appName, { quiet: true });
124
+ if (success) {
125
+ setMessage(`Stopped ${appName}`);
126
+ setMessageColor('green');
127
+ }
128
+ else {
129
+ setMessage(`Failed to stop ${appName}`);
130
+ setMessageColor('red');
131
+ }
132
+ }
133
+ else if (action === 'restart') {
134
+ const success = await processManager.stopApp(appName, { quiet: true });
135
+ if (success) {
136
+ // Wait a bit before restarting
137
+ await new Promise(resolve => setTimeout(resolve, 500));
138
+ await processManager.startApp(app, {
139
+ daemon: false,
140
+ clearLogs: clearLogs ?? false
141
+ });
142
+ setMessage(`Restarted ${appName}`);
143
+ setMessageColor('green');
144
+ }
145
+ else {
146
+ setMessage(`Failed to stop ${appName} for restart`);
147
+ setMessageColor('red');
148
+ }
149
+ }
150
+ setProcesses(computeStatuses());
151
+ }
152
+ catch (error) {
153
+ const err = error;
154
+ setMessage(`Error: ${err.message}`);
155
+ setMessageColor('red');
156
+ }
157
+ finally {
158
+ setIsProcessing(false);
159
+ }
160
+ // Clear message after 3 seconds
161
+ setTimeout(() => setMessage(''), 3000);
162
+ }, [apps, clearLogs, computeStatuses, processManager, isProcessing]);
163
+ const getMenuItems = useCallback(() => {
164
+ const items = [];
165
+ processes.forEach(proc => {
166
+ const statusLabel = proc.isRunning
167
+ ? `🟢 Running (PID: ${proc.pid})`
168
+ : '🔴 Stopped';
169
+ items.push({
170
+ label: `${proc.name.padEnd(40)} ${statusLabel}`,
171
+ value: `info:${proc.name}`
172
+ });
173
+ if (proc.isRunning) {
174
+ items.push({
175
+ label: ` ├─ Stop ${proc.name}`,
176
+ value: `stop:${proc.name}`
177
+ });
178
+ items.push({
179
+ label: ` └─ Restart ${proc.name}`,
180
+ value: `restart:${proc.name}`
181
+ });
182
+ }
183
+ else {
184
+ items.push({
185
+ label: ` └─ Start ${proc.name}`,
186
+ value: `start:${proc.name}`
187
+ });
188
+ }
189
+ });
190
+ return items;
191
+ }, [processes]);
192
+ const items = useMemo(() => getMenuItems(), [getMenuItems]);
193
+ const handleMenuSelect = useCallback((item) => {
194
+ if (item.value === 'separator' || item.value.startsWith('info:')) {
195
+ // Informational rows are read-only
196
+ }
197
+ else {
198
+ handleSelect(item);
199
+ }
200
+ }, [handleSelect]);
201
+ const notificationText = isProcessing ? 'Processing...' : message;
202
+ const notificationColor = isProcessing ? 'yellow' : message ? messageColor : undefined;
203
+ return (React.createElement(Box, { flexDirection: "column" },
204
+ React.createElement(Box, { borderStyle: "round", borderColor: "cyan", padding: 1, marginBottom: 1 },
205
+ React.createElement(Text, { bold: true, color: "cyan" }, "Pomitu Process Manager - Interactive Mode")),
206
+ processes.length > 0 ? (React.createElement(React.Fragment, null,
207
+ React.createElement(Box, { marginBottom: 1 },
208
+ React.createElement(Text, { dimColor: true }, "Use arrow keys to navigate, Enter to select, 'q' or Ctrl+C to quit")),
209
+ React.createElement(SelectInput, { items: items, onSelect: handleMenuSelect, isFocused: !isProcessing }))) : (React.createElement(Text, null, "Loading processes...")),
210
+ React.createElement(Box, { marginTop: 1 },
211
+ React.createElement(Text, { color: notificationColor ?? 'gray' }, notificationText ?? ' '))));
212
+ }
package/dist/helpers.js CHANGED
@@ -1,17 +1,5 @@
1
- import * as fs from 'node:fs';
2
- import * as YAML from 'yaml';
3
- import { configSchema } from './schema.js';
4
1
  import { homedir } from 'node:os';
5
2
  import * as path from 'node:path';
6
- export function readConfig(configFilePath) {
7
- const configParsed = YAML.parse(fs.readFileSync(configFilePath, 'utf8'));
8
- const { success, data: config } = configSchema.safeParse(configParsed);
9
- if (!success) {
10
- console.error('Invalid config file');
11
- process.exit(1);
12
- }
13
- return config;
14
- }
15
3
  export function getPomituDirectory() {
16
4
  const homeDirectory = homedir();
17
5
  return path.join(homeDirectory, '.pomitu');
@@ -31,9 +19,6 @@ export function getProcessLogOutFilePath(name) {
31
19
  export function getProcessLogErrorFilePath(name) {
32
20
  return path.join(getPomituLogsDirectory(), `${name}-error.log`);
33
21
  }
34
- export function getProcessPidFilePath(name) {
35
- return path.join(getPomituPidsDirectory(), `${name}.pid`);
36
- }
37
22
  // From: https://stackoverflow.com/a/21296291/4932305
38
23
  export function pidIsRunning(pid) {
39
24
  try {
@@ -0,0 +1,50 @@
1
+ import * as fs from 'node:fs';
2
+ import * as YAML from 'yaml';
3
+ import { configSchema } from '../schema.js';
4
+ export class ConfigManager {
5
+ readConfig(configFilePath) {
6
+ if (!fs.existsSync(configFilePath)) {
7
+ throw new Error(`Config file not found: ${configFilePath}`);
8
+ }
9
+ try {
10
+ const configContent = fs.readFileSync(configFilePath, 'utf8');
11
+ const configParsed = YAML.parse(configContent);
12
+ const { success, data: config, error } = configSchema.safeParse(configParsed);
13
+ if (!success) {
14
+ throw new Error(`Invalid config file: ${error.message}`);
15
+ }
16
+ return config;
17
+ }
18
+ catch (error) {
19
+ const err = error;
20
+ throw new Error(`Error reading config file: ${err.message}`);
21
+ }
22
+ }
23
+ validateAppConfig(app) {
24
+ if (!app.name?.trim()) {
25
+ throw new Error('App name is required');
26
+ }
27
+ if (!app.cwd?.trim()) {
28
+ throw new Error(`App '${app.name}' must have a valid working directory`);
29
+ }
30
+ if (!app.run?.trim()) {
31
+ throw new Error(`App '${app.name}' must have a valid run command`);
32
+ }
33
+ }
34
+ validateConfig(config) {
35
+ if (!config.apps || !Array.isArray(config.apps)) {
36
+ throw new Error('Config must contain an apps array');
37
+ }
38
+ if (config.apps.length === 0) {
39
+ throw new Error('Config must contain at least one app');
40
+ }
41
+ const appNames = new Set();
42
+ for (const app of config.apps) {
43
+ this.validateAppConfig(app);
44
+ if (appNames.has(app.name)) {
45
+ throw new Error(`Duplicate app name found: ${app.name}`);
46
+ }
47
+ appNames.add(app.name);
48
+ }
49
+ }
50
+ }
@@ -0,0 +1,50 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { getPomituLogsDirectory, getProcessLogOutFilePath, getProcessLogErrorFilePath, getFileNameFriendlyName } from '../helpers.js';
4
+ export class LogManager {
5
+ logsDirectory;
6
+ constructor() {
7
+ this.logsDirectory = getPomituLogsDirectory();
8
+ }
9
+ clearAppLogs(appName) {
10
+ const stdoutPath = getProcessLogOutFilePath(appName);
11
+ const stderrPath = getProcessLogErrorFilePath(appName);
12
+ if (fs.existsSync(stdoutPath)) {
13
+ fs.unlinkSync(stdoutPath);
14
+ }
15
+ if (fs.existsSync(stderrPath)) {
16
+ fs.unlinkSync(stderrPath);
17
+ }
18
+ }
19
+ flushLogs(appName) {
20
+ if (!fs.existsSync(this.logsDirectory)) {
21
+ return [];
22
+ }
23
+ let logs = fs.readdirSync(this.logsDirectory);
24
+ if (appName) {
25
+ const fileNameFriendlyName = getFileNameFriendlyName(appName);
26
+ logs = logs.filter((log) => log.startsWith(fileNameFriendlyName));
27
+ }
28
+ const fullLogPaths = logs.map((log) => path.join(this.logsDirectory, log));
29
+ const flushedFiles = [];
30
+ for (const logFilePath of fullLogPaths) {
31
+ if (fs.existsSync(logFilePath)) {
32
+ console.log(`Flushing ${logFilePath}`);
33
+ fs.unlinkSync(logFilePath);
34
+ flushedFiles.push(logFilePath);
35
+ }
36
+ }
37
+ return flushedFiles;
38
+ }
39
+ getLogFiles(appName) {
40
+ if (!fs.existsSync(this.logsDirectory)) {
41
+ return [];
42
+ }
43
+ let logs = fs.readdirSync(this.logsDirectory);
44
+ if (appName) {
45
+ const fileNameFriendlyName = getFileNameFriendlyName(appName);
46
+ logs = logs.filter((log) => log.startsWith(fileNameFriendlyName));
47
+ }
48
+ return logs.map((log) => path.join(this.logsDirectory, log));
49
+ }
50
+ }
@@ -0,0 +1,41 @@
1
+ import * as fs from 'node:fs';
2
+ import { getPomituPidsDirectory } from '../helpers.js';
3
+ import * as path from 'node:path';
4
+ export class PidManager {
5
+ pidsDirectory;
6
+ constructor() {
7
+ this.pidsDirectory = getPomituPidsDirectory();
8
+ }
9
+ savePid(appName, pid) {
10
+ const pidFilePath = this.getPidFilePath(appName);
11
+ fs.writeFileSync(pidFilePath, pid.toString());
12
+ }
13
+ getPid(appName) {
14
+ const pidFilePath = this.getPidFilePath(appName);
15
+ if (!fs.existsSync(pidFilePath)) {
16
+ return null;
17
+ }
18
+ try {
19
+ const pidContent = fs.readFileSync(pidFilePath, 'utf-8');
20
+ return parseInt(pidContent);
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ }
26
+ removePid(appName) {
27
+ const pidFilePath = this.getPidFilePath(appName);
28
+ if (fs.existsSync(pidFilePath)) {
29
+ fs.unlinkSync(pidFilePath);
30
+ }
31
+ }
32
+ getAllPidFiles() {
33
+ if (!fs.existsSync(this.pidsDirectory)) {
34
+ return [];
35
+ }
36
+ return fs.readdirSync(this.pidsDirectory).filter(file => file.endsWith('.pid'));
37
+ }
38
+ getPidFilePath(appName) {
39
+ return path.join(this.pidsDirectory, `${appName}.pid`);
40
+ }
41
+ }
@@ -0,0 +1,132 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { parse } from 'shell-quote';
3
+ import * as fs from 'node:fs';
4
+ import { getProcessLogOutFilePath, getProcessLogErrorFilePath, pidIsRunning, getFileNameFriendlyName, } from '../helpers.js';
5
+ import { PidManager } from './PidManager.js';
6
+ import { LogManager } from './LogManager.js';
7
+ export class ProcessManager {
8
+ pidManager = new PidManager();
9
+ logManager = new LogManager();
10
+ async startApp(app, options = {}) {
11
+ const isDaemon = options.daemon ?? true;
12
+ if (isDaemon) {
13
+ console.log(`Starting: ${app.name} (${app.cwd})`);
14
+ }
15
+ if (!fs.existsSync(app.cwd)) {
16
+ throw new Error(`Directory ${app.cwd} does not exist`);
17
+ }
18
+ const run = parse(app.run);
19
+ if (!run.length) {
20
+ throw new Error(`Invalid run command for ${app.name}: ${app.run}`);
21
+ }
22
+ const fileNameFriendAppName = getFileNameFriendlyName(app.name);
23
+ // Check if process is already running and stop it
24
+ await this.stopIfRunning(app.name);
25
+ // Clear logs if requested
26
+ if (options.clearLogs) {
27
+ this.logManager.clearAppLogs(fileNameFriendAppName);
28
+ }
29
+ // Start the process
30
+ const process = await this.spawnProcess(app, run, options.daemon ?? true);
31
+ // Save PID
32
+ this.pidManager.savePid(fileNameFriendAppName, process.pid);
33
+ if (isDaemon) {
34
+ console.log(`Started: ${app.name} with pid ${process.pid}`);
35
+ }
36
+ }
37
+ async stopApp(name, options = {}) {
38
+ const quiet = options.quiet ?? false;
39
+ const fileNameFriendlyName = getFileNameFriendlyName(name);
40
+ const pid = this.pidManager.getPid(fileNameFriendlyName);
41
+ if (!pid) {
42
+ return false;
43
+ }
44
+ if (!pidIsRunning(pid)) {
45
+ console.warn(`${name} with pid ${pid} is not running`);
46
+ this.pidManager.removePid(fileNameFriendlyName);
47
+ return false;
48
+ }
49
+ if (!quiet) {
50
+ console.log(`Stopping ${name} with pid ${pid}`);
51
+ }
52
+ try {
53
+ process.kill(pid);
54
+ this.pidManager.removePid(fileNameFriendlyName);
55
+ if (!quiet) {
56
+ console.log(`${name} with pid ${pid} stopped`);
57
+ }
58
+ return true;
59
+ }
60
+ catch (error) {
61
+ const err = error;
62
+ console.error(`Error stopping ${name} with pid ${pid}: ${err.message}`);
63
+ return false;
64
+ }
65
+ }
66
+ async stopAllApps(options = {}) {
67
+ const runningProcesses = this.listRunningProcesses();
68
+ let stoppedCount = 0;
69
+ for (const processInfo of runningProcesses) {
70
+ const success = await this.stopApp(processInfo.name, options);
71
+ if (success) {
72
+ stoppedCount++;
73
+ }
74
+ }
75
+ return stoppedCount;
76
+ }
77
+ listRunningProcesses() {
78
+ const pidFiles = this.pidManager.getAllPidFiles();
79
+ const processes = [];
80
+ for (const pidFile of pidFiles) {
81
+ const appName = pidFile.replace('.pid', '');
82
+ const pid = this.pidManager.getPid(appName);
83
+ if (pid) {
84
+ processes.push({
85
+ name: appName,
86
+ pid,
87
+ isRunning: pidIsRunning(pid)
88
+ });
89
+ }
90
+ }
91
+ return processes;
92
+ }
93
+ async stopIfRunning(appName) {
94
+ const fileNameFriendAppName = getFileNameFriendlyName(appName);
95
+ const existingPid = this.pidManager.getPid(fileNameFriendAppName);
96
+ if (existingPid && pidIsRunning(existingPid)) {
97
+ console.warn(`Process ${appName} is already running with pid ${existingPid}`);
98
+ console.log(`Stopping ${appName} at pid ${existingPid}`);
99
+ try {
100
+ process.kill(existingPid);
101
+ }
102
+ catch (error) {
103
+ const err = error;
104
+ console.error(`Error stopping ${appName}: ${err.message}`);
105
+ }
106
+ }
107
+ this.pidManager.removePid(fileNameFriendAppName);
108
+ }
109
+ async spawnProcess(app, command, daemon) {
110
+ const fileNameFriendAppName = getFileNameFriendlyName(app.name);
111
+ const stdoutPath = getProcessLogOutFilePath(fileNameFriendAppName);
112
+ const stderrPath = getProcessLogErrorFilePath(fileNameFriendAppName);
113
+ const stdout = fs.openSync(stdoutPath, 'a');
114
+ const stderr = fs.openSync(stderrPath, 'a');
115
+ return new Promise((resolve, reject) => {
116
+ const startedProcess = spawn(command[0], command.slice(1), {
117
+ cwd: app.cwd,
118
+ stdio: ['ignore', stdout, stderr],
119
+ detached: daemon,
120
+ });
121
+ startedProcess.on('error', (error) => {
122
+ reject(new Error(`Error starting ${app.name}: ${error.message}`));
123
+ });
124
+ startedProcess.on('spawn', () => {
125
+ if (daemon) {
126
+ startedProcess.unref();
127
+ }
128
+ resolve(startedProcess);
129
+ });
130
+ });
131
+ }
132
+ }
@@ -0,0 +1,4 @@
1
+ export { ProcessManager } from './ProcessManager.js';
2
+ export { PidManager } from './PidManager.js';
3
+ export { LogManager } from './LogManager.js';
4
+ export { ConfigManager } from './ConfigManager.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pomitu",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "description": "Pomitu is a process manager inspired by PM2",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -15,6 +15,7 @@
15
15
  "@eslint/js": "^9.12.0",
16
16
  "@stylistic/eslint-plugin-js": "^2.9.0",
17
17
  "@types/node": "^22.7.4",
18
+ "@types/react": "^19.2.2",
18
19
  "@types/shell-quote": "^1.7.5",
19
20
  "eslint": "^9.12.0",
20
21
  "globals": "^15.10.0",
@@ -24,12 +25,15 @@
24
25
  },
25
26
  "main": "dist/app.js",
26
27
  "bin": {
27
- "pomitu": "./dist/app.js"
28
+ "pomitu": "dist/app.js"
28
29
  },
29
30
  "author": "flawiddsouza",
30
31
  "license": "ISC",
31
32
  "dependencies": {
32
33
  "commander": "^12.1.0",
34
+ "ink": "^6.4.0",
35
+ "ink-select-input": "^6.2.0",
36
+ "react": "^19.2.0",
33
37
  "shell-quote": "^1.8.1",
34
38
  "yaml": "^2.5.1",
35
39
  "zod": "^3.23.8"
package/dist/cli.js DELETED
@@ -1,5 +0,0 @@
1
- #!/usr/bin/env node
2
- "use strict";
3
- Object.defineProperty(exports, "__esModule", { value: true });
4
- const app_js_1 = require("./app.js");
5
- app_js_1.program.parse(process.argv);