ndev-stack 0.3.0__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/PKG-INFO +2 -1
  2. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/README.md +1 -0
  3. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/__init__.py +1 -1
  4. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/common/modules/builtin/mongodb/module.py +8 -3
  5. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/common/modules/builtin/postgres/module.py +8 -3
  6. ndev_stack-0.3.2/ndev/linux/commands/grok.py +97 -0
  7. ndev_stack-0.3.2/ndev/win/bin/shim.exe +0 -0
  8. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/win/cli.py +88 -7
  9. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/win/core/fcgi.py +7 -3
  10. ndev_stack-0.3.2/ndev/win/core/grok.py +95 -0
  11. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/win/core/php.py +18 -28
  12. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/win/core/redis_core.py +8 -5
  13. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/win/core/setup.py +27 -27
  14. ndev_stack-0.3.2/ndev/win/core/shim.py +284 -0
  15. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/win/core/upgrade.py +12 -11
  16. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/win/core/vhost.py +9 -0
  17. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/win/templates/vhost.conf.tmpl +2 -0
  18. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/win/templates/vhost_ssl.conf.tmpl +2 -0
  19. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev_stack.egg-info/PKG-INFO +2 -1
  20. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev_stack.egg-info/SOURCES.txt +4 -1
  21. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/pyproject.toml +2 -2
  22. ndev_stack-0.3.2/tests/test_shim.py +86 -0
  23. ndev_stack-0.3.0/ndev/linux/commands/grok.py +0 -74
  24. ndev_stack-0.3.0/ndev/win/core/grok.py +0 -54
  25. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/__main__.py +0 -0
  26. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/cli.py +0 -0
  27. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/common/__init__.py +0 -0
  28. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/common/config.py +0 -0
  29. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/common/constants.py +0 -0
  30. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/common/github.py +0 -0
  31. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/common/logger.py +0 -0
  32. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/common/manifest.py +0 -0
  33. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/common/modules/__init__.py +0 -0
  34. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/common/modules/base.py +0 -0
  35. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/common/modules/builtin/mailpit/manifest.json +0 -0
  36. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/common/modules/builtin/mailpit/module.py +0 -0
  37. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/common/modules/builtin/mongodb/manifest.json +0 -0
  38. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/common/modules/builtin/postgres/manifest.json +0 -0
  39. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/common/modules/builtin/redis/manifest.json +0 -0
  40. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/common/modules/builtin/redis/module.py +0 -0
  41. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/common/modules/manager.py +0 -0
  42. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/common/utils.py +0 -0
  43. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/__init__.py +0 -0
  44. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/chroot/manager.py +0 -0
  45. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/chroot/packages.py +0 -0
  46. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/chroot/shell.py +0 -0
  47. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/cli.py +0 -0
  48. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/commands/available.py +0 -0
  49. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/commands/clean.py +0 -0
  50. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/commands/ctl.py +0 -0
  51. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/commands/current.py +0 -0
  52. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/commands/db.py +0 -0
  53. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/commands/doctor.py +0 -0
  54. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/commands/install.py +0 -0
  55. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/commands/list.py +0 -0
  56. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/commands/logs.py +0 -0
  57. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/commands/mailpit.py +0 -0
  58. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/commands/modules.py +0 -0
  59. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/commands/mongodb.py +0 -0
  60. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/commands/postgres.py +0 -0
  61. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/commands/reload.py +0 -0
  62. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/commands/restart.py +0 -0
  63. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/commands/setup.py +0 -0
  64. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/commands/start.py +0 -0
  65. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/commands/status.py +0 -0
  66. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/commands/stop.py +0 -0
  67. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/commands/uninstall.py +0 -0
  68. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/commands/update.py +0 -0
  69. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/commands/upgrade.py +0 -0
  70. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/commands/use.py +0 -0
  71. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/commands/vhost.py +0 -0
  72. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/php/builder.py +0 -0
  73. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/php/downloader.py +0 -0
  74. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/php/extensions.py +0 -0
  75. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/php/installer.py +0 -0
  76. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/php/resolver.py +0 -0
  77. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/php/templates.py +0 -0
  78. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/runtime/fpm.py +0 -0
  79. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/runtime/mailpit.py +0 -0
  80. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/runtime/pma.py +0 -0
  81. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/runtime/process.py +0 -0
  82. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/runtime/sockets.py +0 -0
  83. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/runtime/upgrade.py +0 -0
  84. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/linux/tui.py +0 -0
  85. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/main.py +0 -0
  86. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/tui.py +0 -0
  87. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/win/__init__.py +0 -0
  88. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/win/commands/__init__.py +0 -0
  89. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/win/core/__init__.py +0 -0
  90. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/win/core/db.py +0 -0
  91. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/win/core/elevate.py +0 -0
  92. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/win/core/ext.py +0 -0
  93. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/win/core/logs.py +0 -0
  94. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/win/core/mailpit.py +0 -0
  95. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/win/core/mkcert.py +0 -0
  96. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/win/core/paths.py +0 -0
  97. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/win/core/pma.py +0 -0
  98. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/win/core/services.py +0 -0
  99. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev/win/tui.py +0 -0
  100. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev_stack.egg-info/dependency_links.txt +0 -0
  101. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev_stack.egg-info/entry_points.txt +0 -0
  102. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev_stack.egg-info/requires.txt +0 -0
  103. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/ndev_stack.egg-info/top_level.txt +0 -0
  104. {ndev_stack-0.3.0 → ndev_stack-0.3.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ndev-stack
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Cross-platform developer stack manager for PHP, FastCGI/PHP-FPM, MariaDB, Redis, PostgreSQL, MongoDB, Nginx, SSL, and Mailpit on Linux and Windows
5
5
  Author-email: Ankit Karan <ankit.karan99@gmail.com>
6
6
  Project-URL: Homepage, https://github.com/ankitkaran99/ndev
@@ -244,6 +244,7 @@ ndev ui
244
244
  | `ndev upgrade` | Check for and upgrade stack components (Nginx, MariaDB, Redis, PMA, Mailpit, mkcert, Composer) |
245
245
  | `ndev upgrade --check` | Check component versions against upstream releases without upgrading |
246
246
  | `ndev shell` | Interactive developer subshell pre-loaded with PHP/Composer/MySQL/Redis on PATH |
247
+ | `ndev shim` | Manage Windows binary shims and PATH registration (`list`, `init`, `add`, `rm`) |
247
248
  | `ndev doctor` | Run environment diagnostics and health checks |
248
249
  | `ndev logs` | View and tail service and virtual host logs |
249
250
  | `ndev clean` | Clean up downloads cache and stale runtime state |
@@ -223,6 +223,7 @@ ndev ui
223
223
  | `ndev upgrade` | Check for and upgrade stack components (Nginx, MariaDB, Redis, PMA, Mailpit, mkcert, Composer) |
224
224
  | `ndev upgrade --check` | Check component versions against upstream releases without upgrading |
225
225
  | `ndev shell` | Interactive developer subshell pre-loaded with PHP/Composer/MySQL/Redis on PATH |
226
+ | `ndev shim` | Manage Windows binary shims and PATH registration (`list`, `init`, `add`, `rm`) |
226
227
  | `ndev doctor` | Run environment diagnostics and health checks |
227
228
  | `ndev logs` | View and tail service and virtual host logs |
228
229
  | `ndev clean` | Clean up downloads cache and stale runtime state |
@@ -1,7 +1,7 @@
1
1
  """
2
2
  ndev: Fast, cross-platform PHP developer environment for Windows and Linux.
3
3
  """
4
- __version__ = "0.3.0"
4
+ __version__ = "0.3.2"
5
5
 
6
6
  from . import common as common
7
7
  from .common.logger import logger as logger
@@ -119,16 +119,21 @@ class Module:
119
119
  except Exception:
120
120
  pass
121
121
 
122
- # Copy tools to ~/.ndev/shims/
122
+ # Create tool shims in ~/.ndev/shims/
123
123
  shim_dir = self.module_dir.parent.parent / "shims"
124
124
  if shim_dir.exists():
125
125
  for tool in ["mongod.exe", "mongos.exe", "mongosh.exe"]:
126
126
  src_tool = self.bin_dir / tool
127
127
  if src_tool.exists():
128
+ name = tool[:-4] if tool.lower().endswith(".exe") else tool
128
129
  try:
129
- shutil.copy2(src_tool, shim_dir / tool)
130
+ from ndev.win.core import shim as win_shim
131
+ win_shim.create(name, str(src_tool), path_prepend=str(self.bin_dir), shim_dir=shim_dir)
130
132
  except Exception:
131
- pass
133
+ try:
134
+ shutil.copy2(src_tool, shim_dir / tool)
135
+ except Exception:
136
+ pass
132
137
  else:
133
138
  # Linux: try installing via system package manager or apt
134
139
  if not shutil.which("mongod"):
@@ -108,16 +108,21 @@ class Module:
108
108
  except Exception:
109
109
  pass
110
110
 
111
- # Copy key cli binaries to ~/.ndev/shims/
111
+ # Create key cli binary shims in ~/.ndev/shims/
112
112
  shim_dir = self.module_dir.parent.parent / "shims"
113
113
  if shim_dir.exists():
114
114
  for tool in ["psql.exe", "pg_ctl.exe", "pg_dump.exe", "createdb.exe", "dropdb.exe"]:
115
115
  src_tool = self.bin_dir / tool
116
116
  if src_tool.exists():
117
+ name = tool[:-4] if tool.lower().endswith(".exe") else tool
117
118
  try:
118
- shutil.copy2(src_tool, shim_dir / tool)
119
+ from ndev.win.core import shim as win_shim
120
+ win_shim.create(name, str(src_tool), path_prepend=str(self.bin_dir), shim_dir=shim_dir)
119
121
  except Exception:
120
- pass
122
+ try:
123
+ shutil.copy2(src_tool, shim_dir / tool)
124
+ except Exception:
125
+ pass
121
126
  else:
122
127
  # Linux: try installing via system package manager
123
128
  if not shutil.which("pg_ctl"):
@@ -0,0 +1,97 @@
1
+ import tempfile
2
+ import subprocess
3
+ import shutil
4
+ import typer
5
+ from pathlib import Path
6
+ from rich.console import Console
7
+ from ndev.common.logger import logger
8
+
9
+ console = Console()
10
+
11
+ def get_vhost_info() -> list[dict]:
12
+ nginx_dir = Path("/etc/nginx/sites-enabled")
13
+ vhosts = []
14
+ if not nginx_dir.exists():
15
+ return vhosts
16
+
17
+ for file in nginx_dir.glob("*.conf"):
18
+ try:
19
+ content = file.read_text()
20
+ domain = None
21
+ for line in content.splitlines():
22
+ line = line.strip()
23
+ if line.startswith("server_name"):
24
+ parts = line.split()
25
+ for p in parts[1:]:
26
+ p = p.rstrip(";").strip()
27
+ if p and p != "_":
28
+ domain = p
29
+ break
30
+ if domain:
31
+ break
32
+ if domain:
33
+ has_ssl = "listen 443 ssl" in content or "listen [::]:443 ssl" in content
34
+ vhosts.append({"domain": domain, "ssl": has_ssl})
35
+ except Exception:
36
+ pass
37
+ return vhosts
38
+
39
+ def grok_cmd():
40
+ """Proxy local Nginx vhosts over public internet using Ngrok."""
41
+ if not shutil.which("ngrok"):
42
+ logger.error("ngrok is not installed or not in PATH.")
43
+ raise typer.Exit(code=1)
44
+
45
+ vhosts = get_vhost_info()
46
+ if not vhosts:
47
+ logger.error("No vhosts found in /etc/nginx/sites-enabled.")
48
+ raise typer.Exit(code=1)
49
+
50
+ console.print("\n[bold]Available VHosts[/bold]")
51
+ console.print("----------------")
52
+ for i, v in enumerate(vhosts):
53
+ ssl_tag = " (HTTPS)" if v["ssl"] else ""
54
+ console.print(f" {i + 1}) {v['domain']}{ssl_tag}")
55
+
56
+ console.print("")
57
+ choice = typer.prompt("Select vhost index", type=int)
58
+ if choice < 1 or choice > len(vhosts):
59
+ logger.error("Invalid selection.")
60
+ raise typer.Exit(code=1)
61
+
62
+ selected = vhosts[choice - 1]
63
+ domain = selected["domain"]
64
+ is_ssl = selected["ssl"]
65
+
66
+ policy_content = f'''on_http_request:
67
+ - actions:
68
+ - type: add-headers
69
+ config:
70
+ headers:
71
+ Host: "{domain}"
72
+
73
+ on_http_response:
74
+ - expressions:
75
+ - 'res.status_code >= 300 && res.status_code < 400 && "location" in res.headers'
76
+ - 'res.headers["location"][0].contains("://{domain}")'
77
+ actions:
78
+ - type: add-headers
79
+ config:
80
+ headers:
81
+ Location: '${{res.headers["location"][0].replace("https://{domain}", conn.server_name.size() > 0 ? "https://" + conn.server_name : "").replace("http://{domain}", conn.server_name.size() > 0 ? "https://" + conn.server_name : "")}}'
82
+ '''
83
+
84
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp:
85
+ tmp.write(policy_content)
86
+ policy_file = tmp.name
87
+
88
+ logger.info(f"Selected domain: {domain}{' (HTTPS)' if is_ssl else ''}")
89
+ logger.info("Starting ngrok...")
90
+ target = "https://localhost:443" if is_ssl else "80"
91
+ cmd = ["ngrok", "http", target, "--traffic-policy-file", policy_file]
92
+ if is_ssl:
93
+ cmd.append("--upstream-tls-verify=false")
94
+ try:
95
+ subprocess.run(cmd)
96
+ finally:
97
+ Path(policy_file).unlink(missing_ok=True)
Binary file
@@ -33,6 +33,7 @@ from .core import (
33
33
  redis_core,
34
34
  services,
35
35
  setup as setup_core,
36
+ shim,
36
37
  upgrade as upgrade_core,
37
38
  vhost as vhost_core,
38
39
  )
@@ -375,8 +376,8 @@ def doctor():
375
376
  table.add_row("ngrok Tunneling", "[yellow]MISSING[/yellow]", "Run `ndev setup` to install")
376
377
 
377
378
  # Composer
378
- composer_bat = paths.SHIM_DIR / "composer.bat"
379
- if composer_bat.exists() or shutil.which("composer"):
379
+ composer_cmd = paths.SHIM_DIR / "composer.exe"
380
+ if composer_cmd.exists() or shutil.which("composer"):
380
381
  table.add_row("Composer", "[green]OK[/green]", "Installed and available on CLI")
381
382
  else:
382
383
  table.add_row("Composer", "[yellow]MISSING[/yellow]", "Run `ndev setup` to install")
@@ -390,7 +391,7 @@ def doctor():
390
391
  # PATH Check
391
392
  shim_on_path = str(paths.SHIM_DIR).lower() in os.environ.get("PATH", "").lower()
392
393
  path_status = "[green]OK[/green]" if shim_on_path else "[yellow]WARNING[/yellow]"
393
- path_detail = f"Found on PATH ({paths.SHIM_DIR})" if shim_on_path else f"Add {paths.SHIM_DIR} to your system PATH"
394
+ path_detail = f"Found on PATH ({paths.SHIM_DIR})" if shim_on_path else f"Run `ndev shim init` or add {paths.SHIM_DIR} to PATH"
394
395
  table.add_row("Shims on PATH", path_status, path_detail)
395
396
 
396
397
  console.print(table)
@@ -1876,7 +1877,7 @@ def redis_cli(port: int):
1876
1877
 
1877
1878
  @main.command(name="grok")
1878
1879
  @click.option("--domain", default=None, help="Vhost domain to tunnel. Omit to select interactively.")
1879
- @click.option("--ssl", is_flag=True, help="Tunnel HTTPS port 443")
1880
+ @click.option("--ssl/--no-ssl", default=None, help="Tunnel HTTPS port 443 (defaults to auto-detecting vhost SSL)")
1880
1881
  def grok(domain, ssl):
1881
1882
  """Tunnel a local virtual host to the public web via ngrok."""
1882
1883
  if not domain:
@@ -1896,12 +1897,19 @@ def grok(domain, ssl):
1896
1897
  raise click.ClickException("Invalid selection.")
1897
1898
 
1898
1899
  domain = re.sub(r"^https?://", "", domain.strip().lower()).rstrip("/")
1899
- console.print(f"Starting ngrok tunnel for [bold cyan]{domain}[/bold cyan]{' (HTTPS)' if ssl else ''} -- Ctrl+C to stop.")
1900
- proc = grok_core.start_tunnel(domain, ssl=ssl)
1900
+ v_meta = vhost_core.get_vhost(domain)
1901
+ is_ssl = ssl if ssl is not None else bool(v_meta and v_meta.get("ssl"))
1902
+ console.print(f"Starting ngrok tunnel for [bold cyan]{domain}[/bold cyan]{' (HTTPS)' if is_ssl else ''} -- Ctrl+C to stop.")
1903
+ proc, policy_file = grok_core.start_tunnel(domain, ssl=ssl)
1901
1904
  try:
1902
1905
  proc.wait()
1903
1906
  except KeyboardInterrupt:
1904
1907
  proc.terminate()
1908
+ finally:
1909
+ try:
1910
+ policy_file.unlink(missing_ok=True)
1911
+ except Exception:
1912
+ pass
1905
1913
 
1906
1914
 
1907
1915
  @main.command(name="tunnel", hidden=True)
@@ -1994,9 +2002,10 @@ def setup(nginx, mariadb, mkcert, ngrok, composer, cacert, nginx_version, mariad
1994
2002
  for name, path in results.items():
1995
2003
  console.print(f"[bold green]✓[/bold green] Installed {name:<10} -> {path}")
1996
2004
 
2005
+ shim.ensure_on_path()
1997
2006
  shim_on_path = str(paths.SHIM_DIR).lower() in os.environ.get("PATH", "").lower()
1998
2007
  if not shim_on_path:
1999
- console.print(f"\n[bold yellow]Important:[/bold yellow] Add [bold cyan]{paths.SHIM_DIR}[/bold cyan] to your system PATH.")
2008
+ console.print(f"\n[bold green]✓[/bold green] Added [bold cyan]{paths.SHIM_DIR}[/bold cyan] to User PATH (open a new terminal to use shims).")
2000
2009
 
2001
2010
 
2002
2011
  # ---- Component Upgrade (upgrade) --------------------------------------------
@@ -2372,6 +2381,78 @@ def mongodb_status_cmd():
2372
2381
  console.print(f"MongoDB: {'[bold green]RUNNING[/bold green]' if st.get('running') else '[bold red]STOPPED[/bold red]'} ({st.get('details', '')})")
2373
2382
 
2374
2383
 
2384
+ # ---- Shim Management (shim / shims) -----------------------------------------
2385
+
2386
+ @main.group(name="shim")
2387
+ def shim_cmd():
2388
+ """Manage Windows executable shims and PATH registration."""
2389
+ pass
2390
+
2391
+
2392
+ @shim_cmd.command(name="list")
2393
+ def shim_list_cmd():
2394
+ """List all configured shims."""
2395
+ shims = shim.list_shims()
2396
+ if not shims:
2397
+ console.print("[yellow]No shims configured in ~/.ndev/shims.[/yellow]")
2398
+ return
2399
+
2400
+ table = Table(title="ndev Windows Shims")
2401
+ table.add_column("Command", style="bold cyan")
2402
+ table.add_column("Target Executable / Details", style="green")
2403
+ for name, target in shims.items():
2404
+ table.add_row(name, target)
2405
+ console.print(table)
2406
+
2407
+
2408
+ @shim_cmd.command(name="init")
2409
+ def shim_init_cmd():
2410
+ """Ensure ~/.ndev/shims is added to the user PATH environment variable."""
2411
+ updated = shim.ensure_on_path()
2412
+ if updated:
2413
+ console.print(f"[bold green]✓ Added {paths.SHIM_DIR} to User PATH registry.[/bold green]")
2414
+ console.print("[dim]A WM_SETTINGCHANGE broadcast was sent. Open a new terminal to use shims.[/dim]")
2415
+ else:
2416
+ console.print(f"[green]✓ {paths.SHIM_DIR} is already present in your User PATH registry.[/green]")
2417
+
2418
+
2419
+ @shim_cmd.command(name="add")
2420
+ @click.argument("name")
2421
+ @click.argument("target")
2422
+ @click.option("--args", default="", help="Default arguments to pass to target executable")
2423
+ @click.option("--cwd", default=None, help="Working directory for execution")
2424
+ @click.option("--prepend", default=None, help="Directory to prepend to PATH for target")
2425
+ def shim_add_cmd(name, target, args, cwd, prepend):
2426
+ """Create or update a custom native Windows .exe shim."""
2427
+ try:
2428
+ created = shim.create(name, target, args=args, cwd=cwd, path_prepend=prepend)
2429
+ console.print(f"[bold green]✓ Created shim '{name}' -> {created}[/bold green]")
2430
+ except Exception as e:
2431
+ raise click.ClickException(str(e))
2432
+
2433
+
2434
+ @shim_cmd.command(name="rm")
2435
+ @click.argument("name")
2436
+ def shim_rm_cmd(name):
2437
+ """Remove a shim."""
2438
+ if shim.remove(name):
2439
+ console.print(f"[bold green]✓ Removed shim '{name}'.[/bold green]")
2440
+ else:
2441
+ console.print(f"[yellow]No shim named '{name}' found.[/yellow]")
2442
+
2443
+
2444
+ @main.group(name="shims", hidden=True)
2445
+ def shims_alias():
2446
+ """Alias for shim."""
2447
+ pass
2448
+
2449
+
2450
+ shims_alias.add_command(shim_list_cmd, "list")
2451
+ shims_alias.add_command(shim_init_cmd, "init")
2452
+ shims_alias.add_command(shim_add_cmd, "add")
2453
+ shims_alias.add_command(shim_rm_cmd, "rm")
2454
+
2455
+
2375
2456
  if __name__ == "__main__":
2376
2457
  sys.exit(main())
2377
2458
 
@@ -209,8 +209,12 @@ def nginx_upstream_name(version: str, domain: str | None = None) -> str:
209
209
 
210
210
  def render_upstream_block(version: str, domain: str | None = None) -> str:
211
211
  workers = status(version)
212
- if not workers:
213
- raise RuntimeError(f"PHP {version} pool is not running; start it first")
214
- servers = "\n".join(f" server 127.0.0.1:{w.port};" for w in workers)
212
+ if workers:
213
+ servers = "\n".join(f" server 127.0.0.1:{w.port};" for w in workers)
214
+ else:
215
+ cfg = paths.load_config()
216
+ ports = ports_for(version, cfg["fcgi_workers_per_version"], cfg["fcgi_base_port"])
217
+ servers = "\n".join(f" server 127.0.0.1:{p};" for p in ports)
215
218
  u_name = nginx_upstream_name(version, domain=domain)
216
219
  return f"upstream {u_name} {{\n{servers}\n}}\n"
220
+
@@ -0,0 +1,95 @@
1
+ """
2
+ ngrok tunnel wrapper for ndev-win virtual hosts.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import shutil
7
+ import subprocess
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+ from . import paths, vhost
12
+
13
+ HTTP_PORT = 80
14
+ HTTPS_PORT = 443
15
+
16
+
17
+ def list_vhosts() -> list[str]:
18
+ """Return list of domain names with configured Nginx virtual hosts."""
19
+ return [v["domain"] for v in vhost.list_vhosts()]
20
+
21
+
22
+ def _ngrok_exe() -> str:
23
+ # 1. Check shim dir
24
+ shim_exe = paths.SHIM_DIR / "ngrok.exe"
25
+ if shim_exe.exists():
26
+ return str(shim_exe)
27
+
28
+ # 2. Check config
29
+ cfg = paths.load_config()
30
+ path = cfg.get("ngrok_path")
31
+ if path and Path(path).exists():
32
+ return str(path)
33
+
34
+ # 3. Check system PATH
35
+ which_path = shutil.which("ngrok") or shutil.which("ngrok.exe")
36
+ if which_path:
37
+ return which_path
38
+
39
+ raise FileNotFoundError("ngrok isn't installed -- run `ndev setup` first")
40
+
41
+
42
+ def start_tunnel(domain: str, ssl: bool | None = None) -> tuple[subprocess.Popen, Path]:
43
+ """
44
+ Start `ngrok http` against Nginx.
45
+ Rewrites the request Host header to `domain` and rewrites any response
46
+ redirect Location headers from the local domain back to the ngrok public URL.
47
+ Returns (Popen, policy_file_path).
48
+ """
49
+ import re
50
+ clean_domain = re.sub(r"^https?://", "", domain.strip().lower()).rstrip("/")
51
+ vhost_meta = vhost.get_vhost(clean_domain)
52
+ known = list_vhosts()
53
+ if not vhost_meta and clean_domain not in known:
54
+ raise FileNotFoundError(
55
+ f"No vhost found for '{clean_domain}'. Known vhosts: {known or '(none)'}"
56
+ )
57
+
58
+ # Determine SSL mode: use explicit flag if provided, otherwise auto-detect from vhost config
59
+ is_ssl = ssl if ssl is not None else bool(vhost_meta and vhost_meta.get("ssl"))
60
+
61
+ target = f"https://localhost:{HTTPS_PORT}" if is_ssl else str(HTTP_PORT)
62
+
63
+ # Build traffic policy YAML to ensure:
64
+ # 1. Request Host header is rewritten to clean_domain so Nginx routes to the right vhost.
65
+ # 2. Response Location header rewriting prevents external devices from being redirected
66
+ # to the unresolvable local domain (e.g. *.local).
67
+ policy_content = f'''on_http_request:
68
+ - actions:
69
+ - type: add-headers
70
+ config:
71
+ headers:
72
+ Host: "{clean_domain}"
73
+
74
+ on_http_response:
75
+ - expressions:
76
+ - 'res.status_code >= 300 && res.status_code < 400 && "location" in res.headers'
77
+ - 'res.headers["location"][0].contains("://{clean_domain}")'
78
+ actions:
79
+ - type: add-headers
80
+ config:
81
+ headers:
82
+ Location: '${{res.headers["location"][0].replace("https://{clean_domain}", conn.server_name.size() > 0 ? "https://" + conn.server_name : "").replace("http://{clean_domain}", conn.server_name.size() > 0 ? "https://" + conn.server_name : "")}}'
83
+ '''
84
+
85
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp:
86
+ tmp.write(policy_content)
87
+ policy_file = Path(tmp.name)
88
+
89
+ cmd = [_ngrok_exe(), "http", target, "--traffic-policy-file", str(policy_file)]
90
+ if is_ssl:
91
+ cmd.append("--upstream-tls-verify=false")
92
+
93
+ proc = subprocess.Popen(cmd)
94
+ return proc, policy_file
95
+
@@ -15,7 +15,7 @@ from pathlib import Path
15
15
 
16
16
  import httpx
17
17
 
18
- from . import paths
18
+ from . import paths, shim
19
19
 
20
20
  RELEASES_INDEX_URL = "https://windows.php.net/downloads/releases/releases.json"
21
21
  ARCHIVES_INDEX_URL = "https://windows.php.net/downloads/releases/archives/"
@@ -498,12 +498,9 @@ def uninstall(version: str) -> None:
498
498
  if paths.CURRENT_FILE.exists():
499
499
  paths.CURRENT_FILE.unlink(missing_ok=True)
500
500
  # Remove all PHP and Composer shims
501
- for shim in [
502
- "php.bat", "php.cmd", "php.ps1",
503
- "php-cgi.bat", "php-cgi.cmd", "php-cgi.ps1",
504
- "composer.bat", "composer.cmd", "composer.ps1",
505
- ]:
506
- (paths.SHIM_DIR / shim).unlink(missing_ok=True)
501
+ shim.remove("php")
502
+ shim.remove("php-cgi")
503
+ shim.remove("composer")
507
504
 
508
505
 
509
506
  def list_installed() -> list[str]:
@@ -570,37 +567,30 @@ def get_current_version() -> str | None:
570
567
  def use(version: str) -> str:
571
568
  """
572
569
  Point ndev shims (on PATH) at this version's php.exe and php-cgi.exe.
573
- Generates php.bat, php.cmd, php-cgi.bat, and composer.bat in ~/.ndev/shims.
570
+ Generates binary .exe shims with .cmd fallback in ~/.ndev/shims.
574
571
  Returns the resolved version string.
575
572
  """
576
573
  resolved_ver = resolve_installed(version)
577
574
  paths.ensure_dirs()
578
575
 
579
- exe_path = str(paths.version_dir(resolved_ver) / "php.exe")
580
- cgi_path = str(paths.version_dir(resolved_ver) / "php-cgi.exe")
576
+ php_dir = paths.version_dir(resolved_ver)
577
+ exe_path = str(php_dir / "php.exe")
578
+ cgi_path = str(php_dir / "php-cgi.exe")
581
579
 
582
- # php.bat, php.cmd, php.ps1
583
- (paths.SHIM_DIR / "php.bat").write_text(f'@echo off\r\n"{exe_path}" %*\r\n', encoding="utf-8")
584
- (paths.SHIM_DIR / "php.cmd").write_text(f'@echo off\r\n"{exe_path}" %*\r\n', encoding="utf-8")
585
- (paths.SHIM_DIR / "php.ps1").write_text(f'& "{exe_path}" @args\r\n', encoding="utf-8")
580
+ # Create native shims via shimgen with path_prepend for PHP DLLs (cleans any legacy scripts)
581
+ shim.create("php", exe_path, path_prepend=str(php_dir), clean_legacy=True)
582
+ shim.create("php-cgi", cgi_path, path_prepend=str(php_dir), clean_legacy=True)
586
583
 
587
- # php-cgi.bat, php-cgi.cmd, php-cgi.ps1
588
- (paths.SHIM_DIR / "php-cgi.bat").write_text(f'@echo off\r\n"{cgi_path}" %*\r\n', encoding="utf-8")
589
- (paths.SHIM_DIR / "php-cgi.cmd").write_text(f'@echo off\r\n"{cgi_path}" %*\r\n', encoding="utf-8")
590
- (paths.SHIM_DIR / "php-cgi.ps1").write_text(f'& "{cgi_path}" @args\r\n', encoding="utf-8")
591
-
592
- # If composer.phar is present, create/update composer shims
584
+ # If composer.phar is present, create/update composer native shim
593
585
  composer_phar = paths.SHIM_DIR / "composer.phar"
594
586
  if composer_phar.exists():
595
587
  clean_phar = str(composer_phar.resolve()).replace("\\", "/")
596
- (paths.SHIM_DIR / "composer.bat").write_text(
597
- f'@echo off\r\n"{exe_path}" "{clean_phar}" %*\r\n', encoding="utf-8"
598
- )
599
- (paths.SHIM_DIR / "composer.cmd").write_text(
600
- f'@echo off\r\n"{exe_path}" "{clean_phar}" %*\r\n', encoding="utf-8"
601
- )
602
- (paths.SHIM_DIR / "composer.ps1").write_text(
603
- f'& "{exe_path}" "{clean_phar}" @args\r\n', encoding="utf-8"
588
+ shim.create(
589
+ "composer",
590
+ exe_path,
591
+ args=f'"{clean_phar}"',
592
+ path_prepend=str(php_dir),
593
+ clean_legacy=True,
604
594
  )
605
595
 
606
596
  paths.set_current_version(resolved_ver)
@@ -16,7 +16,7 @@ import time
16
16
  import zipfile
17
17
  from pathlib import Path
18
18
 
19
- from . import fcgi, paths, setup as setup_core
19
+ from . import fcgi, paths, setup as setup_core, shim
20
20
 
21
21
  DEFAULT_PORT = 6379
22
22
  DEFAULT_VERSION = "8.10.1"
@@ -103,12 +103,15 @@ def install(version: str = DEFAULT_VERSION) -> Path:
103
103
  )
104
104
 
105
105
  # Place redis-cli shim in ~/.ndev/shims/
106
- if (REDIS_DIR / "redis-cli.exe").exists():
107
- shim_cli = paths.SHIM_DIR / "redis-cli.exe"
106
+ cli_file = REDIS_DIR / "redis-cli.exe"
107
+ if cli_file.exists():
108
108
  try:
109
- shutil.copy2(REDIS_DIR / "redis-cli.exe", shim_cli)
109
+ shim.create("redis-cli", str(cli_file), path_prepend=str(REDIS_DIR))
110
110
  except Exception:
111
- pass
111
+ try:
112
+ shutil.copy2(cli_file, paths.SHIM_DIR / "redis-cli.exe")
113
+ except Exception:
114
+ pass
112
115
 
113
116
  return REDIS_DIR
114
117
 
@@ -12,7 +12,7 @@ from typing import Optional
12
12
 
13
13
  import httpx
14
14
 
15
- from . import paths
15
+ from . import paths, shim
16
16
 
17
17
  # ---- Defaults ---------------------------------------------------------------
18
18
 
@@ -170,16 +170,13 @@ def install_mariadb(version: str = DEFAULT_MARIADB_VERSION) -> Path:
170
170
 
171
171
  def _create_mariadb_shims() -> None:
172
172
  paths.ensure_dirs()
173
- mysql_exe = paths.MARIADB_DIR / "bin" / "mysql.exe"
174
- mysqldump_exe = paths.MARIADB_DIR / "bin" / "mysqldump.exe"
173
+ bin_dir = paths.MARIADB_DIR / "bin"
174
+ mysql_exe = bin_dir / "mysql.exe"
175
+ mysqldump_exe = bin_dir / "mysqldump.exe"
175
176
  if mysql_exe.exists():
176
- (paths.SHIM_DIR / "mysql.bat").write_text(f'@echo off\r\n"{mysql_exe}" %*\r\n', encoding="utf-8")
177
- (paths.SHIM_DIR / "mysql.cmd").write_text(f'@echo off\r\n"{mysql_exe}" %*\r\n', encoding="utf-8")
178
- (paths.SHIM_DIR / "mysql.ps1").write_text(f'& "{mysql_exe}" @args\r\n', encoding="utf-8")
177
+ shim.create("mysql", str(mysql_exe), path_prepend=str(bin_dir), clean_legacy=True)
179
178
  if mysqldump_exe.exists():
180
- (paths.SHIM_DIR / "mysqldump.bat").write_text(f'@echo off\r\n"{mysqldump_exe}" %*\r\n', encoding="utf-8")
181
- (paths.SHIM_DIR / "mysqldump.cmd").write_text(f'@echo off\r\n"{mysqldump_exe}" %*\r\n', encoding="utf-8")
182
- (paths.SHIM_DIR / "mysqldump.ps1").write_text(f'& "{mysqldump_exe}" @args\r\n', encoding="utf-8")
179
+ shim.create("mysqldump", str(mysqldump_exe), path_prepend=str(bin_dir), clean_legacy=True)
183
180
 
184
181
 
185
182
  def _init_mariadb_data_dir() -> None:
@@ -280,14 +277,28 @@ def install_ngrok() -> Path:
280
277
  # ---- Composer -----------------------------------------------------------------
281
278
 
282
279
  def install_composer() -> Path:
283
- """Download composer.phar and generate composer.bat, composer.cmd, and composer.ps1 shims."""
280
+ """Download composer.phar and generate native composer.exe shim."""
284
281
  phar_dest = paths.SHIM_DIR / "composer.phar"
285
282
  _download(COMPOSER_PHAR_URL, phar_dest)
286
283
 
287
284
  clean_phar = str(phar_dest.resolve()).replace("\\", "/")
288
- (paths.SHIM_DIR / "composer.bat").write_text(f'@echo off\r\nphp "{clean_phar}" %*\r\n', encoding="utf-8")
289
- (paths.SHIM_DIR / "composer.cmd").write_text(f'@echo off\r\nphp "{clean_phar}" %*\r\n', encoding="utf-8")
290
- (paths.SHIM_DIR / "composer.ps1").write_text(f'& php "{clean_phar}" @args\r\n', encoding="utf-8")
285
+ # If active PHP is installed, point composer shim directly to it
286
+ from . import php
287
+ curr_ver = php.get_current_version()
288
+ if curr_ver:
289
+ try:
290
+ exe_path = str(paths.version_dir(curr_ver) / "php.exe")
291
+ shim.create(
292
+ "composer",
293
+ exe_path,
294
+ args=f'"{clean_phar}"',
295
+ path_prepend=str(paths.version_dir(curr_ver)),
296
+ clean_legacy=True,
297
+ )
298
+ return phar_dest
299
+ except Exception:
300
+ pass
301
+
291
302
  return phar_dest
292
303
 
293
304
 
@@ -306,25 +317,14 @@ def install_cacert() -> Path:
306
317
 
307
318
 
308
319
  def create_ndev_shims() -> Path:
309
- """Create ndev.bat, ndev.cmd, and ndev.ps1 in ~/.ndev/shims/ pointing to active python/ndev."""
320
+ """Create ndev native executable shim in ~/.ndev/shims/ pointing to active python/ndev."""
310
321
  import sys
311
322
  paths.ensure_dirs()
312
323
  scripts_dir = Path(sys.executable).parent
313
324
  ndev_exe = scripts_dir / "ndev.exe"
314
325
  if ndev_exe.exists():
315
- bat_target = f'"{ndev_exe}" %*'
316
- ps_target = f'& "{ndev_exe}" @args'
317
- else:
318
- bat_target = f'"{sys.executable}" -m ndev_win.cli %*'
319
- ps_target = f'& "{sys.executable}" -m ndev_win.cli @args'
320
-
321
- bat_path = paths.SHIM_DIR / "ndev.bat"
322
- cmd_path = paths.SHIM_DIR / "ndev.cmd"
323
- ps_path = paths.SHIM_DIR / "ndev.ps1"
324
- bat_path.write_text(f'@echo off\r\n{bat_target}\r\n', encoding="utf-8")
325
- cmd_path.write_text(f'@echo off\r\n{bat_target}\r\n', encoding="utf-8")
326
- ps_path.write_text(f'{ps_target}\r\n', encoding="utf-8")
327
- return bat_path
326
+ return shim.create("ndev", str(ndev_exe), clean_legacy=True)
327
+ return shim.create("ndev", sys.executable, args="-m ndev.main", clean_legacy=True)
328
328
 
329
329
 
330
330
  # ---- Orchestration ----------------------------------------------------------