@mesh-tech/mesh-cli 0.39.0 → 0.40.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 (95) hide show
  1. package/dist/bin/{mesh.auth-preflight-JO5G7SOF.js → mesh.auth-preflight-AUFQ2FGD.js} +1 -1
  2. package/dist/bin/{mesh.chunk-CNLSVKYY.js → mesh.chunk-2ASXJ5DO.js} +233 -581
  3. package/dist/bin/mesh.chunk-2ASXJ5DO.js.map +6 -0
  4. package/dist/bin/{mesh.chunk-WODQO6TP.js → mesh.chunk-6AIG5OJM.js} +3 -3
  5. package/dist/bin/{mesh.chunk-OSV3EZQ3.js → mesh.chunk-6SSOVZOI.js} +1 -1
  6. package/dist/bin/{mesh.chunk-2ZJR5LGN.js → mesh.chunk-7HTKSUWR.js} +1507 -548
  7. package/dist/bin/mesh.chunk-7HTKSUWR.js.map +6 -0
  8. package/dist/bin/{mesh.chunk-UI6EFAMD.js → mesh.chunk-F44A3ACW.js} +2 -2
  9. package/dist/bin/{mesh.chunk-C2TKCYHP.js → mesh.chunk-HJGMOEUG.js} +1 -1
  10. package/dist/bin/{mesh.chunk-O6WRZU4X.js → mesh.chunk-NSQJEX36.js} +1 -1
  11. package/dist/bin/{mesh.chunk-C4ZM7DM2.js → mesh.chunk-PZVI7V3J.js} +1 -1
  12. package/dist/bin/mesh.chunk-TDCH4SFQ.js +419 -0
  13. package/dist/bin/mesh.chunk-TDCH4SFQ.js.map +6 -0
  14. package/dist/bin/mesh.chunk-YE2WIY3Y.js +473 -0
  15. package/dist/bin/mesh.chunk-YE2WIY3Y.js.map +6 -0
  16. package/dist/bin/{mesh.create-app-SOHBL75U.js → mesh.create-app-Z3RKSTBO.js} +3 -3
  17. package/dist/bin/{mesh.dev-token-server-SS7QEQOQ.js → mesh.dev-token-server-XG26C6IQ.js} +2 -2
  18. package/dist/bin/mesh.generate-XSM4C6FQ.js +10 -0
  19. package/dist/bin/mesh.js +9 -8
  20. package/dist/bin/mesh.js.map +1 -1
  21. package/dist/bin/{mesh.login-6VPMWHHU.js → mesh.login-3AWVGPCM.js} +2 -2
  22. package/dist/bin/{mesh.platform-setup-DJJB2PWL.js → mesh.platform-setup-2ZDMHRCI.js} +1 -1
  23. package/dist/bin/{mesh.program-Y4HUNBFP.js → mesh.program-K5JQXEHX.js} +9 -8
  24. package/dist/bin/{mesh.registry-5P4ZLFLJ.js → mesh.registry-JLQYFUNN.js} +4 -4
  25. package/dist/bin/{mesh.registry-identity-ONGUJQAY.js → mesh.registry-identity-3AR27UGO.js} +3 -3
  26. package/dist/bin/mesh.registry-identity-3AR27UGO.js.map +6 -0
  27. package/dist/bin/mesh.review-command-LUVZQN2T.js +238 -0
  28. package/dist/bin/mesh.review-command-LUVZQN2T.js.map +6 -0
  29. package/dist/bin/{mesh.start-5CPWOHDJ.js → mesh.start-KZ5L6SOJ.js} +2 -2
  30. package/dist/bin/{mesh.wizard-FNRTSGVN.js → mesh.wizard-FCTT55KI.js} +7 -7
  31. package/dist/bin/mesh.workspace-membership-DI3YQGEK.js +65 -0
  32. package/dist/bin/mesh.workspace-membership-DI3YQGEK.js.map +6 -0
  33. package/dist/build-info.json +2 -2
  34. package/dist/src/commands/dev-doctor.d.ts +22 -0
  35. package/dist/src/commands/dev-doctor.js +149 -0
  36. package/dist/src/commands/dev-env-leaks.d.ts +24 -0
  37. package/dist/src/commands/dev-env-leaks.js +178 -0
  38. package/dist/src/commands/dev-launch.d.ts +17 -1
  39. package/dist/src/commands/dev-launch.js +130 -5
  40. package/dist/src/commands/dev-readiness.d.ts +49 -0
  41. package/dist/src/commands/dev-readiness.js +78 -0
  42. package/dist/src/commands/dev.d.ts +60 -0
  43. package/dist/src/commands/dev.js +539 -140
  44. package/dist/src/commands/hub/index.js +10 -7
  45. package/dist/src/commands/mcp/generate.d.ts +11 -0
  46. package/dist/src/commands/mcp/generate.js +78 -0
  47. package/dist/src/commands/mcp/index.d.ts +2 -0
  48. package/dist/src/commands/mcp/index.js +70 -0
  49. package/dist/src/commands/mcp/manifest-file.d.ts +5 -0
  50. package/dist/src/commands/mcp/manifest-file.js +24 -0
  51. package/dist/src/commands/mcp/review-command.d.ts +24 -0
  52. package/dist/src/commands/mcp/review-command.js +66 -0
  53. package/dist/src/commands/mcp/review.d.ts +41 -0
  54. package/dist/src/commands/mcp/review.js +140 -0
  55. package/dist/src/commands/mcp/workspace-membership.d.ts +7 -0
  56. package/dist/src/commands/mcp/workspace-membership.js +77 -0
  57. package/dist/src/commands/secrets/reindex.js +2 -7
  58. package/dist/src/commands/secrets/set.d.ts +22 -1
  59. package/dist/src/commands/secrets/set.js +31 -8
  60. package/dist/src/commands/temporal.js +35 -4
  61. package/dist/src/commands/temporal.js.map +2 -2
  62. package/dist/src/program.js +2 -0
  63. package/dist/src/utils/aws-auth.d.ts +4 -0
  64. package/dist/src/utils/aws-auth.js +7 -3
  65. package/dist/src/utils/dev-sessions-root.d.ts +3 -0
  66. package/dist/src/utils/dev-sessions-root.js +45 -0
  67. package/dist/src/utils/kubeconfig.js +2 -2
  68. package/dist/src/utils/tailscale.js +7 -1
  69. package/dist/src/utils/temporal-auth.js +2 -1
  70. package/dist/src/utils/workflow-fingerprint.js +23 -12
  71. package/fragments/base/index.ts.hbs +3 -0
  72. package/package.json +7 -5
  73. package/skills/mesh/SKILL.md +10 -6
  74. package/templates/api-auth/index.ts.hbs +1 -0
  75. package/templates/api-role-gating/index.ts.hbs +1 -0
  76. package/templates/external-service/index.ts.hbs +1 -0
  77. package/templates/workflow/index.ts.hbs +2 -0
  78. package/dist/bin/mesh.chunk-2ZJR5LGN.js.map +0 -6
  79. package/dist/bin/mesh.chunk-CNLSVKYY.js.map +0 -6
  80. /package/dist/bin/{mesh.auth-preflight-JO5G7SOF.js.map → mesh.auth-preflight-AUFQ2FGD.js.map} +0 -0
  81. /package/dist/bin/{mesh.chunk-WODQO6TP.js.map → mesh.chunk-6AIG5OJM.js.map} +0 -0
  82. /package/dist/bin/{mesh.chunk-OSV3EZQ3.js.map → mesh.chunk-6SSOVZOI.js.map} +0 -0
  83. /package/dist/bin/{mesh.chunk-UI6EFAMD.js.map → mesh.chunk-F44A3ACW.js.map} +0 -0
  84. /package/dist/bin/{mesh.chunk-C2TKCYHP.js.map → mesh.chunk-HJGMOEUG.js.map} +0 -0
  85. /package/dist/bin/{mesh.chunk-O6WRZU4X.js.map → mesh.chunk-NSQJEX36.js.map} +0 -0
  86. /package/dist/bin/{mesh.chunk-C4ZM7DM2.js.map → mesh.chunk-PZVI7V3J.js.map} +0 -0
  87. /package/dist/bin/{mesh.create-app-SOHBL75U.js.map → mesh.create-app-Z3RKSTBO.js.map} +0 -0
  88. /package/dist/bin/{mesh.dev-token-server-SS7QEQOQ.js.map → mesh.dev-token-server-XG26C6IQ.js.map} +0 -0
  89. /package/dist/bin/{mesh.login-6VPMWHHU.js.map → mesh.generate-XSM4C6FQ.js.map} +0 -0
  90. /package/dist/bin/{mesh.platform-setup-DJJB2PWL.js.map → mesh.login-3AWVGPCM.js.map} +0 -0
  91. /package/dist/bin/{mesh.program-Y4HUNBFP.js.map → mesh.platform-setup-2ZDMHRCI.js.map} +0 -0
  92. /package/dist/bin/{mesh.registry-5P4ZLFLJ.js.map → mesh.program-K5JQXEHX.js.map} +0 -0
  93. /package/dist/bin/{mesh.registry-identity-ONGUJQAY.js.map → mesh.registry-JLQYFUNN.js.map} +0 -0
  94. /package/dist/bin/{mesh.start-5CPWOHDJ.js.map → mesh.start-KZ5L6SOJ.js.map} +0 -0
  95. /package/dist/bin/{mesh.wizard-FNRTSGVN.js.map → mesh.wizard-FCTT55KI.js.map} +0 -0
@@ -1,7 +1,7 @@
1
1
  import { execFileSync, spawn, spawnSync } from 'child_process';
