@tdk-landscape/tdk-cli-core 1.3.15 → 1.3.22

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 (274) hide show
  1. package/Tiltfile +144 -0
  2. package/discovery/AGENTS.md +362 -0
  3. package/discovery/TILT_DISCOVERY.star +424 -0
  4. package/discovery/Tiltfile +58 -0
  5. package/discovery/config.star +258 -0
  6. package/discovery/constants.star +127 -0
  7. package/discovery/deterministic.star +211 -0
  8. package/discovery/discovery_daemon.star +191 -0
  9. package/discovery/discovery_orchestrator.star +460 -0
  10. package/discovery/filtering.star +5 -0
  11. package/discovery/identity-management-backend/service.yaml +26 -0
  12. package/discovery/identity-management-frontend/service.yaml +29 -0
  13. package/discovery/identity-sdk/service.yaml +9 -0
  14. package/discovery/incremental_discovery.star +213 -0
  15. package/discovery/indexing.star +7 -0
  16. package/discovery/json_manifest_scanner.star +51 -0
  17. package/discovery/libraries.star +46 -0
  18. package/discovery/loading.star +23 -0
  19. package/discovery/manifest/constants.star +104 -0
  20. package/discovery/manifest/loading.star +386 -0
  21. package/discovery/manifest/normalize.star +281 -0
  22. package/discovery/manifest/validation.star +108 -0
  23. package/discovery/normalization.star +11 -0
  24. package/discovery/registry.star +544 -0
  25. package/discovery/resource_snapshot.py +382 -0
  26. package/discovery/resource_snapshot.star +143 -0
  27. package/discovery/schemas.star +1 -0
  28. package/discovery/services/platform/cdc/debezium/nats-connector/service.yaml +9 -0
  29. package/discovery/services/platform/cdc/nats-http-bridge/service.yaml +9 -0
  30. package/discovery/services/platform/orchestrator/orchestrator-glue-backend/service.yaml +26 -0
  31. package/discovery/services/platform/synthetic-monitor/service.yaml +9 -0
  32. package/discovery/test_resource_snapshot.py +251 -0
  33. package/discovery/validation.star +78 -0
  34. package/discovery/versioning.star +1 -0
  35. package/dist/commands/config.d.ts.map +1 -1
  36. package/dist/commands/config.js +11 -0
  37. package/dist/commands/config.js.map +1 -1
  38. package/dist/commands/doctor.d.ts.map +1 -1
  39. package/dist/commands/doctor.js +1 -2
  40. package/dist/commands/doctor.js.map +1 -1
  41. package/dist/commands/project.d.ts.map +1 -1
  42. package/dist/commands/project.js +36 -41
  43. package/dist/commands/project.js.map +1 -1
  44. package/dist/commands/resource.d.ts.map +1 -1
  45. package/dist/commands/resource.js +51 -55
  46. package/dist/commands/resource.js.map +1 -1
  47. package/dist/commands/stack.d.ts.map +1 -1
  48. package/dist/commands/stack.js +22 -24
  49. package/dist/commands/stack.js.map +1 -1
  50. package/dist/commands/ui.d.ts.map +1 -1
  51. package/dist/commands/ui.js +1 -2
  52. package/dist/commands/ui.js.map +1 -1
  53. package/dist/commands/up.d.ts.map +1 -1
  54. package/dist/commands/up.js +1 -2
  55. package/dist/commands/up.js.map +1 -1
  56. package/dist/commands/upgrade.d.ts +8 -0
  57. package/dist/commands/upgrade.d.ts.map +1 -1
  58. package/dist/commands/upgrade.js +59 -18
  59. package/dist/commands/upgrade.js.map +1 -1
  60. package/dist/generator/extension-fetch.d.ts +19 -0
  61. package/dist/generator/extension-fetch.d.ts.map +1 -1
  62. package/dist/generator/extension-fetch.js +71 -5
  63. package/dist/generator/extension-fetch.js.map +1 -1
  64. package/dist/generator/template-engine.d.ts +1 -0
  65. package/dist/generator/template-engine.d.ts.map +1 -1
  66. package/dist/generator/template-engine.js +50 -6
  67. package/dist/generator/template-engine.js.map +1 -1
  68. package/dist/utils/command-helpers.d.ts.map +1 -1
  69. package/dist/utils/command-helpers.js +7 -9
  70. package/dist/utils/command-helpers.js.map +1 -1
  71. package/dist/utils/constants.d.ts +1 -0
  72. package/dist/utils/constants.d.ts.map +1 -1
  73. package/dist/utils/constants.js +1 -0
  74. package/dist/utils/constants.js.map +1 -1
  75. package/dist/utils/errors.d.ts +2 -0
  76. package/dist/utils/errors.d.ts.map +1 -1
  77. package/dist/utils/errors.js +6 -1
  78. package/dist/utils/errors.js.map +1 -1
  79. package/dist/utils/project-features.d.ts.map +1 -1
  80. package/dist/utils/project-features.js +1 -1
  81. package/dist/utils/project-features.js.map +1 -1
  82. package/dist/utils/resource-features.d.ts.map +1 -1
  83. package/dist/utils/resource-features.js +2 -2
  84. package/dist/utils/resource-features.js.map +1 -1
  85. package/dist/utils/tar.js +4 -4
  86. package/dist/utils/tar.js.map +1 -1
  87. package/dist/utils/validation.d.ts +7 -0
  88. package/dist/utils/validation.d.ts.map +1 -1
  89. package/dist/utils/validation.js +9 -0
  90. package/dist/utils/validation.js.map +1 -1
  91. package/engine/AGENTS.md +705 -0
  92. package/engine/README.md +29 -0
  93. package/engine/REFACTORING_MANIFEST_SYSTEM.md +974 -0
  94. package/engine/Tiltfile +80 -0
  95. package/engine/assets/docker/golden-layers.Dockerfile +221 -0
  96. package/engine/assets/scripts/cleanup-completed-migrator-images.sh +37 -0
  97. package/engine/assets/scripts/cleanup-tilt-images.sh +120 -0
  98. package/engine/assets/scripts/mac-m1-resource-monitor.sh +121 -0
  99. package/engine/config.star +3 -0
  100. package/engine/docs/AUTODISCOVERY_TEST.md +182 -0
  101. package/engine/docs/C4-ARCHITECTURE-operations.md +396 -0
  102. package/engine/docs/C4-ARCHITECTURE-overview.md +284 -0
  103. package/engine/docs/C4-ARCHITECTURE.md +17 -0
  104. package/engine/docs/GOLDEN_IMAGE_INTEGRATION.md +349 -0
  105. package/engine/docs/MANIFEST_DRIVEN_ARCHITECTURE.md +357 -0
  106. package/engine/docs/README.md +284 -0
  107. package/engine/entrypoint.star +97 -0
  108. package/engine/lifecycle/orchestrator.star +260 -0
  109. package/engine/paths.star +717 -0
  110. package/engine/schemas/manifest-schema.json +301 -0
  111. package/engine/schemas/service-schema.json +301 -0
  112. package/engine/scripts/README.md +89 -0
  113. package/engine/scripts/build-and-publish-lib.sh +205 -0
  114. package/engine/scripts/check-lib-publish.sh +85 -0
  115. package/engine/scripts/debug-container.sh +258 -0
  116. package/engine/scripts/generate-manifest.sh +197 -0
  117. package/engine/spec.master +702 -0
  118. package/engine/spec.master.example +341 -0
  119. package/engine/topologies/README.md +34 -0
  120. package/engine/topologies/platform/docker/AGENTS.md +59 -0
  121. package/engine/topologies/platform/docker/auth/auth.star +167 -0
  122. package/engine/topologies/platform/docker/build/golden_image.star +37 -0
  123. package/engine/topologies/platform/docker/build/golden_image_build.star +82 -0
  124. package/engine/topologies/platform/docker/build/golden_image_constants.star +93 -0
  125. package/engine/topologies/platform/docker/build/golden_image_dockerfile.star +291 -0
  126. package/engine/topologies/platform/docker/compose/compose.star +636 -0
  127. package/engine/topologies/platform/docker/compose/compose_helpers.star +52 -0
  128. package/engine/topologies/platform/docker/compose/traefik_standalone.star +53 -0
  129. package/engine/topologies/platform/docker/config/bunignore.star +142 -0
  130. package/engine/topologies/platform/docker/config/dockerignore.star +150 -0
  131. package/engine/topologies/platform/docker/constants.star +170 -0
  132. package/engine/topologies/platform/docker/dockerfile/dockerfile.star +54 -0
  133. package/engine/topologies/platform/docker/generators/golden_docker_generator_v2.star +73 -0
  134. package/engine/topologies/platform/docker/index.star +27 -0
  135. package/engine/topologies/platform/docker/layers/bun_helpers.star +23 -0
  136. package/engine/topologies/platform/docker/layers/infisical/index.star +40 -0
  137. package/engine/topologies/platform/docker/layers/infisical/infisical_docker.star +262 -0
  138. package/engine/topologies/platform/docker/layers/l1_base_layers.star +87 -0
  139. package/engine/topologies/platform/docker/layers/l2_dependency_layers.star +125 -0
  140. package/engine/topologies/platform/docker/layers/l3_builder_layers.star +209 -0
  141. package/engine/topologies/platform/docker/layers/l4_runtime_layers.star +214 -0
  142. package/engine/topologies/platform/docker/layers/prisma/constants.star +135 -0
  143. package/engine/topologies/platform/docker/layers/prisma/db_readiness.star +96 -0
  144. package/engine/topologies/platform/docker/layers/prisma/filesystem_setup.star +109 -0
  145. package/engine/topologies/platform/docker/layers/prisma/migrate_script.star +98 -0
  146. package/engine/topologies/platform/docker/layers/prisma/prisma_build.star +126 -0
  147. package/engine/topologies/platform/docker/layers/prisma/prisma_runtime.star +161 -0
  148. package/engine/topologies/platform/docker/layers/prisma/secret_resolution.star +119 -0
  149. package/engine/topologies/platform/docker/networking/api_path_constants.star +230 -0
  150. package/engine/topologies/platform/docker/networking/sablier_container_cycle.star +27 -0
  151. package/engine/topologies/platform/docker/networking/traefik.star +232 -0
  152. package/engine/topologies/platform/docker/networking/traefik_constants.star +146 -0
  153. package/engine/topologies/platform/docker/networking/traefik_helpers.star +160 -0
  154. package/engine/topologies/platform/docker/secrets/infisical.star +135 -0
  155. package/engine/topologies/platform/net/proxy.star +4 -0
  156. package/engine/topologies/platform/registries/local.star +4 -0
  157. package/engine/topologies/platform/security/secrets.star +239 -0
  158. package/engine/topologies/platform/state/relational.star +4 -0
  159. package/engine/topologies/tilt/common/AGENTS.md +59 -0
  160. package/engine/topologies/tilt/common/utils.star +364 -0
  161. package/engine/topologies/tilt/common/utils_debug.star +69 -0
  162. package/engine/topologies/tilt/common/utils_deterministic.star +168 -0
  163. package/engine/topologies/tilt/common/utils_docker_networks.star +46 -0
  164. package/engine/topologies/tilt/common/utils_env.star +43 -0
  165. package/engine/topologies/tilt/common/utils_output.star +97 -0
  166. package/engine/topologies/tilt/config/AGENTS.md +68 -0
  167. package/engine/topologies/tilt/config/defaults.star +3 -0
  168. package/engine/topologies/tilt/config/feature_flags.star +10 -0
  169. package/engine/topologies/tilt/config/global.star +55 -0
  170. package/engine/topologies/tilt/config/profiles.star +382 -0
  171. package/engine/topologies/tilt/generators/agents_md.star +62 -0
  172. package/engine/topologies/tilt/generators/api_gateway.star +145 -0
  173. package/engine/topologies/tilt/generators/index.star +124 -0
  174. package/engine/topologies/tilt/generators/infisical/README.md +424 -0
  175. package/engine/topologies/tilt/generators/infisical/examples.star +312 -0
  176. package/engine/topologies/tilt/generators/infisical/index.star +57 -0
  177. package/engine/topologies/tilt/generators/infisical/machine_identity.star +420 -0
  178. package/engine/topologies/tilt/generators/infisical/organization.star +534 -0
  179. package/engine/topologies/tilt/generators/infisical/path_manager.star +476 -0
  180. package/engine/topologies/tilt/generators/infisical/secrets_generator.star +420 -0
  181. package/engine/topologies/tilt/generators/nginx_conf.star +143 -0
  182. package/engine/topologies/tilt/generators/npmrc.star +111 -0
  183. package/engine/topologies/tilt/generators/tsconfig.star +23 -0
  184. package/engine/topologies/tilt/generators/typescript/backend_library_tsconfig.star +71 -0
  185. package/engine/topologies/tilt/generators/typescript/backend_tsconfig.star +124 -0
  186. package/engine/topologies/tilt/generators/typescript/common.star +7 -0
  187. package/engine/topologies/tilt/generators/typescript/frontend_library_tsconfig.star +76 -0
  188. package/engine/topologies/tilt/generators/typescript/frontend_tsconfig.star +110 -0
  189. package/engine/topologies/tilt/generators/typescript/include.star +23 -0
  190. package/engine/topologies/tilt/generators/typescript/playwright_config.star +40 -0
  191. package/engine/topologies/tilt/generators/typescript/prisma_config.star +49 -0
  192. package/engine/topologies/tilt/generators/validators.star +853 -0
  193. package/engine/topologies/tilt/generators/vite/backend.star +78 -0
  194. package/engine/topologies/tilt/generators/vite/frontend.star +98 -0
  195. package/engine/topologies/tilt/generators/vite/generators.star +43 -0
  196. package/engine/topologies/tilt/generators/vite/helpers.star +244 -0
  197. package/engine/topologies/tilt/generators/vite/library.star +65 -0
  198. package/engine/topologies/tilt/generators/vite/sdk.star +73 -0
  199. package/engine/topologies/tilt/generators/vite/templates.star +737 -0
  200. package/engine/topologies/tilt/generators/vite_config.star +22 -0
  201. package/engine/topologies/tilt/manifest/AGENTS.md +61 -0
  202. package/engine/topologies/tilt/manifest/README.md +693 -0
  203. package/engine/topologies/tilt/manifest/__init__.star +218 -0
  204. package/engine/topologies/tilt/manifest/constants.star +252 -0
  205. package/engine/topologies/tilt/manifest/errors.star +266 -0
  206. package/engine/topologies/tilt/manifest/integration.star +339 -0
  207. package/engine/topologies/tilt/manifest/json_to_yaml.py +54 -0
  208. package/engine/topologies/tilt/manifest/loader.star +214 -0
  209. package/engine/topologies/tilt/manifest/mapper.star +227 -0
  210. package/engine/topologies/tilt/manifest/parser.star +400 -0
  211. package/engine/topologies/tilt/manifest/schema.star +462 -0
  212. package/engine/topologies/tilt/manifest/tests/test_integration.star +276 -0
  213. package/engine/topologies/tilt/manifest/tests/test_loader_parser.star +397 -0
  214. package/engine/topologies/tilt/manifest/tests/test_manifest.star +316 -0
  215. package/engine/topologies/tilt/manifest/tests/test_validator.star +470 -0
  216. package/engine/topologies/tilt/manifest/validator.star +717 -0
  217. package/engine/topologies/tilt/manifest/yaml_to_json.py +18 -0
  218. package/engine/topologies/tilt/resources/AGENTS.md +71 -0
  219. package/engine/topologies/tilt/resources/conditions.star +3 -0
  220. package/engine/topologies/tilt/resources/databases.star +244 -0
  221. package/engine/topologies/tilt/resources/declaration.star +3 -0
  222. package/engine/topologies/tilt/resources/deps.star +3 -0
  223. package/engine/topologies/tilt/resources/infra-loader.star +437 -0
  224. package/engine/topologies/tilt/resources/libs.star +30 -0
  225. package/engine/topologies/tilt/resources/libs_discovery.star +102 -0
  226. package/engine/topologies/tilt/resources/libs_paths.star +79 -0
  227. package/engine/topologies/tilt/resources/libs_resource_definition.star +151 -0
  228. package/engine/topologies/tilt/resources/libs_setup.star +164 -0
  229. package/engine/topologies/tilt/resources/libs_tsconfig.star +37 -0
  230. package/engine/topologies/tilt/resources/orchestrator/apply.star +77 -0
  231. package/engine/topologies/tilt/resources/orchestrator/apply_compose_resource_registration.star +431 -0
  232. package/engine/topologies/tilt/resources/orchestrator/apply_manifest_orchestration.star +81 -0
  233. package/engine/topologies/tilt/resources/orchestrator/apply_migrator_orchestration.star +148 -0
  234. package/engine/topologies/tilt/resources/orchestrator/apply_resource_validation.star +36 -0
  235. package/engine/topologies/tilt/resources/orchestrator/apply_runtime_flags.star +44 -0
  236. package/engine/topologies/tilt/resources/orchestrator/builders/typescript.star +164 -0
  237. package/engine/topologies/tilt/resources/orchestrator/generators/dependencies/AGENTS.md +75 -0
  238. package/engine/topologies/tilt/resources/orchestrator/generators/dependencies/index.star +21 -0
  239. package/engine/topologies/tilt/resources/orchestrator/generators/dependencies/resolver.star +134 -0
  240. package/engine/topologies/tilt/resources/orchestrator/generators/env.star +258 -0
  241. package/engine/topologies/tilt/resources/orchestrator/generators/frontend.star +169 -0
  242. package/engine/topologies/tilt/resources/orchestrator/generators/manifest_resource.star +524 -0
  243. package/engine/topologies/tilt/resources/orchestrator/helpers.star +33 -0
  244. package/engine/topologies/tilt/resources/orchestrator.star +54 -0
  245. package/engine/topologies/tilt/resources/ordering.star +3 -0
  246. package/engine/topologies/tilt/resources/triggers.star +3 -0
  247. package/engine/topologies/tilt/resources/watchers.star +299 -0
  248. package/engine/where-things-live.star +717 -0
  249. package/ext/Tiltfile +66 -0
  250. package/ext/ide-components/README.md +219 -0
  251. package/ext/ide-components/code_executor/server.py +268 -0
  252. package/ext/ide-components/code_viewer/server.py +206 -0
  253. package/ext/ide-components/config_inspector/server.py +1050 -0
  254. package/ext/ide-components/file_browser/server.py +285 -0
  255. package/ext/ide-components/shared/config_service.py +469 -0
  256. package/ext/ide-components/shared/file_utils.py +298 -0
  257. package/ext/ide-components/shared/http_utils.py +232 -0
  258. package/ext/ide-components/shared/static/styles.css +1334 -0
  259. package/ext/ide-components/shared/syntax_highlighter.py +140 -0
  260. package/ext/ide-components/shared/template_engine.py +123 -0
  261. package/ext/ide-components/shared/templates/base.html +121 -0
  262. package/ext/ide-components/shared/test_config_service.py +326 -0
  263. package/ext/ide-components/shared/tilt_integration.py +158 -0
  264. package/ext/ui-enhancements/cron-status-server.sh +49 -0
  265. package/ext/ui-enhancements/cron-status-tracker.sh +146 -0
  266. package/ext/ui-enhancements/extension.yaml +36 -0
  267. package/ext/ui-enhancements/tiltfile.py +99 -0
  268. package/ext/ui-enhancements/ui/cron-jobs-tab.css +396 -0
  269. package/ext/ui-enhancements/ui/help-panel.css +279 -0
  270. package/ext/ui-enhancements/ui/icons.css +125 -0
  271. package/ext/ui-enhancements/ui/tooltips.css +114 -0
  272. package/package.json +13 -9
  273. package/specs/specs/TILT_SERVICE_DEFAULTS.star +205 -0
  274. package/specs/specs/TILT_TECH_STACK.star +159 -0
