dev-prism 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +192 -0
- package/bin/dev-prism.js +399 -0
- package/dist/chunk-25WQHUYW.js +46 -0
- package/dist/chunk-GWDGC2OE.js +116 -0
- package/dist/chunk-LEHA65A7.js +59 -0
- package/dist/chunk-PJKUD2N2.js +22 -0
- package/dist/chunk-QSG5CXPX.js +171 -0
- package/dist/chunk-SMFAL2VP.js +69 -0
- package/dist/chunk-VR3QWHHB.js +57 -0
- package/dist/chunk-XUWQUDLT.js +67 -0
- package/dist/commands/claude.d.ts +6 -0
- package/dist/commands/claude.js +105 -0
- package/dist/commands/create.d.ts +10 -0
- package/dist/commands/create.js +11 -0
- package/dist/commands/destroy.d.ts +6 -0
- package/dist/commands/destroy.js +9 -0
- package/dist/commands/list.d.ts +3 -0
- package/dist/commands/list.js +10 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +30 -0
- package/dist/lib/config.d.ts +14 -0
- package/dist/lib/config.js +10 -0
- package/dist/lib/docker.d.ts +12 -0
- package/dist/lib/docker.js +14 -0
- package/dist/lib/env.d.ts +7 -0
- package/dist/lib/env.js +10 -0
- package/dist/lib/ports.d.ts +6 -0
- package/dist/lib/ports.js +8 -0
- package/dist/lib/worktree.d.ts +16 -0
- package/dist/lib/worktree.js +16 -0
- package/package.json +55 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Emil Bryggare
|
|
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,192 @@
|
|
|
1
|
+
# dev-prism
|
|
2
|
+
|
|
3
|
+
A minimal CLI tool for managing isolated parallel development sessions. Enables multiple Claude Code (or human developer) sessions to work on the same repo simultaneously with complete isolation.
|
|
4
|
+
|
|
5
|
+
## Philosophy
|
|
6
|
+
|
|
7
|
+
**Minimal orchestration, maximal Docker Compose.** This tool does the bare minimum:
|
|
8
|
+
1. Creates git worktrees for isolated working directories
|
|
9
|
+
2. Generates `.env.session` with calculated ports
|
|
10
|
+
3. Runs `docker compose` commands
|
|
11
|
+
|
|
12
|
+
All Docker configuration lives in `docker-compose.session.yml` in your project - a standard file you control.
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- **Git worktrees** for isolated working directories
|
|
17
|
+
- **Docker Compose** handles all container orchestration
|
|
18
|
+
- **Unique ports** per session (calculated from session ID)
|
|
19
|
+
- **Two modes**: Docker (apps in containers) or Native (apps run locally)
|
|
20
|
+
- **Portable**: Works with any project
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install -g dev-prism
|
|
26
|
+
# or
|
|
27
|
+
pnpm add -D dev-prism
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
### Create a session
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# Docker mode (default) - apps run in containers
|
|
36
|
+
dev-prism create 001
|
|
37
|
+
|
|
38
|
+
# Native mode - only infrastructure in Docker, apps run via pnpm dev
|
|
39
|
+
dev-prism create 001 --mode=native
|
|
40
|
+
|
|
41
|
+
# In-place mode - use current directory instead of creating worktree
|
|
42
|
+
dev-prism create 001 --in-place
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**Note:** In-place sessions are not shown in `dev-prism list` (which only lists worktree-based sessions). Use `dev-prism info` from within an in-place session directory to see its details.
|
|
46
|
+
|
|
47
|
+
### List sessions
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
dev-prism list
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Session info (for current directory)
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
dev-prism info
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Start/Stop services
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
dev-prism stop 001 # Stop without destroying
|
|
63
|
+
dev-prism start 001 # Start again
|
|
64
|
+
dev-prism stop-all # Stop all sessions
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### View logs
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
dev-prism logs 001
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Destroy a session
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
dev-prism destroy 001 # Destroy specific session
|
|
77
|
+
dev-prism destroy --all # Destroy all sessions
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Port Allocation
|
|
81
|
+
|
|
82
|
+
Formula: `port = portBase + (sessionId * 100) + offset`
|
|
83
|
+
|
|
84
|
+
With base port 47000:
|
|
85
|
+
|
|
86
|
+
| Service | Session 001 | Session 002 | Session 003 |
|
|
87
|
+
|----------------|-------------|-------------|-------------|
|
|
88
|
+
| CONVAS_APP_PORT| 47100 | 47200 | 47300 |
|
|
89
|
+
| CONVAS_WEB_PORT| 47101 | 47201 | 47301 |
|
|
90
|
+
| POSTGRES_PORT | 47110 | 47210 | 47310 |
|
|
91
|
+
| MAILPIT_SMTP | 47111 | 47211 | 47311 |
|
|
92
|
+
| MAILPIT_WEB | 47112 | 47212 | 47312 |
|
|
93
|
+
|
|
94
|
+
## Configuration
|
|
95
|
+
|
|
96
|
+
### session.config.mjs (minimal)
|
|
97
|
+
|
|
98
|
+
```javascript
|
|
99
|
+
export default {
|
|
100
|
+
portBase: 47000,
|
|
101
|
+
sessionsDir: '../my-project-sessions',
|
|
102
|
+
|
|
103
|
+
// Port offsets - become env vars for docker-compose
|
|
104
|
+
ports: {
|
|
105
|
+
POSTGRES_PORT: 10,
|
|
106
|
+
REDIS_PORT: 11,
|
|
107
|
+
APP_PORT: 0,
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
// Optional: app-specific env for CLI commands from host
|
|
111
|
+
appEnv: {
|
|
112
|
+
'apps/my-app': {
|
|
113
|
+
DATABASE_URL: 'postgresql://postgres:postgres@localhost:${POSTGRES_PORT}/postgres',
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
setup: ['pnpm install', 'pnpm db:push'],
|
|
118
|
+
};
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### docker-compose.session.yml (standard Docker Compose)
|
|
122
|
+
|
|
123
|
+
```yaml
|
|
124
|
+
services:
|
|
125
|
+
postgres:
|
|
126
|
+
image: postgres:16
|
|
127
|
+
container_name: postgres-${SESSION_ID}
|
|
128
|
+
ports:
|
|
129
|
+
- "${POSTGRES_PORT}:5432"
|
|
130
|
+
environment:
|
|
131
|
+
POSTGRES_USER: postgres
|
|
132
|
+
POSTGRES_PASSWORD: postgres
|
|
133
|
+
healthcheck:
|
|
134
|
+
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
|
135
|
+
interval: 5s
|
|
136
|
+
timeout: 5s
|
|
137
|
+
retries: 5
|
|
138
|
+
|
|
139
|
+
my-app:
|
|
140
|
+
profiles: ["apps"] # Only runs in docker mode
|
|
141
|
+
build:
|
|
142
|
+
context: .
|
|
143
|
+
dockerfile: apps/my-app/Dockerfile.dev
|
|
144
|
+
container_name: my-app-${SESSION_ID}
|
|
145
|
+
ports:
|
|
146
|
+
- "${APP_PORT}:3000"
|
|
147
|
+
environment:
|
|
148
|
+
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/postgres
|
|
149
|
+
depends_on:
|
|
150
|
+
postgres:
|
|
151
|
+
condition: service_healthy
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## How It Works
|
|
155
|
+
|
|
156
|
+
1. **Create session**: `dev-prism create 001`
|
|
157
|
+
- Creates git worktree at `../project-sessions/session-001`
|
|
158
|
+
- Generates `.env.session` with calculated ports
|
|
159
|
+
- Runs `docker compose --env-file .env.session up -d`
|
|
160
|
+
- Runs setup commands
|
|
161
|
+
|
|
162
|
+
2. **Docker Compose** reads `.env.session` and substitutes `${VAR}` placeholders
|
|
163
|
+
|
|
164
|
+
3. **Docker mode** (`--profile apps`): All services including apps run in containers
|
|
165
|
+
4. **Native mode**: Only infrastructure runs; apps use `pnpm dev` with `.env.session`
|
|
166
|
+
|
|
167
|
+
## Generated Files
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
session-001/
|
|
171
|
+
├── .env.session # Port variables for docker-compose
|
|
172
|
+
├── docker-compose.session.yml # (from git, not generated)
|
|
173
|
+
└── apps/my-app/.env.session # App-specific env for host CLI
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Example `.env.session`:
|
|
177
|
+
```bash
|
|
178
|
+
SESSION_ID=001
|
|
179
|
+
POSTGRES_PORT=47110
|
|
180
|
+
MAILPIT_SMTP_PORT=47111
|
|
181
|
+
MAILPIT_WEB_PORT=47112
|
|
182
|
+
CONVAS_APP_PORT=47100
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Portability
|
|
186
|
+
|
|
187
|
+
To use in another project:
|
|
188
|
+
|
|
189
|
+
1. Install: `pnpm add -D dev-prism`
|
|
190
|
+
2. Create `session.config.mjs` with port offsets
|
|
191
|
+
3. Create `docker-compose.session.yml` with `${VAR}` placeholders
|
|
192
|
+
4. Run `dev-prism create 001`
|
package/bin/dev-prism.js
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { createSession } from '../dist/commands/create.js';
|
|
5
|
+
import { destroySession } from '../dist/commands/destroy.js';
|
|
6
|
+
import { listSessions } from '../dist/commands/list.js';
|
|
7
|
+
import { installClaude } from '../dist/commands/claude.js';
|
|
8
|
+
|
|
9
|
+
const program = new Command();
|
|
10
|
+
|
|
11
|
+
program
|
|
12
|
+
.name('dev-prism')
|
|
13
|
+
.description('CLI tool for managing isolated parallel development sessions')
|
|
14
|
+
.version('0.1.0');
|
|
15
|
+
|
|
16
|
+
program
|
|
17
|
+
.command('create [sessionId]')
|
|
18
|
+
.description('Create a new isolated development session')
|
|
19
|
+
.option('-m, --mode <mode>', 'App mode: docker (default) or native', 'docker')
|
|
20
|
+
.option('-b, --branch <branch>', 'Git branch name (default: session/YYYY-MM-DD/XXX)')
|
|
21
|
+
.option('-W, --without <apps>', 'Exclude apps (comma-separated: app,web,widget)', (val) => val.split(','))
|
|
22
|
+
.option('--no-detach', 'Stream container logs after starting (default: detach)')
|
|
23
|
+
.option('--in-place', 'Run in current directory instead of creating a worktree')
|
|
24
|
+
.action(async (sessionId, options) => {
|
|
25
|
+
const projectRoot = process.cwd();
|
|
26
|
+
await createSession(projectRoot, sessionId, {
|
|
27
|
+
mode: options.mode,
|
|
28
|
+
branch: options.branch,
|
|
29
|
+
detach: options.detach,
|
|
30
|
+
without: options.without,
|
|
31
|
+
inPlace: options.inPlace,
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
program
|
|
36
|
+
.command('destroy [sessionId]')
|
|
37
|
+
.description('Destroy a development session')
|
|
38
|
+
.option('-a, --all', 'Destroy all sessions')
|
|
39
|
+
.action(async (sessionId, options) => {
|
|
40
|
+
const projectRoot = process.cwd();
|
|
41
|
+
await destroySession(projectRoot, sessionId, { all: options.all });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
program
|
|
45
|
+
.command('list')
|
|
46
|
+
.description('List all active development sessions')
|
|
47
|
+
.action(async () => {
|
|
48
|
+
const projectRoot = process.cwd();
|
|
49
|
+
await listSessions(projectRoot);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
program
|
|
53
|
+
.command('info')
|
|
54
|
+
.description('Show session info for current directory (useful for --in-place sessions)')
|
|
55
|
+
.action(async () => {
|
|
56
|
+
const cwd = process.cwd();
|
|
57
|
+
const chalk = (await import('chalk')).default;
|
|
58
|
+
const { existsSync, readFileSync } = await import('node:fs');
|
|
59
|
+
const { resolve } = await import('node:path');
|
|
60
|
+
const docker = await import('../dist/lib/docker.js');
|
|
61
|
+
|
|
62
|
+
const envFile = resolve(cwd, '.env.session');
|
|
63
|
+
if (!existsSync(envFile)) {
|
|
64
|
+
console.log(chalk.yellow('No .env.session found in current directory.'));
|
|
65
|
+
console.log(chalk.gray('Run `dev-prism create --in-place` to create a session here.'));
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Parse .env.session
|
|
70
|
+
const envContent = readFileSync(envFile, 'utf-8');
|
|
71
|
+
const env = {};
|
|
72
|
+
for (const line of envContent.split('\n')) {
|
|
73
|
+
const match = line.match(/^([^=]+)=(.*)$/);
|
|
74
|
+
if (match) {
|
|
75
|
+
env[match[1]] = match[2];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const sessionId = env.SESSION_ID || 'unknown';
|
|
80
|
+
const running = await docker.isRunning({ cwd });
|
|
81
|
+
|
|
82
|
+
console.log(chalk.blue(`\nSession ${sessionId}`));
|
|
83
|
+
console.log(chalk.gray(`Directory: ${cwd}`));
|
|
84
|
+
console.log(running ? chalk.green('Status: running') : chalk.yellow('Status: stopped'));
|
|
85
|
+
|
|
86
|
+
console.log(chalk.gray('\nPorts:'));
|
|
87
|
+
for (const [key, value] of Object.entries(env)) {
|
|
88
|
+
if (key.includes('PORT')) {
|
|
89
|
+
console.log(chalk.gray(` ${key}: ${value}`));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
console.log(chalk.gray('\nURLs:'));
|
|
94
|
+
for (const [key, value] of Object.entries(env)) {
|
|
95
|
+
if (key.includes('APP') || key.includes('WEB') || key.includes('WIDGET')) {
|
|
96
|
+
if (key.includes('PORT')) {
|
|
97
|
+
console.log(chalk.cyan(` ${key.replace('_PORT', '')}: http://localhost:${value}`));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
console.log('');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
program
|
|
105
|
+
.command('start <sessionId>')
|
|
106
|
+
.description('Start Docker services for a session')
|
|
107
|
+
.option('-m, --mode <mode>', 'App mode: docker or native', 'docker')
|
|
108
|
+
.option('-W, --without <apps>', 'Exclude apps (comma-separated: app,web,widget)', (val) => val.split(','))
|
|
109
|
+
.action(async (sessionId, options) => {
|
|
110
|
+
const projectRoot = process.cwd();
|
|
111
|
+
const { loadConfig, getSessionDir } = await import('../dist/lib/config.js');
|
|
112
|
+
const docker = await import('../dist/lib/docker.js');
|
|
113
|
+
|
|
114
|
+
const config = await loadConfig(projectRoot);
|
|
115
|
+
const sessionDir = getSessionDir(config, projectRoot, sessionId);
|
|
116
|
+
let profiles;
|
|
117
|
+
if (options.mode === 'docker') {
|
|
118
|
+
const allApps = config.apps ?? ['app', 'web', 'widget'];
|
|
119
|
+
const excludeApps = options.without ?? [];
|
|
120
|
+
profiles = allApps.filter((app) => !excludeApps.includes(app));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
await docker.up({ cwd: sessionDir, profiles });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
program
|
|
127
|
+
.command('stop <sessionId>')
|
|
128
|
+
.description('Stop Docker services for a session (without destroying)')
|
|
129
|
+
.action(async (sessionId) => {
|
|
130
|
+
const projectRoot = process.cwd();
|
|
131
|
+
const { loadConfig, getSessionDir } = await import('../dist/lib/config.js');
|
|
132
|
+
const { execa } = await import('execa');
|
|
133
|
+
|
|
134
|
+
const config = await loadConfig(projectRoot);
|
|
135
|
+
const sessionDir = getSessionDir(config, projectRoot, sessionId);
|
|
136
|
+
|
|
137
|
+
// Use stop instead of down to preserve volumes
|
|
138
|
+
await execa(
|
|
139
|
+
'docker',
|
|
140
|
+
['compose', '-f', 'docker-compose.session.yml', '--env-file', '.env.session', 'stop'],
|
|
141
|
+
{ cwd: sessionDir, stdio: 'inherit' }
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
program
|
|
146
|
+
.command('logs <sessionId>')
|
|
147
|
+
.description('Stream logs from a session\'s Docker services')
|
|
148
|
+
.option('-m, --mode <mode>', 'App mode: docker or native', 'docker')
|
|
149
|
+
.option('-W, --without <apps>', 'Exclude apps (comma-separated: app,web,widget)', (val) => val.split(','))
|
|
150
|
+
.option('-n, --tail <lines>', 'Number of lines to show from the end', '50')
|
|
151
|
+
.action(async (sessionId, options) => {
|
|
152
|
+
const projectRoot = process.cwd();
|
|
153
|
+
const { loadConfig, getSessionDir } = await import('../dist/lib/config.js');
|
|
154
|
+
const { execa } = await import('execa');
|
|
155
|
+
|
|
156
|
+
const config = await loadConfig(projectRoot);
|
|
157
|
+
const sessionDir = getSessionDir(config, projectRoot, sessionId);
|
|
158
|
+
let profileFlags = [];
|
|
159
|
+
if (options.mode === 'docker') {
|
|
160
|
+
const allApps = config.apps ?? ['app', 'web', 'widget'];
|
|
161
|
+
const excludeApps = options.without ?? [];
|
|
162
|
+
const profiles = allApps.filter((app) => !excludeApps.includes(app));
|
|
163
|
+
profileFlags = profiles.flatMap((p) => ['--profile', p]);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const args = [
|
|
167
|
+
'compose',
|
|
168
|
+
'-f', 'docker-compose.session.yml',
|
|
169
|
+
'--env-file', '.env.session',
|
|
170
|
+
...profileFlags,
|
|
171
|
+
'logs',
|
|
172
|
+
'-f',
|
|
173
|
+
'--tail', options.tail,
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
await execa('docker', args, { cwd: sessionDir, stdio: 'inherit' });
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
program
|
|
180
|
+
.command('stop-all')
|
|
181
|
+
.description('Stop all running sessions (preserves data)')
|
|
182
|
+
.action(async () => {
|
|
183
|
+
const projectRoot = process.cwd();
|
|
184
|
+
const chalk = (await import('chalk')).default;
|
|
185
|
+
const { loadConfig, getSessionDir } = await import('../dist/lib/config.js');
|
|
186
|
+
const { getSessionWorktrees } = await import('../dist/lib/worktree.js');
|
|
187
|
+
const docker = await import('../dist/lib/docker.js');
|
|
188
|
+
const { existsSync } = await import('node:fs');
|
|
189
|
+
const { resolve } = await import('node:path');
|
|
190
|
+
|
|
191
|
+
const config = await loadConfig(projectRoot);
|
|
192
|
+
const sessions = await getSessionWorktrees(projectRoot);
|
|
193
|
+
|
|
194
|
+
if (sessions.length === 0) {
|
|
195
|
+
console.log(chalk.gray('No sessions found.'));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Find running sessions
|
|
200
|
+
const runningSessions = [];
|
|
201
|
+
for (const session of sessions) {
|
|
202
|
+
const envFile = resolve(session.path, '.env.session');
|
|
203
|
+
if (existsSync(envFile)) {
|
|
204
|
+
const running = await docker.isRunning({ cwd: session.path });
|
|
205
|
+
if (running) {
|
|
206
|
+
runningSessions.push(session);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (runningSessions.length === 0) {
|
|
212
|
+
console.log(chalk.gray('No running sessions found.'));
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
console.log(chalk.blue(`Stopping ${runningSessions.length} running session(s)...\n`));
|
|
217
|
+
|
|
218
|
+
// Get all app profiles and service names to ensure we stop everything
|
|
219
|
+
const allApps = config.apps ?? ['app', 'web', 'widget'];
|
|
220
|
+
const profileFlags = allApps.flatMap((p) => ['--profile', p]);
|
|
221
|
+
// Explicitly list all services to stop (infrastructure + apps)
|
|
222
|
+
const allServices = ['postgres', 'mailpit', 'convas-app', 'convas-web', 'convas-widget'];
|
|
223
|
+
|
|
224
|
+
const { execa } = await import('execa');
|
|
225
|
+
for (const session of runningSessions) {
|
|
226
|
+
console.log(chalk.gray(` Stopping session ${session.sessionId}...`));
|
|
227
|
+
try {
|
|
228
|
+
await execa(
|
|
229
|
+
'docker',
|
|
230
|
+
['compose', '-f', 'docker-compose.session.yml', '--env-file', '.env.session', ...profileFlags, 'stop', ...allServices],
|
|
231
|
+
{ cwd: session.path, stdio: 'pipe' }
|
|
232
|
+
);
|
|
233
|
+
console.log(chalk.green(` Session ${session.sessionId} stopped.`));
|
|
234
|
+
} catch (error) {
|
|
235
|
+
console.log(chalk.yellow(` Warning: Could not stop session ${session.sessionId}`));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
console.log(chalk.green(`\nStopped ${runningSessions.length} session(s).`));
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
program
|
|
243
|
+
.command('prune')
|
|
244
|
+
.description('Remove all stopped sessions (destroys data)')
|
|
245
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
246
|
+
.action(async (options) => {
|
|
247
|
+
const projectRoot = process.cwd();
|
|
248
|
+
const chalk = (await import('chalk')).default;
|
|
249
|
+
const { loadConfig } = await import('../dist/lib/config.js');
|
|
250
|
+
const { getSessionWorktrees, removeWorktree } = await import('../dist/lib/worktree.js');
|
|
251
|
+
const docker = await import('../dist/lib/docker.js');
|
|
252
|
+
const { existsSync } = await import('node:fs');
|
|
253
|
+
const { resolve } = await import('node:path');
|
|
254
|
+
const readline = await import('node:readline');
|
|
255
|
+
|
|
256
|
+
const config = await loadConfig(projectRoot);
|
|
257
|
+
const sessions = await getSessionWorktrees(projectRoot);
|
|
258
|
+
|
|
259
|
+
if (sessions.length === 0) {
|
|
260
|
+
console.log(chalk.gray('No sessions found.'));
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Find stopped sessions
|
|
265
|
+
const stoppedSessions = [];
|
|
266
|
+
for (const session of sessions) {
|
|
267
|
+
const envFile = resolve(session.path, '.env.session');
|
|
268
|
+
let running = false;
|
|
269
|
+
if (existsSync(envFile)) {
|
|
270
|
+
running = await docker.isRunning({ cwd: session.path });
|
|
271
|
+
}
|
|
272
|
+
if (!running) {
|
|
273
|
+
stoppedSessions.push(session);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (stoppedSessions.length === 0) {
|
|
278
|
+
console.log(chalk.gray('No stopped sessions to prune.'));
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
console.log(chalk.yellow(`\nFound ${stoppedSessions.length} stopped session(s) to prune:`));
|
|
283
|
+
for (const session of stoppedSessions) {
|
|
284
|
+
console.log(chalk.gray(` - Session ${session.sessionId} (${session.branch})`));
|
|
285
|
+
}
|
|
286
|
+
console.log('');
|
|
287
|
+
|
|
288
|
+
// Confirm unless --yes flag provided
|
|
289
|
+
if (!options.yes) {
|
|
290
|
+
const rl = readline.createInterface({
|
|
291
|
+
input: process.stdin,
|
|
292
|
+
output: process.stdout,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const answer = await new Promise((resolve) => {
|
|
296
|
+
rl.question(chalk.red('Are you sure you want to delete these sessions? This cannot be undone. [y/N] '), resolve);
|
|
297
|
+
});
|
|
298
|
+
rl.close();
|
|
299
|
+
|
|
300
|
+
if (answer.toLowerCase() !== 'y' && answer.toLowerCase() !== 'yes') {
|
|
301
|
+
console.log(chalk.gray('Cancelled.'));
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
console.log(chalk.blue('\nPruning stopped sessions...\n'));
|
|
307
|
+
|
|
308
|
+
for (const session of stoppedSessions) {
|
|
309
|
+
console.log(chalk.gray(` Removing session ${session.sessionId}...`));
|
|
310
|
+
try {
|
|
311
|
+
// Clean up any docker resources
|
|
312
|
+
const envFile = resolve(session.path, '.env.session');
|
|
313
|
+
if (existsSync(envFile)) {
|
|
314
|
+
try {
|
|
315
|
+
await docker.down({ cwd: session.path });
|
|
316
|
+
} catch {
|
|
317
|
+
// Ignore errors - containers might already be removed
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// Remove worktree and branch
|
|
321
|
+
await removeWorktree(projectRoot, session.path, session.branch);
|
|
322
|
+
console.log(chalk.green(` Session ${session.sessionId} removed.`));
|
|
323
|
+
} catch (error) {
|
|
324
|
+
console.log(chalk.yellow(` Warning: Could not fully remove session ${session.sessionId}`));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
console.log(chalk.green(`\nPruned ${stoppedSessions.length} session(s).`));
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
program
|
|
332
|
+
.command('claude')
|
|
333
|
+
.description('Install Claude Code integration (skill + CLAUDE.md)')
|
|
334
|
+
.option('-f, --force', 'Overwrite existing files')
|
|
335
|
+
.action(async (options) => {
|
|
336
|
+
await installClaude(process.cwd(), { force: options.force });
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
program
|
|
340
|
+
.command('help')
|
|
341
|
+
.description('Show detailed help and examples')
|
|
342
|
+
.action(async () => {
|
|
343
|
+
const chalk = (await import('chalk')).default;
|
|
344
|
+
|
|
345
|
+
console.log(`
|
|
346
|
+
${chalk.bold('dev-prism')} - Manage isolated parallel development sessions
|
|
347
|
+
|
|
348
|
+
${chalk.bold('USAGE')}
|
|
349
|
+
dev-prism <command> [options]
|
|
350
|
+
|
|
351
|
+
${chalk.bold('COMMANDS')}
|
|
352
|
+
${chalk.cyan('create')} [id] Create a new session (auto-assigns ID if not provided)
|
|
353
|
+
${chalk.cyan('destroy')} <id> Destroy a specific session
|
|
354
|
+
${chalk.cyan('list')} List all sessions and their status
|
|
355
|
+
${chalk.cyan('info')} Show session info for current directory
|
|
356
|
+
${chalk.cyan('start')} <id> Start Docker services for a stopped session
|
|
357
|
+
${chalk.cyan('stop')} <id> Stop Docker services (preserves data)
|
|
358
|
+
${chalk.cyan('stop-all')} Stop all running sessions
|
|
359
|
+
${chalk.cyan('logs')} <id> Stream logs from a session
|
|
360
|
+
${chalk.cyan('prune')} Remove all stopped sessions
|
|
361
|
+
|
|
362
|
+
${chalk.bold('EXAMPLES')}
|
|
363
|
+
${chalk.gray('# Create a new session (auto-assigns next available ID)')}
|
|
364
|
+
$ dev-prism create
|
|
365
|
+
|
|
366
|
+
${chalk.gray('# Create session with specific branch')}
|
|
367
|
+
$ dev-prism create --branch feature/my-feature
|
|
368
|
+
|
|
369
|
+
${chalk.gray('# Create session in native mode (apps run on host)')}
|
|
370
|
+
$ dev-prism create --mode native
|
|
371
|
+
|
|
372
|
+
${chalk.gray('# Create session without web app')}
|
|
373
|
+
$ dev-prism create --without web
|
|
374
|
+
|
|
375
|
+
${chalk.gray('# Create session in current directory (no worktree)')}
|
|
376
|
+
$ dev-prism create --in-place
|
|
377
|
+
|
|
378
|
+
${chalk.gray('# Check session status in current directory')}
|
|
379
|
+
$ dev-prism info
|
|
380
|
+
|
|
381
|
+
${chalk.gray('# Stop all running sessions before switching context')}
|
|
382
|
+
$ dev-prism stop-all
|
|
383
|
+
|
|
384
|
+
${chalk.gray('# Clean up old stopped sessions')}
|
|
385
|
+
$ dev-prism prune
|
|
386
|
+
|
|
387
|
+
${chalk.gray('# Destroy all sessions')}
|
|
388
|
+
$ dev-prism destroy --all
|
|
389
|
+
|
|
390
|
+
${chalk.bold('SESSION MODES')}
|
|
391
|
+
${chalk.cyan('docker')} (default) All apps run in containers
|
|
392
|
+
${chalk.cyan('native')} Only infrastructure in Docker, apps on host
|
|
393
|
+
|
|
394
|
+
${chalk.bold('MORE INFO')}
|
|
395
|
+
Run ${chalk.cyan('dev-prism <command> --help')} for command-specific options
|
|
396
|
+
`);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
program.parse();
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// src/lib/config.ts
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import { resolve } from "path";
|
|
4
|
+
import { pathToFileURL } from "url";
|
|
5
|
+
var DEFAULT_CONFIG = {
|
|
6
|
+
portBase: 47e3,
|
|
7
|
+
sessionsDir: "../sessions",
|
|
8
|
+
ports: {
|
|
9
|
+
POSTGRES_PORT: 10
|
|
10
|
+
},
|
|
11
|
+
setup: ["pnpm install"]
|
|
12
|
+
};
|
|
13
|
+
async function loadConfig(projectRoot) {
|
|
14
|
+
let configPath = resolve(projectRoot, "session.config.mjs");
|
|
15
|
+
if (!existsSync(configPath)) {
|
|
16
|
+
configPath = resolve(projectRoot, "session.config.js");
|
|
17
|
+
}
|
|
18
|
+
if (!existsSync(configPath)) {
|
|
19
|
+
console.warn("No session.config.mjs found, using defaults");
|
|
20
|
+
return DEFAULT_CONFIG;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const configUrl = pathToFileURL(configPath).href;
|
|
24
|
+
const module = await import(configUrl);
|
|
25
|
+
const userConfig = module.default || module;
|
|
26
|
+
return {
|
|
27
|
+
...DEFAULT_CONFIG,
|
|
28
|
+
...userConfig
|
|
29
|
+
};
|
|
30
|
+
} catch (error) {
|
|
31
|
+
console.error(`Failed to load config from ${configPath}:`, error);
|
|
32
|
+
return DEFAULT_CONFIG;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function getSessionsDir(config, projectRoot) {
|
|
36
|
+
return resolve(projectRoot, config.sessionsDir);
|
|
37
|
+
}
|
|
38
|
+
function getSessionDir(config, projectRoot, sessionId) {
|
|
39
|
+
return resolve(getSessionsDir(config, projectRoot), `session-${sessionId}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export {
|
|
43
|
+
loadConfig,
|
|
44
|
+
getSessionsDir,
|
|
45
|
+
getSessionDir
|
|
46
|
+
};
|