simplemdg-dev-cli 1.2.0 → 1.3.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.
@@ -7,7 +7,7 @@ import prompts from "prompts";
7
7
  import { authenticateCloudFoundry, buildCloudFoundryTargetKey, listCloudFoundryApps, inferCloudFoundryRegionFromApiEndpoint, listCloudFoundryOrganizations, listCloudFoundrySpaces, readCloudFoundryTarget, scanCloudFoundryOrganizationsAcrossRegions, setCloudFoundryApiEndpoint, targetCloudFoundryOrg, targetCloudFoundrySpace, } from "../core/cf.js";
8
8
  import { parseCloudFoundryEnvironment } from "../core/cf-env-parser.js";
9
9
  import { readCache, rememberCloudFoundryApps, rememberCloudFoundryLoginProfile, rememberCloudFoundryOrgEntries, rememberEnvironmentFileName, rememberSelectedApp, } from "../core/cache.js";
10
- import { ensureCommandAvailable, runCommand, runCommandInherit } from "../core/process.js";
10
+ import { runCommand, runCommandInherit } from "../core/process.js";
11
11
  import { resolveRepositoryPath } from "../core/repository.js";
12
12
  import { searchableSelectChoice, selectFromHistoryOrInput } from "../core/prompts.js";
13
13
  function validateRequired(value) {
@@ -20,6 +20,297 @@ function buildCloudFoundryLogsArgs(options) {
20
20
  }
21
21
  return args;
22
22
  }