@@ -0,0 +1,1050 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tilt IDE Config Inspector
4
+ Compare master configs and auto-generated outputs at http://localhost:9767
5
+
6
+ Features:
7
+ - Multi-pane comparison (1-3 configs side-by-side)
8
+ - Edit mode with safe file saving
9
+ - Sync scroll support
10
+ - Keyboard shortcuts (Ctrl+S, Ctrl+Shift+S, Ctrl+E)
11
+ """
12
+
13
+ import json
14
+ import os
15
+ import secrets
16
+ import sys
17
+ import urllib.parse
18
+ from pathlib import Path
19
+ from typing import Optional, Tuple
20
+
21
+ # Add shared modules
22
+ sys.path.insert(0, str(Path(__file__).parent.parent / 'shared'))
23
+ from file_utils import FileUtils, PROJECT_ROOT
24
+ from config_service import ConfigService
25
+ from http_utils import BaseIDEHandler, run_server
26
+
27
+ PORT = 9767
28
+
29
+
30
+ class ConfigInspectorHandler(BaseIDEHandler):
31
+ """HTTP request handler for config inspector"""
32
+
33
+ # CSRF token storage (in-memory, per-server-instance)
34
+ csrf_tokens = set()
35
+
36
+ # Master configs allowed at project root
37
+ ALLOWED_ROOT_CONFIGS = [
38
+ 'TILT_RESOURCE_DEFAULTS.star',
39
+ 'spec.master',
40
+ 'engine/spec.master',
41
+ ]
42
+
43
+ def generate_csrf_token(self) -> str:
44
+ """Generate and store a new CSRF token"""
45
+ token = secrets.token_urlsafe(32)
46
+ self.csrf_tokens.add(token)
47
+ # Keep token set from growing too large
48
+ if len(self.csrf_tokens) > 1000:
49
+ self.csrf_tokens.clear()
50
+ return token
51
+
52
+ def validate_csrf_token(self, token: str) -> bool:
53
+ """Validate a CSRF token"""
54
+ if not token:
55
+ return False
56
+ referer = self.headers.get('Referer', '')
57
+ origin = self.headers.get('Origin', '')
58
+ is_valid, _ = ConfigService.validate_csrf_token(token, referer, origin)
59
+ return is_valid
60
+
61
+ def discover_master_configs(self) -> list:
62
+ """Discover all .star files in engine/topologies/ and project root master configs"""
63
+ configs = []
64
+
65
+ # First, add project root master configs
66
+ for config_name in self.ALLOWED_ROOT_CONFIGS:
67
+ config_path = PROJECT_ROOT / config_name
68
+ if config_path.exists():
69
+ configs.append({
70
+ 'path': config_name,
71
+ 'name': config_path.name,
72
+ 'category': 'Project Root',
73
+ })
74
+
75
+ # Then discover from engine/topologies/
76
+ topologies_dir = PROJECT_ROOT / 'engine' / 'topologies'
77
+
78
+ if topologies_dir.exists():
79
+ for root, dirs, files in os.walk(topologies_dir):
80
+ # Skip __pycache__ and hidden
81
+ dirs[:] = [d for d in dirs if not d.startswith('.') and d != '__pycache__']
82
+
83
+ for file in sorted(files):
84
+ if file.endswith('.star'):
85
+ file_path = Path(root) / file
86
+ rel_path = file_path.relative_to(PROJECT_ROOT)
87
+ configs.append({
88
+ 'path': str(rel_path),
89
+ 'name': file,
90
+ 'category': self._categorize_config(rel_path),
91
+ })
92
+
93
+ return sorted(configs, key=lambda x: (x['category'], x['path']))
94
+
95
+ def _categorize_config(self, path: Path) -> str:
96
+ """Categorize a config file by its location"""
97
+ path_str = str(path).lower()
98
+ if 'platform/docker' in path_str:
99
+ return 'Docker'
100
+ elif 'platform/' in path_str:
101
+ return 'Platform'
102
+ elif 'tilt/' in path_str:
103
+ return 'Tilt'
104
+ elif 'generators' in path_str:
105
+ return 'Generators'
106
+ elif 'discovery' in path_str:
107
+ return 'Discovery'
108
+ else:
109
+ return 'Other'
110
+
111
+ def discover_autogenerated_configs(self) -> list:
112
+ """Discover all autogenerated config files"""
113
+ configs = []
114
+
115
+ for root, dirs, files in os.walk(PROJECT_ROOT):
116
+ # Skip certain directories
117
+ dirs[:] = [d for d in dirs if not d.startswith('.') and
118
+ d not in ('node_modules', 'dist', 'build', '__pycache__')]
119
+
120
+ if 'autogenerated' in root:
121
+ for file in sorted(files):
122
+ if 'autogenerated' in file:
123
+ file_path = Path(root) / file
124
+ rel_path = file_path.relative_to(PROJECT_ROOT)
125
+
126
+ # Determine service name from path
127
+ parts = str(rel_path).split('/')
128
+ resource_name = 'unknown'
129
+ for i, part in enumerate(parts):
130
+ if part == 'services' and i + 2 < len(parts):
131
+ resource_name = parts[i + 2]
132
+ break
133
+
134
+ configs.append({
135
+ 'path': str(rel_path),
136
+ 'name': file,
137
+ 'service': resource_name,
138
+ 'type': self._get_autogen_type(file),
139
+ })
140
+
141
+ return sorted(configs, key=lambda x: (x['service'], x['type'], x['path']))
142
+
143
+ def _get_autogen_type(self, filename: str) -> str:
144
+ """Determine autogenerated config type from filename"""
145
+ if 'docker-compose' in filename:
146
+ return 'Docker Compose'
147
+ elif 'Dockerfile' in filename:
148
+ return 'Dockerfile'
149
+ elif 'vite.config' in filename:
150
+ return 'Vite Config'
151
+ elif 'tsconfig' in filename:
152
+ return 'TypeScript'
153
+ elif 'nginx' in filename:
154
+ return 'Nginx'
155
+ elif 'prisma' in filename:
156
+ return 'Prisma'
157
+ elif 'env' in filename:
158
+ return 'Environment'
159
+ else:
160
+ return 'Other'
161
+
162
+ def read_file_content(self, path: str) -> Tuple[str, Optional[str]]:
163
+ """
164
+ Read file content safely.
165
+ Returns: (content, content_hash)
166
+ """
167
+ resolved = FileUtils.validate_path(path)
168
+ if not resolved or not resolved.exists():
169
+ return f"# File not found: {path}", None
170
+
171
+ try:
172
+ with open(resolved, 'r', encoding='utf-8', errors='replace') as f:
173
+ content = f.read()
174
+
175
+ # Compute hash for conflict detection
176
+ content_hash = ConfigService.compute_content_hash(content)
177
+ return content, content_hash
178
+ except Exception as e:
179
+ return f"# Error reading file: {e}", None
180
+
181
+ def validate_url_path(self, path: str) -> Tuple[bool, str]:
182
+ """
183
+ Validate a path from URL parameter to prevent path traversal.
184
+ Returns: (is_valid, error_message)
185
+ """
186
+ if not path:
187
+ return False, "Empty path"
188
+
189
+ # Check for path traversal sequences
190
+ decoded = urllib.parse.unquote(path)
191
+ if '..' in decoded or '%2e' in path.lower():
192
+ return False, "Path traversal not allowed"
193
+
194
+ # Validate using ConfigService
195
+ resolved = ConfigService.validate_write_path(path)
196
+ if not resolved:
197
+ return False, "Path not in allowed directories"
198
+
199
+ # Validate file type
200
+ is_valid, msg = ConfigService.validate_file_type(path)
201
+ if not is_valid:
202
+ return False, msg
203
+
204
+ return True, "Valid"
205
+
206
+ def parse_multi_master_params(self, query: dict) -> Tuple[list, str]:
207
+ """
208
+ Parse and validate master1, master2, master3 URL parameters.
209
+ Returns: (list_of_paths, error_message)
210
+ """
211
+ masters = []
212
+
213
+ for i in range(1, 4):
214
+ param_name = f'master{i}'
215
+ if param_name in query and query[param_name][0]:
216
+ path = query[param_name][0]
217
+
218
+ # Check for sequential parameter gap
219
+ if i > 1 and not masters:
220
+ return [], f"Invalid URL parameters: {param_name} specified without prior masters"
221
+
222
+ # Validate path
223
+ is_valid, msg = self.validate_url_path(path)
224
+ if not is_valid:
225
+ return [], f"Invalid path in {param_name}: {msg}"
226
+
227
+ # Check for duplicates
228
+ if path in masters:
229
+ return [], f"Duplicate file: {path} cannot be selected multiple times"
230
+
231
+ masters.append(path)
232
+
233
+ return masters, ""
234
+
235
+ def render_config_tree(self, configs: list, selected_paths: list = None) -> str:
236
+ """Render config tree navigation with multi-select support."""
237
+ if selected_paths is None:
238
+ selected_paths = []
239
+
240
+ current_category = None
241
+ html = ['<ul class="config-tree">']
242
+
243
+ for cfg in configs:
244
+ # Category header
245
+ if cfg['category'] != current_category:
246
+ if current_category:
247
+ html.append('</ul></li>')
248
+ current_category = cfg['category']
249
+ html.append(f'<li style="margin-top: 1rem;"><strong style="color: var(--text-muted); font-size: 0.8rem; text-transform: uppercase;">{current_category}</strong><ul style="list-style: none; padding-left: 0.5rem;">')
250
+
251
+ # Check if this config is selected and get its order
252
+ is_selected = cfg['path'] in selected_paths
253
+ selection_order = ''
254
+ if is_selected:
255
+ order = selected_paths.index(cfg['path']) + 1
256
+ selection_order = f'<span class="selection-order">{order}</span>'
257
+
258
+ active_class = 'active' if is_selected else ''
259
+ encoded_path = urllib.parse.quote(cfg['path'])
260
+
261
+ # Build onclick handler for multi-select
262
+ if selected_paths:
263
+ # If we already have selections, append to them
264
+ if is_selected:
265
+ # Deselect if already selected
266
+ new_selection = [p for p in selected_paths if p != cfg['path']]
267
+ else:
268
+ # Add to selection if less than 3
269
+ if len(selected_paths) < 3:
270
+ new_selection = selected_paths + [cfg['path']]
271
+ else:
272
+ new_selection = selected_paths # Max reached
273
+
274
+ # Build new URL
275
+ params = []
276
+ for i, p in enumerate(new_selection, 1):
277
+ params.append(f"master{i}={urllib.parse.quote(p)}")
278
+ new_url = '/compare?' + '&'.join(params) if params else '/'
279
+
280
+ html.append(f'''
281
+ <li class="tree-item {active_class}" onclick="handleConfigClick(event, '{encoded_path}', {len(selected_paths)})">
282
+ <span>⭐</span>
283
+ <span>{cfg['name']}</span>
284
+ {selection_order}
285
+ </li>
286
+ ''')
287
+ else:
288
+ # Single select mode
289
+ html.append(f'''
290
+ <li class="tree-item {active_class}" onclick="handleConfigClick(event, '{encoded_path}', 0)">
291
+ <span>⭐</span>
292
+ <span>{cfg['name']}</span>
293
+ {selection_order}
294
+ </li>
295
+ ''')
296
+
297
+ if current_category:
298
+ html.append('</ul></li>')
299
+
300
+ html.append('</ul>')
301
+ return ''.join(html)
302
+
303
+ def render_multi_pane_view(self, master_paths: list, edit_mode: bool, sync_scroll: bool, csrf_token: str) -> str:
304
+ """Render multi-pane view for 1-3 master configs."""
305
+ num_panes = len(master_paths)
306
+ if num_panes == 0:
307
+ return '<div class="empty-state"><div class="empty-state-icon">⚙️</div><p>No master configs selected</p></div>'
308
+
309
+ # Determine grid layout class
310
+ if num_panes == 1:
311
+ grid_class = 'single-pane'
312
+ elif num_panes == 2:
313
+ grid_class = 'dual-pane'
314
+ else:
315
+ grid_class = 'triple-pane'
316
+
317
+ # Build panes
318
+ panes_html = []
319
+ pane_data = []
320
+
321
+ for i, path in enumerate(master_paths, 1):
322
+ content, content_hash = self.read_file_content(path)
323
+ pane_data.append({
324
+ 'path': path,
325
+ 'content': content,
326
+ 'hash': content_hash,
327
+ 'index': i
328
+ })
329
+
330
+ # Determine unsaved status (will be tracked client-side)
331
+ unsaved_indicator = '<span class="unsaved-indicator hidden">●</span>'
332
+
333
+ # Render content based on edit mode
334
+ if edit_mode:
335
+ # Editable textarea with HTML-escaped content
336
+ escaped_content = self.escape_html(content)
337
+ content_html = f'''
338
+ <textarea class="config-editor"
339
+ data-path="{self.escape_html(path)}"
340
+ data-hash="{content_hash or ''}"
341
+ data-pane="{i}"
342
+ spellcheck="false">{escaped_content}</textarea>
343
+ '''
344
+ else:
345
+ # Read-only pre/code view
346
+ content_html = f'<pre class="config-viewer" data-pane="{i}"><code>{self.escape_html(content)}</code></pre>'
347
+
348
+ pane_html = f'''
349
+ <div class="panel pane-{i}" data-pane="{i}">
350
+ <div class="panel-header">
351
+ <div class="panel-header-left">
352
+ <span class="panel-title">⭐ Master Config {i}/{num_panes}</span>
353
+ <span class="text-muted panel-path">{path}</span>
354
+ {unsaved_indicator}
355
+ </div>
356
+ {f'<button class="save-pane-btn hidden" data-pane="{i}" onclick="savePane({i})">💾 Save</button>' if edit_mode else ''}
357
+ </div>
358
+ <div class="panel-content" data-pane="{i}">
359
+ {content_html}
360
+ </div>
361
+ </div>
362
+ '''
363
+ panes_html.append(pane_html)
364
+
365
+ # Generate pane data for JavaScript
366
+ pane_data_json = json.dumps(pane_data)
367
+
368
+ # Build the multi-pane container
369
+ content_html = f'''
370
+ <div class="multi-pane-view {grid_class}" id="multiPaneView" data-sync="{str(sync_scroll).lower()}">
371
+ {''.join(panes_html)}
372
+ </div>
373
+
374
+ <script>
375
+ // Store pane data for JavaScript
376
+ window.PANE_DATA = {pane_data_json};
377
+ window.EDIT_MODE = {'true' if edit_mode else 'false'};
378
+ window.SYNC_SCROLL = {'true' if sync_scroll else 'false'};
379
+ window.CSRF_TOKEN = '{csrf_token}';
380
+ </script>
381
+ '''
382
+
383
+ return content_html
384
+
385
+ def check_resource_status(self) -> dict:
386
+ """Check if other IDE services are running"""
387
+ import socket
388
+ services = {
389
+ 'files': {'host': 'localhost', 'port': 9765},
390
+ 'viewer': {'host': 'localhost', 'port': 9766},
391
+ 'terminal': {'host': 'localhost', 'port': 9768},
392
+ }
393
+ status = {}
394
+ for name, info in services.items():
395
+ try:
396
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
397
+ sock.settimeout(0.5)
398
+ result = sock.connect_ex(('127.0.0.1', info['port']))
399
+ sock.close()
400
+ status[name] = result == 0
401
+ except:
402
+ status[name] = False
403
+ return status
404
+
405
+ def get_main_javascript(self, edit_mode: bool) -> str:
406
+ """Get the main JavaScript code for the Config Inspector"""
407
+ # This is a long JavaScript string - could be moved to a separate file
408
+ # For now, keeping inline to avoid breaking existing functionality
409
+ return '''
410
+ <script>
411
+ // Track dirty state per pane
412
+ window.DIRTY_PANES = new Set();
413
+
414
+ // Handle config tree click with multi-select support
415
+ function handleConfigClick(event, path, currentSelectionCount) {
416
+ const isMultiSelect = event.ctrlKey || event.metaKey;
417
+
418
+ if (isMultiSelect) {
419
+ event.preventDefault();
420
+
421
+ // Build new selection
422
+ let currentPaths = window.PANE_DATA ? window.PANE_DATA.map(p => p.path) : [];
423
+ const index = currentPaths.indexOf(path);
424
+
425
+ if (index >= 0) {
426
+ // Deselect
427
+ currentPaths.splice(index, 1);
428
+ } else if (currentPaths.length < 3) {
429
+ // Add to selection (max 3)
430
+ currentPaths.push(path);
431
+ }
432
+
433
+ // Navigate to new URL
434
+ const params = currentPaths.map((p, i) => `master${i+1}=${encodeURIComponent(p)}`);
435
+ const newUrl = params.length > 0 ? '/compare?' + params.join('&') : '/';
436
+ window.location.href = newUrl;
437
+ } else {
438
+ // Single select - just navigate
439
+ window.location.href = '/compare?master1=' + encodeURIComponent(path);
440
+ }
441
+ }
442
+
443
+ // Clear all selections
444
+ function clearSelection() {
445
+ window.location.href = '/';
446
+ }
447
+
448
+ // Toggle edit mode
449
+ function toggleEditMode() {
450
+ if (window.DIRTY_PANES.size > 0) {
451
+ const files = Array.from(window.DIRTY_PANES).map(pane => {
452
+ const paneData = window.PANE_DATA.find(p => p.index === pane);
453
+ return paneData ? paneData.path : 'Unknown';
454
+ });
455
+ if (!confirm(`You have unsaved changes in:\n${files.join('\\n')}\n\nDiscard changes and exit edit mode?`)) {
456
+ return;
457
+ }
458
+ }
459
+
460
+ const url = new URL(window.location);
461
+ const currentEdit = url.searchParams.get('edit') === 'true';
462
+ url.searchParams.set('edit', (!currentEdit).toString());
463
+ window.location.href = url.toString();
464
+ }
465
+
466
+ // Toggle sync scroll
467
+ function toggleSyncScroll() {
468
+ const checkbox = document.getElementById('syncScrollCheckbox');
469
+ const url = new URL(window.location);
470
+ url.searchParams.set('sync', checkbox.checked.toString());
471
+ window.location.href = url.toString();
472
+ }
473
+
474
+ // Save a single pane
475
+ async function savePane(paneIndex) {
476
+ const textarea = document.querySelector(`textarea[data-pane="${paneIndex}"]`);
477
+ if (!textarea) return;
478
+
479
+ const path = textarea.dataset.path;
480
+ const content = textarea.value;
481
+ const originalHash = textarea.dataset.hash;
482
+
483
+ await saveFile(path, content, originalHash, paneIndex);
484
+ }
485
+
486
+ // Save all panes
487
+ async function saveAllPanes() {
488
+ const textareas = document.querySelectorAll('textarea.config-editor');
489
+ const results = [];
490
+
491
+ for (const textarea of textareas) {
492
+ const paneIndex = parseInt(textarea.dataset.pane);
493
+ const path = textarea.dataset.path;
494
+ const content = textarea.value;
495
+ const originalHash = textarea.dataset.hash;
496
+
497
+ const result = await saveFile(path, content, originalHash, paneIndex, false);
498
+ results.push({ paneIndex, success: result.success });
499
+ }
500
+
501
+ // Show summary notification
502
+ const successCount = results.filter(r => r.success).length;
503
+ const totalCount = results.length;
504
+
505
+ if (successCount === totalCount) {
506
+ showNotification(`All ${totalCount} files saved successfully`, 'success');
507
+ window.DIRTY_PANES.clear();
508
+ updateUnsavedIndicators();
509
+ updateSaveAllButton();
510
+ } else {
511
+ showNotification(`${successCount}/${totalCount} files saved`, 'warning');
512
+ }
513
+ }
514
+
515
+ // Save a file via API
516
+ async function saveFile(path, content, originalHash, paneIndex, showToast = true) {
517
+ try {
518
+ const response = await fetch('/save', {
519
+ method: 'POST',
520
+ headers: {
521
+ 'Content-Type': 'application/json',
522
+ 'X-CSRF-Token': window.CSRF_TOKEN
523
+ },
524
+ body: JSON.stringify({
525
+ path: path,
526
+ content: content,
527
+ original_hash: originalHash,
528
+ csrf_token: window.CSRF_TOKEN
529
+ })
530
+ });
531
+
532
+ const result = await response.json();
533
+
534
+ if (result.success) {
535
+ if (showToast) {
536
+ showNotification(`Saved: ${path}`, 'success');
537
+ }
538
+
539
+ // Trigger save success animation on the panel
540
+ const panel = document.querySelector(`.pane-${paneIndex}`);
541
+ if (panel) {
542
+ panel.classList.add('save-success');
543
+ setTimeout(() => panel.classList.remove('save-success'), 1000);
544
+ }
545
+
546
+ // Clear dirty state for this pane
547
+ window.DIRTY_PANES.delete(paneIndex);
548
+ updateUnsavedIndicators();
549
+ updateSaveAllButton();
550
+
551
+ // Update the hash in the textarea
552
+ const textarea = document.querySelector(`textarea[data-pane="${paneIndex}"]`);
553
+ if (textarea && result.new_hash) {
554
+ textarea.dataset.hash = result.new_hash;
555
+ }
556
+
557
+ return { success: true };
558
+ } else {
559
+ if (showToast) {
560
+ showNotification(`Failed: ${result.message}`, 'error');
561
+ }
562
+ return { success: false, message: result.message };
563
+ }
564
+ } catch (error) {
565
+ if (showToast) {
566
+ showNotification(`Error: ${error.message}`, 'error');
567
+ }
568
+ return { success: false, message: error.message };
569
+ }
570
+ }
571
+
572
+ // Show notification toast
573
+ function showNotification(message, type = 'info') {
574
+ const existing = document.querySelector('.notification-toast');
575
+ if (existing) existing.remove();
576
+
577
+ const toast = document.createElement('div');
578
+ toast.className = `notification-toast ${type}`;
579
+ toast.textContent = message;
580
+ document.body.appendChild(toast);
581
+
582
+ setTimeout(() => {
583
+ toast.classList.add('show');
584
+ }, 10);
585
+
586
+ setTimeout(() => {
587
+ toast.classList.remove('show');
588
+ setTimeout(() => toast.remove(), 300);
589
+ }, 3000);
590
+ }
591
+
592
+ // Update unsaved change indicators
593
+ function updateUnsavedIndicators() {
594
+ document.querySelectorAll('.unsaved-indicator').forEach(indicator => {
595
+ const pane = indicator.closest('.panel').dataset.pane;
596
+ if (window.DIRTY_PANES.has(parseInt(pane))) {
597
+ indicator.classList.remove('hidden');
598
+ } else {
599
+ indicator.classList.add('hidden');
600
+ }
601
+ });
602
+ }
603
+
604
+ // Update save all button visibility
605
+ function updateSaveAllButton() {
606
+ const btn = document.getElementById('saveAllBtn');
607
+ if (window.EDIT_MODE && window.DIRTY_PANES.size > 0) {
608
+ btn.classList.remove('hidden');
609
+ } else {
610
+ btn.classList.add('hidden');
611
+ }
612
+ }
613
+
614
+ // Track content changes
615
+ function onContentChange(paneIndex) {
616
+ window.DIRTY_PANES.add(paneIndex);
617
+ updateUnsavedIndicators();
618
+ updateSaveAllButton();
619
+
620
+ // Show save button for this pane
621
+ const saveBtn = document.querySelector(`.save-pane-btn[data-pane="${paneIndex}"]`);
622
+ if (saveBtn) saveBtn.classList.remove('hidden');
623
+ }
624
+
625
+ function setupSyncScroll() {
626
+ if (!window.SYNC_SCROLL) return;
627
+
628
+ const panes = document.querySelectorAll('.panel-content');
629
+ let isSyncing = false;
630
+
631
+ panes.forEach(sourcePane => {
632
+ sourcePane.addEventListener('scroll', () => {
633
+ if (isSyncing || !window.SYNC_SCROLL) return;
634
+
635
+ isSyncing = true;
636
+ requestAnimationFrame(() => {
637
+ const scrollTop = sourcePane.scrollTop;
638
+ const scrollLeft = sourcePane.scrollLeft;
639
+
640
+ panes.forEach(targetPane => {
641
+ if (targetPane !== sourcePane) {
642
+ targetPane.scrollTop = scrollTop;
643
+ targetPane.scrollLeft = scrollLeft;
644
+ }
645
+ });
646
+
647
+ isSyncing = false;
648
+ });
649
+ });
650
+ });
651
+ }
652
+
653
+ // Setup keyboard shortcuts
654
+ function setupKeyboardShortcuts() {
655
+ document.addEventListener('keydown', (e) => {
656
+ const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
657
+ const cmdKey = isMac ? e.metaKey : e.ctrlKey;
658
+
659
+ if (!cmdKey) return;
660
+
661
+ // Ctrl/Cmd+S: Save focused pane
662
+ if (e.key === 's' && !e.shiftKey) {
663
+ e.preventDefault();
664
+ if (!window.EDIT_MODE) return;
665
+
666
+ const activeElement = document.activeElement;
667
+ if (activeElement && activeElement.classList.contains('config-editor')) {
668
+ const paneIndex = parseInt(activeElement.dataset.pane);
669
+ savePane(paneIndex);
670
+ }
671
+ }
672
+
673
+ // Ctrl/Cmd+Shift+S: Save all panes
674
+ if (e.key === 'S' || (e.key === 's' && e.shiftKey)) {
675
+ e.preventDefault();
676
+ if (!window.EDIT_MODE || window.DIRTY_PANES.size === 0) return;
677
+ saveAllPanes();
678
+ }
679
+
680
+ // Ctrl/Cmd+E: Toggle edit mode
681
+ if (e.key === 'e' || e.key === 'E') {
682
+ e.preventDefault();
683
+ toggleEditMode();
684
+ }
685
+ });
686
+ }
687
+
688
+ // Warn about unsaved changes on page unload
689
+ function setupBeforeUnload() {
690
+ window.addEventListener('beforeunload', (e) => {
691
+ if (window.DIRTY_PANES.size > 0) {
692
+ e.preventDefault();
693
+ e.returnValue = '';
694
+ return '';
695
+ }
696
+ });
697
+ }
698
+
699
+ document.addEventListener('DOMContentLoaded', () => {
700
+ document.querySelectorAll('textarea.config-editor').forEach(textarea => {
701
+ const paneIndex = parseInt(textarea.dataset.pane);
702
+ textarea.addEventListener('input', () => onContentChange(paneIndex));
703
+ });
704
+ setupSyncScroll();
705
+ setupKeyboardShortcuts();
706
+ setupBeforeUnload();
707
+ updateSaveAllButton();
708
+ });
709
+ </script>
710
+ '''
711
+
712
+ def _get_template_path(self) -> Path:
713
+ """Override to use shared template"""
714
+ return Path(__file__).parent.parent / 'shared' / 'templates' / 'base.html'
715
+
716
+ def do_GET(self):
717
+ """Handle GET requests"""
718
+ parsed = urllib.parse.urlparse(self.path)
719
+ path = parsed.path
720
+ query = urllib.parse.parse_qs(parsed.query)
721
+
722
+ # Health check
723
+ if path == '/health':
724
+ self.handle_health_check('config-inspector', ['multi-pane', 'edit-mode', 'sync-scroll'])
725
+ return
726
+
727
+ # API: List all master configs
728
+ if path == '/api/masters':
729
+ master_configs = self.discover_master_configs()
730
+ self.send_json_response({
731
+ 'status': 'ok',
732
+ 'count': len(master_configs),
733
+ 'configs': master_configs
734
+ })
735
+ return
736
+
737
+ # API: Get specific config content
738
+ if path == '/api/config':
739
+ config_path = query.get('path', [''])[0]
740
+ if not config_path:
741
+ self.send_json_response({
742
+ 'status': 'error',
743
+ 'message': 'Missing path parameter'
744
+ }, 400)
745
+ return
746
+
747
+ content, content_hash = self.read_file_content(config_path)
748
+ if content is None:
749
+ self.send_json_response({
750
+ 'status': 'error',
751
+ 'message': f'Config not found: {config_path}'
752
+ }, 404)
753
+ return
754
+
755
+ self.send_json_response({
756
+ 'status': 'ok',
757
+ 'path': config_path,
758
+ 'content': content,
759
+ 'hash': content_hash
760
+ })
761
+ return
762
+
763
+ # Static files
764
+ if path.startswith('/static/'):
765
+ static_path = Path(__file__).parent.parent / 'shared' / path[1:]
766
+ if static_path.exists():
767
+ with open(static_path, 'r', encoding='utf-8') as f:
768
+ content = f.read()
769
+ self.send_css_response(content)
770
+ else:
771
+ self.send_error_response(404, 'Not found')
772
+ return
773
+
774
+ # Main page or compare view
775
+ if path in ('/', '/compare'):
776
+ master_configs = self.discover_master_configs()
777
+
778
+ # Parse multi-master parameters
779
+ selected_paths, error_msg = self.parse_multi_master_params(query)
780
+
781
+ if error_msg:
782
+ sidebar = self.render_config_tree(master_configs)
783
+ error_html = f'<div class="empty-state"><div class="empty-state-icon">❌</div><p>{self.escape_html(error_msg)}</p></div>'
784
+ csrf_token = self.generate_csrf_token()
785
+ self.send_html_response(self.render_page(
786
+ 'Config Inspector',
787
+ error_html,
788
+ icon='⚙️',
789
+ sidebar=sidebar,
790
+ scripts=f'<script>window.CSRF_TOKEN = "{csrf_token}";</script>'
791
+ ))
792
+ return
793
+
794
+ # Get edit and sync params
795
+ edit_mode = query.get('edit', ['false'])[0].lower() == 'true'
796
+ sync_scroll = query.get('sync', ['false'])[0].lower() == 'true'
797
+
798
+ # If no selection, use first config as default
799
+ if not selected_paths and master_configs:
800
+ # Prefer Project Root configs first
801
+ root_configs = [c for c in master_configs if c['category'] == 'Project Root']
802
+ if root_configs:
803
+ selected_paths = [root_configs[0]['path']]
804
+ else:
805
+ selected_paths = [master_configs[0]['path']]
806
+
807
+ # Build sidebar with multi-select support
808
+ sidebar = self.render_config_tree(master_configs, selected_paths)
809
+
810
+ # Generate CSRF token
811
+ csrf_token = self.generate_csrf_token()
812
+
813
+ # Build header controls
814
+ edit_toggle_class = 'active' if edit_mode else ''
815
+ sync_checked = 'checked' if sync_scroll else ''
816
+ has_selection = selected_paths and len(selected_paths) > 0
817
+
818
+ header_controls = f'''
819
+ <div class="header-controls">
820
+ <label class="sync-scroll-label">
821
+ <input type="checkbox" id="syncScrollCheckbox" {sync_checked} onchange="toggleSyncScroll()">
822
+ <span>🔗 Sync Scroll</span>
823
+ </label>
824
+ <button class="edit-mode-btn {edit_toggle_class}" id="editModeBtn" onclick="toggleEditMode()" title="Ctrl+E">
825
+ ✏️ Edit Mode
826
+ </button>
827
+ <button class="save-all-btn hidden" id="saveAllBtn" onclick="saveAllPanes()" title="Ctrl+Shift+S">
828
+ 💾 Save All
829
+ </button>
830
+ {f'<button class="clear-selection-btn" onclick="clearSelection()">❌ Clear</button>' if has_selection else ''}
831
+ </div>
832
+ '''
833
+
834
+ # Build multi-pane content
835
+ if selected_paths:
836
+ content_html = self.render_multi_pane_view(
837
+ selected_paths, edit_mode, sync_scroll, csrf_token
838
+ )
839
+ else:
840
+ content_html = '<div class="empty-state"><div class="empty-state-icon">⚙️</div><p>No master configs found</p></div>'
841
+
842
+ # Check service status for other IDE components
843
+ resource_status = self.check_resource_status()
844
+
845
+ # Get JavaScript
846
+ scripts = self.get_main_javascript(edit_mode)
847
+
848
+ # Build the complete page
849
+ page = self.render_page(
850
+ 'Config Inspector',
851
+ content_html,
852
+ icon='⚙️',
853
+ sidebar=sidebar,
854
+ controls=header_controls,
855
+ scripts=scripts
856
+ )
857
+
858
+ # Add resource status placeholders if they exist in template
859
+ page = page.replace('{files_available}', 'true' if resource_status.get('files') else 'false')
860
+ page = page.replace('{viewer_available}', 'true' if resource_status.get('viewer') else 'false')
861
+ page = page.replace('{terminal_available}', 'true' if resource_status.get('terminal') else 'false')
862
+
863
+ self.send_html_response(page)
864
+ return
865
+
866
+ # 404
867
+ self.send_html_response(self.render_page(
868
+ 'Not Found',
869
+ '<div class="empty-state"><div class="empty-state-icon">❓</div>Page not found</div>',
870
+ icon='❓'
871
+ ), 404)
872
+
873
+ def do_POST(self):
874
+ """Handle POST requests for save operations"""
875
+ parsed = urllib.parse.urlparse(self.path)
876
+ path = parsed.path
877
+
878
+ # Validate Content-Length header for DoS prevention
879
+ content_length = self.headers.get('Content-Length')
880
+ is_valid, msg = ConfigService.validate_request_content_length(content_length)
881
+ if not is_valid:
882
+ self.send_json_response({
883
+ 'success': False,
884
+ 'message': f'Invalid request: {msg}'
885
+ }, 400)
886
+ return
887
+
888
+ # Read request body
889
+ content_length = int(content_length)
890
+ body = self.rfile.read(content_length).decode('utf-8')
891
+
892
+ try:
893
+ data = json.loads(body)
894
+ except json.JSONDecodeError:
895
+ self.send_json_response({
896
+ 'success': False,
897
+ 'message': 'Invalid JSON in request body'
898
+ }, 400)
899
+ return
900
+
901
+ # Validate CSRF token
902
+ csrf_token = data.get('csrf_token', '')
903
+ if not self.validate_csrf_token(csrf_token):
904
+ self.send_json_response({
905
+ 'success': False,
906
+ 'message': 'Invalid CSRF token'
907
+ }, 403)
908
+ return
909
+
910
+ # Handle save endpoints
911
+ if path == '/save':
912
+ self._handle_save_single(data)
913
+ elif path == '/save-all':
914
+ self._handle_save_all(data)
915
+ else:
916
+ self.send_json_response({
917
+ 'success': False,
918
+ 'message': 'Unknown endpoint'
919
+ }, 404)
920
+
921
+ def _handle_save_single(self, data: dict):
922
+ """Handle single file save"""
923
+ file_path = data.get('path', '')
924
+ content = data.get('content', '')
925
+ original_hash = data.get('original_hash', '')
926
+
927
+ # Validate path
928
+ is_valid, msg = self.validate_url_path(file_path)
929
+ if not is_valid:
930
+ self.send_json_response({
931
+ 'success': False,
932
+ 'message': f'Invalid path: {msg}'
933
+ }, 400)
934
+ return
935
+
936
+ # Validate file type
937
+ is_valid, msg = ConfigService.validate_file_type(file_path)
938
+ if not is_valid:
939
+ self.send_json_response({
940
+ 'success': False,
941
+ 'message': msg
942
+ }, 400)
943
+ return
944
+
945
+ # Resolve path
946
+ resolved = ConfigService.validate_write_path(file_path)
947
+ if not resolved:
948
+ self.send_json_response({
949
+ 'success': False,
950
+ 'message': 'Path validation failed'
951
+ }, 400)
952
+ return
953
+
954
+ # Perform atomic write
955
+ result = ConfigService.atomic_write(resolved, content, original_hash, create_backup=True)
956
+
957
+ # Compute new hash if successful
958
+ new_hash = None
959
+ if result.success:
960
+ new_hash = ConfigService.compute_content_hash(content)
961
+
962
+ self.send_json_response({
963
+ 'success': result.success,
964
+ 'message': result.message,
965
+ 'new_hash': new_hash,
966
+ 'backup_path': str(result.backup_path) if result.backup_path else None
967
+ }, 200 if result.success else 409)
968
+
969
+ def _handle_save_all(self, data: dict):
970
+ """Handle batch save of multiple files"""
971
+ files = data.get('files', [])
972
+
973
+ if not isinstance(files, list):
974
+ self.send_json_response({
975
+ 'success': False,
976
+ 'message': 'Invalid request: files must be an array'
977
+ }, 400)
978
+ return
979
+
980
+ results = []
981
+ all_success = True
982
+
983
+ for file_data in files:
984
+ file_path = file_data.get('path', '')
985
+ content = file_data.get('content', '')
986
+ original_hash = file_data.get('original_hash', '')
987
+
988
+ # Validate
989
+ is_valid, msg = self.validate_url_path(file_path)
990
+ if not is_valid:
991
+ results.append({
992
+ 'path': file_path,
993
+ 'success': False,
994
+ 'message': f'Invalid path: {msg}'
995
+ })
996
+ all_success = False
997
+ continue
998
+
999
+ is_valid, msg = ConfigService.validate_file_type(file_path)
1000
+ if not is_valid:
1001
+ results.append({
1002
+ 'path': file_path,
1003
+ 'success': False,
1004
+ 'message': msg
1005
+ })
1006
+ all_success = False
1007
+ continue
1008
+
1009
+ # Save
1010
+ resolved = ConfigService.validate_write_path(file_path)
1011
+ if not resolved:
1012
+ results.append({
1013
+ 'path': file_path,
1014
+ 'success': False,
1015
+ 'message': 'Path validation failed'
1016
+ })
1017
+ all_success = False
1018
+ continue
1019
+
1020
+ result = ConfigService.atomic_write(resolved, content, original_hash, create_backup=True)
1021
+
1022
+ results.append({
1023
+ 'path': file_path,
1024
+ 'success': result.success,
1025
+ 'message': result.message,
1026
+ 'new_hash': ConfigService.compute_content_hash(content) if result.success else None
1027
+ })
1028
+
1029
+ if not result.success:
1030
+ all_success = False
1031
+
1032
+ self.send_json_response({
1033
+ 'success': all_success,
1034
+ 'message': f'{sum(1 for r in results if r["success"])}/{len(results)} files saved',
1035
+ 'results': results
1036
+ }, 200 if all_success else 207) # 207 Multi-Status for partial success
1037
+
1038
+
1039
+ def main():
1040
+ """Run the config inspector server"""
1041
+ run_server(
1042
+ ConfigInspectorHandler,
1043
+ PORT,
1044
+ '⚙️ Tilt IDE Config Inspector',
1045
+ '🔍 Compare master .star configs with auto-generated outputs\n ✏️ Multi-pane editing with Ctrl+S, Ctrl+Shift+S, Ctrl+E'
1046
+ )
1047
+
1048
+
1049
+ if __name__ == '__main__':
1050
+ main()