@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.
- package/dist/bin/{mesh.auth-preflight-AQJ5TQES.js → mesh.auth-preflight-AUFQ2FGD.js} +1 -1
- package/dist/bin/{mesh.chunk-I6NXLXGC.js → mesh.chunk-2ASXJ5DO.js} +235 -581
- package/dist/bin/mesh.chunk-2ASXJ5DO.js.map +6 -0
- package/dist/bin/{mesh.chunk-IU2IWH6O.js → mesh.chunk-6AIG5OJM.js} +3 -3
- package/dist/bin/{mesh.chunk-KN4BZOYH.js → mesh.chunk-6SSOVZOI.js} +1 -1
- package/dist/bin/{mesh.chunk-LMYPTJ2F.js → mesh.chunk-7HTKSUWR.js} +1900 -577
- package/dist/bin/mesh.chunk-7HTKSUWR.js.map +6 -0
- package/dist/bin/{mesh.chunk-2YRWJH5X.js → mesh.chunk-F44A3ACW.js} +4 -4
- package/dist/bin/{mesh.chunk-2YRWJH5X.js.map → mesh.chunk-F44A3ACW.js.map} +1 -1
- package/dist/bin/{mesh.chunk-BF2SH6XT.js → mesh.chunk-HJGMOEUG.js} +1 -1
- package/dist/bin/{mesh.chunk-TZAGJTKU.js → mesh.chunk-NSQJEX36.js} +1 -1
- package/dist/bin/{mesh.chunk-HPYZWPRM.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-NGXACFAU.js → mesh.create-app-Z3RKSTBO.js} +3 -3
- package/dist/bin/{mesh.dev-token-server-YP2VBP2S.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-7SIAKFXJ.js → mesh.login-3AWVGPCM.js} +2 -2
- package/dist/bin/{mesh.platform-setup-CJXE3BCP.js → mesh.platform-setup-2ZDMHRCI.js} +1 -1
- package/dist/bin/{mesh.program-BIDQBSSI.js → mesh.program-K5JQXEHX.js} +9 -8
- package/dist/bin/{mesh.registry-7P4MGXEK.js → mesh.registry-JLQYFUNN.js} +4 -4
- package/dist/bin/{mesh.registry-identity-JW4KO7N7.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-MPQFIEMH.js → mesh.start-KZ5L6SOJ.js} +2 -2
- package/dist/bin/{mesh.wizard-ZJE4S6PC.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/app-check.js +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 +574 -140
- package/dist/src/commands/hub/index.js +10 -7
- package/dist/src/commands/local/app-authz.d.ts +118 -0
- package/dist/src/commands/local/app-authz.js +295 -0
- package/dist/src/commands/local/auth-provision.d.ts +1 -0
- package/dist/src/commands/local/auth-provision.js +12 -1
- package/dist/src/commands/local/index.js +2 -0
- 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 +51 -5
- 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/base/index.ts.hbs +4 -1
- package/package.json +7 -5
- package/skills/mesh/SKILL.md +10 -6
- package/templates/api-auth/api/src/schema.ts.hbs +17 -0
- package/templates/api-auth/index.ts.hbs +1 -0
- package/templates/api-auth/package.json.hbs +3 -0
- package/templates/api-role-gating/api/src/schema.ts.hbs +10 -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-I6NXLXGC.js.map +0 -6
- package/dist/bin/mesh.chunk-LMYPTJ2F.js.map +0 -6
- /package/dist/bin/{mesh.auth-preflight-AQJ5TQES.js.map → mesh.auth-preflight-AUFQ2FGD.js.map} +0 -0
- /package/dist/bin/{mesh.chunk-IU2IWH6O.js.map → mesh.chunk-6AIG5OJM.js.map} +0 -0
- /package/dist/bin/{mesh.chunk-KN4BZOYH.js.map → mesh.chunk-6SSOVZOI.js.map} +0 -0
- /package/dist/bin/{mesh.chunk-BF2SH6XT.js.map → mesh.chunk-HJGMOEUG.js.map} +0 -0
- /package/dist/bin/{mesh.chunk-TZAGJTKU.js.map → mesh.chunk-NSQJEX36.js.map} +0 -0
- /package/dist/bin/{mesh.chunk-HPYZWPRM.js.map → mesh.chunk-PZVI7V3J.js.map} +0 -0
- /package/dist/bin/{mesh.create-app-NGXACFAU.js.map → mesh.create-app-Z3RKSTBO.js.map} +0 -0
- /package/dist/bin/{mesh.dev-token-server-YP2VBP2S.js.map → mesh.dev-token-server-XG26C6IQ.js.map} +0 -0
- /package/dist/bin/{mesh.login-7SIAKFXJ.js.map → mesh.generate-XSM4C6FQ.js.map} +0 -0
- /package/dist/bin/{mesh.platform-setup-CJXE3BCP.js.map → mesh.login-3AWVGPCM.js.map} +0 -0
- /package/dist/bin/{mesh.program-BIDQBSSI.js.map → mesh.platform-setup-2ZDMHRCI.js.map} +0 -0
- /package/dist/bin/{mesh.registry-7P4MGXEK.js.map → mesh.program-K5JQXEHX.js.map} +0 -0
- /package/dist/bin/{mesh.registry-identity-JW4KO7N7.js.map → mesh.registry-JLQYFUNN.js.map} +0 -0
- /package/dist/bin/{mesh.start-MPQFIEMH.js.map → mesh.start-KZ5L6SOJ.js.map} +0 -0
- /package/dist/bin/{mesh.wizard-ZJE4S6PC.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,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 =
|
|
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
|
|
124
|
-
'
|
|
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
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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(
|
|
627
|
-
if (typeof manifest?.name === 'string' && manifest.name)
|
|
628
|
-
|
|
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
|
-
|
|
636
|
-
|
|
637
|
-
break;
|
|
788
|
+
if (path.dirname(at) === at)
|
|
789
|
+
return null;
|
|
638
790
|
}
|
|
639
|
-
|
|
640
|
-
|
|
791
|
+
}
|
|
792
|
+
function appResolvedPackageDir(name, appRoot) {
|
|
641
793
|
for (let dir = appRoot;; dir = path.dirname(dir)) {
|
|
642
794
|
try {
|
|
643
|
-
|
|
644
|
-
return true;
|
|
795
|
+
return fs.realpathSync(path.join(dir, 'node_modules', name));
|
|
645
796
|
}
|
|
646
797
|
catch {
|
|
647
798
|
}
|
|
648
|
-
|
|
649
|
-
|
|
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
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
732
|
-
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) {
|
|
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
|
|
775
|
-
if (
|
|
776
|
-
const encodingKey = resolveTemporalEncodingKey(
|
|
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
|
-
|
|
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
|
|
982
|
-
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;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1299
|
-
if (
|
|
1300
|
-
const encodingKey = resolveTemporalEncodingKey(
|
|
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
|
-
|
|
1309
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
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
|
-
|
|
1377
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1572
|
-
|
|
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
|
-
|
|
1580
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2315
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
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;
|