@tdk-landscape/tdk-cli-core 1.3.91 → 1.3.92
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/cli/dist/backend-frameworks/mcp.js +87 -0
- package/cli/dist/backend-frameworks/registry.js +16 -0
- package/cli/dist/commands/completion.js +3 -3
- package/cli/dist/commands/doctor.js +8 -2
- package/cli/dist/commands/networks.js +2 -1
- package/cli/dist/commands/project.js +15 -2
- package/cli/dist/commands/resource.js +19 -13
- package/cli/dist/commands/up.js +37 -1
- package/cli/dist/types/index.js +1 -0
- package/cli/dist/utils/constants.js +4 -0
- package/cli/dist/utils/doctor-runtime.js +3 -2
- package/cli/dist/utils/doctor-wiring.js +2 -1
- package/cli/dist/utils/env-validator.js +118 -30
- package/cli/dist/utils/resource-kind.js +8 -0
- package/cli/dist/utils/service-manifest.js +23 -3
- package/cli/dist/utils/service-urls.js +4 -3
- package/cli/dist/utils/services.js +2 -1
- package/cli/dist/utils/smoke.js +225 -0
- package/cli/package.json +1 -1
- package/engine/schemas/service-schema.json +73 -27
- package/engine/topologies/platform/docker/auth/auth.star +66 -28
- package/engine/topologies/platform/docker/compose/compose.star +8 -26
- package/engine/topologies/platform/docker/layers/l3_builder_layers.star +3 -0
- package/engine/topologies/platform/docker/layers/l4_runtime_layers.star +3 -0
- package/engine/topologies/platform/docker/networking/api_path_constants.star +1 -57
- package/engine/topologies/platform/docker/secrets/env_secrets.star +72 -0
- package/engine/topologies/tilt/common/utils.star +0 -2
- package/engine/topologies/tilt/common/utils_env.star +72 -25
- package/engine/topologies/tilt/manifest/constants.star +2 -0
- package/engine/topologies/tilt/manifest/json_to_yaml.py +4 -0
- package/engine/topologies/tilt/manifest/loader.star +7 -0
- package/engine/topologies/tilt/resources/orchestrator/generators/env.star +43 -96
- package/package.json +1 -1
|
@@ -3,39 +3,86 @@
|
|
|
3
3
|
# =============================================================================
|
|
4
4
|
|
|
5
5
|
|
|
6
|
+
_DOTENV_NAME_FIRST = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_'
|
|
7
|
+
_DOTENV_NAME_REST = _DOTENV_NAME_FIRST + '0123456789'
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _is_dotenv_name(name):
|
|
11
|
+
if not name or name[0] not in _DOTENV_NAME_FIRST:
|
|
12
|
+
return False
|
|
13
|
+
for i in range(len(name)):
|
|
14
|
+
if name[i] not in _DOTENV_NAME_REST:
|
|
15
|
+
return False
|
|
16
|
+
return True
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _dotenv_value(raw):
|
|
20
|
+
"""One layer of matching quotes is removed, like a dotenv parser would.
|
|
21
|
+
|
|
22
|
+
Single quotes are literal. Inside double quotes a backslash escapes a quote, a backslash or a dollar sign, and \\n, \\r and \\t
|
|
23
|
+
become a newline, a carriage return and a tab. An unquoted value is used as is (inline comments are not stripped).
|
|
24
|
+
This reads the project .env; it does not undo the `$$` that the generated env files use for Docker Compose.
|
|
25
|
+
"""
|
|
26
|
+
value = raw.strip()
|
|
27
|
+
if len(value) < 2 or value[0] not in ('"', "'") or value[-1] != value[0]:
|
|
28
|
+
return value
|
|
29
|
+
|
|
30
|
+
inner = value[1:-1]
|
|
31
|
+
if value[0] == "'":
|
|
32
|
+
return inner
|
|
33
|
+
|
|
34
|
+
result = ''
|
|
35
|
+
i = 0
|
|
36
|
+
while i < len(inner):
|
|
37
|
+
char = inner[i]
|
|
38
|
+
if char == '\\' and i + 1 < len(inner):
|
|
39
|
+
following = inner[i + 1]
|
|
40
|
+
if following == 'n':
|
|
41
|
+
result += '\n'
|
|
42
|
+
elif following == 'r':
|
|
43
|
+
result += '\r'
|
|
44
|
+
elif following == 't':
|
|
45
|
+
result += '\t'
|
|
46
|
+
elif following in ('"', '\\', '$'):
|
|
47
|
+
result += following
|
|
48
|
+
else:
|
|
49
|
+
result += char + following
|
|
50
|
+
i += 2
|
|
51
|
+
continue
|
|
52
|
+
result += char
|
|
53
|
+
i += 1
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
|
|
6
57
|
def load_dotenv(project_root=''):
|
|
7
|
-
"""Load .env file into os.environ.
|
|
58
|
+
"""Load the project .env file into os.environ.
|
|
8
59
|
|
|
9
60
|
The Tiltfile lives in `.tdk/.tdk-out/`, so a bare `.env` lookup misses the
|
|
10
61
|
project-root file. Prefer an explicit project_root, then TDK_PROJECT_ROOT.
|
|
62
|
+
|
|
63
|
+
The file is read with `read_file` and parsed here: nothing goes through a shell, so a path containing a quote is fine, and Tilt
|
|
64
|
+
re-evaluates the Tiltfile when the .env changes. Lines may be `NAME=value` or `export NAME=value`; comments, blank lines and
|
|
65
|
+
lines that are not an assignment to a valid name are ignored. A missing file does nothing.
|
|
11
66
|
"""
|
|
12
67
|
if not project_root:
|
|
13
68
|
project_root = os.environ.get('TDK_PROJECT_ROOT', '')
|
|
14
69
|
env_path = (project_root + '/.env') if project_root else '.env'
|
|
15
|
-
content = str(
|
|
16
|
-
if content:
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
{missing_vars}
|
|
32
|
-
|
|
33
|
-
Please set these variables in your environment or .env file.
|
|
34
|
-
Example:
|
|
35
|
-
export INFISICAL_CLIENT_ID=your_client_id
|
|
36
|
-
export INFISICAL_CLIENT_SECRET=your_client_secret
|
|
37
|
-
export INFISICAL_PROJECT_ID=your_project_id
|
|
38
|
-
""".format(missing_vars=", ".join(missing_vars)))
|
|
70
|
+
content = str(read_file(env_path, default=''))
|
|
71
|
+
if not content:
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
for line in content.split('\n'):
|
|
75
|
+
line = line.strip()
|
|
76
|
+
if not line or line.startswith('#'):
|
|
77
|
+
continue
|
|
78
|
+
if line.startswith('export '):
|
|
79
|
+
line = line[len('export '):].strip()
|
|
80
|
+
equals = line.find('=')
|
|
81
|
+
if equals <= 0:
|
|
82
|
+
continue
|
|
83
|
+
name = line[:equals].strip()
|
|
84
|
+
if _is_dotenv_name(name):
|
|
85
|
+
os.environ[name] = _dotenv_value(line[equals + 1:])
|
|
39
86
|
|
|
40
87
|
|
|
41
88
|
def should_enable(resource_name, cfg, defaults):
|
|
@@ -61,6 +61,7 @@ VALID_RESOURCE_TYPES = [
|
|
|
61
61
|
"sdk",
|
|
62
62
|
"worker",
|
|
63
63
|
"infra",
|
|
64
|
+
"mcp",
|
|
64
65
|
"bring-your-own",
|
|
65
66
|
]
|
|
66
67
|
|
|
@@ -88,6 +89,7 @@ PORT_RANGES = {
|
|
|
88
89
|
"frontend": {"min": 3000, "max": 5999},
|
|
89
90
|
"backend": {"min": 4000, "max": 5999},
|
|
90
91
|
"bring-your-own": {"min": 4000, "max": 5999},
|
|
92
|
+
"mcp": {"min": 4000, "max": 5999},
|
|
91
93
|
"worker": {"min": 6000, "max": 6999},
|
|
92
94
|
"migrator": {"min": 7000, "max": 7999},
|
|
93
95
|
"sdk": {"min": 3000, "max": 9999},
|
|
@@ -15,6 +15,10 @@ try:
|
|
|
15
15
|
with open(json_file, 'r') as f:
|
|
16
16
|
data = json.load(f)
|
|
17
17
|
|
|
18
|
+
# The YAML is a generated, committed mirror used only for Tilt resource tracking. A secret that someone put in service.json must
|
|
19
|
+
# not be copied into it (the JWT secret belongs in the project .env; `tdk doctor` warns about it).
|
|
20
|
+
data = {key: value for key, value in data.items() if key not in ("jwtSecret",)}
|
|
21
|
+
|
|
18
22
|
now = datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
|
|
19
23
|
uid = str(uuid.uuid4())
|
|
20
24
|
|
|
@@ -64,6 +64,13 @@ def load_from_file(path):
|
|
|
64
64
|
if manifest == None:
|
|
65
65
|
return struct(manifest=None, error="Invalid manifest: " + path)
|
|
66
66
|
|
|
67
|
+
# An `mcp` resource is a Model Context Protocol server: an HTTP service on Bun that is built, routed and health-checked
|
|
68
|
+
# exactly like a backend. Every backend code path keys off appType == 'backend', so normalise here, once, at the single
|
|
69
|
+
# place a service.json becomes a manifest, and keep a flag so the original type is not lost.
|
|
70
|
+
if type(manifest) == "dict" and manifest.get("appType") == "mcp":
|
|
71
|
+
manifest["appType"] = "backend"
|
|
72
|
+
manifest["mcp"] = True
|
|
73
|
+
|
|
67
74
|
return struct(manifest=manifest, error=None)
|
|
68
75
|
|
|
69
76
|
def _synthesize_manifest(resource_path, stack, app_type):
|
|
@@ -80,11 +80,45 @@ def _build_dependency_env_lines(manifest):
|
|
|
80
80
|
return lines
|
|
81
81
|
|
|
82
82
|
|
|
83
|
+
# Characters that can appear in a dotenv value without quoting.
|
|
84
|
+
_ENV_BARE_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-./:@%+=,'
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _format_env_value(value):
|
|
88
|
+
"""Format a param value so the generated file stays valid dotenv.
|
|
89
|
+
|
|
90
|
+
A plain value stays bare. Anything else (a space, `#`, `$`, a quote, a backslash, a newline, or an empty string) is wrapped in double
|
|
91
|
+
quotes with backslash, quote and newline escaped, which is how dotenv parsers (including Docker Compose's) read it back.
|
|
92
|
+
|
|
93
|
+
`$` is written as `$$`: Docker Compose expands `$NAME` and `${NAME}` in env files, so an unescaped dollar would silently pick up
|
|
94
|
+
some other variable (checked in a real container: "price $$FOO" arrives as the literal `price $FOO`).
|
|
95
|
+
"""
|
|
96
|
+
text = str(value)
|
|
97
|
+
bare = text != ''
|
|
98
|
+
for i in range(len(text)):
|
|
99
|
+
if text[i] not in _ENV_BARE_CHARS:
|
|
100
|
+
bare = False
|
|
101
|
+
break
|
|
102
|
+
if bare:
|
|
103
|
+
return text
|
|
104
|
+
|
|
105
|
+
escaped = text.replace('\\', '\\\\').replace('"', '\\"').replace('$', '$$').replace('\r', '\\r').replace('\n', '\\n')
|
|
106
|
+
return '"' + escaped + '"'
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _uses_prisma(manifest):
|
|
110
|
+
"""Prisma is opt-in: an explicit usePrisma, or `prisma` in featuresEnabled (the same rule the Dockerfile generator uses)."""
|
|
111
|
+
if manifest.get('usePrisma'):
|
|
112
|
+
return True
|
|
113
|
+
return 'prisma' in manifest.get('featuresEnabled', [])
|
|
114
|
+
|
|
115
|
+
|
|
83
116
|
def _generate_params_env(manifest, backend_manifest=None, write_fn=None):
|
|
84
117
|
"""Generate params (non-secret) environment variables from manifest.
|
|
85
118
|
|
|
86
|
-
This generates config like BASE_URL, PORT, etc. - NEVER secrets.
|
|
87
|
-
|
|
119
|
+
This generates config like BASE_URL, PORT, etc. - NEVER secrets. The file is generated and verified by `tdk config verify`, so
|
|
120
|
+
nothing secret may be written here: the JWT secret and `secrets.required` / `secrets.optional` reach the container from the
|
|
121
|
+
project `.env` through the Compose entry instead.
|
|
88
122
|
|
|
89
123
|
Args:
|
|
90
124
|
manifest: Service manifest dict with configuration
|
|
@@ -103,31 +137,10 @@ def _generate_params_env(manifest, backend_manifest=None, write_fn=None):
|
|
|
103
137
|
api_url = 'http://' + PlatformDockerConstants.LOCAL_DOMAIN + api_path
|
|
104
138
|
target_filename = '/' + OUTPUT.AUTOGENERATED_FOLDER + '/.env.backend.autogenerated'
|
|
105
139
|
|
|
106
|
-
# Auth configuration
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
|
|
110
|
-
internal_deps = manifest.get('dependsOn', []) if manifest else []
|
|
111
|
-
has_identity_dep = 'identity' in internal_deps
|
|
112
|
-
|
|
113
|
-
# If manifest has authMode, use it; otherwise auto-detect based on identity dependency
|
|
114
|
-
manifest_auth_mode = manifest.get('authMode') if manifest else None
|
|
115
|
-
if manifest_auth_mode:
|
|
116
|
-
auth_mode = manifest_auth_mode
|
|
117
|
-
elif has_identity_dep:
|
|
118
|
-
auth_mode = 'identity-service'
|
|
119
|
-
else:
|
|
120
|
-
auth_mode = None
|
|
121
|
-
|
|
122
|
-
auth_config = {
|
|
123
|
-
'authMode': auth_mode,
|
|
124
|
-
'jwtSecret': manifest.get('jwtSecret') if manifest else None,
|
|
125
|
-
'identityServiceUrl': manifest.get('identityServiceUrl') if manifest else None,
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
# If we auto-detected identity-service mode but no URL is set, use default
|
|
129
|
-
if auth_mode == 'identity-service' and not auth_config['identityServiceUrl']:
|
|
130
|
-
auth_config['identityServiceUrl'] = AuthConfig.get_identity_resource_url(None)
|
|
140
|
+
# Auth configuration: the same builder Compose uses, so the two outputs cannot disagree. A missing value is None and AuthConfig
|
|
141
|
+
# treats None as "use the default". `jwtSecret` is deliberately not read: a secret in a committed service.json would be copied into
|
|
142
|
+
# a generated file, so the secret comes from the project .env instead.
|
|
143
|
+
auth_config = AuthConfig.from_manifest(manifest)
|
|
131
144
|
|
|
132
145
|
if app_type == 'frontend':
|
|
133
146
|
backend_port = backend_manifest.get('port', BASE_PORT_BACKEND) if backend_manifest else BASE_PORT_BACKEND
|
|
@@ -161,7 +174,7 @@ DATABASE_NAME={db_name}
|
|
|
161
174
|
API_URL={api_url}
|
|
162
175
|
""".format(port=port, db_name=db_name, api_url=api_url)
|
|
163
176
|
|
|
164
|
-
if manifest
|
|
177
|
+
if _uses_prisma(manifest):
|
|
165
178
|
content += """
|
|
166
179
|
# Prisma
|
|
167
180
|
DATABASE_URL=""" + PlatformDockerConstants.get_database_url_for_env(db_name) + """
|
|
@@ -171,14 +184,14 @@ DATABASE_URL=""" + PlatformDockerConstants.get_database_url_for_env(db_name) + "
|
|
|
171
184
|
content += AuthConfig.generate_env_file_auth_section(auth_config)
|
|
172
185
|
|
|
173
186
|
# Add params (non-secret config variables)
|
|
174
|
-
# NOTE: Secrets are NEVER added here
|
|
187
|
+
# NOTE: Secrets are NEVER added here (see the docstring).
|
|
175
188
|
params = manifest.get('params', manifest.get('envVars', {})) # Fallback to envVars during migration
|
|
176
189
|
if params:
|
|
177
190
|
content += '\n# Params (non-secret config)\n'
|
|
178
191
|
# Sort keys for deterministic output to avoid unnecessary file rewrites.
|
|
179
192
|
for key in sorted(params.keys()):
|
|
180
193
|
value = params.get(key)
|
|
181
|
-
content += key + '=' +
|
|
194
|
+
content += key + '=' + _format_env_value(value) + '\n'
|
|
182
195
|
|
|
183
196
|
if write_fn and resource_path:
|
|
184
197
|
write_fn(resource_path + target_filename, content)
|
|
@@ -186,74 +199,8 @@ DATABASE_URL=""" + PlatformDockerConstants.get_database_url_for_env(db_name) + "
|
|
|
186
199
|
return content
|
|
187
200
|
|
|
188
201
|
|
|
189
|
-
def _generate_secrets_env(manifest, write_fn=None):
|
|
190
|
-
"""Generate secrets environment variables from Infisical.
|
|
191
|
-
|
|
192
|
-
This function returns a dict of secrets that will be resolved
|
|
193
|
-
from Infisical at runtime. The actual values are NOT embedded here.
|
|
194
|
-
|
|
195
|
-
CRITICAL: This function NEVER reads secrets from files - only from Infisical.
|
|
196
|
-
|
|
197
|
-
Args:
|
|
198
|
-
manifest: Service manifest dict with secrets configuration
|
|
199
|
-
write_fn: Optional write function for file output (callable)
|
|
200
|
-
|
|
201
|
-
Returns:
|
|
202
|
-
dict: Map of secret names to Infisical placeholder values
|
|
203
|
-
"""
|
|
204
|
-
resource_path = manifest.get('_resource_path', '')
|
|
205
|
-
secrets_config = manifest.get('secrets', {})
|
|
206
|
-
|
|
207
|
-
if not secrets_config:
|
|
208
|
-
return {}
|
|
209
|
-
|
|
210
|
-
required = secrets_config.get('required', [])
|
|
211
|
-
optional = secrets_config.get('optional', [])
|
|
212
|
-
|
|
213
|
-
# Build a placeholder map - actual values come from Infisical at runtime
|
|
214
|
-
secrets_env = {}
|
|
215
|
-
|
|
216
|
-
for secret_name in required:
|
|
217
|
-
# Placeholder - value resolved by Infisical
|
|
218
|
-
secrets_env[secret_name] = "${INFISICAL_SECRET:" + secret_name + "}"
|
|
219
|
-
|
|
220
|
-
for secret_name in optional:
|
|
221
|
-
# Placeholder - value resolved by Infisical
|
|
222
|
-
secrets_env[secret_name] = "${INFISICAL_SECRET:" + secret_name + "}"
|
|
223
|
-
|
|
224
|
-
return secrets_env
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
def _get_all_env(manifest, backend_manifest=None, write_fn=None):
|
|
228
|
-
"""Generate complete environment (params + secrets placeholder).
|
|
229
|
-
|
|
230
|
-
Returns a dict with both params and secrets keys.
|
|
231
|
-
Actual secret values resolved by Infisical at container runtime.
|
|
232
|
-
|
|
233
|
-
Args:
|
|
234
|
-
manifest: Service manifest dict with configuration
|
|
235
|
-
backend_manifest: Optional backend manifest for frontend services
|
|
236
|
-
write_fn: Optional write function for file output (callable)
|
|
237
|
-
|
|
238
|
-
Returns:
|
|
239
|
-
dict: Contains 'params' (str) and 'secrets' (dict) keys
|
|
240
|
-
"""
|
|
241
|
-
# Phase 1: Generate params (non-secret config)
|
|
242
|
-
params_content = _generate_params_env(manifest, backend_manifest, write_fn)
|
|
243
|
-
|
|
244
|
-
# Phase 2: Get secrets configuration (resolved by Infisical)
|
|
245
|
-
secrets_env = _generate_secrets_env(manifest, write_fn)
|
|
246
|
-
|
|
247
|
-
return {
|
|
248
|
-
'params': params_content,
|
|
249
|
-
'secrets': secrets_env,
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
|
|
253
202
|
EnvGenerators = struct(
|
|
254
203
|
generate_params_env = _generate_params_env,
|
|
255
|
-
generate_secrets_env = _generate_secrets_env,
|
|
256
|
-
get_all_env = _get_all_env,
|
|
257
204
|
# Legacy alias for backward compatibility during migration
|
|
258
205
|
generate_env_file = _generate_params_env,
|
|
259
206
|
)
|