@tdk-landscape/tdk-cli-core 1.3.75 → 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 (77) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +59 -13
  3. package/cli/bin/tdk.js +22 -2
  4. package/cli/dist/cli.js +9 -2
  5. package/cli/dist/commands/completion.js +30 -3
  6. package/cli/dist/commands/config.js +59 -1
  7. package/cli/dist/commands/doctor.js +295 -98
  8. package/cli/dist/commands/eject.js +77 -0
  9. package/cli/dist/commands/networks.js +17 -4
  10. package/cli/dist/commands/project.js +28 -5
  11. package/cli/dist/commands/resource.js +118 -10
  12. package/cli/dist/commands/up.js +93 -21
  13. package/cli/dist/commands/upgrade.js +163 -68
  14. package/cli/dist/generator/template-engine.js +77 -13
  15. package/cli/dist/types/index.js +6 -1
  16. package/cli/dist/utils/cold-preflight.js +198 -0
  17. package/cli/dist/utils/constants.js +4 -0
  18. package/cli/dist/utils/docker.js +15 -1
  19. package/cli/dist/utils/doctor-runtime.js +48 -16
  20. package/cli/dist/utils/doctor-wiring.js +269 -0
  21. package/cli/dist/utils/errors.js +5 -5
  22. package/cli/dist/utils/exec-async.js +30 -0
  23. package/cli/dist/utils/generated-paths.js +49 -0
  24. package/cli/dist/utils/platform.js +53 -0
  25. package/cli/dist/utils/port-assignment.js +13 -20
  26. package/cli/dist/utils/project-templates.js +7 -2
  27. package/cli/dist/utils/resource-features.js +6 -2
  28. package/cli/dist/utils/service-manifest.js +73 -0
  29. package/cli/dist/utils/services.js +8 -1
  30. package/cli/dist/utils/tar.js +27 -5
  31. package/cli/dist/utils/tilt-process.js +53 -0
  32. package/cli/dist/utils/tilt.js +3 -1
  33. package/cli/dist/utils/which.js +26 -0
  34. package/cli/dist/utils/windows-doctor.js +175 -0
  35. package/cli/package.json +1 -1
  36. package/cli/templates/TILT_RESOURCE_DEFAULTS.star.hbs +1 -2
  37. package/cli/templates/TILT_TECH_STACK.star.hbs +0 -1
  38. package/cli/templates/Tiltfile.hbs +24 -4
  39. package/cli/templates/spec.master.hbs +1 -2
  40. package/discovery/AGENTS.md +1 -1
  41. package/discovery/constants.star +2 -2
  42. package/discovery/discovery_orchestrator.star +2 -2
  43. package/discovery/manifest/constants.star +1 -1
  44. package/discovery/manifest/loading.star +7 -3
  45. package/discovery/registry.star +17 -3
  46. package/engine/README.md +2 -0
  47. package/engine/schemas/service-schema.json +47 -21
  48. package/engine/scripts/generate-manifest.sh +2 -1
  49. package/engine/topologies/platform/docker/compose/compose.star +37 -5
  50. package/engine/topologies/tilt/common/utils.star +54 -11
  51. package/engine/topologies/tilt/manifest/AGENTS.md +1 -1
  52. package/engine/topologies/tilt/manifest/constants.star +3 -0
  53. package/engine/topologies/tilt/manifest/schema.star +2 -4
  54. package/engine/topologies/tilt/manifest/validator.star +1 -1
  55. package/engine/topologies/tilt/resources/infra-loader.star +14 -3
  56. package/engine/topologies/tilt/resources/orchestrator/apply_compose_resource_registration.star +15 -11
  57. package/engine/topologies/tilt/resources/orchestrator/generators/manifest_resource.star +15 -6
  58. package/examples/tdk-example/LICENSE +21 -0
  59. package/examples/tdk-example/README.md +23 -0
  60. package/examples/tdk-example/TILT_SERVICE_DEFAULTS.star +42 -0
  61. package/examples/tdk-example/TILT_TECH_STACK.star +67 -0
  62. package/examples/tdk-example/Tiltfile +25 -0
  63. package/examples/tdk-example/services/platform/messaging/docker-compose.yml +26 -0
  64. package/examples/tdk-example/services/shop/orders-api/package.json +20 -0
  65. package/examples/tdk-example/services/shop/orders-api/service.json +14 -0
  66. package/examples/tdk-example/services/shop/orders-api/src/index.ts +81 -0
  67. package/examples/tdk-example/services/shop/orders-app/index.html +12 -0
  68. package/examples/tdk-example/services/shop/orders-app/package.json +20 -0
  69. package/examples/tdk-example/services/shop/orders-app/public/.gitkeep +0 -0
  70. package/examples/tdk-example/services/shop/orders-app/service.json +9 -0
  71. package/examples/tdk-example/services/shop/orders-app/src/main.tsx +9 -0
  72. package/examples/tdk-example/services/shop/orders-app/src/orders.tsx +67 -0
  73. package/examples/tdk-example/services/shop/orders-app/src/style.css +15 -0
  74. package/examples/tdk-example/services/shop/orders-worker/package.json +18 -0
  75. package/examples/tdk-example/services/shop/orders-worker/service.json +13 -0
  76. package/examples/tdk-example/services/shop/orders-worker/src/index.ts +33 -0
  77. package/package.json +2 -1