2
+ import { createHash } from 'crypto';
2
3
  import * as fs from 'fs';
3
4
  import * as net from 'net';
4
- import * as os from 'os';
5
5
  import * as path from 'path';
6
6
  import { Option } from 'commander';
7
7
  import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
@@ -16,7 +16,7 @@ import { ensureKubeconfig, resolveHubPlatformName, sessionKubeconfigPath, } from
16
16
  import { resolveTemporalAuth } from '../utils/temporal-auth.js';
17
17
  import { probeTcpReachable, probeConnectionHolds } from '../utils/reachability.js';
18
18
  import { fingerprintWorkflowSource } from '../utils/workflow-fingerprint.js';
19
- import { buildLaunchCommand, envFileName, waitForPort, writeEnvFile } from './dev-launch.js';
19
+ import { buildLaunchCommand, envFileName, getSessionEnvDir, pickLaunchHostEnv, waitForPort, writeEnvFile, parseEnvFile, diffEnvFiles, readServiceOverrides, serviceOverridesPaths, } from './dev-launch.js';
20
20
  import { buildLocalDevOutput, detectLocalTenant, ensureLocalPlatformRunning, hasStackBacking, localAppNamespace } from './local/dev-local.js';
21
21
  import { ensureAppTenantAuth, ensureSignInApp, registerLocalApp } from './local/auth-provision.js';
22
22
  import { publishLocalAppAuthz, readLocalAuthzConfig } from './local/app-authz.js';
