@saptools/cf-debugger 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -14,26 +14,38 @@ var CfDebuggerError = class extends Error {
14
14
  }
15
15
  };
16
16
 
17
- // src/debugger.ts
17
+ // src/debug-session/start.ts
18
18
  import { mkdir as mkdir3, rm } from "fs/promises";
19
- import { hostname as getHostname2 } from "os";
20
- import process3 from "process";
19
+ import process4 from "process";
21
20
 
22
- // src/cf.ts
23
- import { execFile, spawn } from "child_process";
21
+ // src/cloud-foundry/execute.ts
22
+ import { execFile } from "child_process";
24
23
  import { promisify } from "util";
25
24
  var execFileAsync = promisify(execFile);
26
25
  var MAX_BUFFER = 16 * 1024 * 1024;
27
26
  var CF_CLI_TIMEOUT_MS = 3e4;
28
- var CF_RESTART_TIMEOUT_MS = 12e4;
29
- var CF_SSH_SIGNAL_TIMEOUT_MS = 15e3;
30
- var CF_AUTH_MAX_ATTEMPTS = 3;
27
+ var REDACTED_ARG = "<redacted>";
31
28
  function buildEnv(cfHome) {
32
29
  return { ...process.env, CF_HOME: cfHome };
33
30
  }
34
31
  function resolveBin(context) {
35
32
  return context.command ?? process.env["CF_DEBUGGER_CF_BIN"] ?? "cf";
36
33
  }
