@tdk-landscape/tdk-cli-core 1.3.170 → 1.3.171

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.
@@ -0,0 +1,151 @@
1
+ /**
2
+ * The `custom` agent runtime: a plain Bun HTTP service with no model SDK, no agent framework and no vendor dependency.
3
+ * It is the escape hatch the agent resource type is built on. Replace the `runTurn` body with your model call and tools;
4
+ * the process contract around it (health, readiness, session checkpoints, SIGTERM drain) is what TDK relies on.
5
+ */
6
+ export function getCustomAgentIndexTemplate(name) {
7
+ return `import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
8
+ import { mkdirSync, readFileSync, renameSync, writeFileSync, existsSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+
11
+ // Contract with TDK (see docs/ai-agents.md):
12
+ // AGENT_DURABILITY files (set by tdk from service.json agent.durability)
13
+ // AGENT_STATE_DIR where files-durability checkpoints live (a named volume that survives tdk down)
14
+ // PORT port to listen on
15
+ // /health: the process is alive. /ready: the checkpoint store is reachable. Idle is healthy, so /health never checks the store.
16
+
17
+ const DURABILITY = 'files';
18
+ const STATE_DIR = process.env.AGENT_STATE_DIR || join(process.cwd(), '.agent-state');
19
+ const PORT = Number(process.env.PORT || 3000);
20
+ const DRAIN_TIMEOUT_MS = 10_000;
21
+
22
+ interface Checkpoint {
23
+ sessionId: string;
24
+ messages: Array<{ role: 'user' | 'agent'; content: string; at: string }>;
25
+ }
26
+
27
+ // Session store. files: one JSON checkpoint per session, written atomically (temp file, then rename), so a kill
28
+ // mid-write leaves the previous checkpoint intact.
29
+ const store = {
30
+ ready(): boolean {
31
+ try {
32
+ mkdirSync(STATE_DIR, { recursive: true });
33
+ return true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ },
38
+ load(sessionId: string): Checkpoint {
39
+ const file = join(STATE_DIR, sessionId + '.json');
40
+ return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : { sessionId, messages: [] };
41
+ },
42
+ save(checkpoint: Checkpoint): void {
43
+ mkdirSync(STATE_DIR, { recursive: true });
44
+ const file = join(STATE_DIR, checkpoint.sessionId + '.json');
45
+ const tmp = file + '.' + process.pid + '.tmp';
46
+ writeFileSync(tmp, JSON.stringify(checkpoint));
47
+ renameSync(tmp, file);
48
+ },
49
+ };
50
+
51
+ // Replace this with the agent's work: call your model, run tools, return the reply. Keep it a pure function of the
52
+ // checkpoint so a resumed session continues from what was saved.
53
+ async function runTurn(checkpoint: Checkpoint, input: string): Promise<string> {
54
+ // The caller has already appended this turn's user message, so the turn number is half the message count, rounded up.
55
+ return 'received ' + input + ' (turn ' + Math.ceil(checkpoint.messages.length / 2) + ')';
56
+ }
57
+
58
+ let draining = false;
59
+ let inFlight = 0;
60
+
61
+ function sendJson(res: ServerResponse, status: number, body: unknown) {
62
+ res.statusCode = status;
63
+ res.setHeader('content-type', 'application/json');
64
+ res.end(JSON.stringify(body));
65
+ }
66
+
67
+ async function readJson(req: IncomingMessage): Promise<any> {
68
+ const chunks: Buffer[] = [];
69
+ for await (const chunk of req) chunks.push(chunk as Buffer);
70
+ const raw = Buffer.concat(chunks).toString('utf8');
71
+ return raw ? JSON.parse(raw) : {};
72
+ }
73
+
74
+ const server = createServer(async (req, res) => {
75
+ inFlight++;
76
+ res.on('close', () => inFlight--);
77
+ const url = new URL(req.url ?? '/', 'http://localhost');
78
+
79
+ try {
80
+ if (url.pathname === '/health') {
81
+ return sendJson(res, 200, { status: 'ok', service: '${name}', draining });
82
+ }
83
+
84
+ if (url.pathname === '/ready') {
85
+ const ok = !draining && store.ready();
86
+ return sendJson(res, ok ? 200 : 503, { ready: ok, durability: DURABILITY });
87
+ }
88
+
89
+ // Session routing: the session id comes from the x-session-id header, so several instances of one agent can
90
+ // share a Traefik route and each session still reaches its own checkpoint.
91
+ if (url.pathname === '/turn' && req.method === 'POST') {
92
+ if (draining) return sendJson(res, 503, { error: 'draining' });
93
+ const sessionId = String(req.headers['x-session-id'] ?? '');
94
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(sessionId)) {
95
+ return sendJson(res, 400, { error: 'x-session-id header is required (1-128 letters, digits, - or _)' });
96
+ }
97
+ const body = await readJson(req);
98
+ const input = String(body.input ?? '');
99
+ const checkpoint = store.load(sessionId);
100
+ checkpoint.messages.push({ role: 'user', content: input, at: new Date().toISOString() });
101
+ const reply = await runTurn(checkpoint, input);
102
+ checkpoint.messages.push({ role: 'agent', content: reply, at: new Date().toISOString() });
103
+ store.save(checkpoint);
104
+ return sendJson(res, 200, { sessionId, reply, turns: checkpoint.messages.length / 2 });
105
+ }
106
+
107
+ if (url.pathname === '/session' && req.method === 'GET') {
108
+ const sessionId = String(req.headers['x-session-id'] ?? '');
109
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(sessionId)) return sendJson(res, 400, { error: 'x-session-id header is required' });
110
+ const checkpoint = store.load(sessionId);
111
+ return sendJson(res, 200, { sessionId, turns: checkpoint.messages.length / 2, messages: checkpoint.messages });
112
+ }
113
+
114
+ return sendJson(res, 404, { error: 'not_found' });
115
+ } catch (error) {
116
+ console.error('[agent] request failed:', error);
117
+ return sendJson(res, 500, { error: 'internal_error' });
118
+ }
119
+ });
120
+
121
+ // Bind beyond loopback: Traefik reaches the container over the Docker network.
122
+ server.listen(PORT, '0.0.0.0', () => {
123
+ console.log('\\n🤖 ${name} agent on http://localhost:' + PORT + ' (durability: ' + DURABILITY + ')');
124
+ });
125
+
126
+ // Drain on SIGTERM (tdk down, hot reload, docker stop): stop taking turns, let in-flight turns finish, then exit 0.
127
+ // Checkpoints are written before a turn is answered, so a drained exit loses no acknowledged turn.
128
+ function shutdown(signal: string) {
129
+ if (draining) return;
130
+ draining = true;
131
+ console.log('[agent] ' + signal + ' received, draining ' + inFlight + ' in-flight request(s)');
132
+ server.close();
133
+ const deadline = Date.now() + DRAIN_TIMEOUT_MS;
134
+ const timer = setInterval(() => {
135
+ if (inFlight === 0 || Date.now() > deadline) {
136
+ clearInterval(timer);
137
+ process.exit(0);
138
+ }
139
+ }, 100);
140
+ }
141
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
142
+ process.on('SIGINT', () => shutdown('SIGINT'));
143
+ `;
144
+ }
145
+ export const customAgentRuntime = {
146
+ id: "custom",
147
+ label: "Custom agent runtime (Bun HTTP, no vendor dependency)",
148
+ dependencies: {},
149
+ devDependencies: {},
150
+ createIndex: getCustomAgentIndexTemplate,
151
+ };
@@ -0,0 +1,36 @@
1
+ // Copyright (c) 2026 TDK Landscape contributors
2
+ // SPDX-License-Identifier: MIT
3
+ import { TdkError } from "../utils/errors.js";
4
+ import { customAgentRuntime } from "./custom.js";
5
+ /**
6
+ * Agent runtimes an `agent` resource can be scaffolded with. No runtime is privileged: each is a scaffold plus the
7
+ * process contract in its index file, and `custom` is the default because it has no vendor dependency.
8
+ */
9
+ export const AGENT_RUNTIMES = {
10
+ custom: customAgentRuntime,
11
+ };
12
+ export const DEFAULT_AGENT_RUNTIME = "custom";
13
+ // `postgres` is deliberately absent: the scaffold has no Postgres session store yet (#1256), so offering it would produce an
14
+ // agent that answers every turn with a 500. Add it here together with the store.
15
+ export const AGENT_DURABILITY_OPTIONS = ["files"];
16
+ export function resolveAgentRuntime(runtimeId) {
17
+ const id = runtimeId ?? DEFAULT_AGENT_RUNTIME;
18
+ const provider = AGENT_RUNTIMES[id];
19
+ if (!provider) {
20
+ const supported = Object.keys(AGENT_RUNTIMES).join(", ");
21
+ throw new TdkError(`Unknown agent runtime "${id}". Supported runtimes: ${supported}.`, [
22
+ `Use one of: ${supported}`,
23
+ `Omit --runtime to use ${DEFAULT_AGENT_RUNTIME}`,
24
+ ]);
25
+ }
26
+ return provider;
27
+ }
28
+ export function resolveAgentDurability(value) {
29
+ const durability = value ?? "files";
30
+ if (!AGENT_DURABILITY_OPTIONS.includes(durability)) {
31
+ throw new TdkError(`Unknown --durability "${durability}".`, [
32
+ `Use one of: ${AGENT_DURABILITY_OPTIONS.join(", ")}`,
33
+ ]);
34
+ }
35
+ return durability;
36
+ }
@@ -1,5 +1,6 @@
1
1
  // Copyright (c) 2026 TDK Landscape contributors
