@mesh-tech/mesh-cli 0.1.0

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 (140) hide show
  1. package/dist/bin/mesh.d.ts +16 -0
  2. package/dist/bin/mesh.d.ts.map +1 -0
  3. package/dist/bin/mesh.js +40 -0
  4. package/dist/bin/mesh.js.map +1 -0
  5. package/dist/src/commands/create-app.d.ts +10 -0
  6. package/dist/src/commands/create-app.d.ts.map +1 -0
  7. package/dist/src/commands/create-app.js +176 -0
  8. package/dist/src/commands/create-app.js.map +1 -0
  9. package/dist/src/commands/db/connect.d.ts +13 -0
  10. package/dist/src/commands/db/connect.d.ts.map +1 -0
  11. package/dist/src/commands/db/connect.js +68 -0
  12. package/dist/src/commands/db/connect.js.map +1 -0
  13. package/dist/src/commands/db/credentials.d.ts +16 -0
  14. package/dist/src/commands/db/credentials.d.ts.map +1 -0
  15. package/dist/src/commands/db/credentials.js +56 -0
  16. package/dist/src/commands/db/credentials.js.map +1 -0
  17. package/dist/src/commands/db/env.d.ts +15 -0
  18. package/dist/src/commands/db/env.d.ts.map +1 -0
  19. package/dist/src/commands/db/env.js +50 -0
  20. package/dist/src/commands/db/env.js.map +1 -0
  21. package/dist/src/commands/db/exec.d.ts +54 -0
  22. package/dist/src/commands/db/exec.d.ts.map +1 -0
  23. package/dist/src/commands/db/exec.js +331 -0
  24. package/dist/src/commands/db/exec.js.map +1 -0
  25. package/dist/src/commands/db/index.d.ts +12 -0
  26. package/dist/src/commands/db/index.d.ts.map +1 -0
  27. package/dist/src/commands/db/index.js +62 -0
  28. package/dist/src/commands/db/index.js.map +1 -0
  29. package/dist/src/commands/db/psql.d.ts +16 -0
  30. package/dist/src/commands/db/psql.d.ts.map +1 -0
  31. package/dist/src/commands/db/psql.js +133 -0
  32. package/dist/src/commands/db/psql.js.map +1 -0
  33. package/dist/src/commands/deploy.d.ts +24 -0
  34. package/dist/src/commands/deploy.d.ts.map +1 -0
  35. package/dist/src/commands/deploy.js +228 -0
  36. package/dist/src/commands/deploy.js.map +1 -0
  37. package/dist/src/commands/dev.d.ts +17 -0
  38. package/dist/src/commands/dev.d.ts.map +1 -0
  39. package/dist/src/commands/dev.js +1248 -0
  40. package/dist/src/commands/dev.js.map +1 -0
  41. package/dist/src/commands/devbox.d.ts +22 -0
  42. package/dist/src/commands/devbox.d.ts.map +1 -0
  43. package/dist/src/commands/devbox.js +463 -0
  44. package/dist/src/commands/devbox.js.map +1 -0
  45. package/dist/src/commands/login.d.ts +47 -0
  46. package/dist/src/commands/login.d.ts.map +1 -0
  47. package/dist/src/commands/login.js +344 -0
  48. package/dist/src/commands/login.js.map +1 -0
  49. package/dist/src/commands/registry.d.ts +20 -0
  50. package/dist/src/commands/registry.d.ts.map +1 -0
  51. package/dist/src/commands/registry.js +336 -0
  52. package/dist/src/commands/registry.js.map +1 -0
  53. package/dist/src/commands/secrets/exec.d.ts +17 -0
  54. package/dist/src/commands/secrets/exec.d.ts.map +1 -0
  55. package/dist/src/commands/secrets/exec.js +132 -0
  56. package/dist/src/commands/secrets/exec.js.map +1 -0
  57. package/dist/src/commands/secrets/index.d.ts +10 -0
  58. package/dist/src/commands/secrets/index.d.ts.map +1 -0
  59. package/dist/src/commands/secrets/index.js +35 -0
  60. package/dist/src/commands/secrets/index.js.map +1 -0
  61. package/dist/src/commands/secrets/set.d.ts +22 -0
  62. package/dist/src/commands/secrets/set.d.ts.map +1 -0
  63. package/dist/src/commands/secrets/set.js +234 -0
  64. package/dist/src/commands/secrets/set.js.map +1 -0
  65. package/dist/src/commands/stack.d.ts +17 -0
  66. package/dist/src/commands/stack.d.ts.map +1 -0
  67. package/dist/src/commands/stack.js +175 -0
  68. package/dist/src/commands/stack.js.map +1 -0
  69. package/dist/src/commands/tunnel/index.d.ts +14 -0
  70. package/dist/src/commands/tunnel/index.d.ts.map +1 -0
  71. package/dist/src/commands/tunnel/index.js +217 -0
  72. package/dist/src/commands/tunnel/index.js.map +1 -0
  73. package/dist/src/index.d.ts +7 -0
  74. package/dist/src/index.d.ts.map +1 -0
  75. package/dist/src/index.js +7 -0
  76. package/dist/src/index.js.map +1 -0
  77. package/dist/src/utils/bastion.d.ts +43 -0
  78. package/dist/src/utils/bastion.d.ts.map +1 -0
  79. package/dist/src/utils/bastion.js +77 -0
  80. package/dist/src/utils/bastion.js.map +1 -0
  81. package/dist/src/utils/context.d.ts +29 -0
  82. package/dist/src/utils/context.d.ts.map +1 -0
  83. package/dist/src/utils/context.js +132 -0
  84. package/dist/src/utils/context.js.map +1 -0
  85. package/dist/src/utils/credentials.d.ts +61 -0
  86. package/dist/src/utils/credentials.d.ts.map +1 -0
  87. package/dist/src/utils/credentials.js +145 -0
  88. package/dist/src/utils/credentials.js.map +1 -0
  89. package/dist/src/utils/index.d.ts +9 -0
  90. package/dist/src/utils/index.d.ts.map +1 -0
  91. package/dist/src/utils/index.js +9 -0
  92. package/dist/src/utils/index.js.map +1 -0
  93. package/dist/src/utils/log.d.ts +8 -0
  94. package/dist/src/utils/log.d.ts.map +1 -0
  95. package/dist/src/utils/log.js +17 -0
  96. package/dist/src/utils/log.js.map +1 -0
  97. package/dist/src/utils/pulumi.d.ts +24 -0
  98. package/dist/src/utils/pulumi.d.ts.map +1 -0
  99. package/dist/src/utils/pulumi.js +63 -0
  100. package/dist/src/utils/pulumi.js.map +1 -0
  101. package/package.json +50 -0
  102. package/templates/api-web-db/api/.dockerignore +2 -0
  103. package/templates/api-web-db/api/Dockerfile +16 -0
  104. package/templates/api-web-db/api/package.json.hbs +21 -0
  105. package/templates/api-web-db/api/src/index.ts.hbs +123 -0
  106. package/templates/api-web-db/api/tsconfig.json +13 -0
  107. package/templates/api-web-db/package.json.hbs +27 -0
  108. package/templates/api-web-db/sst.config.ts.hbs +151 -0
  109. package/templates/api-web-db/tsconfig.json +11 -0
  110. package/templates/api-web-db/web/.dockerignore +3 -0
  111. package/templates/api-web-db/web/Dockerfile +17 -0
  112. package/templates/api-web-db/web/app/lib/db.server.ts +36 -0
  113. package/templates/api-web-db/web/app/root.tsx +29 -0
  114. package/templates/api-web-db/web/app/routes/home.tsx.hbs +114 -0
  115. package/templates/api-web-db/web/app/routes.ts +3 -0
  116. package/templates/api-web-db/web/package.json.hbs +30 -0
  117. package/templates/api-web-db/web/prisma/schema.prisma +25 -0
  118. package/templates/api-web-db/web/prisma.config.ts +18 -0
  119. package/templates/api-web-db/web/react-router.config.ts +5 -0
  120. package/templates/api-web-db/web/tsconfig.json +19 -0
  121. package/templates/api-web-db/web/vite.config.ts +9 -0
  122. package/templates/temporal-api-worker/README.md.hbs +133 -0
  123. package/templates/temporal-api-worker/api/.dockerignore +4 -0
  124. package/templates/temporal-api-worker/api/Dockerfile +13 -0
  125. package/templates/temporal-api-worker/api/package.json.hbs +23 -0
  126. package/templates/temporal-api-worker/api/src/encryption-codec.ts +133 -0
  127. package/templates/temporal-api-worker/api/src/index.ts.hbs +145 -0
  128. package/templates/temporal-api-worker/api/tsconfig.json +14 -0
  129. package/templates/temporal-api-worker/package.json.hbs +29 -0
  130. package/templates/temporal-api-worker/sst.config.ts.hbs +161 -0
  131. package/templates/temporal-api-worker/tsconfig.json +10 -0
  132. package/templates/temporal-api-worker/worker/.dockerignore +4 -0
  133. package/templates/temporal-api-worker/worker/Dockerfile +11 -0
  134. package/templates/temporal-api-worker/worker/package.json.hbs +25 -0
  135. package/templates/temporal-api-worker/worker/src/activities.ts.hbs +49 -0
  136. package/templates/temporal-api-worker/worker/src/bundle-workflows.ts +38 -0
  137. package/templates/temporal-api-worker/worker/src/encryption-codec.ts +156 -0
  138. package/templates/temporal-api-worker/worker/src/worker.ts.hbs +93 -0
  139. package/templates/temporal-api-worker/worker/src/workflows.ts.hbs +60 -0
  140. package/templates/temporal-api-worker/worker/tsconfig.json +14 -0
