@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.
- package/Tiltfile +144 -0
- package/discovery/AGENTS.md +362 -0
- package/discovery/TILT_DISCOVERY.star +424 -0
- package/discovery/Tiltfile +58 -0
- package/discovery/config.star +258 -0
- package/discovery/constants.star +127 -0
- package/discovery/deterministic.star +211 -0
- package/discovery/discovery_daemon.star +191 -0
- package/discovery/discovery_orchestrator.star +460 -0
- package/discovery/filtering.star +5 -0
- package/discovery/identity-management-backend/service.yaml +26 -0
- package/discovery/identity-management-frontend/service.yaml +29 -0
- package/discovery/identity-sdk/service.yaml +9 -0
- package/discovery/incremental_discovery.star +213 -0
- package/discovery/indexing.star +7 -0
- package/discovery/json_manifest_scanner.star +51 -0
- package/discovery/libraries.star +46 -0
- package/discovery/loading.star +23 -0
- package/discovery/manifest/constants.star +104 -0
- package/discovery/manifest/loading.star +386 -0
- package/discovery/manifest/normalize.star +281 -0
- package/discovery/manifest/validation.star +108 -0
- package/discovery/normalization.star +11 -0
- package/discovery/registry.star +544 -0
- package/discovery/resource_snapshot.py +382 -0
- package/discovery/resource_snapshot.star +143 -0
- package/discovery/schemas.star +1 -0
- package/discovery/services/platform/cdc/debezium/nats-connector/service.yaml +9 -0
- package/discovery/services/platform/cdc/nats-http-bridge/service.yaml +9 -0
- package/discovery/services/platform/orchestrator/orchestrator-glue-backend/service.yaml +26 -0
- package/discovery/services/platform/synthetic-monitor/service.yaml +9 -0
- package/discovery/test_resource_snapshot.py +251 -0
- package/discovery/validation.star +78 -0
- package/discovery/versioning.star +1 -0
- package/dist/commands/config.d.ts.map +1 -1
- package/dist/commands/config.js +11 -0
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +1 -2
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/project.d.ts.map +1 -1
- package/dist/commands/project.js +36 -41
- package/dist/commands/project.js.map +1 -1
- package/dist/commands/resource.d.ts.map +1 -1
- package/dist/commands/resource.js +51 -55
- package/dist/commands/resource.js.map +1 -1
- package/dist/commands/stack.d.ts.map +1 -1
- package/dist/commands/stack.js +22 -24
- package/dist/commands/stack.js.map +1 -1
- package/dist/commands/ui.d.ts.map +1 -1
- package/dist/commands/ui.js +1 -2
- package/dist/commands/ui.js.map +1 -1
- package/dist/commands/up.d.ts.map +1 -1
- package/dist/commands/up.js +1 -2
- package/dist/commands/up.js.map +1 -1
- package/dist/commands/upgrade.d.ts +8 -0
- package/dist/commands/upgrade.d.ts.map +1 -1
- package/dist/commands/upgrade.js +59 -18
- package/dist/commands/upgrade.js.map +1 -1
- package/dist/generator/extension-fetch.d.ts +19 -0
- package/dist/generator/extension-fetch.d.ts.map +1 -1
- package/dist/generator/extension-fetch.js +71 -5
- package/dist/generator/extension-fetch.js.map +1 -1
- package/dist/generator/template-engine.d.ts +1 -0
- package/dist/generator/template-engine.d.ts.map +1 -1
- package/dist/generator/template-engine.js +50 -6
- package/dist/generator/template-engine.js.map +1 -1
- package/dist/utils/command-helpers.d.ts.map +1 -1
- package/dist/utils/command-helpers.js +7 -9
- package/dist/utils/command-helpers.js.map +1 -1
- package/dist/utils/constants.d.ts +1 -0
- package/dist/utils/constants.d.ts.map +1 -1
- package/dist/utils/constants.js +1 -0
- package/dist/utils/constants.js.map +1 -1
- package/dist/utils/errors.d.ts +2 -0
- package/dist/utils/errors.d.ts.map +1 -1
- package/dist/utils/errors.js +6 -1
- package/dist/utils/errors.js.map +1 -1
- package/dist/utils/project-features.d.ts.map +1 -1
- package/dist/utils/project-features.js +1 -1
- package/dist/utils/project-features.js.map +1 -1
- package/dist/utils/resource-features.d.ts.map +1 -1
- package/dist/utils/resource-features.js +2 -2
- package/dist/utils/resource-features.js.map +1 -1
- package/dist/utils/tar.js +4 -4
- package/dist/utils/tar.js.map +1 -1
- package/dist/utils/validation.d.ts +7 -0
- package/dist/utils/validation.d.ts.map +1 -1
- package/dist/utils/validation.js +9 -0
- package/dist/utils/validation.js.map +1 -1
- package/engine/AGENTS.md +705 -0
- package/engine/README.md +29 -0
- package/engine/REFACTORING_MANIFEST_SYSTEM.md +974 -0
- package/engine/Tiltfile +80 -0
- package/engine/assets/docker/golden-layers.Dockerfile +221 -0
- package/engine/assets/scripts/cleanup-completed-migrator-images.sh +37 -0
- package/engine/assets/scripts/cleanup-tilt-images.sh +120 -0
- package/engine/assets/scripts/mac-m1-resource-monitor.sh +121 -0
- package/engine/config.star +3 -0
- package/engine/docs/AUTODISCOVERY_TEST.md +182 -0
- package/engine/docs/C4-ARCHITECTURE-operations.md +396 -0
- package/engine/docs/C4-ARCHITECTURE-overview.md +284 -0
- package/engine/docs/C4-ARCHITECTURE.md +17 -0
- package/engine/docs/GOLDEN_IMAGE_INTEGRATION.md +349 -0
- package/engine/docs/MANIFEST_DRIVEN_ARCHITECTURE.md +357 -0
- package/engine/docs/README.md +284 -0
- package/engine/entrypoint.star +97 -0
- package/engine/lifecycle/orchestrator.star +260 -0
- package/engine/paths.star +717 -0
- package/engine/schemas/manifest-schema.json +301 -0
- package/engine/schemas/service-schema.json +301 -0
- package/engine/scripts/README.md +89 -0
- package/engine/scripts/build-and-publish-lib.sh +205 -0
- package/engine/scripts/check-lib-publish.sh +85 -0
- package/engine/scripts/debug-container.sh +258 -0
- package/engine/scripts/generate-manifest.sh +197 -0
- package/engine/spec.master +702 -0
- package/engine/spec.master.example +341 -0
- package/engine/topologies/README.md +34 -0
- package/engine/topologies/platform/docker/AGENTS.md +59 -0
- package/engine/topologies/platform/docker/auth/auth.star +167 -0
- package/engine/topologies/platform/docker/build/golden_image.star +37 -0
- package/engine/topologies/platform/docker/build/golden_image_build.star +82 -0
- package/engine/topologies/platform/docker/build/golden_image_constants.star +93 -0
- package/engine/topologies/platform/docker/build/golden_image_dockerfile.star +291 -0
- package/engine/topologies/platform/docker/compose/compose.star +636 -0
- package/engine/topologies/platform/docker/compose/compose_helpers.star +52 -0
- package/engine/topologies/platform/docker/compose/traefik_standalone.star +53 -0
- package/engine/topologies/platform/docker/config/bunignore.star +142 -0
- package/engine/topologies/platform/docker/config/dockerignore.star +150 -0
- package/engine/topologies/platform/docker/constants.star +170 -0
- package/engine/topologies/platform/docker/dockerfile/dockerfile.star +54 -0
- package/engine/topologies/platform/docker/generators/golden_docker_generator_v2.star +73 -0
- package/engine/topologies/platform/docker/index.star +27 -0
- package/engine/topologies/platform/docker/layers/bun_helpers.star +23 -0
- package/engine/topologies/platform/docker/layers/infisical/index.star +40 -0
- package/engine/topologies/platform/docker/layers/infisical/infisical_docker.star +262 -0
- package/engine/topologies/platform/docker/layers/l1_base_layers.star +87 -0
- package/engine/topologies/platform/docker/layers/l2_dependency_layers.star +125 -0
- package/engine/topologies/platform/docker/layers/l3_builder_layers.star +209 -0
- package/engine/topologies/platform/docker/layers/l4_runtime_layers.star +214 -0
- package/engine/topologies/platform/docker/layers/prisma/constants.star +135 -0
- package/engine/topologies/platform/docker/layers/prisma/db_readiness.star +96 -0
- package/engine/topologies/platform/docker/layers/prisma/filesystem_setup.star +109 -0
- package/engine/topologies/platform/docker/layers/prisma/migrate_script.star +98 -0
- package/engine/topologies/platform/docker/layers/prisma/prisma_build.star +126 -0
- package/engine/topologies/platform/docker/layers/prisma/prisma_runtime.star +161 -0
- package/engine/topologies/platform/docker/layers/prisma/secret_resolution.star +119 -0
- package/engine/topologies/platform/docker/networking/api_path_constants.star +230 -0
- package/engine/topologies/platform/docker/networking/sablier_container_cycle.star +27 -0
- package/engine/topologies/platform/docker/networking/traefik.star +232 -0
- package/engine/topologies/platform/docker/networking/traefik_constants.star +146 -0
- package/engine/topologies/platform/docker/networking/traefik_helpers.star +160 -0
- package/engine/topologies/platform/docker/secrets/infisical.star +135 -0
- package/engine/topologies/platform/net/proxy.star +4 -0
- package/engine/topologies/platform/registries/local.star +4 -0
- package/engine/topologies/platform/security/secrets.star +239 -0
- package/engine/topologies/platform/state/relational.star +4 -0
- package/engine/topologies/tilt/common/AGENTS.md +59 -0
- package/engine/topologies/tilt/common/utils.star +364 -0
- package/engine/topologies/tilt/common/utils_debug.star +69 -0
- package/engine/topologies/tilt/common/utils_deterministic.star +168 -0
- package/engine/topologies/tilt/common/utils_docker_networks.star +46 -0
- package/engine/topologies/tilt/common/utils_env.star +43 -0
- package/engine/topologies/tilt/common/utils_output.star +97 -0
- package/engine/topologies/tilt/config/AGENTS.md +68 -0
- package/engine/topologies/tilt/config/defaults.star +3 -0
- package/engine/topologies/tilt/config/feature_flags.star +10 -0
- package/engine/topologies/tilt/config/global.star +55 -0
- package/engine/topologies/tilt/config/profiles.star +382 -0
- package/engine/topologies/tilt/generators/agents_md.star +62 -0
- package/engine/topologies/tilt/generators/api_gateway.star +145 -0
- package/engine/topologies/tilt/generators/index.star +124 -0
- package/engine/topologies/tilt/generators/infisical/README.md +424 -0
- package/engine/topologies/tilt/generators/infisical/examples.star +312 -0
- package/engine/topologies/tilt/generators/infisical/index.star +57 -0
- package/engine/topologies/tilt/generators/infisical/machine_identity.star +420 -0
- package/engine/topologies/tilt/generators/infisical/organization.star +534 -0
- package/engine/topologies/tilt/generators/infisical/path_manager.star +476 -0
- package/engine/topologies/tilt/generators/infisical/secrets_generator.star +420 -0
- package/engine/topologies/tilt/generators/nginx_conf.star +143 -0
- package/engine/topologies/tilt/generators/npmrc.star +111 -0
- package/engine/topologies/tilt/generators/tsconfig.star +23 -0
- package/engine/topologies/tilt/generators/typescript/backend_library_tsconfig.star +71 -0
- package/engine/topologies/tilt/generators/typescript/backend_tsconfig.star +124 -0
- package/engine/topologies/tilt/generators/typescript/common.star +7 -0
- package/engine/topologies/tilt/generators/typescript/frontend_library_tsconfig.star +76 -0
- package/engine/topologies/tilt/generators/typescript/frontend_tsconfig.star +110 -0
- package/engine/topologies/tilt/generators/typescript/include.star +23 -0
- package/engine/topologies/tilt/generators/typescript/playwright_config.star +40 -0
- package/engine/topologies/tilt/generators/typescript/prisma_config.star +49 -0
- package/engine/topologies/tilt/generators/validators.star +853 -0
- package/engine/topologies/tilt/generators/vite/backend.star +78 -0
- package/engine/topologies/tilt/generators/vite/frontend.star +98 -0
- package/engine/topologies/tilt/generators/vite/generators.star +43 -0
- package/engine/topologies/tilt/generators/vite/helpers.star +244 -0
- package/engine/topologies/tilt/generators/vite/library.star +65 -0
- package/engine/topologies/tilt/generators/vite/sdk.star +73 -0
- package/engine/topologies/tilt/generators/vite/templates.star +737 -0
- package/engine/topologies/tilt/generators/vite_config.star +22 -0
- package/engine/topologies/tilt/manifest/AGENTS.md +61 -0
- package/engine/topologies/tilt/manifest/README.md +693 -0
- package/engine/topologies/tilt/manifest/__init__.star +218 -0
- package/engine/topologies/tilt/manifest/constants.star +252 -0
- package/engine/topologies/tilt/manifest/errors.star +266 -0
- package/engine/topologies/tilt/manifest/integration.star +339 -0
- package/engine/topologies/tilt/manifest/json_to_yaml.py +54 -0
- package/engine/topologies/tilt/manifest/loader.star +214 -0
- package/engine/topologies/tilt/manifest/mapper.star +227 -0
- package/engine/topologies/tilt/manifest/parser.star +400 -0
- package/engine/topologies/tilt/manifest/schema.star +462 -0
- package/engine/topologies/tilt/manifest/tests/test_integration.star +276 -0
- package/engine/topologies/tilt/manifest/tests/test_loader_parser.star +397 -0
- package/engine/topologies/tilt/manifest/tests/test_manifest.star +316 -0
- package/engine/topologies/tilt/manifest/tests/test_validator.star +470 -0
- package/engine/topologies/tilt/manifest/validator.star +717 -0
- package/engine/topologies/tilt/manifest/yaml_to_json.py +18 -0
- package/engine/topologies/tilt/resources/AGENTS.md +71 -0
- package/engine/topologies/tilt/resources/conditions.star +3 -0
- package/engine/topologies/tilt/resources/databases.star +244 -0
- package/engine/topologies/tilt/resources/declaration.star +3 -0
- package/engine/topologies/tilt/resources/deps.star +3 -0
- package/engine/topologies/tilt/resources/infra-loader.star +437 -0
- package/engine/topologies/tilt/resources/libs.star +30 -0
- package/engine/topologies/tilt/resources/libs_discovery.star +102 -0
- package/engine/topologies/tilt/resources/libs_paths.star +79 -0
- package/engine/topologies/tilt/resources/libs_resource_definition.star +151 -0
- package/engine/topologies/tilt/resources/libs_setup.star +164 -0
- package/engine/topologies/tilt/resources/libs_tsconfig.star +37 -0
- package/engine/topologies/tilt/resources/orchestrator/apply.star +77 -0
- package/engine/topologies/tilt/resources/orchestrator/apply_compose_resource_registration.star +431 -0
- package/engine/topologies/tilt/resources/orchestrator/apply_manifest_orchestration.star +81 -0
- package/engine/topologies/tilt/resources/orchestrator/apply_migrator_orchestration.star +148 -0
- package/engine/topologies/tilt/resources/orchestrator/apply_resource_validation.star +36 -0
- package/engine/topologies/tilt/resources/orchestrator/apply_runtime_flags.star +44 -0
- package/engine/topologies/tilt/resources/orchestrator/builders/typescript.star +164 -0
- package/engine/topologies/tilt/resources/orchestrator/generators/dependencies/AGENTS.md +75 -0
- package/engine/topologies/tilt/resources/orchestrator/generators/dependencies/index.star +21 -0
- package/engine/topologies/tilt/resources/orchestrator/generators/dependencies/resolver.star +134 -0
- package/engine/topologies/tilt/resources/orchestrator/generators/env.star +258 -0
- package/engine/topologies/tilt/resources/orchestrator/generators/frontend.star +169 -0
- package/engine/topologies/tilt/resources/orchestrator/generators/manifest_resource.star +524 -0
- package/engine/topologies/tilt/resources/orchestrator/helpers.star +33 -0
- package/engine/topologies/tilt/resources/orchestrator.star +54 -0
- package/engine/topologies/tilt/resources/ordering.star +3 -0
- package/engine/topologies/tilt/resources/triggers.star +3 -0
- package/engine/topologies/tilt/resources/watchers.star +299 -0
- package/engine/where-things-live.star +717 -0
- package/ext/Tiltfile +66 -0
- package/ext/ide-components/README.md +219 -0
- package/ext/ide-components/code_executor/server.py +268 -0
- package/ext/ide-components/code_viewer/server.py +206 -0
- package/ext/ide-components/config_inspector/server.py +1050 -0
- package/ext/ide-components/file_browser/server.py +285 -0
- package/ext/ide-components/shared/config_service.py +469 -0
- package/ext/ide-components/shared/file_utils.py +298 -0
- package/ext/ide-components/shared/http_utils.py +232 -0
- package/ext/ide-components/shared/static/styles.css +1334 -0
- package/ext/ide-components/shared/syntax_highlighter.py +140 -0
- package/ext/ide-components/shared/template_engine.py +123 -0
- package/ext/ide-components/shared/templates/base.html +121 -0
- package/ext/ide-components/shared/test_config_service.py +326 -0
- package/ext/ide-components/shared/tilt_integration.py +158 -0
- package/ext/ui-enhancements/cron-status-server.sh +49 -0
- package/ext/ui-enhancements/cron-status-tracker.sh +146 -0
- package/ext/ui-enhancements/extension.yaml +36 -0
- package/ext/ui-enhancements/tiltfile.py +99 -0
- package/ext/ui-enhancements/ui/cron-jobs-tab.css +396 -0
- package/ext/ui-enhancements/ui/help-panel.css +279 -0
- package/ext/ui-enhancements/ui/icons.css +125 -0
- package/ext/ui-enhancements/ui/tooltips.css +114 -0
- package/package.json +13 -9
- package/specs/specs/TILT_SERVICE_DEFAULTS.star +205 -0
- 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()
|