-
-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathdevctl.py
More file actions
executable file
·761 lines (625 loc) · 26.3 KB
/
devctl.py
File metadata and controls
executable file
·761 lines (625 loc) · 26.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
#!/usr/bin/env python
"""
Development process controller for TWICC.
Manages frontend (npm run dev) and backend (uv run ./run.py) processes
as independent background daemons with logging.
Data directory resolution:
1. In a git worktree: forced to the worktree root (PROJECT_ROOT)
2. TWICC_DATA_DIR environment variable (if set)
3. Default: ~/.twicc/
The .env file (ports, password hash, etc.) is read from the data directory.
The backend process receives TWICC_DATA_DIR so it uses the same paths.
"""
import glob
import os
import re
import shutil
import signal
import socket
import subprocess
import sys
import time
from pathlib import Path
# Paths
PROJECT_ROOT = Path(__file__).resolve().parent
DEVCTL_DIR = PROJECT_ROOT / ".devctl"
PIDS_DIR = DEVCTL_DIR / "pids"
# Default ports
DEFAULT_BACKEND_PORT = 3500
DEFAULT_FRONTEND_PORT = 5173
# Default data directory (same as twicc.paths)
DEFAULT_DATA_DIR = Path.home() / ".twicc"
TWICC_DATA_DIR_ENV = "TWICC_DATA_DIR"
def purge_claude_code_vars(env: dict) -> None:
"""Remove Claude Code environment variables from *env* in-place."""
for key in list(env):
if key.startswith(("CLAUDE_CODE", "CLAUDECODE")):
del env[key]
def is_git_worktree() -> bool:
"""Detect if we're running inside a git worktree (not the main working tree).
Compares git-dir (per-worktree) with git-common-dir (shared).
In the main worktree they resolve to the same path; in a secondary
worktree, git-dir points to .git/worktrees/<name>.
"""
try:
git_dir = subprocess.check_output(
["git", "rev-parse", "--git-dir"],
cwd=PROJECT_ROOT,
stderr=subprocess.DEVNULL,
).decode().strip()
common_dir = subprocess.check_output(
["git", "rev-parse", "--git-common-dir"],
cwd=PROJECT_ROOT,
stderr=subprocess.DEVNULL,
).decode().strip()
git_dir_resolved = os.path.realpath(os.path.join(str(PROJECT_ROOT), git_dir))
common_dir_resolved = os.path.realpath(os.path.join(str(PROJECT_ROOT), common_dir))
return git_dir_resolved != common_dir_resolved
except (subprocess.CalledProcessError, FileNotFoundError):
return False
def get_data_dir() -> Path:
"""Resolve the data directory for this devctl instance.
Priority:
1. Git worktree detected → PROJECT_ROOT (always forced, no override)
2. TWICC_DATA_DIR environment variable (if set)
3. Default → ~/.twicc/
"""
if is_git_worktree():
return PROJECT_ROOT
env_value = os.environ.get(TWICC_DATA_DIR_ENV, "").strip()
if env_value:
return Path(env_value).resolve()
return DEFAULT_DATA_DIR
# Resolve once at module level
DATA_DIR = get_data_dir()
ENV_FILE = DATA_DIR / ".env"
LOGS_DIR = DATA_DIR / "logs"
def find_available_port(start: int, max_attempts: int = 100) -> int:
"""Find an available port by incrementing from start.
Tries start, start+1, start+2, ... until a free port is found.
Raises RuntimeError if no port is available within max_attempts.
"""
for offset in range(max_attempts):
port = start + offset
if port > 65535:
break
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", port))
return port
except OSError:
continue
raise RuntimeError(
f"Could not find an available port starting from {start} "
f"(tried {max_attempts} ports)"
)
def save_ports_to_env(backend_port: int, frontend_port: int) -> None:
"""Append port configuration to the .env file in the data directory."""
lines_to_add = [
"",
"# Ports auto-configured by devctl (worktree mode)",
f"TWICC_PORT={backend_port}",
f"VITE_PORT={frontend_port}",
"",
]
with open(ENV_FILE, "a") as f:
f.write("\n".join(lines_to_add))
def copy_data_from_main() -> bool:
"""Copy the database and search index from the main data directory to the worktree.
Copies data.sqlite and any WAL/SHM files, plus the search-index/ directory.
Only called in worktree mode when the local database doesn't exist yet.
Both are copied together (all or nothing) to keep them in sync.
Returns True if the copy succeeded (or source doesn't exist), False on error.
"""
source_db = DEFAULT_DATA_DIR / "db" / "data.sqlite"
target_db_dir = DATA_DIR / "db"
target_db = target_db_dir / "data.sqlite"
if target_db.exists():
return True # Already have local data
if not source_db.exists():
print(" No main database found, starting fresh")
return True # No source to copy, Django migrate will create it
# Copy database
print(f" Copying database from {source_db.parent}...", end=" ", flush=True)
# Ensure target directory exists
target_db_dir.mkdir(parents=True, exist_ok=True)
# Copy data.sqlite and any associated files (-wal, -shm)
source_pattern = str(source_db) + "*"
copied_files = []
for source_file in glob.glob(source_pattern):
filename = os.path.basename(source_file)
shutil.copy2(source_file, target_db_dir / filename)
copied_files.append(filename)
print("OK")
print(f" Files: {', '.join(copied_files)}")
# Copy search index (if it exists in the main data dir)
source_search = DEFAULT_DATA_DIR / "search-index"
target_search = DATA_DIR / "search-index"
if source_search.exists() and not target_search.exists():
print(f" Copying search index from {source_search}...", end=" ", flush=True)
shutil.copytree(source_search, target_search)
print("OK")
return True
def clear_local_data() -> None:
"""Delete the local database and search index in the worktree.
Removes data.sqlite and any WAL/SHM files, plus the search-index/ directory,
so the next start creates a fresh empty database and rebuilds the search index.
"""
# Clear database
target_db = DATA_DIR / "db" / "data.sqlite"
if target_db.exists():
removed_files = []
for db_file in glob.glob(str(target_db) + "*"):
os.remove(db_file)
removed_files.append(os.path.basename(db_file))
print(f" Cleared local database: {', '.join(removed_files)}")
# Clear search index
target_search = DATA_DIR / "search-index"
if target_search.exists():
shutil.rmtree(target_search)
print(" Cleared local search index")
def load_env_file() -> dict[str, str]:
"""Load environment variables from .env file in the data directory."""
env_vars = {}
if ENV_FILE.exists():
with open(ENV_FILE) as f:
for line in f:
line = line.strip()
# Skip empty lines and comments
if not line or line.startswith("#"):
continue
# Parse KEY=VALUE (strip optional quotes around value)
if "=" in line:
key, _, value = line.partition("=")
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
value = value[1:-1]
env_vars[key.strip()] = value
return env_vars
def get_ports(auto_find: bool = False) -> tuple[int, int]:
"""Get backend and frontend ports from .env file or defaults.
If auto_find is True and we're in a worktree, automatically find
available ports (incrementing from defaults) and save them to .env.
This avoids port conflicts when running multiple worktrees.
"""
env_vars = load_env_file()
backend_port = DEFAULT_BACKEND_PORT
frontend_port = DEFAULT_FRONTEND_PORT
has_backend_port = "TWICC_PORT" in env_vars
has_frontend_port = "VITE_PORT" in env_vars
if has_backend_port:
try:
backend_port = int(env_vars["TWICC_PORT"])
except ValueError:
print(f"Warning: Invalid TWICC_PORT in .env, using default {DEFAULT_BACKEND_PORT}")
if has_frontend_port:
try:
frontend_port = int(env_vars["VITE_PORT"])
except ValueError:
print(f"Warning: Invalid VITE_PORT in .env, using default {DEFAULT_FRONTEND_PORT}")
# In worktree mode, auto-find available ports if not already configured
if auto_find and is_git_worktree() and (not has_backend_port or not has_frontend_port):
if not has_backend_port:
backend_port = find_available_port(DEFAULT_BACKEND_PORT + 1)
if not has_frontend_port:
frontend_port = find_available_port(DEFAULT_FRONTEND_PORT + 1)
save_ports_to_env(backend_port, frontend_port)
print(f" Auto-configured ports for worktree: backend={backend_port}, frontend={frontend_port}")
print(f" Saved to {ENV_FILE}")
return backend_port, frontend_port
def get_process_config(backend_port: int, frontend_port: int) -> dict:
"""Build process configuration with the given port settings."""
env_vars = load_env_file()
dev_hostname = env_vars.get("TWICC_DEV_HOSTNAME", "")
frontend_env = {
"BACKEND_PORT": str(backend_port),
}
if dev_hostname:
frontend_env["DEV_HOSTNAME"] = dev_hostname
return {
"front": {
"name": "Frontend (Vite)",
"cmd": ["npm", "run", "dev", "--", "--port", str(frontend_port)],
"cwd": PROJECT_ROOT / "frontend",
"log": LOGS_DIR / "frontend.log",
"pid": PIDS_DIR / "frontend.pid",
"port": frontend_port,
"env": frontend_env,
},
"back": {
"name": "Backend (Django)",
"cmd": ["uv", "run", "./run.py"],
"cwd": PROJECT_ROOT,
"log": LOGS_DIR / "backend.log",
"pid": PIDS_DIR / "backend.pid",
"port": backend_port,
"env": {
"TWICC_PORT": str(backend_port),
TWICC_DATA_DIR_ENV: str(DATA_DIR),
"TWICC_DEBUG": "1",
# In worktree mode, use a distinct session cookie name to avoid
# conflicts with the main instance (browsers share cookies across ports).
**({"TWICC_SESSION_COOKIE": f"sessionid_{backend_port}"} if is_git_worktree() else {}),
},
},
}
def ensure_dirs():
"""Create directory structure for PIDs and logs."""
PIDS_DIR.mkdir(parents=True, exist_ok=True)
LOGS_DIR.mkdir(parents=True, exist_ok=True)
def is_running(proc_key: str, processes: dict) -> tuple[bool, int | None]:
"""Check if a process is running. Returns (is_running, pid)."""
pid_file = processes[proc_key]["pid"]
if not pid_file.exists():
return False, None
pid = int(pid_file.read_text().strip())
try:
os.kill(pid, 0) # Signal 0 just checks if process exists
return True, pid
except OSError:
# Process doesn't exist, clean up stale pid file
pid_file.unlink()
return False, None
def verify_port(proc_key: str, log_start_pos: int, processes: dict, timeout: float = 5.0) -> bool:
"""Verify that process started on the expected port by checking NEW log lines only."""
config = processes[proc_key]
expected_port = config.get("port")
if not expected_port:
return True # No port check needed
log_file = config["log"]
start_time = time.time()
# Pattern to match the port in logs
# Frontend: "Local: http://localhost:5173/"
# Backend: "Uvicorn running on http://0.0.0.0:3500"
port_patterns = [
rf"localhost:{expected_port}",
rf"0\.0\.0\.0:{expected_port}",
rf"127\.0\.0\.1:{expected_port}",
]
while time.time() - start_time < timeout:
if log_file.exists():
# Only read NEW content since process started
with open(log_file) as f:
f.seek(log_start_pos)
new_content = f.read()
if new_content:
for pattern in port_patterns:
if re.search(pattern, new_content):
return True
# Check if wrong port was used (Vite fallback)
wrong_port_match = re.search(r"localhost:(\d+)/", new_content)
if wrong_port_match:
actual_port = int(wrong_port_match.group(1))
if actual_port != expected_port:
print(f" WARNING: Started on port {actual_port} instead of {expected_port}!")
print(f" Port {expected_port} may be in use. Check with: lsof -i :{expected_port}")
return False
time.sleep(0.3)
print(f" WARNING: Could not verify port {expected_port} (timeout)")
return False
def npm_install(processes: dict) -> bool:
"""Run npm install in the frontend directory if needed."""
frontend_dir = processes["front"]["cwd"]
node_modules = frontend_dir / "node_modules"
if node_modules.exists():
return True
print(" Installing frontend dependencies (npm install)...", end=" ", flush=True)
result = subprocess.run(
["npm", "install"],
cwd=frontend_dir,
capture_output=True,
text=True,
)
if result.returncode != 0:
print("FAILED")
print(f" {result.stderr.strip()}")
return False
print("OK")
return True
def start(proc_key: str, processes: dict) -> bool:
"""Start a process as a detached daemon."""
config = processes[proc_key]
running, pid = is_running(proc_key, processes)
if running:
print(f" {config['name']}: already running (PID {pid})")
return True
ensure_dirs()
# Ensure frontend dependencies are installed before starting Vite
if proc_key == "front":
if not npm_install(processes):
return False
# Remember log file position before starting (to only check new lines)
log_start_pos = 0
if config["log"].exists():
log_start_pos = config["log"].stat().st_size
# Prepare environment with custom variables
proc_env = os.environ.copy()
# Purge Claude Code environment variables so the backend/frontend processes
# don't inherit them (e.g., when devctl is launched from within Claude Code).
# CLAUDE_CODE_ENTRYPOINT in particular causes Claude Code to think it's
# already running inside an SDK session, preventing interactive use.
purge_claude_code_vars(proc_env)
# In worktree mode, purge inherited TWICC_* variables so the child
# process only sees values from the worktree's .env (loaded by run.py).
# Without this, variables like TWICC_PASSWORD_HASH from the parent
# shell would leak into the backend and override the worktree config.
if is_git_worktree():
for key in list(proc_env):
if key.startswith("TWICC_"):
del proc_env[key]
if "env" in config:
proc_env.update(config["env"])
# Backend: stdout/stderr → DEVNULL (logs go via Python logging FileHandler)
# Frontend: stdout/stderr → log file (Vite has no Python logger)
if proc_key == "back":
stdout_target = subprocess.DEVNULL
log_file_handle = None
else:
log_file_handle = open(config["log"], "a")
stdout_target = log_file_handle
# Start detached process
# stdin must be redirected to DEVNULL to prevent child processes
# (especially Vite's readline interface for CLI shortcuts) from
# modifying the parent terminal settings. Without this, killing
# the process leaves the terminal in a corrupted state (raw mode)
# because readline doesn't get a chance to restore terminal settings.
proc = subprocess.Popen(
config["cmd"],
cwd=config["cwd"],
stdin=subprocess.DEVNULL,
stdout=stdout_target,
stderr=subprocess.STDOUT,
env=proc_env,
start_new_session=True, # Detach from parent
)
# Close the log file handle in the parent process (if opened);
# the child has its own copy of the file descriptor
if log_file_handle is not None:
log_file_handle.close()
# Save PID
config["pid"].write_text(str(proc.pid))
print(f" {config['name']}: started (PID {proc.pid})")
print(f" Logs: {config['log']}")
# Verify correct port
expected_port = config.get("port")
if expected_port:
print(f" Verifying port {expected_port}...", end=" ", flush=True)
if verify_port(proc_key, log_start_pos, processes):
print("OK")
else:
print("FAILED")
return False
return True
def stop(proc_key: str, processes: dict) -> bool:
"""Stop a process and all its children (process group)."""
config = processes[proc_key]
running, pid = is_running(proc_key, processes)
if not running:
print(f" {config['name']}: not running")
return True
try:
# Send SIGTERM to the entire process group (negative PID)
# This kills npm AND its child processes (node/vite)
os.killpg(pid, signal.SIGTERM)
print(f" {config['name']}: stopped (was PID {pid})")
config["pid"].unlink(missing_ok=True)
return True
except OSError as e:
# Fallback: try killing just the process if group kill fails
try:
os.kill(pid, signal.SIGTERM)
print(f" {config['name']}: stopped (was PID {pid})")
config["pid"].unlink(missing_ok=True)
return True
except OSError:
print(f" {config['name']}: failed to stop - {e}")
return False
def status(processes: dict):
"""Show status of all processes."""
backend_port = processes["back"]["port"]
frontend_port = processes["front"]["port"]
print(f"Data directory: {DATA_DIR}")
if is_git_worktree():
print(" (git worktree detected, using project root)")
elif os.environ.get(TWICC_DATA_DIR_ENV, "").strip():
print(f" (from ${TWICC_DATA_DIR_ENV})")
else:
print(" (default)")
print()
print(f"Port configuration: frontend={frontend_port}, backend={backend_port}")
if ENV_FILE.exists():
print(f" (from {ENV_FILE})")
else:
print(" (defaults, no .env file)")
print()
print("Process status:")
for key, config in processes.items():
running, pid = is_running(key, processes)
if running:
print(f" {config['name']}: running (PID {pid}) on port {config['port']}")
else:
print(f" {config['name']}: stopped")
def logs(proc_key: str, processes: dict, lines: int = 50):
"""Show last N lines of logs for a process."""
config = processes[proc_key]
log_file = config["log"]
if not log_file.exists():
print(f" No logs found for {config['name']}")
print(f" Expected at: {log_file}")
return
# Read last N lines
with open(log_file) as f:
all_lines = f.readlines()
last_lines = all_lines[-lines:] if len(all_lines) > lines else all_lines
print(f"=== {config['name']} logs (last {len(last_lines)} lines) ===")
print(f"=== Log file: {log_file} ===")
print()
for line in last_lines:
print(line, end="")
if not last_lines:
print(" (empty)")
def parse_target(target: str | None, processes: dict) -> list[str]:
"""Parse target argument into list of process keys."""
if target is None or target == "all":
return list(processes.keys())
if target in processes:
return [target]
print(f"Error: Unknown target '{target}'. Use: front, back, or all")
sys.exit(1)
def print_help():
"""Print help message."""
help_text = """
devctl.py - Development process controller for TWICC
Manages frontend and backend dev servers as background processes with logging.
Processes run independently and survive after the command exits.
USAGE:
uv run ./devctl.py <command> [target] [options]
COMMANDS:
start [target] Start process(es) in background
stop [target] Stop running process(es)
restart [target] Stop then start process(es)
status Show running status and port configuration
logs <target> Show recent log output
help, --help, -h Show this help message
TARGETS:
front Frontend dev server (npm run dev)
back Backend server (uv run ./run.py)
all Both frontend and backend (default for start/stop/restart)
OPTIONS:
--empty-db Start with an empty database instead of copying from
the main data directory (worktree mode only)
--lines=N Number of log lines to show (default: 50)
DATA DIRECTORY:
All persistent data (database, logs, config) lives in a data directory:
1. Git worktree detected → project root (always forced)
2. $TWICC_DATA_DIR environment variable (if set)
3. Default → ~/.twicc/
The .env file is read from the data directory.
The backend process receives TWICC_DATA_DIR automatically.
PORT CONFIGURATION:
Ports are configured via .env file in the data directory.
If no .env file exists, defaults are used.
.env file contents:
TWICC_PORT=3500 # Backend port (default: 3500)
VITE_PORT=5173 # Frontend port (default: 5173)
In git worktrees, if ports are not configured in .env, devctl
automatically finds available ports by incrementing from
default+1 (3501→3502→3503... and 5174→5175→5176...) and saves
them to the worktree's .env file on first start.
DEV HOSTNAME:
If you access the dev server through a custom hostname (e.g. via a
reverse proxy or tunnel), set it in the .env file:
TWICC_DEV_HOSTNAME=myhost.example.com
This adds the hostname to Vite's allowedHosts so it accepts requests
for that host. Without this, Vite rejects requests from unknown hosts.
DATABASE & SEARCH INDEX (WORKTREE MODE):
On start/restart in a worktree, devctl automatically copies the
database and search index from ~/.twicc/ if no local data exists yet.
Use --empty-db to skip the copy and start fresh (clears both).
EXAMPLES:
uv run ./devctl.py start # Start both servers
uv run ./devctl.py start back # Start only backend
uv run ./devctl.py stop front # Stop frontend
uv run ./devctl.py restart back # Restart backend
uv run ./devctl.py status # Check what's running and port config
uv run ./devctl.py logs back # Show last 50 lines of backend logs
uv run ./devctl.py logs front --lines=100
uv run ./devctl.py start --empty-db # Worktree: start with fresh database
FILES:
<data_dir>/.env Configuration (ports, password hash)
<data_dir>/db/data.sqlite SQLite database
<data_dir>/search-index/ Tantivy full-text search index
<data_dir>/logs/backend.log Backend application logs
<data_dir>/logs/frontend.log Frontend (Vite) process output
<data_dir>/logs/sdk/ Raw SDK message logs (per session)
.devctl/pids/ PID files for running processes (local)
"""
print(help_text.strip())
def print_access_urls(frontend_port: int) -> None:
"""Print URLs where the application can be accessed."""
env_vars = load_env_file()
dev_hostname = env_vars.get("TWICC_DEV_HOSTNAME", "")
print()
print(f" Access: http://localhost:{frontend_port}")
if dev_hostname:
print(f" https://{dev_hostname}")
def main():
if len(sys.argv) < 2 or sys.argv[1] in ("help", "--help", "-h"):
print_help()
sys.exit(0)
command = sys.argv[1]
# Parse positional target and flags from remaining args
target = None
empty_db = False
for arg in sys.argv[2:]:
if arg == "--empty-db":
empty_db = True
elif not arg.startswith("--") and target is None:
target = arg
# Validate --empty-db: only allowed in worktree mode
if empty_db:
if not is_git_worktree():
print("Error: --empty-db is only supported in git worktree mode")
sys.exit(1)
if command not in ("start", "restart"):
print("Error: --empty-db is only supported with start/restart commands")
sys.exit(1)
# Auto-find ports only on start/restart (may write to .env in worktree mode)
auto_find = command in ("start", "restart")
backend_port, frontend_port = get_ports(auto_find=auto_find)
processes = get_process_config(backend_port, frontend_port)
if command == "start":
targets = parse_target(target, processes)
# In worktree mode: copy DB + search index from main, or clear both if --empty-db
if is_git_worktree():
if empty_db:
clear_local_data()
else:
copy_data_from_main()
print(f"Starting processes (frontend:{frontend_port}, backend:{backend_port})...")
for key in targets:
start(key, processes)
print_access_urls(frontend_port)
elif command == "stop":
targets = parse_target(target, processes)
print("Stopping processes...")
for key in targets:
stop(key, processes)
elif command == "restart":
targets = parse_target(target, processes)
# In worktree mode: copy DB + search index from main, or clear both if --empty-db
if is_git_worktree():
if empty_db:
clear_local_data()
else:
copy_data_from_main()
print(f"Restarting processes (frontend:{frontend_port}, backend:{backend_port})...")
for key in targets:
stop(key, processes)
start(key, processes)
print_access_urls(frontend_port)
elif command == "status":
status(processes)
elif command == "logs":
if target is None:
print("Error: logs requires a target (front or back)")
sys.exit(1)
if target not in processes:
print(f"Error: Unknown target '{target}'. Use: front or back")
sys.exit(1)
# Parse --lines=N
lines = 50
for arg in sys.argv[3:]:
if arg.startswith("--lines="):
try:
lines = int(arg.split("=")[1])
except ValueError:
print("Error: --lines must be a number")
sys.exit(1)
logs(target, processes, lines)
else:
print(f"Error: Unknown command '{command}'")
print("Commands: start, stop, restart, status, logs")
sys.exit(1)
if __name__ == "__main__":
main()