@@ -0,0 +1,1248 @@
1
+ /**
2
+ * mesh dev — Pulumi-output-driven local development orchestrator
3
+ *
4
+ * Reads `pulumi stack output dev --json` to discover services and tunnels,
5
+ * then launches each service's dev command in a tmux window.
6
+ *
7
+ * Usage:
8
+ * mesh dev Start all services, attach tmux
9
+ * mesh dev --headless Start without attaching
10
+ * mesh dev --kill Stop everything
11
+ * mesh dev --status Show service health (--json for structured)
12
+ * mesh dev logs <service> Tail a service's output
13
+ * mesh dev restart <svc> Restart a single service
14
+ */
15
+ import { execFileSync, spawnSync } from "child_process";
16
+ import * as fs from "fs";
17
+ import * as net from "net";
18
+ import * as os from "os";
19
+ import * as path from "path";
20
+ import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
21
+ import { logInfo, logSuccess, logError, logWarn } from "../utils/index.js";
22
+ function getSessionStatePath(sessionName) {
23
+ const dir = path.join(os.tmpdir(), "mesh-dev-sessions");
24
+ if (!fs.existsSync(dir))
25
+ fs.mkdirSync(dir, { recursive: true });
26
+ return path.join(dir, `${sessionName}.json`);
27
+ }
28
+ function saveSessionState(sessionName, state) {
29
+ fs.writeFileSync(getSessionStatePath(sessionName), JSON.stringify(state, null, 2));
30
+ }
31
+ function loadSessionState(sessionName) {
32
+ const filePath = getSessionStatePath(sessionName);
33
+ if (!fs.existsSync(filePath))
34
+ return null;
35
+ try {
36
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ function removeSessionState(sessionName) {
43
+ try {
44
+ fs.unlinkSync(getSessionStatePath(sessionName));
45
+ }
46
+ catch { }
47
+ }
48
+ /**
49
+ * Check whether a TCP port is available on localhost.
50
+ */
51
+ async function isPortFree(port) {
52
+ return new Promise((resolve) => {
53
+ const server = net.createServer();
54
+ server.once("error", () => resolve(false));
55
+ server.listen(port, "127.0.0.1", () => {
56
+ server.close(() => resolve(true));
57
+ });
58
+ });
59
+ }
60
+ /**
61
+ * Check if a port is being listened on (synchronous, for status display).
62
+ */
63
+ function isPortListeningSync(port) {
64
+ try {
65
+ execFileSync("bash", ["-c", `echo >/dev/tcp/127.0.0.1/${port}`], {
66
+ timeout: 500,
67
+ stdio: "ignore",
68
+ });
69
+ return true;
70
+ }
71
+ catch {
72
+ return false;
73
+ }
74
+ }
75
+ /**
76
+ * Ask the OS for an ephemeral free port.
77
+ */
78
+ async function findFreePort() {
79
+ return new Promise((resolve, reject) => {
80
+ const server = net.createServer();
81
+ server.once("error", reject);
82
+ server.listen(0, "127.0.0.1", () => {
83
+ const { port } = server.address();
84
+ server.close(() => resolve(port));
85
+ });
86
+ });
87
+ }
88
+ /**
89
+ * Clone devOutput and replace any colliding ports with dynamically
90
+ * allocated free ports. Preferred ports are kept when available.
91
+ */
92
+ async function allocatePorts(devOutput) {
93
+ const allocated = JSON.parse(JSON.stringify(devOutput));
94
+ const usedPorts = new Set();
95
+ // Tunnels first (services may depend on them)
96
+ for (const [name, tunnel] of Object.entries(allocated.tunnels)) {
97
+ const preferred = tunnel.localPort;
98
+ if (preferred > 0 &&
99
+ !usedPorts.has(preferred) &&
100
+ (await isPortFree(preferred))) {
101
+ usedPorts.add(preferred);
102
+ }
103
+ else {
104
+ const newPort = await findFreePort();
105
+ if (preferred > 0)
106
+ logWarn(`Port ${preferred} busy for tunnel '${name}', using ${newPort}`);
107
+ tunnel.localPort = newPort;
108
+ usedPorts.add(newPort);
109
+ }
110
+ }
111
+ // Service ports — track remappings for cross-service URL rewriting
112
+ const portRemaps = new Map();
113
+ for (const [name, service] of Object.entries(allocated.services)) {
114
+ if (service.port <= 0)
115
+ continue;
116
+ // Skip port allocation for deployed services (no dev command).
117
+ // They run in K8s and are accessed via tunnels, not local ports.
118
+ if (!service.command || service.command.length === 0)
119
+ continue;
120
+ const preferred = service.port;
121
+ if (!usedPorts.has(preferred) &&
122
+ (await isPortFree(preferred))) {
123
+ usedPorts.add(preferred);
124
+ }
125
+ else {
126
+ const newPort = await findFreePort();
127
+ logWarn(`Port ${preferred} busy for service '${name}', using ${newPort}`);
128
+ // Update PORT env var to match
129
+ if (service.env?.PORT && service.env.PORT.value === String(preferred)) {
130
+ service.env.PORT.value = String(newPort);
131
+ }
132
+ portRemaps.set(preferred, newPort);
133
+ service.port = newPort;
134
+ usedPorts.add(newPort);
135
+ }
136
+ }
137
+ // Cross-service URL rewriting: when a service port is reassigned,
138
+ // update any env var in other services that references the old port
139
+ // (e.g., API_URL from a link() pointing to the API's original port).
140
+ if (portRemaps.size > 0) {
141
+ for (const service of Object.values(allocated.services)) {
142
+ if (!service.env)
143
+ continue;
144
+ for (const envVar of Object.values(service.env)) {
145
+ for (const [oldPort, newPort] of portRemaps) {
146
+ if (envVar.value.includes(`localhost:${oldPort}`)) {
147
+ envVar.value = envVar.value.replace(`localhost:${oldPort}`, `localhost:${newPort}`);
148
+ }
149
+ }
150
+ }
151
+ }
152
+ }
153
+ return allocated;
154
+ }
155
+ // ============================================================================
156
+ // Pulumi helpers
157
+ // ============================================================================
158
+ /**
159
+ * Find the monorepo root by walking up from cwd looking for the root
160
+ * package.json (has "workspaces" or the well-known repo markers).
161
+ */
162
+ function findMonorepoRoot() {
163
+ let dir = process.cwd();
164
+ const root = path.parse(dir).root;
165
+ while (dir !== root) {
166
+ // Look for pnpm-workspace.yaml (pnpm monorepo) or .git (repo root)
167
+ if (fs.existsSync(path.join(dir, "pnpm-workspace.yaml")) ||
168
+ fs.existsSync(path.join(dir, "turbo.json"))) {
169
+ return dir;
170
+ }
171
+ // .git as fallback — but only if it's a directory (not a worktree file)
172
+ const gitPath = path.join(dir, ".git");
173
+ if (fs.existsSync(gitPath)) {
174
+ return dir;
175
+ }
176
+ dir = path.dirname(dir);
177
+ }
178
+ return null;
179
+ }
180
+ /**
181
+ * Discover Pulumi apps in the monorepo.
182
+ * Scans:
183
+ * {tenants,tests/tenants}/<tenant>/apps/<app>/Pulumi.yaml
184
+ * apps/<app>/Pulumi.yaml (flat layout, tenant from stack config)
185
+ */
186
+ function discoverApps(monorepoRoot) {
187
+ const apps = [];
188
+ // Helper: extract stacks and add an app entry
189
+ function addApp(appDir, tenant) {
190
+ const projectName = getProjectName(appDir);
191
+ const stacks = fs
192
+ .readdirSync(appDir)
193
+ .filter((f) => f.startsWith("Pulumi.") &&
194
+ f.endsWith(".yaml") &&
195
+ f !== "Pulumi.yaml")
196
+ .map((f) => f.replace(/^Pulumi\./, "").replace(/\.yaml$/, ""));
197
+ apps.push({
198
+ name: projectName,
199
+ tenant,
200
+ appPath: appDir,
201
+ relativePath: path.relative(monorepoRoot, appDir),
202
+ stacks,
203
+ });
204
+ }
205
+ // 1. Tenant-scoped: {tenants,tests/tenants}/<tenant>/apps/<app>/
206
+ const searchDirs = ["tenants", "tests/tenants"];
207
+ for (const searchDir of searchDirs) {
208
+ const base = path.join(monorepoRoot, searchDir);
209
+ if (!fs.existsSync(base))
210
+ continue;
211
+ for (const tenant of fs.readdirSync(base)) {
212
+ const appsDir = path.join(base, tenant, "apps");
213
+ if (!fs.existsSync(appsDir) || !fs.statSync(appsDir).isDirectory())
214
+ continue;
215
+ for (const app of fs.readdirSync(appsDir)) {
216
+ const appDir = path.join(appsDir, app);
217
+ if (!fs.existsSync(path.join(appDir, "Pulumi.yaml")))
218
+ continue;
219
+ addApp(appDir, tenant);
220
+ }
221
+ }
222
+ }
223
+ // 2. Flat layout: apps/<app>/ (tenant read from first stack config)
224
+ const flatAppsDir = path.join(monorepoRoot, "apps");
225
+ if (fs.existsSync(flatAppsDir) && fs.statSync(flatAppsDir).isDirectory()) {
226
+ for (const app of fs.readdirSync(flatAppsDir)) {
227
+ const appDir = path.join(flatAppsDir, app);
228
+ if (!fs.statSync(appDir).isDirectory())
229
+ continue;
230
+ if (!fs.existsSync(path.join(appDir, "Pulumi.yaml")))
231
+ continue;
232
+ // Read tenant from first stack config's mesh:tenant
233
+ let tenant = "unknown";
234
+ const stackConfigs = fs
235
+ .readdirSync(appDir)
236
+ .filter((f) => f.startsWith("Pulumi.") &&
237
+ f.endsWith(".yaml") &&
238
+ f !== "Pulumi.yaml");
239
+ if (stackConfigs.length > 0) {
240
+ try {
241
+ const content = fs.readFileSync(path.join(appDir, stackConfigs[0]), "utf-8");
242
+ const tenantMatch = content.match(/mesh:tenant:\s*(\S+)/);
243
+ if (tenantMatch)
244
+ tenant = tenantMatch[1];
245
+ }
246
+ catch { }
247
+ }
248
+ addApp(appDir, tenant);
249
+ }
250
+ }
251
+ return apps;
252
+ }
253
+ /**
254
+ * Find the Pulumi app root by walking up from cwd looking for Pulumi.yaml.
255
+ */
256
+ function findAppRoot(appPath) {
257
+ if (appPath) {
258
+ // Resolve relative to cwd or monorepo root
259
+ const resolved = path.resolve(appPath);
260
+ if (fs.existsSync(path.join(resolved, "Pulumi.yaml")))
261
+ return resolved;
262
+ // Try relative to monorepo root
263
+ const mono = findMonorepoRoot();
264
+ if (mono) {
265
+ const fromMono = path.resolve(mono, appPath);
266
+ if (fs.existsSync(path.join(fromMono, "Pulumi.yaml")))
267
+ return fromMono;
268
+ }
269
+ logError(`No Pulumi.yaml found at: ${appPath}`);
270
+ process.exit(1);
271
+ }
272
+ let dir = process.cwd();
273
+ const root = path.parse(dir).root;
274
+ while (dir !== root) {
275
+ if (fs.existsSync(path.join(dir, "Pulumi.yaml"))) {
276
+ return dir;
277
+ }
278
+ dir = path.dirname(dir);
279
+ }
280
+ return process.cwd();
281
+ }
282
+ /**
283
+ * Read the Pulumi project name from Pulumi.yaml.
284
+ */
285
+ function getProjectName(appRoot) {
286
+ const yamlPath = path.join(appRoot, "Pulumi.yaml");
287
+ if (!fs.existsSync(yamlPath))
288
+ return path.basename(appRoot);
289
+ const content = fs.readFileSync(yamlPath, "utf-8");
290
+ const match = content.match(/^name:\s*(.+)$/m);
291
+ return match?.[1]?.trim() ?? path.basename(appRoot);
292
+ }
293
+ /**
294
+ * Detect the current Pulumi stack from:
295
+ * 1. --stage flag
296
+ * 2. MESH_STAGE env var
297
+ * 3. `pulumi stack --show-name` (currently-selected stack)
298
+ * 4. Pulumi.<stack>.yaml files in the app root (if exactly one, use it)
299
+ */
300
+ function detectStack(appRoot, stageArg) {
301
+ if (stageArg)
302
+ return stageArg;
303
+ if (process.env.MESH_STAGE)
304
+ return process.env.MESH_STAGE;
305
+ try {
306
+ const result = execFileSync("pulumi", ["stack", "--show-name"], {
307
+ cwd: appRoot,
308
+ encoding: "utf-8",
309
+ stdio: ["ignore", "pipe", "ignore"],
310
+ }).trim();
311
+ if (result)
312
+ return result;
313
+ }
314
+ catch {
315
+ // Fall through
316
+ }
317
+ // Infer from Pulumi.<stack>.yaml config files
318
+ try {
319
+ const files = fs.readdirSync(appRoot).filter((f) => f.startsWith("Pulumi.") && f.endsWith(".yaml") && f !== "Pulumi.yaml");
320
+ if (files.length === 1) {
321
+ const match = files[0].match(/^Pulumi\.(.+)\.yaml$/);
322
+ if (match?.[1]) {
323
+ logInfo(`Auto-detected stack from ${files[0]}`);
324
+ return match[1];
325
+ }
326
+ }
327
+ }
328
+ catch {
329
+ // Fall through
330
+ }
331
+ return "dev";
332
+ }
333
+ /**
334
+ * Read the dev output from Pulumi stack.
335
+ * Tries `app` output first (new env.register() format), falls back to `dev`.
336
+ */
337
+ function getDevOutput(appRoot, stack) {
338
+ // Try new format: export const app = env.register() → { dev: { ... } }
339
+ try {
340
+ const result = execFileSync("pulumi", ["stack", "output", "app", "--json", "--show-secrets", "--stack", stack], {
341
+ cwd: appRoot,
342
+ encoding: "utf-8",
343
+ stdio: ["ignore", "pipe", "pipe"],
344
+ });
345
+ const appOutput = JSON.parse(result);
346
+ if (appOutput.dev) {
347
+ return appOutput.dev;
348
+ }
349
+ }
350
+ catch {
351
+ // Fall through to legacy format
352
+ }
353
+ // Legacy format: export const dev = ...
354
+ try {
355
+ const result = execFileSync("pulumi", ["stack", "output", "dev", "--json", "--show-secrets", "--stack", stack], {
356
+ cwd: appRoot,
357
+ encoding: "utf-8",
358
+ stdio: ["ignore", "pipe", "pipe"],
359
+ });
360
+ return JSON.parse(result);
361
+ }
362
+ catch (err) {
363
+ logError(`Failed to read Pulumi stack output for stack '${stack}'.`);
364
+ logInfo("Make sure you've deployed with: pulumi up");
365
+ logInfo("And that the stack exports 'app' or 'dev' output.");
366
+ throw err;
367
+ }
368
+ }
369
+ // ============================================================================
370
+ // tmux helpers
371
+ // ============================================================================
372
+ function hasTmux() {
373
+ try {
374
+ execFileSync("which", ["tmux"], { stdio: "ignore" });
375
+ return true;
376
+ }
377
+ catch {
378
+ return false;
379
+ }
380
+ }
381
+ function sessionExists(name) {
382
+ try {
383
+ execFileSync("tmux", ["has-session", "-t", name], { stdio: "ignore" });
384
+ return true;
385
+ }
386
+ catch {
387
+ return false;
388
+ }
389
+ }
390
+ function killSession(name) {
391
+ try {
392
+ execFileSync("tmux", ["kill-session", "-t", name], { stdio: "ignore" });
393
+ return true;
394
+ }
395
+ catch {
396
+ return false;
397
+ }
398
+ }
399
+ /**
400
+ * Collect AWS credentials from the current environment.
401
+ */
402
+ function getAwsEnvVars() {
403
+ const result = {};
404
+ const vars = [
405
+ "AWS_PROFILE",
406
+ "AWS_ACCESS_KEY_ID",
407
+ "AWS_SECRET_ACCESS_KEY",
408
+ "AWS_SESSION_TOKEN",
409
+ "AWS_REGION",
410
+ "AWS_DEFAULT_REGION",
411
+ ];
412
+ for (const name of vars) {
413
+ const value = process.env[name];
414
+ if (value)
415
+ result[name] = value;
416
+ }
417
+ return result;
418
+ }
419
+ /**
420
+ * Resolve env vars for a service, rewriting tunnel references.
421
+ */
422
+ function getServiceEnvVars(service, tunnels) {
423
+ const result = {};
424
+ if (service.env) {
425
+ for (const [key, spec] of Object.entries(service.env)) {
426
+ let value = spec.value;
427
+ if (spec.tunnel) {
428
+ const tunnel = tunnels[spec.tunnel];
429
+ if (tunnel) {
430
+ try {
431
+ // Try as a full URL first (e.g. postgres://host:5432/db)
432
+ const url = new URL(value);
433
+ if (url.hostname) {
434
+ url.hostname = "localhost";
435
+ url.port = String(tunnel.localPort);
436
+ value = url.toString();
437
+ }
438
+ else {
439
+ // URL parsed but hostname empty (e.g. host:port parsed as protocol)
440
+ value = `localhost:${tunnel.localPort}`;
441
+ }
442
+ }
443
+ catch {
444
+ // Not a URL at all — bare host:port or just hostname
445
+ value = `localhost:${tunnel.localPort}`;
446
+ }
447
+ }
448
+ }
449
+ result[key] = value;
450
+ }
451
+ }
452
+ return result;
453
+ }
454
+ /**
455
+ * Set env vars on a tmux session. New windows inherit them.
456
+ *
457
+ * Use for truly shared vars (AWS creds, secrets). For per-service
458
+ * vars like PORT, use envPrefix() instead to avoid cross-service leaks.
459
+ */
460
+ function setTmuxEnv(sessionName, vars) {
461
+ for (const [key, value] of Object.entries(vars)) {
462
+ execFileSync("tmux", [
463
+ "set-environment", "-t", sessionName, key, value,
464
+ ], { stdio: "ignore" });
465
+ }
466
+ }
467
+ /**
468
+ * Build an `env K=V …` prefix string for per-service env vars.
469
+ *
470
+ * This scopes variables to the process instead of polluting the
471
+ * tmux session environment (where the last service's PORT would
472
+ * leak to every other service on restart).
473
+ */
474
+ function envPrefix(vars) {
475
+ const entries = Object.entries(vars);
476
+ if (entries.length === 0)
477
+ return "";
478
+ const parts = entries.map(([k, v]) => `${k}=${shellEscape(v)}`);
479
+ return `env ${parts.join(" ")} `;
480
+ }
481
+ /** Escape a value for shell interpolation. */
482
+ function shellEscape(s) {
483
+ // If the value is safe, return as-is
484
+ if (/^[a-zA-Z0-9_./:@=+,-]+$/.test(s))
485
+ return s;
486
+ // Otherwise single-quote it, escaping any embedded single quotes
487
+ return `'${s.replace(/'/g, "'\\''")}'`;
488
+ }
489
+ // ============================================================================
490
+ // Secret resolution
491
+ // ============================================================================
492
+ /**
493
+ * Resolve secrets from Secrets Manager and return env var key→value pairs.
494
+ * Applies tunnel rewrites to DATABASE_URL if a tunnel is active.
495
+ */
496
+ async function resolveSecrets(secrets, tunnels) {
497
+ const client = new SecretsManagerClient({});
498
+ const resolved = {};
499
+ for (const [name, secret] of Object.entries(secrets)) {
500
+ try {
501
+ const response = await client.send(new GetSecretValueCommand({ SecretId: secret.secretName }));
502
+ if (!response.SecretString)
503
+ continue;
504
+ const values = JSON.parse(response.SecretString);
505
+ for (const [secretKey, envVar] of Object.entries(secret.envMapping)) {
506
+ const raw = values[secretKey];
507
+ if (raw === undefined)
508
+ continue;
509
+ let value = raw;
510
+ // Rewrite DATABASE_URL host:port if there's a tunnel
511
+ if (envVar === "DATABASE_URL" || secretKey === "DATABASE_URL") {
512
+ for (const tunnel of Object.values(tunnels)) {
513
+ try {
514
+ const parsed = new URL(value);
515
+ parsed.hostname = "localhost";
516
+ parsed.port = String(tunnel.localPort);
517
+ value = parsed.toString();
518
+ break;
519
+ }
520
+ catch {
521
+ // Not a URL
522
+ }
523
+ }
524
+ }
525
+ resolved[envVar] = value;
526
+ }
527
+ logSuccess(`Secret resolved: ${name} (${secret.secretName})`);
528
+ }
529
+ catch (err) {
530
+ logWarn(`Could not resolve secret '${name}' (${secret.secretName}): ${err instanceof Error ? err.message : String(err)}`);
531
+ }
532
+ }
533
+ return resolved;
534
+ }
535
+ /**
536
+ * Update the DevBox host's Caddy reverse proxy config.
537
+ *
538
+ * Generates a Caddyfile so services are accessible at
539
+ * http://{service}.{stack}.{app}.{tenant}-{env}.mesh.local
540
+ * from the developer's Mac (via Tailscale + dnsmasq).
541
+ */
542
+ async function updateDevboxProxy(devOutput, appRoot) {
543
+ const appName = devOutput.app ?? path.basename(appRoot);
544
+ const tenant = devOutput.platform?.tenant ?? "mesh";
545
+ const env = devOutput.platform?.env ?? "dev";
546
+ const stack = devOutput.stack ?? "dev";
547
+ const baseDomain = `${tenant}-${env}.mesh.local`;
548
+ // Build hostname → port map
549
+ const services = {};
550
+ for (const [name, service] of Object.entries(devOutput.services)) {
551
+ if (service.port) {
552
+ services[`${name}.${stack}.${appName}.${baseDomain}`] = service.port;
553
+ }
554
+ }
555
+ for (const [name, tunnel] of Object.entries(devOutput.tunnels)) {
556
+ if (tunnel.localPort) {
557
+ services[`${name}.${stack}.${appName}.${baseDomain}`] = tunnel.localPort;
558
+ }
559
+ }
560
+ if (Object.keys(services).length === 0)
561
+ return;
562
+ // Generate Caddyfile
563
+ const lines = [
564
+ "# Auto-generated by mesh dev — do not edit manually.",
565
+ "",
566
+ ":8080 {",
567
+ ' respond /health "OK" 200',
568
+ "}",
569
+ "",
570
+ ];
571
+ for (const [hostname, port] of Object.entries(services)) {
572
+ lines.push(`http://${hostname} {`);
573
+ lines.push(` reverse_proxy localhost:${port}`);
574
+ lines.push("}");
575
+ lines.push("");
576
+ }
577
+ const caddyfile = lines.join("\n");
578
+ // Write to host's /etc/caddy via a throwaway container with a host volume mount.
579
+ // The Docker socket is the HOST's, so -v /etc/caddy mounts the HOST path.
580
+ execFileSync("docker", [
581
+ "run", "--rm",
582
+ "-v", "/etc/caddy:/etc/caddy",
583
+ "busybox", "sh", "-c",
584
+ `cat > /etc/caddy/Caddyfile << 'CADDYEOF'\n${caddyfile}\nCADDYEOF`,
585
+ ], { stdio: "pipe" });
586
+ // Reload Caddy on the host via nsenter into PID 1's mount namespace
587
+ execFileSync("docker", [
588
+ "run", "--rm", "--pid=host", "--privileged",
589
+ "busybox", "nsenter", "-t", "1", "-m", "--",
590
+ "caddy", "reload", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile",
591
+ ], { stdio: "pipe" });
592
+ logSuccess("DevBox proxy updated:");
593
+ for (const [hostname, port] of Object.entries(services)) {
594
+ logInfo(` http://${hostname} → localhost:${port}`);
595
+ }
596
+ }
597
+ // ============================================================================
598
+ // Core actions
599
+ // ============================================================================
600
+ /**
601
+ * Start all services in tmux windows.
602
+ */
603
+ async function startServices(sessionName, appRoot, devOutput, headless) {
604
+ if (!hasTmux()) {
605
+ logError("tmux is required. Install with: brew install tmux");
606
+ process.exit(1);
607
+ }
608
+ // Kill existing session
609
+ if (sessionExists(sessionName)) {
610
+ logInfo(`Killing existing session: ${sessionName}`);
611
+ killSession(sessionName);
612
+ }
613
+ const serviceNames = Object.keys(devOutput.services);
614
+ if (serviceNames.length === 0) {
615
+ logWarn("No services defined in dev output.");
616
+ return;
617
+ }
618
+ const tunnelNames = Object.keys(devOutput.tunnels);
619
+ // Create session with a status window
620
+ logInfo(`Creating tmux session: ${sessionName}`);
621
+ execFileSync("tmux", [
622
+ "new-session", "-d", "-s", sessionName, "-n", "status", "-c", appRoot,
623
+ ]);
624
+ // Start tunnels via tunnel manager in the status window (before services)
625
+ if (tunnelNames.length > 0) {
626
+ const bastionTunnels = Object.entries(devOutput.tunnels).filter(([, t]) => (t.type ?? "bastion") === "bastion");
627
+ // Validate bastion tunnels have a bastion instance
628
+ if (bastionTunnels.length > 0 && !devOutput.bastion?.instanceId) {
629
+ logError("Bastion tunnels defined but no bastion.instanceId in dev output.");
630
+ logInfo("Add bastion: { instanceId: '...' } to your dev stack output.");
631
+ process.exit(1);
632
+ }
633
+ // Set AWS creds on session so tunnel windows inherit them
634
+ setTmuxEnv(sessionName, getAwsEnvVars());
635
+ // Write tunnel config for the tunnel manager
636
+ const stateDir = path.join(os.tmpdir(), "mesh-dev-sessions");
637
+ if (!fs.existsSync(stateDir))
638
+ fs.mkdirSync(stateDir, { recursive: true });
639
+ const tunnelConfigPath = path.join(stateDir, `${sessionName}-tunnels.json`);
640
+ fs.writeFileSync(tunnelConfigPath, JSON.stringify({
641
+ tunnels: devOutput.tunnels,
642
+ bastionId: devOutput.bastion?.instanceId,
643
+ }));
644
+ // Run tunnel manager in the status window (combines tunnel management + status display)
645
+ // Use process.argv to self-reference (npx mesh may not work in all setups)
646
+ const meshBin = process.argv.slice(0, 2).join(" ");
647
+ const managerCmd = `${meshBin} dev __tunnel-manager --config '${tunnelConfigPath}' --tmux-session '${sessionName}'`;
648
+ execFileSync("tmux", [
649
+ "send-keys", "-t", `${sessionName}:status`, managerCmd, "Enter",
650
+ ]);
651
+ for (const [tName, tunnel] of Object.entries(devOutput.tunnels)) {
652
+ const target = tunnel.type === "kubectl"
653
+ ? `${tunnel.namespace ?? "default"}/${tunnel.resource ?? tName}:${tunnel.port}`
654
+ : `${tunnel.host}:${tunnel.port}`;
655
+ logSuccess(`Tunnel: ${tName} → localhost:${tunnel.localPort} → ${target}`);
656
+ }
657
+ // Give tunnels a moment to establish before starting services
658
+ logInfo("Waiting for tunnels to establish...");
659
+ spawnSync("sleep", ["3"]);
660
+ }
661
+ // Resolve secrets from Secrets Manager
662
+ let secretEnvVars = {};
663
+ if (devOutput.secrets && Object.keys(devOutput.secrets).length > 0) {
664
+ secretEnvVars = await resolveSecrets(devOutput.secrets, devOutput.tunnels);
665
+ }
666
+ // Set shared env vars (AWS creds + secrets) on the tmux session.
667
+ // Per-service vars are set before each window is created.
668
+ setTmuxEnv(sessionName, getAwsEnvVars());
669
+ setTmuxEnv(sessionName, secretEnvVars);
670
+ for (const [name, service] of Object.entries(devOutput.services)) {
671
+ const cmd = service.command.join(" ");
672
+ // Skip services with no dev command (e.g., deployed-only like ApiDocs)
673
+ if (!cmd) {
674
+ logInfo(`Skipped: ${name} (no dev command, deployed to K8s)`);
675
+ continue;
676
+ }
677
+ const serviceDir = path.resolve(appRoot, service.src);
678
+ // Per-service env vars are inlined into the command via `env K=V`
679
+ // to avoid cross-service leaks through the tmux session environment.
680
+ const serviceVars = getServiceEnvVars(service, devOutput.tunnels);
681
+ const prefixedCmd = `${envPrefix(serviceVars)}${cmd}`;
682
+ execFileSync("tmux", [
683
+ "new-window", "-t", sessionName, "-n", name, "-c", serviceDir,
684
+ ]);
685
+ // Keep window alive on exit so errors are visible
686
+ execFileSync("tmux", [
687
+ "set-option", "-t", `${sessionName}:${name}`, "remain-on-exit", "on",
688
+ ], { stdio: "ignore" });
689
+ execFileSync("tmux", [
690
+ "send-keys", "-t", `${sessionName}:${name}`, prefixedCmd, "Enter",
691
+ ]);
692
+ logSuccess(`Started: ${name} (${service.src}, port ${service.port})`);
693
+ }
694
+ // Generate reverse proxy config for DevBox hostname-based routing.
695
+ // When running inside a devbox (DEVCONTAINER=1 on EC2), update the host's
696
+ // Caddy config so services are accessible via {service}.mesh.local.
697
+ if (process.env.DEVCONTAINER === "1") {
698
+ try {
699
+ await updateDevboxProxy(devOutput, appRoot);
700
+ }
701
+ catch (e) {
702
+ logWarn(`Could not update devbox proxy: ${e.message}`);
703
+ }
704
+ }
705
+ // Status window: if no tunnels, run watch for live status updates.
706
+ // (When tunnels exist, the tunnel manager already runs in the status window.)
707
+ if (tunnelNames.length === 0) {
708
+ const statusCmd = `watch -n2 -t npx mesh dev --status --session '${sessionName}'`;
709
+ execFileSync("tmux", [
710
+ "send-keys", "-t", `${sessionName}:status`, statusCmd, "Enter",
711
+ ]);
712
+ }
713
+ console.log("");
714
+ logSuccess(`Dev session started: ${sessionName}`);
715
+ console.log("");
716
+ if (tunnelNames.length > 0) {
717
+ console.log("Tunnels:");
718
+ for (const tName of tunnelNames) {
719
+ const t = devOutput.tunnels[tName];
720
+ const target = t.type === "kubectl"
721
+ ? `${t.namespace ?? "default"}/${t.resource ?? tName}:${t.port}`
722
+ : `${t.host}:${t.port}`;
723
+ const label = t.type === "kubectl" ? " (kubectl)" : "";
724
+ console.log(` ${tName.padEnd(20)} localhost:${t.localPort} → ${target}${label}`);
725
+ }
726
+ console.log("");
727
+ }
728
+ console.log("Services:");
729
+ for (const name of serviceNames) {
730
+ const s = devOutput.services[name];
731
+ const hasCmd = s.command && s.command.length > 0;
732
+ if (hasCmd) {
733
+ const addr = s.port ? `http://localhost:${s.port}` : "(no port)";
734
+ console.log(` ${name.padEnd(20)} ${addr}`);
735
+ }
736
+ else {
737
+ console.log(` ${name.padEnd(20)} (deployed)`);
738
+ }
739
+ }
740
+ console.log("");
741
+ if (!headless) {
742
+ logInfo("Attaching to tmux session...");
743
+ spawnSync("tmux", ["attach", "-t", sessionName], { stdio: "inherit" });
744
+ }
745
+ else {
746
+ console.log(`Attach with: tmux attach -t ${sessionName}`);
747
+ console.log(`Stop with: mesh dev --kill`);
748
+ console.log("");
749
+ }
750
+ }
751
+ /**
752
+ * Show status of running services.
753
+ */
754
+ function showStatus(sessionName, devOutput, asJson) {
755
+ if (!sessionExists(sessionName)) {
756
+ if (asJson) {
757
+ console.log(JSON.stringify({ running: false, session: sessionName }));
758
+ }
759
+ else {
760
+ logInfo(`No active session: ${sessionName}`);
761
+ }
762
+ return;
763
+ }
764
+ // Get tmux window list
765
+ let windows = [];
766
+ try {
767
+ const raw = execFileSync("tmux", [
768
+ "list-windows", "-t", sessionName, "-F", "#{window_name} #{pane_dead}",
769
+ ], { encoding: "utf-8" });
770
+ windows = raw.trim().split("\n");
771
+ }
772
+ catch {
773
+ // Session might have disappeared
774
+ }
775
+ const windowStatus = {};
776
+ for (const line of windows) {
777
+ const [name, dead] = line.split(" ");
778
+ if (name)
779
+ windowStatus[name] = dead === "1" ? "exited" : "running";
780
+ }
781
+ const tunnelNames = Object.keys(devOutput.tunnels);
782
+ if (asJson) {
783
+ const services = {};
784
+ for (const [name, svc] of Object.entries(devOutput.services)) {
785
+ services[name] = {
786
+ status: windowStatus[name] ?? "unknown",
787
+ port: svc.port,
788
+ src: svc.src,
789
+ };
790
+ }
791
+ const tunnels = {};
792
+ for (const [name, tun] of Object.entries(devOutput.tunnels)) {
793
+ const target = tun.type === "kubectl"
794
+ ? `${tun.namespace ?? "default"}/${tun.resource ?? name}:${tun.port}`
795
+ : `${tun.host}:${tun.port}`;
796
+ const listening = isPortListeningSync(tun.localPort);
797
+ tunnels[name] = {
798
+ status: listening ? "running" : "down",
799
+ localPort: tun.localPort,
800
+ remote: target,
801
+ type: tun.type ?? "bastion",
802
+ };
803
+ }
804
+ console.log(JSON.stringify({ running: true, session: sessionName, services, tunnels }, null, 2));
805
+ }
806
+ else {
807
+ console.log("── mesh dev ──");
808
+ console.log("");
809
+ console.log(`Session: ${sessionName}`);
810
+ if (tunnelNames.length > 0) {
811
+ // Tunnel manager runs in a single window; check health by probing local ports
812
+ const managerStatus = windowStatus["tunnels"] ?? "unknown";
813
+ const managerIcon = managerStatus === "running" ? "●" : "✗";
814
+ console.log("");
815
+ console.log(`Tunnels: ${managerIcon} manager ${managerStatus}`);
816
+ for (const [name, tun] of Object.entries(devOutput.tunnels)) {
817
+ const listening = isPortListeningSync(tun.localPort);
818
+ const icon = listening ? "●" : "✗";
819
+ const status = listening ? "up" : "down";
820
+ const target = tun.type === "kubectl"
821
+ ? `${tun.namespace ?? "default"}/${tun.resource ?? name}:${tun.port}`
822
+ : `${tun.host}:${tun.port}`;
823
+ const label = tun.type === "kubectl" ? " kubectl" : "";
824
+ console.log(` ${icon} ${name.padEnd(16)} ${status.padEnd(10)} :${tun.localPort} → ${target}${label}`);
825
+ }
826
+ }
827
+ console.log("");
828
+ console.log("Services:");
829
+ for (const [name, svc] of Object.entries(devOutput.services)) {
830
+ const hasCmd = svc.command && svc.command.length > 0;
831
+ if (!hasCmd) {
832
+ console.log(` ☁ ${name.padEnd(16)} deployed ${svc.src}`);
833
+ continue;
834
+ }
835
+ const status = windowStatus[name] ?? "unknown";
836
+ const icon = status === "running" ? "●" : status === "exited" ? "✗" : "?";
837
+ const addr = svc.port ? `:${svc.port}` : "";
838
+ console.log(` ${icon} ${name.padEnd(16)} ${status.padEnd(10)} ${addr.padEnd(8)} ${svc.src}`);
839
+ }
840
+ console.log("");
841
+ console.log("Commands:");
842
+ console.log(" mesh dev restart <s> Restart a service");
843
+ console.log(" mesh dev logs <s> Tail service logs");
844
+ console.log(" mesh dev --kill Stop everything");
845
+ console.log("");
846
+ console.log("tmux: Ctrl+b n/p switch windows, d detach");
847
+ }
848
+ }
849
+ /**
850
+ * Restart a single service window.
851
+ */
852
+ function restartService(sessionName, serviceName, _appRoot, devOutput) {
853
+ const service = devOutput.services[serviceName];
854
+ if (!service) {
855
+ logError(`Unknown service: ${serviceName}`);
856
+ logInfo(`Available: ${Object.keys(devOutput.services).join(", ")}`);
857
+ process.exit(1);
858
+ }
859
+ if (!sessionExists(sessionName)) {
860
+ logError(`No active session: ${sessionName}. Run 'mesh dev' first.`);
861
+ process.exit(1);
862
+ }
863
+ const target = `${sessionName}:${serviceName}`;
864
+ // Kill the current pane process and respawn
865
+ try {
866
+ execFileSync("tmux", ["respawn-pane", "-k", "-t", target], { stdio: "ignore" });
867
+ }
868
+ catch {
869
+ // Window might not exist yet
870
+ logError(`Window '${serviceName}' not found in session.`);
871
+ process.exit(1);
872
+ }
873
+ // Refresh shared env (AWS creds may have rotated) on the session.
874
+ // Per-service vars are inlined into the command to avoid cross-service leaks.
875
+ setTmuxEnv(sessionName, getAwsEnvVars());
876
+ const serviceVars = getServiceEnvVars(service, devOutput.tunnels);
877
+ const cmd = service.command.join(" ");
878
+ const prefixedCmd = `${envPrefix(serviceVars)}${cmd}`;
879
+ execFileSync("tmux", ["send-keys", "-t", target, prefixedCmd, "Enter"]);
880
+ logSuccess(`Restarted: ${serviceName}`);
881
+ }
882
+ /**
883
+ * Capture and display logs from a service window.
884
+ */
885
+ function showLogs(sessionName, serviceName, tail) {
886
+ if (!sessionExists(sessionName)) {
887
+ logError(`No active session: ${sessionName}. Run 'mesh dev' first.`);
888
+ process.exit(1);
889
+ }
890
+ const target = `${sessionName}:${serviceName}`;
891
+ try {
892
+ const result = execFileSync("tmux", [
893
+ "capture-pane", "-t", target, "-p", "-S", `-${tail}`,
894
+ ], { encoding: "utf-8" });
895
+ process.stdout.write(result);
896
+ }
897
+ catch {
898
+ logError(`Could not capture logs for '${serviceName}'.`);
899
+ logInfo("Is the service name correct? Check with: mesh dev --status");
900
+ process.exit(1);
901
+ }
902
+ }
903
+ // ============================================================================
904
+ // Tunnel Manager
905
+ // ============================================================================
906
+ import { spawn } from "child_process";
907
+ /**
908
+ * Tunnel manager — spawns all tunnels as child processes, monitors health,
909
+ * auto-restarts on failure, and prints a live status table.
910
+ *
911
+ * Runs in a single tmux window instead of one window per tunnel.
912
+ */
913
+ function runTunnelManager(configPath, sessionName) {
914
+ const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
915
+ const tunnelEntries = Object.entries(config.tunnels);
916
+ if (tunnelEntries.length === 0) {
917
+ console.log("No tunnels to manage.");
918
+ return;
919
+ }
920
+ const states = tunnelEntries.map(([name, tunnel]) => {
921
+ let argv;
922
+ if ((tunnel.type ?? "bastion") === "bastion") {
923
+ argv = [
924
+ "aws", "ssm", "start-session",
925
+ "--target", config.bastionId,
926
+ "--document-name", "AWS-StartPortForwardingSessionToRemoteHost",
927
+ "--parameters", JSON.stringify({
928
+ host: [tunnel.host],
929
+ portNumber: [String(tunnel.port)],
930
+ localPortNumber: [String(tunnel.localPort)],
931
+ }),
932
+ ];
933
+ }
934
+ else {
935
+ const ns = tunnel.namespace ?? "default";
936
+ const resource = tunnel.resource ?? `svc/${name}`;
937
+ argv = ["kubectl", "port-forward", "-n", ns, resource, `${tunnel.localPort}:${tunnel.port}`];
938
+ }
939
+ return {
940
+ name,
941
+ tunnel,
942
+ process: null,
943
+ status: "starting",
944
+ restarts: 0,
945
+ lastStart: 0,
946
+ argv,
947
+ };
948
+ });
949
+ function startTunnel(state) {
950
+ state.status = "starting";
951
+ state.lastStart = Date.now();
952
+ const [cmd, ...args] = state.argv;
953
+ const child = spawn(cmd, args, {
954
+ stdio: ["ignore", "pipe", "pipe"],
955
+ });
956
+ child.stdout?.on("data", () => {
957
+ // SSM/kubectl output — tunnel is establishing
958
+ if (state.status === "starting") {
959
+ state.status = "up";
960
+ }
961
+ });
962
+ child.stderr?.on("data", (data) => {
963
+ const msg = data.toString().trim();
964
+ if (msg && !msg.includes("Starting session") && !msg.includes("Forwarding from")) {
965
+ console.log(` [${state.name}] ${msg}`);
966
+ }
967
+ });
968
+ child.on("exit", (code) => {
969
+ state.process = null;
970
+ if (shuttingDown)
971
+ return;
972
+ state.status = "down";
973
+ state.restarts++;
974
+ // Back off: wait longer between restarts (1s, 2s, 4s, 8s, max 30s)
975
+ const delay = Math.min(1000 * Math.pow(2, Math.min(state.restarts - 1, 4)), 30000);
976
+ console.log(` [${state.name}] exited (code ${code}), restarting in ${delay / 1000}s...`);
977
+ state.status = "restarting";
978
+ setTimeout(() => {
979
+ if (!shuttingDown)
980
+ startTunnel(state);
981
+ }, delay);
982
+ });
983
+ state.process = child;
984
+ // Mark as "up" after a short delay if still running
985
+ setTimeout(() => {
986
+ if (state.process && state.status === "starting") {
987
+ state.status = "up";
988
+ }
989
+ }, 2000);
990
+ }
991
+ let shuttingDown = false;
992
+ function shutdown() {
993
+ if (shuttingDown)
994
+ return;
995
+ shuttingDown = true;
996
+ console.log("\nShutting down tunnels...");
997
+ for (const state of states) {
998
+ if (state.process) {
999
+ state.process.kill("SIGTERM");
1000
+ }
1001
+ }
1002
+ setTimeout(() => process.exit(0), 1000);
1003
+ }
1004
+ process.on("SIGTERM", shutdown);
1005
+ process.on("SIGINT", shutdown);
1006
+ // Print header
1007
+ console.log("mesh dev — tunnel manager");
1008
+ console.log("─".repeat(60));
1009
+ for (const state of states) {
1010
+ const target = state.tunnel.type === "kubectl"
1011
+ ? `${state.tunnel.namespace ?? "default"}/${state.tunnel.resource ?? state.name}:${state.tunnel.port}`
1012
+ : `${state.tunnel.host}:${state.tunnel.port}`;
1013
+ const label = state.tunnel.type === "kubectl" ? "kubectl" : "bastion";
1014
+ console.log(` ${state.name.padEnd(16)} :${state.tunnel.localPort} → ${target} (${label})`);
1015
+ }
1016
+ console.log("─".repeat(60));
1017
+ console.log("");
1018
+ // Start all tunnels
1019
+ for (const state of states) {
1020
+ startTunnel(state);
1021
+ }
1022
+ // Periodically print full status (tunnels + services)
1023
+ function printStatus() {
1024
+ if (shuttingDown)
1025
+ return;
1026
+ // Clear screen and move cursor to top
1027
+ process.stdout.write("\x1b[2J\x1b[H");
1028
+ console.log("── mesh dev ──");
1029
+ console.log("");
1030
+ // Tunnels
1031
+ console.log("Tunnels:");
1032
+ for (const s of states) {
1033
+ const icon = s.status === "up" ? "●" : s.status === "restarting" ? "↻" : s.status === "starting" ? "…" : "✗";
1034
+ const statusLabel = s.status === "up" ? "up" : s.status === "restarting" ? `restart(${s.restarts})` : s.status;
1035
+ const target = s.tunnel.type === "kubectl"
1036
+ ? `${s.tunnel.namespace ?? "default"}/${s.tunnel.resource ?? s.name}:${s.tunnel.port}`
1037
+ : `${s.tunnel.host}:${s.tunnel.port}`;
1038
+ const label = s.tunnel.type === "kubectl" ? " kubectl" : "";
1039
+ console.log(` ${icon} ${s.name.padEnd(16)} ${statusLabel.padEnd(14)} :${s.tunnel.localPort} → ${target}${label}`);
1040
+ }
1041
+ // Services (check tmux window status if session name provided)
1042
+ if (sessionName) {
1043
+ let windows = [];
1044
+ try {
1045
+ const raw = execFileSync("tmux", [
1046
+ "list-windows", "-t", sessionName, "-F", "#{window_name} #{pane_dead}",
1047
+ ], { encoding: "utf-8" });
1048
+ windows = raw.trim().split("\n");
1049
+ }
1050
+ catch { }
1051
+ const windowStatus = {};
1052
+ for (const line of windows) {
1053
+ const [name, dead] = line.split(" ");
1054
+ if (name)
1055
+ windowStatus[name] = dead === "1" ? "exited" : "running";
1056
+ }
1057
+ // Get service names from session state
1058
+ const state = loadSessionState(sessionName);
1059
+ if (state?.devOutput?.services) {
1060
+ console.log("");
1061
+ console.log("Services:");
1062
+ for (const [name, svc] of Object.entries(state.devOutput.services)) {
1063
+ const hasCmd = svc.command && svc.command.length > 0;
1064
+ if (!hasCmd) {
1065
+ console.log(` ☁ ${name.padEnd(16)} deployed ${svc.src}`);
1066
+ continue;
1067
+ }
1068
+ const status = windowStatus[name] ?? "unknown";
1069
+ const icon = status === "running" ? "●" : status === "exited" ? "✗" : "?";
1070
+ const addr = svc.port ? `:${svc.port}` : "";
1071
+ console.log(` ${icon} ${name.padEnd(16)} ${status.padEnd(14)} ${addr.padEnd(8)} ${svc.src}`);
1072
+ }
1073
+ }
1074
+ }
1075
+ console.log("");
1076
+ console.log("mesh dev --status / --kill | Ctrl+b n/p switch windows");
1077
+ }
1078
+ // Print initial status after tunnels start, then refresh every 2s
1079
+ setTimeout(printStatus, 3000);
1080
+ setInterval(printStatus, 2000);
1081
+ }
1082
+ // ============================================================================
1083
+ // Command registration
1084
+ // ============================================================================
1085
+ export function registerDevCommand(program) {
1086
+ const dev = program
1087
+ .command("dev")
1088
+ .description("Start local dev environment (reads Pulumi stack outputs)")
1089
+ .option("--app <path>", "Path to a Pulumi app (relative to monorepo root or cwd)")
1090
+ .option("--stage <stage>", "Pulumi stack name (default: auto-detect)")
1091
+ .option("--headless", "Start without attaching to tmux")
1092
+ .option("--kill", "Kill existing dev session")
1093
+ .option("--status", "Show service status")
1094
+ .option("--json", "Output status as JSON (with --status)")
1095
+ .option("--session <name>", "Custom tmux session name");
1096
+ // Default action: start or show status
1097
+ dev.action(async (options) => {
1098
+ // Fast-path: --status/--kill with explicit --session can skip app root resolution
1099
+ if (options.session && (options.status || options.kill)) {
1100
+ const sessionName = options.session;
1101
+ if (options.kill) {
1102
+ if (sessionExists(sessionName)) {
1103
+ killSession(sessionName);
1104
+ logSuccess(`Killed session: ${sessionName}`);
1105
+ }
1106
+ else {
1107
+ logInfo(`No active session: ${sessionName}`);
1108
+ }
1109
+ removeSessionState(sessionName);
1110
+ return;
1111
+ }
1112
+ // --status with --session: use state file, no Pulumi needed
1113
+ const state = loadSessionState(sessionName);
1114
+ if (state) {
1115
+ showStatus(sessionName, state.devOutput, !!options.json);
1116
+ return;
1117
+ }
1118
+ // No state — fall through to normal flow
1119
+ }
1120
+ const appRoot = findAppRoot(options.app);
1121
+ const projectName = getProjectName(appRoot);
1122
+ const sessionName = options.session ?? `${projectName}-dev`;
1123
+ // --kill
1124
+ if (options.kill) {
1125
+ if (sessionExists(sessionName)) {
1126
+ killSession(sessionName);
1127
+ logSuccess(`Killed session: ${sessionName}`);
1128
+ }
1129
+ else {
1130
+ logInfo(`No active session: ${sessionName}`);
1131
+ }
1132
+ removeSessionState(sessionName);
1133
+ return;
1134
+ }
1135
+ const stack = detectStack(appRoot, options.stage);
1136
+ logInfo(`Project: ${projectName}, Stack: ${stack}`);
1137
+ const rawDevOutput = getDevOutput(appRoot, stack);
1138
+ // --status — use persisted state (has allocated ports), fall back to raw
1139
+ if (options.status) {
1140
+ const state = loadSessionState(sessionName);
1141
+ showStatus(sessionName, state?.devOutput ?? rawDevOutput, !!options.json);
1142
+ return;
1143
+ }
1144
+ // Allocate dynamic ports (avoid collisions with other running apps)
1145
+ const devOutput = await allocatePorts(rawDevOutput);
1146
+ // Persist session state so --status and restart see allocated ports
1147
+ saveSessionState(sessionName, {
1148
+ appRoot,
1149
+ stack,
1150
+ devOutput,
1151
+ startedAt: new Date().toISOString(),
1152
+ });
1153
+ // Start services
1154
+ await startServices(sessionName, appRoot, devOutput, !!options.headless);
1155
+ });
1156
+ // Subcommand: logs
1157
+ dev
1158
+ .command("logs <service>")
1159
+ .description("Show logs for a service")
1160
+ .option("--tail <lines>", "Number of lines", "100")
1161
+ .action((service, opts) => {
1162
+ // --session is on the parent dev command
1163
+ const sessionName = dev.opts().session;
1164
+ if (sessionName) {
1165
+ showLogs(sessionName, service, parseInt(opts.tail, 10));
1166
+ }
1167
+ else {
1168
+ const appRoot = findAppRoot();
1169
+ const projectName = getProjectName(appRoot);
1170
+ showLogs(`${projectName}-dev`, service, parseInt(opts.tail, 10));
1171
+ }
1172
+ });
1173
+ // Subcommand: restart
1174
+ dev
1175
+ .command("restart <service>")
1176
+ .description("Restart a service")
1177
+ .option("--stage <stage>", "Pulumi stack name")
1178
+ .action((service, opts) => {
1179
+ // --session is on the parent dev command
1180
+ const parentSession = dev.opts().session;
1181
+ const appRoot = findAppRoot();
1182
+ const projectName = getProjectName(appRoot);
1183
+ const sessionName = parentSession ?? `${projectName}-dev`;
1184
+ // Use persisted state (has allocated ports), fall back to raw Pulumi output
1185
+ const state = loadSessionState(sessionName);
1186
+ const devOutput = state?.devOutput ??
1187
+ getDevOutput(appRoot, detectStack(appRoot, opts.stage));
1188
+ restartService(sessionName, service, appRoot, devOutput);
1189
+ });
1190
+ // Subcommand: list — discover apps in the monorepo
1191
+ dev
1192
+ .command("list")
1193
+ .description("List Pulumi apps in the monorepo")
1194
+ .action(() => {
1195
+ // --json is on the parent dev command
1196
+ const asJson = dev.opts().json;
1197
+ const mono = findMonorepoRoot();
1198
+ if (!mono) {
1199
+ logError("Could not find monorepo root (pnpm-workspace.yaml or .git)");
1200
+ process.exit(1);
1201
+ }
1202
+ const apps = discoverApps(mono);
1203
+ if (asJson) {
1204
+ // Include running state for each app
1205
+ const result = apps.map((app) => {
1206
+ const sessionName = `${app.name}-dev`;
1207
+ return {
1208
+ ...app,
1209
+ running: sessionExists(sessionName),
1210
+ session: sessionName,
1211
+ };
1212
+ });
1213
+ console.log(JSON.stringify(result, null, 2));
1214
+ }
1215
+ else {
1216
+ if (apps.length === 0) {
1217
+ logInfo("No Pulumi apps found.");
1218
+ return;
1219
+ }
1220
+ console.log("Apps in monorepo:");
1221
+ console.log("");
1222
+ for (const app of apps) {
1223
+ const sessionName = `${app.name}-dev`;
1224
+ const running = sessionExists(sessionName);
1225
+ const icon = running ? "●" : "○";
1226
+ const stacks = app.stacks.length > 0
1227
+ ? ` (${app.stacks.join(", ")})`
1228
+ : "";
1229
+ console.log(` ${icon} ${app.name.padEnd(24)} ${app.tenant.padEnd(12)} ${app.relativePath}${stacks}`);
1230
+ }
1231
+ console.log("");
1232
+ }
1233
+ });
1234
+ // Hidden subcommand: tunnel manager (started internally by mesh dev)
1235
+ dev
1236
+ .command("__tunnel-manager")
1237
+ .description("Internal: manage all tunnels in a single process")
1238
+ .option("--config <path>", "Path to tunnel config JSON")
1239
+ .option("--tmux-session <name>", "tmux session name for status display")
1240
+ .action((opts) => {
1241
+ if (!opts.config) {
1242
+ logError("--config is required");
1243
+ process.exit(1);
1244
+ }
1245
+ runTunnelManager(opts.config, opts.tmuxSession);
1246
+ });
1247
+ }
1248
+ //# sourceMappingURL=dev.js.map