2
2
  // SPDX-License-Identifier: MIT
3
+ import { resolveAgentRuntime } from "../agent-runtimes/registry.js";
3
4
  import { getBackendIndexTemplate } from "../backend-languages/bun.js";
4
5
  import { TdkError } from "../utils/errors.js";
5
6
  import { elysiaBackendProvider } from "./elysia.js";
@@ -56,6 +57,21 @@ export function resolveBackendFramework(resourceType, frameworkId, language) {
56
57
  }
57
58
  return mcpBackendProvider;
58
59
  }
60
+ // `agent` is scaffolded by an agent runtime (src/agent-runtimes/), not a framework. Only the default runtime is reachable here:
61
+ // `tdk resource` validates --runtime before it scaffolds, so the package.json and tsconfig always match the chosen runtime.
62
+ if (resourceType === "agent") {
63
+ if (frameworkId !== undefined) {
64
+ throw new TdkError("--framework cannot be combined with --type agent.", [
65
+ "An agent resource is scaffolded by an agent runtime; use --runtime <id> instead",
66
+ ]);
67
+ }
68
+ if (language?.createFiles) {
69
+ throw new TdkError(`--language ${language.id} cannot be combined with --type agent.`, [
70
+ "An agent resource is a Bun service; drop --language",
71
+ ]);
72
+ }
73
+ return resolveAgentRuntime();
74
+ }
59
75
  if (resourceType !== "backend" || frameworkId === undefined)