@@ -32,8 +32,10 @@ import { writeDevCompose, dockerDevUp, dockerDevDown, dockerDevPs, dockerDevLogs
32
32
  import { MeshCliError } from '../utils/errors.js';
33
33
  import { resolveStackOption } from '../utils/stack-flag.js';
34
34
  import { rewriteClusterHostsToLocal } from './peer-addressing.js';
35
- import { ALL_CHECKS, aggregateStatus, renderHuman, runChecks, runDoctor, } from './dev-doctor.js';
35
+ import { ALL_CHECKS, aggregateStatus, classifyPaneLiveness, classifySessionServices, describePaneExit, paneProcessQuery, renderHuman, runChecks, runDoctor, } from './dev-doctor.js';
36
+ import { awaitServiceReadiness, paneTail, parseWaitSeconds, portHeldByAnother, RESTART_READY_TIMEOUT_MS, TEMPORAL_WORKER_RUNNING, } from './dev-readiness.js';
36
37
  import { resolveWorktreeIdentity, withAppScopedPortBlock, blockBasePort, PORT_BLOCK_SERVICE_SUBRANGE, } from '../utils/worktree-identity.js';
38
+ import { devSessionsRoot } from '../utils/dev-sessions-root.js';
37
39
  export function registerServiceProbes(devOutput, tenant, probeFiles) {
38
40
  const services = {};
39
41
  for (const [name, service] of Object.entries(devOutput.services)) {
@@ -58,6 +60,9 @@ export function registerServiceProbes(devOutput, tenant, probeFiles) {
58
60
  function deriveSessionName(projectName, wt) {
59
61
  return wt.isPrimary ? `${projectName}-dev` : `${projectName}-${wt.slug}`;
60
62
  }
63
+ function sessionNameFor(appRoot, explicit) {
64
+ return explicit ?? deriveSessionName(getProjectName(appRoot), resolveWorktreeIdentity(appRoot));
65
+ }
61
66
  function printDevPlan(sessionName, appRoot, wt, devOutput) {
62
67
  const kind = wt.isPrimary ? 'primary checkout' : `linked worktree (block ${wt.portBlock})`;
63
68
  console.log(`\nmesh dev plan — ${kind}\n`);
@@ -89,9 +94,7 @@ function printDevPlan(sessionName, appRoot, wt, devOutput) {
89
94
  console.log('');
90
95
  }
91
96
  function getSessionStatePath(sessionName) {
92
- const dir = path.join(os.tmpdir(), 'mesh-dev-sessions');
93
- if (!fs.existsSync(dir))
94
- fs.mkdirSync(dir, { recursive: true });
97
+ const dir = devSessionsRoot();
95
98
  return path.join(dir, `${sessionName}.json`);
96
99
  }
97
100
  function saveSessionState(sessionName, state) {
@@ -121,8 +124,9 @@ function workflowChangeWouldStrand(priorState, appRoot, force) {
121
124
  logWarn('Workflow code changed since this session started.\n' +
122
125
  ' Restarting the worker replays any in-flight conversations against the NEW code —\n' +
123
126
  ' a replay-incompatible change strands them (they get stuck and become unviewable).\n' +
124
- ' Keep it replay-compatible: gate the change with wf.patched() and regenerate the replay\n' +
125
- ' goldens (see the temporal-workflow-safety skill), or accept the risk.');
127
+ ' Keep it replay-compatible: gate the change with wf.patched() and commit a frozen\n' +
128
+ ' taken/not-taken fixture pair for the gate (the temporal-replay skill\'s patch rule;\n' +
129
+ ' never regenerate an existing golden), or accept the risk.');
126
130
  if (force) {
127
131
  logWarn(' Proceeding anyway (--force).');
128
132
  return false;
@@ -130,9 +134,6 @@ function workflowChangeWouldStrand(priorState, appRoot, force) {
130
134
  logError(' Refusing to restart the worker. Re-run with --force once the change is replay-safe.');
131
135
  return true;
132
136
  }
133
- function getSessionEnvDir(sessionName) {
134
- return path.join(os.tmpdir(), 'mesh-dev-sessions', sessionName);
135
- }
136
137
  function getServiceEnvFilePath(sessionName, serviceName) {
137
138
  return path.join(getSessionEnvDir(sessionName), envFileName(serviceName));
138
139
  }
@@ -146,6 +147,51 @@ function removeSessionState(sessionName) {
146
147
  }
147
148
  catch { }
148
149
  }
150
+ function getSessionPortMemoryPath(sessionName) {
151
+ return path.join(path.dirname(getSessionStatePath(sessionName)), `${sessionName}.ports.json`);
152
+ }
153
+ function loadSessionPortMemory(sessionName) {
154
+ try {
155
+ const raw = JSON.parse(fs.readFileSync(getSessionPortMemoryPath(sessionName), 'utf-8'));
156
+ return rememberedPortsOf(raw);
157
+ }
158
+ catch {
159
+ return {};
160
+ }
161
+ }
162
+ function sessionPortPlan(sessionName) {
163
+ const remembered = loadSessionPortMemory(sessionName);
164
+ const ownPorts = new Set();
165
+ if (sessionExists(sessionName)) {
166
+ const running = loadSessionState(sessionName)?.devOutput?.services ?? {};
167
+ for (const [name, port] of Object.entries(remembered)) {
168
+ if (running[name]?.port === port)
169
+ ownPorts.add(port);
170
+ }
171
+ }
172
+ return { remembered, ownPorts };
173
+ }
174
+ export function rememberedPortsOf(memory) {
175
+ const out = {};
176
+ for (const [name, port] of Object.entries(memory?.services ?? {})) {
177
+ if (Number.isInteger(port) && port > 0)
178
+ out[name] = port;
179
+ }
180
+ return out;
181
+ }
182
+ function saveSessionPortMemory(sessionName, devOutput) {
183
+ const services = {};
184
+ for (const [name, svc] of Object.entries(devOutput.services)) {
185
+ if (svc.port > 0 && svc.command && svc.command.length > 0)
186
+ services[name] = svc.port;
187
+ }
188
+ const memory = { services, savedAt: new Date().toISOString() };
189
+ try {
190
+ fs.writeFileSync(getSessionPortMemoryPath(sessionName), JSON.stringify(memory, null, 2));
191
+ }
192
+ catch {
193
+ }
194
+ }
149
195
  export async function isPortFree(port) {
150
196
  const bindSucceeds = (host) => new Promise((resolve) => {
151
197
  const server = net.createServer();
@@ -195,7 +241,7 @@ export function rewriteOwnServicePort(service, oldPort, newPort) {
195
241
  }
196
242
  }
197
243
  }
198
- async function allocatePorts(devOutput, worktree) {
244
+ export async function allocatePorts(devOutput, worktree, remembered = {}, probes = { isPortFree, findFreePort }, ownPorts = new Set()) {
199
245
  const allocated = JSON.parse(JSON.stringify(devOutput));
200
246
  const usedPorts = new Set();
201
247
  const portRemaps = new Map();
@@ -207,27 +253,42 @@ async function allocatePorts(devOutput, worktree) {
207
253
  if (!service.command || service.command.length === 0)
208
254
  continue;
209
255
  const preferred = service.port;
256
+ const last = remembered[name];
257
+ const lastIsUsable = async () => last !== undefined &&
258
+ last !== preferred &&
259
+ !usedPorts.has(last) &&
260
+ (ownPorts.has(last) || (await probes.isPortFree(last)));
210
261
  let target = -1;
211
262
  if (worktree.isPrimary) {
212
- if (!usedPorts.has(preferred) && (await isPortFree(preferred))) {
263
+ if (!usedPorts.has(preferred) && (await probes.isPortFree(preferred))) {
213
264
  target = preferred;
214
265
  }
266
+ else if (await lastIsUsable()) {
267
+ target = last;
268
+ logInfo(`Port ${preferred} busy for service '${name}', reusing last launch's ${target}`);
269
+ }
215
270
  else {
216
- target = await findFreePort();
271
+ target = await probes.findFreePort();
217
272
  logWarn(`Port ${preferred} busy for service '${name}', using ${target}`);
218
273
  }
219
274
  }
275
+ else if (await lastIsUsable()) {
276
+ target = last;
277
+ }
220
278
  else {
279
+ if (last !== undefined && last !== preferred) {
280
+ logWarn(`Port ${last} (this session's last launch) is not available for service '${name}'; allocating another`);
281
+ }
221
282
  for (let i = blockCursor; i < PORT_BLOCK_SERVICE_SUBRANGE; i++) {
222
283
  const candidate = blockBase + i;
223
- if (!usedPorts.has(candidate) && (await isPortFree(candidate))) {
284
+ if (!usedPorts.has(candidate) && (await probes.isPortFree(candidate))) {
224
285
  target = candidate;
225
286
  blockCursor = i + 1;
226
287
  break;
227
288
  }
228
289
  }
229
290
  if (target < 0) {
230
- target = await findFreePort();
291
+ target = await probes.findFreePort();
231
292
  logWarn(`Worktree port block exhausted for '${name}', using ${target}`);
232
293
  }
233
294
  }
@@ -268,12 +329,15 @@ export function mergePinnedPorts(fresh, pinned) {
268
329
  continue;
269
330
  const freshPort = mergedSvc.port;
270
331
  mergedSvc.port = pinnedSvc.port;
332
+ if (typeof freshPort === 'number' && freshPort > 0) {
333
+ rewriteOwnServicePort(mergedSvc, freshPort, pinnedSvc.port);
334
+ if (freshPort !== pinnedSvc.port) {
335
+ portRemaps.set(freshPort, pinnedSvc.port);
336
+ }
337
+ }
271
338
  if (mergedSvc.env?.PORT) {
272
339
  mergedSvc.env.PORT.value = String(pinnedSvc.port);
273
340
  }
274
- if (typeof freshPort === 'number' && freshPort > 0 && freshPort !== pinnedSvc.port) {
275
- portRemaps.set(freshPort, pinnedSvc.port);
276
- }
277
341
  }
278
342
  rewriteCrossServicePorts(merged, portRemaps);
279
343
  rewriteClusterHostsToLocal(merged);
@@ -469,6 +533,95 @@ function getDevOutput(appRoot, stack, awsEnv) {
469
533
  throw new MissingStackOutputError(stack, err);
470
534
  }
471
535
  }
536
+ function gitCommitOfDir(dir) {
537
+ const run = (...args) => execFileSync('git', ['-C', dir, ...args], {
538
+ encoding: 'utf-8',
539
+ stdio: ['ignore', 'pipe', 'ignore'],
540
+ timeout: 2000,
541
+ }).trim();
542
+ try {
543
+ const commit = run('rev-parse', '--short=12', 'HEAD');
544
+ if (!commit)
545
+ return null;
546
+ const dirty = run('status', '--porcelain').length > 0;
547
+ return dirty ? `${commit}-dirty` : commit;
548
+ }
549
+ catch {
550
+ return null;
551
+ }
552
+ }
553
+ export function worktreeDigestOfDir(dir) {
554
+ const run = (cwd, ...args) => execFileSync('git', ['-C', cwd, ...args], {
555
+ stdio: ['ignore', 'pipe', 'ignore'],
556
+ timeout: 10_000,
557
+ maxBuffer: 256 * 1024 * 1024,
558
+ });
559
+ try {
560
+ const top = run(dir, 'rev-parse', '--show-toplevel').toString().trim();
561
+ if (!top)
562
+ return null;
563
+ const diff = run(top, 'diff', 'HEAD', '--no-color', '--no-ext-diff');
564
+ const untracked = run(top, 'ls-files', '--others', '--exclude-standard', '-z')
565
+ .toString()
566
+ .split('\0')
567
+ .filter((f) => f.length > 0)
568
+ .sort();
569
+ if (diff.length === 0 && untracked.length === 0)
570
+ return null;
571
+ const hash = createHash('sha256').update(diff);
572
+ for (const file of untracked) {
573
+ hash.update('\0').update(file).update('\0');
574
+ try {
575
+ hash.update(fs.readFileSync(path.join(top, file)));
576
+ }
577
+ catch {
578
+ }
579
+ }
580
+ return hash.digest('hex').slice(0, 16);
581
+ }
582
+ catch {
583
+ return null;
584
+ }
585
+ }
586
+ export function currentProgramProvenance(appRoot, stack) {
587
+ let configSha256 = null;
588
+ try {
589
+ configSha256 = createHash('sha256')
590
+ .update(fs.readFileSync(path.join(appRoot, `Pulumi.${stack}.yaml`)))
591
+ .digest('hex');
592
+ }
593
+ catch {
594
+ }
595
+ const gitCommit = gitCommitOfDir(appRoot);
596
+ const worktreeDigest = gitCommit?.endsWith('-dirty') ? worktreeDigestOfDir(appRoot) : null;
597
+ return { gitCommit, configSha256, worktreeDigest };
598
+ }
599
+ export function devOutputStaleness(recorded, current) {
600
+ const reasons = [];
601
+ if (recorded?.gitCommit && current.gitCommit && recorded.gitCommit !== current.gitCommit) {
602
+ reasons.push(`program is at ${current.gitCommit} but the outputs were deployed from ${recorded.gitCommit}`);
603
+ }
604
+ else if (recorded?.gitCommit &&
605
+ current.gitCommit &&
606
+ recorded.worktreeDigest &&
607
+ current.worktreeDigest &&
608
+ recorded.worktreeDigest !== current.worktreeDigest) {
609
+ reasons.push(`program is at ${current.gitCommit} with different uncommitted changes than the outputs were deployed from`);
610
+ }
611
+ if (recorded?.configSha256 &&
612
+ current.configSha256 &&
613
+ recorded.configSha256 !== current.configSha256) {
614
+ reasons.push('the stack config (Pulumi.<stack>.yaml) changed since the deploy');
615
+ }
616
+ return reasons;
617
+ }
618
+ function warnIfDevOutputStale(devOutput, appRoot, stack) {
619
+ const reasons = devOutputStaleness(devOutput.provenance, currentProgramProvenance(appRoot, stack));
620
+ if (reasons.length === 0)
621
+ return;
622
+ logWarn(`Stack outputs are older than the program (${reasons.join('; ')}); ` +
623
+ `run \`mesh deploy up --stack ${stack}\` to refresh the dev env.`);
624
+ }
472
625
  function hasTmux() {
473
626
  try {
474
627
  execFileSync('which', ['tmux'], { stdio: 'ignore' });
@@ -620,37 +773,43 @@ export function rebaseServiceSrc(src, monorepoRoot) {
620
773
  return fs.existsSync(rebased) ? rebased : src;
621
774
  }
622
775
  export function isLinkedDependencyDir(serviceDir, appRoot) {
623
- let pkgRoot = null;
624
- let name = '';
625
- for (let dir = serviceDir;; dir = path.dirname(dir)) {
776
+ const pkg = enclosingPackage(serviceDir);
777
+ return Boolean(pkg && appResolvedPackageDir(pkg.name, appRoot) === pkg.root);
778
+ }
779
+ function enclosingPackage(dir) {
780
+ for (let at = dir;; at = path.dirname(at)) {
626
781
  try {
627
- const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
628
- if (typeof manifest?.name === 'string' && manifest.name) {
629
- pkgRoot = dir;
630
- name = manifest.name;
631
- break;
632
- }
782
+ const manifest = JSON.parse(fs.readFileSync(path.join(at, 'package.json'), 'utf8'));
783
+ if (typeof manifest?.name === 'string' && manifest.name)
784
+ return { root: at, name: manifest.name };
633
785
  }
634
786
  catch {
635
787
  }
636
- const parent = path.dirname(dir);
637
- if (parent === dir)
638
- break;
788
+ if (path.dirname(at) === at)
789
+ return null;
639
790
  }
640
- if (!pkgRoot)
641
- return false;
791
+ }
792
+ function appResolvedPackageDir(name, appRoot) {
642
793
  for (let dir = appRoot;; dir = path.dirname(dir)) {
643
794
  try {
644
- if (fs.realpathSync(path.join(dir, 'node_modules', name)) === pkgRoot)
645
- return true;
795
+ return fs.realpathSync(path.join(dir, 'node_modules', name));
646
796
  }
647
797
  catch {
648
798
  }
649
- const parent = path.dirname(dir);
650
- if (parent === dir)
651
- return false;
799
+ if (path.dirname(dir) === dir)
800
+ return null;
652
801
  }
653
802
  }
803
+ export function relinkedServiceDir(serviceDir, appRoot) {
804
+ const pkg = enclosingPackage(serviceDir);
805
+ if (!pkg)
806
+ return null;
807
+ const own = appResolvedPackageDir(pkg.name, appRoot);
808
+ if (!own || own === pkg.root)
809
+ return null;
810
+ const candidate = path.join(own, path.relative(pkg.root, serviceDir));
811
+ return fs.existsSync(candidate) ? candidate : null;
812
+ }
654
813
  export function monorepoRelativeSrc(src) {
655
814
  if (!path.isAbsolute(src))
656
815
  return src.replace(/^\.\//, '') || '.';
@@ -668,32 +827,57 @@ function getServiceEnvVars(service, tunnels) {
668
827
  if (spec.tunnel) {
669
828
  const tunnel = tunnels[spec.tunnel];
670
829
  if (tunnel) {
671
- try {
672
- const url = new URL(value);
673
- if (url.hostname) {
674
- url.hostname = tunnel.host;
675
- url.port = String(tunnel.port);
676
- value = url.toString();
677
- }
678
- else {
679
- value = `${tunnel.host}:${tunnel.port}`;
680
- }
681
- }
682
- catch {
683
- value = `${tunnel.host}:${tunnel.port}`;
684
- }
830
+ value = pointAtTunnel(key, value, tunnel);
831
+ const portKey = portSiblingOf(key);
832
+ if (portKey && service.env[portKey])
833
+ result[portKey] = String(tunnel.port);
685
834
  }
686
835
  }
687
- result[key] = value;
836
+ if (!(key in result))
837
+ result[key] = value;
688
838
  }
689
839
  }
690
840
  return result;
691
841
  }
842
+ export function portSiblingOf(key) {
843
+ return key.endsWith('_HOST') ? `${key.slice(0, -'_HOST'.length)}_PORT` : undefined;
844
+ }
845
+ export function pointAtTunnel(key, value, tunnel) {
846
+ try {
847
+ const url = new URL(value);
848
+ if (url.hostname) {
849
+ url.hostname = tunnel.host;
850
+ url.port = String(tunnel.port);
851
+ return url.toString();
852
+ }
853
+ }
854
+ catch {
855
+ }
856
+ if (key.endsWith('_HOST') && !value.includes(':'))
857
+ return tunnel.host;
858
+ return `${tunnel.host}:${tunnel.port}`;
859
+ }
692
860
  function setTmuxEnv(sessionName, vars) {
693
861
  for (const [key, value] of Object.entries(vars)) {
694
862
  execFileSync('tmux', ['set-environment', '-t', sessionName, key, value], { stdio: 'ignore' });
695
863
  }
696
864
  }
865
+ function readTmuxSessionEnv(sessionName, keys) {
866
+ const out = {};
867
+ for (const key of keys) {
868
+ try {
869
+ const line = execFileSync('tmux', ['show-environment', '-t', sessionName, key], {
870
+ encoding: 'utf-8',
871
+ stdio: ['ignore', 'pipe', 'ignore'],
872
+ }).trim();
873
+ if (line.startsWith(`${key}=`))
874
+ out[key] = line.slice(key.length + 1);
875
+ }
876
+ catch {
877
+ }
878
+ }
879
+ return out;
880
+ }
697
881
  function envPrefix(vars) {
698
882
  const entries = Object.entries(vars);
699
883
  if (entries.length === 0)
@@ -706,7 +890,34 @@ function shellEscape(s) {
706
890
  return s;
707
891
  return `'${s.replace(/'/g, "'\\''")}'`;
708
892
  }
709
- async function resolveSecrets(secrets) {
893
+ export function tunnelForSecret(secretName, tunnels) {
894
+ return tunnels[secretName] ?? (secretName === 'database' ? tunnels['db'] : undefined);
895
+ }
896
+ export function pointSecretUrlsAtTunnel(values, tunnel) {
897
+ if (!tunnel)
898
+ return values;
899
+ const out = {};
900
+ for (const [key, value] of Object.entries(values)) {
901
+ let rewritten = value;
902
+ try {
903
+ const url = new URL(value);
904
+ if (url.hostname) {
905
+ url.hostname = tunnel.host === 'localhost' ? '127.0.0.1' : tunnel.host;
906
+ url.port = String(tunnel.port);
907
+ if (/^postgres(ql)?:$/.test(url.protocol) && !url.searchParams.has('sslmode')) {
908
+ url.searchParams.set('sslmode', 'no-verify');
909
+ url.searchParams.set('sslaccept', 'accept_invalid_certs');
910
+ }
911
+ rewritten = url.toString();
912
+ }
913
+ }
914
+ catch {
915
+ }
916
+ out[key] = rewritten;
917
+ }
918
+ return out;
919
+ }
920
+ async function resolveSecrets(secrets, tunnels) {
710
921
  const client = new SecretsManagerClient({});
711
922
  const resolved = {};
712
923
  for (const [name, secret] of Object.entries(secrets)) {
@@ -715,12 +926,14 @@ async function resolveSecrets(secrets) {
715
926
  if (!response.SecretString)
716
927
  continue;
717
928
  const values = JSON.parse(response.SecretString);
929
+ const mapped = {};
718
930
  for (const [secretKey, envVar] of Object.entries(secret.envMapping)) {
719
931
  const value = values[secretKey];
720
932
  if (value === undefined)
721
933
  continue;
722
- resolved[envVar] = value;
934
+ mapped[envVar] = value;
723
935
  }
936
+ Object.assign(resolved, pointSecretUrlsAtTunnel(mapped, tunnelForSecret(name, tunnels)));
724
937
  logSuccess(`Secret resolved: ${name} (${secret.secretName})`);
725
938
  }
726
939
  catch (err) {
@@ -729,8 +942,12 @@ async function resolveSecrets(secrets) {
729
942
  }
730
943
  return resolved;
731
944
  }
732
- function resolveTemporalEncodingKey(tenant, env, appName) {
733
- const namespace = `${tenant}-${env}-${appName}`;
945
+ export function stackNamespace(devOutput) {
946
+ const tenant = devOutput.platform?.tenant ?? 'mesh';
947
+ const stack = devOutput.stack ?? devOutput.platform?.env ?? 'dev';
948
+ return devOutput.app ? `${tenant}-${stack}-${devOutput.app}` : undefined;
949
+ }
950
+ function resolveTemporalEncodingKey(namespace) {
734
951
  const secretName = `${namespace}-temporal-encoding-key`;
735
952
  try {
736
953
  const b64 = execFileSync('kubectl', [
@@ -772,12 +989,15 @@ async function resolveTemporalAuthVars(sessionName, devOutput) {
772
989
  let temporalAuthVars = {};
773
990
  if (devOutput.tunnels['temporal']) {
774
991
  temporalAuthVars = await resolveTemporalAuth(tenant, platformEnv, devOutput.platform?.name ?? tenant);
775
- const appName = devOutput.app ?? '';
776
- if (appName) {
777
- const encodingKey = resolveTemporalEncodingKey(tenant, platformEnv, appName);
992
+ const namespace = stackNamespace(devOutput);
993
+ if (namespace) {
994
+ const encodingKey = resolveTemporalEncodingKey(namespace);
778
995
  if (encodingKey) {
779
996
  temporalAuthVars.TEMPORAL_ENCODING_KEY = encodingKey;
780
997
  }
998
+ else {
999
+ logInfo(`No deployed encoding key in ${namespace} — using the stack's dev key from the dev block.`);
1000
+ }
781
1001
  }
782
1002
  const temporalTunnel = devOutput.tunnels['temporal'];
783
1003
  temporalAuthVars.TEMPORAL_ADDRESS = tunnelClientAddress(temporalTunnel);
@@ -943,7 +1163,22 @@ function hasSessionManagerPlugin() {
943
1163
  return false;
944
1164
  }
945
1165
  }
946
- async function startSsmTunnels(sessionName, devOutput) {
1166
+ const TUNNEL_TO_BASTION_KEY = {
1167
+ temporal: 'temporal-frontend',
1168
+ 'temporal-ui': 'temporal-ui',
1169
+ rds: 'rds',
1170
+ db: 'rds',
1171
+ database: 'rds',
1172
+ };
1173
+ export function bastionServiceForTunnel(tunnelName, tunnel, services) {
1174
+ for (const key of [TUNNEL_TO_BASTION_KEY[tunnelName], tunnelName]) {
1175
+ if (key && services[key])
1176
+ return { key, endpoint: services[key] };
1177
+ }
1178
+ const byEndpoint = Object.entries(services).find(([, svc]) => svc.host === tunnel.host && svc.port === tunnel.port);
1179
+ return byEndpoint ? { key: byEndpoint[0], endpoint: byEndpoint[1] } : undefined;
1180
+ }
1181
+ async function startSsmTunnels(sessionName, devOutput, appRoot, awsEnv) {
947
1182
  const platformEnv = devOutput.platform?.env ?? 'dev';
948
1183
  const bastionTenant = devOutput.platform?.name ?? 'mesh';
949
1184
  const bastion = await getPlatformBastionInfo(bastionTenant, platformEnv).catch(() => null);
@@ -960,13 +1195,6 @@ async function startSsmTunnels(sessionName, devOutput) {
960
1195
  return rewritten;
961
1196
  }
962
1197
  logInfo(`Starting ${tunnelCount} SSM tunnel(s) via bastion ${bastion.instanceId}...`);
963
- const tunnelToBastionKey = {
964
- temporal: 'temporal-frontend',
965
- 'temporal-ui': 'temporal-ui',
966
- rds: 'rds',
967
- db: 'rds',
968
- database: 'rds',
969
- };
970
1198
  const OPTIONAL_TUNNEL_NAMES = new Set([
971
1199
  'loki',
972
1200
  'prometheus',
@@ -978,9 +1206,10 @@ async function startSsmTunnels(sessionName, devOutput) {
978
1206
  const skippedOptional = [];
979
1207
  const reservedPorts = new Set();
980
1208
  const plannedTunnels = [];
981
- for (const [tunnelName] of Object.entries(devOutput.tunnels)) {
982
- const bastionKey = tunnelToBastionKey[tunnelName] ?? tunnelName;
983
- const endpoint = bastion.services[bastionKey];
1209
+ for (const [tunnelName, tunnel] of Object.entries(devOutput.tunnels)) {
1210
+ const matched = bastionServiceForTunnel(tunnelName, tunnel, bastion.services);
1211
+ const bastionKey = matched?.key ?? tunnelName;
1212
+ const endpoint = matched?.endpoint;
984
1213
  if (!endpoint) {
985
1214
  if (OPTIONAL_TUNNEL_NAMES.has(tunnelName)) {
986
1215
  skippedOptional.push(`${tunnelName} (bastion key: ${bastionKey})`);
@@ -1012,6 +1241,7 @@ async function startSsmTunnels(sessionName, devOutput) {
1012
1241
  logInfo('Connect VPN instead or deploy/update PlatformBastion with the missing service endpoints.');
1013
1242
  throw new Error('SSM tunnel fallback unavailable: missing bastion endpoints');
1014
1243
  }
1244
+ const hostEnv = pickLaunchHostEnv(process.env);
1015
1245
  for (const { tunnelName, endpoint, localPort } of plannedTunnels) {
1016
1246
  const windowName = `tunnel-${tunnelName}`;
1017
1247
  const ssmCmd = [
@@ -1031,7 +1261,15 @@ async function startSsmTunnels(sessionName, devOutput) {
1031
1261
  ].join(' ');
1032
1262
  execFileSync('tmux', ['new-window', '-t', sessionName, '-n', windowName]);
1033
1263
  execFileSync('tmux', ['set-option', '-t', `${sessionName}:${windowName}`, 'remain-on-exit', 'on'], { stdio: 'ignore' });
1034
- execFileSync('tmux', ['send-keys', '-t', `${sessionName}:${windowName}`, ssmCmd, 'Enter']);
1264
+ const envFilePath = getServiceEnvFilePath(sessionName, windowName);
1265
+ writeEnvFile(envFilePath, { ...hostEnv, ...awsEnv });
1266
+ execFileSync('tmux', [
1267
+ 'send-keys',
1268
+ '-t',
1269
+ `${sessionName}:${windowName}`,
1270
+ buildLaunchCommand(envFilePath, appRoot, ssmCmd, undefined, hostEnv),
1271
+ 'Enter',
1272
+ ]);
1035
1273
  rewritten.tunnels[tunnelName] = {
1036
1274
  host: 'localhost',
1037
1275
  port: localPort,
@@ -1213,7 +1451,13 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
1213
1451
  }
1214
1452
  logInfo(`Creating tmux session: ${sessionName}`);
1215
1453
  execFileSync('tmux', ['new-session', '-d', '-s', sessionName, '-n', 'status', '-c', appRoot]);
1216
- setTmuxEnv(sessionName, awsEnv);
1454
+ const sessionEnv = {};
1455
+ const shareOnSession = (vars) => {
1456
+ Object.assign(sessionEnv, vars);
1457
+ setTmuxEnv(sessionName, vars);
1458
+ };
1459
+ const hostEnv = pickLaunchHostEnv(process.env);
1460
+ shareOnSession(awsEnv);
1217
1461
  const hasTunnels = Object.keys(devOutput.tunnels).length > 0;
1218
1462
  let effectiveTransport = tunnelPlan.transport;
1219
1463
  if (hasTunnels && effectiveTransport === 'tailscale' && tunnelPlan.tailscale) {
@@ -1241,7 +1485,7 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
1241
1485
  }
1242
1486
  }
1243
1487
  if (hasTunnels && effectiveTransport === 'ssm') {
1244
- devOutput = await startSsmTunnels(sessionName, devOutput);
1488
+ devOutput = await startSsmTunnels(sessionName, devOutput, appRoot, awsEnv);
1245
1489
  }
1246
1490
  if (hasTunnels && effectiveTransport !== 'vpn-direct') {
1247
1491
  for (const [tName, tunnel] of Object.entries(devOutput.tunnels)) {
@@ -1274,7 +1518,7 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
1274
1518
  }
1275
1519
  let secretEnvVars = {};
1276
1520
  if (devOutput.secrets && Object.keys(devOutput.secrets).length > 0) {
1277
- secretEnvVars = await resolveSecrets(devOutput.secrets);
1521
+ secretEnvVars = await resolveSecrets(devOutput.secrets, devOutput.tunnels);
1278
1522
  }
1279
1523
  const tenant = devOutput.platform?.tenant ?? 'mesh';
1280
1524
  const platformEnv = devOutput.platform?.env ?? 'dev';
@@ -1286,7 +1530,7 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
1286
1530
  });
1287
1531
  if (kubeconfigPath) {
1288
1532
  process.env.KUBECONFIG = kubeconfigPath;
1289
- setTmuxEnv(sessionName, { KUBECONFIG: kubeconfigPath });
1533
+ shareOnSession({ KUBECONFIG: kubeconfigPath });
1290
1534
  logSuccess(`Kubeconfig resolved from SSM (hub ${platformName}/${platformEnv}) → ${kubeconfigPath}`);
1291
1535
  }
1292
1536
  else {
@@ -1296,18 +1540,21 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
1296
1540
  let temporalAuthVars = {};
1297
1541
  if (devOutput.tunnels['temporal']) {
1298
1542
  temporalAuthVars = await resolveTemporalAuth(tenant, platformEnv, devOutput.platform?.name ?? tenant);
1299
- const appName = devOutput.app ?? '';
1300
- if (appName) {
1301
- const encodingKey = resolveTemporalEncodingKey(tenant, platformEnv, appName);
1543
+ const namespace = stackNamespace(devOutput);
1544
+ if (namespace) {
1545
+ const encodingKey = resolveTemporalEncodingKey(namespace);
1302
1546
  if (encodingKey) {
1303
1547
  temporalAuthVars.TEMPORAL_ENCODING_KEY = encodingKey;
1304
1548
  }
1549
+ else {
1550
+ logInfo(`No deployed encoding key in ${namespace} — using the stack's dev key from the dev block.`);
1551
+ }
1305
1552
  }
1306
1553
  const temporalTunnel = devOutput.tunnels['temporal'];
1307
1554
  temporalAuthVars.TEMPORAL_ADDRESS = tunnelClientAddress(temporalTunnel);
1308
1555
  }
1309
- setTmuxEnv(sessionName, secretEnvVars);
1310
- setTmuxEnv(sessionName, temporalAuthVars);
1556
+ shareOnSession(secretEnvVars);
1557
+ shareOnSession(temporalAuthVars);
1311
1558
  const credContext = devOutput.platform
1312
1559
  ? isLocalPlatform
1313
1560
  ? 'local'
@@ -1315,18 +1562,20 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
1315
1562
  : null;
1316
1563
  const devUserVars = resolveDevUserVars(devOutput);
1317
1564
  if (Object.keys(devUserVars).length > 0) {
1318
- setTmuxEnv(sessionName, devUserVars);
1565
+ shareOnSession(devUserVars);
1319
1566
  logSuccess(`Dev user injected from mesh login: ${credContext}`);
1320
1567
  const tokenPort = await findFreePort();
1321
1568
  const tokenUrl = `http://127.0.0.1:${tokenPort}`;
1322
- setTmuxEnv(sessionName, { DEV_USER_TOKEN_URL: tokenUrl });
1569
+ shareOnSession({ DEV_USER_TOKEN_URL: tokenUrl });
1323
1570
  execFileSync('tmux', ['new-window', '-t', sessionName, '-n', 'token-server']);
1324
1571
  execFileSync('tmux', ['set-option', '-t', `${sessionName}:token-server`, 'remain-on-exit', 'on'], { stdio: 'ignore' });
1572
+ const tokenServerEnvFile = getServiceEnvFilePath(sessionName, 'token-server');
1573
+ writeEnvFile(tokenServerEnvFile, hostEnv);
1325
1574
  execFileSync('tmux', [
1326
1575
  'send-keys',
1327
1576
  '-t',
1328
1577
  `${sessionName}:token-server`,
1329
- `npx mesh dev __token-server ${tokenPort} ${credContext}`,
1578
+ buildLaunchCommand(tokenServerEnvFile, appRoot, `npx mesh dev __token-server ${tokenPort} ${credContext}`, undefined, hostEnv),
1330
1579
  'Enter',
1331
1580
  ]);
1332
1581
  for (let i = 0; i < 15 && !(await isPortListening(tokenPort)); i++) {
@@ -1340,13 +1589,14 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
1340
1589
  logInfo(' Hub UI will show unauthenticated state without valid credentials.');
1341
1590
  }
1342
1591
  const monorepoRoot = findMonorepoRoot();
1592
+ const notLaunched = [];
1343
1593
  for (const [name, service] of Object.entries(devOutput.services)) {
1344
1594
  const cmd = service.command.join(' ');
1345
1595
  if (!cmd) {
1346
1596
  logInfo(`Skipped: ${name} (no dev command, deployed to K8s)`);
1347
1597
  continue;
1348
1598
  }
1349
- const serviceDir = path.resolve(appRoot, rebaseServiceSrc(service.src, monorepoRoot));
1599
+ let serviceDir = path.resolve(appRoot, rebaseServiceSrc(service.src, monorepoRoot));
1350
1600
  if (worktreeRoot && fs.existsSync(serviceDir)) {
1351
1601
  const wtRootWithSep = worktreeRoot.endsWith(path.sep) ? worktreeRoot : worktreeRoot + path.sep;
1352
1602
  const outside = serviceDir !== worktreeRoot && !serviceDir.startsWith(wtRootWithSep);
@@ -1354,11 +1604,20 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
1354
1604
  logInfo(`${name}: source is a dev-linked package (${serviceDir})`);
1355
1605
  }
1356
1606
  else if (outside) {
1357
- logError(`Refusing to launch '${name}': its source resolved to ${serviceDir}, ` +
1358
- `outside this worktree (${worktreeRoot}), and it is not a package this ` +
1359
- `app resolves there. The stack was likely deployed from another ` +
1360
- `worktree. Re-run \`mesh deploy up\` here, or pass --app.`);
1361
- process.exit(1);
1607
+ const own = relinkedServiceDir(serviceDir, appRoot);
1608
+ if (own) {
1609
+ logWarn(`${name}: the stack recorded its source in another copy of its package ` +
1610
+ `(${serviceDir}); running this app's own copy (${own}).`);
1611
+ serviceDir = own;
1612
+ }
1613
+ else {
1614
+ logError(`Not launching '${name}': its source resolved to ${serviceDir}, ` +
1615
+ `outside this worktree (${worktreeRoot}), and it is not a package this ` +
1616
+ `app resolves. The stack was likely deployed from another worktree; ` +
1617
+ `re-run \`mesh deploy up\` here, or pass --app. Every other service still starts.`);
1618
+ notLaunched.push(name);
1619
+ continue;
1620
+ }
1362
1621
  }
1363
1622
  }
1364
1623
  if (!fs.existsSync(serviceDir)) {
@@ -1367,15 +1626,21 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
1367
1626
  continue;
1368
1627
  }
1369
1628
  const serviceVars = {
1629
+ ...hostEnv,
1370
1630
  ...getServiceEnvVars(service, devOutput.tunnels),
1631
+ ...secretEnvVars,
1371
1632
  ...temporalAuthVars,
1372
1633
  ...devUserVars,
1373
1634
  ...(taskQueueSuffix ? { MESH_TASK_QUEUE_SUFFIX: taskQueueSuffix } : {}),
1374
1635
  ...awsEnv,
1636
+ ...sessionEnv,
1375
1637
  };
1376
1638
  const envFilePath = getServiceEnvFilePath(sessionName, name);
1377
- writeEnvFile(envFilePath, serviceVars);
1378
- const launchCmd = buildLaunchCommand(envFilePath, serviceDir, cmd, service.env?.OTEL_RESOURCE_ATTRIBUTES ? logShipperPath() : undefined);
1639
+ const overrides = readServiceOverrides(sessionName, name);
1640
+ if (Object.keys(overrides).length)
1641
+ logInfo(`${name}: applying ${Object.keys(overrides).length} override(s) from ${serviceOverridesPaths(sessionName, name).join(' / ')}`);
1642
+ writeEnvFile(envFilePath, { ...serviceVars, ...overrides });
1643
+ const launchCmd = buildLaunchCommand(envFilePath, serviceDir, cmd, service.env?.OTEL_RESOURCE_ATTRIBUTES ? logShipperPath() : undefined, hostEnv);
1379
1644
  execFileSync('tmux', ['new-window', '-t', sessionName, '-n', name, '-c', serviceDir]);
1380
1645
  execFileSync('tmux', ['set-option', '-t', `${sessionName}:${name}`, 'remain-on-exit', 'on'], {
1381
1646
  stdio: 'ignore',
@@ -1395,6 +1660,9 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
1395
1660
  execFileSync('tmux', ['send-keys', '-t', `${sessionName}:status`, statusCmd, 'Enter']);
1396
1661
  console.log('');
1397
1662
  logSuccess(`Dev session started: ${sessionName}`);
1663
+ if (notLaunched.length > 0)
1664
+ logWarn(`Not launched (source outside this worktree, see above): ${notLaunched.join(', ')}. ` +
1665
+ `The rest of the session is up.`);
1398
1666
  console.log('');
1399
1667
  if (tunnelNames.length > 0) {
1400
1668
  console.log('Connections (VPN direct):');
@@ -1453,29 +1721,22 @@ async function showStatus(sessionName, devOutput, asJson) {
1453
1721
  }
1454
1722
  return;
1455
1723
  }
1456
- let windows = [];
1457
- try {
1458
- const raw = execFileSync('tmux', ['list-windows', '-t', sessionName, '-F', '#{window_name} #{pane_dead}'], { encoding: 'utf-8' });
1459
- windows = raw.trim().split('\n');
1460
- }
1461
- catch {
1462
- }
1463
- const windowStatus = {};
1464
- for (const line of windows) {
1465
- const [name, dead] = line.split(' ');
1466
- if (name)
1467
- windowStatus[name] = dead === '1' ? 'exited' : 'running';
1468
- }
1724
+ const liveness = classifySessionServices(sessionName, devOutput.services, paneProcessQuery);
1469
1725
  const tunnelNames = Object.keys(devOutput.tunnels);
1470
1726
  const tunnelHealth = await probeTunnelHealth(devOutput.tunnels);
1471
1727
  const healthByName = new Map(tunnelHealth.map((h) => [h.name, h]));
1472
1728
  if (asJson) {
1473
1729
  const services = {};
1474
1730
  for (const [name, svc] of Object.entries(devOutput.services)) {
1731
+ const l = liveness.get(name);
1475
1732
  services[name] = {
1476
- status: windowStatus[name] ?? 'unknown',
1733
+ status: l?.state ?? 'unknown',
1477
1734
  port: svc.port,
1478
1735
  src: svc.src,
1736
+ ...(l?.state === 'exited' && l.exitCode !== null ? { exitCode: l.exitCode } : {}),
1737
+ ...(l?.state === 'exited' && l.lastActivityEpoch !== null
1738
+ ? { lastActivity: new Date(l.lastActivityEpoch * 1000).toISOString() }
1739
+ : {}),
1479
1740
  };
1480
1741
  }
1481
1742
  const tunnels = {};
@@ -1505,10 +1766,16 @@ async function showStatus(sessionName, devOutput, asJson) {
1505
1766
  console.log(` ☁ ${name.padEnd(16)} deployed ${svc.src}`);
1506
1767
  continue;
1507
1768
  }
1508
- const status = windowStatus[name] ?? 'unknown';
1769
+ const l = liveness.get(name);
1770
+ const status = l?.state ?? 'unknown';
1509
1771
  const icon = status === 'running' ? '●' : status === 'exited' ? '✗' : '?';
1772
+ const statusCol = l?.state === 'exited' && l.exitCode !== null ? `exited(${l.exitCode})` : status;
1510
1773
  const addr = svc.port ? `:${svc.port}` : '';
1511
- console.log(` ${icon} ${name.padEnd(16)} ${status.padEnd(10)} ${addr.padEnd(8)} ${svc.src}`);
1774
+ let line = ` ${icon} ${name.padEnd(16)} ${statusCol.padEnd(10)} ${addr.padEnd(8)} ${svc.src}`;
1775
+ if (l?.state === 'exited' && l.lastActivityEpoch !== null) {
1776
+ line += ` (last output ${new Date(l.lastActivityEpoch * 1000).toLocaleTimeString()})`;
1777
+ }
1778
+ console.log(line);
1512
1779
  }
1513
1780
  console.log('');
1514
1781
  console.log('Commands:');
@@ -1530,10 +1797,15 @@ async function restartService(sessionName, serviceName, appRoot, devOutput, awsE
1530
1797
  logError(`No active session: ${sessionName}. Run 'mesh dev' first.`);
1531
1798
  process.exit(1);
1532
1799
  }
1533
- if (opts?.refreshEnv) {
1800
+ const waitMs = opts?.waitMs ?? RESTART_READY_TIMEOUT_MS;
1801
+ const neverLaunched = !fs.existsSync(getServiceEnvFilePath(sessionName, serviceName));
1802
+ if (neverLaunched && !opts?.refreshEnv && opts?.stack)
1803
+ logInfo(`'${serviceName}' has no launch env file (never started) — rendering it from the stack.`);
1804
+ const refreshEnv = opts?.refreshEnv || (neverLaunched && Boolean(opts?.stack));
1805
+ if (refreshEnv && opts) {
1534
1806
  if (!opts.stack) {
1535
1807
  logError('--refresh-env: no stack resolved; keeping existing env and NOT restarting.');
1536
- return;
1808
+ return null;
1537
1809
  }
1538
1810
  const pinned = opts.sessionState?.devOutput ?? devOutput;
1539
1811
  let fresh;
@@ -1543,24 +1815,52 @@ async function restartService(sessionName, serviceName, appRoot, devOutput, awsE
1543
1815
  catch (err) {
1544
1816
  logError(`--refresh-env: failed to read stack output for '${opts.stack}'. Keeping the existing env file for '${serviceName}' and NOT restarting.`);
1545
1817
  logInfo(String(err?.message ?? err));
1546
- return;
1818
+ return null;
1547
1819
  }
1820
+ warnIfDevOutputStale(fresh, appRoot, opts.stack);
1548
1821
  const merged = mergePinnedPorts(fresh, pinned);
1549
1822
  const mergedService = merged.services[serviceName];
1550
1823
  if (!mergedService) {
1551
1824
  logError(`--refresh-env: service '${serviceName}' is absent from the fresh stack output. Keeping the existing env file and NOT restarting.`);
1552
- return;
1825
+ return null;
1553
1826
  }
1554
1827
  const temporalAuthVars = await resolveTemporalAuthVars(sessionName, merged);
1828
+ const secretEnvVars = merged.secrets && Object.keys(merged.secrets).length > 0
1829
+ ? await resolveSecrets(merged.secrets, merged.tunnels)
1830
+ : {};
1555
1831
  const { taskQueueSuffix } = resolveWorktreeIdentity(appRoot);
1556
1832
  const serviceVars = {
1833
+ ...pickLaunchHostEnv(process.env),
1557
1834
  ...getServiceEnvVars(mergedService, merged.tunnels),
1835
+ ...secretEnvVars,
1558
1836
  ...temporalAuthVars,
1559
1837
  ...resolveDevUserVars(merged),
1560
1838
  ...(taskQueueSuffix ? { MESH_TASK_QUEUE_SUFFIX: taskQueueSuffix } : {}),
1561
1839
  ...awsEnv,
1840
+ ...readTmuxSessionEnv(sessionName, ['KUBECONFIG', 'DEV_USER_TOKEN_URL']),
1562
1841
  };
1563
- writeEnvFile(getServiceEnvFilePath(sessionName, serviceName), serviceVars);
1842
+ const envFilePathToRefresh = getServiceEnvFilePath(sessionName, serviceName);
1843
+ let previousVars = null;
1844
+ try {
1845
+ previousVars = parseEnvFile(fs.readFileSync(envFilePathToRefresh, 'utf-8'));
1846
+ }
1847
+ catch {
1848
+ }
1849
+ if (previousVars) {
1850
+ const changes = diffEnvFiles(previousVars, { ...serviceVars, ...readServiceOverrides(sessionName, serviceName) });
1851
+ if (changes.length === 0) {
1852
+ logInfo(`--refresh-env: the regenerated env for '${serviceName}' is identical to the running one.`);
1853
+ }
1854
+ else {
1855
+ logInfo(`--refresh-env: '${serviceName}' env changes (${changes.length}):`);
1856
+ for (const change of changes)
1857
+ console.log(` ${change.detail}`);
1858
+ }
1859
+ }
1860
+ const overrides = readServiceOverrides(sessionName, serviceName);
1861
+ if (Object.keys(overrides).length)
1862
+ logInfo(`'${serviceName}': applying ${Object.keys(overrides).length} override(s).`);
1863
+ writeEnvFile(envFilePathToRefresh, { ...serviceVars, ...overrides });
1564
1864
  service = mergedService;
1565
1865
  logSuccess(`Regenerated env for '${serviceName}' from current stack/SSM outputs.`);
1566
1866
  }
@@ -1569,16 +1869,40 @@ async function restartService(sessionName, serviceName, appRoot, devOutput, awsE
1569
1869
  execFileSync('tmux', ['respawn-pane', '-k', '-t', target], { stdio: 'ignore' });
1570
1870
  }
1571
1871
  catch {
1572
- logError(`Window '${serviceName}' not found in session.`);
1573
- process.exit(1);
1872
+ try {
1873
+ execFileSync('tmux', ['new-window', '-d', '-t', sessionName, '-n', serviceName], { stdio: 'ignore' });
1874
+ logInfo(`Window '${serviceName}' did not exist — created it.`);
1875
+ }
1876
+ catch {
1877
+ logError(`Window '${serviceName}' not found in session, and could not be created.`);
1878
+ process.exit(1);
1879
+ }
1880
+ }
1881
+ try {
1882
+ execFileSync('tmux', ['clear-history', '-t', target], { stdio: 'ignore' });
1883
+ }
1884
+ catch {
1885
+ }
1886
+ const portHeldBeforeStart = waitMs > 0 &&
1887
+ (await portHeldByAnother(service.port, {
1888
+ portAccepts: isPortListening,
1889
+ now: () => Date.now(),
1890
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
1891
+ }));
1892
+ if (portHeldBeforeStart) {
1893
+ logWarn(`Port :${service.port} is still accepting connections with '${serviceName}' stopped — another process holds it. ` +
1894
+ `The restarted service cannot bind it; whatever answers on :${service.port} is not this session's ${serviceName}.`);
1895
+ logInfo(`Find the holder: lsof -nP -iTCP:${service.port} -sTCP:LISTEN`);
1574
1896
  }
1575
1897
  setTmuxEnv(sessionName, awsEnv);
1576
1898
  const cmd = service.command.join(' ');
1577
1899
  const envFilePath = getServiceEnvFilePath(sessionName, serviceName);
1578
1900
  let restartCmd;
1579
1901
  if (fs.existsSync(envFilePath)) {
1580
- const serviceDir = path.resolve(appRoot, rebaseServiceSrc(service.src, findMonorepoRoot()));
1581
- restartCmd = buildLaunchCommand(envFilePath, serviceDir, cmd, service.env?.OTEL_RESOURCE_ATTRIBUTES ? logShipperPath() : undefined);
1902
+ let serviceDir = path.resolve(appRoot, rebaseServiceSrc(service.src, findMonorepoRoot()));
1903
+ if (!isLinkedDependencyDir(serviceDir, appRoot))
1904
+ serviceDir = relinkedServiceDir(serviceDir, appRoot) ?? serviceDir;
1905
+ restartCmd = buildLaunchCommand(envFilePath, serviceDir, cmd, service.env?.OTEL_RESOURCE_ATTRIBUTES ? logShipperPath() : undefined, pickLaunchHostEnv(process.env));
1582
1906
  }
1583
1907
  else {
1584
1908
  logWarn(`No launch env file for '${serviceName}' (${envFilePath}) — session predates env-file launches.`);
@@ -1587,7 +1911,78 @@ async function restartService(sessionName, serviceName, appRoot, devOutput, awsE
1587
1911
  restartCmd = `${envPrefix(serviceVars)}${cmd}`;
1588
1912
  }
1589
1913
  execFileSync('tmux', ['send-keys', '-t', target, restartCmd, 'Enter']);
1590
- logSuccess(`Restarted: ${serviceName}`);
1914
+ if (waitMs <= 0) {
1915
+ logSuccess(`Restarted: ${serviceName} (not waiting for readiness — check \`mesh dev --status\`)`);
1916
+ return null;
1917
+ }
1918
+ const readiness = await awaitServiceReadiness({
1919
+ port: service.port,
1920
+ portHeldBeforeStart,
1921
+ readyMarker: service.port > 0 ? undefined : TEMPORAL_WORKER_RUNNING,
1922
+ timeoutMs: waitMs,
1923
+ }, {
1924
+ liveness: () => classifyPaneLiveness(target, paneProcessQuery),
1925
+ portAccepts: isPortListening,
1926
+ paneOutput: () => capturePane(target),
1927
+ now: () => Date.now(),
1928
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
1929
+ });
1930
+ reportRestartReadiness(serviceName, service.port, readiness);
1931
+ return readiness;
1932
+ }
1933
+ function capturePane(target, lines = 2000) {
1934
+ try {
1935
+ return execFileSync('tmux', ['capture-pane', '-p', '-t', target, '-S', `-${lines}`], {
1936
+ encoding: 'utf-8',
1937
+ stdio: ['ignore', 'pipe', 'ignore'],
1938
+ });
1939
+ }
1940
+ catch {
1941
+ return '';
1942
+ }
1943
+ }
1944
+ function reportRestartReadiness(serviceName, port, readiness) {
1945
+ const secs = (ms) => `${(ms / 1000).toFixed(1)}s`;
1946
+ switch (readiness.state) {
1947
+ case 'ready': {
1948
+ const how = readiness.via === 'port'
1949
+ ? `listening on :${port}`
1950
+ : readiness.via === 'marker'
1951
+ ? 'worker RUNNING'
1952
+ : 'process up';
1953
+ logSuccess(`Restarted: ${serviceName} (${how} after ${secs(readiness.afterMs)})`);
1954
+ return;
1955
+ }
1956
+ case 'exited': {
1957
+ logError(`Restart FAILED: '${serviceName}' ${describePaneExit(readiness.liveness)} ${secs(readiness.afterMs)} after its command was typed.`);
1958
+ const tail = paneTail(readiness.output);
1959
+ if (tail.length > 0) {
1960
+ logInfo(`Last output from '${serviceName}':`);
1961
+ for (const line of tail)
1962
+ console.log(` ${line}`);
1963
+ }
1964
+ logInfo(`Full output: mesh dev logs ${serviceName}`);
1965
+ return;
1966
+ }
1967
+ case 'pending': {
1968
+ const what = port > 0 ? `:${port} is not accepting connections` : 'no ready marker printed';
1969
+ logWarn(`Restarted: ${serviceName}, but readiness is UNCONFIRMED — ${what} after ${secs(readiness.afterMs)} (process still up).`);
1970
+ logInfo(`Watch it: mesh dev logs ${serviceName} · mesh dev --status`);
1971
+ return;
1972
+ }
1973
+ case 'port-held': {
1974
+ logError(`Restart NOT confirmed: '${serviceName}' is up ${secs(readiness.afterMs)} after its command was typed, but :${port} was already held by another process — ` +
1975
+ 'it either failed to bind and is exiting, or moved to a port nothing dials.');
1976
+ const tail = paneTail(readiness.output, 8);
1977
+ for (const line of tail)
1978
+ console.log(` ${line}`);
1979
+ logInfo(`Free :${port} (lsof -nP -iTCP:${port} -sTCP:LISTEN) and restart again, or relaunch: mesh dev --kill && mesh dev`);
1980
+ return;
1981
+ }
1982
+ case 'unknown':
1983
+ logWarn(`Restarted: ${serviceName}, but readiness could not be checked (${readiness.reason}).`);
1984
+ return;
1985
+ }
1591
1986
  }
1592
1987
  function showLogs(sessionName, serviceName, tail) {
1593
1988
  if (!sessionExists(sessionName)) {
@@ -1634,7 +2029,7 @@ function buildDoctorContext(appRoot, stack, sessionName) {
1634
2029
  export function registerDevCommand(program) {
1635
2030
  const dev = program
1636
2031
  .command('dev')
1637
- .description('Start local dev environment (reads Pulumi stack outputs)')
2032
+ .description('Start local dev environment (reads Pulumi stack outputs). The env is rendered from the stack\'s STORED outputs — when they are older than the program (git HEAD) or the stack config, mesh dev warns to run `mesh deploy up --stack <stack>` first')
1638
2033
  .option('--app <path>', 'Path to a Pulumi app (relative to monorepo root or cwd)')
1639
2034
  .option('--stack <stack>', 'Pulumi stack name (default: auto-detect)')
1640
2035
  .addOption(new Option('--stage <stack>', 'Deprecated alias for --stack').hideHelp())
@@ -1809,7 +2204,8 @@ export function registerDevCommand(program) {
1809
2204
  mocks: selectedMocks,
1810
2205
  });
1811
2206
  if (options.dryRun) {
1812
- printDevPlan(sessionName, appRoot, worktree, await allocatePorts(rawDevOutput, worktree));
2207
+ const plan = sessionPortPlan(sessionName);
2208
+ printDevPlan(sessionName, appRoot, worktree, await allocatePorts(rawDevOutput, worktree, plan.remembered, undefined, plan.ownPorts));
1813
2209
  for (const [name, decl] of Object.entries(selectedMocks)) {
1814
2210
  const mode = externalMode(name, decl);
1815
2211
  if (mode === 'local') {
@@ -1890,7 +2286,8 @@ export function registerDevCommand(program) {
1890
2286
  ` — the Hub cannot author its roles, and a policy-engine app denies every check.`);
1891
2287
  }
1892
2288
  }
1893
- const devOutput = await allocatePorts(rawDevOutput, worktree);
2289
+ const plan = sessionPortPlan(sessionName);
2290
+ const devOutput = await allocatePorts(rawDevOutput, worktree, plan.remembered, undefined, plan.ownPorts);
1894
2291
  for (const service of signInServices) {
1895
2292
  const port = devOutput.services[service]?.port;
1896
2293
  if (!port) {
@@ -2097,6 +2494,7 @@ export function registerDevCommand(program) {
2097
2494
  externalProbeFiles,
2098
2495
  ...(localDocs ? { localDocs } : {}),
2099
2496
  });
2497
+ saveSessionPortMemory(sessionName, devOutput);
2100
2498
  console.log('');
2101
2499
  logSuccess(`Dev session started (docker): ${sessionName}`);
2102
2500
  console.log('');
@@ -2117,10 +2515,12 @@ export function registerDevCommand(program) {
2117
2515
  devOutput: finalDevOutput,
2118
2516
  startedAt: new Date().toISOString(),
2119
2517
  runner: 'tmux',
2518
+ cleanEnv: true,
2120
2519
  composeExternals,
2121
2520
  externalProbeFiles,
2122
2521
  ...(localDocs ? { localDocs } : {}),
2123
2522
  });
2523
+ saveSessionPortMemory(sessionName, finalDevOutput);
2124
2524
  return;
2125
2525
  }
2126
2526
  if (options.mock || options.externals) {
@@ -2270,7 +2670,9 @@ export function registerDevCommand(program) {
2270
2670
  await showStatus(sessionName, state?.devOutput ?? rawDevOutput, !!options.json);
2271
2671
  return;
2272
2672
  }
2273
- const devOutput = await allocatePorts(rawDevOutput, worktree);
2673
+ warnIfDevOutputStale(rawDevOutput, appRoot, stack);
2674
+ const plan = sessionPortPlan(sessionName);
2675
+ const devOutput = await allocatePorts(rawDevOutput, worktree, plan.remembered, undefined, plan.ownPorts);
2274
2676
  transport = await gateVpnDirectReachability(transport, devOutput);
2275
2677
  if (options.dryRun) {
2276
2678
  printDevPlan(sessionName, appRoot, worktree, devOutput);
@@ -2337,28 +2739,22 @@ export function registerDevCommand(program) {
2337
2739
  stack,
2338
2740
  devOutput: finalDevOutput,
2339
2741
  startedAt: new Date().toISOString(),
2742
+ cleanEnv: true,
2340
2743
  workflowFingerprint: fingerprintWorkflowSource(appRoot, finalDevOutput.services),
2341
2744
  });
2745
+ saveSessionPortMemory(sessionName, finalDevOutput);
2342
2746
  });
2343
2747
  dev
2344
2748
  .command('logs <service>')
2345
2749
  .description('Show logs for a service')
2346
2750
  .option('--tail <lines>', 'Number of lines', '100')
2347
2751
  .action((service, opts) => {
2348
- const sessionName = dev.opts().session;
2349
- const resolvedSession = sessionName ?? `${getProjectName(findAppRoot())}-dev`;
2350
- if (loadSessionState(resolvedSession)?.runner === 'docker') {
2351
- console.log(dockerDevLogs(resolvedSession, service, parseInt(opts.tail, 10)));
2752
+ const sessionName = sessionNameFor(findAppRoot(), dev.opts().session);
2753
+ if (loadSessionState(sessionName)?.runner === 'docker') {
2754
+ console.log(dockerDevLogs(sessionName, service, parseInt(opts.tail, 10)));
2352
2755
  return;
2353
2756
  }
2354
- if (sessionName) {
2355
- showLogs(sessionName, service, parseInt(opts.tail, 10));
2356
- }
2357
- else {
2358
- const appRoot = findAppRoot();
2359
- const projectName = getProjectName(appRoot);
2360
- showLogs(`${projectName}-dev`, service, parseInt(opts.tail, 10));
2361
- }
2757
+ showLogs(sessionName, service, parseInt(opts.tail, 10));
2362
2758
  });
2363
2759
  dev
2364
2760
  .command('restart <service>')
@@ -2367,6 +2763,8 @@ export function registerDevCommand(program) {
2367
2763
  .addOption(new Option('--stage <stack>', 'Deprecated alias for --stack').hideHelp())
2368
2764
  .option('--refresh-env', "Regenerate this service's env from current stack/SSM outputs before restarting (for config/deploy changes)")
2369
2765
  .option('--force', 'Restart the worker even if workflow code changed since launch (may strand in-flight conversations)')
2766
+ .option('--wait <seconds>', 'How long to wait for the service to prove ready (port accepting, or a Temporal worker RUNNING) before reporting; an exit in that window fails the restart', String(RESTART_READY_TIMEOUT_MS / 1000))
2767
+ .option('--no-wait', 'Return as soon as the command is typed, without checking readiness')
2370
2768
  .action(async (service, opts) => {
2371
2769
  let ctx = null;
2372
2770
  try {
@@ -2376,8 +2774,7 @@ export function registerDevCommand(program) {
2376
2774
  applyAwsProfileOverride(parentProfile);
2377
2775
  }
2378
2776
  const appRoot = findAppRoot();
2379
- const projectName = getProjectName(appRoot);
2380
- const sessionName = parentSession ?? `${projectName}-dev`;
2777
+ const sessionName = sessionNameFor(appRoot, parentSession);
2381
2778
  const state = loadSessionState(sessionName);
2382
2779
  if (state?.runner === 'docker') {
2383
2780
  dockerDevRestart(sessionName, service);
@@ -2426,10 +2823,11 @@ export function registerDevCommand(program) {
2426
2823
  : undefined;
2427
2824
  const awsEnv = buildChildAwsEnv(baseAwsEnv, resolved?.method ?? 'ambient', profileHint, zitadelOpts);
2428
2825
  const devOutput = state?.devOutput ?? getDevOutput(appRoot, stack, baseAwsEnv);
2429
- await restartService(sessionName, service, appRoot, devOutput, awsEnv, {
2826
+ const readiness = await restartService(sessionName, service, appRoot, devOutput, awsEnv, {
2430
2827
  refreshEnv: !!opts.refreshEnv,
2431
2828
  stack,
2432
2829
  sessionState: state,
2830
+ waitMs: parseWaitSeconds(opts.wait, RESTART_READY_TIMEOUT_MS / 1000) * 1000,
2433
2831
  });
2434
2832
  if (/worker/i.test(service) && state) {
2435
2833
  saveSessionState(sessionName, {
@@ -2437,6 +2835,8 @@ export function registerDevCommand(program) {
2437
2835
  workflowFingerprint: fingerprintWorkflowSource(appRoot, state.devOutput?.services),
2438
2836
  });
2439
2837
  }
2838
+ if (readiness?.state === 'exited' || readiness?.state === 'port-held')
2839
+ process.exit(1);
2440
2840
  }
2441
2841
  catch (err) {
2442
2842
  logError(`Failed to restart '${service}': ${err?.message ?? String(err)}`);
@@ -2447,7 +2847,7 @@ export function registerDevCommand(program) {
2447
2847
  });
2448
2848
  dev
2449
2849
  .command('doctor')
2450
- .description('Diagnose the dev session (creds, tunnels, config, ports, worktree, Temporal) — names the fix')
2850
+ .description('Diagnose the dev session (creds, registry, config, ports, service processes, worktree, tunnels, Temporal) — names the fix')
2451
2851
  .option('--stack <stack>', 'Pulumi stack name')
2452
2852
  .addOption(new Option('--stage <stack>', 'Deprecated alias for --stack').hideHelp())
2453
2853
  .option('--json', 'Machine-readable output')
@@ -2457,8 +2857,7 @@ export function registerDevCommand(program) {
2457
2857
  applyAwsProfileOverride(parentProfile);
2458
2858
  }
2459
2859
  const appRoot = findAppRoot();
2460
- const projectName = getProjectName(appRoot);
2461
- const sessionName = dev.opts().session ?? `${projectName}-dev`;
2860
+ const sessionName = sessionNameFor(appRoot, dev.opts().session);
2462
2861
  const state = loadSessionState(sessionName);
2463
2862
  const stack = state?.stack ?? detectStack(appRoot, resolveStackOption(opts));
2464
2863
  const asJson = !!opts.json || !!dev.opts().json;