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