60
76
  return undefined;
61
77
  if (language?.createFiles) {
@@ -131,6 +131,7 @@ export function showHelp() {
131
131
  ${chalk.cyan("$")} tdk project # Create master configs
132
132
  ${chalk.cyan("$")} tdk resource api --type backend # Create a backend service
133
133
  ${chalk.cyan("$")} tdk resource app --type frontend # Create a frontend app
134
+ ${chalk.cyan("$")} tdk resource bot --type agent # Create an AI agent (sessions survive tdk down)
134
135
  ${chalk.cyan("$")} tdk stacks --services # List stacks with their resources
135
136
  ${chalk.cyan("$")} tdk up my-stack # Start a stack
136
137
  ${chalk.cyan("$")} tdk networks # Show Traefik-routed URLs
@@ -4,6 +4,7 @@ import { existsSync, mkdirSync } from "node:fs";
4
4
  import { isAbsolute, relative, resolve } from "node:path";
5
5
  import chalk from "chalk";
6
6
  import { Command } from "commander";
7
+ import { DEFAULT_AGENT_RUNTIME, resolveAgentDurability, resolveAgentRuntime, } from "../agent-runtimes/registry.js";
7
8
  import { resolveBackendFramework } from "../backend-frameworks/registry.js";
8
9
  import { getBackendIndexTemplate } from "../backend-languages/bun.js";
9
10
  import { resolveBackendLanguage } from "../backend-languages/registry.js";
@@ -56,6 +57,10 @@ export const TYPE_SPECIFIC = {
56
57
  mcp: {
57
58
  healthCheckPath: "/health",
58
59
  },
60
+ // /ready is the readiness route (checkpoint store reachable); /health is liveness only, so an idle agent stays healthy.
61
+ agent: {
62
+ healthCheckPath: "/health",
63
+ },
59
64
  [BRING_YOUR_OWN_TYPE]: {
60
65
  healthCheckPath: "/health",
61
66
  },
@@ -84,7 +89,7 @@ export function resolveResourcePort(resourceType, value, assignedPort, resources
84
89
  }
85
90
  return port;
86
91
  }
87
- export function createServiceJson(name, type, stack, port, extraFeatures = [], frameworkId, languageId) {
92
+ export function createServiceJson(name, type, stack, port, extraFeatures = [], frameworkId, languageId, agent) {
88
93
  const framework = resolveFrontendFramework(type, frameworkId);
89
94
  // Only an explicit selection is persisted: a missing `language` has always meant Bun.
90
95
  const language = languageId === undefined ? undefined : resolveBackendLanguage(type, languageId);
@@ -123,8 +128,11 @@ export function createServiceJson(name, type, stack, port, extraFeatures = [], f
123
128
  stack,
124
129
  port,
125
130
  ...(framework ? { framework: framework.id } : {}),
126
- ...(backendFramework && type !== "mcp" ? { framework: backendFramework.id } : {}),
131
+ ...(backendFramework && type !== "mcp" && type !== "agent"
132
+ ? { framework: backendFramework.id }
133
+ : {}),
127
134
  ...(language ? { language: language.id } : {}),
135
+ ...(type === "agent" && agent ? { agent } : {}),
128
136
  };
129
137
  }
130
138
  export function createByoServiceJson(name, stack, port, options) {
@@ -272,7 +280,7 @@ export function parseResourceType(type) {
272
280
  export const resourceCommand = new Command("resource")
273
281
  .description("Create a new resource (service) from scratch, or register an existing one")
274
282
  .argument("[name]", "Resource name (kebab-case)")
275
- .option("-t, --type <type>", "Resource type: backend, frontend, worker, mcp, bring-your-own, sdk", "backend")
283
+ .option("-t, --type <type>", "Resource type: backend, frontend, worker, mcp, agent, bring-your-own, sdk", "backend")
276
284
  .option("--framework <id>", "Framework: frontend react (default), vue, svelte, preact, lit, solid, qwik, vanilla, tanstack-router (see --frameworks); backend hono (default), express, elysia, fastify, nestjs, koa, h3")
277
285
  .option("--frameworks", "List registered frontend frameworks and exit")
278
286
  .option("--language <id>", "Backend language: bun (default), python, go, rust")
@@ -289,6 +297,8 @@ export const resourceCommand = new Command("resource")
289
297
  .option("--image <name>", "Docker image name instead of building from Dockerfile (for bring-your-own)")
290
298
  .option("--port <port>", "Port for the resource, within its type's range (default: next free port in that range)")
291
299
  .option("--feature <feature...>", "Enable a resource feature (for example: prisma)")
300
+ .option("--runtime <id>", "Agent runtime (for agent only): custom (default)")
301
+ .option("--durability <kind>", "Where agent sessions are checkpointed (for agent only): files (default, a named volume that survives tdk down)")
292
302
  .action(async (name, options) => {
293
303
  if (options.frameworks) {
294
304
  for (const f of listFrontendFrameworks()) {
@@ -322,6 +332,21 @@ export const resourceCommand = new Command("resource")
322
332
  assertValid(validateResourceName(resourceName));
323
333
  }
324
334
  const resourceType = parseResourceType(options.type);
335
+ if (resourceType !== "agent" &&
336
+ (options.runtime !== undefined || options.durability !== undefined)) {
337
+ throw new TdkError("--runtime and --durability can only be used with --type agent.", [
338
+ "Drop --runtime and --durability, or pass --type agent",
339
+ ]);
340
+ }
341
+ // Validate the agent options before anything is written, so a bad flag leaves no half-scaffolded directory.
342
+ let agentConfig;
343
+ if (resourceType === "agent") {
344
+ resolveAgentRuntime(options.runtime);
345
+ agentConfig = {
346
+ runtime: options.runtime ?? DEFAULT_AGENT_RUNTIME,
347
+ durability: resolveAgentDurability(options.durability),
348
+ };
349
+ }
325
350
  let frameworkOption = options.framework;
326
351
  if (resourceType === "frontend" &&
327
352
  frameworkOption === undefined &&
@@ -343,7 +368,9 @@ export const resourceCommand = new Command("resource")
343
368
  const backendFramework = resolveBackendFramework(resourceType, frameworkOption, backendLanguage);
344
369
  // The id passed on to the file generators. An mcp resource has one fixed scaffold and no `framework` setting, so it passes none
345
370
  // (its provider is selected by the resource type itself).
346
- const scaffoldFrameworkId = resourceType === "mcp" ? undefined : (frontendFramework?.id ?? backendFramework?.id);
371
+ const scaffoldFrameworkId = resourceType === "mcp" || resourceType === "agent"
372
+ ? undefined
373
+ : (frontendFramework?.id ?? backendFramework?.id);
347
374
  let dddEnabled = false;
348
375
  if (options.ddd) {
349
376
  if (resourceType !== "backend" && resourceType !== "worker") {
@@ -423,6 +450,7 @@ export const resourceCommand = new Command("resource")
423
450
  frontend: `apps/${resourceName}`,
424
451
  worker: `workers/${resourceName}`,
425
452
  mcp: `services/${stackName}/${resourceName}`,
453
+ agent: `services/${stackName}/${resourceName}`,
426
454
  [BRING_YOUR_OWN_TYPE]: `services/${stackName}/${resourceName}`,
427
455
  sdk: `packages/${resourceName}`,
428
456
  };
@@ -607,7 +635,7 @@ This file contains the resource configuration for TDK.
607
635
  // Prepare file generation tasks
608
636
  const requestedFeatures = Array.isArray(options.feature) ? options.feature : [];
609
637
  const prismaEnabled = requestedFeatures.includes("prisma");
610
- const serviceJson = createServiceJson(resourceName, resourceType, stackName, assignedPort, [...(dddEnabled ? ["ddd"] : []), ...requestedFeatures], scaffoldFrameworkId, options.language === undefined ? undefined : backendLanguage?.id);
638
+ const serviceJson = createServiceJson(resourceName, resourceType, stackName, assignedPort, [...(dddEnabled ? ["ddd"] : []), ...requestedFeatures], scaffoldFrameworkId, options.language === undefined ? undefined : backendLanguage?.id, agentConfig);
611
639
  const packageJson = createPackageJson(resourceName, resourceType, scaffoldFrameworkId, prismaEnabled);
612
640
  if (prismaEnabled && resourceType === "backend") {
613
641
  serviceJson.dependsOn = [`${resourceName}-migrator`];
@@ -645,12 +673,16 @@ This file contains the resource configuration for TDK.
645
673
  },
646
674
  ];
647
675
  // Add source files based on resource type
648
- if (resourceType === "backend" || resourceType === "mcp") {
676
+ if (resourceType === "backend" || resourceType === "mcp" || resourceType === "agent") {
649
677
  tasks.push({
650
678
  type: "text",
651
679
  filename: "src/index.ts",
652
680
  content: (backendFramework?.createIndex ?? getBackendIndexTemplate)(resourceName),
653
- description: resourceType === "mcp" ? "Generating MCP server source" : "Generating backend source",
681
+ description: resourceType === "mcp"
682
+ ? "Generating MCP server source"
683
+ : resourceType === "agent"
684
+ ? "Generating agent source"
685
+ : "Generating backend source",
654
686
  emoji: "💻",
655
687
  });
656
688
  }
@@ -9,6 +9,7 @@ export const RESOURCE_CONFIG_APP_TYPES = [
9
9
  "worker",
10
10
  "migrator",
11
11
  "mcp",
12
+ "agent",
12
13
  "bring-your-own",
13
14
  ];
14
15
  export const CREATABLE_RESOURCE_TYPES = [
@@ -16,6 +17,7 @@ export const CREATABLE_RESOURCE_TYPES = [
16
17
  "frontend",
17
18
  "worker",
18
19
  "mcp",
20
+ "agent",
19
21
  "bring-your-own",
20
22
  ];
21
23
  export function isCreatableResourceType(value) {
@@ -45,6 +45,7 @@ export const VALID_RESOURCE_TYPES = [
45
45
  "worker",
46
46
  "migrator",
47
47
  "mcp",
48
+ "agent",
48
49
  BRING_YOUR_OWN_TYPE,
49
50
  ];
50
51
  // Frontend/library/sdk services are served statically or consumed as
@@ -56,6 +57,7 @@ export const REQUIRED_PACKAGE_SCRIPTS = {
56
57
  worker: ["dev", "build", "start"],
57
58
  migrator: ["dev", "build", "start"],
58
59
  mcp: ["dev", "build", "start"],
60
+ agent: ["dev", "build", "start"],
59
61
  frontend: ["dev", "build"],
60
62
  library: ["dev", "build"],
61
63
  sdk: ["dev", "build"],
@@ -76,6 +78,8 @@ export const PORT_RANGES = {
76
78
  backend: { base: 4000, min: 4000, max: 4999, range: "4000-4999" },
77
79
  // An MCP server is an HTTP service, so it shares the backend-compatible range with bring-your-own.
78
80
  mcp: { base: 4000, min: 4000, max: 5999, range: "4000-5999" },
81
+ // An agent serves /health and /ready over HTTP (see agent-runtimes/custom.ts), so it shares the same range.
82
+ agent: { base: 4000, min: 4000, max: 5999, range: "4000-5999" },
79
83
  [BRING_YOUR_OWN_TYPE]: { base: 4000, min: 4000, max: 5999, range: "4000-5999" },
80
84
  worker: { base: 6000, min: 6000, max: 6999, range: "6000-6999" },
81
85
  health: { base: 5000, min: 5000, max: 5999, range: "5000-5999" },
@@ -232,7 +232,7 @@ export function checkDuplicateResourceNames(projectRoot = findProjectRoot() ?? p
232
232
  fix: 'Give each resource its own "appName" in its service.json. The name becomes the Compose service and the Traefik router, so two resources with one name collide',
233
233
  };
234
234
  }
235
- /** A resource that gets a Traefik route to its own `port`: a backend, an mcp server, or a bring-your-own resource that is not opted out. */
235
+ /** A resource that gets a Traefik route to its own `port`: a backend, an mcp server, an agent, or a bring-your-own resource that is not opted out. */
236
236
  function isRoutedToPort(resource) {
237
237
  const appType = resource.config?.appType;
238
238
  if (isApiServiceType(appType))
@@ -5,5 +5,5 @@
5
5
  * Protocol endpoint, which the engine builds, routes and health-checks exactly like a backend).
6
6
  */
7
7
  export function isApiServiceType(appType) {
8
- return appType === "backend" || appType === "mcp";
8
+ return appType === "backend" || appType === "mcp" || appType === "agent";
9
9
  }
@@ -15,6 +15,7 @@ const DEPRECATED_FIELDS = {
15
15
  };
16
16
  const KNOWN_SERVICE_FIELDS = new Set([
17
17
  "$schema",
18
+ "agent",
18
19
  "apiBasePath",
19
20
  "apiPath",
20
21
  "appName",
@@ -9,7 +9,9 @@ export function buildServicePorts(resource, ingressHttp) {
9
9
  const { appBase, apiBase } = resolveSubdomainBases(ingressHttp);
10
10
  url = `${isApiServiceType(appType) ? apiBase : appBase}${resolveServicePath(resource)}`;
11
11
  }
12
- return { url, containerPort: resource.port ?? resource.config?.port ?? null, hostPort: null };
12
+ const containerPort = resource.port ?? resource.config?.port ?? null;
13
+ const directUrl = !routable && containerPort ? `http://localhost:${containerPort}` : null;
14
+ return { url, containerPort, hostPort: null, directUrl };
13
15
  }
14
16
  /** Ports a client should forward or open: ingress, Tilt UI, and the published datastore. Not one per service. */
15
17
  export function buildStackPorts(plan, tiltPort) {