@tdk-landscape/tdk-cli-core 1.3.76 → 1.3.77

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 (76) hide show
  1. package/README.md +58 -12
  2. package/cli/bin/tdk.js +22 -2
  3. package/cli/dist/cli.js +9 -2
  4. package/cli/dist/commands/completion.js +30 -3
  5. package/cli/dist/commands/config.js +59 -1
  6. package/cli/dist/commands/doctor.js +286 -98
  7. package/cli/dist/commands/eject.js +77 -0
  8. package/cli/dist/commands/networks.js +17 -4
  9. package/cli/dist/commands/project.js +28 -5
  10. package/cli/dist/commands/resource.js +118 -10
  11. package/cli/dist/commands/up.js +93 -21
  12. package/cli/dist/commands/upgrade.js +163 -68
  13. package/cli/dist/generator/template-engine.js +77 -13
  14. package/cli/dist/types/index.js +6 -1
  15. package/cli/dist/utils/cold-preflight.js +198 -0
  16. package/cli/dist/utils/constants.js +4 -0
  17. package/cli/dist/utils/docker.js +15 -1
  18. package/cli/dist/utils/doctor-runtime.js +48 -16
  19. package/cli/dist/utils/doctor-wiring.js +15 -7
  20. package/cli/dist/utils/errors.js +5 -5
  21. package/cli/dist/utils/exec-async.js +30 -0
  22. package/cli/dist/utils/generated-paths.js +49 -0
  23. package/cli/dist/utils/platform.js +53 -0
  24. package/cli/dist/utils/port-assignment.js +13 -20
  25. package/cli/dist/utils/project-templates.js +3 -2
  26. package/cli/dist/utils/resource-features.js +6 -2
  27. package/cli/dist/utils/service-manifest.js +73 -0
  28. package/cli/dist/utils/services.js +8 -1
  29. package/cli/dist/utils/tar.js +27 -5
  30. package/cli/dist/utils/tilt-process.js +53 -0
  31. package/cli/dist/utils/tilt.js +3 -1
  32. package/cli/dist/utils/which.js +26 -0
  33. package/cli/dist/utils/windows-doctor.js +175 -0
  34. package/cli/package.json +1 -1
  35. package/cli/templates/TILT_RESOURCE_DEFAULTS.star.hbs +1 -2
  36. package/cli/templates/TILT_TECH_STACK.star.hbs +0 -1
  37. package/cli/templates/Tiltfile.hbs +0 -1
  38. package/cli/templates/spec.master.hbs +1 -2
  39. package/discovery/AGENTS.md +1 -1
  40. package/discovery/constants.star +2 -2
  41. package/discovery/discovery_orchestrator.star +2 -2
  42. package/discovery/manifest/constants.star +1 -1
  43. package/discovery/manifest/loading.star +7 -3
  44. package/discovery/registry.star +17 -3
  45. package/engine/README.md +2 -0
  46. package/engine/schemas/service-schema.json +47 -21
  47. package/engine/scripts/generate-manifest.sh +2 -1
  48. package/engine/topologies/platform/docker/compose/compose.star +37 -5
  49. package/engine/topologies/tilt/common/utils.star +54 -11
  50. package/engine/topologies/tilt/manifest/AGENTS.md +1 -1
  51. package/engine/topologies/tilt/manifest/constants.star +3 -0
  52. package/engine/topologies/tilt/manifest/schema.star +2 -4
  53. package/engine/topologies/tilt/manifest/validator.star +1 -1
  54. package/engine/topologies/tilt/resources/infra-loader.star +14 -3
  55. package/engine/topologies/tilt/resources/orchestrator/apply_compose_resource_registration.star +15 -11
  56. package/engine/topologies/tilt/resources/orchestrator/generators/manifest_resource.star +15 -6
  57. package/examples/tdk-example/LICENSE +21 -0
  58. package/examples/tdk-example/README.md +23 -0
  59. package/examples/tdk-example/TILT_SERVICE_DEFAULTS.star +42 -0
  60. package/examples/tdk-example/TILT_TECH_STACK.star +67 -0
  61. package/examples/tdk-example/Tiltfile +25 -0
  62. package/examples/tdk-example/services/platform/messaging/docker-compose.yml +26 -0
  63. package/examples/tdk-example/services/shop/orders-api/package.json +20 -0
  64. package/examples/tdk-example/services/shop/orders-api/service.json +14 -0
  65. package/examples/tdk-example/services/shop/orders-api/src/index.ts +81 -0
  66. package/examples/tdk-example/services/shop/orders-app/index.html +12 -0
  67. package/examples/tdk-example/services/shop/orders-app/package.json +20 -0
  68. package/examples/tdk-example/services/shop/orders-app/public/.gitkeep +0 -0
  69. package/examples/tdk-example/services/shop/orders-app/service.json +9 -0
  70. package/examples/tdk-example/services/shop/orders-app/src/main.tsx +9 -0
  71. package/examples/tdk-example/services/shop/orders-app/src/orders.tsx +67 -0
  72. package/examples/tdk-example/services/shop/orders-app/src/style.css +15 -0
  73. package/examples/tdk-example/services/shop/orders-worker/package.json +18 -0
  74. package/examples/tdk-example/services/shop/orders-worker/service.json +13 -0
  75. package/examples/tdk-example/services/shop/orders-worker/src/index.ts +33 -0
  76. package/package.json +2 -1
