@tdk-landscape/tdk-cli-core 1.3.90 → 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.
Files changed (36) hide show
  1. package/README.md +9 -0
  2. package/cli/dist/backend-frameworks/h3.js +38 -0
  3. package/cli/dist/backend-frameworks/koa.js +48 -0
  4. package/cli/dist/backend-frameworks/mcp.js +87 -0
  5. package/cli/dist/backend-frameworks/registry.js +20 -0
  6. package/cli/dist/commands/completion.js +3 -3
  7. package/cli/dist/commands/doctor.js +8 -2
  8. package/cli/dist/commands/networks.js +2 -1
  9. package/cli/dist/commands/project.js +15 -2
  10. package/cli/dist/commands/resource.js +20 -14
  11. package/cli/dist/commands/up.js +37 -1
  12. package/cli/dist/types/index.js +1 -0
  13. package/cli/dist/utils/constants.js +4 -0
  14. package/cli/dist/utils/doctor-runtime.js +3 -2
  15. package/cli/dist/utils/doctor-wiring.js +2 -1
  16. package/cli/dist/utils/env-validator.js +118 -30
  17. package/cli/dist/utils/resource-kind.js +8 -0
  18. package/cli/dist/utils/service-manifest.js +23 -3
  19. package/cli/dist/utils/service-urls.js +4 -3
  20. package/cli/dist/utils/services.js +2 -1
  21. package/cli/dist/utils/smoke.js +225 -0
  22. package/cli/package.json +1 -1
  23. package/engine/schemas/service-schema.json +73 -25
  24. package/engine/topologies/platform/docker/auth/auth.star +66 -28
  25. package/engine/topologies/platform/docker/compose/compose.star +8 -26
  26. package/engine/topologies/platform/docker/layers/l3_builder_layers.star +3 -0
  27. package/engine/topologies/platform/docker/layers/l4_runtime_layers.star +3 -0
  28. package/engine/topologies/platform/docker/networking/api_path_constants.star +1 -57
  29. package/engine/topologies/platform/docker/secrets/env_secrets.star +72 -0
  30. package/engine/topologies/tilt/common/utils.star +0 -2
  31. package/engine/topologies/tilt/common/utils_env.star +72 -25
  32. package/engine/topologies/tilt/manifest/constants.star +2 -0
  33. package/engine/topologies/tilt/manifest/json_to_yaml.py +4 -0
  34. package/engine/topologies/tilt/manifest/loader.star +7 -0
  35. package/engine/topologies/tilt/resources/orchestrator/generators/env.star +43 -96
  36. 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(local("cat '" + env_path + "' 2>/dev/null || true", quiet=True, echo_off=True))
16
- if content:
17
- for line in content.split('\n'):
18
- line = line.strip()
19
- if line and not line.startswith('#') and '=' in line:
20
- key, value = line.split('=', 1)
21
- os.environ[key.strip()] = value.strip().strip('"').strip("'")
22
-
23
-
24
- def validate_infisical_environment():
25
- """Validate required Infisical environment variables are set."""
26
- required_vars = ["INFISICAL_CLIENT_ID", "INFISICAL_CLIENT_SECRET", "INFISICAL_PROJECT_ID"]
27
- missing_vars = [var for var in required_vars if not os.environ.get(var)]
28
- if missing_vars:
29
- fail("""
30
- 🛑 MISSING REQUIRED ENVIRONMENT VARIABLES FOR INFISICAL:
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
- Secrets are handled separately by _generate_secrets_env().
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 - support both identity service and local JWT modes
107
- # Use centralized auth utilities for consistency
108
- # Extract auth config from manifest to pass as a separate dict
109
- # Auto-detect identity-service mode if 'identity' is in dependsOn
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.get('usePrisma', True):
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 - they come from Infisical only
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 + '=' + str(value) + '\n'
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
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tdk-landscape/tdk-cli-core",
3
- "version": "1.3.90",
3
+ "version": "1.3.92",
4
4
  "description": "TDK CLI — start services on your laptop.",
5
5
  "homepage": "https://tdk-landscape.github.io/tdk-website",
6
6
  "repository": {