@mesh-tech/mesh-cli 0.38.1 → 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 (104) hide show
  1. package/dist/bin/{mesh.auth-preflight-AQJ5TQES.js → mesh.auth-preflight-AUFQ2FGD.js} +1 -1
  2. package/dist/bin/{mesh.chunk-I6NXLXGC.js → mesh.chunk-2ASXJ5DO.js} +235 -581
  3. package/dist/bin/mesh.chunk-2ASXJ5DO.js.map +6 -0
  4. package/dist/bin/{mesh.chunk-IU2IWH6O.js → mesh.chunk-6AIG5OJM.js} +3 -3
  5. package/dist/bin/{mesh.chunk-KN4BZOYH.js → mesh.chunk-6SSOVZOI.js} +1 -1
  6. package/dist/bin/{mesh.chunk-LMYPTJ2F.js → mesh.chunk-7HTKSUWR.js} +1900 -577
  7. package/dist/bin/mesh.chunk-7HTKSUWR.js.map +6 -0
  8. package/dist/bin/{mesh.chunk-2YRWJH5X.js → mesh.chunk-F44A3ACW.js} +4 -4
  9. package/dist/bin/{mesh.chunk-2YRWJH5X.js.map → mesh.chunk-F44A3ACW.js.map} +1 -1
  10. package/dist/bin/{mesh.chunk-BF2SH6XT.js → mesh.chunk-HJGMOEUG.js} +1 -1
  11. package/dist/bin/{mesh.chunk-TZAGJTKU.js → mesh.chunk-NSQJEX36.js} +1 -1
  12. package/dist/bin/{mesh.chunk-HPYZWPRM.js → mesh.chunk-PZVI7V3J.js} +1 -1
  13. package/dist/bin/mesh.chunk-TDCH4SFQ.js +419 -0
  14. package/dist/bin/mesh.chunk-TDCH4SFQ.js.map +6 -0
  15. package/dist/bin/mesh.chunk-YE2WIY3Y.js +473 -0
  16. package/dist/bin/mesh.chunk-YE2WIY3Y.js.map +6 -0
  17. package/dist/bin/{mesh.create-app-NGXACFAU.js → mesh.create-app-Z3RKSTBO.js} +3 -3
  18. package/dist/bin/{mesh.dev-token-server-YP2VBP2S.js → mesh.dev-token-server-XG26C6IQ.js} +2 -2
  19. package/dist/bin/mesh.generate-XSM4C6FQ.js +10 -0
  20. package/dist/bin/mesh.js +9 -8
  21. package/dist/bin/mesh.js.map +1 -1
  22. package/dist/bin/{mesh.login-7SIAKFXJ.js → mesh.login-3AWVGPCM.js} +2 -2
  23. package/dist/bin/{mesh.platform-setup-CJXE3BCP.js → mesh.platform-setup-2ZDMHRCI.js} +1 -1
  24. package/dist/bin/{mesh.program-BIDQBSSI.js → mesh.program-K5JQXEHX.js} +9 -8
  25. package/dist/bin/{mesh.registry-7P4MGXEK.js → mesh.registry-JLQYFUNN.js} +4 -4
  26. package/dist/bin/{mesh.registry-identity-JW4KO7N7.js → mesh.registry-identity-3AR27UGO.js} +3 -3
  27. package/dist/bin/mesh.registry-identity-3AR27UGO.js.map +6 -0
  28. package/dist/bin/mesh.review-command-LUVZQN2T.js +238 -0
  29. package/dist/bin/mesh.review-command-LUVZQN2T.js.map +6 -0
  30. package/dist/bin/{mesh.start-MPQFIEMH.js → mesh.start-KZ5L6SOJ.js} +2 -2
  31. package/dist/bin/{mesh.wizard-ZJE4S6PC.js → mesh.wizard-FCTT55KI.js} +7 -7
  32. package/dist/bin/mesh.workspace-membership-DI3YQGEK.js +65 -0
  33. package/dist/bin/mesh.workspace-membership-DI3YQGEK.js.map +6 -0
  34. package/dist/build-info.json +2 -2
  35. package/dist/src/commands/app-check.js +2 -2
  36. package/dist/src/commands/dev-doctor.d.ts +22 -0
  37. package/dist/src/commands/dev-doctor.js +149 -0
  38. package/dist/src/commands/dev-env-leaks.d.ts +24 -0
  39. package/dist/src/commands/dev-env-leaks.js +178 -0
  40. package/dist/src/commands/dev-launch.d.ts +17 -1
  41. package/dist/src/commands/dev-launch.js +130 -5
  42. package/dist/src/commands/dev-readiness.d.ts +49 -0
  43. package/dist/src/commands/dev-readiness.js +78 -0
  44. package/dist/src/commands/dev.d.ts +60 -0
  45. package/dist/src/commands/dev.js +574 -140
  46. package/dist/src/commands/hub/index.js +10 -7
  47. package/dist/src/commands/local/app-authz.d.ts +118 -0
  48. package/dist/src/commands/local/app-authz.js +295 -0
  49. package/dist/src/commands/local/auth-provision.d.ts +1 -0
  50. package/dist/src/commands/local/auth-provision.js +12 -1
  51. package/dist/src/commands/local/index.js +2 -0
  52. package/dist/src/commands/mcp/generate.d.ts +11 -0
  53. package/dist/src/commands/mcp/generate.js +78 -0
  54. package/dist/src/commands/mcp/index.d.ts +2 -0
  55. package/dist/src/commands/mcp/index.js +70 -0
  56. package/dist/src/commands/mcp/manifest-file.d.ts +5 -0
  57. package/dist/src/commands/mcp/manifest-file.js +24 -0
  58. package/dist/src/commands/mcp/review-command.d.ts +24 -0
  59. package/dist/src/commands/mcp/review-command.js +66 -0
  60. package/dist/src/commands/mcp/review.d.ts +41 -0
  61. package/dist/src/commands/mcp/review.js +140 -0
  62. package/dist/src/commands/mcp/workspace-membership.d.ts +7 -0
  63. package/dist/src/commands/mcp/workspace-membership.js +77 -0
  64. package/dist/src/commands/secrets/reindex.js +2 -7
  65. package/dist/src/commands/secrets/set.d.ts +22 -1
  66. package/dist/src/commands/secrets/set.js +31 -8
  67. package/dist/src/commands/temporal.js +51 -5
  68. package/dist/src/commands/temporal.js.map +2 -2
  69. package/dist/src/program.js +2 -0
  70. package/dist/src/utils/aws-auth.d.ts +4 -0
  71. package/dist/src/utils/aws-auth.js +7 -3
  72. package/dist/src/utils/dev-sessions-root.d.ts +3 -0
  73. package/dist/src/utils/dev-sessions-root.js +45 -0
  74. package/dist/src/utils/kubeconfig.js +2 -2
  75. package/dist/src/utils/tailscale.js +7 -1
  76. package/dist/src/utils/temporal-auth.js +2 -1
  77. package/dist/src/utils/workflow-fingerprint.js +23 -12
  78. package/fragments/base/index.ts.hbs +4 -1
  79. package/package.json +7 -5
  80. package/skills/mesh/SKILL.md +10 -6
  81. package/templates/api-auth/api/src/schema.ts.hbs +17 -0
  82. package/templates/api-auth/index.ts.hbs +1 -0
  83. package/templates/api-auth/package.json.hbs +3 -0
  84. package/templates/api-role-gating/api/src/schema.ts.hbs +10 -0
  85. package/templates/api-role-gating/index.ts.hbs +1 -0
  86. package/templates/external-service/index.ts.hbs +1 -0
  87. package/templates/workflow/index.ts.hbs +2 -0
  88. package/dist/bin/mesh.chunk-I6NXLXGC.js.map +0 -6
  89. package/dist/bin/mesh.chunk-LMYPTJ2F.js.map +0 -6
  90. /package/dist/bin/{mesh.auth-preflight-AQJ5TQES.js.map → mesh.auth-preflight-AUFQ2FGD.js.map} +0 -0
  91. /package/dist/bin/{mesh.chunk-IU2IWH6O.js.map → mesh.chunk-6AIG5OJM.js.map} +0 -0
  92. /package/dist/bin/{mesh.chunk-KN4BZOYH.js.map → mesh.chunk-6SSOVZOI.js.map} +0 -0
  93. /package/dist/bin/{mesh.chunk-BF2SH6XT.js.map → mesh.chunk-HJGMOEUG.js.map} +0 -0
  94. /package/dist/bin/{mesh.chunk-TZAGJTKU.js.map → mesh.chunk-NSQJEX36.js.map} +0 -0
  95. /package/dist/bin/{mesh.chunk-HPYZWPRM.js.map → mesh.chunk-PZVI7V3J.js.map} +0 -0
  96. /package/dist/bin/{mesh.create-app-NGXACFAU.js.map → mesh.create-app-Z3RKSTBO.js.map} +0 -0
  97. /package/dist/bin/{mesh.dev-token-server-YP2VBP2S.js.map → mesh.dev-token-server-XG26C6IQ.js.map} +0 -0
  98. /package/dist/bin/{mesh.login-7SIAKFXJ.js.map → mesh.generate-XSM4C6FQ.js.map} +0 -0
  99. /package/dist/bin/{mesh.platform-setup-CJXE3BCP.js.map → mesh.login-3AWVGPCM.js.map} +0 -0
  100. /package/dist/bin/{mesh.program-BIDQBSSI.js.map → mesh.platform-setup-2ZDMHRCI.js.map} +0 -0
  101. /package/dist/bin/{mesh.registry-7P4MGXEK.js.map → mesh.program-K5JQXEHX.js.map} +0 -0
  102. /package/dist/bin/{mesh.registry-identity-JW4KO7N7.js.map → mesh.registry-JLQYFUNN.js.map} +0 -0
  103. /package/dist/bin/{mesh.start-MPQFIEMH.js.map → mesh.start-KZ5L6SOJ.js.map} +0 -0
  104. /package/dist/bin/{mesh.wizard-ZJE4S6PC.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,9 +16,10 @@ 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
+ import { publishLocalAppAuthz, readLocalAuthzConfig } from './local/app-authz.js';
22
23
  import { localAwsEnv, ensureTemporalNamespace, LOCAL_AWS_CONFIG, LOCAL_ENV } from './local/seed.js';
23
24
  import { publishLocalDocs, readLocalPublishDeclaration, unpublishLocalDocs } from './local/publish-docs.js';
24
25
  import { actAsEnv, publishLocalHubActAsKey } from './local/act-as-local.js';
@@ -31,8 +32,10 @@ import { writeDevCompose, dockerDevUp, dockerDevDown, dockerDevPs, dockerDevLogs
31
32
  import { MeshCliError } from '../utils/errors.js';
32
33
  import { resolveStackOption } from '../utils/stack-flag.js';
33
34
  import { rewriteClusterHostsToLocal } from './peer-addressing.js';
34
- 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';
35
37
  import { resolveWorktreeIdentity, withAppScopedPortBlock, blockBasePort, PORT_BLOCK_SERVICE_SUBRANGE, } from '../utils/worktree-identity.js';
38
+ import { devSessionsRoot } from '../utils/dev-sessions-root.js';
36
39
  export function registerServiceProbes(devOutput, tenant, probeFiles) {
37
40
  const services = {};
38
41
  for (const [name, service] of Object.entries(devOutput.services)) {
@@ -57,6 +60,9 @@ export function registerServiceProbes(devOutput, tenant, probeFiles) {
57
60
  function deriveSessionName(projectName, wt) {
58
61
  return wt.isPrimary ? `${projectName}-dev` : `${projectName}-${wt.slug}`;
59
62
  }
63
+ function sessionNameFor(appRoot, explicit) {
64
+ return explicit ?? deriveSessionName(getProjectName(appRoot), resolveWorktreeIdentity(appRoot));
65
+ }
60
66
  function printDevPlan(sessionName, appRoot, wt, devOutput) {
61
67
  const kind = wt.isPrimary ? 'primary checkout' : `linked worktree (block ${wt.portBlock})`;
62
68
  console.log(`\nmesh dev plan — ${kind}\n`);
@@ -88,9 +94,7 @@ function printDevPlan(sessionName, appRoot, wt, devOutput) {
88
94
  console.log('');
89
95
  }
90
96
  function getSessionStatePath(sessionName) {
91
- const dir = path.join(os.tmpdir(), 'mesh-dev-sessions');
92
- if (!fs.existsSync(dir))
93
- fs.mkdirSync(dir, { recursive: true });
97
+ const dir = devSessionsRoot();
94
98
  return path.join(dir, `${sessionName}.json`);
95
99
  }
96
100
  function saveSessionState(sessionName, state) {
@@ -120,8 +124,9 @@ function workflowChangeWouldStrand(priorState, appRoot, force) {
120
124
  logWarn('Workflow code changed since this session started.\n' +
121
125
  ' Restarting the worker replays any in-flight conversations against the NEW code —\n' +
122
126
  ' a replay-incompatible change strands them (they get stuck and become unviewable).\n' +
123
- ' Keep it replay-compatible: gate the change with wf.patched() and regenerate the replay\n' +
124
- ' 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.');
125
130
  if (force) {
126
131
  logWarn(' Proceeding anyway (--force).');
127
132
  return false;
@@ -129,9 +134,6 @@ function workflowChangeWouldStrand(priorState, appRoot, force) {
129
134
  logError(' Refusing to restart the worker. Re-run with --force once the change is replay-safe.');
130
135
  return true;
131
136
  }
132
- function getSessionEnvDir(sessionName) {
133
- return path.join(os.tmpdir(), 'mesh-dev-sessions', sessionName);
134
- }
135
137
  function getServiceEnvFilePath(sessionName, serviceName) {
136
138
  return path.join(getSessionEnvDir(sessionName), envFileName(serviceName));
137
139
  }
@@ -145,6 +147,51 @@ function removeSessionState(sessionName) {
145
147
  }
146
148
  catch { }
147
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
+ }
148
195
  export async function isPortFree(port) {
149
196
  const bindSucceeds = (host) => new Promise((resolve) => {
150
197
  const server = net.createServer();
@@ -194,7 +241,7 @@ export function rewriteOwnServicePort(service, oldPort, newPort) {
194
241
  }
195
242
  }
196
243
  }
197
- async function allocatePorts(devOutput, worktree) {
244
+ export async function allocatePorts(devOutput, worktree, remembered = {}, probes = { isPortFree, findFreePort }, ownPorts = new Set()) {
198
245
  const allocated = JSON.parse(JSON.stringify(devOutput));
199
246
  const usedPorts = new Set();
200
247
  const portRemaps = new Map();
@@ -206,27 +253,42 @@ async function allocatePorts(devOutput, worktree) {
206
253
  if (!service.command || service.command.length === 0)
207
254
  continue;
208
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)));
209
261
  let target = -1;
210
262
  if (worktree.isPrimary) {
211
- if (!usedPorts.has(preferred) && (await isPortFree(preferred))) {
263
+ if (!usedPorts.has(preferred) && (await probes.isPortFree(preferred))) {
212
264
  target = preferred;
213
265
  }
266
+ else if (await lastIsUsable()) {
267
+ target = last;
268
+ logInfo(`Port ${preferred} busy for service '${name}', reusing last launch's ${target}`);
269
+ }
214
270
  else {
215
- target = await findFreePort();
271
+ target = await probes.findFreePort();
216
272
  logWarn(`Port ${preferred} busy for service '${name}', using ${target}`);
217
273
  }
218
274
  }
275
+ else if (await lastIsUsable()) {
276
+ target = last;
277
+ }
219
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
+ }
220
282
  for (let i = blockCursor; i < PORT_BLOCK_SERVICE_SUBRANGE; i++) {
221
283
  const candidate = blockBase + i;
222
- if (!usedPorts.has(candidate) && (await isPortFree(candidate))) {
284
+ if (!usedPorts.has(candidate) && (await probes.isPortFree(candidate))) {
223
285
  target = candidate;
224
286
  blockCursor = i + 1;
225
287
  break;
226
288
  }
227
289
  }
228
290
  if (target < 0) {
229
- target = await findFreePort();
291
+ target = await probes.findFreePort();
230
292
  logWarn(`Worktree port block exhausted for '${name}', using ${target}`);
231
293
  }
232
294
  }
@@ -267,12 +329,15 @@ export function mergePinnedPorts(fresh, pinned) {
267
329
  continue;
268
330
  const freshPort = mergedSvc.port;
269
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
+ }
270
338
  if (mergedSvc.env?.PORT) {
271
339
  mergedSvc.env.PORT.value = String(pinnedSvc.port);
272
340
  }
273
- if (typeof freshPort === 'number' && freshPort > 0 && freshPort !== pinnedSvc.port) {
274
- portRemaps.set(freshPort, pinnedSvc.port);
275
- }
276
341
  }
277
342
  rewriteCrossServicePorts(merged, portRemaps);
278
343
  rewriteClusterHostsToLocal(merged);
@@ -468,6 +533,95 @@ function getDevOutput(appRoot, stack, awsEnv) {
468
533
  throw new MissingStackOutputError(stack, err);
469
534
  }
470
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
+ }
471
625
  function hasTmux() {
472
626
  try {
473
627
  execFileSync('which', ['tmux'], { stdio: 'ignore' });
@@ -619,37 +773,43 @@ export function rebaseServiceSrc(src, monorepoRoot) {
619
773
  return fs.existsSync(rebased) ? rebased : src;
620
774
  }
621
775
  export function isLinkedDependencyDir(serviceDir, appRoot) {
622
- let pkgRoot = null;
623
- let name = '';
624
- 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)) {
625
781
  try {
626
- const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
627
- if (typeof manifest?.name === 'string' && manifest.name) {
628
- pkgRoot = dir;
629
- name = manifest.name;
630
- break;
631
- }
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 };
632
785
  }
633
786
  catch {
634
787
  }
635
- const parent = path.dirname(dir);
636
- if (parent === dir)
637
- break;
788
+ if (path.dirname(at) === at)
789
+ return null;
638
790
  }
639
- if (!pkgRoot)
640
- return false;
791
+ }
792
+ function appResolvedPackageDir(name, appRoot) {
641
793
  for (let dir = appRoot;; dir = path.dirname(dir)) {
642
794
  try {
643
- if (fs.realpathSync(path.join(dir, 'node_modules', name)) === pkgRoot)
644
- return true;
795
+ return fs.realpathSync(path.join(dir, 'node_modules', name));
645
796
  }
646
797
  catch {
647
798
  }
648
- const parent = path.dirname(dir);
649
- if (parent === dir)
650
- return false;
799
+ if (path.dirname(dir) === dir)
800
+ return null;
651
801
  }
652
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
+ }
653
813
  export function monorepoRelativeSrc(src) {
654
814
  if (!path.isAbsolute(src))
655
815
  return src.replace(/^\.\//, '') || '.';
@@ -667,32 +827,57 @@ function getServiceEnvVars(service, tunnels) {
667
827
  if (spec.tunnel) {
668
828
  const tunnel = tunnels[spec.tunnel];
669
829
  if (tunnel) {
670
- try {
671
- const url = new URL(value);
672
- if (url.hostname) {
673
- url.hostname = tunnel.host;
674
- url.port = String(tunnel.port);
675
- value = url.toString();
676
- }
677
- else {
678
- value = `${tunnel.host}:${tunnel.port}`;
679
- }
680
- }
681
- catch {
682
- value = `${tunnel.host}:${tunnel.port}`;
683
- }
830
+ value = pointAtTunnel(key, value, tunnel);
831
+ const portKey = portSiblingOf(key);
832
+ if (portKey && service.env[portKey])
833
+ result[portKey] = String(tunnel.port);
684
834
  }
685
835
  }
686
- result[key] = value;
836
+ if (!(key in result))
837
+ result[key] = value;
687
838
  }
688
839
  }
689
840
  return result;
690
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
+ }
691
860
  function setTmuxEnv(sessionName, vars) {
692
861
  for (const [key, value] of Object.entries(vars)) {
693
862
  execFileSync('tmux', ['set-environment', '-t', sessionName, key, value], { stdio: 'ignore' });
694
863
  }
695
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
+ }
696
881
  function envPrefix(vars) {
697
882
  const entries = Object.entries(vars);
698
883
  if (entries.length === 0)
@@ -705,7 +890,34 @@ function shellEscape(s) {
705
890
  return s;
706
891
  return `'${s.replace(/'/g, "'\\''")}'`;
707
892
  }
708
- 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) {
709
921
  const client = new SecretsManagerClient({});
710
922
  const resolved = {};
711
923
  for (const [name, secret] of Object.entries(secrets)) {
@@ -714,12 +926,14 @@ async function resolveSecrets(secrets) {
714
926
  if (!response.SecretString)
715
927
  continue;
716
928
  const values = JSON.parse(response.SecretString);
929
+ const mapped = {};
717
930
  for (const [secretKey, envVar] of Object.entries(secret.envMapping)) {
718
931
  const value = values[secretKey];
719
932
  if (value === undefined)
720
933
  continue;
721
- resolved[envVar] = value;
934
+ mapped[envVar] = value;
722
935
  }
936
+ Object.assign(resolved, pointSecretUrlsAtTunnel(mapped, tunnelForSecret(name, tunnels)));
723
937
  logSuccess(`Secret resolved: ${name} (${secret.secretName})`);
724
938
  }
725
939
  catch (err) {
@@ -728,8 +942,12 @@ async function resolveSecrets(secrets) {
728
942
  }
729
943
  return resolved;
730
944
  }
731
- function resolveTemporalEncodingKey(tenant, env, appName) {
732
- 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) {
733
951
  const secretName = `${namespace}-temporal-encoding-key`;
734
952
  try {
735
953
  const b64 = execFileSync('kubectl', [
@@ -771,12 +989,15 @@ async function resolveTemporalAuthVars(sessionName, devOutput) {
771
989
  let temporalAuthVars = {};
772
990
  if (devOutput.tunnels['temporal']) {
773
991
  temporalAuthVars = await resolveTemporalAuth(tenant, platformEnv, devOutput.platform?.name ?? tenant);
774
- const appName = devOutput.app ?? '';
775
- if (appName) {
776
- const encodingKey = resolveTemporalEncodingKey(tenant, platformEnv, appName);
992
+ const namespace = stackNamespace(devOutput);
993
+ if (namespace) {
994
+ const encodingKey = resolveTemporalEncodingKey(namespace);
777
995
  if (encodingKey) {
778
996
  temporalAuthVars.TEMPORAL_ENCODING_KEY = encodingKey;
779
997
  }
998
+ else {
999
+ logInfo(`No deployed encoding key in ${namespace} — using the stack's dev key from the dev block.`);
1000
+ }
780
1001
  }
781
1002
  const temporalTunnel = devOutput.tunnels['temporal'];
782
1003
  temporalAuthVars.TEMPORAL_ADDRESS = tunnelClientAddress(temporalTunnel);
@@ -942,7 +1163,22 @@ function hasSessionManagerPlugin() {
942
1163
  return false;
943
1164
  }
944
1165
  }
945
- 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) {
946
1182
  const platformEnv = devOutput.platform?.env ?? 'dev';
947
1183
  const bastionTenant = devOutput.platform?.name ?? 'mesh';
948
1184
  const bastion = await getPlatformBastionInfo(bastionTenant, platformEnv).catch(() => null);
@@ -959,13 +1195,6 @@ async function startSsmTunnels(sessionName, devOutput) {
959
1195
  return rewritten;
960
1196
  }
961
1197
  logInfo(`Starting ${tunnelCount} SSM tunnel(s) via bastion ${bastion.instanceId}...`);
962
- const tunnelToBastionKey = {
963
- temporal: 'temporal-frontend',
964
- 'temporal-ui': 'temporal-ui',
965
- rds: 'rds',
966
- db: 'rds',
967
- database: 'rds',
968
- };
969
1198
  const OPTIONAL_TUNNEL_NAMES = new Set([
970
1199
  'loki',
971
1200
  'prometheus',
@@ -977,9 +1206,10 @@ async function startSsmTunnels(sessionName, devOutput) {
977
1206
  const skippedOptional = [];
978
1207
  const reservedPorts = new Set();
979
1208
  const plannedTunnels = [];
980
- for (const [tunnelName] of Object.entries(devOutput.tunnels)) {
981
- const bastionKey = tunnelToBastionKey[tunnelName] ?? tunnelName;
982
- 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;
983
1213
  if (!endpoint) {
984
1214
  if (OPTIONAL_TUNNEL_NAMES.has(tunnelName)) {
985
1215
  skippedOptional.push(`${tunnelName} (bastion key: ${bastionKey})`);
@@ -1011,6 +1241,7 @@ async function startSsmTunnels(sessionName, devOutput) {
1011
1241
  logInfo('Connect VPN instead or deploy/update PlatformBastion with the missing service endpoints.');
1012
1242
  throw new Error('SSM tunnel fallback unavailable: missing bastion endpoints');
1013
1243
  }
1244
+ const hostEnv = pickLaunchHostEnv(process.env);
1014
1245
  for (const { tunnelName, endpoint, localPort } of plannedTunnels) {
1015
1246
  const windowName = `tunnel-${tunnelName}`;
1016
1247
  const ssmCmd = [
@@ -1030,7 +1261,15 @@ async function startSsmTunnels(sessionName, devOutput) {
1030
1261
  ].join(' ');
1031
1262
  execFileSync('tmux', ['new-window', '-t', sessionName, '-n', windowName]);
1032
1263
  execFileSync('tmux', ['set-option', '-t', `${sessionName}:${windowName}`, 'remain-on-exit', 'on'], { stdio: 'ignore' });
1033
- 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
+ ]);
1034
1273
  rewritten.tunnels[tunnelName] = {
1035
1274
  host: 'localhost',
1036
1275
  port: localPort,
@@ -1212,7 +1451,13 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
1212
1451
  }
1213
1452
  logInfo(`Creating tmux session: ${sessionName}`);
1214
1453
  execFileSync('tmux', ['new-session', '-d', '-s', sessionName, '-n', 'status', '-c', appRoot]);
1215
- 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);
1216
1461
  const hasTunnels = Object.keys(devOutput.tunnels).length > 0;
1217
1462
  let effectiveTransport = tunnelPlan.transport;
1218
1463
  if (hasTunnels && effectiveTransport === 'tailscale' && tunnelPlan.tailscale) {
@@ -1240,7 +1485,7 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
1240
1485
  }
1241
1486
  }
1242
1487
  if (hasTunnels && effectiveTransport === 'ssm') {
1243
- devOutput = await startSsmTunnels(sessionName, devOutput);
1488
+ devOutput = await startSsmTunnels(sessionName, devOutput, appRoot, awsEnv);
1244
1489
  }
1245
1490
  if (hasTunnels && effectiveTransport !== 'vpn-direct') {
1246
1491
  for (const [tName, tunnel] of Object.entries(devOutput.tunnels)) {
@@ -1273,7 +1518,7 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
1273
1518
  }
1274
1519
  let secretEnvVars = {};
1275
1520
  if (devOutput.secrets && Object.keys(devOutput.secrets).length > 0) {
1276
- secretEnvVars = await resolveSecrets(devOutput.secrets);
1521
+ secretEnvVars = await resolveSecrets(devOutput.secrets, devOutput.tunnels);
1277
1522
  }
1278
1523
  const tenant = devOutput.platform?.tenant ?? 'mesh';
1279
1524
  const platformEnv = devOutput.platform?.env ?? 'dev';
@@ -1285,7 +1530,7 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
1285
1530
  });
1286
1531
  if (kubeconfigPath) {
1287
1532
  process.env.KUBECONFIG = kubeconfigPath;
1288
- setTmuxEnv(sessionName, { KUBECONFIG: kubeconfigPath });
1533
+ shareOnSession({ KUBECONFIG: kubeconfigPath });
1289
1534
  logSuccess(`Kubeconfig resolved from SSM (hub ${platformName}/${platformEnv}) → ${kubeconfigPath}`);
1290
1535
  }
1291
1536
  else {
@@ -1295,18 +1540,21 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
1295
1540
  let temporalAuthVars = {};
1296
1541
  if (devOutput.tunnels['temporal']) {
1297
1542
  temporalAuthVars = await resolveTemporalAuth(tenant, platformEnv, devOutput.platform?.name ?? tenant);
1298
- const appName = devOutput.app ?? '';
1299
- if (appName) {
1300
- const encodingKey = resolveTemporalEncodingKey(tenant, platformEnv, appName);
1543
+ const namespace = stackNamespace(devOutput);
1544
+ if (namespace) {
1545
+ const encodingKey = resolveTemporalEncodingKey(namespace);
1301
1546
  if (encodingKey) {
1302
1547
  temporalAuthVars.TEMPORAL_ENCODING_KEY = encodingKey;
1303
1548
  }
1549
+ else {
1550
+ logInfo(`No deployed encoding key in ${namespace} — using the stack's dev key from the dev block.`);
1551
+ }
1304
1552
  }
1305
1553
  const temporalTunnel = devOutput.tunnels['temporal'];
1306
1554
  temporalAuthVars.TEMPORAL_ADDRESS = tunnelClientAddress(temporalTunnel);
1307
1555
  }
1308
- setTmuxEnv(sessionName, secretEnvVars);
1309
- setTmuxEnv(sessionName, temporalAuthVars);
1556
+ shareOnSession(secretEnvVars);
1557
+ shareOnSession(temporalAuthVars);
1310
1558
  const credContext = devOutput.platform
1311
1559
  ? isLocalPlatform
1312
1560
  ? 'local'
@@ -1314,18 +1562,20 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
1314
1562
  : null;
1315
1563
  const devUserVars = resolveDevUserVars(devOutput);
1316
1564
  if (Object.keys(devUserVars).length > 0) {
1317
- setTmuxEnv(sessionName, devUserVars);
1565
+ shareOnSession(devUserVars);
1318
1566
  logSuccess(`Dev user injected from mesh login: ${credContext}`);
1319
1567
  const tokenPort = await findFreePort();
1320
1568
  const tokenUrl = `http://127.0.0.1:${tokenPort}`;
1321
- setTmuxEnv(sessionName, { DEV_USER_TOKEN_URL: tokenUrl });
1569
+ shareOnSession({ DEV_USER_TOKEN_URL: tokenUrl });
1322
1570
  execFileSync('tmux', ['new-window', '-t', sessionName, '-n', 'token-server']);
1323
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);
1324
1574
  execFileSync('tmux', [
1325
1575
  'send-keys',
1326
1576
  '-t',
1327
1577
  `${sessionName}:token-server`,
1328
- `npx mesh dev __token-server ${tokenPort} ${credContext}`,
1578
+ buildLaunchCommand(tokenServerEnvFile, appRoot, `npx mesh dev __token-server ${tokenPort} ${credContext}`, undefined, hostEnv),
1329
1579
  'Enter',
1330
1580
  ]);
1331
1581
  for (let i = 0; i < 15 && !(await isPortListening(tokenPort)); i++) {
@@ -1339,13 +1589,14 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
1339
1589
  logInfo(' Hub UI will show unauthenticated state without valid credentials.');
1340
1590
  }
1341
1591
  const monorepoRoot = findMonorepoRoot();
1592
+ const notLaunched = [];
1342
1593
  for (const [name, service] of Object.entries(devOutput.services)) {
1343
1594
  const cmd = service.command.join(' ');
1344
1595
  if (!cmd) {
1345
1596
  logInfo(`Skipped: ${name} (no dev command, deployed to K8s)`);
1346
1597
  continue;
1347
1598
  }
1348
- const serviceDir = path.resolve(appRoot, rebaseServiceSrc(service.src, monorepoRoot));
1599
+ let serviceDir = path.resolve(appRoot, rebaseServiceSrc(service.src, monorepoRoot));
1349
1600
  if (worktreeRoot && fs.existsSync(serviceDir)) {
1350
1601
  const wtRootWithSep = worktreeRoot.endsWith(path.sep) ? worktreeRoot : worktreeRoot + path.sep;
1351
1602
  const outside = serviceDir !== worktreeRoot && !serviceDir.startsWith(wtRootWithSep);
@@ -1353,11 +1604,20 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
1353
1604
  logInfo(`${name}: source is a dev-linked package (${serviceDir})`);
1354
1605
  }
1355
1606
  else if (outside) {
1356
- logError(`Refusing to launch '${name}': its source resolved to ${serviceDir}, ` +
1357
- `outside this worktree (${worktreeRoot}), and it is not a package this ` +
1358
- `app resolves there. The stack was likely deployed from another ` +
1359
- `worktree. Re-run \`mesh deploy up\` here, or pass --app.`);
1360
- 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
+ }
1361
1621
  }
1362
1622
  }
1363
1623
  if (!fs.existsSync(serviceDir)) {
@@ -1366,15 +1626,21 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
1366
1626
  continue;
1367
1627
  }
1368
1628
  const serviceVars = {
1629
+ ...hostEnv,
1369
1630
  ...getServiceEnvVars(service, devOutput.tunnels),
1631
+ ...secretEnvVars,
1370
1632
  ...temporalAuthVars,
1371
1633
  ...devUserVars,
1372
1634
  ...(taskQueueSuffix ? { MESH_TASK_QUEUE_SUFFIX: taskQueueSuffix } : {}),
1373
1635
  ...awsEnv,
1636
+ ...sessionEnv,
1374
1637
  };
1375
1638
  const envFilePath = getServiceEnvFilePath(sessionName, name);
1376
- writeEnvFile(envFilePath, serviceVars);
1377
- 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);
1378
1644
  execFileSync('tmux', ['new-window', '-t', sessionName, '-n', name, '-c', serviceDir]);
1379
1645
  execFileSync('tmux', ['set-option', '-t', `${sessionName}:${name}`, 'remain-on-exit', 'on'], {
1380
1646
  stdio: 'ignore',
@@ -1394,6 +1660,9 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
1394
1660
  execFileSync('tmux', ['send-keys', '-t', `${sessionName}:status`, statusCmd, 'Enter']);
1395
1661
  console.log('');
1396
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.`);
1397
1666
  console.log('');
1398
1667
  if (tunnelNames.length > 0) {
1399
1668
  console.log('Connections (VPN direct):');
@@ -1452,29 +1721,22 @@ async function showStatus(sessionName, devOutput, asJson) {
1452
1721
  }
1453
1722
  return;
1454
1723
  }
1455
- let windows = [];
1456
- try {
1457
- const raw = execFileSync('tmux', ['list-windows', '-t', sessionName, '-F', '#{window_name} #{pane_dead}'], { encoding: 'utf-8' });
1458
- windows = raw.trim().split('\n');
1459
- }
1460
- catch {
1461
- }
1462
- const windowStatus = {};
1463
- for (const line of windows) {
1464
- const [name, dead] = line.split(' ');
1465
- if (name)
1466
- windowStatus[name] = dead === '1' ? 'exited' : 'running';
1467
- }
1724
+ const liveness = classifySessionServices(sessionName, devOutput.services, paneProcessQuery);
1468
1725
  const tunnelNames = Object.keys(devOutput.tunnels);
1469
1726
  const tunnelHealth = await probeTunnelHealth(devOutput.tunnels);
1470
1727
  const healthByName = new Map(tunnelHealth.map((h) => [h.name, h]));
1471
1728
  if (asJson) {
1472
1729
  const services = {};
1473
1730
  for (const [name, svc] of Object.entries(devOutput.services)) {
1731
+ const l = liveness.get(name);
1474
1732
  services[name] = {
1475
- status: windowStatus[name] ?? 'unknown',
1733
+ status: l?.state ?? 'unknown',
1476
1734
  port: svc.port,
1477
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
+ : {}),
1478
1740
  };
1479
1741
  }
1480
1742
  const tunnels = {};
@@ -1504,10 +1766,16 @@ async function showStatus(sessionName, devOutput, asJson) {
1504
1766
  console.log(` ☁ ${name.padEnd(16)} deployed ${svc.src}`);
1505
1767
  continue;
1506
1768
  }
1507
- const status = windowStatus[name] ?? 'unknown';
1769
+ const l = liveness.get(name);
1770
+ const status = l?.state ?? 'unknown';
1508
1771
  const icon = status === 'running' ? '●' : status === 'exited' ? '✗' : '?';
1772
+ const statusCol = l?.state === 'exited' && l.exitCode !== null ? `exited(${l.exitCode})` : status;
1509
1773
  const addr = svc.port ? `:${svc.port}` : '';
1510
- 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);
1511
1779
  }
1512
1780
  console.log('');
1513
1781
  console.log('Commands:');
@@ -1529,10 +1797,15 @@ async function restartService(sessionName, serviceName, appRoot, devOutput, awsE
1529
1797
  logError(`No active session: ${sessionName}. Run 'mesh dev' first.`);
1530
1798
  process.exit(1);
1531
1799
  }
1532
- 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) {
1533
1806
  if (!opts.stack) {
1534
1807
  logError('--refresh-env: no stack resolved; keeping existing env and NOT restarting.');
1535
- return;
1808
+ return null;
1536
1809
  }
1537
1810
  const pinned = opts.sessionState?.devOutput ?? devOutput;
1538
1811
  let fresh;
@@ -1542,24 +1815,52 @@ async function restartService(sessionName, serviceName, appRoot, devOutput, awsE
1542
1815
  catch (err) {
1543
1816
  logError(`--refresh-env: failed to read stack output for '${opts.stack}'. Keeping the existing env file for '${serviceName}' and NOT restarting.`);
1544
1817
  logInfo(String(err?.message ?? err));
1545
- return;
1818
+ return null;
1546
1819
  }
1820
+ warnIfDevOutputStale(fresh, appRoot, opts.stack);
1547
1821
  const merged = mergePinnedPorts(fresh, pinned);
1548
1822
  const mergedService = merged.services[serviceName];
1549
1823
  if (!mergedService) {
1550
1824
  logError(`--refresh-env: service '${serviceName}' is absent from the fresh stack output. Keeping the existing env file and NOT restarting.`);
1551
- return;
1825
+ return null;
1552
1826
  }
1553
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
+ : {};
1554
1831
  const { taskQueueSuffix } = resolveWorktreeIdentity(appRoot);
1555
1832
  const serviceVars = {
1833
+ ...pickLaunchHostEnv(process.env),
1556
1834
  ...getServiceEnvVars(mergedService, merged.tunnels),
1835
+ ...secretEnvVars,
1557
1836
  ...temporalAuthVars,
1558
1837
  ...resolveDevUserVars(merged),
1559
1838
  ...(taskQueueSuffix ? { MESH_TASK_QUEUE_SUFFIX: taskQueueSuffix } : {}),
1560
1839
  ...awsEnv,
1840
+ ...readTmuxSessionEnv(sessionName, ['KUBECONFIG', 'DEV_USER_TOKEN_URL']),
1561
1841
  };
1562
- 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 });
1563
1864
  service = mergedService;
1564
1865
  logSuccess(`Regenerated env for '${serviceName}' from current stack/SSM outputs.`);
1565
1866
  }
@@ -1568,16 +1869,40 @@ async function restartService(sessionName, serviceName, appRoot, devOutput, awsE
1568
1869
  execFileSync('tmux', ['respawn-pane', '-k', '-t', target], { stdio: 'ignore' });
1569
1870
  }
1570
1871
  catch {
1571
- logError(`Window '${serviceName}' not found in session.`);
1572
- 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`);
1573
1896
  }
1574
1897
  setTmuxEnv(sessionName, awsEnv);
1575
1898
  const cmd = service.command.join(' ');
1576
1899
  const envFilePath = getServiceEnvFilePath(sessionName, serviceName);
1577
1900
  let restartCmd;
1578
1901
  if (fs.existsSync(envFilePath)) {
1579
- const serviceDir = path.resolve(appRoot, rebaseServiceSrc(service.src, findMonorepoRoot()));
1580
- 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));
1581
1906
  }
1582
1907
  else {
1583
1908
  logWarn(`No launch env file for '${serviceName}' (${envFilePath}) — session predates env-file launches.`);
@@ -1586,7 +1911,78 @@ async function restartService(sessionName, serviceName, appRoot, devOutput, awsE
1586
1911
  restartCmd = `${envPrefix(serviceVars)}${cmd}`;
1587
1912
  }
1588
1913
  execFileSync('tmux', ['send-keys', '-t', target, restartCmd, 'Enter']);
1589
- 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
+ }
1590
1986
  }
1591
1987
  function showLogs(sessionName, serviceName, tail) {
1592
1988
  if (!sessionExists(sessionName)) {
@@ -1633,7 +2029,7 @@ function buildDoctorContext(appRoot, stack, sessionName) {
1633
2029
  export function registerDevCommand(program) {
1634
2030
  const dev = program
1635
2031
  .command('dev')
1636
- .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')
1637
2033
  .option('--app <path>', 'Path to a Pulumi app (relative to monorepo root or cwd)')
1638
2034
  .option('--stack <stack>', 'Pulumi stack name (default: auto-detect)')
1639
2035
  .addOption(new Option('--stage <stack>', 'Deprecated alias for --stack').hideHelp())
@@ -1808,7 +2204,8 @@ export function registerDevCommand(program) {
1808
2204
  mocks: selectedMocks,
1809
2205
  });
1810
2206
  if (options.dryRun) {
1811
- 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));
1812
2209
  for (const [name, decl] of Object.entries(selectedMocks)) {
1813
2210
  const mode = externalMode(name, decl);
1814
2211
  if (mode === 'local') {
@@ -1855,7 +2252,42 @@ export function registerDevCommand(program) {
1855
2252
  logWarn(`Auth auto-provisioning skipped: ${err instanceof Error ? err.message : err}` +
1856
2253
  ` — if the local Zitadel predates seeding, run: mesh stop --destroy && mesh start`);
1857
2254
  }
1858
- const devOutput = await allocatePorts(rawDevOutput, worktree);
2255
+ const authzConfig = readLocalAuthzConfig(appRoot);
2256
+ if (authzConfig?.prebuilt) {
2257
+ logWarn(`package.json declares both mesh.authz.schema and mesh.authz.metadata — both publish this app's authorization.` +
2258
+ ` Using mesh.authz.metadata; remove one of them.`);
2259
+ }
2260
+ else if (authzConfig) {
2261
+ const app = rawDevOutput.app ?? projectName;
2262
+ try {
2263
+ const published = await publishLocalAppAuthz({
2264
+ appRoot,
2265
+ schema: authzConfig.schema,
2266
+ tenant: localTenant,
2267
+ app,
2268
+ ...(provisioned
2269
+ ? { zitadel: { projectId: provisioned.projectId, orgId: provisioned.orgId } }
2270
+ : {}),
2271
+ });
2272
+ for (const [name, svc] of Object.entries(rawDevOutput.services)) {
2273
+ if (name.startsWith('mock-'))
2274
+ continue;
2275
+ svc.env = {
2276
+ ...(svc.env ?? {}),
2277
+ ...Object.fromEntries(Object.entries(published.env).map(([k, value]) => [k, { value }])),
2278
+ };
2279
+ }
2280
+ logSuccess(published.instance
2281
+ ? `Authz (${published.mode}) published for '${app}' — its SpiceDB on localhost:${published.instance.grpcPort} (HTTP ${published.instance.httpPort})`
2282
+ : `Authz (${published.mode}) published for '${app}'`);
2283
+ }
2284
+ catch (err) {
2285
+ logWarn(`Could not publish '${app}'s authorization (${err instanceof Error ? err.message : err})` +
2286
+ ` — the Hub cannot author its roles, and a policy-engine app denies every check.`);
2287
+ }
2288
+ }
2289
+ const plan = sessionPortPlan(sessionName);
2290
+ const devOutput = await allocatePorts(rawDevOutput, worktree, plan.remembered, undefined, plan.ownPorts);
1859
2291
  for (const service of signInServices) {
1860
2292
  const port = devOutput.services[service]?.port;
1861
2293
  if (!port) {
@@ -2062,6 +2494,7 @@ export function registerDevCommand(program) {
2062
2494
  externalProbeFiles,
2063
2495
  ...(localDocs ? { localDocs } : {}),
2064
2496
  });
2497
+ saveSessionPortMemory(sessionName, devOutput);
2065
2498
  console.log('');
2066
2499
  logSuccess(`Dev session started (docker): ${sessionName}`);
2067
2500
  console.log('');
@@ -2082,10 +2515,12 @@ export function registerDevCommand(program) {
2082
2515
  devOutput: finalDevOutput,
2083
2516
  startedAt: new Date().toISOString(),
2084
2517
  runner: 'tmux',
2518
+ cleanEnv: true,
2085
2519
  composeExternals,
2086
2520
  externalProbeFiles,
2087
2521
  ...(localDocs ? { localDocs } : {}),
2088
2522
  });
2523
+ saveSessionPortMemory(sessionName, finalDevOutput);
2089
2524
  return;
2090
2525
  }
2091
2526
  if (options.mock || options.externals) {
@@ -2235,7 +2670,9 @@ export function registerDevCommand(program) {
2235
2670
  await showStatus(sessionName, state?.devOutput ?? rawDevOutput, !!options.json);
2236
2671
  return;
2237
2672
  }
2238
- 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);
2239
2676
  transport = await gateVpnDirectReachability(transport, devOutput);
2240
2677
  if (options.dryRun) {
2241
2678
  printDevPlan(sessionName, appRoot, worktree, devOutput);
@@ -2302,28 +2739,22 @@ export function registerDevCommand(program) {
2302
2739
  stack,
2303
2740
  devOutput: finalDevOutput,
2304
2741
  startedAt: new Date().toISOString(),
2742
+ cleanEnv: true,
2305
2743
  workflowFingerprint: fingerprintWorkflowSource(appRoot, finalDevOutput.services),
2306
2744
  });
2745
+ saveSessionPortMemory(sessionName, finalDevOutput);
2307
2746
  });
2308
2747
  dev
2309
2748
  .command('logs <service>')
2310
2749
  .description('Show logs for a service')
2311
2750
  .option('--tail <lines>', 'Number of lines', '100')
2312
2751
  .action((service, opts) => {
2313
- const sessionName = dev.opts().session;
2314
- const resolvedSession = sessionName ?? `${getProjectName(findAppRoot())}-dev`;
2315
- if (loadSessionState(resolvedSession)?.runner === 'docker') {
2316
- 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)));
2317
2755
  return;
2318
2756
  }
2319
- if (sessionName) {
2320
- showLogs(sessionName, service, parseInt(opts.tail, 10));
2321
- }
2322
- else {
2323
- const appRoot = findAppRoot();
2324
- const projectName = getProjectName(appRoot);
2325
- showLogs(`${projectName}-dev`, service, parseInt(opts.tail, 10));
2326
- }
2757
+ showLogs(sessionName, service, parseInt(opts.tail, 10));
2327
2758
  });
2328
2759
  dev
2329
2760
  .command('restart <service>')
@@ -2332,6 +2763,8 @@ export function registerDevCommand(program) {
2332
2763
  .addOption(new Option('--stage <stack>', 'Deprecated alias for --stack').hideHelp())
2333
2764
  .option('--refresh-env', "Regenerate this service's env from current stack/SSM outputs before restarting (for config/deploy changes)")
2334
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')
2335
2768
  .action(async (service, opts) => {
2336
2769
  let ctx = null;
2337
2770
  try {
@@ -2341,8 +2774,7 @@ export function registerDevCommand(program) {
2341
2774
  applyAwsProfileOverride(parentProfile);
2342
2775
  }
2343
2776
  const appRoot = findAppRoot();
2344
- const projectName = getProjectName(appRoot);
2345
- const sessionName = parentSession ?? `${projectName}-dev`;
2777
+ const sessionName = sessionNameFor(appRoot, parentSession);
2346
2778
  const state = loadSessionState(sessionName);
2347
2779
  if (state?.runner === 'docker') {
2348
2780
  dockerDevRestart(sessionName, service);
@@ -2391,10 +2823,11 @@ export function registerDevCommand(program) {
2391
2823
  : undefined;
2392
2824
  const awsEnv = buildChildAwsEnv(baseAwsEnv, resolved?.method ?? 'ambient', profileHint, zitadelOpts);
2393
2825
  const devOutput = state?.devOutput ?? getDevOutput(appRoot, stack, baseAwsEnv);
2394
- await restartService(sessionName, service, appRoot, devOutput, awsEnv, {
2826
+ const readiness = await restartService(sessionName, service, appRoot, devOutput, awsEnv, {
2395
2827
  refreshEnv: !!opts.refreshEnv,
2396
2828
  stack,
2397
2829
  sessionState: state,
2830
+ waitMs: parseWaitSeconds(opts.wait, RESTART_READY_TIMEOUT_MS / 1000) * 1000,
2398
2831
  });
2399
2832
  if (/worker/i.test(service) && state) {
2400
2833
  saveSessionState(sessionName, {
@@ -2402,6 +2835,8 @@ export function registerDevCommand(program) {
2402
2835
  workflowFingerprint: fingerprintWorkflowSource(appRoot, state.devOutput?.services),
2403
2836
  });
2404
2837
  }
2838
+ if (readiness?.state === 'exited' || readiness?.state === 'port-held')
2839
+ process.exit(1);
2405
2840
  }
2406
2841
  catch (err) {
2407
2842
  logError(`Failed to restart '${service}': ${err?.message ?? String(err)}`);
@@ -2412,7 +2847,7 @@ export function registerDevCommand(program) {
2412
2847
  });
2413
2848
  dev
2414
2849
  .command('doctor')
2415
- .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')
2416
2851
  .option('--stack <stack>', 'Pulumi stack name')
2417
2852
  .addOption(new Option('--stage <stack>', 'Deprecated alias for --stack').hideHelp())
2418
2853
  .option('--json', 'Machine-readable output')
@@ -2422,8 +2857,7 @@ export function registerDevCommand(program) {
2422
2857
  applyAwsProfileOverride(parentProfile);
2423
2858
  }
2424
2859
  const appRoot = findAppRoot();
2425
- const projectName = getProjectName(appRoot);
2426
- const sessionName = dev.opts().session ?? `${projectName}-dev`;
2860
+ const sessionName = sessionNameFor(appRoot, dev.opts().session);
2427
2861
  const state = loadSessionState(sessionName);
2428
2862
  const stack = state?.stack ?? detectStack(appRoot, resolveStackOption(opts));
2429
2863
  const asJson = !!opts.json || !!dev.opts().json;