@@ -303,6 +303,16 @@ def _render_product_stacks_index(resources):
303
303
 
304
304
  def _write_file_if_changed(path, content):
305
305
  """Write file only if content changed. Returns True if written."""
306
+ normalized = path.replace('\\', '/').replace('./', '', 1)
307
+ if '..' in normalized.split('/') or not (
308
+ '/.autogenerated/' in normalized or
309
+ normalized.startswith('.autogenerated/') or
310
+ normalized.startswith('.tdk/.tdk-out/') or
311
+ normalized in ['Tiltfile', '.tiltignore'] or
312
+ (normalized.startswith('TILT_') and normalized.endswith('.star') and '/' not in normalized)
313
+ ):
314
+ fail("TDK discovery refused to write outside its generated-file allowlist: " + path)
315
+
306
316
  # Read existing file (returns empty string on error via shell)
307
317
  existing = str(local("cat " + path + " 2>/dev/null || echo ''", quiet=True, echo_off=True))
308
318
 
@@ -322,6 +332,8 @@ def generate_product_stack_topology():
322
332
  changes = 0
323
333
  for resource in APP_RESOURCES:
324
334
  stack = resource.get("name", "unknown")
335
+ if not stack or '/' in stack or '\\' in stack or stack == '..':
336
+ fail("Invalid stack name for generated topology path: " + str(stack))
325
337
  stack_file = PRODUCT_TOPOLOGY_DIR + "/" + stack + "/stack.star"
326
338
  if _write_file_if_changed(stack_file, _render_product_stack_file(resource)):
327
339
  changes += 1
@@ -443,7 +455,8 @@ def _generate_yaml_from_json_manifests():
443
455
  json_files = _find_manifest_files(root, MANIFEST_FILENAME)
444
456
 
445
457
  for json_file in json_files:
446
- yaml_file = json_file.replace(MANIFEST_FILENAME, MANIFEST_FILENAME_YAML)
458
+ resource_dir = json_file.rsplit('/', 1)[0]
459
+ yaml_file = resource_dir + "/.autogenerated/" + MANIFEST_FILENAME_YAML
447
460
  load_result = ManifestLoader.load_from_file(json_file)
448
461
  if not load_result.error and load_result.manifest:
449
462
  yaml_content = _json_to_yaml(load_result.manifest)
@@ -461,7 +474,7 @@ def load_yaml_manifests_as_resources():
461
474
  for root in DISCOVERY_SCAN_ROOTS:
462
475
  files = _find_manifest_files(root, MANIFEST_FILENAME_YAML)
463
476
  for f in files:
464
- if f not in yaml_files:
477
+ if '/.autogenerated/' + MANIFEST_FILENAME_YAML in f and f not in yaml_files:
465
478
  yaml_files.append(f)
466
479
 
467
480
  if not yaml_files:
@@ -469,7 +482,8 @@ def load_yaml_manifests_as_resources():
469
482
 
470
483
  created_resources = {}
471
484
  for yaml_file in yaml_files:
472
- json_file = yaml_file.replace(MANIFEST_FILENAME_YAML, MANIFEST_FILENAME)
485
+ service_dir = yaml_file.rsplit('/.autogenerated/', 1)[0]
486
+ json_file = service_dir + '/' + MANIFEST_FILENAME
473
487
  load_result = ManifestLoader.load_from_file(json_file)
474
488
  resource_name = ""
475
489
 
package/engine/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # .tilt-engine/
2
2
 