@@ -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`.
@@ -0,0 +1,42 @@
1
+ # =============================================================================
2
+ # TILT_SERVICE_DEFAULTS.star - Platform Runtime Configuration
3
+ # =============================================================================
4
+
5
+ BASE_PORT_FRONTEND = 3000
6
+ BASE_PORT_BACKEND = 4000
7
+
8
+ HEALTH_CHECK_PATH = "/health"
9
+ HEALTH_CHECK_PATH_LIVE = "/health/live"
10
+ HEALTH_CHECK_PATH_READY = "/health/ready"
11
+ HEALTH_CHECK_INTERVAL = 10
12
+ HEALTH_CHECK_TIMEOUT = 5
13
+ HEALTH_CHECK_RETRIES = 3
14
+
15
+ MEMORY_LIMITS = {
16
+ "frontend": 512,
17
+ "backend": 1024,
18
+ "worker": 768,
19
+ "infra": 256,
20
+ }
21
+
22
+ DOCKER_BASE_IMAGES = {
23
+ "bun": "oven/bun:1.2",
24
+ "node": "node:20-alpine",
25
+ "nginx": "nginx:alpine",
26
+ }
27
+
28
+ NETWORK_PREFIX = "tdk"
29
+
30
+ exports = {
31
+ "BASE_PORT_FRONTEND": BASE_PORT_FRONTEND,
32
+ "BASE_PORT_BACKEND": BASE_PORT_BACKEND,
33
+ "HEALTH_CHECK_PATH": HEALTH_CHECK_PATH,
34
+ "HEALTH_CHECK_PATH_LIVE": HEALTH_CHECK_PATH_LIVE,
35
+ "HEALTH_CHECK_PATH_READY": HEALTH_CHECK_PATH_READY,
36
+ "HEALTH_CHECK_INTERVAL": HEALTH_CHECK_INTERVAL,
37
+ "HEALTH_CHECK_TIMEOUT": HEALTH_CHECK_TIMEOUT,
38
+ "HEALTH_CHECK_RETRIES": HEALTH_CHECK_RETRIES,
39
+ "MEMORY_LIMITS": MEMORY_LIMITS,
40
+ "DOCKER_BASE_IMAGES": DOCKER_BASE_IMAGES,
41
+ "NETWORK_PREFIX": NETWORK_PREFIX,
42
+ }
@@ -0,0 +1,67 @@
1
+ # =============================================================================
2
+ # TILT_TECH_STACK.star - Technology Stack Configuration
3
+ # =============================================================================
4
+
5
+ RUNTIME = "bun"
6
+ RUNTIME_VERSION = "1.2"
7
+
8
+ BUNDLER = "vite"
9
+ BUNDLER_VERSION = "5"
10
+
11
+ ORM = "prisma"
12
+ ORM_VERSION = "7"
13
+
14
+ MESSAGING = "nats"
15
+ MESSAGING_VERSION = "2"
16
+ NATS_SERVER = "nats://nats:4222"
17
+
18
+ TESTING = "vitest"
19
+ TESTING_VERSION = "1"
20
+
21
+ LINTING = "biome"
22
+ LINTING_VERSION = "1.5"
23
+
24
+ WEB_FRAMEWORK = "hono"
25
+ WEB_FRAMEWORK_VERSION = "4"
26
+
27
+ CONTAINER_PLATFORM = "docker"
28
+ COMPOSE_VERSION = "3.8"
29
+
30
+ def assert_tech_stack(loaded_stack):
31
+ required = {
32
+ "bundler": BUNDLER,
33
+ "runtime": RUNTIME,
34
+ }
35
+
36
+ for key, expected in required.items():
37
+ actual = loaded_stack.get(key)
38
+ if actual != expected:
39
+ fail("Tech stack mismatch: {} should be '{}' but got '{}'".format(
40
+ key, expected, actual
41
+ ))
42
+
43
+ print("✅ Tech stack validated: {} / {} / {}".format(
44
+ BUNDLER, RUNTIME, TESTING
45
+ ))
46
+
47
+ exports = {
48
+ "RUNTIME": RUNTIME,
49
+ "RUNTIME_VERSION": RUNTIME_VERSION,
50
+ "BUNDLER": BUNDLER,
51
+ "BUNDLER_VERSION": BUNDLER_VERSION,
52
+ "ORM": ORM,
53
+ "ORM_VERSION": ORM_VERSION,
54
+ "MESSAGING": MESSAGING,
55
+ "MESSAGING_VERSION": MESSAGING_VERSION,
56
+ "NATS_SERVER": NATS_SERVER,
57
+ "TESTING": TESTING,
58
+ "TESTING_VERSION": TESTING_VERSION,
59
+ "LINTING": LINTING,
60
+ "LINTING_VERSION": LINTING_VERSION,
61
+ "WEB_FRAMEWORK": WEB_FRAMEWORK,
62
+ "WEB_FRAMEWORK_VERSION": WEB_FRAMEWORK_VERSION,
63
+ "CONTAINER_PLATFORM": CONTAINER_PLATFORM,
64
+ "COMPOSE_VERSION": COMPOSE_VERSION,
65
+ "assert_tech_stack": assert_tech_stack,
66
+ "TECH_STACK": exports,
67
+ }
@@ -0,0 +1,25 @@
1
+ # TDK Example Tiltfile
2
+ # Minimal Tiltfile demonstrating TDK PSR model
3
+
4
+ # Must be set before the extension loads: discovery/constants.star reads it to
5
+ # find this project's spec.master (services/identity, services/appointment)
6
+ # instead of falling back to the beauty-crm monorepo's default scan roots.
7
+ os.environ['TDK_PROJECT_ROOT'] = config.main_dir
8
+
9
+ # Load TDK extension
10
+ v1alpha1.extension_repo(name='tdk-cli', url='https://github.com/tdk-landscape/tdk-cli')
11
+ v1alpha1.extension(name='tdk-cli', repo_name='tdk-cli', repo_path='')
12
+
13
+ # Load TDK utilities
14
+ load('ext://tdk-cli', 'Utils', 'Manifest', 'Config')
15
+
16
+ print("🚀 TDK Example Project")
17
+ print(" Project → Stack → Resource")
18
+ print("")
19
+
20
+ # Default to all services if no args
21
+ config.define_string_list("to-run", args=True)
22
+ cfg = config.parse()
23
+
24
+ # Let TDK handle service discovery and registration
25
+ # Services are discovered from service.json files automatically
@@ -0,0 +1,26 @@
1
+ services:
2
+ nats:
3
+ image: nats:2.10-alpine
4
+ container_name: tdk_example_nats
5
+ command: ["--jetstream", "--store_dir=/data/jetstream", "--http_port", "8222"]
6
+ restart: unless-stopped
7
+ networks:
8
+ tdk_example_backend:
9
+ aliases:
10
+ - tdk_example_nats
11
+ volumes:
12
+ - tdk_example_nats_data:/data
13
+ healthcheck:
14
+ test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8222/healthz"]
15
+ interval: 5s
16
+ timeout: 3s
17
+ start_period: 5s
18
+ retries: 20
19
+
20
+ networks:
21
+ tdk_example_backend:
22
+ external: true
23
+ name: tdk_example_backend
24
+
25
+ volumes:
26
+ tdk_example_nats_data:
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "orders-api",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "build": "bun build src/index.ts --outdir dist --target bun --packages external",
7
+ "start": "bun run dist/index.js",
8
+ "dev": "bun --watch src/index.ts"
9
+ },
10
+ "dependencies": {
11
+ "@hono/node-server": "^1.19.0",
12
+ "hono": "^4.9.0",
13
+ "nats": "^2.29.3",
14
+ "pg": "^8.16.3"
15
+ },
16
+ "devDependencies": {
17
+ "@types/node": "^20.0.0",
18
+ "@types/pg": "^8.15.5"
19
+ }
20
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "https://tdk-landscape.github.io/schema.service.json",
3
+ "schemaVersion": 1,
4
+ "appName": "orders-api",
5
+ "appType": "backend",
6
+ "stack": "shop",
7
+ "port": 4000,
8
+ "featuresEnabled": ["nats"],
9
+ "params": {
10
+ "NATS_URL": "nats://tdk_example_nats:4222"
11
+ },
12
+ "healthCheckPath": "/health",
13
+ "dependsOn": []
14
+ }
@@ -0,0 +1,81 @@
1
+ import { serve } from "@hono/node-server";
2
+ import { connect, type NatsConnection } from "nats";
3
+ import { Hono } from "hono";
4
+ import { Pool } from "pg";
5
+
6
+ const port = Number(process.env.PORT ?? 4000);
7
+ const databaseUrl = process.env.DATABASE_URL;
8
+ const natsUrl = process.env.NATS_URL ?? "nats://tdk_example_nats:4222";
9
+ if (!databaseUrl) throw new Error("DATABASE_URL is required");
10
+
11
+ const pgUrl = new URL(databaseUrl);
12
+ pgUrl.searchParams.delete("schema");
13
+ const db = new Pool({ connectionString: pgUrl.toString() });
14
+ let nats: NatsConnection;
15
+ let workerReady = false;
16
+
17
+ async function connectDependencies(): Promise<void> {
18
+ while (true) {
19
+ try {
20
+ await db.query(`
21
+ CREATE TABLE IF NOT EXISTS demo_orders (
22
+ id bigserial PRIMARY KEY,
23
+ item text NOT NULL,
24
+ created_at timestamptz NOT NULL DEFAULT now(),
25
+ worker_seen_at timestamptz
26
+ )
27
+ `);
28
+ nats = await connect({ servers: natsUrl });
29
+ return;
30
+ } catch (error) {
31
+ console.error("waiting for PostgreSQL and NATS", error);
32
+ await new Promise((resolve) => setTimeout(resolve, 1500));
33
+ }
34
+ }
35
+ }
36
+
37
+ const app = new Hono();
38
+ app.use("*", async (c, next) => {
39
+ c.header("Access-Control-Allow-Origin", "*");
40
+ c.header("Access-Control-Allow-Headers", "content-type");
41
+ c.header("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
42
+ await next();
43
+ });
44
+ app.options("*", (c) => c.body(null, 204));
45
+ app.get("/health", (c) => c.json({ ok: true }));
46
+ app.get("/worker-ready", (c) => c.json({ ready: workerReady }, workerReady ? 200 : 503));
47
+ app.get("/", async (c) => {
48
+ const result = await db.query("SELECT id, item, created_at AS \"createdAt\", worker_seen_at AS \"workerSeenAt\" FROM demo_orders ORDER BY id DESC LIMIT 20");
49
+ return c.json({ orders: result.rows });
50
+ });
51
+ app.post("/", async (c) => {
52
+ if (!workerReady) return c.json({ error: "orders worker is not ready" }, 503);
53
+ const body = await c.req.json<{ item?: unknown }>();
54
+ if (typeof body.item !== "string" || body.item.trim().length === 0) {
55
+ return c.json({ error: "item is required" }, 400);
56
+ }
57
+ const result = await db.query(
58
+ "INSERT INTO demo_orders (item) VALUES ($1) RETURNING id, item, created_at AS \"createdAt\", worker_seen_at AS \"workerSeenAt\"",
59
+ [body.item.trim()],
60
+ );
61
+ const order = result.rows[0];
62
+ nats.publish("demo.orders.created", new TextEncoder().encode(String(order.id)));
63
+ return c.json({ order }, 201);
64
+ });
65
+ app.get("/:id", async (c) => {
66
+ const result = await db.query(
67
+ "SELECT id, item, created_at AS \"createdAt\", worker_seen_at AS \"workerSeenAt\" FROM demo_orders WHERE id = $1",
68
+ [c.req.param("id")],
69
+ );
70
+ if (!result.rows[0]) return c.json({ error: "order not found" }, 404);
71
+ return c.json({ order: result.rows[0] });
72
+ });
73
+
74
+ await connectDependencies();
75
+ const readySubscription = nats.subscribe("demo.orders.worker-ready");
76
+ await nats.flush();
77
+ void (async () => {
78
+ for await (const _message of readySubscription) workerReady = true;
79
+ })();
80
+ serve({ fetch: app.fetch, port });
81
+ console.log(`orders-api listening on ${port}`);
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>TDK Orders</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>