34
+ function sensitiveArgs(args) {
35
+ if (args[0] !== "auth") {
36
+ return [];
37
+ }
38
+ return args.slice(1).filter((arg) => arg.length > 0);
39
+ }
40
+ function redactText(text, values) {
41
+ return values.reduce((current, value) => current.split(value).join(REDACTED_ARG), text);
42
+ }
43
+ function formatArgsForError(args) {
44
+ if (args[0] !== "auth") {
45
+ return args.join(" ");
46
+ }
47
+ return args.map((arg, index) => index === 0 ? arg : REDACTED_ARG).join(" ");
48
+ }
37
49
  async function runCf(args, context, timeoutMs = CF_CLI_TIMEOUT_MS) {
38
50
  try {
39
51
  const { stdout } = await execFileAsync(resolveBin(context), [...args], {
@@ -44,14 +56,20 @@ async function runCf(args, context, timeoutMs = CF_CLI_TIMEOUT_MS) {
44
56
  return stdout;
45
57
  } catch (err) {
46
58
  const e = err;
47
- const stderr = e.stderr?.trim() ?? "";
59
+ const redactionValues = sensitiveArgs(args);
60
+ const stderr = redactText(e.stderr?.trim() ?? "", redactionValues);
61
+ const fallbackMessage = redactText(e.message, redactionValues);
48
62
  throw new CfDebuggerError(
49
63
  "CF_CLI_FAILED",
50
- `cf ${args.join(" ")} failed: ${stderr.length > 0 ? stderr : e.message}`,
64
+ `cf ${formatArgsForError(args)} failed: ${stderr.length > 0 ? stderr : fallbackMessage}`,
51
65
  stderr
52
66
  );
53
67
  }
54
68
  }
69
+
70
+ // src/cloud-foundry/commands.ts
71
+ var CF_RESTART_TIMEOUT_MS = 12e4;
72
+ var CF_AUTH_MAX_ATTEMPTS = 3;
55
73
  async function cfApi(apiEndpoint, context) {
56
74
  await runCf(["api", apiEndpoint], context);
57
75
  }
@@ -117,6 +135,10 @@ async function cfEnableSsh(appName, context) {
117
135
  async function cfRestartApp(appName, context) {
118
136
  await runCf(["restart", appName], context, CF_RESTART_TIMEOUT_MS);
119
137
  }
138
+
139
+ // src/cloud-foundry/ssh.ts
140
+ import { spawn } from "child_process";
141
+ var CF_SSH_SIGNAL_TIMEOUT_MS = 15e3;
120
142
  async function cfSshOneShot(appName, command, context) {
121
143
  return await new Promise((resolve) => {
122
144
  const child = spawn(resolveBin(context), ["ssh", appName, "-c", command], {
@@ -191,7 +213,7 @@ function sessionCfHomeDir(sessionId) {
191
213
  return join(saptoolsDir(), CF_DEBUGGER_HOMES_DIRNAME, sessionId);
192
214
  }
193
215
 
194
- // src/port.ts
216
+ // src/network/ports.ts
195
217
  import { execFile as execFile2 } from "child_process";
196
218
  import { createConnection, createServer } from "net";
197
219
  import { promisify as promisify2 } from "util";
@@ -363,7 +385,7 @@ function listKnownRegionKeys() {
363
385
  return Object.keys(REGION_API_ENDPOINTS);
364
386
  }
365
387
 
366
- // src/state.ts
388
+ // src/session-state/store.ts
367
389
  import { randomUUID } from "crypto";
368
390
  import { mkdir as mkdir2, readFile, rename, writeFile } from "fs/promises";
369
391
  import { hostname as getHostname } from "os";
@@ -418,7 +440,7 @@ async function withFileLock(lockPath, work, options) {
418
440
  }
419
441
  }
420
442
 
421
- // src/state.ts
443
+ // src/session-state/store.ts
422
444
  async function readJsonFile(path) {
423
445
  let raw;
424
446
  try {
@@ -650,13 +672,29 @@ async function removeSession(sessionId) {
650
672
  });
651
673
  }
652
674
 
653
- // src/debugger.ts
675
+ // src/debug-session/constants.ts
654
676
  var DEFAULT_TUNNEL_READY_TIMEOUT_MS = 3e4;
655
677
  var POST_USR1_DELAY_MS = 300;
656
678
  var PORT_CLEANUP_DELAY_MS = 600;
657
679
  var CHILD_SIGTERM_GRACE_MS = 2e3;
658
680
  var PORT_RECLAIM_DELAY_MS = 250;
659
681
  var PID_TERMINATION_POLL_MS = 100;
682
+
683
+ // src/debug-session/orphans.ts
684
+ import { hostname as getHostname2 } from "os";
685
+ async function pruneAndCleanupOrphans() {
686
+ const result = await readAndPruneActiveSessions();
687
+ const host = getHostname2();
688
+ for (const removed of result.removed) {
689
+ if (removed.hostname === host) {
690
+ void killProcessOnPort(removed.localPort);
691
+ }
692
+ }
693
+ return result.sessions;
694
+ }
695
+
696
+ // src/debug-session/processes.ts
697
+ import process3 from "process";
660
698
  function signalPidOrGroup(pid, signal) {
661
699
  const isWindows = process3.platform === "win32";
662
700
  if (!isWindows) {
@@ -696,24 +734,16 @@ async function killProcessGroupOrProc(child, timeoutMs = CHILD_SIGTERM_GRACE_MS)
696
734
  }
697
735
  await terminatePidOrGroup(child.pid, timeoutMs);
698
736
  }
699
- async function pruneAndCleanupOrphans() {
700
- const result = await readAndPruneActiveSessions();
701
- const host = getHostname2();
702
- for (const removed of result.removed) {
703
- if (removed.hostname === host) {
704
- void killProcessOnPort(removed.localPort);
705
- }
706
- }
707
- return result.sessions;
708
- }
737
+
738
+ // src/debug-session/start.ts
709
739
  function checkAbort(signal) {
710
740
  if (signal?.aborted) {
711
741
  throw new CfDebuggerError("ABORTED", "Operation aborted by caller");
712
742
  }
713
743
  }
714
744
  function requireCredentials(options) {
715
- const email = options.email ?? process3.env["SAP_EMAIL"];
716
- const password = options.password ?? process3.env["SAP_PASSWORD"];
745
+ const email = options.email ?? process4.env["SAP_EMAIL"];
746
+ const password = options.password ?? process4.env["SAP_PASSWORD"];
717
747
  if (email === void 0 || email === "") {
718
748
  throw new CfDebuggerError(
719
749
  "MISSING_CREDENTIALS",
@@ -728,15 +758,7 @@ function requireCredentials(options) {
728
758
  }
729
759
  return { email, password };
730
760
  }
731
- async function startDebugger(options) {
732
- const { email, password } = requireCredentials(options);
733
- const apiEndpoint = resolveApiEndpoint(options.region, options.apiEndpoint);
734
- const tunnelReadyTimeoutMs = options.tunnelReadyTimeoutMs ?? DEFAULT_TUNNEL_READY_TIMEOUT_MS;
735
- const emit = (status, message) => {
736
- options.onStatus?.(status, message);
737
- };
738
- checkAbort(options.signal);
739
- await pruneAndCleanupOrphans();
761
+ async function registerSession(options, apiEndpoint) {
740
762
  const registration = await registerNewSession({
741
763
  region: options.region,
742
764
  org: options.org,
@@ -753,20 +775,151 @@ async function startDebugger(options) {
753
775
  `A debugger session is already running for ${sessionKeyString(options)} on port ${registration.existing.localPort.toString()} (pid ${registration.existing.pid.toString()}, sessionId ${registration.existing.sessionId}). Stop it first with \`cf-debugger stop\`.`
754
776
  );
755
777
  }
756
- const session = registration.session;
778
+ return registration.session;
779
+ }
780
+ async function loginAndTarget(options, apiEndpoint, email, password, context, sessionId, emit) {
781
+ emit("logging-in");
782
+ await updateSessionStatus(sessionId, "logging-in");
783
+ await cfLogin(apiEndpoint, email, password, context);
784
+ checkAbort(options.signal);
785
+ emit("targeting");
786
+ await updateSessionStatus(sessionId, "targeting");
787
+ await cfTarget(options.org, options.space, context);
788
+ checkAbort(options.signal);
789
+ }
790
+ async function signalRemoteNode(options, context, sessionId, emit) {
791
+ emit("signaling");
792
+ await updateSessionStatus(sessionId, "signaling");
793
+ const signalResult = await cfSshOneShot(options.app, `kill -s USR1 $(pidof node)`, context);
794
+ if (!isSshDisabledError(signalResult.stderr)) {
795
+ if (signalResult.exitCode === 0) {
796
+ return;
797
+ }
798
+ const detail = signalResult.stderr.trim().length > 0 ? signalResult.stderr.trim() : `exit code ${String(signalResult.exitCode)}`;
799
+ throw new CfDebuggerError(
800
+ "USR1_SIGNAL_FAILED",
801
+ `Failed to send SIGUSR1 to the Node.js process on ${options.app}: ${detail}`,
802
+ signalResult.stderr
803
+ );
804
+ }
805
+ const alreadyEnabled = await cfSshEnabled(options.app, context);
806
+ if (!alreadyEnabled) {
807
+ emit("ssh-enabling", "Enabling SSH on the app");
808
+ await updateSessionStatus(sessionId, "ssh-enabling");
809
+ await cfEnableSsh(options.app, context);
810
+ }
811
+ emit("ssh-restarting", "Restarting app so SSH becomes active");
812
+ await updateSessionStatus(sessionId, "ssh-restarting");
813
+ await cfRestartApp(options.app, context);
814
+ checkAbort(options.signal);
815
+ await retryRemoteSignal(options, context, sessionId, emit);
816
+ }
817
+ async function retryRemoteSignal(options, context, sessionId, emit) {
818
+ emit("signaling");
819
+ await updateSessionStatus(sessionId, "signaling");
820
+ const retrySignalResult = await cfSshOneShot(
821
+ options.app,
822
+ `kill -s USR1 $(pidof node)`,
823
+ context
824
+ );
825
+ if (retrySignalResult.exitCode === 0) {
826
+ return;
827
+ }
828
+ const detail = retrySignalResult.stderr.trim().length > 0 ? retrySignalResult.stderr.trim() : `exit code ${String(retrySignalResult.exitCode)}`;
829
+ throw new CfDebuggerError(
830
+ "USR1_SIGNAL_FAILED",
831
+ `Failed to send SIGUSR1 to the Node.js process on ${options.app} after enabling SSH: ${detail}`,
832
+ retrySignalResult.stderr
833
+ );
834
+ }
835
+ async function waitAfterSignal(signal) {
836
+ await new Promise((resolve) => {
837
+ setTimeout(resolve, POST_USR1_DELAY_MS);
838
+ });
839
+ checkAbort(signal);
840
+ }
841
+ async function ensurePortAvailable(localPort) {
842
+ if (await isPortFree(localPort)) {
843
+ return;
844
+ }
845
+ await killProcessOnPort(localPort);
846
+ await new Promise((resolve) => {
847
+ setTimeout(resolve, PORT_RECLAIM_DELAY_MS);
848
+ });
849
+ if (!await isPortFree(localPort)) {
850
+ throw new CfDebuggerError(
851
+ "PORT_UNAVAILABLE",
852
+ `Local port ${localPort.toString()} is in use and could not be reclaimed for the tunnel.`
853
+ );
854
+ }
855
+ }
856
+ async function openReadyTunnel(options, session, context, tunnelReadyTimeoutMs, onChild) {
857
+ await ensurePortAvailable(session.localPort);
858
+ const child = spawnSshTunnel(options.app, session.localPort, session.remotePort, context);
859
+ onChild(child);
860
+ if (child.pid !== void 0) {
861
+ await updateSessionPid(session.sessionId, child.pid);
862
+ }
863
+ const ready = await probeTunnelReady(session.localPort, tunnelReadyTimeoutMs);
864
+ checkAbort(options.signal);
865
+ if (!ready) {
866
+ throw new CfDebuggerError(
867
+ "TUNNEL_NOT_READY",
868
+ `SSH tunnel on port ${session.localPort.toString()} did not become ready within ${Math.round(tunnelReadyTimeoutMs / 1e3).toString()}s.`
869
+ );
870
+ }
871
+ const listeningPid = await findListeningProcessId(session.localPort);
872
+ const activePid = listeningPid ?? child.pid ?? session.pid;
873
+ if (activePid !== session.pid) {
874
+ await updateSessionPid(session.sessionId, activePid);
875
+ }
876
+ return { child, activePid };
877
+ }
878
+ function attachTunnelEvents(child, markClosed, resolveExit, emit) {
879
+ child.on("close", (code) => {
880
+ markClosed();
881
+ resolveExit(code);
882
+ });
883
+ child.on("error", (err) => {
884
+ emit("error", err.message);
885
+ });
886
+ }
887
+ function createHandle(session, emit, finalize, exitPromise) {
888
+ let disposePromise;
889
+ return {
890
+ session,
891
+ dispose: async () => {
892
+ disposePromise ??= (async () => {
893
+ emit("stopping");
894
+ await updateSessionStatus(session.sessionId, "stopping");
895
+ await finalize();
896
+ })();
897
+ await disposePromise;
898
+ },
899
+ waitForExit: async () => {
900
+ return await exitPromise;
901
+ }
902
+ };
903
+ }
904
+ async function startDebugger(options) {
905
+ const { email, password } = requireCredentials(options);
906
+ const apiEndpoint = resolveApiEndpoint(options.region, options.apiEndpoint);
907
+ const tunnelReadyTimeoutMs = options.tunnelReadyTimeoutMs ?? DEFAULT_TUNNEL_READY_TIMEOUT_MS;
908
+ const emit = (status, message) => {
909
+ options.onStatus?.(status, message);
910
+ };
911
+ checkAbort(options.signal);
912
+ await pruneAndCleanupOrphans();
913
+ const session = await registerSession(options, apiEndpoint);
757
914
  const context = { cfHome: session.cfHomeDir };
758
915
  let child;
759
916
  let tunnelClosed = false;
760
- let exitResolve;
917
+ let exitResolve = (_code) => {
918
+ throw new Error("Exit resolver was used before initialization");
919
+ };
761
920
  const exitPromise = new Promise((resolve) => {
762
921
  exitResolve = resolve;
763
922
  });
764
- const cleanupFilesystem = async () => {
765
- try {
766
- await rm(session.cfHomeDir, { recursive: true, force: true });
767
- } catch {
768
- }
769
- };
770
923
  const finalize = async () => {
771
924
  if (!tunnelClosed) {
772
925
  tunnelClosed = true;
@@ -778,125 +931,37 @@ async function startDebugger(options) {
778
931
  }, PORT_CLEANUP_DELAY_MS);
779
932
  }
780
933
  await removeSession(session.sessionId);
781
- await cleanupFilesystem();
934
+ await cleanupFilesystem(session.cfHomeDir);
782
935
  emit("stopped");
783
936
  };
784
937
  try {
785
938
  await mkdir3(session.cfHomeDir, { recursive: true });
786
- emit("logging-in");
787
- await updateSessionStatus(session.sessionId, "logging-in");
788
- await cfLogin(apiEndpoint, email, password, context);
789
- checkAbort(options.signal);
790
- emit("targeting");
791
- await updateSessionStatus(session.sessionId, "targeting");
792
- await cfTarget(options.org, options.space, context);
793
- checkAbort(options.signal);
939
+ await loginAndTarget(options, apiEndpoint, email, password, context, session.sessionId, emit);
794
940
  await killProcessOnPort(session.localPort);
795
941
  await new Promise((resolve) => {
796
942
  setTimeout(resolve, 200);
797
943
  });
798
- emit("signaling");
799
- await updateSessionStatus(session.sessionId, "signaling");
800
- const signalResult = await cfSshOneShot(
801
- options.app,
802
- `kill -s USR1 $(pidof node)`,
803
- context
804
- );
805
- if (isSshDisabledError(signalResult.stderr)) {
806
- const alreadyEnabled = await cfSshEnabled(options.app, context);
807
- if (!alreadyEnabled) {
808
- emit("ssh-enabling", "Enabling SSH on the app");
809
- await updateSessionStatus(session.sessionId, "ssh-enabling");
810
- await cfEnableSsh(options.app, context);
811
- }
812
- emit("ssh-restarting", "Restarting app so SSH becomes active");
813
- await updateSessionStatus(session.sessionId, "ssh-restarting");
814
- await cfRestartApp(options.app, context);
815
- checkAbort(options.signal);
816
- emit("signaling");
817
- await updateSessionStatus(session.sessionId, "signaling");
818
- const retrySignalResult = await cfSshOneShot(
819
- options.app,
820
- `kill -s USR1 $(pidof node)`,
821
- context
822
- );
823
- if (retrySignalResult.exitCode !== 0) {
824
- const detail = retrySignalResult.stderr.trim().length > 0 ? retrySignalResult.stderr.trim() : `exit code ${String(retrySignalResult.exitCode)}`;
825
- throw new CfDebuggerError(
826
- "USR1_SIGNAL_FAILED",
827
- `Failed to send SIGUSR1 to the Node.js process on ${options.app} after enabling SSH: ${detail}`,
828
- retrySignalResult.stderr
829
- );
830
- }
831
- } else if (signalResult.exitCode !== 0) {
832
- const detail = signalResult.stderr.trim().length > 0 ? signalResult.stderr.trim() : `exit code ${String(signalResult.exitCode)}`;
833
- throw new CfDebuggerError(
834
- "USR1_SIGNAL_FAILED",
835
- `Failed to send SIGUSR1 to the Node.js process on ${options.app}: ${detail}`,
836
- signalResult.stderr
837
- );
838
- }
839
- await new Promise((resolve) => {
840
- setTimeout(resolve, POST_USR1_DELAY_MS);
841
- });
842
- checkAbort(options.signal);
944
+ await signalRemoteNode(options, context, session.sessionId, emit);
945
+ await waitAfterSignal(options.signal);
843
946
  emit("tunneling");
844
947
  await updateSessionStatus(session.sessionId, "tunneling");
845
- if (!await isPortFree(session.localPort)) {
846
- await killProcessOnPort(session.localPort);
847
- await new Promise((resolve) => {
848
- setTimeout(resolve, PORT_RECLAIM_DELAY_MS);
849
- });
850
- if (!await isPortFree(session.localPort)) {
851
- throw new CfDebuggerError(
852
- "PORT_UNAVAILABLE",
853
- `Local port ${session.localPort.toString()} is in use and could not be reclaimed for the tunnel.`
854
- );
948
+ const tunnel = await openReadyTunnel(
949
+ options,
950
+ session,
951
+ context,
952
+ tunnelReadyTimeoutMs,
953
+ (tunnelChild) => {
954
+ child = tunnelChild;
955
+ attachTunnelEvents(tunnelChild, () => {
956
+ tunnelClosed = true;
957
+ }, exitResolve, emit);
855
958
  }
856
- }
857
- child = spawnSshTunnel(options.app, session.localPort, session.remotePort, context);
858
- if (child.pid !== void 0) {
859
- await updateSessionPid(session.sessionId, child.pid);
860
- }
861
- child.on("close", (code) => {
862
- tunnelClosed = true;
863
- exitResolve?.(code);
864
- });
865
- child.on("error", (err) => {
866
- emit("error", err.message);
867
- });
868
- const ready = await probeTunnelReady(session.localPort, tunnelReadyTimeoutMs);
869
- checkAbort(options.signal);
870
- if (!ready) {
871
- throw new CfDebuggerError(
872
- "TUNNEL_NOT_READY",
873
- `SSH tunnel on port ${session.localPort.toString()} did not become ready within ${Math.round(tunnelReadyTimeoutMs / 1e3).toString()}s.`
874
- );
875
- }
876
- const listeningPid = await findListeningProcessId(session.localPort);
877
- const activePid = listeningPid ?? child.pid ?? session.pid;
878
- if (activePid !== session.pid) {
879
- await updateSessionPid(session.sessionId, activePid);
880
- }
959
+ );
960
+ child = tunnel.child;
881
961
  emit("ready");
882
962
  const readySession = await updateSessionStatus(session.sessionId, "ready");
883
- const activeSession = readySession ?? { ...session, pid: activePid, status: "ready" };
884
- let disposePromise;
885
- const handle = {
886
- session: activeSession,
887
- dispose: async () => {
888
- disposePromise ??= (async () => {
889
- emit("stopping");
890
- await updateSessionStatus(session.sessionId, "stopping");
891
- await finalize();
892
- })();
893
- await disposePromise;
894
- },
895
- waitForExit: async () => {
896
- return await exitPromise;
897
- }
898
- };
899
- return handle;
963
+ const activeSession = readySession ?? { ...session, pid: tunnel.activePid, status: "ready" };
964
+ return createHandle(activeSession, emit, finalize, exitPromise);
900
965
  } catch (err) {
901
966
  const message = err instanceof Error ? err.message : String(err);
902
967
  emit("error", message);
@@ -904,6 +969,16 @@ async function startDebugger(options) {
904
969
  throw err;
905
970
  }
906
971
  }
972
+ async function cleanupFilesystem(cfHomeDir) {
973
+ try {
974
+ await rm(cfHomeDir, { recursive: true, force: true });
975
+ } catch {
976
+ }
977
+ }
978
+
979
+ // src/debug-session/sessions.ts
980
+ import { rm as rm2 } from "fs/promises";
981
+ import process5 from "process";
907
982
  async function stopDebugger(options) {
908
983
  const sessions = await pruneAndCleanupOrphans();
909
984
  let target;
@@ -916,7 +991,7 @@ async function stopDebugger(options) {
916
991
  if (target === void 0) {
917
992
  return void 0;
918
993
  }
919
- if (target.pid !== process3.pid) {
994
+ if (target.pid !== process5.pid) {
920
995
  try {
921
996
  await terminatePidOrGroup(target.pid);
922
997
  } catch {
@@ -927,7 +1002,7 @@ async function stopDebugger(options) {
927
1002
  }, PORT_CLEANUP_DELAY_MS);
928
1003
  const removed = await removeSession(target.sessionId);
929
1004
  try {
930
- await rm(target.cfHomeDir, { recursive: true, force: true });
1005
+ await rm2(target.cfHomeDir, { recursive: true, force: true });
931
1006
  } catch {
932
1007
  }
933
1008
  return removed ?? target;