3
+ > **Contributing to the current engine:** follow the [engine and discovery recipe](../docs/contributing/02-feature-recipes.md#change-the-engine-or-service-discovery). This README contains older architecture notes; check paths against the current `engine/topologies/` source before relying on them.
4
+
3
5
  **Tilt Platform Framework Code**
4
6
 
5
7
  This directory contains the TDK Landscape Tilt infrastructure framework - the code that powers our local development orchestration.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$id": "https://tdk-landscape.github.io/schema.service.json",
3
3
  "$schema": "http://json-schema.org/draft-07/schema#",
4
- "additionalProperties": false,
4
+ "additionalProperties": true,
5
5
  "allOf": [
6
6
  {
7
7
  "if": {
@@ -60,6 +60,27 @@
60
60
  "featuresEnabled"
61
61
  ]
62
62
  }
63
+ },
64
+ {
65
+ "if": {
66
+ "properties": {
67
+ "appType": {
68
+ "const": "bring-your-own"
69
+ }
70
+ }
71
+ },
72
+ "then": {
73
+ "properties": {
74
+ "port": {
75
+ "maximum": 5999,
76
+ "minimum": 4000
77
+ }
78
+ },
79
+ "required": [
80
+ "port",
81
+ "healthCheckPath"
82
+ ]
83
+ }
63
84
  }
64
85
  ],
65
86
  "description": "Configuration for TDK Landscape resources. Drives Tilt orchestration, Docker Compose generation, and dependency resolution. Supports both explicit service.json files and synthesis from directory structure.",
@@ -86,14 +107,15 @@
86
107
  "type": "string"
87
108
  },
88
109
  "appType": {
89
- "description": "Resource type determining build strategy:\n• backend - Hono/Express API server\n• frontend - Vite single-page application (React or Vue, see framework)\n• sdk - Shared library package\n• worker - Background job processor\n• migrator - Database migration runner\n• library - Shared code library",
110
+ "description": "Resource type determining build strategy:\n• backend - Hono/Express API server\n• frontend - Vite single-page application (React or Vue, see framework)\n• sdk - Shared library package\n• worker - Background job processor\n• migrator - Database migration runner\n• library - Shared code library\n• bring-your-own - Wrap an existing service. No app scaffold. Needs Dockerfile or --image.",
90
111
  "enum": [
91
112
  "backend",
92
113
  "frontend",
93
114
  "sdk",
94
115
  "worker",
95
116
  "migrator",
96
- "library"
117
+ "library",
118
+ "bring-your-own"
97
119
  ],
98
120
  "type": "string"
99
121
  },
@@ -124,26 +146,19 @@
124
146
  },
125
147
  "dockerfile": {
126
148
  "default": "autogenerated/Dockerfile.app.autogenerated",
127
- "description": "Custom Dockerfile path relative to resource directory.",
149
+ "description": "Custom Dockerfile path relative to resource directory. Optional for bring-your-own type.",
150
+ "type": "string"
151
+ },
152
+ "exposeViaProxy": {
153
+ "description": "Whether this bring-your-own resource receives a Traefik route. Defaults to true.",
154
+ "type": "boolean"
155
+ },
156
+ "image": {
157
+ "description": "Docker image name instead of building from Dockerfile. Optional for bring-your-own type.",
128
158
  "type": "string"
129
159
  },
130
160
  "stack": {
131
161
  "description": "Technology stack this resource belongs to.\nUsed for:\n• Database naming: TDK_{stack}\n• Traefik routing: /api/{stack}\n• NATS queue: {stack}_backend_svc",
132
- "enum": [
133
- "user",
134
- "team",
135
- "auth",
136
- "order",
137
- "order-planner",
138
- "service",
139
- "inventory",
140
- "billing",
141
- "notification",
142
- "analytics",
143
- "profile",
144
- "platform",
145
- "product"
146
- ],
147
162
  "type": "string"
148
163
  },
149
164
  "params": {
@@ -273,6 +288,11 @@
273
288
  ],
274
289
  "type": "string"
275
290
  },
