port-arranger 0.0.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/.vite/build/index.cjs +2 -0
- package/LICENSE +21 -0
- package/README.md +192 -0
- package/dist/cli/commands/list.d.ts +1 -0
- package/dist/cli/commands/list.js +87 -0
- package/dist/cli/commands/run.d.ts +7 -0
- package/dist/cli/commands/run.js +151 -0
- package/dist/cli/commands/stop.d.ts +5 -0
- package/dist/cli/commands/stop.js +105 -0
- package/dist/cli/commands/ui.d.ts +1 -0
- package/dist/cli/commands/ui.js +29 -0
- package/dist/cli/core/compose-parser.d.ts +56 -0
- package/dist/cli/core/compose-parser.js +184 -0
- package/dist/cli/core/compose-parser.test.d.ts +1 -0
- package/dist/cli/core/compose-parser.test.js +262 -0
- package/dist/cli/core/port-finder.d.ts +2 -0
- package/dist/cli/core/port-finder.js +52 -0
- package/dist/cli/core/port-finder.test.d.ts +1 -0
- package/dist/cli/core/port-finder.test.js +106 -0
- package/dist/cli/core/port-injector.d.ts +3 -0
- package/dist/cli/core/port-injector.js +191 -0
- package/dist/cli/core/port-injector.test.d.ts +1 -0
- package/dist/cli/core/port-injector.test.js +264 -0
- package/dist/cli/core/process-manager.d.ts +8 -0
- package/dist/cli/core/process-manager.js +84 -0
- package/dist/cli/core/process-manager.test.d.ts +1 -0
- package/dist/cli/core/process-manager.test.js +50 -0
- package/dist/cli/core/state.d.ts +10 -0
- package/dist/cli/core/state.js +65 -0
- package/dist/cli/core/state.test.d.ts +1 -0
- package/dist/cli/core/state.test.js +72 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +67 -0
- package/dist/gui/main/index.d.ts +1 -0
- package/dist/gui/main/index.js +60 -0
- package/dist/gui/main/ipc-handlers.d.ts +2 -0
- package/dist/gui/main/ipc-handlers.js +66 -0
- package/dist/gui/main/state-watcher.d.ts +7 -0
- package/dist/gui/main/state-watcher.js +56 -0
- package/dist/gui/preload/index.d.ts +1 -0
- package/dist/gui/preload/index.js +20 -0
- package/dist/gui/renderer/App.d.ts +2 -0
- package/dist/gui/renderer/App.js +44 -0
- package/dist/gui/renderer/components/ProcessItem.d.ts +10 -0
- package/dist/gui/renderer/components/ProcessItem.js +115 -0
- package/dist/gui/renderer/components/ProcessList.d.ts +9 -0
- package/dist/gui/renderer/components/ProcessList.js +64 -0
- package/dist/gui/renderer/components/TitleBar.d.ts +2 -0
- package/dist/gui/renderer/components/TitleBar.js +92 -0
- package/dist/gui/renderer/hooks/useProcesses.d.ts +10 -0
- package/dist/gui/renderer/hooks/useProcesses.js +44 -0
- package/dist/gui/renderer/main.d.ts +1 -0
- package/dist/gui/renderer/main.js +11 -0
- package/dist/shared/types.d.ts +62 -0
- package/dist/shared/types.js +1 -0
- package/package.json +76 -0
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
"use strict";const i=require("electron"),d=require("path"),A=require("chokidar"),h=require("fs/promises"),j=require("child_process"),I=require("os"),M=require("tree-kill"),E=d.join(I.homedir(),".port-arranger","state.json");function N(e){const t=new Map;try{const o=`${["/usr/local/bin","/opt/homebrew/bin","/usr/bin"].join(":")}:${process.env.PATH||""}`,p=j.execSync("docker compose ps -a --format json",{cwd:e,encoding:"utf-8",stdio:["pipe","pipe","pipe"],env:{...process.env,PATH:o},shell:"/bin/zsh"}).trim().split(`
|
|
2
|
+
`).filter(Boolean);for(const r of p)try{const f=JSON.parse(r),g=f.Service||f.Name,y=(f.State||"").toLowerCase()==="running";(!t.has(g)||y)&&t.set(g,y)}catch{}}catch{}return t}let c=null,u=null;const l=new Set,q=3e3;async function m(){var e;try{const t=await h.readFile(E,"utf-8"),o=JSON.parse(t).mappings;for(const n of Object.values(o))if(n.injectionType==="compose"&&((e=n.composePorts)!=null&&e.length)){const p=N(n.cwd);n.composePorts=n.composePorts.map(r=>({...r,running:p.get(r.serviceName)??!1}))}return o}catch{return{}}}async function w(){const e=await m();l.forEach(t=>t(e))}function $(){c||(c=A.watch(E,{persistent:!0,ignoreInitial:!1,awaitWriteFinish:{stabilityThreshold:100,pollInterval:50}}),c.on("add",()=>w()),c.on("change",()=>w()),c.on("unlink",()=>{l.forEach(e=>e({}))}),u=setInterval(()=>{w()},q))}function L(){c&&(c.close(),c=null),u&&(clearInterval(u),u=null),l.clear()}function O(e){return l.add(e),m().then(e),()=>{l.delete(e)}}async function W(){return m()}const b=d.join(I.homedir(),".port-arranger","state.json");async function S(){try{const e=await h.readFile(b,"utf-8");return JSON.parse(e)}catch{return{mappings:{}}}}async function v(e){await h.writeFile(b,JSON.stringify(e,null,2),"utf-8")}function P(e){return new Promise((t,s)=>{M(e,"SIGTERM",o=>{o?s(o):t()})})}function T(e,t){try{j.execSync("docker compose down",{cwd:e,stdio:"pipe"})}catch{M(t,"SIGTERM")}}function H(e){i.ipcMain.handle("stop-process",async(t,s)=>{const o=await S(),n=o.mappings[s];if(!n)throw new Error(`Process not found: ${s}`);n.injectionType==="compose"?T(n.cwd,n.pid):await P(n.pid);const{[s]:p,...r}=o.mappings;await v({...o,mappings:r})}),i.ipcMain.handle("restart-process",async(t,s)=>{const o=await S(),n=o.mappings[s];if(!n)throw new Error(`Process not found: ${s}`);n.injectionType==="compose"?T(n.cwd,n.pid):await P(n.pid);const{[s]:p,...r}=o.mappings;await v({...o,mappings:r})}),i.ipcMain.handle("open-browser",async(t,s)=>{await i.shell.openExternal(`http://localhost:${s}`)}),i.ipcMain.handle("set-always-on-top",async(t,s)=>{e.setAlwaysOnTop(s)}),i.ipcMain.on("minimize-window",()=>{e.minimize()}),i.ipcMain.on("close-window",()=>{e.close()})}let a=null;function _(){a=new i.BrowserWindow({width:410,height:500,minWidth:320,minHeight:300,frame:!1,transparent:!1,resizable:!0,webPreferences:{preload:d.join(__dirname,"preload.cjs"),contextIsolation:!0,nodeIntegration:!1}}),a.loadFile(d.join(__dirname,"../renderer/main_window/index.html")),H(a),$(),i.ipcMain.handle("get-processes",async()=>W());const e=O(t=>{a&&!a.isDestroyed()&&a.webContents.send("processes-update",t)});a.on("closed",()=>{e(),a=null})}i.app.whenReady().then(()=>{_(),i.app.on("activate",()=>{i.BrowserWindow.getAllWindows().length===0&&_()})});i.app.on("window-all-closed",()=>{L(),process.platform!=="darwin"&&i.app.quit()});
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Changmin (Chris) Kang (https://github.com/mindori)
|
|
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
|
|
13
|
+
all 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
|
|
21
|
+
THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# port-arranger
|
|
4
|
+
|
|
5
|
+
**Run multiple dev servers without port conflicts**
|
|
6
|
+
|
|
7
|
+
[Quick Start](#quick-start) · [Usage Guide](#usage-guide) · [Commands](#commands) · [How It Works](#how-it-works)
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
<a href="https://www.npmjs.com/package/port-arranger"><img src="https://img.shields.io/npm/v/port-arranger.svg" alt="npm version"></a>
|
|
11
|
+
<img src="https://img.shields.io/github/license/mindori/port-arranger" alt="license">
|
|
12
|
+
<img src="https://img.shields.io/badge/node-%3E%3D18-brightgreen" alt="node version">
|
|
13
|
+
<img src="https://img.shields.io/badge/AI-Claude-blueviolet" alt="AI powered by Claude">
|
|
14
|
+
</p>
|
|
15
|
+
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<br />
|
|
19
|
+
|
|
20
|
+
<div align="center">
|
|
21
|
+
<img src="assets/demo.gif" alt="port-arranger demo" width="600" />
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<br />
|
|
25
|
+
|
|
26
|
+
## Why port-arranger?
|
|
27
|
+
|
|
28
|
+
### The Problem
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# Terminal 1
|
|
32
|
+
npm run dev # localhost:3000
|
|
33
|
+
|
|
34
|
+
# Terminal 2
|
|
35
|
+
npm run dev # Error: Port 3000 is already in use
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
When running multiple projects simultaneously during development, port conflicts are inevitable. You end up manually changing ports or hunting down processes to kill.
|
|
39
|
+
|
|
40
|
+
### The Solution
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pa run "npm run dev" # Automatically assigns an available port
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
port-arranger wraps your dev server commands and automatically finds and assigns available ports. No more port conflicts, ever.
|
|
47
|
+
|
|
48
|
+
### Key Features
|
|
49
|
+
|
|
50
|
+
| | Feature | Description |
|
|
51
|
+
| ------------------ | ------------------------ | ---------------------------------------------------- |
|
|
52
|
+
| :zap: | **Auto Port Assignment** | Automatically finds and assigns available ports |
|
|
53
|
+
| :mag: | **Framework Detection** | Recognizes Next.js, Vite, Express, FastAPI, and more |
|
|
54
|
+
| :clipboard: | **Process Management** | List, monitor, and stop running servers |
|
|
55
|
+
| :desktop_computer: | **GUI Dashboard** | Visual server management with Electron-based UI |
|
|
56
|
+
|
|
57
|
+
## Requirements
|
|
58
|
+
|
|
59
|
+
- **Node.js 18+**
|
|
60
|
+
|
|
61
|
+
## Quick Start
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
# Install globally
|
|
65
|
+
npm install -g port-arranger
|
|
66
|
+
|
|
67
|
+
# Run your dev server
|
|
68
|
+
pa run "npm run dev"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
That's it! port-arranger handles the rest.
|
|
72
|
+
|
|
73
|
+
## Usage Guide
|
|
74
|
+
|
|
75
|
+
### Step 1: Run a Server
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
# Basic usage
|
|
79
|
+
pa run "npm run dev"
|
|
80
|
+
|
|
81
|
+
# With a custom name
|
|
82
|
+
pa run "npm run dev" --name my-frontend
|
|
83
|
+
|
|
84
|
+
# With a preferred port (falls back to available port if taken)
|
|
85
|
+
pa run "npm run dev" --port 3000
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Step 2: Manage Servers
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
# List all running servers
|
|
92
|
+
pa list
|
|
93
|
+
|
|
94
|
+
# Stop a specific server
|
|
95
|
+
pa stop my-frontend
|
|
96
|
+
|
|
97
|
+
# Stop all servers
|
|
98
|
+
pa stop --all
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Step 3: GUI Dashboard
|
|
102
|
+
|
|
103
|
+
Launch the visual dashboard for an overview of all your running servers:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
pa ui
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Commands
|
|
110
|
+
|
|
111
|
+
| Command | Description | Example |
|
|
112
|
+
| ------------------------- | --------------------------------------- | ---------------------------------- |
|
|
113
|
+
| `pa run <cmd>` | Run a command with auto port assignment | `pa run "npm run dev"` |
|
|
114
|
+
| `pa run <cmd> --name <n>` | Run with a custom project name | `pa run "npm run dev" --name api` |
|
|
115
|
+
| `pa run <cmd> --port <p>` | Run with a preferred port | `pa run "npm run dev" --port 3000` |
|
|
116
|
+
| `pa list` | List all running servers | `pa list` |
|
|
117
|
+
| `pa stop <name>` | Stop a server by name | `pa stop my-project` |
|
|
118
|
+
| `pa stop --all` | Stop all running servers | `pa stop --all` |
|
|
119
|
+
| `pa ui` | Open the GUI dashboard | `pa ui` |
|
|
120
|
+
|
|
121
|
+
## Supported Frameworks
|
|
122
|
+
|
|
123
|
+
| Framework | Port Injection Method | Example |
|
|
124
|
+
| ------------------ | --------------------- | ------------------------------ |
|
|
125
|
+
| Next.js | Environment variable | `PORT=3001 npm run dev` |
|
|
126
|
+
| Vite | CLI flag | `vite --port 3001` |
|
|
127
|
+
| Express | Environment variable | `PORT=3001 node server.js` |
|
|
128
|
+
| FastAPI / uvicorn | CLI flag | `uvicorn main:app --port 3001` |
|
|
129
|
+
| Python http.server | Argument | `python -m http.server 3001` |
|
|
130
|
+
|
|
131
|
+
## How It Works
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
135
|
+
│ pa run "npm run dev" │
|
|
136
|
+
└─────────────────────────────────────────────────────────────┘
|
|
137
|
+
│
|
|
138
|
+
▼
|
|
139
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
140
|
+
│ 1. Parse Command │
|
|
141
|
+
│ Detect framework pattern (Next.js, Vite, Express, etc.) │
|
|
142
|
+
└─────────────────────────────────────────────────────────────┘
|
|
143
|
+
│
|
|
144
|
+
▼
|
|
145
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
146
|
+
│ 2. Find Available Port │
|
|
147
|
+
│ Use detect-port to find an open port │
|
|
148
|
+
└─────────────────────────────────────────────────────────────┘
|
|
149
|
+
│
|
|
150
|
+
▼
|
|
151
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
152
|
+
│ 3. Inject Port │
|
|
153
|
+
│ Apply framework-specific injection (env, flag, arg) │
|
|
154
|
+
└─────────────────────────────────────────────────────────────┘
|
|
155
|
+
│
|
|
156
|
+
▼
|
|
157
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
158
|
+
│ 4. Spawn & Track Process │
|
|
159
|
+
│ Save process info to ~/.port-arranger/state.json │
|
|
160
|
+
└─────────────────────────────────────────────────────────────┘
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Configuration
|
|
164
|
+
|
|
165
|
+
port-arranger stores state in `~/.port-arranger/state.json`:
|
|
166
|
+
|
|
167
|
+
```json
|
|
168
|
+
{
|
|
169
|
+
"processes": [
|
|
170
|
+
{
|
|
171
|
+
"name": "my-frontend",
|
|
172
|
+
"port": 3001,
|
|
173
|
+
"pid": 12345,
|
|
174
|
+
"command": "npm run dev",
|
|
175
|
+
"cwd": "/path/to/project",
|
|
176
|
+
"startedAt": "2024-01-15T10:30:00.000Z"
|
|
177
|
+
}
|
|
178
|
+
]
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Contributing
|
|
183
|
+
|
|
184
|
+
Contributions are welcome! Feel free to:
|
|
185
|
+
|
|
186
|
+
- 🐛 Report bugs
|
|
187
|
+
- 💡 Suggest features
|
|
188
|
+
- 🔧 Submit pull requests
|
|
189
|
+
|
|
190
|
+
## Author
|
|
191
|
+
|
|
192
|
+
[Changmin (Chris) Kang](https://github.com/mindori)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function listCommand(): Promise<void>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { getAllProcesses } from '../core/state.js';
|
|
3
|
+
import { isProcessRunning, getComposeServicesStatus } from '../core/process-manager.js';
|
|
4
|
+
export async function listCommand() {
|
|
5
|
+
const processes = await getAllProcesses();
|
|
6
|
+
const entries = Object.entries(processes);
|
|
7
|
+
if (entries.length === 0) {
|
|
8
|
+
console.log(chalk.gray('No running processes.'));
|
|
9
|
+
console.log(chalk.gray('Start a process with \'pa run "<command>"\''));
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
console.log(chalk.bold('\nRunning processes:\n'));
|
|
13
|
+
// 테이블 헤더
|
|
14
|
+
console.log(chalk.gray(padEnd('Name', 20) +
|
|
15
|
+
padEnd('Port', 8) +
|
|
16
|
+
padEnd('PID', 10) +
|
|
17
|
+
padEnd('Status', 10) +
|
|
18
|
+
'Command'));
|
|
19
|
+
console.log(chalk.gray('─'.repeat(80)));
|
|
20
|
+
for (const [name, mapping] of entries) {
|
|
21
|
+
const running = isProcessRunning(mapping.pid);
|
|
22
|
+
const status = running
|
|
23
|
+
? chalk.green('Running')
|
|
24
|
+
: chalk.red('Stopped');
|
|
25
|
+
const pid = String(mapping.pid);
|
|
26
|
+
const cmd = truncate(mapping.originalCommand, 30);
|
|
27
|
+
// compose인 경우 서비스별로 하위 표시
|
|
28
|
+
if (mapping.injectionType === 'compose' && mapping.composePorts?.length) {
|
|
29
|
+
// 각 서비스의 실제 상태 조회
|
|
30
|
+
const serviceStatuses = await getComposeServicesStatus(mapping.cwd);
|
|
31
|
+
// 실행 중인 서비스 수 계산
|
|
32
|
+
const services = mapping.composePorts;
|
|
33
|
+
const runningCount = services.filter(s => serviceStatuses.get(s.serviceName)).length;
|
|
34
|
+
const totalCount = services.length;
|
|
35
|
+
// 프로젝트 레벨 상태: 항상 N/M 형식으로 표시
|
|
36
|
+
let projectStatus;
|
|
37
|
+
if (runningCount === totalCount) {
|
|
38
|
+
projectStatus = chalk.green(`${runningCount}/${totalCount}`);
|
|
39
|
+
}
|
|
40
|
+
else if (runningCount > 0) {
|
|
41
|
+
projectStatus = chalk.yellow(`${runningCount}/${totalCount}`);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
projectStatus = chalk.red(`${runningCount}/${totalCount}`);
|
|
45
|
+
}
|
|
46
|
+
console.log(padEnd(name, 20) +
|
|
47
|
+
padEnd(chalk.gray('-'), 8) +
|
|
48
|
+
padEnd(pid, 10) +
|
|
49
|
+
padEnd(projectStatus, 10) +
|
|
50
|
+
chalk.gray(cmd));
|
|
51
|
+
// 서비스별 표시 (상태 포함)
|
|
52
|
+
for (let i = 0; i < services.length; i++) {
|
|
53
|
+
const svc = services[i];
|
|
54
|
+
const isLast = i === services.length - 1;
|
|
55
|
+
const prefix = isLast ? ' └─ ' : ' ├─ ';
|
|
56
|
+
const svcRunning = serviceStatuses.get(svc.serviceName) ?? false;
|
|
57
|
+
const svcStatus = svcRunning ? chalk.green('●') : chalk.red('●');
|
|
58
|
+
const portColor = svcRunning ? chalk.cyan : chalk.gray;
|
|
59
|
+
console.log(chalk.gray(prefix) +
|
|
60
|
+
svcStatus + ' ' +
|
|
61
|
+
padEnd(svc.serviceName, 13) +
|
|
62
|
+
portColor(String(svc.port)));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
const port = chalk.cyan(String(mapping.port));
|
|
67
|
+
console.log(padEnd(name, 20) +
|
|
68
|
+
padEnd(port, 8) +
|
|
69
|
+
padEnd(pid, 10) +
|
|
70
|
+
padEnd(status, 10) +
|
|
71
|
+
chalk.gray(cmd));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
console.log();
|
|
75
|
+
}
|
|
76
|
+
function padEnd(str, length) {
|
|
77
|
+
// ANSI 코드 제거 후 길이 계산
|
|
78
|
+
const plainStr = str.replace(/\x1b\[[0-9;]*m/g, '');
|
|
79
|
+
const padding = Math.max(0, length - plainStr.length);
|
|
80
|
+
return str + ' '.repeat(padding);
|
|
81
|
+
}
|
|
82
|
+
function truncate(str, maxLength) {
|
|
83
|
+
if (str.length <= maxLength) {
|
|
84
|
+
return str;
|
|
85
|
+
}
|
|
86
|
+
return str.slice(0, maxLength - 3) + '...';
|
|
87
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { basename } from 'path';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { findAvailablePort } from '../core/port-finder.js';
|
|
4
|
+
import { injectPort, getDefaultPort } from '../core/port-injector.js';
|
|
5
|
+
import { spawnProcess } from '../core/process-manager.js';
|
|
6
|
+
import { addProcess } from '../core/state.js';
|
|
7
|
+
import { parseComposeFile, getAllServicePorts, extractServiceNames, generateOverrideYaml, writeOverrideFile, transformComposeCommand, } from '../core/compose-parser.js';
|
|
8
|
+
export async function runCommand(command, options) {
|
|
9
|
+
const projectName = options.name || basename(process.cwd());
|
|
10
|
+
const cwd = process.cwd();
|
|
11
|
+
// 포트 주입 (패턴 인식)
|
|
12
|
+
const injection = injectPort(command, options.port || 3000);
|
|
13
|
+
// Docker Compose 처리
|
|
14
|
+
if (injection.injectionType === 'compose') {
|
|
15
|
+
await runComposeCommand(command, projectName, cwd, options);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
// 일반 명령어 처리
|
|
19
|
+
await runNormalCommand(command, injection, projectName, cwd, options);
|
|
20
|
+
}
|
|
21
|
+
async function runNormalCommand(command, injection, projectName, cwd, options) {
|
|
22
|
+
const defaultPort = getDefaultPort(command);
|
|
23
|
+
const preferredPort = options.port || defaultPort;
|
|
24
|
+
const availablePort = await findAvailablePort(preferredPort);
|
|
25
|
+
// 포트 재주입 (실제 할당된 포트로)
|
|
26
|
+
const finalInjection = injectPort(command, availablePort);
|
|
27
|
+
console.log(chalk.blue(`[${projectName}]`) + ` Port ${chalk.green(availablePort)} assigned`);
|
|
28
|
+
if (options.dryRun) {
|
|
29
|
+
console.log(chalk.yellow('\n[dry-run] Not actually executing'));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const pid = await spawnProcess(projectName, finalInjection.command, finalInjection.env, cwd);
|
|
33
|
+
const mapping = {
|
|
34
|
+
port: availablePort,
|
|
35
|
+
pid,
|
|
36
|
+
command: finalInjection.command,
|
|
37
|
+
originalCommand: command,
|
|
38
|
+
injectionType: finalInjection.injectionType,
|
|
39
|
+
cwd,
|
|
40
|
+
startedAt: new Date().toISOString(),
|
|
41
|
+
status: 'running',
|
|
42
|
+
};
|
|
43
|
+
await addProcess(projectName, mapping);
|
|
44
|
+
console.log(chalk.green(`\n✓ Process started (PID: ${pid})`));
|
|
45
|
+
console.log(chalk.gray(`http://localhost:${availablePort}`));
|
|
46
|
+
}
|
|
47
|
+
async function runComposeCommand(command, projectName, cwd, options) {
|
|
48
|
+
console.log(chalk.blue(`[${projectName}]`) + ' Docker Compose mode');
|
|
49
|
+
// 1. docker-compose.yml 파싱
|
|
50
|
+
const config = parseComposeFile(cwd);
|
|
51
|
+
const serviceNames = extractServiceNames(command);
|
|
52
|
+
const servicePorts = getAllServicePorts(config, serviceNames.length > 0 ? serviceNames : undefined);
|
|
53
|
+
if (servicePorts.length === 0) {
|
|
54
|
+
console.log(chalk.yellow('No services with exposed ports. Running original command.'));
|
|
55
|
+
if (!options.dryRun) {
|
|
56
|
+
const pid = await spawnProcess(projectName, command, {}, cwd);
|
|
57
|
+
await addProcess(projectName, {
|
|
58
|
+
port: 0,
|
|
59
|
+
pid,
|
|
60
|
+
command,
|
|
61
|
+
originalCommand: command,
|
|
62
|
+
injectionType: 'compose',
|
|
63
|
+
cwd,
|
|
64
|
+
startedAt: new Date().toISOString(),
|
|
65
|
+
status: 'running',
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
// 2. 각 서비스의 각 포트에 대해 사용 가능한 포트 할당
|
|
71
|
+
const allocatedPorts = new Map();
|
|
72
|
+
const portSummary = [];
|
|
73
|
+
for (const service of servicePorts) {
|
|
74
|
+
const allocated = [];
|
|
75
|
+
for (const port of service.ports) {
|
|
76
|
+
const newHostPort = await findAvailablePort(port.hostPort);
|
|
77
|
+
allocated.push({
|
|
78
|
+
...port,
|
|
79
|
+
originalHostPort: port.hostPort,
|
|
80
|
+
newHostPort,
|
|
81
|
+
});
|
|
82
|
+
if (port.hostPort !== newHostPort) {
|
|
83
|
+
portSummary.push(` ${chalk.cyan(service.serviceName)}: ${chalk.yellow(port.hostPort)} → ${chalk.green(newHostPort)}`);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
portSummary.push(` ${chalk.cyan(service.serviceName)}: ${chalk.green(port.hostPort)}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
allocatedPorts.set(service.serviceName, allocated);
|
|
90
|
+
}
|
|
91
|
+
// 3. 포트 충돌이 있는 경우에만 override 파일 생성
|
|
92
|
+
const hasConflict = Array.from(allocatedPorts.values())
|
|
93
|
+
.flat()
|
|
94
|
+
.some(p => p.originalHostPort !== p.newHostPort);
|
|
95
|
+
let finalCommand = command;
|
|
96
|
+
if (hasConflict) {
|
|
97
|
+
const overrideYaml = generateOverrideYaml(allocatedPorts);
|
|
98
|
+
const overridePath = writeOverrideFile(cwd, overrideYaml);
|
|
99
|
+
finalCommand = transformComposeCommand(command, overridePath);
|
|
100
|
+
console.log(chalk.gray(`Override 파일 생성: ${overridePath}`));
|
|
101
|
+
}
|
|
102
|
+
// 4. 포트 할당 결과 출력
|
|
103
|
+
console.log(chalk.blue('\nPort assignments:'));
|
|
104
|
+
for (const line of portSummary) {
|
|
105
|
+
console.log(line);
|
|
106
|
+
}
|
|
107
|
+
if (options.dryRun) {
|
|
108
|
+
console.log(chalk.yellow('\n[dry-run] Not actually executing'));
|
|
109
|
+
console.log(chalk.gray(`Command: ${finalCommand}`));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
// 5. 프로세스 실행
|
|
113
|
+
const pid = await spawnProcess(projectName, finalCommand, {}, cwd);
|
|
114
|
+
// 6. 상태 저장 (첫 번째 서비스의 첫 번째 포트를 대표 포트로 사용)
|
|
115
|
+
const firstService = servicePorts[0];
|
|
116
|
+
const firstAllocated = allocatedPorts.get(firstService.serviceName)?.[0];
|
|
117
|
+
const representativePort = firstAllocated?.newHostPort || 0;
|
|
118
|
+
// compose 서비스별 포트 정보 수집
|
|
119
|
+
const composePorts = [];
|
|
120
|
+
for (const service of servicePorts) {
|
|
121
|
+
const allocated = allocatedPorts.get(service.serviceName);
|
|
122
|
+
if (allocated && allocated.length > 0) {
|
|
123
|
+
composePorts.push({
|
|
124
|
+
serviceName: service.serviceName,
|
|
125
|
+
port: allocated[0].newHostPort,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const mapping = {
|
|
130
|
+
port: representativePort,
|
|
131
|
+
pid,
|
|
132
|
+
command: finalCommand,
|
|
133
|
+
originalCommand: command,
|
|
134
|
+
injectionType: 'compose',
|
|
135
|
+
cwd,
|
|
136
|
+
startedAt: new Date().toISOString(),
|
|
137
|
+
status: 'running',
|
|
138
|
+
composePorts,
|
|
139
|
+
};
|
|
140
|
+
await addProcess(projectName, mapping);
|
|
141
|
+
console.log(chalk.green(`\n✓ Docker Compose started (PID: ${pid})`));
|
|
142
|
+
// 각 서비스별 URL 출력
|
|
143
|
+
for (const service of servicePorts) {
|
|
144
|
+
const allocated = allocatedPorts.get(service.serviceName);
|
|
145
|
+
if (allocated) {
|
|
146
|
+
for (const port of allocated) {
|
|
147
|
+
console.log(chalk.gray(`${service.serviceName}: http://localhost:${port.newHostPort}`));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { execSync } from 'child_process';
|
|
3
|
+
import { getAllProcesses, removeProcess, getProcess } from '../core/state.js';
|
|
4
|
+
import { killProcess, isProcessRunning } from '../core/process-manager.js';
|
|
5
|
+
export async function stopCommand(name, options) {
|
|
6
|
+
if (options.all) {
|
|
7
|
+
await stopAllProcesses();
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
if (!name) {
|
|
11
|
+
console.log(chalk.red('Specify a process name or use --all option.'));
|
|
12
|
+
console.log(chalk.gray('Usage: pa stop <name> or pa stop --all'));
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
await stopSingleProcess(name);
|
|
16
|
+
}
|
|
17
|
+
async function stopComposeProcess(name, mapping) {
|
|
18
|
+
const { cwd, pid } = mapping;
|
|
19
|
+
try {
|
|
20
|
+
execSync('docker compose down', {
|
|
21
|
+
cwd,
|
|
22
|
+
stdio: 'pipe',
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// compose down 실패 시 fallback으로 프로세스 kill
|
|
27
|
+
if (isProcessRunning(pid)) {
|
|
28
|
+
await killProcess(pid);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async function stopSingleProcess(name) {
|
|
33
|
+
const mapping = await getProcess(name);
|
|
34
|
+
if (!mapping) {
|
|
35
|
+
console.log(chalk.red(`Process '${name}' not found.`));
|
|
36
|
+
console.log(chalk.gray("Use 'pa list' to see running processes."));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const { pid } = mapping;
|
|
40
|
+
// Docker Compose인 경우 다른 방식으로 종료
|
|
41
|
+
if (mapping.injectionType === 'compose') {
|
|
42
|
+
try {
|
|
43
|
+
await stopComposeProcess(name, mapping);
|
|
44
|
+
await removeProcess(name);
|
|
45
|
+
console.log(chalk.green(`✓ ${name} (Docker Compose stopped)`));
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
console.log(chalk.red(`Failed to stop Docker Compose: ${error.message}`));
|
|
49
|
+
}
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (!isProcessRunning(pid)) {
|
|
53
|
+
console.log(chalk.yellow(`Process '${name}' already stopped.`));
|
|
54
|
+
await removeProcess(name);
|
|
55
|
+
console.log(chalk.gray('Removed from state'));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
await killProcess(pid);
|
|
60
|
+
await removeProcess(name);
|
|
61
|
+
console.log(chalk.green(`✓ Process '${name}' stopped (PID: ${pid})`));
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
console.log(chalk.red(`Failed to stop process: ${error.message}`));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async function stopAllProcesses() {
|
|
68
|
+
const processes = await getAllProcesses();
|
|
69
|
+
const entries = Object.entries(processes);
|
|
70
|
+
if (entries.length === 0) {
|
|
71
|
+
console.log(chalk.gray('No processes to stop.'));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
console.log(chalk.blue(`Stopping ${entries.length} processes...`));
|
|
75
|
+
let successCount = 0;
|
|
76
|
+
let failCount = 0;
|
|
77
|
+
for (const [name, mapping] of entries) {
|
|
78
|
+
try {
|
|
79
|
+
if (mapping.injectionType === 'compose') {
|
|
80
|
+
await stopComposeProcess(name, mapping);
|
|
81
|
+
console.log(chalk.green(` ✓ ${name} (Docker Compose stopped)`));
|
|
82
|
+
}
|
|
83
|
+
else if (isProcessRunning(mapping.pid)) {
|
|
84
|
+
await killProcess(mapping.pid);
|
|
85
|
+
console.log(chalk.green(` ✓ ${name} (PID: ${mapping.pid})`));
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
console.log(chalk.gray(` - ${name} (already stopped)`));
|
|
89
|
+
}
|
|
90
|
+
await removeProcess(name);
|
|
91
|
+
successCount++;
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
console.log(chalk.red(` ✗ ${name}: ${error.message}`));
|
|
95
|
+
failCount++;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
console.log();
|
|
99
|
+
if (failCount === 0) {
|
|
100
|
+
console.log(chalk.green(`✓ All processes stopped (${successCount})`));
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
console.log(chalk.yellow(`Complete: ${successCount} succeeded, ${failCount} failed`));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function uiCommand(): Promise<void>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import { join, dirname } from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
6
|
+
const __dirname = dirname(__filename);
|
|
7
|
+
export async function uiCommand() {
|
|
8
|
+
// Electron 실행 파일 경로 찾기
|
|
9
|
+
const projectRoot = join(__dirname, '..', '..', '..');
|
|
10
|
+
const electronPath = join(projectRoot, 'node_modules', '.bin', 'electron');
|
|
11
|
+
if (!existsSync(electronPath)) {
|
|
12
|
+
throw new Error('Electron is not installed.\n' +
|
|
13
|
+
'Run npm install and try again.');
|
|
14
|
+
}
|
|
15
|
+
// Vite 빌드 결과물 확인
|
|
16
|
+
const mainPath = join(projectRoot, '.vite', 'build', 'index.cjs');
|
|
17
|
+
if (!existsSync(mainPath)) {
|
|
18
|
+
throw new Error('GUI is not built.\n' +
|
|
19
|
+
'Run npm run build:gui and try again.');
|
|
20
|
+
}
|
|
21
|
+
// Electron 프로세스를 detached 모드로 실행
|
|
22
|
+
const child = spawn(electronPath, [mainPath], {
|
|
23
|
+
detached: true,
|
|
24
|
+
stdio: 'ignore',
|
|
25
|
+
cwd: projectRoot,
|
|
26
|
+
});
|
|
27
|
+
child.unref();
|
|
28
|
+
console.log('Port Arranger GUI launched.');
|
|
29
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { ComposePortMapping, ComposeServicePorts, AllocatedComposePort } from '../../shared/types.js';
|
|
2
|
+
interface ComposeService {
|
|
3
|
+
ports?: (string | number | PortObject)[];
|
|
4
|
+
[key: string]: unknown;
|
|
5
|
+
}
|
|
6
|
+
interface PortObject {
|
|
7
|
+
target: number;
|
|
8
|
+
published?: number | string;
|
|
9
|
+
protocol?: 'tcp' | 'udp';
|
|
10
|
+
}
|
|
11
|
+
interface ComposeConfig {
|
|
12
|
+
services?: Record<string, ComposeService>;
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* 포트 문자열을 파싱하여 호스트/컨테이너 포트 추출
|
|
17
|
+
* 지원 형식:
|
|
18
|
+
* - "3000" → 3000:3000
|
|
19
|
+
* - "3000:8000" → 3000:8000
|
|
20
|
+
* - "127.0.0.1:3000:8000" → 3000:8000 (IP 무시)
|
|
21
|
+
* - "3000:8000/tcp" → 3000:8000 (tcp)
|
|
22
|
+
*/
|
|
23
|
+
export declare function parsePortString(portStr: string | number): ComposePortMapping;
|
|
24
|
+
/**
|
|
25
|
+
* docker-compose.yml 파일을 파싱
|
|
26
|
+
*/
|
|
27
|
+
export declare function parseComposeFile(cwd: string, filename?: string): ComposeConfig;
|
|
28
|
+
/**
|
|
29
|
+
* 서비스 이름 목록 추출 (명령어에서 특정 서비스가 지정된 경우)
|
|
30
|
+
*/
|
|
31
|
+
export declare function extractServiceNames(command: string): string[];
|
|
32
|
+
/**
|
|
33
|
+
* 특정 서비스의 포트 매핑 추출
|
|
34
|
+
*/
|
|
35
|
+
export declare function extractServicePorts(config: ComposeConfig, serviceName: string): ComposePortMapping[];
|
|
36
|
+
/**
|
|
37
|
+
* 모든 서비스의 포트 매핑 추출
|
|
38
|
+
*/
|
|
39
|
+
export declare function getAllServicePorts(config: ComposeConfig, serviceNames?: string[]): ComposeServicePorts[];
|
|
40
|
+
/**
|
|
41
|
+
* Override YAML 파일 생성
|
|
42
|
+
*/
|
|
43
|
+
export declare function generateOverrideYaml(allocatedPorts: Map<string, AllocatedComposePort[]>): string;
|
|
44
|
+
/**
|
|
45
|
+
* Override 파일 작성
|
|
46
|
+
*/
|
|
47
|
+
export declare function writeOverrideFile(cwd: string, content: string): string;
|
|
48
|
+
/**
|
|
49
|
+
* docker compose 명령어를 override 파일을 포함하도록 변환
|
|
50
|
+
*/
|
|
51
|
+
export declare function transformComposeCommand(originalCommand: string, overridePath: string): string;
|
|
52
|
+
/**
|
|
53
|
+
* Override 파일 경로 반환
|
|
54
|
+
*/
|
|
55
|
+
export declare function getOverrideFilePath(cwd: string): string;
|
|
56
|
+
export {};
|