@mesh-tech/mesh-cli 0.39.0 → 0.40.1
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.
- package/dist/bin/{mesh.auth-preflight-JO5G7SOF.js → mesh.auth-preflight-AUFQ2FGD.js} +1 -1
- package/dist/bin/{mesh.chunk-CNLSVKYY.js → mesh.chunk-2ASXJ5DO.js} +233 -581
- package/dist/bin/mesh.chunk-2ASXJ5DO.js.map +6 -0
- package/dist/bin/{mesh.chunk-WODQO6TP.js → mesh.chunk-6AIG5OJM.js} +3 -3
- package/dist/bin/{mesh.chunk-OSV3EZQ3.js → mesh.chunk-6SSOVZOI.js} +1 -1
- package/dist/bin/{mesh.chunk-2ZJR5LGN.js → mesh.chunk-7HTKSUWR.js} +1507 -548
- package/dist/bin/mesh.chunk-7HTKSUWR.js.map +6 -0
- package/dist/bin/{mesh.chunk-UI6EFAMD.js → mesh.chunk-F44A3ACW.js} +2 -2
- package/dist/bin/{mesh.chunk-C2TKCYHP.js → mesh.chunk-HJGMOEUG.js} +1 -1
- package/dist/bin/{mesh.chunk-O6WRZU4X.js → mesh.chunk-NSQJEX36.js} +1 -1
- package/dist/bin/{mesh.chunk-C4ZM7DM2.js → mesh.chunk-PZVI7V3J.js} +1 -1
- package/dist/bin/mesh.chunk-TDCH4SFQ.js +419 -0
- package/dist/bin/mesh.chunk-TDCH4SFQ.js.map +6 -0
- package/dist/bin/mesh.chunk-YE2WIY3Y.js +473 -0
- package/dist/bin/mesh.chunk-YE2WIY3Y.js.map +6 -0
- package/dist/bin/{mesh.create-app-SOHBL75U.js → mesh.create-app-Z3RKSTBO.js} +3 -3
- package/dist/bin/{mesh.dev-token-server-SS7QEQOQ.js → mesh.dev-token-server-XG26C6IQ.js} +2 -2
- package/dist/bin/mesh.generate-XSM4C6FQ.js +10 -0
- package/dist/bin/mesh.js +9 -8
- package/dist/bin/mesh.js.map +1 -1
- package/dist/bin/{mesh.login-6VPMWHHU.js → mesh.login-3AWVGPCM.js} +2 -2
- package/dist/bin/{mesh.platform-setup-DJJB2PWL.js → mesh.platform-setup-2ZDMHRCI.js} +1 -1
- package/dist/bin/{mesh.program-Y4HUNBFP.js → mesh.program-K5JQXEHX.js} +9 -8
- package/dist/bin/{mesh.registry-5P4ZLFLJ.js → mesh.registry-JLQYFUNN.js} +4 -4
- package/dist/bin/{mesh.registry-identity-ONGUJQAY.js → mesh.registry-identity-3AR27UGO.js} +3 -3
- package/dist/bin/mesh.registry-identity-3AR27UGO.js.map +6 -0
- package/dist/bin/mesh.review-command-LUVZQN2T.js +238 -0
- package/dist/bin/mesh.review-command-LUVZQN2T.js.map +6 -0
- package/dist/bin/{mesh.start-5CPWOHDJ.js → mesh.start-KZ5L6SOJ.js} +2 -2
- package/dist/bin/{mesh.wizard-FNRTSGVN.js → mesh.wizard-FCTT55KI.js} +7 -7
- package/dist/bin/mesh.workspace-membership-DI3YQGEK.js +65 -0
- package/dist/bin/mesh.workspace-membership-DI3YQGEK.js.map +6 -0
- package/dist/build-info.json +2 -2
- package/dist/src/commands/dev-doctor.d.ts +22 -0
- package/dist/src/commands/dev-doctor.js +149 -0
- package/dist/src/commands/dev-env-leaks.d.ts +24 -0
- package/dist/src/commands/dev-env-leaks.js +178 -0
- package/dist/src/commands/dev-launch.d.ts +17 -1
- package/dist/src/commands/dev-launch.js +130 -5
- package/dist/src/commands/dev-readiness.d.ts +49 -0
- package/dist/src/commands/dev-readiness.js +78 -0
- package/dist/src/commands/dev.d.ts +60 -0
- package/dist/src/commands/dev.js +539 -140
- package/dist/src/commands/hub/index.js +10 -7
- package/dist/src/commands/mcp/generate.d.ts +11 -0
- package/dist/src/commands/mcp/generate.js +78 -0
- package/dist/src/commands/mcp/index.d.ts +2 -0
- package/dist/src/commands/mcp/index.js +70 -0
- package/dist/src/commands/mcp/manifest-file.d.ts +5 -0
- package/dist/src/commands/mcp/manifest-file.js +24 -0
- package/dist/src/commands/mcp/review-command.d.ts +24 -0
- package/dist/src/commands/mcp/review-command.js +66 -0
- package/dist/src/commands/mcp/review.d.ts +41 -0
- package/dist/src/commands/mcp/review.js +140 -0
- package/dist/src/commands/mcp/workspace-membership.d.ts +7 -0
- package/dist/src/commands/mcp/workspace-membership.js +77 -0
- package/dist/src/commands/secrets/reindex.js +2 -7
- package/dist/src/commands/secrets/set.d.ts +22 -1
- package/dist/src/commands/secrets/set.js +31 -8
- package/dist/src/commands/temporal.js +35 -4
- package/dist/src/commands/temporal.js.map +2 -2
- package/dist/src/program.js +2 -0
- package/dist/src/utils/aws-auth.d.ts +4 -0
- package/dist/src/utils/aws-auth.js +7 -3
- package/dist/src/utils/dev-sessions-root.d.ts +3 -0
- package/dist/src/utils/dev-sessions-root.js +45 -0
- package/dist/src/utils/kubeconfig.js +2 -2
- package/dist/src/utils/tailscale.js +7 -1
- package/dist/src/utils/temporal-auth.js +2 -1
- package/dist/src/utils/workflow-fingerprint.js +23 -12
- package/fragments/agents/AGENTS.md.hbs +3 -1
- package/fragments/base/index.ts.hbs +3 -0
- package/package.json +7 -5
- package/skills/mesh/SKILL.md +10 -6
- package/templates/api-auth/index.ts.hbs +1 -0
- package/templates/api-role-gating/index.ts.hbs +1 -0
- package/templates/external-service/index.ts.hbs +1 -0
- package/templates/workflow/index.ts.hbs +2 -0
- package/dist/bin/mesh.chunk-2ZJR5LGN.js.map +0 -6
- package/dist/bin/mesh.chunk-CNLSVKYY.js.map +0 -6
- /package/dist/bin/{mesh.auth-preflight-JO5G7SOF.js.map → mesh.auth-preflight-AUFQ2FGD.js.map} +0 -0
- /package/dist/bin/{mesh.chunk-WODQO6TP.js.map → mesh.chunk-6AIG5OJM.js.map} +0 -0
- /package/dist/bin/{mesh.chunk-OSV3EZQ3.js.map → mesh.chunk-6SSOVZOI.js.map} +0 -0
- /package/dist/bin/{mesh.chunk-UI6EFAMD.js.map → mesh.chunk-F44A3ACW.js.map} +0 -0
- /package/dist/bin/{mesh.chunk-C2TKCYHP.js.map → mesh.chunk-HJGMOEUG.js.map} +0 -0
- /package/dist/bin/{mesh.chunk-O6WRZU4X.js.map → mesh.chunk-NSQJEX36.js.map} +0 -0
- /package/dist/bin/{mesh.chunk-C4ZM7DM2.js.map → mesh.chunk-PZVI7V3J.js.map} +0 -0
- /package/dist/bin/{mesh.create-app-SOHBL75U.js.map → mesh.create-app-Z3RKSTBO.js.map} +0 -0
- /package/dist/bin/{mesh.dev-token-server-SS7QEQOQ.js.map → mesh.dev-token-server-XG26C6IQ.js.map} +0 -0
- /package/dist/bin/{mesh.login-6VPMWHHU.js.map → mesh.generate-XSM4C6FQ.js.map} +0 -0
- /package/dist/bin/{mesh.platform-setup-DJJB2PWL.js.map → mesh.login-3AWVGPCM.js.map} +0 -0
- /package/dist/bin/{mesh.program-Y4HUNBFP.js.map → mesh.platform-setup-2ZDMHRCI.js.map} +0 -0
- /package/dist/bin/{mesh.registry-5P4ZLFLJ.js.map → mesh.program-K5JQXEHX.js.map} +0 -0
- /package/dist/bin/{mesh.registry-identity-ONGUJQAY.js.map → mesh.registry-JLQYFUNN.js.map} +0 -0
- /package/dist/bin/{mesh.start-5CPWOHDJ.js.map → mesh.start-KZ5L6SOJ.js.map} +0 -0
- /package/dist/bin/{mesh.wizard-FNRTSGVN.js.map → mesh.wizard-FCTT55KI.js.map} +0 -0
package/dist/src/commands/dev.js
CHANGED
|
@@ -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 =
|
|
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
|
|
125
|
-
'
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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(
|
|
628
|
-
if (typeof manifest?.name === 'string' && manifest.name)
|
|
629
|
-
|
|
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
|
-
|
|
637
|
-
|
|
638
|
-
break;
|
|
788
|
+
if (path.dirname(at) === at)
|
|
789
|
+
return null;
|
|
639
790
|
}
|
|
640
|
-
|
|
641
|
-
|
|
791
|
+
}
|
|
792
|
+
function appResolvedPackageDir(name, appRoot) {
|
|
642
793
|
for (let dir = appRoot;; dir = path.dirname(dir)) {
|
|
643
794
|
try {
|
|
644
|
-
|
|
645
|
-
return true;
|
|
795
|
+
return fs.realpathSync(path.join(dir, 'node_modules', name));
|
|
646
796
|
}
|
|
647
797
|
catch {
|
|
648
798
|
}
|
|
649
|
-
|
|
650
|
-
|
|
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
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
733
|
-
const
|
|
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
|
|
776
|
-
if (
|
|
777
|
-
const encodingKey = resolveTemporalEncodingKey(
|
|
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
|
-
|
|
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
|
|
983
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1300
|
-
if (
|
|
1301
|
-
const encodingKey = resolveTemporalEncodingKey(
|
|
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
|
-
|
|
1310
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
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
|
-
|
|
1378
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1573
|
-
|
|
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
|
-
|
|
1581
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2350
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
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;
|