291
+ "schemaVersion": {
292
+ "const": 1,
293
+ "description": "Version of the TDK service manifest contract.",
294
+ "type": "integer"
295
+ },
276
296
  "sablier": {
277
297
  "description": "On-demand scaling: stop this container when idle, wake it on the next request.\nNot recommended for databases or other stateful resources with in-flight writes.\n(Premium - requires TDK_LICENSE_KEY; see hasSablierLicense() in extension-fetch.ts)",
278
298
  "properties": {
@@ -281,6 +301,11 @@
281
301
  "description": "Scale this resource to zero when idle",
282
302
  "type": "boolean"
283
303
  },
304
+ "deferStart": {
305
+ "default": false,
306
+ "description": "Build the image during tdk up, then create and start the container on its first request",
307
+ "type": "boolean"
308
+ },
284
309
  "group": {
285
310
  "description": "Resources sharing a group wake and sleep together. Defaults to `stack`.",
286
311
  "type": "string"
@@ -322,8 +347,9 @@
322
347
  "required": [
323
348
  "appName",
324
349
  "appType",
325
- "stack"
350
+ "stack",
351
+ "schemaVersion"
326
352
  ],
327
353
  "title": "TDK Landscape Resource Configuration",
328
354
  "type": "object"
329
- }
355
+ }
@@ -170,12 +170,13 @@ main() {
170
170
  cat > "$manifest_file" << EOF
171
171
  {
172
172
  "\$schema": "../../.tilt/schemas/service-schema.json",
173
+ "schemaVersion": 1,
173
174
  "appName": "${full_name}",
174
175
  "appType": "${resource_type}",
175
176
  "stack": "${stack}",
176
177
  "port": ${port},
177
178
  "replicas": 1,
178
- "features": ${features},
179
+ "featuresEnabled": ${features},
179
180
  "dependsOn": ${deps}
180
181
  }
181
182
  EOF
@@ -217,7 +217,26 @@ def _generate_single_backend_entry(resource_path, resource_name, res, manifest,
217
217
  auth_env = AuthConfig.generate_docker_compose_auth_env(auth_config)
218
218
 
219
219
  # Use .autogenerated/Dockerfile.app.autogenerated by default for build config (primary only)
220
- build_config = compose_build_config(resource_path, res_name)
220
+ if manifest and manifest.get('appType') == 'bring-your-own':
221
+ if manifest.get('image'):
222
+ build_config = ""
223
+ else:
224
+ # A BYO Dockerfile copies paths relative to the service directory
225
+ # (for example, `COPY app.py .`). Build with that directory as the
226
+ # context instead of the project root used by generated TDK apps.
227
+ context_prefix = '/'.join(['..'] * (len(resource_path.split('/')) + 1))
228
+ build_context = context_prefix + '/' + full_resource_path
229
+ dockerfile = manifest.get('dockerfile', 'Dockerfile')
230
+ if dockerfile.startswith('./'):
231
+ dockerfile = dockerfile[2:]
232
+ build_config = """ build:
233
+ context: {build_context}
234
+ dockerfile: {dockerfile}""".format(
235
+ build_context=build_context,
236
+ dockerfile=dockerfile,
237
+ )
238
+ else:
239
+ build_config = compose_build_config(resource_path, res_name)
221
240
 
222
241
  # REMOVED: External port exposure - all services accessed via Traefik only
223
242
  # Services are available at {project}.localhost/api/v1/{service}
@@ -243,10 +262,16 @@ def _generate_single_backend_entry(resource_path, resource_name, res, manifest,
243
262
  # them a Traefik route advertises an endpoint that can never answer, and giving
244
263
  # them an HTTP healthcheck marks a perfectly healthy worker as unhealthy forever.
245
264
  is_worker = manifest.get('appType') == 'worker' if manifest else False
265
+ is_byo = manifest.get('appType') == 'bring-your-own' if manifest else False
266
+ is_proxy_disabled_byo = (
267
+ is_byo and not manifest.get('exposeViaProxy', True)
268
+ ) if manifest else False
246
269
 
247
- if is_worker:
270
+ if is_worker or is_proxy_disabled_byo:
248
271
  traefik_labels = ' - "traefik.enable=false"'
249
- healthcheck_section = ""
272
+ healthcheck_section = "" if is_worker or is_byo else """ healthcheck:
273
+ test: ["CMD", "curl", "-f", "--max-time", "5", "http://localhost:{internal_port}{health_path}"]
274
+ """.format(internal_port=internal_port, health_path=health_path) + compose_healthcheck_timing()
250
275
  else:
251
276
  # Traefik's load-balancer check can be overridden separately in service.json
252
277
  # (`traefik.healthCheck`); it defaults to the container check's path.
@@ -260,7 +285,10 @@ def _generate_single_backend_entry(resource_path, resource_name, res, manifest,
260
285
  health_path=traefik_health_path,
261
286
  manifest=manifest,
262
287
  )
263
- healthcheck_section = """ healthcheck:
288
+ # BYO images may not contain curl. Traefik uses its own HTTP health
289
+ # check labels above, so don't mark an otherwise healthy container
290
+ # unhealthy because the image lacks a command TDK cannot require.
291
+ healthcheck_section = "" if is_byo else """ healthcheck:
264
292
  test: ["CMD", "curl", "-f", "--max-time", "5", "http://localhost:{internal_port}{health_path}"]
265
293
  """.format(internal_port=internal_port, health_path=health_path) + compose_healthcheck_timing()
266
294
  infisical_env = get_infisical_environment_vars(as_array=True, resource_name=res_name)
@@ -311,7 +339,11 @@ def _generate_single_backend_entry(resource_path, resource_name, res, manifest,
311
339
  resource_entry_name=resource_entry_name,
312
340
  res_name=res_name,
313
341
  resource_name=resource_name,
314
- image_name=with_runtime_image_tag(resource_name + '_' + res_name),
342
+ image_name=(
343
+ manifest.get('image')
344
+ if manifest and manifest.get('appType') == 'bring-your-own' and manifest.get('image')
345
+ else with_runtime_image_tag(resource_name + '_' + res_name)
346
+ ),
315
347
  build_config=build_config,
316
348
  ports_section=ports_section,
317
349
  env_file_rel_path=env_file_rel_path,
@@ -260,6 +260,37 @@ def encode_json(obj, _depth=0):
260
260
  # 📁 FILE I/O UTILITIES
261
261
  # =============================================================================
262
262
 
263
+ def _is_allowed_generated_path(file_path, project_root):
264
+ path = file_path.replace('\\', '/').strip()
265
+ root = project_root.replace('\\', '/').rstrip('/')
266
+ if root and path.startswith(root + '/'):
267
+ path = path[len(root) + 1:]
268
+ if path.startswith('./'):
269
+ path = path[2:]
270
+ parts = path.split('/')
271
+ if '..' in parts or path.startswith('/'):
272
+ return False
273
+ if '.autogenerated' in parts:
274
+ return True
275
+ if path.startswith('.tdk/.tdk-out/'):
276
+ return True
277
+ if path.startswith('product_autogenerated_resource_manifests/') or path.startswith('product_autogenerated_services_list/'):
278
+ return True
279
+ if path == 'Tiltfile' or path == '.tiltignore':
280
+ return True
281
+ if len(parts) == 1 and path.startswith('TILT_') and path.endswith('.star'):
282
+ return True
283
+ return False
284
+
285
+ def _write_generated_file(file_path, content):
286
+ expected = str(content).rstrip()
287
+ dir_path = file_path.rsplit('/', 1)[0]
288
+ if not dir_path:
289
+ dir_path = '/'
290
+ safe_content = (expected + '\n').replace("'", "'\\''")
291
+ cmd = "mkdir -p '" + dir_path + "' && printf '%s' '" + safe_content + "' > '" + file_path + "'"
292
+ local(cmd, quiet=True, echo_off=True)
293
+
263
294
  def write_file_if_changed(file_path, content):
264
295
  """
265
296
  Writes content to file only if it differs from existing content.
@@ -272,28 +303,40 @@ def write_file_if_changed(file_path, content):
272
303
  content = ""
273
304
 
274
305
  # For relative paths, prepend project root to ensure files go to correct location
306
+ project_root = os.environ.get('TDK_PROJECT_ROOT', '.')
275
307
  if not file_path.startswith('/'):
276
- project_root = os.environ.get('TDK_PROJECT_ROOT', '.')
277
308
  # Remove leading ./ if present in file_path
278
309
  clean_file_path = file_path[2:] if file_path.startswith('./') else file_path
279
310
  file_path = project_root + '/' + clean_file_path
280
311
 
312
+ if not _is_allowed_generated_path(file_path, project_root):
313
+ fail("TDK refused to write outside its generated-file allowlist: " + file_path)
314
+
281
315
  expected = str(content).rstrip()
282
316
  existing_blob = read_file(file_path, default='')
283
317
  existing = str(existing_blob).rstrip()
284
318
 
285
319
  if existing == expected:
286
320
  print("DEBUG WRITE: Skipping {} (no changes)".format(file_path))
287
- return
288
-
289
- dir_path = file_path.rsplit('/', 1)[0]
290
- if not dir_path:
291
- dir_path = '/'
292
- safe_content = (expected + '\n').replace("'", "'\\''")
293
- cmd = "mkdir -p '" + dir_path + "' && printf '%s' '" + safe_content + "' > '" + file_path + "'"
294
- print("DEBUG WRITE: Writing {} bytes to {}".format(len(expected), file_path))
295
- local(cmd, quiet=True, echo_off=True)
296
- print("DEBUG WRITE: Completed {}".format(file_path))
321
+ else:
322
+ print("DEBUG WRITE: Writing {} bytes to {}".format(len(expected), file_path))
323
+ _write_generated_file(file_path, expected)
324
+ print("DEBUG WRITE: Completed {}".format(file_path))
325
+
326
+ # Keep a generated-content snapshot beside the service outputs. `tdk config verify`
327
+ # compares these snapshots with the files and the source manifest, detecting both
328
+ # hand edits and stale generated output without needing to run Tilt from CI.
329
+ normalized = file_path.replace('\\', '/')
330
+ generated_marker = '/.autogenerated/'
331
+ marker_index = normalized.rfind(generated_marker)
332
+ if marker_index >= 0 and '/.tdk-contract/' not in normalized:
333
+ service_root = normalized[:marker_index]
334
+ generated_rel = normalized[marker_index + len(generated_marker):]
335
+ contract_root = service_root + '/.autogenerated/.tdk-contract'
336
+ _write_generated_file(contract_root + '/' + generated_rel, expected)
337
+ manifest_content = read_file(service_root + '/service.json', default='')
338
+ if manifest_content:
339
+ _write_generated_file(contract_root + '/service.json.source', str(manifest_content))
297
340
 
298
341
 
299
342
  # =============================================================================
@@ -46,7 +46,7 @@ load("./constants.star", "MANIFEST_FILENAME")
46
46
  ```json
47
47
  {
48
48
  "appName": "resource-name-backend",
49
- "appType": "backend", // frontend, backend, library, migrator, sdk, worker
49
+ "appType": "backend", // frontend, backend, library, migrator, sdk, worker, bring-your-own
50
50
  "stack": "order",
51
51
  "port": 4000,
52
52
  "features": ["nats", "prisma", "vite-node"],
@@ -61,6 +61,7 @@ VALID_RESOURCE_TYPES = [
61
61
  "sdk",
62
62
  "worker",
63
63
  "infra",
64
+ "bring-your-own",
64
65
  ]
65
66
 
66
67
  VALID_FEATURES = [
@@ -86,6 +87,7 @@ VALID_FEATURES = [
86
87
  PORT_RANGES = {
87
88
  "frontend": {"min": 3000, "max": 5999},
88
89
  "backend": {"min": 4000, "max": 5999},
90
+ "bring-your-own": {"min": 4000, "max": 5999},
89
91
  "worker": {"min": 6000, "max": 6999},
90
92
  "migrator": {"min": 7000, "max": 7999},
91
93
  "sdk": {"min": 3000, "max": 9999},
@@ -186,6 +188,7 @@ DEFAULT_SYNCS = {
186
188
  'migrator': ['prisma'],
187
189
  'library': ['src', 'package.json'],
188
190
  'sdk': ['src', 'package.json'],
191
+ 'bring-your-own': [],
189
192
  }
190
193
 
191
194
  RUNTIME_CONFIGS = {
@@ -64,11 +64,9 @@ MANIFEST_SCHEMA = {
64
64
  'default': 'backend',
65
65
  },
66
66
  'stack': {
67
- 'type': 'enum',
67
+ 'type': 'string',
68
68
  'required': True,
69
- 'constraints': {
70
- 'values': VALID_STACKS,
71
- },
69
+ 'constraints': {'min_length': 1},
72
70
  'description': 'Technology stack this resource belongs to',
73
71
  'example': 'user',
74
72
  },
@@ -9,7 +9,7 @@
9
9
  # === INLINED CONSTANTS for pure extension loading ===
10
10
  VALID_STACKS = () # Stacks are project-specific, discovered dynamically
11
11
  VALID_FEATURES = "api-client", "env-config", "api-index", "prisma", "ddd", "nats", "redis", "infisical", "vitest", "traefik", "websocket", "graphql", "grpc", "vite-node", "maintenance", "sablier"
12
- PORT_RANGES = {"frontend": {"min": 3000, "max": 5999}, "backend": {"min": 4000, "max": 5999}, "worker": {"min": 6000, "max": 6999}, "migrator": {"min": 7000, "max": 7999}, "sdk": {"min": 3000, "max": 9999}, "library": {"min": 3000, "max": 9999}}
12
+ PORT_RANGES = {"frontend": {"min": 3000, "max": 5999}, "backend": {"min": 4000, "max": 5999}, "bring-your-own": {"min": 4000, "max": 5999}, "worker": {"min": 6000, "max": 6999}, "migrator": {"min": 7000, "max": 7999}, "sdk": {"min": 3000, "max": 9999}, "library": {"min": 3000, "max": 9999}}
13
13
  DEFAULTS = {}
14
14
 
15
15
  # === END INLINED CONSTANTS ===
@@ -7,7 +7,7 @@
7
7
  #
8
8
  # This module centralizes infrastructure loading:
9
9
  # - Database (PostgreSQL, Redis)
10
- # - Messaging (NATS, Kafka)
10
+ # - Messaging (Redis, NATS, Kafka)
11
11
  # - Secrets (Infisical)
12
12
  # - Registry (Verdaccio)
13
13
  # - Proxy (Traefik)
@@ -33,6 +33,13 @@ def _file_exists(path):
33
33
  return result == 'yes'
34
34
 
35
35
 
36
+ def _compose_has_service(compose_path, service_name):
37
+ """Check whether a Compose file declares a named service."""
38
+ document = read_yaml(compose_path, default={})
39
+ services = document.get('services', {}) if type(document) == 'dict' else {}
40
+ return service_name in services
41
+
42
+
36
43
  def _docker_compose(compose_paths, env_file):
37
44
  """docker_compose() wrapper: Tilt's builtin rejects env_file=None outright
38
45
  (it type-checks the kwarg as path|string, not Optional), so the keyword must be
@@ -116,8 +123,12 @@ def _load_database_management(should_enable, root_prefix="", env_file=None, writ
116
123
  if _file_exists(messaging_compose):
117
124
  print("DEBUG INFRA: Loading messaging compose from {}".format(messaging_compose))
118
125
  _docker_compose(messaging_compose, env_file)
119
- dc_resource('redis', labels=['infra.messaging'], auto_init=False)
120
- dc_resource('nats', labels=['infra.messaging'], auto_init=True)
126
+ # Register only services present in the project compose file. Minimal
127
+ # NATS-only examples should not need to add an unrelated Redis broker.
128
+ if _compose_has_service(messaging_compose, 'redis'):
129
+ dc_resource('redis', labels=['infra.messaging'], auto_init=False)
130
+ if _compose_has_service(messaging_compose, 'nats'):
131
+ dc_resource('nats', labels=['infra.messaging'], auto_init=True)
121
132
  else:
122
133
  print("DEBUG INFRA: Skipping messaging (compose file not found)")
123
134
 
@@ -137,10 +137,13 @@ def resource_defers_start(manifest):
137
137
  static route wakes it on first request (see openspec/changes/
138
138
  prioritized-cold-start)."""
139
139
  _, sablier_licensed_and_enabled = sablier_middleware_suffix(manifest, '')
140
+ sablier = manifest.get('sablier', {}) if manifest else {}
141
+ defers_start = bool(sablier.get('enable', False) and sablier.get('deferStart', False))
142
+ if defers_start and not sablier_licensed_and_enabled:
143
+ print("⚠️ '{}' requests sablier.deferStart but the licensed Sablier overlay is disabled; starting normally".format(manifest.get('name', 'resource')))
140
144
  if not sablier_licensed_and_enabled:
141
145
  return False
142
- sablier = manifest.get('sablier', {}) if manifest else {}
143
- return bool(sablier.get('deferStart', False))
146
+ return defers_start
144
147
 
145
148
 
146
149
  def resolve_manifest_dependency_names(manifest, all_services_map):
@@ -365,7 +368,7 @@ def _register_single_resource(config, resource_path, compose_project_name, auto_
365
368
  print("DEBUG REGISTER: Registering '{}' app_type='{}' auto_init={}".format(res_name, app_type, effective_auto_init))
366
369
 
367
370
  # Skip docker_build for libraries and SDKs - they don't have Dockerfiles
368
- if app_type not in ['library', 'sdk'] and config.get('syncs'):
371
+ if app_type not in ['library', 'sdk', 'bring-your-own'] and config.get('syncs'):
369
372
  print("DEBUG REGISTER: '{}' has syncs, calling docker_build".format(res_name))
370
373
  live_update_rules = _build_live_update_rules(
371
374
  config['res_path'],
@@ -429,13 +432,14 @@ def _process_frontend_resource(res, manifest, full_res_path, resource_path, reso
429
432
 
430
433
  def _process_backend_resource(res, manifest, full_res_path, resource_path, resource_name, should_enable):
431
434
  """Process a backend resource and return compose entry."""
432
- Docker.backend(
433
- res_path=full_res_path,
434
- resource_name=res['name'],
435
- port=None, # Use default BASE_PORT_BACKEND
436
- build_cmd=res.get('start_command', 'bun run build'),
437
- use_golden=should_enable('golden-image'),
438
- )
435
+ if manifest.get('appType') != 'bring-your-own':
436
+ Docker.backend(
437
+ res_path=full_res_path,
438
+ resource_name=res['name'],
439
+ port=None, # Use default BASE_PORT_BACKEND
440
+ build_cmd=res.get('start_command', 'bun run build'),
441
+ use_golden=should_enable('golden-image'),
442
+ )
439
443
  return Docker.backend_compose(resource_path=resource_path, resource_name=resource_name, res=res, manifest=manifest)
440
444
 
441
445
 
@@ -462,7 +466,7 @@ def _build_resource_config(res, manifest, resource_config, full_res_path, infra_
462
466
  'replicas': replicas,
463
467
  'syncs': res.get('syncs', []),
464
468
  'app_type': manifest.get('appType', 'backend'),
465
- 'dockerfile': res.get('dockerfile', OUTPUT.AUTOGENERATED_FOLDER + '/Dockerfile.app.autogenerated'),
469
+ 'dockerfile': res.get('dockerfile', manifest.get('dockerfile', OUTPUT.AUTOGENERATED_FOLDER + '/Dockerfile.app.autogenerated')),
466
470
  'res_path': full_res_path,
467
471
  'env': env_vars,
468
472
  'project_root': project_root,
@@ -126,6 +126,8 @@ def _build_generated_file_list(resource_config, resource_path, manifest):
126
126
  """
127
127
  files = []
128
128
  app_type = manifest.get('appType', 'backend')
129
+ if app_type == 'bring-your-own':
130
+ return []
129
131
  is_frontend = resource_config.get('frontend', False) or app_type == 'frontend'
130
132
 
131
133
  # Vite
@@ -267,6 +269,16 @@ def _generate_all_configs_for_resource(
267
269
  write_file = ctx['write_file']
268
270
  should_enable = ctx['should_enable']
269
271
  global_config = ctx['global_config']
272
+
273
+ # Env generation needs the actual service directory on the manifest. Set it
274
+ # before the BYO fast path too; otherwise Compose references an env file that
275
+ # was never written for bring-your-own resources.
276
+ manifest['_resource_path'] = resource_path
277
+
278
+ if manifest.get('appType') == 'bring-your-own':
279
+ _generate_env_file(manifest, backend_manifest, write_file)
280
+ _generate_yaml_manifest(resource_path, manifest, write_file)
281
+ return
270
282
 
271
283
  # WORKAROUND: Prevent infinite recursion by checking recursion depth
272
284
  # Use a unique key per resource to track depth, avoiding interference between different resources
@@ -279,9 +291,6 @@ def _generate_all_configs_for_resource(
279
291
  ctx = dict(ctx)
280
292
  ctx[recursion_key] = recursion_depth + 1
281
293
 
282
- # Add resource_path to manifest so generators can access it
283
- manifest['_resource_path'] = resource_path
284
-
285
294
  # ==========================================================================
286
295
  # NEW: PRE-FLIGHT VALIDATIONS (v2.0)
287
296
  # Run validations BEFORE generating configs to catch issues early
@@ -444,8 +453,8 @@ def _generate_all_configs_for_resource(
444
453
 
445
454
 
446
455
  def _generate_yaml_manifest(resource_path, manifest, write_file):
447
- """Generate YAML version of manifest for verification (idempotent)."""
448
- yaml_path = resource_path + "/" + MANIFEST_FILENAME_YAML
456
+ """Generate an allowlisted YAML shadow beside other TDK-owned outputs."""
457
+ yaml_path = resource_path + "/.autogenerated/" + MANIFEST_FILENAME_YAML
449
458
 
450
459
  # Check if YAML already exists and is up-to-date by comparing with JSON
451
460
  json_path = resource_path + "/" + MANIFEST_FILENAME
@@ -525,7 +534,7 @@ def create_manifest_config_resource(
525
534
  local_resource(
526
535
  name=config_gen_resource_name,
527
536
  cmd=log_cmd,
528
- deps=[manifest_path, package_json_path],
537
+ deps=[manifest_path] if manifest.get('appType') == 'bring-your-own' else [manifest_path, package_json_path],
529
538
  labels=['config-gen', resource_name],
530
539
  allow_parallel=True,
531
540
  auto_init=auto_init_config_gen
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TDK Landscape
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,23 @@
1
+ # TDK product example
2
+
3
+ A small working landscape with a Hono API, PostgreSQL, a NATS worker, a Vite UI, and Traefik routes.
4
+
5
+ This is a local demo, not a Helm chart.
6
+
7
+ ```sh
8
+ tdk doctor
9
+ tdk project --yes
10
+ tdk up
11
+ ```
12
+
13
+ Open `http://app.tdk-example.localhost/orders-app/` and create an order, or use the routed API:
14
+
15
+ ```sh
16
+ curl -fsS -X POST http://api.tdk-example.localhost/api/orders \
17
+ -H 'content-type: application/json' -d '{"item":"coffee"}'
18
+ ```
19
+
20
+ The API stores each order in PostgreSQL and publishes its id to NATS. The worker consumes the
21
+ event, reads the stored row, and marks it observed. Poll `GET /api/orders/<id>` through the API
22
+ route until `workerSeenAt` is set. `tdk config verify` checks generated output. Stop the landscape
23
+ with `tdk down`.