simplemdg-dev-cli 1.5.1 → 2.4.4
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/README.md +65 -243
- package/USER_GUIDE.md +55 -249
- package/dist/commands/cds.command.js +69 -60
- package/dist/commands/cds.command.js.map +1 -1
- package/dist/commands/cf-db.command.d.ts +2 -0
- package/dist/commands/cf-db.command.js +606 -0
- package/dist/commands/cf-db.command.js.map +1 -0
- package/dist/commands/cf.command.js +1625 -198
- package/dist/commands/cf.command.js.map +1 -1
- package/dist/commands/gitlab.command.d.ts +2 -0
- package/dist/commands/gitlab.command.js +351 -0
- package/dist/commands/gitlab.command.js.map +1 -0
- package/dist/commands/npmrc.command.js +50 -44
- package/dist/commands/npmrc.command.js.map +1 -1
- package/dist/core/cache.d.ts +1 -1
- package/dist/core/cache.js +58 -31
- package/dist/core/cache.js.map +1 -1
- package/dist/core/cds.js +32 -22
- package/dist/core/cds.js.map +1 -1
- package/dist/core/cf-env-parser.d.ts +1 -1
- package/dist/core/cf-env-parser.js +4 -1
- package/dist/core/cf-env-parser.js.map +1 -1
- package/dist/core/cf.d.ts +1 -1
- package/dist/core/cf.js +46 -31
- package/dist/core/cf.js.map +1 -1
- package/dist/core/db/db-btp.d.ts +48 -0
- package/dist/core/db/db-btp.js +162 -0
- package/dist/core/db/db-btp.js.map +1 -0
- package/dist/core/db/db-cache.d.ts +35 -0
- package/dist/core/db/db-cache.js +164 -0
- package/dist/core/db/db-cache.js.map +1 -0
- package/dist/core/db/db-connection.d.ts +22 -0
- package/dist/core/db/db-connection.js +73 -0
- package/dist/core/db/db-connection.js.map +1 -0
- package/dist/core/db/db-crypto.d.ts +3 -0
- package/dist/core/db/db-crypto.js +54 -0
- package/dist/core/db/db-crypto.js.map +1 -0
- package/dist/core/db/db-hana-adapter.d.ts +32 -0
- package/dist/core/db/db-hana-adapter.js +243 -0
- package/dist/core/db/db-hana-adapter.js.map +1 -0
- package/dist/core/db/db-metadata.d.ts +25 -0
- package/dist/core/db/db-metadata.js +150 -0
- package/dist/core/db/db-metadata.js.map +1 -0
- package/dist/core/db/db-postgres-adapter.d.ts +30 -0
- package/dist/core/db/db-postgres-adapter.js +245 -0
- package/dist/core/db/db-postgres-adapter.js.map +1 -0
- package/dist/core/db/db-query-files.d.ts +20 -0
- package/dist/core/db/db-query-files.js +106 -0
- package/dist/core/db/db-query-files.js.map +1 -0
- package/dist/core/db/db-query-history.d.ts +5 -0
- package/dist/core/db/db-query-history.js +49 -0
- package/dist/core/db/db-query-history.js.map +1 -0
- package/dist/core/db/db-row.d.ts +22 -0
- package/dist/core/db/db-row.js +70 -0
- package/dist/core/db/db-row.js.map +1 -0
- package/dist/core/db/db-studio-html.d.ts +4 -0
- package/dist/core/db/db-studio-html.js +437 -0
- package/dist/core/db/db-studio-html.js.map +1 -0
- package/dist/core/db/db-studio-server.d.ts +11 -0
- package/dist/core/db/db-studio-server.js +465 -0
- package/dist/core/db/db-studio-server.js.map +1 -0
- package/dist/core/db/db-types.d.ts +174 -0
- package/dist/core/db/db-types.js +3 -0
- package/dist/core/db/db-types.js.map +1 -0
- package/dist/core/db/db-vcap-parser.d.ts +7 -0
- package/dist/core/db/db-vcap-parser.js +137 -0
- package/dist/core/db/db-vcap-parser.js.map +1 -0
- package/dist/core/doctor.d.ts +1 -1
- package/dist/core/doctor.js +14 -8
- package/dist/core/doctor.js.map +1 -1
- package/dist/core/guide.js +31 -26
- package/dist/core/guide.js.map +1 -1
- package/dist/core/install.d.ts +1 -1
- package/dist/core/install.js +17 -11
- package/dist/core/install.js.map +1 -1
- package/dist/core/navigator.d.ts +17 -0
- package/dist/core/navigator.js +140 -0
- package/dist/core/navigator.js.map +1 -0
- package/dist/core/npmrc.js +29 -16
- package/dist/core/npmrc.js.map +1 -1
- package/dist/core/process.js +11 -6
- package/dist/core/process.js.map +1 -1
- package/dist/core/prompts.js +16 -8
- package/dist/core/prompts.js.map +1 -1
- package/dist/core/repository.d.ts +1 -1
- package/dist/core/repository.js +16 -9
- package/dist/core/repository.js.map +1 -1
- package/dist/core/scanner.d.ts +1 -1
- package/dist/core/scanner.js +13 -7
- package/dist/core/scanner.js.map +1 -1
- package/dist/core/tooling.d.ts +28 -0
- package/dist/core/tooling.js +168 -0
- package/dist/core/tooling.js.map +1 -0
- package/dist/core/types.js +2 -1
- package/dist/core/version-conflict.d.ts +2 -2
- package/dist/core/version-conflict.js +11 -6
- package/dist/core/version-conflict.js.map +1 -1
- package/dist/index.js +65 -48
- package/dist/index.js.map +1 -1
- package/dist/types-local.js +2 -1
- package/package.json +12 -6
- package/src/commands/cds.command.ts +529 -0
- package/src/commands/cf-db.command.ts +636 -0
- package/src/commands/cf.command.ts +3345 -0
- package/src/commands/gitlab.command.ts +373 -0
- package/src/commands/npmrc.command.ts +581 -0
- package/src/core/cache.ts +332 -0
- package/src/core/cds.ts +278 -0
- package/src/core/cf-env-parser.ts +131 -0
- package/src/core/cf.ts +271 -0
- package/src/core/db/db-btp.ts +207 -0
- package/src/core/db/db-cache.ts +215 -0
- package/src/core/db/db-connection.ts +79 -0
- package/src/core/db/db-crypto.ts +53 -0
- package/src/core/db/db-hana-adapter.ts +294 -0
- package/src/core/db/db-metadata.ts +174 -0
- package/src/core/db/db-postgres-adapter.ts +275 -0
- package/src/core/db/db-query-files.ts +130 -0
- package/src/core/db/db-query-history.ts +53 -0
- package/src/core/db/db-row.ts +93 -0
- package/src/core/db/db-studio-html.ts +439 -0
- package/src/core/db/db-studio-server.ts +559 -0
- package/src/core/db/db-types.ts +195 -0
- package/src/core/db/db-vcap-parser.ts +182 -0
- package/src/core/doctor.ts +70 -0
- package/src/core/guide.ts +261 -0
- package/src/core/install.ts +91 -0
- package/src/core/navigator.ts +164 -0
- package/src/core/npmrc.ts +171 -0
- package/src/core/process.ts +75 -0
- package/src/core/prompts.ts +225 -0
- package/src/core/repository.ts +36 -0
- package/src/core/scanner.ts +41 -0
- package/src/core/tooling.ts +207 -0
- package/src/core/types.ts +152 -0
- package/src/core/version-conflict.ts +46 -0
- package/src/index.ts +460 -0
- package/src/types/external.d.ts +3 -0
- package/src/types-local.ts +11 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,3345 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import net from "node:net";
|
|
5
|
+
import nodeFs from "node:fs";
|
|
6
|
+
import fs from "fs-extra";
|
|
7
|
+
import chalk from "chalk";
|
|
8
|
+
import { Command } from "commander";
|
|
9
|
+
import { registerCloudFoundryDbCommands } from "./cf-db.command";
|
|
10
|
+
import prompts from "prompts";
|
|
11
|
+
import {
|
|
12
|
+
authenticateCloudFoundry,
|
|
13
|
+
buildCloudFoundryTargetKey,
|
|
14
|
+
listCloudFoundryApps,
|
|
15
|
+
inferCloudFoundryRegionFromApiEndpoint,
|
|
16
|
+
listCloudFoundryOrganizations,
|
|
17
|
+
listCloudFoundrySpaces,
|
|
18
|
+
readCloudFoundryTarget,
|
|
19
|
+
scanCloudFoundryOrganizationsAcrossRegions,
|
|
20
|
+
setCloudFoundryApiEndpoint,
|
|
21
|
+
targetCloudFoundryOrg,
|
|
22
|
+
targetCloudFoundrySpace,
|
|
23
|
+
} from "../core/cf";
|
|
24
|
+
import { parseCloudFoundryEnvironment } from "../core/cf-env-parser";
|
|
25
|
+
import {
|
|
26
|
+
readCache,
|
|
27
|
+
rememberCloudFoundryApps,
|
|
28
|
+
rememberCloudFoundryLoginProfile,
|
|
29
|
+
rememberCloudFoundryOrgEntries,
|
|
30
|
+
rememberEnvironmentFileName,
|
|
31
|
+
rememberSelectedApp,
|
|
32
|
+
} from "../core/cache";
|
|
33
|
+
import { runCommand, runCommandInherit } from "../core/process";
|
|
34
|
+
import { resolveRepositoryPath } from "../core/repository";
|
|
35
|
+
import { searchableSelectChoice, selectFromHistoryOrInput } from "../core/prompts";
|
|
36
|
+
import { ensureExternalTool } from "../core/tooling";
|
|
37
|
+
import type { TCloudFoundryApp, TCloudFoundryLoginProfile, TCloudFoundryOrgEntry, TCloudFoundryTarget } from "../core/types";
|
|
38
|
+
|
|
39
|
+
type TCloudFoundryLoginOptions = {
|
|
40
|
+
api?: string;
|
|
41
|
+
username?: string;
|
|
42
|
+
password?: string;
|
|
43
|
+
org?: string;
|
|
44
|
+
space?: string;
|
|
45
|
+
savePassword?: boolean;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type TCloudFoundryAppsOptions = {
|
|
49
|
+
refresh?: boolean;
|
|
50
|
+
select?: boolean;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
type TCloudFoundryOrgOptions = {
|
|
54
|
+
list?: boolean;
|
|
55
|
+
switch?: boolean;
|
|
56
|
+
refresh?: boolean;
|
|
57
|
+
org?: string;
|
|
58
|
+
space?: string;
|
|
59
|
+
api?: string;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
type TCloudFoundryBindOptions = {
|
|
63
|
+
app?: string;
|
|
64
|
+
cwd?: string;
|
|
65
|
+
refresh?: boolean;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
type TCloudFoundryEnvOptions = {
|
|
69
|
+
app?: string;
|
|
70
|
+
out?: string;
|
|
71
|
+
cwd?: string;
|
|
72
|
+
refresh?: boolean;
|
|
73
|
+
raw?: boolean;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
type TCloudFoundryLogsOptions = {
|
|
77
|
+
app?: string;
|
|
78
|
+
out?: string;
|
|
79
|
+
refresh?: boolean;
|
|
80
|
+
recent?: boolean;
|
|
81
|
+
follow?: boolean;
|
|
82
|
+
instance?: string;
|
|
83
|
+
process?: string;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
type TCloudFoundryHttpWatchOptions = {
|
|
87
|
+
app?: string;
|
|
88
|
+
refresh?: boolean;
|
|
89
|
+
recent?: boolean;
|
|
90
|
+
out?: string;
|
|
91
|
+
skipOrgSelect?: boolean;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
type TCloudFoundryDebugOptions = {
|
|
95
|
+
app?: string;
|
|
96
|
+
refresh?: boolean;
|
|
97
|
+
instance?: string;
|
|
98
|
+
process?: string;
|
|
99
|
+
localPort?: string;
|
|
100
|
+
remotePort?: string;
|
|
101
|
+
enableSsh?: boolean;
|
|
102
|
+
restart?: boolean;
|
|
103
|
+
check?: boolean;
|
|
104
|
+
linkOnly?: boolean;
|
|
105
|
+
vscode?: boolean;
|
|
106
|
+
chrome?: boolean;
|
|
107
|
+
configOnly?: boolean;
|
|
108
|
+
open?: boolean;
|
|
109
|
+
skipOrgSelect?: boolean;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
type TCloudFoundryRequestTraceOptions = {
|
|
113
|
+
app?: string;
|
|
114
|
+
refresh?: boolean;
|
|
115
|
+
instance?: string;
|
|
116
|
+
process?: string;
|
|
117
|
+
localPort?: string;
|
|
118
|
+
remotePort?: string;
|
|
119
|
+
maxBodyBytes?: string;
|
|
120
|
+
skipOrgSelect?: boolean;
|
|
121
|
+
out?: string;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
type TRequestTraceMode = "path" | "headers" | "body" | "response";
|
|
125
|
+
|
|
126
|
+
type TRequestTraceAuthMode = "mask" | "full" | "omit";
|
|
127
|
+
|
|
128
|
+
type TRequestTraceHeaderPreset = "minimal" | "common" | "all" | "custom";
|
|
129
|
+
|
|
130
|
+
type TRequestTraceDisplayOptions = {
|
|
131
|
+
headerPreset: TRequestTraceHeaderPreset;
|
|
132
|
+
headerNames: string[];
|
|
133
|
+
parseBodyJson: boolean;
|
|
134
|
+
outputFile?: string;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
type TRequestTraceFilterState = {
|
|
138
|
+
method?: string;
|
|
139
|
+
path?: string;
|
|
140
|
+
body?: string;
|
|
141
|
+
status?: string;
|
|
142
|
+
text?: string;
|
|
143
|
+
paused: boolean;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
type TRequestTraceRuntimeState = {
|
|
147
|
+
display: TRequestTraceDisplayOptions;
|
|
148
|
+
filters: TRequestTraceFilterState;
|
|
149
|
+
events: Record<string, unknown>[];
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
type TCloudFoundryDebugMode =
|
|
153
|
+
| "vscode"
|
|
154
|
+
| "chrome"
|
|
155
|
+
| "config-only"
|
|
156
|
+
| "link-only"
|
|
157
|
+
| "check-ssh"
|
|
158
|
+
| "enable-ssh";
|
|
159
|
+
|
|
160
|
+
type TNodeInspectorPrepareMode =
|
|
161
|
+
| "set-env-restart"
|
|
162
|
+
| "running-process"
|
|
163
|
+
| "already-enabled";
|
|
164
|
+
|
|
165
|
+
function validateRequired(value: string): true | string {
|
|
166
|
+
return value.trim() ? true : "Value is required";
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function ensureCloudFoundrySessionFromCache(): Promise<TCloudFoundryTarget> {
|
|
170
|
+
await ensureExternalTool("cf");
|
|
171
|
+
const target = await readCloudFoundryTarget();
|
|
172
|
+
|
|
173
|
+
if (target.apiEndpoint && target.user) {
|
|
174
|
+
return target;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const cache = await readCache();
|
|
178
|
+
const profilesWithPassword = cache.cloudFoundry.loginProfiles.filter((profile) => profile.password?.trim());
|
|
179
|
+
|
|
180
|
+
if (!profilesWithPassword.length) {
|
|
181
|
+
console.log(chalk.yellow("You are not logged in to Cloud Foundry yet and no cached password was found."));
|
|
182
|
+
console.log(chalk.gray("Run smdg cf login once and choose to save the password for automatic re-login."));
|
|
183
|
+
throw new Error("Cloud Foundry login is required");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const preferredProfiles = target.apiEndpoint
|
|
187
|
+
? [
|
|
188
|
+
...profilesWithPassword.filter((profile) => profile.apiEndpoint === target.apiEndpoint),
|
|
189
|
+
...profilesWithPassword.filter((profile) => profile.apiEndpoint !== target.apiEndpoint),
|
|
190
|
+
]
|
|
191
|
+
: profilesWithPassword;
|
|
192
|
+
|
|
193
|
+
const selectedProfileIndex = preferredProfiles.length === 1
|
|
194
|
+
? "0"
|
|
195
|
+
: await searchableSelectChoice({
|
|
196
|
+
message: "Select cached CF login profile for automatic re-login",
|
|
197
|
+
choices: preferredProfiles.map((profile, index) => ({
|
|
198
|
+
title: `${profile.username} · ${profile.org}${profile.space ? `/${profile.space}` : ""} · ${inferCloudFoundryRegionFromApiEndpoint(profile.apiEndpoint)}`,
|
|
199
|
+
value: String(index),
|
|
200
|
+
})),
|
|
201
|
+
allowCustomValue: false,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const profile = preferredProfiles[Number(selectedProfileIndex)] ?? preferredProfiles[0];
|
|
205
|
+
|
|
206
|
+
console.log(chalk.gray(`Auto login CF: ${profile.username} · ${inferCloudFoundryRegionFromApiEndpoint(profile.apiEndpoint)} · ${profile.org}${profile.space ? `/${profile.space}` : ""}`));
|
|
207
|
+
|
|
208
|
+
const apiExitCode = await setCloudFoundryApiEndpoint(profile.apiEndpoint);
|
|
209
|
+
|
|
210
|
+
if (apiExitCode !== 0) {
|
|
211
|
+
process.exitCode = apiExitCode;
|
|
212
|
+
throw new Error("CF api target failed");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const authExitCode = await authenticateCloudFoundry({
|
|
216
|
+
username: profile.username,
|
|
217
|
+
password: profile.password as string,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
if (authExitCode !== 0) {
|
|
221
|
+
process.exitCode = authExitCode;
|
|
222
|
+
throw new Error("CF automatic login failed. Run smdg cf login and update the cached password.");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const orgExitCode = await targetCloudFoundryOrg(profile.org);
|
|
226
|
+
|
|
227
|
+
if (orgExitCode !== 0) {
|
|
228
|
+
process.exitCode = orgExitCode;
|
|
229
|
+
throw new Error("CF org target failed");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (profile.space) {
|
|
233
|
+
const spaceExitCode = await targetCloudFoundrySpace(profile.space);
|
|
234
|
+
|
|
235
|
+
if (spaceExitCode !== 0) {
|
|
236
|
+
process.exitCode = spaceExitCode;
|
|
237
|
+
throw new Error("CF space target failed");
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
await rememberCloudFoundryLoginProfile({
|
|
242
|
+
...profile,
|
|
243
|
+
updatedAt: new Date().toISOString(),
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
return readCloudFoundryTarget();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
function sortCloudFoundryProfilesForEndpoint(options: {
|
|
251
|
+
profiles: TCloudFoundryLoginProfile[];
|
|
252
|
+
apiEndpoint: string;
|
|
253
|
+
preferredOrg?: string;
|
|
254
|
+
}): TCloudFoundryLoginProfile[] {
|
|
255
|
+
const profilesWithPassword = options.profiles.filter((profile) => profile.password?.trim());
|
|
256
|
+
|
|
257
|
+
return [
|
|
258
|
+
...profilesWithPassword.filter((profile) => profile.apiEndpoint === options.apiEndpoint && profile.org === options.preferredOrg),
|
|
259
|
+
...profilesWithPassword.filter((profile) => profile.apiEndpoint === options.apiEndpoint && profile.org !== options.preferredOrg),
|
|
260
|
+
...profilesWithPassword.filter((profile) => profile.apiEndpoint !== options.apiEndpoint && profile.org === options.preferredOrg),
|
|
261
|
+
...profilesWithPassword.filter((profile) => profile.apiEndpoint !== options.apiEndpoint && profile.org !== options.preferredOrg),
|
|
262
|
+
].filter((profile, index, array) => {
|
|
263
|
+
return array.findIndex((item) => {
|
|
264
|
+
return item.apiEndpoint === profile.apiEndpoint
|
|
265
|
+
&& item.username === profile.username
|
|
266
|
+
&& item.password === profile.password
|
|
267
|
+
&& item.org === profile.org
|
|
268
|
+
&& item.space === profile.space;
|
|
269
|
+
}) === index;
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function ensureCloudFoundryAuthenticatedForApiEndpoint(options: {
|
|
274
|
+
apiEndpoint: string;
|
|
275
|
+
preferredOrg?: string;
|
|
276
|
+
preferredSpace?: string;
|
|
277
|
+
reason?: string;
|
|
278
|
+
}): Promise<TCloudFoundryLoginProfile | undefined> {
|
|
279
|
+
const apiExitCode = await setCloudFoundryApiEndpoint(options.apiEndpoint);
|
|
280
|
+
|
|
281
|
+
if (apiExitCode !== 0) {
|
|
282
|
+
process.exitCode = apiExitCode;
|
|
283
|
+
throw new Error(`Cannot set CF API endpoint: ${options.apiEndpoint}`);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const orgsCheck = await runCommand("cf", ["orgs"]);
|
|
287
|
+
|
|
288
|
+
if (orgsCheck.exitCode === 0) {
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const cache = await readCache();
|
|
293
|
+
const profiles = sortCloudFoundryProfilesForEndpoint({
|
|
294
|
+
profiles: cache.cloudFoundry.loginProfiles,
|
|
295
|
+
apiEndpoint: options.apiEndpoint,
|
|
296
|
+
preferredOrg: options.preferredOrg,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
if (!profiles.length) {
|
|
300
|
+
console.log(chalk.yellow(`Not logged in to ${inferCloudFoundryRegionFromApiEndpoint(options.apiEndpoint)} and no cached password was found for automatic login.`));
|
|
301
|
+
console.log(chalk.gray("Run smdg cf login once, choose to save password, then retry this command."));
|
|
302
|
+
throw new Error("Cloud Foundry automatic login is required");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
let lastError = orgsCheck.stderr || orgsCheck.stdout || "cf orgs failed";
|
|
306
|
+
|
|
307
|
+
for (const profile of profiles) {
|
|
308
|
+
console.log(chalk.gray(`Auto auth CF ${inferCloudFoundryRegionFromApiEndpoint(options.apiEndpoint)} as ${profile.username}...`));
|
|
309
|
+
const authExitCode = await authenticateCloudFoundry({
|
|
310
|
+
username: profile.username,
|
|
311
|
+
password: profile.password as string,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
if (authExitCode !== 0) {
|
|
315
|
+
lastError = `cf auth failed for ${profile.username}`;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const nextOrgsCheck = await runCommand("cf", ["orgs"]);
|
|
320
|
+
|
|
321
|
+
if (nextOrgsCheck.exitCode === 0) {
|
|
322
|
+
const updatedProfile: TCloudFoundryLoginProfile = {
|
|
323
|
+
...profile,
|
|
324
|
+
apiEndpoint: options.apiEndpoint,
|
|
325
|
+
org: options.preferredOrg || profile.org,
|
|
326
|
+
space: options.preferredSpace || profile.space,
|
|
327
|
+
updatedAt: new Date().toISOString(),
|
|
328
|
+
};
|
|
329
|
+
await rememberCloudFoundryLoginProfile(updatedProfile);
|
|
330
|
+
return updatedProfile;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
lastError = nextOrgsCheck.stderr || nextOrgsCheck.stdout || lastError;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
throw new Error(`CF automatic login failed for ${options.apiEndpoint}. ${lastError}`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function buildCloudFoundryLogsArgs(options: { appName: string; recent?: boolean }): string[] {
|
|
340
|
+
const args = ["logs", options.appName];
|
|
341
|
+
|
|
342
|
+
if (options.recent) {
|
|
343
|
+
args.push("--recent");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return args;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function parsePositivePort(value: string | undefined, defaultValue: number): number {
|
|
350
|
+
if (!value?.trim()) {
|
|
351
|
+
return defaultValue;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const port = Number(value.trim());
|
|
355
|
+
|
|
356
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
357
|
+
throw new Error(`Invalid port: ${value}`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return port;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function buildNodeInspectorRemoteCommand(remotePort: number): string {
|
|
364
|
+
if (remotePort !== 9229) {
|
|
365
|
+
return [
|
|
366
|
+
`echo "Remote port ${remotePort} was requested."`,
|
|
367
|
+
`echo "SIGUSR1 starts the Node.js inspector on its default port 9229 for a running Node process."`,
|
|
368
|
+
`echo "Use remote port 9229, or start the app process with NODE_OPTIONS=--inspect=0.0.0.0:${remotePort}."`,
|
|
369
|
+
`exit 2`,
|
|
370
|
+
].join("; ");
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const detectNodePidScript = [
|
|
374
|
+
`PID=""`,
|
|
375
|
+
`if command -v pgrep >/dev/null 2>&1; then PID=$(pgrep -f "(^|/| )node( |$)" 2>/dev/null | head -n 1 || true); fi`,
|
|
376
|
+
`if [ -z "$PID" ]; then PID=$(ps -eo pid=,args= 2>/dev/null | awk '/[n]ode/ && $0 !~ /awk/ && $0 !~ /pgrep/ {print $1; exit}'); fi`,
|
|
377
|
+
`if [ -z "$PID" ]; then echo "No Node.js PID found in app container. Use prepare mode: Set NODE_OPTIONS and restart app." >&2; ps -eo pid,args 2>/dev/null | head -n 40 >&2; exit 1; fi`,
|
|
378
|
+
`echo "Detected Node.js PID: $PID"`,
|
|
379
|
+
`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"; tail -f /dev/null; fi`,
|
|
380
|
+
`if command -v netstat >/dev/null 2>&1 && netstat -ntl 2>/dev/null | awk '{print $4}' | grep -Eq '(^|:)9229$'; then echo "Node inspector already listening on 127.0.0.1:9229"; tail -f /dev/null; fi`,
|
|
381
|
+
`kill -USR1 "$PID" || { echo "Cannot send SIGUSR1 to Node.js PID $PID. Use prepare mode: Set NODE_OPTIONS and restart app." >&2; exit 1; }`,
|
|
382
|
+
`echo "Requested Node inspector for PID $PID on 127.0.0.1:9229"`,
|
|
383
|
+
`COUNT=0; while [ "$COUNT" -lt 20 ]; do if command -v ss >/dev/null 2>&1 && ss -H -ntl "sport = :9229" | grep -q .; then echo "Node inspector is listening on 127.0.0.1:9229"; break; fi; if command -v netstat >/dev/null 2>&1 && netstat -ntl 2>/dev/null | awk '{print $4}' | grep -Eq '(^|:)9229$'; then echo "Node inspector is listening on 127.0.0.1:9229"; break; fi; COUNT=$((COUNT + 1)); sleep 1; done`,
|
|
384
|
+
`tail -f /dev/null`,
|
|
385
|
+
];
|
|
386
|
+
|
|
387
|
+
return detectNodePidScript.join("; ");
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function buildKeepAliveRemoteCommand(): string {
|
|
391
|
+
return [
|
|
392
|
+
`echo "SSH tunnel is open. Keep this terminal running."`,
|
|
393
|
+
`tail -f /dev/null`,
|
|
394
|
+
].join("; ");
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function buildCloudFoundryDebugSshArgs(options: {
|
|
398
|
+
appName: string;
|
|
399
|
+
instanceIndex: string;
|
|
400
|
+
processName?: string;
|
|
401
|
+
localPort: number;
|
|
402
|
+
remotePort: number;
|
|
403
|
+
prepareMode: TNodeInspectorPrepareMode;
|
|
404
|
+
}): string[] {
|
|
405
|
+
const args = [
|
|
406
|
+
"ssh",
|
|
407
|
+
options.appName,
|
|
408
|
+
"-i",
|
|
409
|
+
options.instanceIndex,
|
|
410
|
+
];
|
|
411
|
+
|
|
412
|
+
if (options.processName?.trim()) {
|
|
413
|
+
args.push("--process", options.processName.trim());
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const remoteCommand = options.prepareMode === "running-process"
|
|
417
|
+
? buildNodeInspectorRemoteCommand(options.remotePort)
|
|
418
|
+
: buildKeepAliveRemoteCommand();
|
|
419
|
+
|
|
420
|
+
args.push(
|
|
421
|
+
"-T",
|
|
422
|
+
"-L",
|
|
423
|
+
`${options.localPort}:127.0.0.1:${options.remotePort}`,
|
|
424
|
+
"-c",
|
|
425
|
+
remoteCommand,
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
return args;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function selectNodeInspectorPrepareMode(options: { appName: string; remotePort: number }): Promise<TNodeInspectorPrepareMode> {
|
|
432
|
+
return searchableSelectChoice({
|
|
433
|
+
message: "Prepare Node.js inspector",
|
|
434
|
+
choices: [
|
|
435
|
+
{
|
|
436
|
+
title: "Set NODE_OPTIONS and restart app (recommended first time)",
|
|
437
|
+
value: "set-env-restart",
|
|
438
|
+
description: `Runs cf set-env ${options.appName} NODE_OPTIONS --inspect=0.0.0.0:${options.remotePort} and cf restart`,
|
|
439
|
+
},
|
|
440
|
+
{
|
|
441
|
+
title: "Try start inspector on running Node process without restart",
|
|
442
|
+
value: "running-process",
|
|
443
|
+
description: "Uses cf ssh + SIGUSR1. Fast, but may fail if Node PID cannot be detected.",
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
title: "Inspector is already enabled, only open SSH tunnel",
|
|
447
|
+
value: "already-enabled",
|
|
448
|
+
description: "Use when NODE_OPTIONS already contains --inspect and app was restarted.",
|
|
449
|
+
},
|
|
450
|
+
],
|
|
451
|
+
allowCustomValue: false,
|
|
452
|
+
}) as Promise<TNodeInspectorPrepareMode>;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function ensureSshEnabledForDebug(appName: string): Promise<void> {
|
|
456
|
+
const sshEnabledResult = await runCommand("cf", ["ssh-enabled", appName]);
|
|
457
|
+
const combinedOutput = `${sshEnabledResult.stdout}
|
|
458
|
+
${sshEnabledResult.stderr}`;
|
|
459
|
+
|
|
460
|
+
if (sshEnabledResult.exitCode === 0 && /enabled/i.test(combinedOutput) && !/not enabled/i.test(combinedOutput)) {
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
console.log(chalk.yellow("SSH is not enabled for this app. Enabling SSH..."));
|
|
465
|
+
const enableResult = await runCommand("cf", ["enable-ssh", appName]);
|
|
466
|
+
|
|
467
|
+
if (enableResult.stdout) console.log(enableResult.stdout);
|
|
468
|
+
if (enableResult.stderr) console.error(enableResult.stderr);
|
|
469
|
+
|
|
470
|
+
if (enableResult.exitCode !== 0) {
|
|
471
|
+
throw new Error("cf enable-ssh failed. You may not have permission to enable SSH for this app.");
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function setNodeInspectorEnvironmentAndRestart(options: { appName: string; remotePort: number }): Promise<void> {
|
|
476
|
+
const nodeOptions = `--inspect=0.0.0.0:${options.remotePort} --enable-source-maps`;
|
|
477
|
+
|
|
478
|
+
console.log(chalk.gray(`Setting NODE_OPTIONS for ${options.appName}: ${nodeOptions}`));
|
|
479
|
+
const setEnvResult = await runCommand("cf", ["set-env", options.appName, "NODE_OPTIONS", nodeOptions]);
|
|
480
|
+
|
|
481
|
+
if (setEnvResult.stdout) console.log(setEnvResult.stdout);
|
|
482
|
+
if (setEnvResult.stderr) console.error(setEnvResult.stderr);
|
|
483
|
+
|
|
484
|
+
if (setEnvResult.exitCode !== 0) {
|
|
485
|
+
throw new Error("cf set-env NODE_OPTIONS failed");
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
console.log(chalk.yellow("Restarting app so NODE_OPTIONS takes effect..."));
|
|
489
|
+
const restartExitCode = await runCommandInherit("cf", ["restart", options.appName]);
|
|
490
|
+
|
|
491
|
+
if (restartExitCode !== 0) {
|
|
492
|
+
throw new Error(`cf restart ${options.appName} failed`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async function getNodeInspectorDebugUrl(localPort: number): Promise<string | undefined> {
|
|
497
|
+
const response = await fetch(`http://127.0.0.1:${localPort}/json/list`);
|
|
498
|
+
|
|
499
|
+
if (!response.ok) {
|
|
500
|
+
return undefined;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const targets = await response.json() as Array<{ webSocketDebuggerUrl?: string }>;
|
|
504
|
+
const webSocketDebuggerUrl = targets.find((target) => target.webSocketDebuggerUrl)?.webSocketDebuggerUrl;
|
|
505
|
+
|
|
506
|
+
if (!webSocketDebuggerUrl) {
|
|
507
|
+
return undefined;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const webSocketAddress = webSocketDebuggerUrl.replace(/^ws:\/\//, "");
|
|
511
|
+
return `devtools://devtools/bundled/inspector.html?ws=${webSocketAddress}`;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function waitForNodeInspectorDebugUrl(localPort: number, timeoutMs = 10000): Promise<string | undefined> {
|
|
515
|
+
const startedAt = Date.now();
|
|
516
|
+
let lastError: unknown;
|
|
517
|
+
|
|
518
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
519
|
+
try {
|
|
520
|
+
const debugUrl = await getNodeInspectorDebugUrl(localPort);
|
|
521
|
+
|
|
522
|
+
if (debugUrl) {
|
|
523
|
+
return debugUrl;
|
|
524
|
+
}
|
|
525
|
+
} catch (error) {
|
|
526
|
+
lastError = error;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (lastError instanceof Error) {
|
|
533
|
+
console.log(chalk.gray(`Could not read inspector JSON yet: ${lastError.message}`));
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return undefined;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function printNodeInspectorAttachInfo(options: { appName: string; instanceIndex: string; localPort: number; debugUrl?: string }): void {
|
|
540
|
+
console.log("");
|
|
541
|
+
console.log(chalk.green(`Debug tunnel is ready for ${options.appName} instance ${options.instanceIndex}.`));
|
|
542
|
+
console.log(`Chrome inspect: ${chalk.cyan("chrome://inspect")}`);
|
|
543
|
+
console.log(`Local inspector JSON: ${chalk.cyan(`http://127.0.0.1:${options.localPort}/json/list`)}`);
|
|
544
|
+
|
|
545
|
+
if (options.debugUrl) {
|
|
546
|
+
console.log(`Direct DevTools link: ${chalk.cyan(options.debugUrl)}`);
|
|
547
|
+
} else {
|
|
548
|
+
console.log(chalk.yellow("Direct DevTools link was not detected yet. Open chrome://inspect and configure localhost target."));
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
console.log("");
|
|
552
|
+
console.log(chalk.gray("VS Code attach config:"));
|
|
553
|
+
console.log(JSON.stringify({
|
|
554
|
+
type: "node",
|
|
555
|
+
request: "attach",
|
|
556
|
+
name: `Attach ${options.appName} on BTP`,
|
|
557
|
+
address: "127.0.0.1",
|
|
558
|
+
port: options.localPort,
|
|
559
|
+
localRoot: "${workspaceFolder}",
|
|
560
|
+
remoteRoot: "/home/vcap/app",
|
|
561
|
+
skipFiles: ["<node_internals>/**"],
|
|
562
|
+
}, null, 2));
|
|
563
|
+
console.log("");
|
|
564
|
+
console.log(chalk.gray("Keep this terminal open. Press Ctrl+C to close the debug tunnel."));
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
function buildVscodeNodeAttachConfiguration(options: {
|
|
569
|
+
appName: string;
|
|
570
|
+
localPort: number;
|
|
571
|
+
remoteRoot: string;
|
|
572
|
+
}): Record<string, unknown> {
|
|
573
|
+
return {
|
|
574
|
+
type: "node",
|
|
575
|
+
request: "attach",
|
|
576
|
+
name: `Attach BTP ${options.appName}`,
|
|
577
|
+
address: "127.0.0.1",
|
|
578
|
+
port: options.localPort,
|
|
579
|
+
localRoot: "${workspaceFolder}",
|
|
580
|
+
remoteRoot: options.remoteRoot,
|
|
581
|
+
protocol: "inspector",
|
|
582
|
+
sourceMaps: true,
|
|
583
|
+
restart: true,
|
|
584
|
+
skipFiles: ["<node_internals>/**"],
|
|
585
|
+
outFiles: [
|
|
586
|
+
"${workspaceFolder}/**/*.js",
|
|
587
|
+
"!**/node_modules/**",
|
|
588
|
+
],
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async function writeVscodeLaunchConfiguration(options: {
|
|
593
|
+
cwd: string;
|
|
594
|
+
appName: string;
|
|
595
|
+
localPort: number;
|
|
596
|
+
remoteRoot?: string;
|
|
597
|
+
}): Promise<string> {
|
|
598
|
+
const vscodeDirectoryPath = path.resolve(options.cwd, ".vscode");
|
|
599
|
+
const launchJsonPath = path.join(vscodeDirectoryPath, "launch.json");
|
|
600
|
+
const configuration = buildVscodeNodeAttachConfiguration({
|
|
601
|
+
appName: options.appName,
|
|
602
|
+
localPort: options.localPort,
|
|
603
|
+
remoteRoot: options.remoteRoot ?? "/home/vcap/app",
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
await fs.ensureDir(vscodeDirectoryPath);
|
|
607
|
+
|
|
608
|
+
let launchJson: { version: string; configurations: Array<Record<string, unknown>> } = {
|
|
609
|
+
version: "0.2.0",
|
|
610
|
+
configurations: [],
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
if (await fs.pathExists(launchJsonPath)) {
|
|
614
|
+
try {
|
|
615
|
+
const currentContent = await fs.readFile(launchJsonPath, "utf8");
|
|
616
|
+
const parsed = JSON.parse(currentContent) as Partial<typeof launchJson>;
|
|
617
|
+
launchJson = {
|
|
618
|
+
version: typeof parsed.version === "string" ? parsed.version : "0.2.0",
|
|
619
|
+
configurations: Array.isArray(parsed.configurations) ? parsed.configurations as Array<Record<string, unknown>> : [],
|
|
620
|
+
};
|
|
621
|
+
} catch {
|
|
622
|
+
const backupPath = `${launchJsonPath}.backup-${Date.now()}`;
|
|
623
|
+
await fs.copyFile(launchJsonPath, backupPath);
|
|
624
|
+
console.log(chalk.yellow(`Existing launch.json is not valid JSON. Backup created: ${backupPath}`));
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const configurationName = String(configuration.name);
|
|
629
|
+
const existingIndex = launchJson.configurations.findIndex((item) => item.name === configurationName);
|
|
630
|
+
|
|
631
|
+
if (existingIndex >= 0) {
|
|
632
|
+
launchJson.configurations[existingIndex] = configuration;
|
|
633
|
+
} else {
|
|
634
|
+
launchJson.configurations.unshift(configuration);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
await fs.writeFile(launchJsonPath, `${JSON.stringify(launchJson, null, 2)}\n`, "utf8");
|
|
638
|
+
return launchJsonPath;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function printVscodeAttachInstructions(options: {
|
|
642
|
+
appName: string;
|
|
643
|
+
instanceIndex: string;
|
|
644
|
+
localPort: number;
|
|
645
|
+
launchJsonPath: string;
|
|
646
|
+
inspectorReady?: boolean;
|
|
647
|
+
}): void {
|
|
648
|
+
console.log("");
|
|
649
|
+
|
|
650
|
+
if (options.inspectorReady === false) {
|
|
651
|
+
console.log(chalk.yellow(`VS Code config was created, but the Node inspector is not reachable yet for ${options.appName} instance ${options.instanceIndex}.`));
|
|
652
|
+
console.log(chalk.yellow("The VS Code debug toolbar appears only after the inspector is reachable and you start the attach config."));
|
|
653
|
+
} else {
|
|
654
|
+
console.log(chalk.green(`VS Code debug config is ready for ${options.appName} instance ${options.instanceIndex}.`));
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
console.log(`Launch file: ${chalk.cyan(options.launchJsonPath)}`);
|
|
658
|
+
console.log(`Attach config: ${chalk.cyan(`Attach BTP ${options.appName}`)}`);
|
|
659
|
+
console.log(`Inspector: ${chalk.cyan(`127.0.0.1:${options.localPort}`)}`);
|
|
660
|
+
console.log("");
|
|
661
|
+
console.log(chalk.cyan("How to start debugging in VS Code:"));
|
|
662
|
+
console.log("1. Keep this terminal open. It owns the CF SSH tunnel.");
|
|
663
|
+
console.log("2. Open VS Code Run and Debug panel with Ctrl+Shift+D.");
|
|
664
|
+
console.log(`3. Select ${chalk.cyan(`Attach BTP ${options.appName}`)}.`);
|
|
665
|
+
console.log("4. Press F5 or the green Start Debugging button.");
|
|
666
|
+
console.log("5. Debug buttons such as pause, step over, step into, restart, and stop appear only after attach succeeds.");
|
|
667
|
+
console.log("");
|
|
668
|
+
console.log(chalk.gray("Press Ctrl+C in this terminal to close the tunnel."));
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
async function openVisualStudioCode(options: { cwd: string; debugPanel?: boolean }): Promise<void> {
|
|
672
|
+
const args = options.debugPanel
|
|
673
|
+
? ["--reuse-window", options.cwd, "--command", "workbench.view.debug"]
|
|
674
|
+
: [options.cwd];
|
|
675
|
+
const result = await runCommand("code", args);
|
|
676
|
+
|
|
677
|
+
if (result.exitCode !== 0) {
|
|
678
|
+
console.log(chalk.yellow("Could not open VS Code automatically. Open this folder manually in VS Code."));
|
|
679
|
+
console.log(chalk.gray("Then open Run and Debug with Ctrl+Shift+D."));
|
|
680
|
+
if (result.stderr) console.log(chalk.gray(result.stderr));
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
async function selectDebugMode(options: TCloudFoundryDebugOptions): Promise<TCloudFoundryDebugMode> {
|
|
685
|
+
if (options.check) return "check-ssh";
|
|
686
|
+
if (options.enableSsh) return "enable-ssh";
|
|
687
|
+
if (options.configOnly) return "config-only";
|
|
688
|
+
if (options.linkOnly) return "link-only";
|
|
689
|
+
if (options.chrome) return "chrome";
|
|
690
|
+
if (options.vscode) return "vscode";
|
|
691
|
+
|
|
692
|
+
return searchableSelectChoice({
|
|
693
|
+
message: "Select debug mode",
|
|
694
|
+
choices: [
|
|
695
|
+
{
|
|
696
|
+
title: "VS Code guided debugging (recommended)",
|
|
697
|
+
value: "vscode",
|
|
698
|
+
description: "Create launch.json, open VS Code Debug panel, prepare inspector, and open CF SSH tunnel",
|
|
699
|
+
},
|
|
700
|
+
{
|
|
701
|
+
title: "Chrome DevTools / chrome://inspect",
|
|
702
|
+
value: "chrome",
|
|
703
|
+
description: "Open a CF SSH tunnel and print Chrome inspector links",
|
|
704
|
+
},
|
|
705
|
+
{
|
|
706
|
+
title: "Create/update VS Code launch.json only",
|
|
707
|
+
value: "config-only",
|
|
708
|
+
description: "Use when the tunnel is already open or you only need config",
|
|
709
|
+
},
|
|
710
|
+
{
|
|
711
|
+
title: "Print attach links/config only",
|
|
712
|
+
value: "link-only",
|
|
713
|
+
description: "Use when localhost inspector tunnel is already open",
|
|
714
|
+
},
|
|
715
|
+
{
|
|
716
|
+
title: "Check SSH enabled for app",
|
|
717
|
+
value: "check-ssh",
|
|
718
|
+
description: "Run cf ssh-enabled <app>",
|
|
719
|
+
},
|
|
720
|
+
{
|
|
721
|
+
title: "Enable SSH and restart app",
|
|
722
|
+
value: "enable-ssh",
|
|
723
|
+
description: "Run cf enable-ssh <app> and cf restart <app>",
|
|
724
|
+
},
|
|
725
|
+
],
|
|
726
|
+
allowCustomValue: false,
|
|
727
|
+
}) as Promise<TCloudFoundryDebugMode>;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
async function maybeSwitchCloudFoundryTargetForDebug(options: TCloudFoundryDebugOptions): Promise<void> {
|
|
731
|
+
if (options.skipOrgSelect || options.app?.trim()) {
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const target = await ensureCloudFoundrySessionFromCache();
|
|
736
|
+
|
|
737
|
+
const currentTargetLabel = [
|
|
738
|
+
target.org ? `org: ${target.org}` : "org: N/A",
|
|
739
|
+
target.space ? `space: ${target.space}` : "space: N/A",
|
|
740
|
+
target.apiEndpoint ? inferCloudFoundryRegionFromApiEndpoint(target.apiEndpoint) : "current region",
|
|
741
|
+
].join(" · ");
|
|
742
|
+
|
|
743
|
+
const action = await searchableSelectChoice({
|
|
744
|
+
message: "Select BTP target for debug",
|
|
745
|
+
choices: [
|
|
746
|
+
{ title: `Use current target (${currentTargetLabel})`, value: "current" },
|
|
747
|
+
{ title: "Search org across regions and switch", value: "switch" },
|
|
748
|
+
],
|
|
749
|
+
allowCustomValue: false,
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
if (action === "switch") {
|
|
753
|
+
await runOrgCommand({ switch: true });
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
async function selectDebugInstance(options: TCloudFoundryDebugOptions): Promise<string> {
|
|
758
|
+
if (options.instance?.trim()) {
|
|
759
|
+
return options.instance.trim();
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
return searchableSelectChoice({
|
|
763
|
+
message: "Select app instance index",
|
|
764
|
+
choices: [
|
|
765
|
+
{ title: "0", value: "0" },
|
|
766
|
+
{ title: "1", value: "1" },
|
|
767
|
+
{ title: "2", value: "2" },
|
|
768
|
+
{ title: "3", value: "3" },
|
|
769
|
+
],
|
|
770
|
+
validateCustomValue: (value) => /^\d+$/.test(value.trim()) ? true : "Instance index must be a number",
|
|
771
|
+
customValueTitle: (value) => `Use typed instance index: ${value}`,
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
async function selectDebugPort(options: { value?: string; message: string; defaultPort: number }): Promise<number> {
|
|
776
|
+
if (options.value?.trim()) {
|
|
777
|
+
return parsePositivePort(options.value, options.defaultPort);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const portValue = await searchableSelectChoice({
|
|
781
|
+
message: options.message,
|
|
782
|
+
choices: [
|
|
783
|
+
{ title: `${options.defaultPort} recommended`, value: String(options.defaultPort) },
|
|
784
|
+
{ title: "9230", value: "9230" },
|
|
785
|
+
{ title: "9231", value: "9231" },
|
|
786
|
+
],
|
|
787
|
+
validateCustomValue: (value) => {
|
|
788
|
+
try {
|
|
789
|
+
parsePositivePort(value, options.defaultPort);
|
|
790
|
+
return true;
|
|
791
|
+
} catch (error) {
|
|
792
|
+
return error instanceof Error ? error.message : "Invalid port";
|
|
793
|
+
}
|
|
794
|
+
},
|
|
795
|
+
customValueTitle: (value) => `Use typed port: ${value}`,
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
return parsePositivePort(portValue, options.defaultPort);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function shouldIncludeLogLine(line: string, options: { instance?: string; process?: string }): boolean {
|
|
802
|
+
const trimmedInstance = options.instance?.trim();
|
|
803
|
+
const trimmedProcess = options.process?.trim();
|
|
804
|
+
|
|
805
|
+
if (!trimmedInstance && !trimmedProcess) {
|
|
806
|
+
return true;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const normalizedLine = line.toLowerCase();
|
|
810
|
+
|
|
811
|
+
if (trimmedProcess) {
|
|
812
|
+
const normalizedProcess = trimmedProcess.toLowerCase();
|
|
813
|
+
|
|
814
|
+
if (!normalizedLine.includes(`[app/proc/${normalizedProcess}/`) && !normalizedLine.includes(`[app/${normalizedProcess}/`)) {
|
|
815
|
+
return false;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
if (trimmedInstance) {
|
|
820
|
+
const instancePattern = new RegExp(`\\/(?:${trimmedInstance})\\]`, "i");
|
|
821
|
+
|
|
822
|
+
if (!instancePattern.test(line)) {
|
|
823
|
+
return false;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
return true;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function filterCloudFoundryLogsOutput(output: string, options: { instance?: string; process?: string }): string {
|
|
831
|
+
return output
|
|
832
|
+
.split(/\r?\n/)
|
|
833
|
+
.filter((line) => shouldIncludeLogLine(line, options))
|
|
834
|
+
.join("\n");
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function writeFilteredLogChunk(
|
|
838
|
+
chunk: Buffer,
|
|
839
|
+
options: {
|
|
840
|
+
instance?: string;
|
|
841
|
+
process?: string;
|
|
842
|
+
outputStream?: nodeFs.WriteStream;
|
|
843
|
+
isError?: boolean;
|
|
844
|
+
},
|
|
845
|
+
): void {
|
|
846
|
+
const text = chunk.toString("utf8");
|
|
847
|
+
const filteredText = filterCloudFoundryLogsOutput(text, options);
|
|
848
|
+
|
|
849
|
+
if (!filteredText.trim()) {
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const outputText = filteredText.endsWith("\n") ? filteredText : `${filteredText}\n`;
|
|
854
|
+
|
|
855
|
+
if (options.isError) {
|
|
856
|
+
process.stderr.write(outputText);
|
|
857
|
+
} else {
|
|
858
|
+
process.stdout.write(outputText);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
options.outputStream?.write(outputText);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
async function refreshAppsCacheForCurrentTarget(): Promise<TCloudFoundryApp[]> {
|
|
865
|
+
const target = await readCloudFoundryTarget();
|
|
866
|
+
const targetKey = buildCloudFoundryTargetKey(target);
|
|
867
|
+
const apps = await listCloudFoundryApps();
|
|
868
|
+
await rememberCloudFoundryApps(targetKey, apps);
|
|
869
|
+
return apps;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function refreshAppsCacheInDetachedProcess(): void {
|
|
873
|
+
const entryFilePath = process.argv[1];
|
|
874
|
+
|
|
875
|
+
if (!entryFilePath) {
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
const childProcess = spawn(process.execPath, [entryFilePath, "cf", "apps-cache-refresh"], {
|
|
880
|
+
detached: true,
|
|
881
|
+
stdio: "ignore",
|
|
882
|
+
windowsHide: true,
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
childProcess.unref();
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
async function getAppsWithCache(options: { refresh?: boolean; startBackgroundRefresh?: boolean }): Promise<TCloudFoundryApp[]> {
|
|
889
|
+
await ensureCloudFoundrySessionFromCache();
|
|
890
|
+
|
|
891
|
+
if (options.refresh) {
|
|
892
|
+
return refreshAppsCacheForCurrentTarget();
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
const target = await readCloudFoundryTarget();
|
|
896
|
+
const targetKey = buildCloudFoundryTargetKey(target);
|
|
897
|
+
const cache = await readCache();
|
|
898
|
+
const cachedEntry = cache.cloudFoundry.appListsByTarget[targetKey];
|
|
899
|
+
|
|
900
|
+
if (cachedEntry?.apps.length) {
|
|
901
|
+
if (options.startBackgroundRefresh) {
|
|
902
|
+
refreshAppsCacheInDetachedProcess();
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
return cachedEntry.apps;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
return refreshAppsCacheForCurrentTarget();
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
async function resolveAppSelection(options: { app?: string; refresh?: boolean; message: string }): Promise<string> {
|
|
912
|
+
if (options.app?.trim()) {
|
|
913
|
+
await rememberSelectedApp(options.app.trim());
|
|
914
|
+
return options.app.trim();
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const apps = await getAppsWithCache({ refresh: options.refresh, startBackgroundRefresh: !options.refresh });
|
|
918
|
+
const cache = await readCache();
|
|
919
|
+
const cachedSelectedAppNames = cache.cloudFoundry.selectedApps;
|
|
920
|
+
const cachedSelectedApps = cachedSelectedAppNames
|
|
921
|
+
.filter((appName) => !apps.some((app) => app.name === appName))
|
|
922
|
+
.map((appName) => ({ title: `${appName} ${chalk.gray("cached selected")}`, value: appName }));
|
|
923
|
+
|
|
924
|
+
const appName = await searchableSelectChoice({
|
|
925
|
+
message: options.message,
|
|
926
|
+
choices: [
|
|
927
|
+
...apps.map((app) => ({
|
|
928
|
+
title: [app.name, app.requestedState, app.routes].filter(Boolean).join(" | "),
|
|
929
|
+
value: app.name,
|
|
930
|
+
})),
|
|
931
|
+
...cachedSelectedApps,
|
|
932
|
+
],
|
|
933
|
+
validateCustomValue: validateRequired,
|
|
934
|
+
customValueTitle: (value) => `Use typed app name: ${value}`,
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
await rememberSelectedApp(appName);
|
|
938
|
+
return appName;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function printTarget(target: TCloudFoundryTarget): void {
|
|
942
|
+
console.log(`API Endpoint: ${target.apiEndpoint ?? "N/A"}`);
|
|
943
|
+
console.log(`User: ${target.user ?? "N/A"}`);
|
|
944
|
+
console.log(`Org: ${target.org ?? "N/A"}`);
|
|
945
|
+
console.log(`Space: ${target.space ?? "N/A"}`);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const DEFAULT_CLOUD_FOUNDRY_API_ENDPOINTS = [
|
|
949
|
+
"https://api.cf.br10.hana.ondemand.com",
|
|
950
|
+
"https://api.cf.eu10.hana.ondemand.com",
|
|
951
|
+
"https://api.cf.eu10-004.hana.ondemand.com",
|
|
952
|
+
"https://api.cf.eu10-005.hana.ondemand.com",
|
|
953
|
+
"https://api.cf.eu20.hana.ondemand.com",
|
|
954
|
+
"https://api.cf.eu20-001.hana.ondemand.com",
|
|
955
|
+
"https://api.cf.eu20-002.hana.ondemand.com",
|
|
956
|
+
"https://api.cf.us10.hana.ondemand.com",
|
|
957
|
+
"https://api.cf.us10-001.hana.ondemand.com",
|
|
958
|
+
"https://api.cf.us11.hana.ondemand.com",
|
|
959
|
+
"https://api.cf.us20.hana.ondemand.com",
|
|
960
|
+
"https://api.cf.us21.hana.ondemand.com",
|
|
961
|
+
"https://api.cf.ap10.hana.ondemand.com",
|
|
962
|
+
"https://api.cf.ap11.hana.ondemand.com",
|
|
963
|
+
"https://api.cf.ap20.hana.ondemand.com",
|
|
964
|
+
"https://api.cf.ap21.hana.ondemand.com",
|
|
965
|
+
"https://api.cf.jp10.hana.ondemand.com",
|
|
966
|
+
"https://api.cf.ca10.hana.ondemand.com",
|
|
967
|
+
"https://api.cf.ch20.hana.ondemand.com",
|
|
968
|
+
"https://api.cf.sa10.hana.ondemand.com",
|
|
969
|
+
];
|
|
970
|
+
|
|
971
|
+
function uniqueValues(values: string[]): string[] {
|
|
972
|
+
return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
async function selectCloudFoundryApiEndpoint(options: {
|
|
976
|
+
api?: string;
|
|
977
|
+
cachedApiEndpoints: string[];
|
|
978
|
+
}): Promise<string> {
|
|
979
|
+
if (options.api?.trim()) {
|
|
980
|
+
return options.api.trim();
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const cachedApiEndpoints = uniqueValues(options.cachedApiEndpoints);
|
|
984
|
+
|
|
985
|
+
if (cachedApiEndpoints.length === 1) {
|
|
986
|
+
return cachedApiEndpoints[0];
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const choices = [
|
|
990
|
+
...cachedApiEndpoints.map((apiEndpoint) => ({
|
|
991
|
+
title: `${apiEndpoint} ${chalk.gray("cached")}`,
|
|
992
|
+
value: apiEndpoint,
|
|
993
|
+
})),
|
|
994
|
+
...DEFAULT_CLOUD_FOUNDRY_API_ENDPOINTS
|
|
995
|
+
.filter((apiEndpoint) => !cachedApiEndpoints.includes(apiEndpoint))
|
|
996
|
+
.map((apiEndpoint) => ({ title: apiEndpoint, value: apiEndpoint })),
|
|
997
|
+
{ title: "Enter CF API endpoint manually", value: "__ENTER_MANUAL__" },
|
|
998
|
+
];
|
|
999
|
+
|
|
1000
|
+
return searchableSelectChoice({
|
|
1001
|
+
message: "Select CF API endpoint",
|
|
1002
|
+
choices: choices.filter((choice) => choice.value !== "__ENTER_MANUAL__"),
|
|
1003
|
+
validateCustomValue: validateRequired,
|
|
1004
|
+
customValueTitle: (value) => `Use typed CF API endpoint: ${value}`,
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
async function selectCloudFoundryOrganization(options: {
|
|
1009
|
+
org?: string;
|
|
1010
|
+
cachedOrganizations: string[];
|
|
1011
|
+
}): Promise<string> {
|
|
1012
|
+
if (options.org?.trim()) {
|
|
1013
|
+
return options.org.trim();
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
const organizations = await listCloudFoundryOrganizations();
|
|
1017
|
+
|
|
1018
|
+
if (organizations.length === 0) {
|
|
1019
|
+
return selectFromHistoryOrInput({
|
|
1020
|
+
message: "Select CF org",
|
|
1021
|
+
values: options.cachedOrganizations,
|
|
1022
|
+
initialValue: options.cachedOrganizations[0],
|
|
1023
|
+
validate: validateRequired,
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
return searchableSelectChoice({
|
|
1028
|
+
message: "Select CF org",
|
|
1029
|
+
choices: organizations.map((organization) => ({ title: organization, value: organization })),
|
|
1030
|
+
validateCustomValue: validateRequired,
|
|
1031
|
+
customValueTitle: (value) => `Use typed CF org: ${value}`,
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
async function selectCloudFoundrySpace(options: {
|
|
1036
|
+
space?: string;
|
|
1037
|
+
cachedSpaces: string[];
|
|
1038
|
+
}): Promise<string | undefined> {
|
|
1039
|
+
if (options.space?.trim()) {
|
|
1040
|
+
return options.space.trim();
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
const spaces = await listCloudFoundrySpaces();
|
|
1044
|
+
|
|
1045
|
+
if (spaces.length === 0) {
|
|
1046
|
+
return selectFromHistoryOrInput({
|
|
1047
|
+
message: "Select CF space",
|
|
1048
|
+
values: options.cachedSpaces,
|
|
1049
|
+
initialValue: options.cachedSpaces[0] ?? "app",
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
const initialSpace = spaces.includes("app") ? "app" : spaces[0];
|
|
1054
|
+
|
|
1055
|
+
return searchableSelectChoice({
|
|
1056
|
+
message: "Select CF space",
|
|
1057
|
+
choices: [
|
|
1058
|
+
...spaces
|
|
1059
|
+
.filter((space) => space === initialSpace)
|
|
1060
|
+
.map((space) => ({ title: space, value: space })),
|
|
1061
|
+
...spaces
|
|
1062
|
+
.filter((space) => space !== initialSpace)
|
|
1063
|
+
.map((space) => ({ title: space, value: space })),
|
|
1064
|
+
],
|
|
1065
|
+
validateCustomValue: validateRequired,
|
|
1066
|
+
customValueTitle: (value) => `Use typed CF space: ${value}`,
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
async function runLoginCommand(options: TCloudFoundryLoginOptions): Promise<void> {
|
|
1071
|
+
await ensureExternalTool("cf");
|
|
1072
|
+
const cache = await readCache();
|
|
1073
|
+
const lastProfile = cache.cloudFoundry.loginProfiles[0];
|
|
1074
|
+
const apiEndpoint = await selectCloudFoundryApiEndpoint({
|
|
1075
|
+
api: options.api,
|
|
1076
|
+
cachedApiEndpoints: cache.cloudFoundry.loginProfiles.map((item) => item.apiEndpoint),
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
const username = options.username ?? await selectFromHistoryOrInput({
|
|
1080
|
+
message: "Select CF username",
|
|
1081
|
+
values: cache.cloudFoundry.loginProfiles.map((item) => item.username),
|
|
1082
|
+
initialValue: lastProfile?.username,
|
|
1083
|
+
validate: validateRequired,
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
let password = options.password ?? lastProfile?.password;
|
|
1087
|
+
let shouldSavePassword = options.savePassword ?? false;
|
|
1088
|
+
|
|
1089
|
+
if (!password) {
|
|
1090
|
+
const response = await prompts({
|
|
1091
|
+
type: "password",
|
|
1092
|
+
name: "password",
|
|
1093
|
+
message: "Enter CF password",
|
|
1094
|
+
validate: validateRequired,
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
if (!response.password) {
|
|
1098
|
+
throw new Error("Cancelled");
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
password = response.password as string;
|
|
1102
|
+
} else {
|
|
1103
|
+
const response = await prompts({
|
|
1104
|
+
type: "select",
|
|
1105
|
+
name: "useCachedPassword",
|
|
1106
|
+
message: "Use cached password?",
|
|
1107
|
+
choices: [
|
|
1108
|
+
{ title: "Yes", value: true },
|
|
1109
|
+
{ title: "No, enter password again", value: false },
|
|
1110
|
+
],
|
|
1111
|
+
initial: 0,
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
if (!response.useCachedPassword) {
|
|
1115
|
+
const passwordResponse = await prompts({
|
|
1116
|
+
type: "password",
|
|
1117
|
+
name: "password",
|
|
1118
|
+
message: "Enter CF password",
|
|
1119
|
+
validate: validateRequired,
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
if (!passwordResponse.password) {
|
|
1123
|
+
throw new Error("Cancelled");
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
password = passwordResponse.password as string;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
if (!shouldSavePassword) {
|
|
1131
|
+
const savePasswordResponse = await prompts({
|
|
1132
|
+
type: "select",
|
|
1133
|
+
name: "savePassword",
|
|
1134
|
+
message: "Save password for automatic re-login and region scan?",
|
|
1135
|
+
choices: [
|
|
1136
|
+
{ title: "Yes, save password on this machine", value: true },
|
|
1137
|
+
{ title: "No", value: false },
|
|
1138
|
+
],
|
|
1139
|
+
initial: 0,
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
shouldSavePassword = Boolean(savePasswordResponse.savePassword);
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
const apiExitCode = await setCloudFoundryApiEndpoint(apiEndpoint);
|
|
1146
|
+
|
|
1147
|
+
if (apiExitCode !== 0) {
|
|
1148
|
+
process.exitCode = apiExitCode;
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
const authExitCode = await authenticateCloudFoundry({ username, password });
|
|
1153
|
+
|
|
1154
|
+
if (authExitCode !== 0) {
|
|
1155
|
+
process.exitCode = authExitCode;
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
const org = await selectCloudFoundryOrganization({
|
|
1160
|
+
org: options.org,
|
|
1161
|
+
cachedOrganizations: cache.cloudFoundry.loginProfiles
|
|
1162
|
+
.filter((item) => item.apiEndpoint === apiEndpoint && item.username === username)
|
|
1163
|
+
.map((item) => item.org),
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
const orgExitCode = await targetCloudFoundryOrg(org);
|
|
1167
|
+
|
|
1168
|
+
if (orgExitCode !== 0) {
|
|
1169
|
+
process.exitCode = orgExitCode;
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
const space = await selectCloudFoundrySpace({
|
|
1174
|
+
space: options.space,
|
|
1175
|
+
cachedSpaces: cache.cloudFoundry.loginProfiles
|
|
1176
|
+
.filter((item) => item.apiEndpoint === apiEndpoint && item.username === username && item.org === org)
|
|
1177
|
+
.map((item) => item.space ?? "")
|
|
1178
|
+
.filter(Boolean),
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
if (space) {
|
|
1182
|
+
const spaceExitCode = await targetCloudFoundrySpace(space);
|
|
1183
|
+
|
|
1184
|
+
if (spaceExitCode !== 0) {
|
|
1185
|
+
process.exitCode = spaceExitCode;
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
await rememberCloudFoundryLoginProfile({
|
|
1191
|
+
apiEndpoint,
|
|
1192
|
+
username,
|
|
1193
|
+
org,
|
|
1194
|
+
space,
|
|
1195
|
+
password: shouldSavePassword ? password : undefined,
|
|
1196
|
+
updatedAt: new Date().toISOString(),
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1199
|
+
if (shouldSavePassword) {
|
|
1200
|
+
console.log(chalk.yellow("Password was cached in ~/.simplemdg/cache.json for automatic re-login. Do not use this on shared machines."));
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
console.log(chalk.green("CF login completed."));
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
function formatCloudFoundryOrgEntry(entry: TCloudFoundryOrgEntry, target: TCloudFoundryTarget): string {
|
|
1208
|
+
const isCurrent = entry.apiEndpoint === target.apiEndpoint && entry.org === target.org;
|
|
1209
|
+
const spaceText = typeof entry.spaceCount === "number"
|
|
1210
|
+
? `${entry.spaceCount} ${entry.spaceCount === 1 ? "space" : "spaces"}`
|
|
1211
|
+
: "spaces unknown";
|
|
1212
|
+
return `${entry.org} ${chalk.gray(`${entry.region} · ${spaceText}${isCurrent ? " · current" : ""}`)}`;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
function getCloudFoundryApiEndpointsForOrgSearch(options: { api?: string }, target: TCloudFoundryTarget, cache: Awaited<ReturnType<typeof readCache>>): string[] {
|
|
1216
|
+
return uniqueValues([
|
|
1217
|
+
options.api ?? "",
|
|
1218
|
+
target.apiEndpoint ?? "",
|
|
1219
|
+
...cache.cloudFoundry.loginProfiles.map((item) => item.apiEndpoint),
|
|
1220
|
+
...cache.cloudFoundry.orgsAcrossRegions.map((item) => item.apiEndpoint),
|
|
1221
|
+
...DEFAULT_CLOUD_FOUNDRY_API_ENDPOINTS,
|
|
1222
|
+
]);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
async function getCloudFoundryOrganizationsAcrossRegions(options: { api?: string; refresh?: boolean }): Promise<TCloudFoundryOrgEntry[]> {
|
|
1226
|
+
const target = await readCloudFoundryTarget();
|
|
1227
|
+
const cache = await readCache();
|
|
1228
|
+
const cachedEntries = cache.cloudFoundry.orgsAcrossRegions ?? [];
|
|
1229
|
+
|
|
1230
|
+
const cachedRegionCount = new Set(cachedEntries.map((entry) => entry.region)).size;
|
|
1231
|
+
|
|
1232
|
+
if (!options.refresh && cachedEntries.length && cachedRegionCount > 1) {
|
|
1233
|
+
return cachedEntries.sort((left, right) => {
|
|
1234
|
+
const byOrg = left.org.localeCompare(right.org);
|
|
1235
|
+
return byOrg !== 0 ? byOrg : left.region.localeCompare(right.region);
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
const apiEndpoints = getCloudFoundryApiEndpointsForOrgSearch(options, target, cache);
|
|
1240
|
+
console.log(chalk.gray(`Searching CF orgs across ${apiEndpoints.length} region endpoint(s)...`));
|
|
1241
|
+
const credentials = cache.cloudFoundry.loginProfiles.map((profile) => ({
|
|
1242
|
+
apiEndpoint: profile.apiEndpoint,
|
|
1243
|
+
username: profile.username,
|
|
1244
|
+
password: profile.password,
|
|
1245
|
+
}));
|
|
1246
|
+
const entries = await scanCloudFoundryOrganizationsAcrossRegions(apiEndpoints, credentials);
|
|
1247
|
+
|
|
1248
|
+
if (entries.length) {
|
|
1249
|
+
const regionCount = new Set(entries.map((entry) => entry.region)).size;
|
|
1250
|
+
console.log(chalk.green(`Found ${entries.length} org(s) across ${regionCount} region(s).`));
|
|
1251
|
+
await rememberCloudFoundryOrgEntries(entries);
|
|
1252
|
+
return entries;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
const currentOrganizations = await listCloudFoundryOrganizations().catch(() => []);
|
|
1256
|
+
const currentRegion = target.apiEndpoint ? inferCloudFoundryRegionFromApiEndpoint(target.apiEndpoint) : "current";
|
|
1257
|
+
const fallbackEntries = currentOrganizations.map((organization) => ({
|
|
1258
|
+
apiEndpoint: target.apiEndpoint ?? "",
|
|
1259
|
+
region: currentRegion,
|
|
1260
|
+
org: organization,
|
|
1261
|
+
updatedAt: new Date().toISOString(),
|
|
1262
|
+
}));
|
|
1263
|
+
|
|
1264
|
+
if (fallbackEntries.length) {
|
|
1265
|
+
await rememberCloudFoundryOrgEntries(fallbackEntries);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
return fallbackEntries;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
async function runOrgCommand(options: TCloudFoundryOrgOptions): Promise<void> {
|
|
1272
|
+
const target = await ensureCloudFoundrySessionFromCache();
|
|
1273
|
+
|
|
1274
|
+
const action = options.list
|
|
1275
|
+
? "list"
|
|
1276
|
+
: options.switch
|
|
1277
|
+
? "switch"
|
|
1278
|
+
: await searchableSelectChoice({
|
|
1279
|
+
message: "What do you want to do with CF org?",
|
|
1280
|
+
choices: [
|
|
1281
|
+
{ title: "List orgs across regions", value: "list" },
|
|
1282
|
+
{ title: "Switch org across regions", value: "switch" },
|
|
1283
|
+
],
|
|
1284
|
+
validateCustomValue: (value) => {
|
|
1285
|
+
const normalizedValue = value.trim().toLowerCase();
|
|
1286
|
+
return normalizedValue === "list" || normalizedValue === "switch"
|
|
1287
|
+
? true
|
|
1288
|
+
: "Type list or switch, or select one option.";
|
|
1289
|
+
},
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1292
|
+
const organizationEntries = await getCloudFoundryOrganizationsAcrossRegions({
|
|
1293
|
+
api: options.api,
|
|
1294
|
+
refresh: options.refresh,
|
|
1295
|
+
});
|
|
1296
|
+
const organizationRegionCount = new Set(organizationEntries.map((entry) => entry.region)).size;
|
|
1297
|
+
const latestTarget = await readCloudFoundryTarget();
|
|
1298
|
+
|
|
1299
|
+
if (action === "list") {
|
|
1300
|
+
printTarget(latestTarget);
|
|
1301
|
+
console.log("");
|
|
1302
|
+
|
|
1303
|
+
if (!organizationEntries.length) {
|
|
1304
|
+
console.log(chalk.yellow("No orgs found for current CF user. Run smdg cf login, save the password, then run smdg cf org again."));
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
console.log(chalk.gray(`Showing ${organizationEntries.length} org(s) across ${organizationRegionCount} region(s).`));
|
|
1309
|
+
console.log("");
|
|
1310
|
+
|
|
1311
|
+
for (const entry of organizationEntries) {
|
|
1312
|
+
const marker = entry.apiEndpoint === latestTarget.apiEndpoint && entry.org === latestTarget.org ? chalk.green("*") : " ";
|
|
1313
|
+
console.log(`${marker} ${formatCloudFoundryOrgEntry(entry, latestTarget)}`);
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
let selectedEntry: TCloudFoundryOrgEntry | undefined;
|
|
1320
|
+
|
|
1321
|
+
if (options.org?.trim()) {
|
|
1322
|
+
selectedEntry = organizationEntries.find((entry) => {
|
|
1323
|
+
return entry.org === options.org?.trim() && (!options.api?.trim() || entry.apiEndpoint === options.api.trim());
|
|
1324
|
+
}) ?? {
|
|
1325
|
+
apiEndpoint: options.api?.trim() || latestTarget.apiEndpoint || "",
|
|
1326
|
+
region: options.api?.trim()
|
|
1327
|
+
? inferCloudFoundryRegionFromApiEndpoint(options.api.trim())
|
|
1328
|
+
: inferCloudFoundryRegionFromApiEndpoint(latestTarget.apiEndpoint ?? "current"),
|
|
1329
|
+
org: options.org.trim(),
|
|
1330
|
+
updatedAt: new Date().toISOString(),
|
|
1331
|
+
};
|
|
1332
|
+
} else {
|
|
1333
|
+
if (!organizationEntries.length) {
|
|
1334
|
+
console.log(chalk.yellow("No orgs were found across regions."));
|
|
1335
|
+
console.log(chalk.gray("Run smdg cf login and save the password, then run smdg cf org --list --refresh."));
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
const selectedIndex = await searchableSelectChoice({
|
|
1340
|
+
message: `Search CF org across ${organizationRegionCount} region(s)`,
|
|
1341
|
+
choices: organizationEntries.map((entry, index) => ({
|
|
1342
|
+
title: formatCloudFoundryOrgEntry(entry, latestTarget),
|
|
1343
|
+
value: String(index),
|
|
1344
|
+
})),
|
|
1345
|
+
validateCustomValue: validateRequired,
|
|
1346
|
+
customValueTitle: (value) => `Use typed CF org in current region: ${value}`,
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1349
|
+
selectedEntry = organizationEntries[Number(selectedIndex)] ?? {
|
|
1350
|
+
apiEndpoint: latestTarget.apiEndpoint ?? "",
|
|
1351
|
+
region: inferCloudFoundryRegionFromApiEndpoint(latestTarget.apiEndpoint ?? "current"),
|
|
1352
|
+
org: selectedIndex,
|
|
1353
|
+
updatedAt: new Date().toISOString(),
|
|
1354
|
+
};
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
if (!selectedEntry.apiEndpoint) {
|
|
1358
|
+
throw new Error("Cannot determine CF API endpoint for selected org.");
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
const authenticatedProfile = await ensureCloudFoundryAuthenticatedForApiEndpoint({
|
|
1362
|
+
apiEndpoint: selectedEntry.apiEndpoint,
|
|
1363
|
+
preferredOrg: selectedEntry.org,
|
|
1364
|
+
preferredSpace: options.space,
|
|
1365
|
+
reason: "switch-org",
|
|
1366
|
+
});
|
|
1367
|
+
|
|
1368
|
+
const orgExitCode = await targetCloudFoundryOrg(selectedEntry.org);
|
|
1369
|
+
|
|
1370
|
+
if (orgExitCode !== 0) {
|
|
1371
|
+
console.log(chalk.yellow("Cannot switch to this org after automatic authentication."));
|
|
1372
|
+
console.log(chalk.gray("Run smdg cf login, save the password, then try again."));
|
|
1373
|
+
process.exitCode = orgExitCode;
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
const spaces = selectedEntry.spaces?.length ? selectedEntry.spaces : await listCloudFoundrySpaces();
|
|
1378
|
+
const currentTargetAfterOrgSwitch = await readCloudFoundryTarget();
|
|
1379
|
+
const preferredSpace = options.space?.trim() || currentTargetAfterOrgSwitch.space || (spaces.includes("app") ? "app" : spaces[0]);
|
|
1380
|
+
|
|
1381
|
+
const space = options.space?.trim() || await searchableSelectChoice({
|
|
1382
|
+
message: "Select CF space",
|
|
1383
|
+
choices: [
|
|
1384
|
+
...spaces
|
|
1385
|
+
.filter((spaceName) => spaceName === preferredSpace)
|
|
1386
|
+
.map((spaceName) => ({ title: `${spaceName} ${spaceName === currentTargetAfterOrgSwitch.space ? chalk.gray("current") : chalk.gray("suggested")}`, value: spaceName })),
|
|
1387
|
+
...spaces
|
|
1388
|
+
.filter((spaceName) => spaceName !== preferredSpace)
|
|
1389
|
+
.map((spaceName) => ({ title: spaceName, value: spaceName })),
|
|
1390
|
+
],
|
|
1391
|
+
validateCustomValue: validateRequired,
|
|
1392
|
+
customValueTitle: (value) => `Use typed CF space: ${value}`,
|
|
1393
|
+
});
|
|
1394
|
+
|
|
1395
|
+
if (space) {
|
|
1396
|
+
const spaceExitCode = await targetCloudFoundrySpace(space);
|
|
1397
|
+
|
|
1398
|
+
if (spaceExitCode !== 0) {
|
|
1399
|
+
process.exitCode = spaceExitCode;
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
if (authenticatedProfile?.password) {
|
|
1405
|
+
await rememberCloudFoundryLoginProfile({
|
|
1406
|
+
...authenticatedProfile,
|
|
1407
|
+
apiEndpoint: selectedEntry.apiEndpoint,
|
|
1408
|
+
org: selectedEntry.org,
|
|
1409
|
+
space,
|
|
1410
|
+
updatedAt: new Date().toISOString(),
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
const switchedTarget = await readCloudFoundryTarget();
|
|
1415
|
+
console.log(chalk.green("CF org/space switched."));
|
|
1416
|
+
printTarget(switchedTarget);
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
async function runAppsCommand(options: TCloudFoundryAppsOptions): Promise<void> {
|
|
1420
|
+
const target = await readCloudFoundryTarget();
|
|
1421
|
+
const targetKey = buildCloudFoundryTargetKey(target);
|
|
1422
|
+
const cache = await readCache();
|
|
1423
|
+
const cachedEntry = cache.cloudFoundry.appListsByTarget[targetKey];
|
|
1424
|
+
|
|
1425
|
+
printTarget(target);
|
|
1426
|
+
console.log("");
|
|
1427
|
+
|
|
1428
|
+
const shouldUseCache = !options.refresh && Boolean(cachedEntry?.apps.length);
|
|
1429
|
+
const apps = await getAppsWithCache({
|
|
1430
|
+
refresh: options.refresh,
|
|
1431
|
+
startBackgroundRefresh: shouldUseCache,
|
|
1432
|
+
});
|
|
1433
|
+
|
|
1434
|
+
if (shouldUseCache && cachedEntry) {
|
|
1435
|
+
console.log(chalk.gray(`Using cached cf apps: ${cachedEntry.apps.length} apps, updated at ${cachedEntry.updatedAt}`));
|
|
1436
|
+
console.log(chalk.gray("Refreshing cf apps cache in background. Use --refresh when you want to wait for fresh data."));
|
|
1437
|
+
console.log("");
|
|
1438
|
+
} else if (options.refresh) {
|
|
1439
|
+
console.log(chalk.green(`Refreshed cf apps cache for ${targetKey}.`));
|
|
1440
|
+
console.log("");
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
if (options.select) {
|
|
1444
|
+
const appName = await resolveAppSelection({ message: "Select BTP app", refresh: options.refresh });
|
|
1445
|
+
console.log(appName);
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
for (const app of apps) {
|
|
1450
|
+
console.log([app.name, app.requestedState, app.processes, app.routes].filter(Boolean).join(" | "));
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
async function runAppsCacheRefreshCommand(): Promise<void> {
|
|
1455
|
+
await refreshAppsCacheForCurrentTarget();
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
async function runBindCommand(options: TCloudFoundryBindOptions): Promise<void> {
|
|
1459
|
+
const repositoryPath = await resolveRepositoryPath(options.cwd ?? process.cwd());
|
|
1460
|
+
const appName = await resolveAppSelection({ app: options.app, refresh: options.refresh, message: "Select app to cds bind" });
|
|
1461
|
+
const exitCode = await runCommandInherit("cds", ["bind", "--to-app-services", appName], { cwd: repositoryPath });
|
|
1462
|
+
process.exitCode = exitCode;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
async function runEnvCommand(options: TCloudFoundryEnvOptions): Promise<void> {
|
|
1466
|
+
const repositoryPath = await resolveRepositoryPath(options.cwd ?? process.cwd());
|
|
1467
|
+
const appName = await resolveAppSelection({ app: options.app, refresh: options.refresh, message: "Select app to export cf env" });
|
|
1468
|
+
const cache = await readCache();
|
|
1469
|
+
|
|
1470
|
+
const outputFileName = options.out ?? await selectFromHistoryOrInput({
|
|
1471
|
+
message: "Select output env file name",
|
|
1472
|
+
values: cache.cloudFoundry.envFileNames,
|
|
1473
|
+
initialValue: cache.cloudFoundry.envFileNames[0] ?? "default-env.json",
|
|
1474
|
+
validate: validateRequired,
|
|
1475
|
+
});
|
|
1476
|
+
|
|
1477
|
+
const result = await runCommand("cf", ["env", appName]);
|
|
1478
|
+
|
|
1479
|
+
if (result.exitCode !== 0) {
|
|
1480
|
+
throw new Error(result.stderr || result.stdout || "cf env failed");
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
const outputPath = path.resolve(repositoryPath, outputFileName);
|
|
1484
|
+
|
|
1485
|
+
if (options.raw) {
|
|
1486
|
+
await fs.writeFile(outputPath, result.stdout, "utf8");
|
|
1487
|
+
} else {
|
|
1488
|
+
const parsedEnvironment = parseCloudFoundryEnvironment(result.stdout);
|
|
1489
|
+
await fs.writeJson(outputPath, parsedEnvironment, { spaces: 2 });
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
await rememberSelectedApp(appName);
|
|
1493
|
+
await rememberEnvironmentFileName(outputFileName);
|
|
1494
|
+
|
|
1495
|
+
console.log(chalk.green(`Exported ${options.raw ? "raw env" : "clean JSON env"} to ${outputPath}`));
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
|
|
1499
|
+
async function runLogsCommand(options: TCloudFoundryLogsOptions): Promise<void> {
|
|
1500
|
+
const appName = await resolveAppSelection({ app: options.app, refresh: options.refresh, message: "Select app to view logs" });
|
|
1501
|
+
const shouldFollow = options.follow || !options.recent;
|
|
1502
|
+
const shouldReadRecent = options.recent || !shouldFollow;
|
|
1503
|
+
const outputPath = options.out ? path.resolve(process.cwd(), options.out) : undefined;
|
|
1504
|
+
|
|
1505
|
+
if (outputPath) {
|
|
1506
|
+
await fs.ensureDir(path.dirname(outputPath));
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
if (shouldReadRecent && !shouldFollow) {
|
|
1510
|
+
const result = await runCommand("cf", buildCloudFoundryLogsArgs({ appName, recent: true }));
|
|
1511
|
+
const combinedOutput = [result.stdout, result.stderr].filter(Boolean).join("\n");
|
|
1512
|
+
const filteredOutput = filterCloudFoundryLogsOutput(combinedOutput, {
|
|
1513
|
+
instance: options.instance,
|
|
1514
|
+
process: options.process,
|
|
1515
|
+
});
|
|
1516
|
+
|
|
1517
|
+
if (outputPath) {
|
|
1518
|
+
await fs.writeFile(outputPath, filteredOutput.endsWith("\n") ? filteredOutput : `${filteredOutput}\n`, "utf8");
|
|
1519
|
+
console.log(chalk.green(`Exported recent logs to ${outputPath}`));
|
|
1520
|
+
} else {
|
|
1521
|
+
console.log(filteredOutput);
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
process.exitCode = result.exitCode;
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
const outputStream = outputPath ? nodeFs.createWriteStream(outputPath, { flags: "a" }) : undefined;
|
|
1529
|
+
|
|
1530
|
+
if (outputPath) {
|
|
1531
|
+
console.log(chalk.gray(`Streaming logs and appending to ${outputPath}`));
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
console.log(chalk.gray("Press Ctrl+C to stop realtime logs."));
|
|
1535
|
+
|
|
1536
|
+
const childProcess = spawn("cf", buildCloudFoundryLogsArgs({ appName, recent: false }), {
|
|
1537
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1538
|
+
shell: false,
|
|
1539
|
+
windowsHide: true,
|
|
1540
|
+
});
|
|
1541
|
+
|
|
1542
|
+
childProcess.stdout?.on("data", (chunk: Buffer) => {
|
|
1543
|
+
writeFilteredLogChunk(chunk, {
|
|
1544
|
+
instance: options.instance,
|
|
1545
|
+
process: options.process,
|
|
1546
|
+
outputStream,
|
|
1547
|
+
});
|
|
1548
|
+
});
|
|
1549
|
+
|
|
1550
|
+
childProcess.stderr?.on("data", (chunk: Buffer) => {
|
|
1551
|
+
writeFilteredLogChunk(chunk, {
|
|
1552
|
+
instance: options.instance,
|
|
1553
|
+
process: options.process,
|
|
1554
|
+
outputStream,
|
|
1555
|
+
isError: true,
|
|
1556
|
+
});
|
|
1557
|
+
});
|
|
1558
|
+
|
|
1559
|
+
childProcess.on("close", (exitCode) => {
|
|
1560
|
+
outputStream?.end();
|
|
1561
|
+
process.exitCode = exitCode ?? 0;
|
|
1562
|
+
});
|
|
1563
|
+
|
|
1564
|
+
await new Promise<void>((resolve) => {
|
|
1565
|
+
childProcess.on("close", () => resolve());
|
|
1566
|
+
});
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
|
|
1570
|
+
|
|
1571
|
+
type TInspectorProtocolMessage = {
|
|
1572
|
+
id?: number;
|
|
1573
|
+
method?: string;
|
|
1574
|
+
params?: unknown;
|
|
1575
|
+
result?: unknown;
|
|
1576
|
+
error?: unknown;
|
|
1577
|
+
};
|
|
1578
|
+
|
|
1579
|
+
type TInspectorWebSocketInfo = {
|
|
1580
|
+
host: string;
|
|
1581
|
+
port: number;
|
|
1582
|
+
path: string;
|
|
1583
|
+
};
|
|
1584
|
+
|
|
1585
|
+
function parseWebSocketUrl(value: string): TInspectorWebSocketInfo {
|
|
1586
|
+
const url = new URL(value);
|
|
1587
|
+
return {
|
|
1588
|
+
host: url.hostname,
|
|
1589
|
+
port: Number(url.port || 80),
|
|
1590
|
+
path: `${url.pathname}${url.search}`,
|
|
1591
|
+
};
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
async function getNodeInspectorWebSocketUrl(localPort: number): Promise<string | undefined> {
|
|
1595
|
+
const response = await fetch(`http://127.0.0.1:${localPort}/json/list`);
|
|
1596
|
+
|
|
1597
|
+
if (!response.ok) {
|
|
1598
|
+
return undefined;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
const targets = await response.json() as Array<{ webSocketDebuggerUrl?: string }>;
|
|
1602
|
+
return targets.find((target) => target.webSocketDebuggerUrl)?.webSocketDebuggerUrl;
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
async function waitForNodeInspectorWebSocketUrl(localPort: number, timeoutMs = 15000): Promise<string | undefined> {
|
|
1606
|
+
const startedAt = Date.now();
|
|
1607
|
+
let lastError: unknown;
|
|
1608
|
+
|
|
1609
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
1610
|
+
try {
|
|
1611
|
+
const webSocketUrl = await getNodeInspectorWebSocketUrl(localPort);
|
|
1612
|
+
|
|
1613
|
+
if (webSocketUrl) {
|
|
1614
|
+
return webSocketUrl;
|
|
1615
|
+
}
|
|
1616
|
+
} catch (error) {
|
|
1617
|
+
lastError = error;
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
if (lastError instanceof Error) {
|
|
1624
|
+
console.log(chalk.gray(`Could not read inspector WebSocket yet: ${lastError.message}`));
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
return undefined;
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
function encodeWebSocketFrame(payload: string): Buffer {
|
|
1631
|
+
const payloadBuffer = Buffer.from(payload, "utf8");
|
|
1632
|
+
const maskKey = crypto.randomBytes(4);
|
|
1633
|
+
let header: Buffer;
|
|
1634
|
+
|
|
1635
|
+
if (payloadBuffer.length < 126) {
|
|
1636
|
+
header = Buffer.alloc(2);
|
|
1637
|
+
header[0] = 0x81;
|
|
1638
|
+
header[1] = 0x80 | payloadBuffer.length;
|
|
1639
|
+
} else if (payloadBuffer.length <= 0xffff) {
|
|
1640
|
+
header = Buffer.alloc(4);
|
|
1641
|
+
header[0] = 0x81;
|
|
1642
|
+
header[1] = 0x80 | 126;
|
|
1643
|
+
header.writeUInt16BE(payloadBuffer.length, 2);
|
|
1644
|
+
} else {
|
|
1645
|
+
header = Buffer.alloc(10);
|
|
1646
|
+
header[0] = 0x81;
|
|
1647
|
+
header[1] = 0x80 | 127;
|
|
1648
|
+
header.writeBigUInt64BE(BigInt(payloadBuffer.length), 2);
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
const maskedPayload = Buffer.alloc(payloadBuffer.length);
|
|
1652
|
+
|
|
1653
|
+
for (let index = 0; index < payloadBuffer.length; index += 1) {
|
|
1654
|
+
maskedPayload[index] = payloadBuffer[index] ^ maskKey[index % 4];
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
return Buffer.concat([header, maskKey, maskedPayload]);
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
function decodeWebSocketFrames(buffer: Buffer): { messages: string[]; remaining: Buffer } {
|
|
1661
|
+
const messages: string[] = [];
|
|
1662
|
+
let offset = 0;
|
|
1663
|
+
|
|
1664
|
+
while (offset + 2 <= buffer.length) {
|
|
1665
|
+
const firstByte = buffer[offset];
|
|
1666
|
+
const secondByte = buffer[offset + 1];
|
|
1667
|
+
const opcode = firstByte & 0x0f;
|
|
1668
|
+
const isMasked = Boolean(secondByte & 0x80);
|
|
1669
|
+
let payloadLength = secondByte & 0x7f;
|
|
1670
|
+
let headerLength = 2;
|
|
1671
|
+
|
|
1672
|
+
if (payloadLength === 126) {
|
|
1673
|
+
if (offset + 4 > buffer.length) break;
|
|
1674
|
+
payloadLength = buffer.readUInt16BE(offset + 2);
|
|
1675
|
+
headerLength = 4;
|
|
1676
|
+
} else if (payloadLength === 127) {
|
|
1677
|
+
if (offset + 10 > buffer.length) break;
|
|
1678
|
+
const longLength = buffer.readBigUInt64BE(offset + 2);
|
|
1679
|
+
|
|
1680
|
+
if (longLength > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
1681
|
+
throw new Error("WebSocket frame is too large");
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
payloadLength = Number(longLength);
|
|
1685
|
+
headerLength = 10;
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
const maskLength = isMasked ? 4 : 0;
|
|
1689
|
+
const frameLength = headerLength + maskLength + payloadLength;
|
|
1690
|
+
|
|
1691
|
+
if (offset + frameLength > buffer.length) {
|
|
1692
|
+
break;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
let payload = buffer.subarray(offset + headerLength + maskLength, offset + frameLength);
|
|
1696
|
+
|
|
1697
|
+
if (isMasked) {
|
|
1698
|
+
const maskKey = buffer.subarray(offset + headerLength, offset + headerLength + 4);
|
|
1699
|
+
const unmaskedPayload = Buffer.alloc(payload.length);
|
|
1700
|
+
|
|
1701
|
+
for (let index = 0; index < payload.length; index += 1) {
|
|
1702
|
+
unmaskedPayload[index] = payload[index] ^ maskKey[index % 4];
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
payload = unmaskedPayload;
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
if (opcode === 0x1) {
|
|
1709
|
+
messages.push(payload.toString("utf8"));
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
offset += frameLength;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
return { messages, remaining: buffer.subarray(offset) };
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
async function sendInspectorEvaluateCommand(options: {
|
|
1719
|
+
webSocketUrl: string;
|
|
1720
|
+
expression: string;
|
|
1721
|
+
timeoutMs?: number;
|
|
1722
|
+
}): Promise<void> {
|
|
1723
|
+
const connection = parseWebSocketUrl(options.webSocketUrl);
|
|
1724
|
+
const timeoutMs = options.timeoutMs ?? 10000;
|
|
1725
|
+
const key = crypto.randomBytes(16).toString("base64");
|
|
1726
|
+
const request = [
|
|
1727
|
+
`GET ${connection.path} HTTP/1.1`,
|
|
1728
|
+
`Host: ${connection.host}:${connection.port}`,
|
|
1729
|
+
"Upgrade: websocket",
|
|
1730
|
+
"Connection: Upgrade",
|
|
1731
|
+
`Sec-WebSocket-Key: ${key}`,
|
|
1732
|
+
"Sec-WebSocket-Version: 13",
|
|
1733
|
+
"",
|
|
1734
|
+
"",
|
|
1735
|
+
].join("\r\n");
|
|
1736
|
+
|
|
1737
|
+
await new Promise<void>((resolve, reject) => {
|
|
1738
|
+
const socket = net.createConnection({ host: connection.host, port: connection.port });
|
|
1739
|
+
const commandId = 1;
|
|
1740
|
+
let isHandshakeComplete = false;
|
|
1741
|
+
let handshakeBuffer = Buffer.alloc(0);
|
|
1742
|
+
let frameBuffer = Buffer.alloc(0);
|
|
1743
|
+
const timer = setTimeout(() => {
|
|
1744
|
+
socket.destroy();
|
|
1745
|
+
reject(new Error("Inspector Runtime.evaluate timed out"));
|
|
1746
|
+
}, timeoutMs);
|
|
1747
|
+
|
|
1748
|
+
const cleanup = (): void => {
|
|
1749
|
+
clearTimeout(timer);
|
|
1750
|
+
socket.removeAllListeners();
|
|
1751
|
+
socket.end();
|
|
1752
|
+
socket.destroy();
|
|
1753
|
+
};
|
|
1754
|
+
|
|
1755
|
+
socket.on("connect", () => {
|
|
1756
|
+
socket.write(request);
|
|
1757
|
+
});
|
|
1758
|
+
|
|
1759
|
+
socket.on("error", (error) => {
|
|
1760
|
+
cleanup();
|
|
1761
|
+
reject(error);
|
|
1762
|
+
});
|
|
1763
|
+
|
|
1764
|
+
socket.on("data", (chunk: Buffer) => {
|
|
1765
|
+
try {
|
|
1766
|
+
if (!isHandshakeComplete) {
|
|
1767
|
+
handshakeBuffer = Buffer.concat([handshakeBuffer, chunk]);
|
|
1768
|
+
const headerEndIndex = handshakeBuffer.indexOf("\r\n\r\n");
|
|
1769
|
+
|
|
1770
|
+
if (headerEndIndex < 0) {
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
const headerText = handshakeBuffer.subarray(0, headerEndIndex).toString("utf8");
|
|
1775
|
+
|
|
1776
|
+
if (!/^HTTP\/1\.1 101/i.test(headerText)) {
|
|
1777
|
+
throw new Error(`Inspector WebSocket upgrade failed: ${headerText.split("\r\n")[0]}`);
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
isHandshakeComplete = true;
|
|
1781
|
+
const rest = handshakeBuffer.subarray(headerEndIndex + 4);
|
|
1782
|
+
frameBuffer = rest.length ? Buffer.concat([frameBuffer, rest]) : frameBuffer;
|
|
1783
|
+
|
|
1784
|
+
const payload = JSON.stringify({
|
|
1785
|
+
id: commandId,
|
|
1786
|
+
method: "Runtime.evaluate",
|
|
1787
|
+
params: {
|
|
1788
|
+
expression: options.expression,
|
|
1789
|
+
awaitPromise: false,
|
|
1790
|
+
returnByValue: true,
|
|
1791
|
+
},
|
|
1792
|
+
});
|
|
1793
|
+
socket.write(encodeWebSocketFrame(payload));
|
|
1794
|
+
} else {
|
|
1795
|
+
frameBuffer = Buffer.concat([frameBuffer, chunk]);
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
const decoded = decodeWebSocketFrames(frameBuffer);
|
|
1799
|
+
frameBuffer = Buffer.from(decoded.remaining);
|
|
1800
|
+
|
|
1801
|
+
for (const message of decoded.messages) {
|
|
1802
|
+
const parsed = JSON.parse(message) as TInspectorProtocolMessage;
|
|
1803
|
+
|
|
1804
|
+
if (parsed.id === commandId) {
|
|
1805
|
+
cleanup();
|
|
1806
|
+
|
|
1807
|
+
if (parsed.error) {
|
|
1808
|
+
reject(new Error(`Inspector Runtime.evaluate failed: ${JSON.stringify(parsed.error)}`));
|
|
1809
|
+
return;
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
resolve();
|
|
1813
|
+
return;
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
} catch (error) {
|
|
1817
|
+
cleanup();
|
|
1818
|
+
reject(error);
|
|
1819
|
+
}
|
|
1820
|
+
});
|
|
1821
|
+
});
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
|
|
1825
|
+
type TParsedHttpWatchEvent = {
|
|
1826
|
+
source: "APP" | "RTR";
|
|
1827
|
+
method?: string;
|
|
1828
|
+
url?: string;
|
|
1829
|
+
status?: string | number;
|
|
1830
|
+
durationMs?: number;
|
|
1831
|
+
requestId?: string;
|
|
1832
|
+
correlationId?: string;
|
|
1833
|
+
instance?: string;
|
|
1834
|
+
user?: string;
|
|
1835
|
+
tenant?: string;
|
|
1836
|
+
userAgent?: string;
|
|
1837
|
+
contentLength?: string;
|
|
1838
|
+
requestBytes?: string;
|
|
1839
|
+
responseBytes?: string;
|
|
1840
|
+
authorization?: string;
|
|
1841
|
+
message?: string;
|
|
1842
|
+
};
|
|
1843
|
+
|
|
1844
|
+
function extractJsonFromCloudFoundryLogLine(line: string): Record<string, unknown> | undefined {
|
|
1845
|
+
const jsonStart = line.indexOf("{");
|
|
1846
|
+
|
|
1847
|
+
if (jsonStart < 0) return undefined;
|
|
1848
|
+
|
|
1849
|
+
try {
|
|
1850
|
+
return JSON.parse(line.slice(jsonStart)) as Record<string, unknown>;
|
|
1851
|
+
} catch {
|
|
1852
|
+
return undefined;
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
function parseHttpWatchAppLine(line: string): TParsedHttpWatchEvent | undefined {
|
|
1857
|
+
if (!line.includes("[APP/") || !line.includes("OUT")) return undefined;
|
|
1858
|
+
|
|
1859
|
+
const payload = extractJsonFromCloudFoundryLogLine(line);
|
|
1860
|
+
if (!payload) return undefined;
|
|
1861
|
+
|
|
1862
|
+
const msg = String(payload.msg ?? "");
|
|
1863
|
+
const methodMatch = msg.match(/\b(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+([^\s{]+)/);
|
|
1864
|
+
|
|
1865
|
+
if (!methodMatch) return undefined;
|
|
1866
|
+
|
|
1867
|
+
return {
|
|
1868
|
+
source: "APP",
|
|
1869
|
+
method: methodMatch[1],
|
|
1870
|
+
url: methodMatch[2],
|
|
1871
|
+
requestId: String(payload.request_id ?? payload.x_vcap_request_id ?? payload.x_request_id ?? ""),
|
|
1872
|
+
correlationId: String(payload.correlation_id ?? payload.x_correlationid ?? payload.x_correlation_id ?? ""),
|
|
1873
|
+
instance: String(payload.x_cf_instanceindex ?? payload.component_instance ?? ""),
|
|
1874
|
+
user: String(payload.remote_user ?? ""),
|
|
1875
|
+
tenant: String(payload.tenant_subdomain ?? payload.tenantid ?? payload.tenant_id ?? ""),
|
|
1876
|
+
userAgent: String(payload.user_agent ?? ""),
|
|
1877
|
+
contentLength: String(payload.content_length ?? payload.request_size_b ?? ""),
|
|
1878
|
+
authorization: String(payload.authorization ?? ""),
|
|
1879
|
+
message: msg,
|
|
1880
|
+
};
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
function parseKeyValueFromRouterLine(line: string, key: string): string | undefined {
|
|
1884
|
+
const regex = new RegExp(`${key}:"([^"]*)"`);
|
|
1885
|
+
return line.match(regex)?.[1];
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
function parseHttpWatchRouterLine(line: string): TParsedHttpWatchEvent | undefined {
|
|
1889
|
+
if (!line.includes("[RTR/") || !line.includes("HTTP/")) return undefined;
|
|
1890
|
+
|
|
1891
|
+
const requestMatch = line.match(/"(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+([^\s]+)\s+HTTP\/[^"]+"\s+(\d{3})\s+(\d+)\s+(\d+)/);
|
|
1892
|
+
|
|
1893
|
+
if (!requestMatch) return undefined;
|
|
1894
|
+
|
|
1895
|
+
const responseTimeSeconds = Number(line.match(/response_time:([0-9.]+)/)?.[1] ?? "");
|
|
1896
|
+
|
|
1897
|
+
return {
|
|
1898
|
+
source: "RTR",
|
|
1899
|
+
method: requestMatch[1],
|
|
1900
|
+
url: requestMatch[2],
|
|
1901
|
+
status: requestMatch[3],
|
|
1902
|
+
requestBytes: requestMatch[4],
|
|
1903
|
+
responseBytes: requestMatch[5],
|
|
1904
|
+
durationMs: Number.isFinite(responseTimeSeconds) ? Math.round(responseTimeSeconds * 1000) : undefined,
|
|
1905
|
+
requestId: parseKeyValueFromRouterLine(line, "vcap_request_id"),
|
|
1906
|
+
correlationId: parseKeyValueFromRouterLine(line, "x_correlationid"),
|
|
1907
|
+
instance: line.match(/app_index:"([^"]*)"/)?.[1],
|
|
1908
|
+
tenant: parseKeyValueFromRouterLine(line, "tenantid"),
|
|
1909
|
+
userAgent: line.match(/"\s+"([^"]*)"\s+"[^\"]+:\d+"/)?.[1],
|
|
1910
|
+
};
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
function parseHttpWatchLine(line: string): TParsedHttpWatchEvent | undefined {
|
|
1914
|
+
return parseHttpWatchAppLine(line) ?? parseHttpWatchRouterLine(line);
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
function formatHttpWatchEvent(appName: string, event: TParsedHttpWatchEvent): string {
|
|
1918
|
+
const status = event.status ? chalk.green(String(event.status)) : chalk.gray("APP");
|
|
1919
|
+
const duration = event.durationMs !== undefined ? chalk.gray(`${event.durationMs}ms`) : "";
|
|
1920
|
+
const source = event.source === "RTR" ? chalk.magenta("RTR") : chalk.blue("APP");
|
|
1921
|
+
const requestId = event.requestId ? chalk.gray(` req=${event.requestId}`) : "";
|
|
1922
|
+
const instance = event.instance ? chalk.gray(` i=${event.instance}`) : "";
|
|
1923
|
+
const user = event.user ? chalk.gray(` user=${event.user}`) : "";
|
|
1924
|
+
const tenant = event.tenant ? chalk.gray(` tenant=${event.tenant}`) : "";
|
|
1925
|
+
const size = event.contentLength || event.requestBytes ? chalk.gray(` bytes=${event.contentLength || event.requestBytes}`) : "";
|
|
1926
|
+
const auth = event.authorization ? chalk.gray(` auth=${event.authorization}`) : "";
|
|
1927
|
+
|
|
1928
|
+
return `${source} ${chalk.cyan(`[${appName}]`)} ${status} ${chalk.bold(event.method ?? "")} ${event.url ?? ""} ${duration}${instance}${user}${tenant}${size}${auth}${requestId}`.trim();
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
function printHttpWatchLine(appName: string, line: string, outputFile?: string): void {
|
|
1932
|
+
const event = parseHttpWatchLine(line);
|
|
1933
|
+
|
|
1934
|
+
if (!event) return;
|
|
1935
|
+
|
|
1936
|
+
const formatted = formatHttpWatchEvent(appName, event);
|
|
1937
|
+
console.log(formatted);
|
|
1938
|
+
|
|
1939
|
+
if (outputFile) {
|
|
1940
|
+
const plain = formatted.replace(/\u001b\[[0-9;]*m/g, "");
|
|
1941
|
+
fs.appendFileSync(outputFile, `${plain}\n`, "utf8");
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
async function resolveHttpWatchApps(options: { app?: string; refresh?: boolean }): Promise<string[]> {
|
|
1946
|
+
if (options.app?.trim()) {
|
|
1947
|
+
return uniqueValues(options.app.split(","));
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
return resolveRequestTraceApps({ app: options.app, refresh: options.refresh });
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
async function runHttpWatchForApps(options: { appNames: string[]; recent?: boolean; out?: string }): Promise<void> {
|
|
1954
|
+
if (!options.appNames.length) throw new Error("No app selected for HTTP watch");
|
|
1955
|
+
|
|
1956
|
+
if (options.out) {
|
|
1957
|
+
await fs.ensureDir(path.dirname(path.resolve(options.out)));
|
|
1958
|
+
await fs.writeFile(options.out, "", "utf8");
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
if (options.recent) {
|
|
1962
|
+
for (const appName of options.appNames) {
|
|
1963
|
+
const result = await runCommand("cf", ["logs", appName, "--recent"]);
|
|
1964
|
+
const text = `${result.stdout}\n${result.stderr}`;
|
|
1965
|
+
for (const line of text.split(/\r?\n/)) {
|
|
1966
|
+
printHttpWatchLine(appName, line, options.out);
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
return;
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
const children: ChildProcess[] = [];
|
|
1973
|
+
const stopAll = (): void => {
|
|
1974
|
+
for (const child of children) {
|
|
1975
|
+
if (!child.killed) child.kill();
|
|
1976
|
+
}
|
|
1977
|
+
};
|
|
1978
|
+
|
|
1979
|
+
process.once("SIGINT", () => {
|
|
1980
|
+
console.log(chalk.gray("\nStopping HTTP watch..."));
|
|
1981
|
+
stopAll();
|
|
1982
|
+
process.exit(0);
|
|
1983
|
+
});
|
|
1984
|
+
|
|
1985
|
+
for (const appName of options.appNames) {
|
|
1986
|
+
const child = spawn("cf", ["logs", appName], {
|
|
1987
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1988
|
+
shell: false,
|
|
1989
|
+
windowsHide: true,
|
|
1990
|
+
});
|
|
1991
|
+
children.push(child);
|
|
1992
|
+
|
|
1993
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
1994
|
+
for (const line of chunk.toString("utf8").split(/\r?\n/)) {
|
|
1995
|
+
printHttpWatchLine(appName, line, options.out);
|
|
1996
|
+
}
|
|
1997
|
+
});
|
|
1998
|
+
|
|
1999
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
2000
|
+
for (const line of chunk.toString("utf8").split(/\r?\n/)) {
|
|
2001
|
+
printHttpWatchLine(appName, line, options.out);
|
|
2002
|
+
}
|
|
2003
|
+
});
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
console.log(chalk.green(`HTTP watch is watching ${options.appNames.length} app(s).`));
|
|
2007
|
+
console.log(chalk.gray("This uses existing CF/CDS/RTR logs. It shows method/path/status/user/tenant/size, but not full request body or full token."));
|
|
2008
|
+
console.log(chalk.gray("Press Ctrl+C to stop."));
|
|
2009
|
+
|
|
2010
|
+
await new Promise<void>((resolve) => {
|
|
2011
|
+
let closedCount = 0;
|
|
2012
|
+
for (const child of children) {
|
|
2013
|
+
child.on("close", () => {
|
|
2014
|
+
closedCount += 1;
|
|
2015
|
+
if (closedCount >= children.length) resolve();
|
|
2016
|
+
});
|
|
2017
|
+
}
|
|
2018
|
+
});
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
async function runHttpWatchCommand(options: TCloudFoundryHttpWatchOptions): Promise<void> {
|
|
2022
|
+
if (!options.skipOrgSelect) {
|
|
2023
|
+
await maybeSwitchCloudFoundryTargetForDebug({ app: options.app, refresh: options.refresh, skipOrgSelect: false });
|
|
2024
|
+
}
|
|
2025
|
+
await ensureCloudFoundrySessionFromCache();
|
|
2026
|
+
|
|
2027
|
+
const appNames = await resolveHttpWatchApps(options);
|
|
2028
|
+
await runHttpWatchForApps({ appNames, recent: options.recent, out: options.out });
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
async function runRequestTraceDoctorCommand(options: TCloudFoundryRequestTraceOptions): Promise<void> {
|
|
2032
|
+
await maybeSwitchCloudFoundryTargetForDebug({
|
|
2033
|
+
app: options.app,
|
|
2034
|
+
refresh: options.refresh,
|
|
2035
|
+
instance: options.instance,
|
|
2036
|
+
process: options.process,
|
|
2037
|
+
localPort: options.localPort,
|
|
2038
|
+
remotePort: options.remotePort,
|
|
2039
|
+
skipOrgSelect: options.skipOrgSelect,
|
|
2040
|
+
});
|
|
2041
|
+
await ensureCloudFoundrySessionFromCache();
|
|
2042
|
+
|
|
2043
|
+
const appNames = await resolveRequestTraceApps({ app: options.app, refresh: options.refresh });
|
|
2044
|
+
const instanceIndex = await selectDebugInstance({ instance: options.instance });
|
|
2045
|
+
|
|
2046
|
+
for (const appName of appNames) {
|
|
2047
|
+
console.log(chalk.cyan(`\nRequest trace doctor for ${appName} instance ${instanceIndex}`));
|
|
2048
|
+
console.log(chalk.gray("Recent router/app HTTP traffic:"));
|
|
2049
|
+
const result = await runCommand("cf", ["logs", appName, "--recent"]);
|
|
2050
|
+
const text = `${result.stdout}\n${result.stderr}`;
|
|
2051
|
+
let count = 0;
|
|
2052
|
+
for (const line of text.split(/\r?\n/)) {
|
|
2053
|
+
const event = parseHttpWatchLine(line);
|
|
2054
|
+
if (event) {
|
|
2055
|
+
count += 1;
|
|
2056
|
+
console.log(formatHttpWatchEvent(appName, event));
|
|
2057
|
+
if (count >= 10) break;
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
if (!count) {
|
|
2061
|
+
console.log(chalk.yellow("No recent HTTP traffic found in CF logs for this app."));
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
console.log(chalk.gray("\nRemote process list:"));
|
|
2065
|
+
const processList = await runCommand("cf", ["ssh", appName, "-i", instanceIndex, "-T", "-c", "ps -eo pid,args 2>/dev/null | head -n 40"]);
|
|
2066
|
+
if (processList.stdout) console.log(processList.stdout);
|
|
2067
|
+
if (processList.stderr) console.error(processList.stderr);
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
console.log(chalk.yellow("\nDoctor summary:"));
|
|
2071
|
+
console.log("- If HTTP traffic appears above, the app is receiving requests.");
|
|
2072
|
+
console.log("- Full body/token are not available from CF/CDS logs because they are intentionally omitted or masked.");
|
|
2073
|
+
console.log("- Use smdg cf http-watch for stable live tracking.");
|
|
2074
|
+
console.log("- Use deep request-trace only when you accept Inspector/preload limitations in dev/test.");
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
function buildRequestTraceInjectionExpression(options: {
|
|
2078
|
+
appName: string;
|
|
2079
|
+
mode: TRequestTraceMode;
|
|
2080
|
+
authMode: TRequestTraceAuthMode;
|
|
2081
|
+
maxBodyBytes: number;
|
|
2082
|
+
parseBodyJson: boolean;
|
|
2083
|
+
}): string {
|
|
2084
|
+
const traceOptions = JSON.stringify(options);
|
|
2085
|
+
const source = `(() => {
|
|
2086
|
+
const options = ${traceOptions};
|
|
2087
|
+
const globalKey = "__SMDG_NETWORK_SPY__";
|
|
2088
|
+
|
|
2089
|
+
const state = globalThis[globalKey] || {
|
|
2090
|
+
installed: false,
|
|
2091
|
+
requestSeq: 0,
|
|
2092
|
+
options,
|
|
2093
|
+
patchedRequests: new WeakSet(),
|
|
2094
|
+
patchedResponses: new WeakSet(),
|
|
2095
|
+
patchedServers: new WeakSet(),
|
|
2096
|
+
activeRequests: new WeakMap(),
|
|
2097
|
+
};
|
|
2098
|
+
|
|
2099
|
+
state.options = options;
|
|
2100
|
+
globalThis[globalKey] = state;
|
|
2101
|
+
|
|
2102
|
+
function write(event) {
|
|
2103
|
+
try {
|
|
2104
|
+
console.log("SMDG_REQUEST_TRACE " + JSON.stringify(event));
|
|
2105
|
+
} catch (error) {
|
|
2106
|
+
console.log("SMDG_REQUEST_TRACE " + JSON.stringify({
|
|
2107
|
+
type: "smdg-request-trace-error",
|
|
2108
|
+
app: options.appName,
|
|
2109
|
+
message: error && error.message ? error.message : String(error),
|
|
2110
|
+
}));
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
function currentOptions() {
|
|
2115
|
+
return globalThis[globalKey] && globalThis[globalKey].options ? globalThis[globalKey].options : options;
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
function shouldCaptureBody() {
|
|
2119
|
+
const mode = currentOptions().mode;
|
|
2120
|
+
return mode === "body" || mode === "response";
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
function shouldCaptureResponse() {
|
|
2124
|
+
return currentOptions().mode === "response";
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
function maxBodyBytes() {
|
|
2128
|
+
return Number(currentOptions().maxBodyBytes || 20000);
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
function maskAuthorization(value) {
|
|
2132
|
+
if (!value) return undefined;
|
|
2133
|
+
const authMode = currentOptions().authMode;
|
|
2134
|
+
if (authMode === "omit") return undefined;
|
|
2135
|
+
if (authMode === "full") return String(value);
|
|
2136
|
+
const text = String(value);
|
|
2137
|
+
return text.length <= 24 ? "***" : text.slice(0, 16) + "..." + text.slice(-8);
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
function normalizeHeaders(headers) {
|
|
2141
|
+
if (currentOptions().mode === "path") return undefined;
|
|
2142
|
+
const output = {};
|
|
2143
|
+
for (const [key, value] of Object.entries(headers || {})) {
|
|
2144
|
+
const lower = key.toLowerCase();
|
|
2145
|
+
if (lower === "authorization") {
|
|
2146
|
+
const auth = maskAuthorization(value);
|
|
2147
|
+
if (auth !== undefined) output[key] = auth;
|
|
2148
|
+
continue;
|
|
2149
|
+
}
|
|
2150
|
+
if (lower === "cookie" || lower === "set-cookie") {
|
|
2151
|
+
output[key] = "***";
|
|
2152
|
+
continue;
|
|
2153
|
+
}
|
|
2154
|
+
output[key] = value;
|
|
2155
|
+
}
|
|
2156
|
+
return output;
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
function appendChunk(record, chunk) {
|
|
2160
|
+
if (!chunk || !shouldCaptureBody()) return;
|
|
2161
|
+
try {
|
|
2162
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
2163
|
+
record.requestBytes += buffer.length;
|
|
2164
|
+
const currentBytes = record.requestChunks.reduce((sum, item) => sum + item.length, 0);
|
|
2165
|
+
const limit = maxBodyBytes();
|
|
2166
|
+
if (currentBytes < limit) {
|
|
2167
|
+
record.requestChunks.push(buffer.subarray(0, Math.max(0, limit - currentBytes)));
|
|
2168
|
+
}
|
|
2169
|
+
} catch {}
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
function appendResponseChunk(record, chunk) {
|
|
2173
|
+
if (!chunk || !shouldCaptureResponse()) return;
|
|
2174
|
+
try {
|
|
2175
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
2176
|
+
record.responseBytes += buffer.length;
|
|
2177
|
+
const currentBytes = record.responseChunks.reduce((sum, item) => sum + item.length, 0);
|
|
2178
|
+
const limit = maxBodyBytes();
|
|
2179
|
+
if (currentBytes < limit) {
|
|
2180
|
+
record.responseChunks.push(buffer.subarray(0, Math.max(0, limit - currentBytes)));
|
|
2181
|
+
}
|
|
2182
|
+
} catch {}
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
function chunksToText(chunks) {
|
|
2186
|
+
try {
|
|
2187
|
+
if (!chunks || !chunks.length) return undefined;
|
|
2188
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
2189
|
+
} catch {
|
|
2190
|
+
return undefined;
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
function tryParseContent(text, headers) {
|
|
2195
|
+
if (text === undefined) return undefined;
|
|
2196
|
+
if (!currentOptions().parseBodyJson) return text;
|
|
2197
|
+
const contentType = String((headers && (headers["content-type"] || headers["Content-Type"])) || "");
|
|
2198
|
+
if (contentType.includes("application/json") || /^[\\s]*[\\{\\[]/.test(text)) {
|
|
2199
|
+
try { return JSON.parse(text); } catch { return text; }
|
|
2200
|
+
}
|
|
2201
|
+
if (contentType.includes("application/x-www-form-urlencoded")) {
|
|
2202
|
+
try { return Object.fromEntries(new URLSearchParams(text)); } catch { return text; }
|
|
2203
|
+
}
|
|
2204
|
+
return text;
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
function getRequestUrl(req) {
|
|
2208
|
+
return req.originalUrl || req.url || req.path || "";
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
function patchRequestAndResponse(req, res, source) {
|
|
2212
|
+
if (!req || !res || state.patchedRequests.has(req)) return false;
|
|
2213
|
+
|
|
2214
|
+
state.patchedRequests.add(req);
|
|
2215
|
+
const record = {
|
|
2216
|
+
id: ++state.requestSeq,
|
|
2217
|
+
source,
|
|
2218
|
+
startedAt: Date.now(),
|
|
2219
|
+
requestChunks: [],
|
|
2220
|
+
responseChunks: [],
|
|
2221
|
+
requestBytes: 0,
|
|
2222
|
+
responseBytes: 0,
|
|
2223
|
+
};
|
|
2224
|
+
state.activeRequests.set(req, record);
|
|
2225
|
+
|
|
2226
|
+
try {
|
|
2227
|
+
if (!req.__SMDG_NETWORK_SPY_PUSH_PATCHED__) {
|
|
2228
|
+
const originalPush = req.push;
|
|
2229
|
+
if (typeof originalPush === "function") {
|
|
2230
|
+
req.push = function smdgNetworkTraceRequestPush(chunk, encoding) {
|
|
2231
|
+
appendChunk(record, chunk);
|
|
2232
|
+
return originalPush.call(this, chunk, encoding);
|
|
2233
|
+
};
|
|
2234
|
+
Object.defineProperty(req, "__SMDG_NETWORK_SPY_PUSH_PATCHED__", { value: true, enumerable: false });
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
} catch {}
|
|
2238
|
+
|
|
2239
|
+
try {
|
|
2240
|
+
const originalEmit = req.emit;
|
|
2241
|
+
if (typeof originalEmit === "function" && !req.__SMDG_NETWORK_SPY_EMIT_PATCHED__) {
|
|
2242
|
+
req.emit = function smdgNetworkTraceRequestEmit(eventName, chunk, ...args) {
|
|
2243
|
+
if (eventName === "data") appendChunk(record, chunk);
|
|
2244
|
+
return originalEmit.call(this, eventName, chunk, ...args);
|
|
2245
|
+
};
|
|
2246
|
+
Object.defineProperty(req, "__SMDG_NETWORK_SPY_EMIT_PATCHED__", { value: true, enumerable: false });
|
|
2247
|
+
}
|
|
2248
|
+
} catch {}
|
|
2249
|
+
|
|
2250
|
+
try {
|
|
2251
|
+
if (!state.patchedResponses.has(res)) {
|
|
2252
|
+
state.patchedResponses.add(res);
|
|
2253
|
+
const originalWrite = res.write;
|
|
2254
|
+
const originalEnd = res.end;
|
|
2255
|
+
|
|
2256
|
+
if (typeof originalWrite === "function") {
|
|
2257
|
+
res.write = function smdgNetworkTraceResponseWrite(chunk, ...args) {
|
|
2258
|
+
appendResponseChunk(record, chunk);
|
|
2259
|
+
return originalWrite.call(this, chunk, ...args);
|
|
2260
|
+
};
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
if (typeof originalEnd === "function") {
|
|
2264
|
+
res.end = function smdgNetworkTraceResponseEnd(chunk, ...args) {
|
|
2265
|
+
appendResponseChunk(record, chunk);
|
|
2266
|
+
return originalEnd.call(this, chunk, ...args);
|
|
2267
|
+
};
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
} catch {}
|
|
2271
|
+
|
|
2272
|
+
const finish = () => {
|
|
2273
|
+
try {
|
|
2274
|
+
const requestBodyText = chunksToText(record.requestChunks);
|
|
2275
|
+
const responseBodyText = chunksToText(record.responseChunks);
|
|
2276
|
+
const headers = req.headers || {};
|
|
2277
|
+
const event = {
|
|
2278
|
+
type: "smdg-request-trace",
|
|
2279
|
+
app: currentOptions().appName,
|
|
2280
|
+
source: record.source,
|
|
2281
|
+
id: record.id,
|
|
2282
|
+
timestamp: new Date(record.startedAt).toISOString(),
|
|
2283
|
+
method: req.method,
|
|
2284
|
+
url: getRequestUrl(req),
|
|
2285
|
+
status: res.statusCode,
|
|
2286
|
+
durationMs: Date.now() - record.startedAt,
|
|
2287
|
+
requestBytes: record.requestBytes,
|
|
2288
|
+
responseBytes: record.responseBytes,
|
|
2289
|
+
headers: normalizeHeaders(headers),
|
|
2290
|
+
body: shouldCaptureBody() ? tryParseContent(requestBodyText, headers) : undefined,
|
|
2291
|
+
responseBody: shouldCaptureResponse() ? tryParseContent(responseBodyText, res.getHeaders ? res.getHeaders() : {}) : undefined,
|
|
2292
|
+
};
|
|
2293
|
+
write(event);
|
|
2294
|
+
} catch (error) {
|
|
2295
|
+
write({
|
|
2296
|
+
type: "smdg-request-trace-error",
|
|
2297
|
+
app: currentOptions().appName,
|
|
2298
|
+
message: error && error.message ? error.message : String(error),
|
|
2299
|
+
});
|
|
2300
|
+
}
|
|
2301
|
+
};
|
|
2302
|
+
|
|
2303
|
+
if (typeof res.once === "function") {
|
|
2304
|
+
res.once("finish", finish);
|
|
2305
|
+
res.once("close", () => {
|
|
2306
|
+
if (!res.writableEnded) finish();
|
|
2307
|
+
});
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
return true;
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
function installDiagnosticsChannelHook() {
|
|
2314
|
+
try {
|
|
2315
|
+
const diagnostics = require("diagnostics_channel");
|
|
2316
|
+
if (!diagnostics || diagnostics.__SMDG_NETWORK_SPY_PATCHED__) return false;
|
|
2317
|
+
const requestStart = diagnostics.channel("http.server.request.start");
|
|
2318
|
+
requestStart.subscribe((message) => {
|
|
2319
|
+
const req = message && (message.request || message.req);
|
|
2320
|
+
const res = message && (message.response || message.res);
|
|
2321
|
+
patchRequestAndResponse(req, res, "diagnostics_channel:http.server.request.start");
|
|
2322
|
+
});
|
|
2323
|
+
Object.defineProperty(diagnostics, "__SMDG_NETWORK_SPY_PATCHED__", { value: true, enumerable: false });
|
|
2324
|
+
return true;
|
|
2325
|
+
} catch {
|
|
2326
|
+
return false;
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
function installServerEmitHook() {
|
|
2331
|
+
try {
|
|
2332
|
+
const http = require("http");
|
|
2333
|
+
const Server = http && http.Server;
|
|
2334
|
+
if (!Server || !Server.prototype || Server.prototype.__SMDG_NETWORK_SPY_EMIT_PATCHED__) return false;
|
|
2335
|
+
const originalEmit = Server.prototype.emit;
|
|
2336
|
+
Server.prototype.emit = function smdgNetworkTraceServerEmit(eventName, req, res, ...args) {
|
|
2337
|
+
if (eventName === "request") patchRequestAndResponse(req, res, "http.Server.emit");
|
|
2338
|
+
return originalEmit.call(this, eventName, req, res, ...args);
|
|
2339
|
+
};
|
|
2340
|
+
Object.defineProperty(Server.prototype, "__SMDG_NETWORK_SPY_EMIT_PATCHED__", { value: true, enumerable: false });
|
|
2341
|
+
return true;
|
|
2342
|
+
} catch {
|
|
2343
|
+
return false;
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
function installCreateServerHook(moduleName) {
|
|
2348
|
+
try {
|
|
2349
|
+
const mod = require(moduleName);
|
|
2350
|
+
if (!mod || mod.__SMDG_NETWORK_SPY_CREATE_SERVER_PATCHED__) return false;
|
|
2351
|
+
const originalCreateServer = mod.createServer;
|
|
2352
|
+
if (typeof originalCreateServer !== "function") return false;
|
|
2353
|
+
mod.createServer = function smdgNetworkTraceCreateServer(...args) {
|
|
2354
|
+
const server = originalCreateServer.apply(this, args);
|
|
2355
|
+
hookServer(server, moduleName + ".createServer");
|
|
2356
|
+
return server;
|
|
2357
|
+
};
|
|
2358
|
+
Object.defineProperty(mod, "__SMDG_NETWORK_SPY_CREATE_SERVER_PATCHED__", { value: true, enumerable: false });
|
|
2359
|
+
return true;
|
|
2360
|
+
} catch {
|
|
2361
|
+
return false;
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
function hookServer(server, source) {
|
|
2366
|
+
try {
|
|
2367
|
+
if (!server || state.patchedServers.has(server)) return false;
|
|
2368
|
+
if (typeof server.prependListener === "function") {
|
|
2369
|
+
server.prependListener("request", (req, res) => patchRequestAndResponse(req, res, source));
|
|
2370
|
+
state.patchedServers.add(server);
|
|
2371
|
+
return true;
|
|
2372
|
+
}
|
|
2373
|
+
} catch {}
|
|
2374
|
+
return false;
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
function hookActiveServers() {
|
|
2378
|
+
let count = 0;
|
|
2379
|
+
try {
|
|
2380
|
+
const handles = typeof process._getActiveHandles === "function" ? process._getActiveHandles() : [];
|
|
2381
|
+
for (const handle of handles) {
|
|
2382
|
+
if (handle && typeof handle.on === "function" && typeof handle.address === "function") {
|
|
2383
|
+
if (hookServer(handle, "active-handle")) count += 1;
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
} catch {}
|
|
2387
|
+
return count;
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
const diagnosticsHooked = installDiagnosticsChannelHook();
|
|
2391
|
+
const serverEmitHooked = installServerEmitHook();
|
|
2392
|
+
const httpCreateHooked = installCreateServerHook("http");
|
|
2393
|
+
const httpsCreateHooked = installCreateServerHook("https");
|
|
2394
|
+
const activeServers = hookActiveServers();
|
|
2395
|
+
|
|
2396
|
+
state.installed = true;
|
|
2397
|
+
state.installedAt = state.installedAt || new Date().toISOString();
|
|
2398
|
+
|
|
2399
|
+
write({
|
|
2400
|
+
type: "smdg-request-trace-status",
|
|
2401
|
+
app: options.appName,
|
|
2402
|
+
status: "installed",
|
|
2403
|
+
engine: "network-trace-v4",
|
|
2404
|
+
diagnosticsHooked,
|
|
2405
|
+
serverEmitHooked,
|
|
2406
|
+
httpCreateHooked,
|
|
2407
|
+
httpsCreateHooked,
|
|
2408
|
+
activeServers,
|
|
2409
|
+
mode: options.mode,
|
|
2410
|
+
authMode: options.authMode,
|
|
2411
|
+
maxBodyBytes: options.maxBodyBytes,
|
|
2412
|
+
});
|
|
2413
|
+
|
|
2414
|
+
return "installed:network-trace-v4:" + activeServers;
|
|
2415
|
+
})();`;
|
|
2416
|
+
|
|
2417
|
+
return source;
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
async function selectRequestTraceMode(): Promise<TRequestTraceMode> {
|
|
2421
|
+
return searchableSelectChoice({
|
|
2422
|
+
message: "Select request trace mode",
|
|
2423
|
+
choices: [
|
|
2424
|
+
{ title: "Path only", value: "path", description: "method, URL, status, duration" },
|
|
2425
|
+
{ title: "Headers", value: "headers", description: "include request headers, mask sensitive values" },
|
|
2426
|
+
{ title: "Headers + body", value: "body", description: "include request body up to a safe size limit" },
|
|
2427
|
+
{ title: "Headers + body + response", value: "response", description: "include request body and response body" },
|
|
2428
|
+
],
|
|
2429
|
+
allowCustomValue: false,
|
|
2430
|
+
}) as Promise<TRequestTraceMode>;
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
async function selectRequestTraceAuthMode(): Promise<TRequestTraceAuthMode> {
|
|
2434
|
+
return searchableSelectChoice({
|
|
2435
|
+
message: "Authorization header handling",
|
|
2436
|
+
choices: [
|
|
2437
|
+
{ title: "Mask token (recommended)", value: "mask" },
|
|
2438
|
+
{ title: "Show full token (dev/test only)", value: "full" },
|
|
2439
|
+
{ title: "Omit Authorization header", value: "omit" },
|
|
2440
|
+
],
|
|
2441
|
+
allowCustomValue: false,
|
|
2442
|
+
}) as Promise<TRequestTraceAuthMode>;
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
async function selectRequestTraceDisplayOptions(options: TCloudFoundryRequestTraceOptions): Promise<TRequestTraceDisplayOptions> {
|
|
2446
|
+
const headerPreset = await searchableSelectChoice({
|
|
2447
|
+
message: "Headers to display in terminal",
|
|
2448
|
+
choices: [
|
|
2449
|
+
{ title: "Minimal headers", value: "minimal", description: "host, content-type, authorization, request/correlation ids" },
|
|
2450
|
+
{ title: "Common debug headers", value: "common", description: "minimal + user-agent, origin, forwarded, CF/B3 headers" },
|
|
2451
|
+
{ title: "All captured headers", value: "all", description: "large output" },
|
|
2452
|
+
{ title: "Custom header list", value: "custom", description: "enter comma-separated headers" },
|
|
2453
|
+
],
|
|
2454
|
+
allowCustomValue: false,
|
|
2455
|
+
}) as TRequestTraceHeaderPreset;
|
|
2456
|
+
|
|
2457
|
+
let headerNames: string[] = [];
|
|
2458
|
+
if (headerPreset === "minimal") headerNames = getMinimalTraceHeaderNames();
|
|
2459
|
+
if (headerPreset === "common") headerNames = getCommonTraceHeaderNames();
|
|
2460
|
+
if (headerPreset === "custom") {
|
|
2461
|
+
const response = await prompts({
|
|
2462
|
+
type: "text",
|
|
2463
|
+
name: "headers",
|
|
2464
|
+
message: "Header names to display",
|
|
2465
|
+
initial: "authorization,content-type,content-length,x-correlationid,x-vcap-request-id,tenantid,user-agent",
|
|
2466
|
+
validate: (value: string) => value.trim() ? true : "At least one header is required",
|
|
2467
|
+
});
|
|
2468
|
+
headerNames = String(response.headers ?? "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
const parseResponse = await prompts({
|
|
2472
|
+
type: "select",
|
|
2473
|
+
name: "parseBodyJson",
|
|
2474
|
+
message: "Try parse request/response body as JSON when possible?",
|
|
2475
|
+
choices: [
|
|
2476
|
+
{ title: "Yes, parse JSON/form body when possible", value: true },
|
|
2477
|
+
{ title: "No, keep raw body string", value: false },
|
|
2478
|
+
],
|
|
2479
|
+
initial: 0,
|
|
2480
|
+
});
|
|
2481
|
+
|
|
2482
|
+
let outputFile = options.out;
|
|
2483
|
+
if (!outputFile) {
|
|
2484
|
+
const outResponse = await prompts({
|
|
2485
|
+
type: "select",
|
|
2486
|
+
name: "export",
|
|
2487
|
+
message: "Export captured trace events to JSONL file?",
|
|
2488
|
+
choices: [
|
|
2489
|
+
{ title: "No", value: false },
|
|
2490
|
+
{ title: "Yes", value: true },
|
|
2491
|
+
],
|
|
2492
|
+
initial: 0,
|
|
2493
|
+
});
|
|
2494
|
+
|
|
2495
|
+
if (outResponse.export) {
|
|
2496
|
+
const fileResponse = await prompts({
|
|
2497
|
+
type: "text",
|
|
2498
|
+
name: "file",
|
|
2499
|
+
message: "Trace output file",
|
|
2500
|
+
initial: `smdg-request-trace-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`,
|
|
2501
|
+
validate: (value: string) => value.trim() ? true : "Output file is required",
|
|
2502
|
+
});
|
|
2503
|
+
outputFile = String(fileResponse.file ?? "").trim();
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
if (outputFile) {
|
|
2508
|
+
await fs.ensureDir(path.dirname(path.resolve(outputFile)));
|
|
2509
|
+
await fs.writeFile(outputFile, "", "utf8");
|
|
2510
|
+
console.log(chalk.green(`Trace events will be exported to ${path.resolve(outputFile)}`));
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
return {
|
|
2514
|
+
headerPreset,
|
|
2515
|
+
headerNames,
|
|
2516
|
+
parseBodyJson: Boolean(parseResponse.parseBodyJson),
|
|
2517
|
+
outputFile,
|
|
2518
|
+
};
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
async function resolveRequestTraceApps(options: TCloudFoundryRequestTraceOptions): Promise<string[]> {
|
|
2522
|
+
if (options.app?.trim()) {
|
|
2523
|
+
return uniqueValues(options.app.split(","));
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
const apps = await getAppsWithCache({ refresh: options.refresh, startBackgroundRefresh: !options.refresh });
|
|
2527
|
+
const selectedApps: string[] = [];
|
|
2528
|
+
|
|
2529
|
+
while (true) {
|
|
2530
|
+
const appName = await searchableSelectChoice({
|
|
2531
|
+
message: selectedApps.length ? "Add another BTP app to trace, or finish" : "Search/select BTP app to trace",
|
|
2532
|
+
choices: [
|
|
2533
|
+
...apps
|
|
2534
|
+
.filter((app) => !selectedApps.includes(app.name))
|
|
2535
|
+
.map((app) => ({
|
|
2536
|
+
title: [app.name, app.requestedState, app.routes].filter(Boolean).join(" | "),
|
|
2537
|
+
value: app.name,
|
|
2538
|
+
})),
|
|
2539
|
+
...(selectedApps.length ? [{ title: "Done", value: "__DONE__" }] : []),
|
|
2540
|
+
],
|
|
2541
|
+
validateCustomValue: validateRequired,
|
|
2542
|
+
customValueTitle: (value) => `Use typed app name: ${value}`,
|
|
2543
|
+
});
|
|
2544
|
+
|
|
2545
|
+
if (appName === "__DONE__") {
|
|
2546
|
+
break;
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
selectedApps.push(appName);
|
|
2550
|
+
await rememberSelectedApp(appName);
|
|
2551
|
+
|
|
2552
|
+
const moreResponse = await prompts({
|
|
2553
|
+
type: "select",
|
|
2554
|
+
name: "more",
|
|
2555
|
+
message: "Trace another app at the same time?",
|
|
2556
|
+
choices: [
|
|
2557
|
+
{ title: "No, start tracing now", value: false },
|
|
2558
|
+
{ title: "Yes, add another app", value: true },
|
|
2559
|
+
],
|
|
2560
|
+
initial: 0,
|
|
2561
|
+
});
|
|
2562
|
+
|
|
2563
|
+
if (!moreResponse.more) {
|
|
2564
|
+
break;
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
return selectedApps;
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
function getMinimalTraceHeaderNames(): string[] {
|
|
2572
|
+
return [
|
|
2573
|
+
"host",
|
|
2574
|
+
"content-type",
|
|
2575
|
+
"content-length",
|
|
2576
|
+
"authorization",
|
|
2577
|
+
"x-correlation-id",
|
|
2578
|
+
"x-correlationid",
|
|
2579
|
+
"x-vcap-request-id",
|
|
2580
|
+
"tenantid",
|
|
2581
|
+
];
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
function getCommonTraceHeaderNames(): string[] {
|
|
2585
|
+
return [
|
|
2586
|
+
...getMinimalTraceHeaderNames(),
|
|
2587
|
+
"user-agent",
|
|
2588
|
+
"origin",
|
|
2589
|
+
"referer",
|
|
2590
|
+
"x-forwarded-for",
|
|
2591
|
+
"x-forwarded-host",
|
|
2592
|
+
"x-forwarded-path",
|
|
2593
|
+
"x-forwarded-proto",
|
|
2594
|
+
"x-cf-applicationid",
|
|
2595
|
+
"x-cf-instanceindex",
|
|
2596
|
+
"x-cf-true-client-ip",
|
|
2597
|
+
"x-b3-traceid",
|
|
2598
|
+
"x-b3-spanid",
|
|
2599
|
+
"b3",
|
|
2600
|
+
];
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
function normalizeHeaderName(value: string): string {
|
|
2604
|
+
return value.trim().toLowerCase();
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
function filterTraceHeaders(headers: unknown, display: TRequestTraceDisplayOptions): Record<string, unknown> | undefined {
|
|
2608
|
+
if (!headers || typeof headers !== "object") return undefined;
|
|
2609
|
+
const source = headers as Record<string, unknown>;
|
|
2610
|
+
if (display.headerPreset === "all") return source;
|
|
2611
|
+
|
|
2612
|
+
const names = new Set(display.headerNames.map(normalizeHeaderName));
|
|
2613
|
+
const output: Record<string, unknown> = {};
|
|
2614
|
+
for (const [key, value] of Object.entries(source)) {
|
|
2615
|
+
if (names.has(normalizeHeaderName(key))) output[key] = value;
|
|
2616
|
+
}
|
|
2617
|
+
return output;
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
function stringifyTraceValue(value: unknown): string {
|
|
2621
|
+
if (value === undefined || value === null) return "";
|
|
2622
|
+
if (typeof value === "string") return value;
|
|
2623
|
+
try { return JSON.stringify(value); } catch { return String(value); }
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2626
|
+
function traceEventMatchesFilters(event: Record<string, unknown>, filters: TRequestTraceFilterState): boolean {
|
|
2627
|
+
if (filters.paused) return false;
|
|
2628
|
+
const method = String(event.method ?? "").toLowerCase();
|
|
2629
|
+
const url = String(event.url ?? "").toLowerCase();
|
|
2630
|
+
const status = String(event.status ?? "").toLowerCase();
|
|
2631
|
+
const body = stringifyTraceValue(event.body).toLowerCase();
|
|
2632
|
+
const responseBody = stringifyTraceValue(event.responseBody).toLowerCase();
|
|
2633
|
+
const all = stringifyTraceValue(event).toLowerCase();
|
|
2634
|
+
|
|
2635
|
+
if (filters.method && method !== filters.method.toLowerCase()) return false;
|
|
2636
|
+
if (filters.path && !url.includes(filters.path.toLowerCase())) return false;
|
|
2637
|
+
if (filters.status && !status.includes(filters.status.toLowerCase())) return false;
|
|
2638
|
+
if (filters.body && !body.includes(filters.body.toLowerCase()) && !responseBody.includes(filters.body.toLowerCase())) return false;
|
|
2639
|
+
if (filters.text && !all.includes(filters.text.toLowerCase())) return false;
|
|
2640
|
+
return true;
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
function buildPrintableTracePayload(event: Record<string, unknown>, display: TRequestTraceDisplayOptions): Record<string, unknown> {
|
|
2644
|
+
const output: Record<string, unknown> = {
|
|
2645
|
+
type: event.type,
|
|
2646
|
+
app: event.app,
|
|
2647
|
+
source: event.source,
|
|
2648
|
+
id: event.id,
|
|
2649
|
+
timestamp: event.timestamp,
|
|
2650
|
+
method: event.method,
|
|
2651
|
+
url: event.url,
|
|
2652
|
+
status: event.status,
|
|
2653
|
+
durationMs: event.durationMs,
|
|
2654
|
+
requestBytes: event.requestBytes,
|
|
2655
|
+
responseBytes: event.responseBytes,
|
|
2656
|
+
};
|
|
2657
|
+
|
|
2658
|
+
const headers = filterTraceHeaders(event.headers, display);
|
|
2659
|
+
if (headers && Object.keys(headers).length > 0) output.headers = headers;
|
|
2660
|
+
if (event.body !== undefined) output.body = event.body;
|
|
2661
|
+
if (event.responseBody !== undefined) output.responseBody = event.responseBody;
|
|
2662
|
+
return output;
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
function writeTraceEventToFile(outputFile: string | undefined, event: Record<string, unknown>): void {
|
|
2666
|
+
if (!outputFile) return;
|
|
2667
|
+
try {
|
|
2668
|
+
fs.appendFileSync(outputFile, `${JSON.stringify(event)}\n`, "utf8");
|
|
2669
|
+
} catch (error) {
|
|
2670
|
+
console.error(chalk.yellow(`Failed to write trace event to file: ${error instanceof Error ? error.message : String(error)}`));
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
function printRequestTraceEvent(appName: string, payload: Record<string, unknown>, runtime: TRequestTraceRuntimeState): void {
|
|
2675
|
+
const type = String(payload.type ?? "smdg-request-trace");
|
|
2676
|
+
|
|
2677
|
+
if (type === "smdg-request-trace-status") {
|
|
2678
|
+
console.log(chalk.green(`[${appName}] ${String(payload.status ?? "trace-status")}`));
|
|
2679
|
+
console.log(chalk.gray(`engine=${String(payload.engine ?? "unknown")} activeServers=${String(payload.activeServers ?? "?")} mode=${String(payload.mode ?? "")}`));
|
|
2680
|
+
return;
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
if (type === "smdg-request-trace-error") {
|
|
2684
|
+
console.log(chalk.red(`[${appName}] trace error: ${String(payload.message ?? "unknown")}`));
|
|
2685
|
+
return;
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
runtime.events.push(payload);
|
|
2689
|
+
writeTraceEventToFile(runtime.display.outputFile, payload);
|
|
2690
|
+
|
|
2691
|
+
if (!traceEventMatchesFilters(payload, runtime.filters)) return;
|
|
2692
|
+
|
|
2693
|
+
const time = String(payload.timestamp ?? new Date().toISOString());
|
|
2694
|
+
const method = String(payload.method ?? "");
|
|
2695
|
+
const url = String(payload.url ?? "");
|
|
2696
|
+
const status = String(payload.status ?? "");
|
|
2697
|
+
const duration = String(payload.durationMs ?? "");
|
|
2698
|
+
console.log(chalk.cyan(`\n[${time}] [${appName}] ${method} ${url} → ${status} ${duration}ms`));
|
|
2699
|
+
console.log(JSON.stringify(buildPrintableTracePayload(payload, runtime.display), null, 2));
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2702
|
+
function printRequestTraceLine(appName: string, line: string, runtime: TRequestTraceRuntimeState): void {
|
|
2703
|
+
const marker = line.includes("SMDG_REQUEST_TRACE ") ? "SMDG_REQUEST_TRACE " : line.includes("SMDG_REQUEST_SPY ") ? "SMDG_REQUEST_SPY " : undefined;
|
|
2704
|
+
if (!marker) return;
|
|
2705
|
+
|
|
2706
|
+
const markerIndex = line.indexOf(marker);
|
|
2707
|
+
const payloadText = line.slice(markerIndex + marker.length).trim();
|
|
2708
|
+
|
|
2709
|
+
try {
|
|
2710
|
+
const payload = JSON.parse(payloadText) as Record<string, unknown>;
|
|
2711
|
+
printRequestTraceEvent(appName, payload, runtime);
|
|
2712
|
+
} catch {
|
|
2713
|
+
console.log(`[${appName}] ${payloadText}`);
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
function printTraceRuntimeHelp(): void {
|
|
2718
|
+
console.log(chalk.gray("\nRuntime trace commands:"));
|
|
2719
|
+
console.log(chalk.gray(" /method POST show only one method"));
|
|
2720
|
+
console.log(chalk.gray(" /path text show only URLs containing text"));
|
|
2721
|
+
console.log(chalk.gray(" /body text show only request/response body containing text"));
|
|
2722
|
+
console.log(chalk.gray(" /status 500 show only status containing value"));
|
|
2723
|
+
console.log(chalk.gray(" /text value search anywhere in the event"));
|
|
2724
|
+
console.log(chalk.gray(" /headers a,b,c change displayed headers while running"));
|
|
2725
|
+
console.log(chalk.gray(" /headers all display all captured headers"));
|
|
2726
|
+
console.log(chalk.gray(" /clear clear active filters"));
|
|
2727
|
+
console.log(chalk.gray(" /show show active filters"));
|
|
2728
|
+
console.log(chalk.gray(" /replay print matching events already captured"));
|
|
2729
|
+
console.log(chalk.gray(" /pause or /resume pause/resume terminal display"));
|
|
2730
|
+
console.log(chalk.gray(" /help show this help"));
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2733
|
+
function applyTraceRuntimeCommand(input: string, runtime: TRequestTraceRuntimeState): void {
|
|
2734
|
+
const trimmed = input.trim();
|
|
2735
|
+
if (!trimmed) return;
|
|
2736
|
+
if (!trimmed.startsWith("/")) {
|
|
2737
|
+
runtime.filters.text = trimmed;
|
|
2738
|
+
console.log(chalk.yellow(`Search text filter: ${trimmed}`));
|
|
2739
|
+
return;
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
const [commandRaw, ...restParts] = trimmed.slice(1).split(" ");
|
|
2743
|
+
const command = commandRaw.toLowerCase();
|
|
2744
|
+
const value = restParts.join(" ").trim();
|
|
2745
|
+
|
|
2746
|
+
if (command === "method") runtime.filters.method = value || undefined;
|
|
2747
|
+
else if (command === "path") runtime.filters.path = value || undefined;
|
|
2748
|
+
else if (command === "body") runtime.filters.body = value || undefined;
|
|
2749
|
+
else if (command === "status") runtime.filters.status = value || undefined;
|
|
2750
|
+
else if (command === "text") runtime.filters.text = value || undefined;
|
|
2751
|
+
else if (command === "pause") runtime.filters.paused = true;
|
|
2752
|
+
else if (command === "resume") runtime.filters.paused = false;
|
|
2753
|
+
else if (command === "clear") {
|
|
2754
|
+
runtime.filters.method = undefined;
|
|
2755
|
+
runtime.filters.path = undefined;
|
|
2756
|
+
runtime.filters.body = undefined;
|
|
2757
|
+
runtime.filters.status = undefined;
|
|
2758
|
+
runtime.filters.text = undefined;
|
|
2759
|
+
runtime.filters.paused = false;
|
|
2760
|
+
} else if (command === "headers") {
|
|
2761
|
+
if (!value || value.toLowerCase() === "common") {
|
|
2762
|
+
runtime.display.headerPreset = "common";
|
|
2763
|
+
runtime.display.headerNames = getCommonTraceHeaderNames();
|
|
2764
|
+
} else if (value.toLowerCase() === "minimal") {
|
|
2765
|
+
runtime.display.headerPreset = "minimal";
|
|
2766
|
+
runtime.display.headerNames = getMinimalTraceHeaderNames();
|
|
2767
|
+
} else if (value.toLowerCase() === "all") {
|
|
2768
|
+
runtime.display.headerPreset = "all";
|
|
2769
|
+
runtime.display.headerNames = [];
|
|
2770
|
+
} else {
|
|
2771
|
+
runtime.display.headerPreset = "custom";
|
|
2772
|
+
runtime.display.headerNames = value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
2773
|
+
}
|
|
2774
|
+
} else if (command === "show") {
|
|
2775
|
+
console.log(chalk.gray(JSON.stringify({ filters: runtime.filters, display: runtime.display, captured: runtime.events.length }, null, 2)));
|
|
2776
|
+
return;
|
|
2777
|
+
} else if (command === "replay") {
|
|
2778
|
+
console.log(chalk.gray(`Replaying ${runtime.events.length} captured event(s) with current filters...`));
|
|
2779
|
+
for (const event of runtime.events) {
|
|
2780
|
+
if (traceEventMatchesFilters(event, runtime.filters)) {
|
|
2781
|
+
const appName = String(event.app ?? "app");
|
|
2782
|
+
const time = String(event.timestamp ?? "");
|
|
2783
|
+
console.log(chalk.cyan(`\n[${time}] [${appName}] ${String(event.method ?? "")} ${String(event.url ?? "")} → ${String(event.status ?? "")} ${String(event.durationMs ?? "")}ms`));
|
|
2784
|
+
console.log(JSON.stringify(buildPrintableTracePayload(event, runtime.display), null, 2));
|
|
2785
|
+
}
|
|
2786
|
+
}
|
|
2787
|
+
return;
|
|
2788
|
+
} else if (command === "help" || command === "?") {
|
|
2789
|
+
printTraceRuntimeHelp();
|
|
2790
|
+
return;
|
|
2791
|
+
} else {
|
|
2792
|
+
console.log(chalk.yellow(`Unknown runtime command: ${command}`));
|
|
2793
|
+
printTraceRuntimeHelp();
|
|
2794
|
+
return;
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
console.log(chalk.yellow(`Trace runtime updated: ${trimmed}`));
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
function attachTraceRuntimeCommands(runtime: TRequestTraceRuntimeState): void {
|
|
2801
|
+
printTraceRuntimeHelp();
|
|
2802
|
+
process.stdin.setEncoding("utf8");
|
|
2803
|
+
process.stdin.resume();
|
|
2804
|
+
process.stdin.on("data", (chunk: string) => {
|
|
2805
|
+
for (const line of chunk.split(/\r?\n/)) {
|
|
2806
|
+
applyTraceRuntimeCommand(line, runtime);
|
|
2807
|
+
}
|
|
2808
|
+
});
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
function startRequestTraceLogStream(appName: string, runtime: TRequestTraceRuntimeState): ChildProcess {
|
|
2812
|
+
const childProcess = spawn("cf", ["logs", appName], {
|
|
2813
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2814
|
+
shell: false,
|
|
2815
|
+
windowsHide: true,
|
|
2816
|
+
});
|
|
2817
|
+
|
|
2818
|
+
childProcess.stdout.on("data", (chunk: Buffer) => {
|
|
2819
|
+
const lines = chunk.toString("utf8").split(/\r?\n/);
|
|
2820
|
+
for (const line of lines) printRequestTraceLine(appName, line, runtime);
|
|
2821
|
+
});
|
|
2822
|
+
|
|
2823
|
+
childProcess.stderr.on("data", (chunk: Buffer) => {
|
|
2824
|
+
const text = chunk.toString("utf8");
|
|
2825
|
+
if (/SMDG_REQUEST_(TRACE|SPY)/.test(text)) {
|
|
2826
|
+
for (const line of text.split(/\r?\n/)) printRequestTraceLine(appName, line, runtime);
|
|
2827
|
+
}
|
|
2828
|
+
});
|
|
2829
|
+
|
|
2830
|
+
return childProcess;
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
async function runRequestTraceCommand(options: TCloudFoundryRequestTraceOptions): Promise<void> {
|
|
2834
|
+
await maybeSwitchCloudFoundryTargetForDebug({
|
|
2835
|
+
app: options.app,
|
|
2836
|
+
refresh: options.refresh,
|
|
2837
|
+
instance: options.instance,
|
|
2838
|
+
process: options.process,
|
|
2839
|
+
localPort: options.localPort,
|
|
2840
|
+
remotePort: options.remotePort,
|
|
2841
|
+
skipOrgSelect: options.skipOrgSelect,
|
|
2842
|
+
});
|
|
2843
|
+
await ensureCloudFoundrySessionFromCache();
|
|
2844
|
+
|
|
2845
|
+
const appNames = await resolveRequestTraceApps(options);
|
|
2846
|
+
|
|
2847
|
+
if (!appNames.length) {
|
|
2848
|
+
throw new Error("No app selected for request trace");
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2851
|
+
const engine = await searchableSelectChoice({
|
|
2852
|
+
message: "Select request trace engine",
|
|
2853
|
+
choices: [
|
|
2854
|
+
{
|
|
2855
|
+
title: "HTTP watch from existing CF/CDS logs (recommended, stable)",
|
|
2856
|
+
value: "http-watch",
|
|
2857
|
+
description: "Shows method/path/status/user/tenant/size. No restart and no source-code change.",
|
|
2858
|
+
},
|
|
2859
|
+
{
|
|
2860
|
+
title: "Deep Node Inspector trace (experimental body capture)",
|
|
2861
|
+
value: "inspector-trace",
|
|
2862
|
+
description: "Attempts runtime injection. May not work for every CAP runtime. Dev/test only.",
|
|
2863
|
+
},
|
|
2864
|
+
{
|
|
2865
|
+
title: "Doctor: verify traffic, process, and limits",
|
|
2866
|
+
value: "doctor",
|
|
2867
|
+
},
|
|
2868
|
+
],
|
|
2869
|
+
allowCustomValue: false,
|
|
2870
|
+
});
|
|
2871
|
+
|
|
2872
|
+
if (engine === "http-watch") {
|
|
2873
|
+
await runHttpWatchForApps({ appNames, recent: false, out: undefined });
|
|
2874
|
+
return;
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2877
|
+
if (engine === "doctor") {
|
|
2878
|
+
await runRequestTraceDoctorCommand({ ...options, app: appNames.join(",") });
|
|
2879
|
+
return;
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
const traceMode = await selectRequestTraceMode();
|
|
2883
|
+
const authMode = await selectRequestTraceAuthMode();
|
|
2884
|
+
const displayOptions = await selectRequestTraceDisplayOptions(options);
|
|
2885
|
+
const runtime: TRequestTraceRuntimeState = {
|
|
2886
|
+
display: displayOptions,
|
|
2887
|
+
filters: { paused: false },
|
|
2888
|
+
events: [],
|
|
2889
|
+
};
|
|
2890
|
+
const instanceIndex = await selectDebugInstance({ instance: options.instance });
|
|
2891
|
+
const baseLocalPort = await selectDebugPort({
|
|
2892
|
+
value: options.localPort,
|
|
2893
|
+
message: "Select first local inspector port for request trace",
|
|
2894
|
+
defaultPort: 9329,
|
|
2895
|
+
});
|
|
2896
|
+
const remotePort = parsePositivePort(options.remotePort, 9229);
|
|
2897
|
+
const maxBodyBytes = parsePositivePort(options.maxBodyBytes, 20000);
|
|
2898
|
+
|
|
2899
|
+
console.log("");
|
|
2900
|
+
console.log(chalk.yellow("Request trace attaches to the running Node.js app through Node Inspector."));
|
|
2901
|
+
console.log(chalk.gray("It does not modify your repository source code. It is temporary and disappears after app restart."));
|
|
2902
|
+
const prepareMode = await selectNodeInspectorPrepareMode({ appName: appNames.join(", "), remotePort });
|
|
2903
|
+
|
|
2904
|
+
const tunnelProcesses: ChildProcess[] = [];
|
|
2905
|
+
const logProcesses: ChildProcess[] = [];
|
|
2906
|
+
|
|
2907
|
+
const stopAll = (): void => {
|
|
2908
|
+
for (const child of [...tunnelProcesses, ...logProcesses]) {
|
|
2909
|
+
if (!child.killed) child.kill();
|
|
2910
|
+
}
|
|
2911
|
+
};
|
|
2912
|
+
|
|
2913
|
+
process.once("SIGINT", () => {
|
|
2914
|
+
console.log(chalk.gray("\nStopping request trace..."));
|
|
2915
|
+
stopAll();
|
|
2916
|
+
process.exit(0);
|
|
2917
|
+
});
|
|
2918
|
+
|
|
2919
|
+
for (const [index, appName] of appNames.entries()) {
|
|
2920
|
+
const localPort = baseLocalPort + index;
|
|
2921
|
+
await ensureSshEnabledForDebug(appName);
|
|
2922
|
+
|
|
2923
|
+
if (prepareMode === "set-env-restart") {
|
|
2924
|
+
await setNodeInspectorEnvironmentAndRestart({ appName, remotePort });
|
|
2925
|
+
}
|
|
2926
|
+
|
|
2927
|
+
console.log(chalk.gray(`Opening inspector tunnel for ${appName}: localhost:${localPort} -> 127.0.0.1:${remotePort}`));
|
|
2928
|
+
const tunnelProcess = spawn("cf", buildCloudFoundryDebugSshArgs({
|
|
2929
|
+
appName,
|
|
2930
|
+
instanceIndex,
|
|
2931
|
+
processName: options.process,
|
|
2932
|
+
localPort,
|
|
2933
|
+
remotePort,
|
|
2934
|
+
prepareMode,
|
|
2935
|
+
}), {
|
|
2936
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2937
|
+
shell: false,
|
|
2938
|
+
windowsHide: true,
|
|
2939
|
+
});
|
|
2940
|
+
tunnelProcesses.push(tunnelProcess);
|
|
2941
|
+
|
|
2942
|
+
tunnelProcess.stdout.on("data", (chunk: Buffer) => process.stdout.write(chalk.gray(`[${appName}:ssh] ${chunk.toString("utf8")}`)));
|
|
2943
|
+
tunnelProcess.stderr.on("data", (chunk: Buffer) => process.stderr.write(chalk.yellow(`[${appName}:ssh] ${chunk.toString("utf8")}`)));
|
|
2944
|
+
|
|
2945
|
+
const webSocketUrl = await waitForNodeInspectorWebSocketUrl(localPort, 20000);
|
|
2946
|
+
|
|
2947
|
+
if (!webSocketUrl) {
|
|
2948
|
+
console.log(chalk.red(`Cannot reach Node Inspector for ${appName} on localhost:${localPort}.`));
|
|
2949
|
+
console.log(chalk.yellow("Try again and choose: Set NODE_OPTIONS and restart app."));
|
|
2950
|
+
continue;
|
|
2951
|
+
}
|
|
2952
|
+
|
|
2953
|
+
const expression = buildRequestTraceInjectionExpression({
|
|
2954
|
+
appName,
|
|
2955
|
+
mode: traceMode,
|
|
2956
|
+
authMode,
|
|
2957
|
+
maxBodyBytes,
|
|
2958
|
+
parseBodyJson: displayOptions.parseBodyJson,
|
|
2959
|
+
});
|
|
2960
|
+
|
|
2961
|
+
await sendInspectorEvaluateCommand({ webSocketUrl, expression });
|
|
2962
|
+
console.log(chalk.green(`Request trace injected into ${appName}.`));
|
|
2963
|
+
|
|
2964
|
+
const logProcess = startRequestTraceLogStream(appName, runtime);
|
|
2965
|
+
logProcesses.push(logProcess);
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2968
|
+
console.log("");
|
|
2969
|
+
console.log(chalk.green(`Request trace is watching ${appNames.length} app(s).`));
|
|
2970
|
+
console.log(chalk.gray("Send requests to your services. Type /help for runtime search commands. Press Ctrl+C to stop tunnels and log streams."));
|
|
2971
|
+
attachTraceRuntimeCommands(runtime);
|
|
2972
|
+
|
|
2973
|
+
await new Promise<void>((resolve) => {
|
|
2974
|
+
const watchedProcesses = [...tunnelProcesses, ...logProcesses];
|
|
2975
|
+
let closedCount = 0;
|
|
2976
|
+
for (const child of watchedProcesses) {
|
|
2977
|
+
child.on("close", () => {
|
|
2978
|
+
closedCount += 1;
|
|
2979
|
+
if (closedCount >= watchedProcesses.length) resolve();
|
|
2980
|
+
});
|
|
2981
|
+
}
|
|
2982
|
+
});
|
|
2983
|
+
}
|
|
2984
|
+
|
|
2985
|
+
async function runDebugCommand(options: TCloudFoundryDebugOptions): Promise<void> {
|
|
2986
|
+
await maybeSwitchCloudFoundryTargetForDebug(options);
|
|
2987
|
+
await ensureCloudFoundrySessionFromCache();
|
|
2988
|
+
|
|
2989
|
+
const appName = await resolveAppSelection({
|
|
2990
|
+
app: options.app,
|
|
2991
|
+
refresh: options.refresh,
|
|
2992
|
+
message: "Search/select BTP app to debug",
|
|
2993
|
+
});
|
|
2994
|
+
const debugMode = await selectDebugMode(options);
|
|
2995
|
+
const instanceIndex = await selectDebugInstance(options);
|
|
2996
|
+
const localPort = await selectDebugPort({
|
|
2997
|
+
value: options.localPort,
|
|
2998
|
+
message: "Select local debug port",
|
|
2999
|
+
defaultPort: 9229,
|
|
3000
|
+
});
|
|
3001
|
+
const remotePort = parsePositivePort(options.remotePort, 9229);
|
|
3002
|
+
const repositoryPath = await resolveRepositoryPath(process.cwd()).catch(() => process.cwd());
|
|
3003
|
+
|
|
3004
|
+
if (debugMode === "check-ssh") {
|
|
3005
|
+
const result = await runCommand("cf", ["ssh-enabled", appName]);
|
|
3006
|
+
if (result.stdout) console.log(result.stdout);
|
|
3007
|
+
if (result.stderr) console.error(result.stderr);
|
|
3008
|
+
process.exitCode = result.exitCode;
|
|
3009
|
+
return;
|
|
3010
|
+
}
|
|
3011
|
+
|
|
3012
|
+
if (debugMode === "enable-ssh") {
|
|
3013
|
+
const result = await runCommand("cf", ["enable-ssh", appName]);
|
|
3014
|
+
if (result.stdout) console.log(result.stdout);
|
|
3015
|
+
if (result.stderr) console.error(result.stderr);
|
|
3016
|
+
|
|
3017
|
+
if (result.exitCode !== 0) {
|
|
3018
|
+
process.exitCode = result.exitCode;
|
|
3019
|
+
return;
|
|
3020
|
+
}
|
|
3021
|
+
|
|
3022
|
+
const restartResponse = await prompts({
|
|
3023
|
+
type: "select",
|
|
3024
|
+
name: "restart",
|
|
3025
|
+
message: "Restart app now so SSH setting takes effect?",
|
|
3026
|
+
choices: [
|
|
3027
|
+
{ title: "Yes, restart app", value: true },
|
|
3028
|
+
{ title: "No, I will restart later", value: false },
|
|
3029
|
+
],
|
|
3030
|
+
initial: 0,
|
|
3031
|
+
});
|
|
3032
|
+
|
|
3033
|
+
if (restartResponse.restart) {
|
|
3034
|
+
const restartExitCode = await runCommandInherit("cf", ["restart", appName]);
|
|
3035
|
+
process.exitCode = restartExitCode;
|
|
3036
|
+
return;
|
|
3037
|
+
}
|
|
3038
|
+
|
|
3039
|
+
console.log(chalk.yellow(`SSH was enabled. Restart the app before debugging: cf restart ${appName}`));
|
|
3040
|
+
return;
|
|
3041
|
+
}
|
|
3042
|
+
|
|
3043
|
+
let launchJsonPath: string | undefined;
|
|
3044
|
+
|
|
3045
|
+
if (debugMode === "vscode" || debugMode === "config-only") {
|
|
3046
|
+
launchJsonPath = await writeVscodeLaunchConfiguration({
|
|
3047
|
+
cwd: repositoryPath,
|
|
3048
|
+
appName,
|
|
3049
|
+
localPort,
|
|
3050
|
+
remoteRoot: "/home/vcap/app",
|
|
3051
|
+
});
|
|
3052
|
+
console.log(chalk.green(`Updated VS Code launch config: ${launchJsonPath}`));
|
|
3053
|
+
|
|
3054
|
+
const openResponse = await prompts({
|
|
3055
|
+
type: "select",
|
|
3056
|
+
name: "open",
|
|
3057
|
+
message: "Open current folder in VS Code?",
|
|
3058
|
+
choices: [
|
|
3059
|
+
{ title: "No", value: false },
|
|
3060
|
+
{ title: "Yes", value: true },
|
|
3061
|
+
],
|
|
3062
|
+
initial: options.open ? 1 : 0,
|
|
3063
|
+
});
|
|
3064
|
+
|
|
3065
|
+
if (openResponse.open) {
|
|
3066
|
+
await openVisualStudioCode({ cwd: repositoryPath, debugPanel: debugMode === "vscode" });
|
|
3067
|
+
}
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
if (debugMode === "config-only") {
|
|
3071
|
+
printVscodeAttachInstructions({
|
|
3072
|
+
appName,
|
|
3073
|
+
instanceIndex,
|
|
3074
|
+
localPort,
|
|
3075
|
+
launchJsonPath: launchJsonPath ?? path.resolve(repositoryPath, ".vscode", "launch.json"),
|
|
3076
|
+
});
|
|
3077
|
+
return;
|
|
3078
|
+
}
|
|
3079
|
+
|
|
3080
|
+
if (debugMode === "link-only") {
|
|
3081
|
+
const debugUrl = await waitForNodeInspectorDebugUrl(localPort, 2000);
|
|
3082
|
+
printNodeInspectorAttachInfo({ appName, instanceIndex, localPort, debugUrl });
|
|
3083
|
+
return;
|
|
3084
|
+
}
|
|
3085
|
+
|
|
3086
|
+
if (options.enableSsh) {
|
|
3087
|
+
const result = await runCommand("cf", ["enable-ssh", appName]);
|
|
3088
|
+
if (result.stdout) console.log(result.stdout);
|
|
3089
|
+
if (result.stderr) console.error(result.stderr);
|
|
3090
|
+
|
|
3091
|
+
if (result.exitCode !== 0) {
|
|
3092
|
+
process.exitCode = result.exitCode;
|
|
3093
|
+
return;
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
if (options.restart) {
|
|
3097
|
+
const restartExitCode = await runCommandInherit("cf", ["restart", appName]);
|
|
3098
|
+
|
|
3099
|
+
if (restartExitCode !== 0) {
|
|
3100
|
+
process.exitCode = restartExitCode;
|
|
3101
|
+
return;
|
|
3102
|
+
}
|
|
3103
|
+
} else {
|
|
3104
|
+
console.log(chalk.yellow("SSH was enabled. If cf ssh still fails, restart the app or run: cf restart " + appName));
|
|
3105
|
+
}
|
|
3106
|
+
}
|
|
3107
|
+
|
|
3108
|
+
console.log("");
|
|
3109
|
+
console.log(chalk.cyan("BTP debug works by opening a CF SSH tunnel to the Node.js inspector."));
|
|
3110
|
+
console.log(chalk.gray("If this is the first time debugging this app, choose: Set NODE_OPTIONS and restart app."));
|
|
3111
|
+
console.log(chalk.gray("If the app was already restarted with NODE_OPTIONS=--inspect, choose: Inspector is already enabled."));
|
|
3112
|
+
const prepareMode = await selectNodeInspectorPrepareMode({ appName, remotePort });
|
|
3113
|
+
|
|
3114
|
+
await ensureSshEnabledForDebug(appName);
|
|
3115
|
+
|
|
3116
|
+
if (prepareMode === "set-env-restart") {
|
|
3117
|
+
await setNodeInspectorEnvironmentAndRestart({ appName, remotePort });
|
|
3118
|
+
}
|
|
3119
|
+
|
|
3120
|
+
console.log(chalk.gray(`Starting Node.js inspector tunnel for ${appName} instance ${instanceIndex}...`));
|
|
3121
|
+
console.log(chalk.gray(`Forwarding localhost:${localPort} -> app container 127.0.0.1:${remotePort}`));
|
|
3122
|
+
|
|
3123
|
+
const childProcess = spawn("cf", buildCloudFoundryDebugSshArgs({
|
|
3124
|
+
appName,
|
|
3125
|
+
instanceIndex,
|
|
3126
|
+
processName: options.process,
|
|
3127
|
+
localPort,
|
|
3128
|
+
remotePort,
|
|
3129
|
+
prepareMode,
|
|
3130
|
+
}), {
|
|
3131
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
3132
|
+
shell: false,
|
|
3133
|
+
windowsHide: true,
|
|
3134
|
+
});
|
|
3135
|
+
|
|
3136
|
+
let hasPrintedAttachInfo = false;
|
|
3137
|
+
|
|
3138
|
+
const printAttachInfoOnce = async (): Promise<void> => {
|
|
3139
|
+
if (hasPrintedAttachInfo) {
|
|
3140
|
+
return;
|
|
3141
|
+
}
|
|
3142
|
+
|
|
3143
|
+
hasPrintedAttachInfo = true;
|
|
3144
|
+
|
|
3145
|
+
const debugUrl = await waitForNodeInspectorDebugUrl(localPort);
|
|
3146
|
+
|
|
3147
|
+
if (debugMode === "vscode") {
|
|
3148
|
+
if (!debugUrl) {
|
|
3149
|
+
console.log(chalk.yellow("Inspector is not reachable yet on localhost. If you selected running-process mode and see a Node PID error, run debug again and choose 'Set NODE_OPTIONS and restart app'."));
|
|
3150
|
+
}
|
|
3151
|
+
|
|
3152
|
+
printVscodeAttachInstructions({
|
|
3153
|
+
appName,
|
|
3154
|
+
instanceIndex,
|
|
3155
|
+
localPort,
|
|
3156
|
+
launchJsonPath: launchJsonPath ?? path.resolve(repositoryPath, ".vscode", "launch.json"),
|
|
3157
|
+
inspectorReady: Boolean(debugUrl),
|
|
3158
|
+
});
|
|
3159
|
+
return;
|
|
3160
|
+
}
|
|
3161
|
+
|
|
3162
|
+
printNodeInspectorAttachInfo({ appName, instanceIndex, localPort, debugUrl });
|
|
3163
|
+
};
|
|
3164
|
+
|
|
3165
|
+
childProcess.stdout?.on("data", (chunk: Buffer) => {
|
|
3166
|
+
const text = chunk.toString("utf8");
|
|
3167
|
+
process.stdout.write(text);
|
|
3168
|
+
|
|
3169
|
+
if (/inspector|debug|listening|started/i.test(text)) {
|
|
3170
|
+
void printAttachInfoOnce();
|
|
3171
|
+
}
|
|
3172
|
+
});
|
|
3173
|
+
|
|
3174
|
+
childProcess.stderr?.on("data", (chunk: Buffer) => {
|
|
3175
|
+
process.stderr.write(chunk.toString("utf8"));
|
|
3176
|
+
});
|
|
3177
|
+
|
|
3178
|
+
const fallbackTimer = setTimeout(() => {
|
|
3179
|
+
void printAttachInfoOnce();
|
|
3180
|
+
}, 3000);
|
|
3181
|
+
|
|
3182
|
+
childProcess.on("close", (exitCode) => {
|
|
3183
|
+
clearTimeout(fallbackTimer);
|
|
3184
|
+
|
|
3185
|
+
if (!hasPrintedAttachInfo || (exitCode ?? 0) !== 0) {
|
|
3186
|
+
console.log("");
|
|
3187
|
+
console.log(chalk.red("Debug tunnel stopped before a working inspector connection was confirmed."));
|
|
3188
|
+
console.log(chalk.yellow("Run smdg cf debug again and choose 'Set NODE_OPTIONS and restart app' when asked to prepare Node.js inspector."));
|
|
3189
|
+
console.log(chalk.gray("After the app restarts, choose VS Code guided debugging and start the attach config from VS Code Run and Debug."));
|
|
3190
|
+
}
|
|
3191
|
+
|
|
3192
|
+
process.exitCode = exitCode ?? 0;
|
|
3193
|
+
});
|
|
3194
|
+
|
|
3195
|
+
await new Promise<void>((resolve) => {
|
|
3196
|
+
childProcess.on("close", () => resolve());
|
|
3197
|
+
});
|
|
3198
|
+
}
|
|
3199
|
+
|
|
3200
|
+
async function runTargetCommand(): Promise<void> {
|
|
3201
|
+
const target = await readCloudFoundryTarget();
|
|
3202
|
+
printTarget(target);
|
|
3203
|
+
}
|
|
3204
|
+
|
|
3205
|
+
async function runCacheCommand(): Promise<void> {
|
|
3206
|
+
const cache = await readCache();
|
|
3207
|
+
console.log(JSON.stringify(cache.cloudFoundry, null, 2));
|
|
3208
|
+
}
|
|
3209
|
+
|
|
3210
|
+
export function registerCloudFoundryCommands(program: Command): void {
|
|
3211
|
+
const cfCommand = program.command("cf").description("Cloud Foundry helper commands for SimpleMDG");
|
|
3212
|
+
|
|
3213
|
+
cfCommand
|
|
3214
|
+
.command("login")
|
|
3215
|
+
.description("Login to Cloud Foundry and cache login profile")
|
|
3216
|
+
.option("--api <apiEndpoint>", "CF API endpoint")
|
|
3217
|
+
.option("--username <username>", "CF username")
|
|
3218
|
+
.option("--password <password>", "CF password")
|
|
3219
|
+
.option("--org <org>", "CF org")
|
|
3220
|
+
.option("--space <space>", "CF space")
|
|
3221
|
+
.option("--save-password", "Cache password in ~/.simplemdg/cache.json. Avoid on shared machines.")
|
|
3222
|
+
.action(runLoginCommand);
|
|
3223
|
+
|
|
3224
|
+
cfCommand.command("target").description("Show current cf target").action(runTargetCommand);
|
|
3225
|
+
|
|
3226
|
+
cfCommand
|
|
3227
|
+
.command("org")
|
|
3228
|
+
.description("List orgs or switch to another org/space without logging in again")
|
|
3229
|
+
.option("--list", "List orgs across known CF regions")
|
|
3230
|
+
.option("--switch", "Switch to another org and space across known CF regions")
|
|
3231
|
+
.option("--refresh", "Search orgs from CF region endpoints and update cache")
|
|
3232
|
+
.option("--api <apiEndpoint>", "Limit org search/switch to one CF API endpoint")
|
|
3233
|
+
.option("--org <org>", "CF org name")
|
|
3234
|
+
.option("--space <space>", "CF space name")
|
|
3235
|
+
.action(runOrgCommand);
|
|
3236
|
+
|
|
3237
|
+
cfCommand
|
|
3238
|
+
.command("apps")
|
|
3239
|
+
.description("List BTP apps in current org and space with per-target cache")
|
|
3240
|
+
.option("--refresh", "Wait for fresh app list from cf apps and update cache")
|
|
3241
|
+
.option("--select", "Select one app and print its name")
|
|
3242
|
+
.action(runAppsCommand);
|
|
3243
|
+
|
|
3244
|
+
cfCommand
|
|
3245
|
+
.command("bind")
|
|
3246
|
+
.description("Run cds bind --to-app-services <app>")
|
|
3247
|
+
.option("--app <appName>", "BTP app name")
|
|
3248
|
+
.option("--cwd <path>", "Repository path", process.cwd())
|
|
3249
|
+
.option("--refresh", "Refresh app list before selecting")
|
|
3250
|
+
.action(runBindCommand);
|
|
3251
|
+
|
|
3252
|
+
cfCommand
|
|
3253
|
+
.command("env")
|
|
3254
|
+
.description("Export cf env <app> to clean JSON file")
|
|
3255
|
+
.option("--app <appName>", "BTP app name")
|
|
3256
|
+
.option("--out <fileName>", "Output file name", undefined)
|
|
3257
|
+
.option("--cwd <path>", "Repository path", process.cwd())
|
|
3258
|
+
.option("--refresh", "Refresh app list before selecting")
|
|
3259
|
+
.option("--raw", "Export raw cf env output instead of clean JSON")
|
|
3260
|
+
.action(runEnvCommand);
|
|
3261
|
+
|
|
3262
|
+
|
|
3263
|
+
cfCommand
|
|
3264
|
+
.command("logs")
|
|
3265
|
+
.description("View realtime or recent logs for a BTP app")
|
|
3266
|
+
.option("--app <appName>", "BTP app name")
|
|
3267
|
+
.option("--refresh", "Refresh app list before selecting")
|
|
3268
|
+
.option("--recent", "Show recent logs and exit")
|
|
3269
|
+
.option("--follow", "Follow realtime logs. This is the default when --recent is not used")
|
|
3270
|
+
.option("--out <fileName>", "Export logs to file. With realtime logs, append until Ctrl+C")
|
|
3271
|
+
.option("--instance <index>", "Filter logs by app instance index, for example 0 or 1")
|
|
3272
|
+
.option("--process <processName>", "Filter logs by process name, for example WEB")
|
|
3273
|
+
.action(runLogsCommand);
|
|
3274
|
+
|
|
3275
|
+
|
|
3276
|
+
cfCommand
|
|
3277
|
+
.command("debug")
|
|
3278
|
+
.description("Debug a deployed BTP Cloud Foundry Node.js app with selectable VS Code or Chrome mode")
|
|
3279
|
+
.option("--app <appName>", "BTP app name")
|
|
3280
|
+
.option("--refresh", "Refresh app list before selecting")
|
|
3281
|
+
.option("--instance <index>", "App instance index", "0")
|
|
3282
|
+
.option("--process <processName>", "CF process name for multi-process apps")
|
|
3283
|
+
.option("--local-port <port>", "Local inspector port", "9229")
|
|
3284
|
+
.option("--remote-port <port>", "Remote inspector port in app container", "9229")
|
|
3285
|
+
.option("--enable-ssh", "Run cf enable-ssh <app> before opening the debug tunnel")
|
|
3286
|
+
.option("--restart", "Restart app after --enable-ssh")
|
|
3287
|
+
.option("--check", "Run cf ssh-enabled <app> and exit")
|
|
3288
|
+
.option("--link-only", "Only print attach links/config for an already-open tunnel")
|
|
3289
|
+
.option("--vscode", "Use VS Code attach debug mode")
|
|
3290
|
+
.option("--chrome", "Use Chrome DevTools debug mode")
|
|
3291
|
+
.option("--config-only", "Only create/update .vscode/launch.json")
|
|
3292
|
+
.option("--open", "Open current folder in VS Code after creating launch.json")
|
|
3293
|
+
.option("--skip-org-select", "Use current CF org/space without asking")
|
|
3294
|
+
.action(runDebugCommand);
|
|
3295
|
+
|
|
3296
|
+
|
|
3297
|
+
cfCommand
|
|
3298
|
+
.command("http-watch")
|
|
3299
|
+
.alias("watch-http")
|
|
3300
|
+
.description("Watch incoming HTTP requests using existing CF/CDS/RTR logs. Stable and does not modify apps.")
|
|
3301
|
+
.option("--app <appName>", "BTP app name. Use comma-separated names to watch multiple apps")
|
|
3302
|
+
.option("--refresh", "Refresh app list before selecting")
|
|
3303
|
+
.option("--recent", "Parse recent logs and exit")
|
|
3304
|
+
.option("--out <fileName>", "Write parsed HTTP events to a file")
|
|
3305
|
+
.option("--skip-org-select", "Use current CF org/space without asking")
|
|
3306
|
+
.action(runHttpWatchCommand);
|
|
3307
|
+
|
|
3308
|
+
cfCommand
|
|
3309
|
+
.command("request-trace-doctor")
|
|
3310
|
+
.description("Diagnose why deep request-trace may not capture body/header in a BTP Node.js app")
|
|
3311
|
+
.option("--app <appName>", "BTP app name. Use comma-separated names")
|
|
3312
|
+
.option("--refresh", "Refresh app list before selecting")
|
|
3313
|
+
.option("--instance <index>", "App instance index", "0")
|
|
3314
|
+
.option("--process <processName>", "CF process name for multi-process apps")
|
|
3315
|
+
.option("--local-port <port>", "First local inspector port", "9329")
|
|
3316
|
+
.option("--remote-port <port>", "Remote inspector port in app container", "9229")
|
|
3317
|
+
.option("--max-body-bytes <bytes>", "Maximum request/response body bytes to print", "20000")
|
|
3318
|
+
.option("--skip-org-select", "Use current CF org/space without asking")
|
|
3319
|
+
.action(runRequestTraceDoctorCommand);
|
|
3320
|
+
|
|
3321
|
+
cfCommand
|
|
3322
|
+
.command("request-trace")
|
|
3323
|
+
.alias("network-trace")
|
|
3324
|
+
.alias("traffic")
|
|
3325
|
+
.description("Watch incoming HTTP requests from BTP Node.js apps without editing backend source code")
|
|
3326
|
+
.option("--app <appName>", "BTP app name. Use comma-separated names to trace multiple apps")
|
|
3327
|
+
.option("--refresh", "Refresh app list before selecting")
|
|
3328
|
+
.option("--instance <index>", "App instance index", "0")
|
|
3329
|
+
.option("--process <processName>", "CF process name for multi-process apps")
|
|
3330
|
+
.option("--local-port <port>", "First local inspector port", "9329")
|
|
3331
|
+
.option("--remote-port <port>", "Remote inspector port in app container", "9229")
|
|
3332
|
+
.option("--max-body-bytes <bytes>", "Maximum request/response body bytes to print", "20000")
|
|
3333
|
+
.option("--out <fileName>", "Export captured trace events to a JSONL file")
|
|
3334
|
+
.option("--skip-org-select", "Use current CF org/space without asking")
|
|
3335
|
+
.action(runRequestTraceCommand);
|
|
3336
|
+
|
|
3337
|
+
cfCommand
|
|
3338
|
+
.command("apps-cache-refresh")
|
|
3339
|
+
.description("Refresh cached cf apps for current target. Internal command used by smdg cf apps.")
|
|
3340
|
+
.action(runAppsCacheRefreshCommand);
|
|
3341
|
+
|
|
3342
|
+
registerCloudFoundryDbCommands(cfCommand);
|
|
3343
|
+
|
|
3344
|
+
cfCommand.command("cache").description("Print cached Cloud Foundry values").action(runCacheCommand);
|
|
3345
|
+
}
|