23
+ function parsePositivePort(value, defaultValue) {
24
+ if (!value?.trim()) {
25
+ return defaultValue;
26
+ }
27
+ const port = Number(value.trim());
28
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
29
+ throw new Error(`Invalid port: ${value}`);
30
+ }
31
+ return port;
32
+ }
33
+ function buildNodeInspectorRemoteCommand(remotePort) {
34
+ if (remotePort !== 9229) {
35
+ return [
36
+ `echo "Remote port ${remotePort} was requested."`,
37
+ `echo "SIGUSR1 starts the Node.js inspector on its default port 9229 for a running Node process."`,
38
+ `echo "Use remote port 9229, or start the app process with NODE_OPTIONS=--inspect=127.0.0.1:${remotePort}."`,
39
+ `exit 2`,
40
+ ].join("; ");
41
+ }
42
+ return [
43
+ `PID=$(pgrep -xo node || pgrep -x node | head -n 1)`,
44
+ `if [ -z "$PID" ]; then echo "No Node.js PID found in app container" >&2; exit 1; fi`,
45
+ `if command -v ss >/dev/null 2>&1 && ss -H -ntl "sport = :9229" | grep -q .; then echo "Node inspector already listening on 127.0.0.1:9229"`,
46
+ `else kill -s SIGUSR1 "$PID" && echo "Started Node inspector for PID $PID on 127.0.0.1:9229"; fi`,
47
+ `tail -f /dev/null`,
48
+ ].join("; ");
49
+ }
50
+ function buildCloudFoundryDebugSshArgs(options) {
51
+ const args = [
52
+ "ssh",
53
+ options.appName,
54
+ "-i",
55
+ options.instanceIndex,
56
+ ];
57
+ if (options.processName?.trim()) {
58
+ args.push("--process", options.processName.trim());
59
+ }
60
+ args.push("-T", "-L", `${options.localPort}:127.0.0.1:${options.remotePort}`, "-c", buildNodeInspectorRemoteCommand(options.remotePort));
61
+ return args;
62
+ }
63
+ async function getNodeInspectorDebugUrl(localPort) {
64
+ const response = await fetch(`http://127.0.0.1:${localPort}/json/list`);
65
+ if (!response.ok) {
66
+ return undefined;
67
+ }
68
+ const targets = await response.json();
69
+ const webSocketDebuggerUrl = targets.find((target) => target.webSocketDebuggerUrl)?.webSocketDebuggerUrl;
70
+ if (!webSocketDebuggerUrl) {
71
+ return undefined;
72
+ }
73
+ const webSocketAddress = webSocketDebuggerUrl.replace(/^ws:\/\//, "");
74
+ return `devtools://devtools/bundled/inspector.html?ws=${webSocketAddress}`;
75
+ }
76
+ async function waitForNodeInspectorDebugUrl(localPort, timeoutMs = 10000) {
77
+ const startedAt = Date.now();
78
+ let lastError;
79
+ while (Date.now() - startedAt < timeoutMs) {
80
+ try {
81
+ const debugUrl = await getNodeInspectorDebugUrl(localPort);
82
+ if (debugUrl) {
83
+ return debugUrl;
84
+ }
85
+ }
86
+ catch (error) {
87
+ lastError = error;
88
+ }
89
+ await new Promise((resolve) => setTimeout(resolve, 500));
90
+ }
91
+ if (lastError instanceof Error) {
92
+ console.log(chalk.gray(`Could not read inspector JSON yet: ${lastError.message}`));
93
+ }
94
+ return undefined;
95
+ }
96
+ function printNodeInspectorAttachInfo(options) {
97
+ console.log("");
98
+ console.log(chalk.green(`Debug tunnel is ready for ${options.appName} instance ${options.instanceIndex}.`));
99
+ console.log(`Chrome inspect: ${chalk.cyan("chrome://inspect")}`);
100
+ console.log(`Local inspector JSON: ${chalk.cyan(`http://127.0.0.1:${options.localPort}/json/list`)}`);
101
+ if (options.debugUrl) {
102
+ console.log(`Direct DevTools link: ${chalk.cyan(options.debugUrl)}`);
103
+ }
104
+ else {
105
+ console.log(chalk.yellow("Direct DevTools link was not detected yet. Open chrome://inspect and configure localhost target."));
106
+ }
107
+ console.log("");
108
+ console.log(chalk.gray("VS Code attach config:"));
109
+ console.log(JSON.stringify({
110
+ type: "node",
111
+ request: "attach",
112
+ name: `Attach ${options.appName} on BTP`,
113
+ address: "127.0.0.1",
114
+ port: options.localPort,
115
+ localRoot: "${workspaceFolder}",
116
+ remoteRoot: "/home/vcap/app",
117
+ skipFiles: ["<node_internals>/**"],
118
+ }, null, 2));
119
+ console.log("");
120
+ console.log(chalk.gray("Keep this terminal open. Press Ctrl+C to close the debug tunnel."));
121
+ }
122
+ function buildVscodeNodeAttachConfiguration(options) {
123
+ return {
124
+ type: "node",
125
+ request: "attach",
126
+ name: `Attach BTP ${options.appName}`,
127
+ address: "127.0.0.1",
128
+ port: options.localPort,
129
+ localRoot: "${workspaceFolder}",
130
+ remoteRoot: options.remoteRoot,
131
+ protocol: "inspector",
132
+ sourceMaps: true,
133
+ restart: true,
134
+ skipFiles: ["<node_internals>/**"],
135
+ outFiles: [
136
+ "${workspaceFolder}/**/*.js",
137
+ "!**/node_modules/**",
138
+ ],
139
+ };
140
+ }
141
+ async function writeVscodeLaunchConfiguration(options) {
142
+ const vscodeDirectoryPath = path.resolve(options.cwd, ".vscode");
143
+ const launchJsonPath = path.join(vscodeDirectoryPath, "launch.json");
144
+ const configuration = buildVscodeNodeAttachConfiguration({
145
+ appName: options.appName,
146
+ localPort: options.localPort,
147
+ remoteRoot: options.remoteRoot ?? "/home/vcap/app",
148
+ });
149
+ await fs.ensureDir(vscodeDirectoryPath);
150
+ let launchJson = {
151
+ version: "0.2.0",
152
+ configurations: [],
153
+ };
154
+ if (await fs.pathExists(launchJsonPath)) {
155
+ try {
156
+ const currentContent = await fs.readFile(launchJsonPath, "utf8");
157
+ const parsed = JSON.parse(currentContent);
158
+ launchJson = {
159
+ version: typeof parsed.version === "string" ? parsed.version : "0.2.0",
160
+ configurations: Array.isArray(parsed.configurations) ? parsed.configurations : [],
161
+ };
162
+ }
163
+ catch {
164
+ const backupPath = `${launchJsonPath}.backup-${Date.now()}`;
165
+ await fs.copyFile(launchJsonPath, backupPath);
166
+ console.log(chalk.yellow(`Existing launch.json is not valid JSON. Backup created: ${backupPath}`));
167
+ }
168
+ }
169
+ const configurationName = String(configuration.name);
170
+ const existingIndex = launchJson.configurations.findIndex((item) => item.name === configurationName);
171
+ if (existingIndex >= 0) {
172
+ launchJson.configurations[existingIndex] = configuration;
173
+ }
174
+ else {
175
+ launchJson.configurations.unshift(configuration);
176
+ }
177
+ await fs.writeFile(launchJsonPath, `${JSON.stringify(launchJson, null, 2)}\n`, "utf8");
178
+ return launchJsonPath;
179
+ }
180
+ function printVscodeAttachInstructions(options) {
181
+ console.log("");
182
+ console.log(chalk.green(`VS Code debug config is ready for ${options.appName} instance ${options.instanceIndex}.`));
183
+ console.log(`Launch file: ${chalk.cyan(options.launchJsonPath)}`);
184
+ console.log(`Attach config: ${chalk.cyan(`Attach BTP ${options.appName}`)}`);
185
+ console.log(`Inspector: ${chalk.cyan(`127.0.0.1:${options.localPort}`)}`);
186
+ console.log("");
187
+ console.log(chalk.gray("Keep this terminal open, then open VS Code Run and Debug and choose the attach config above."));
188
+ console.log(chalk.gray("Press Ctrl+C in this terminal to close the tunnel."));
189
+ }
190
+ async function openVisualStudioCode(options) {
191
+ const result = await runCommand("code", [options.cwd]);
192
+ if (result.exitCode !== 0) {
193
+ console.log(chalk.yellow("Could not open VS Code automatically. Open this folder manually in VS Code."));
194
+ if (result.stderr)
195
+ console.log(chalk.gray(result.stderr));
196
+ }
197
+ }
198
+ async function selectDebugMode(options) {
199
+ if (options.check)
200
+ return "check-ssh";
201
+ if (options.enableSsh)
202
+ return "enable-ssh";
203
+ if (options.configOnly)
204
+ return "config-only";
205
+ if (options.linkOnly)
206
+ return "link-only";
207
+ if (options.chrome)
208
+ return "chrome";
209
+ if (options.vscode)
210
+ return "vscode";
211
+ return searchableSelectChoice({
212
+ message: "Select debug mode",
213
+ choices: [
214
+ {
215
+ title: "VS Code attach debugger (recommended)",
216
+ value: "vscode",
217
+ description: "Create/update .vscode/launch.json and open a CF SSH tunnel",
218
+ },
219
+ {
220
+ title: "Chrome DevTools / chrome://inspect",
221
+ value: "chrome",
222
+ description: "Open a CF SSH tunnel and print Chrome inspector links",
223
+ },
224
+ {
225
+ title: "Create/update VS Code launch.json only",
226
+ value: "config-only",
227
+ description: "Use when the tunnel is already open or you only need config",
228
+ },
229
+ {
230
+ title: "Print attach links/config only",
231
+ value: "link-only",
232
+ description: "Use when localhost inspector tunnel is already open",
233
+ },
234
+ {
235
+ title: "Check SSH enabled for app",
236
+ value: "check-ssh",
237
+ description: "Run cf ssh-enabled <app>",
238
+ },
239
+ {
240
+ title: "Enable SSH and restart app",
241
+ value: "enable-ssh",
242
+ description: "Run cf enable-ssh <app> and cf restart <app>",
243
+ },
244
+ ],
245
+ allowCustomValue: false,
246
+ });
247
+ }
248
+ async function maybeSwitchCloudFoundryTargetForDebug(options) {
249
+ if (options.skipOrgSelect || options.app?.trim()) {
250
+ return;
251
+ }
252
+ const target = await readCloudFoundryTarget();
253
+ if (!target.apiEndpoint || !target.user) {
254
+ console.log(chalk.yellow("You are not logged in to Cloud Foundry yet. Run smdg cf login first."));
255
+ throw new Error("Cloud Foundry login is required");
256
+ }
257
+ const currentTargetLabel = [
258
+ target.org ? `org: ${target.org}` : "org: N/A",
259
+ target.space ? `space: ${target.space}` : "space: N/A",
260
+ target.apiEndpoint ? inferCloudFoundryRegionFromApiEndpoint(target.apiEndpoint) : "current region",
261
+ ].join(" · ");
262
+ const action = await searchableSelectChoice({
263
+ message: "Select BTP target for debug",
264
+ choices: [
265
+ { title: `Use current target (${currentTargetLabel})`, value: "current" },
266
+ { title: "Search org across regions and switch", value: "switch" },
267
+ ],
268
+ allowCustomValue: false,
269
+ });
270
+ if (action === "switch") {
271
+ await runOrgCommand({ switch: true });
272
+ }
273
+ }
274
+ async function selectDebugInstance(options) {
275
+ if (options.instance?.trim()) {
276
+ return options.instance.trim();
277
+ }
278
+ return searchableSelectChoice({
279
+ message: "Select app instance index",
280
+ choices: [
281
+ { title: "0", value: "0" },
282
+ { title: "1", value: "1" },
283
+ { title: "2", value: "2" },
284
+ { title: "3", value: "3" },
285
+ ],
286
+ validateCustomValue: (value) => /^\d+$/.test(value.trim()) ? true : "Instance index must be a number",
287
+ customValueTitle: (value) => `Use typed instance index: ${value}`,
288
+ });
289
+ }
290
+ async function selectDebugPort(options) {
291
+ if (options.value?.trim()) {
292
+ return parsePositivePort(options.value, options.defaultPort);
293
+ }
294
+ const portValue = await searchableSelectChoice({
295
+ message: options.message,
296
+ choices: [
297
+ { title: `${options.defaultPort} recommended`, value: String(options.defaultPort) },
298
+ { title: "9230", value: "9230" },
299
+ { title: "9231", value: "9231" },
300
+ ],
301
+ validateCustomValue: (value) => {
302
+ try {
303
+ parsePositivePort(value, options.defaultPort);
304
+ return true;
305
+ }
306
+ catch (error) {
307
+ return error instanceof Error ? error.message : "Invalid port";
308
+ }
309
+ },
310
+ customValueTitle: (value) => `Use typed port: ${value}`,
311
+ });
312
+ return parsePositivePort(portValue, options.defaultPort);
313
+ }
23
314
  function shouldIncludeLogLine(line, options) {
24
315
  const trimmedInstance = options.instance?.trim();
25
316
  const trimmedProcess = options.process?.trim();
@@ -564,9 +855,7 @@ async function runLogsCommand(options) {
564
855
  console.log(chalk.gray(`Streaming logs and appending to ${outputPath}`));
565
856
  }
566
857
  console.log(chalk.gray("Press Ctrl+C to stop realtime logs."));
567
- const resolvedCommand = await ensureCommandAvailable("cf");
568
- const childProcess = spawn(resolvedCommand.command, buildCloudFoundryLogsArgs({ appName, recent: false }), {
569
- env: resolvedCommand.env,
858
+ const childProcess = spawn("cf", buildCloudFoundryLogsArgs({ appName, recent: false }), {
570
859
  stdio: ["ignore", "pipe", "pipe"],
571
860
  shell: false,
572
861
  windowsHide: true,
@@ -594,6 +883,169 @@ async function runLogsCommand(options) {
594
883
  childProcess.on("close", () => resolve());
595
884
  });
596
885
  }
886
+ async function runDebugCommand(options) {
887
+ await maybeSwitchCloudFoundryTargetForDebug(options);
888
+ const appName = await resolveAppSelection({
889
+ app: options.app,
890
+ refresh: options.refresh,
891
+ message: "Search/select BTP app to debug",
892
+ });
893
+ const debugMode = await selectDebugMode(options);
894
+ const instanceIndex = await selectDebugInstance(options);
895
+ const localPort = await selectDebugPort({
896
+ value: options.localPort,
897
+ message: "Select local debug port",
898
+ defaultPort: 9229,
899
+ });
900
+ const remotePort = parsePositivePort(options.remotePort, 9229);
901
+ const repositoryPath = await resolveRepositoryPath(process.cwd()).catch(() => process.cwd());
902
+ if (debugMode === "check-ssh") {
903
+ const result = await runCommand("cf", ["ssh-enabled", appName]);
904
+ if (result.stdout)
905
+ console.log(result.stdout);
906
+ if (result.stderr)
907
+ console.error(result.stderr);
908
+ process.exitCode = result.exitCode;
909
+ return;
910
+ }
911
+ if (debugMode === "enable-ssh") {
912
+ const result = await runCommand("cf", ["enable-ssh", appName]);
913
+ if (result.stdout)
914
+ console.log(result.stdout);
915
+ if (result.stderr)
916
+ console.error(result.stderr);
917
+ if (result.exitCode !== 0) {
918
+ process.exitCode = result.exitCode;
919
+ return;
920
+ }
921
+ const restartResponse = await prompts({
922
+ type: "select",
923
+ name: "restart",
924
+ message: "Restart app now so SSH setting takes effect?",
925
+ choices: [
926
+ { title: "Yes, restart app", value: true },
927
+ { title: "No, I will restart later", value: false },
928
+ ],
929
+ initial: 0,
930
+ });
931
+ if (restartResponse.restart) {
932
+ const restartExitCode = await runCommandInherit("cf", ["restart", appName]);
933
+ process.exitCode = restartExitCode;
934
+ return;
935
+ }
936
+ console.log(chalk.yellow(`SSH was enabled. Restart the app before debugging: cf restart ${appName}`));
937
+ return;
938
+ }
939
+ let launchJsonPath;
940
+ if (debugMode === "vscode" || debugMode === "config-only") {
941
+ launchJsonPath = await writeVscodeLaunchConfiguration({
942
+ cwd: repositoryPath,
943
+ appName,
944
+ localPort,
945
+ remoteRoot: "/home/vcap/app",
946
+ });
947
+ console.log(chalk.green(`Updated VS Code launch config: ${launchJsonPath}`));
948
+ const openResponse = await prompts({
949
+ type: "select",
950
+ name: "open",
951
+ message: "Open current folder in VS Code?",
952
+ choices: [
953
+ { title: "No", value: false },
954
+ { title: "Yes", value: true },
955
+ ],
956
+ initial: options.open ? 1 : 0,
957
+ });
958
+ if (openResponse.open) {
959
+ await openVisualStudioCode({ cwd: repositoryPath });
960
+ }
961
+ }
962
+ if (debugMode === "config-only") {
963
+ printVscodeAttachInstructions({
964
+ appName,
965
+ instanceIndex,
966
+ localPort,
967
+ launchJsonPath: launchJsonPath ?? path.resolve(repositoryPath, ".vscode", "launch.json"),
968
+ });
969
+ return;
970
+ }
971
+ if (debugMode === "link-only") {
972
+ const debugUrl = await waitForNodeInspectorDebugUrl(localPort, 2000);
973
+ printNodeInspectorAttachInfo({ appName, instanceIndex, localPort, debugUrl });
974
+ return;
975
+ }
976
+ if (options.enableSsh) {
977
+ const result = await runCommand("cf", ["enable-ssh", appName]);
978
+ if (result.stdout)
979
+ console.log(result.stdout);
980
+ if (result.stderr)
981
+ console.error(result.stderr);
982
+ if (result.exitCode !== 0) {
983
+ process.exitCode = result.exitCode;
984
+ return;
985
+ }
986
+ if (options.restart) {
987
+ const restartExitCode = await runCommandInherit("cf", ["restart", appName]);
988
+ if (restartExitCode !== 0) {
989
+ process.exitCode = restartExitCode;
990
+ return;
991
+ }
992
+ }
993
+ else {
994
+ console.log(chalk.yellow("SSH was enabled. If cf ssh still fails, restart the app or run: cf restart " + appName));
995
+ }
996
+ }
997
+ console.log(chalk.gray(`Starting Node.js inspector tunnel for ${appName} instance ${instanceIndex}...`));
998
+ console.log(chalk.gray(`Forwarding localhost:${localPort} -> app container 127.0.0.1:${remotePort}`));
999
+ const childProcess = spawn("cf", buildCloudFoundryDebugSshArgs({
1000
+ appName,
1001
+ instanceIndex,
1002
+ processName: options.process,
1003
+ localPort,
1004
+ remotePort,
1005
+ }), {
1006
+ stdio: ["ignore", "pipe", "pipe"],
1007
+ shell: false,
1008
+ windowsHide: true,
1009
+ });
1010
+ let hasPrintedAttachInfo = false;
1011
+ const printAttachInfoOnce = async () => {
1012
+ if (hasPrintedAttachInfo) {
1013
+ return;
1014
+ }
1015
+ hasPrintedAttachInfo = true;
1016
+ if (debugMode === "vscode") {
1017
+ printVscodeAttachInstructions({
1018
+ appName,
1019
+ instanceIndex,
1020
+ localPort,
1021
+ launchJsonPath: launchJsonPath ?? path.resolve(repositoryPath, ".vscode", "launch.json"),
1022
+ });
1023
+ return;
1024
+ }
1025
+ const debugUrl = await waitForNodeInspectorDebugUrl(localPort);
1026
+ printNodeInspectorAttachInfo({ appName, instanceIndex, localPort, debugUrl });
1027
+ };
1028
+ childProcess.stdout?.on("data", (chunk) => {
1029
+ const text = chunk.toString("utf8");
1030
+ process.stdout.write(text);
1031
+ if (/inspector|debug|listening|started/i.test(text)) {
1032
+ void printAttachInfoOnce();
1033
+ }
1034
+ });
1035
+ childProcess.stderr?.on("data", (chunk) => {
1036
+ process.stderr.write(chunk.toString("utf8"));
1037
+ });
1038
+ const fallbackTimer = setTimeout(() => {
1039
+ void printAttachInfoOnce();
1040
+ }, 3000);
1041
+ childProcess.on("close", (exitCode) => {
1042
+ clearTimeout(fallbackTimer);
1043
+ process.exitCode = exitCode ?? 0;
1044
+ });
1045
+ await new Promise((resolve) => {
1046
+ childProcess.on("close", () => resolve());
1047
+ });
1048
+ }
597
1049
  async function runTargetCommand() {
598
1050
  const target = await readCloudFoundryTarget();
599
1051
  printTarget(target);
@@ -658,6 +1110,25 @@ export function registerCloudFoundryCommands(program) {
658
1110
  .option("--instance <index>", "Filter logs by app instance index, for example 0 or 1")
659
1111
  .option("--process <processName>", "Filter logs by process name, for example WEB")
660
1112
  .action(runLogsCommand);
1113
+ cfCommand
1114
+ .command("debug")
1115
+ .description("Debug a deployed BTP Cloud Foundry Node.js app with selectable VS Code or Chrome mode")
1116
+ .option("--app <appName>", "BTP app name")
1117
+ .option("--refresh", "Refresh app list before selecting")
1118
+ .option("--instance <index>", "App instance index", "0")
1119
+ .option("--process <processName>", "CF process name for multi-process apps")
1120
+ .option("--local-port <port>", "Local inspector port", "9229")
1121
+ .option("--remote-port <port>", "Remote inspector port in app container", "9229")
1122
+ .option("--enable-ssh", "Run cf enable-ssh <app> before opening the debug tunnel")
1123
+ .option("--restart", "Restart app after --enable-ssh")
1124
+ .option("--check", "Run cf ssh-enabled <app> and exit")
1125
+ .option("--link-only", "Only print attach links/config for an already-open tunnel")
1126
+ .option("--vscode", "Use VS Code attach debug mode")
1127
+ .option("--chrome", "Use Chrome DevTools debug mode")
1128
+ .option("--config-only", "Only create/update .vscode/launch.json")
1129
+ .option("--open", "Open current folder in VS Code after creating launch.json")
1130
+ .option("--skip-org-select", "Use current CF org/space without asking")
1131
+ .action(runDebugCommand);
661
1132
  cfCommand
662
1133
  .command("apps-cache-refresh")
663
1134
  .description("Refresh cached cf apps for current target. Internal command used by smdg cf apps.")