@saptools/cf-debugger 0.1.4 → 0.1.5

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,20 +14,16 @@ 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;
31
27
  function buildEnv(cfHome) {
32
28
  return { ...process.env, CF_HOME: cfHome };
33
29
  }
@@ -52,6 +48,10 @@ async function runCf(args, context, timeoutMs = CF_CLI_TIMEOUT_MS) {
52
48
  );
53
49
  }
54
50
  }
51
+
52
+ // src/cloud-foundry/commands.ts
53
+ var CF_RESTART_TIMEOUT_MS = 12e4;
54
+ var CF_AUTH_MAX_ATTEMPTS = 3;
55
55
  async function cfApi(apiEndpoint, context) {
56
56
  await runCf(["api", apiEndpoint], context);
57
57
  }
@@ -117,6 +117,10 @@ async function cfEnableSsh(appName, context) {
117
117
  async function cfRestartApp(appName, context) {
118
118
  await runCf(["restart", appName], context, CF_RESTART_TIMEOUT_MS);
119
119
  }
120
+
121
+ // src/cloud-foundry/ssh.ts
122
+ import { spawn } from "child_process";
123
+ var CF_SSH_SIGNAL_TIMEOUT_MS = 15e3;
120
124
  async function cfSshOneShot(appName, command, context) {
121
125
  return await new Promise((resolve) => {
122
126
  const child = spawn(resolveBin(context), ["ssh", appName, "-c", command], {
@@ -191,7 +195,7 @@ function sessionCfHomeDir(sessionId) {
191
195
  return join(saptoolsDir(), CF_DEBUGGER_HOMES_DIRNAME, sessionId);
192
196
  }
193
197
 
194
- // src/port.ts
198
+ // src/network/ports.ts
195
199
  import { execFile as execFile2 } from "child_process";
196
200
  import { createConnection, createServer } from "net";
197
201
  import { promisify as promisify2 } from "util";
@@ -363,7 +367,7 @@ function listKnownRegionKeys() {
363
367
  return Object.keys(REGION_API_ENDPOINTS);
364
368
  }
365
369
 
366
- // src/state.ts
370
+ // src/session-state/store.ts
367
371
  import { randomUUID } from "crypto";
368
372
  import { mkdir as mkdir2, readFile, rename, writeFile } from "fs/promises";
369
373
  import { hostname as getHostname } from "os";
@@ -418,7 +422,7 @@ async function withFileLock(lockPath, work, options) {
418
422
  }
419
423
  }
420
424
 
421
- // src/state.ts
425
+ // src/session-state/store.ts
422
426
  async function readJsonFile(path) {
423
427
  let raw;
424
428
  try {
@@ -650,13 +654,29 @@ async function removeSession(sessionId) {
650
654
  });
651
655
  }
652
656
 
653
- // src/debugger.ts
657
+ // src/debug-session/constants.ts
654
658
  var DEFAULT_TUNNEL_READY_TIMEOUT_MS = 3e4;
655
659
  var POST_USR1_DELAY_MS = 300;
656
660
  var PORT_CLEANUP_DELAY_MS = 600;
657
661
  var CHILD_SIGTERM_GRACE_MS = 2e3;
658
662
  var PORT_RECLAIM_DELAY_MS = 250;
659
663
  var PID_TERMINATION_POLL_MS = 100;
664
+
665
+ // src/debug-session/orphans.ts
666
+ import { hostname as getHostname2 } from "os";
667
+ async function pruneAndCleanupOrphans() {
668
+ const result = await readAndPruneActiveSessions();
669
+ const host = getHostname2();
670
+ for (const removed of result.removed) {
671
+ if (removed.hostname === host) {
672
+ void killProcessOnPort(removed.localPort);
673
+ }
674
+ }
675
+ return result.sessions;
676
+ }
677
+
678
+ // src/debug-session/processes.ts
679
+ import process3 from "process";
660
680
  function signalPidOrGroup(pid, signal) {
661
681
  const isWindows = process3.platform === "win32";
662
682
  if (!isWindows) {
@@ -696,24 +716,16 @@ async function killProcessGroupOrProc(child, timeoutMs = CHILD_SIGTERM_GRACE_MS)
696
716
  }
697
717
  await terminatePidOrGroup(child.pid, timeoutMs);
698
718
  }
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
- }
719
+
720
+ // src/debug-session/start.ts
709
721
  function checkAbort(signal) {
710
722
  if (signal?.aborted) {
711
723
  throw new CfDebuggerError("ABORTED", "Operation aborted by caller");
712
724
  }
713
725
  }
714
726
  function requireCredentials(options) {
715
- const email = options.email ?? process3.env["SAP_EMAIL"];
716
- const password = options.password ?? process3.env["SAP_PASSWORD"];
727
+ const email = options.email ?? process4.env["SAP_EMAIL"];
728
+ const password = options.password ?? process4.env["SAP_PASSWORD"];
717
729
  if (email === void 0 || email === "") {
718
730
  throw new CfDebuggerError(
719
731
  "MISSING_CREDENTIALS",
@@ -728,15 +740,7 @@ function requireCredentials(options) {
728
740
  }
729
741
  return { email, password };
730
742
  }
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();
743
+ async function registerSession(options, apiEndpoint) {
740
744
  const registration = await registerNewSession({
741
745
  region: options.region,
742
746
  org: options.org,
@@ -753,20 +757,151 @@ async function startDebugger(options) {
753
757
  `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
758
  );
755
759
  }
756
- const session = registration.session;
760
+ return registration.session;
761
+ }
762
+ async function loginAndTarget(options, apiEndpoint, email, password, context, sessionId, emit) {
763
+ emit("logging-in");
764
+ await updateSessionStatus(sessionId, "logging-in");
765
+ await cfLogin(apiEndpoint, email, password, context);
766
+ checkAbort(options.signal);
767
+ emit("targeting");
768
+ await updateSessionStatus(sessionId, "targeting");
769
+ await cfTarget(options.org, options.space, context);
770
+ checkAbort(options.signal);
771
+ }
772
+ async function signalRemoteNode(options, context, sessionId, emit) {
773
+ emit("signaling");
774
+ await updateSessionStatus(sessionId, "signaling");
775
+ const signalResult = await cfSshOneShot(options.app, `kill -s USR1 $(pidof node)`, context);
776
+ if (!isSshDisabledError(signalResult.stderr)) {
777
+ if (signalResult.exitCode === 0) {
778
+ return;
779
+ }
780
+ const detail = signalResult.stderr.trim().length > 0 ? signalResult.stderr.trim() : `exit code ${String(signalResult.exitCode)}`;
781
+ throw new CfDebuggerError(
782
+ "USR1_SIGNAL_FAILED",
783
+ `Failed to send SIGUSR1 to the Node.js process on ${options.app}: ${detail}`,
784
+ signalResult.stderr
785
+ );
786
+ }
787
+ const alreadyEnabled = await cfSshEnabled(options.app, context);
788
+ if (!alreadyEnabled) {
789
+ emit("ssh-enabling", "Enabling SSH on the app");
790
+ await updateSessionStatus(sessionId, "ssh-enabling");
791
+ await cfEnableSsh(options.app, context);
792
+ }
793
+ emit("ssh-restarting", "Restarting app so SSH becomes active");
794
+ await updateSessionStatus(sessionId, "ssh-restarting");
795
+ await cfRestartApp(options.app, context);
796
+ checkAbort(options.signal);
797
+ await retryRemoteSignal(options, context, sessionId, emit);
798
+ }
799
+ async function retryRemoteSignal(options, context, sessionId, emit) {
800
+ emit("signaling");
801
+ await updateSessionStatus(sessionId, "signaling");
802
+ const retrySignalResult = await cfSshOneShot(
803
+ options.app,
804
+ `kill -s USR1 $(pidof node)`,
805
+ context
806
+ );
807
+ if (retrySignalResult.exitCode === 0) {
808
+ return;
809
+ }
810
+ const detail = retrySignalResult.stderr.trim().length > 0 ? retrySignalResult.stderr.trim() : `exit code ${String(retrySignalResult.exitCode)}`;
811
+ throw new CfDebuggerError(
812
+ "USR1_SIGNAL_FAILED",
813
+ `Failed to send SIGUSR1 to the Node.js process on ${options.app} after enabling SSH: ${detail}`,
814
+ retrySignalResult.stderr
815
+ );
816
+ }
817
+ async function waitAfterSignal(signal) {
818
+ await new Promise((resolve) => {
819
+ setTimeout(resolve, POST_USR1_DELAY_MS);
820
+ });
821
+ checkAbort(signal);
822
+ }
823
+ async function ensurePortAvailable(localPort) {
824
+ if (await isPortFree(localPort)) {
825
+ return;
826
+ }
827
+ await killProcessOnPort(localPort);
828
+ await new Promise((resolve) => {
829
+ setTimeout(resolve, PORT_RECLAIM_DELAY_MS);
830
+ });
831
+ if (!await isPortFree(localPort)) {
832
+ throw new CfDebuggerError(
833
+ "PORT_UNAVAILABLE",
834
+ `Local port ${localPort.toString()} is in use and could not be reclaimed for the tunnel.`
835
+ );
836
+ }
837
+ }
838
+ async function openReadyTunnel(options, session, context, tunnelReadyTimeoutMs, onChild) {
839
+ await ensurePortAvailable(session.localPort);
840
+ const child = spawnSshTunnel(options.app, session.localPort, session.remotePort, context);
841
+ onChild(child);
842
+ if (child.pid !== void 0) {
843
+ await updateSessionPid(session.sessionId, child.pid);
844
+ }
845
+ const ready = await probeTunnelReady(session.localPort, tunnelReadyTimeoutMs);
846
+ checkAbort(options.signal);
847
+ if (!ready) {
848
+ throw new CfDebuggerError(
849
+ "TUNNEL_NOT_READY",
850
+ `SSH tunnel on port ${session.localPort.toString()} did not become ready within ${Math.round(tunnelReadyTimeoutMs / 1e3).toString()}s.`
851
+ );
852
+ }
853
+ const listeningPid = await findListeningProcessId(session.localPort);
854
+ const activePid = listeningPid ?? child.pid ?? session.pid;
855
+ if (activePid !== session.pid) {
856
+ await updateSessionPid(session.sessionId, activePid);
857
+ }
858
+ return { child, activePid };
859
+ }
860
+ function attachTunnelEvents(child, markClosed, resolveExit, emit) {
861
+ child.on("close", (code) => {
862
+ markClosed();
863
+ resolveExit(code);
864
+ });
865
+ child.on("error", (err) => {
866
+ emit("error", err.message);
867
+ });
868
+ }
869
+ function createHandle(session, emit, finalize, exitPromise) {
870
+ let disposePromise;
871
+ return {
872
+ session,
873
+ dispose: async () => {
874
+ disposePromise ??= (async () => {
875
+ emit("stopping");
876
+ await updateSessionStatus(session.sessionId, "stopping");
877
+ await finalize();
878
+ })();
879
+ await disposePromise;
880
+ },
881
+ waitForExit: async () => {
882
+ return await exitPromise;
883
+ }
884
+ };
885
+ }
886
+ async function startDebugger(options) {
887
+ const { email, password } = requireCredentials(options);
888
+ const apiEndpoint = resolveApiEndpoint(options.region, options.apiEndpoint);
889
+ const tunnelReadyTimeoutMs = options.tunnelReadyTimeoutMs ?? DEFAULT_TUNNEL_READY_TIMEOUT_MS;
890
+ const emit = (status, message) => {
891
+ options.onStatus?.(status, message);
892
+ };
893
+ checkAbort(options.signal);
894
+ await pruneAndCleanupOrphans();
895
+ const session = await registerSession(options, apiEndpoint);
757
896
  const context = { cfHome: session.cfHomeDir };
758
897
  let child;
759
898
  let tunnelClosed = false;
760
- let exitResolve;
899
+ let exitResolve = (_code) => {
900
+ throw new Error("Exit resolver was used before initialization");
901
+ };
761
902
  const exitPromise = new Promise((resolve) => {
762
903
  exitResolve = resolve;
763
904
  });
764
- const cleanupFilesystem = async () => {
765
- try {
766
- await rm(session.cfHomeDir, { recursive: true, force: true });
767
- } catch {
768
- }
769
- };
770
905
  const finalize = async () => {
771
906
  if (!tunnelClosed) {
772
907
  tunnelClosed = true;
@@ -778,125 +913,36 @@ async function startDebugger(options) {
778
913
  }, PORT_CLEANUP_DELAY_MS);
779
914
  }
780
915
  await removeSession(session.sessionId);
781
- await cleanupFilesystem();
916
+ await cleanupFilesystem(session.cfHomeDir);
782
917
  emit("stopped");
783
918
  };
784
919
  try {
785
920
  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);
921
+ await loginAndTarget(options, apiEndpoint, email, password, context, session.sessionId, emit);
794
922
  await killProcessOnPort(session.localPort);
795
923
  await new Promise((resolve) => {
796
924
  setTimeout(resolve, 200);
797
925
  });
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);
926
+ await signalRemoteNode(options, context, session.sessionId, emit);
927
+ await waitAfterSignal(options.signal);
843
928
  emit("tunneling");
844
929
  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
- );
930
+ const tunnel = await openReadyTunnel(
931
+ options,
932
+ session,
933
+ context,
934
+ tunnelReadyTimeoutMs,
935
+ (tunnelChild) => {
936
+ attachTunnelEvents(tunnelChild, () => {
937
+ tunnelClosed = true;
938
+ }, exitResolve, emit);
855
939
  }
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
- }
940
+ );
941
+ child = tunnel.child;
881
942
  emit("ready");
882
943
  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;
944
+ const activeSession = readySession ?? { ...session, pid: tunnel.activePid, status: "ready" };
945
+ return createHandle(activeSession, emit, finalize, exitPromise);
900
946
  } catch (err) {
901
947
  const message = err instanceof Error ? err.message : String(err);
902
948
  emit("error", message);
@@ -904,6 +950,16 @@ async function startDebugger(options) {
904
950
  throw err;
905
951
  }
906
952
  }
953
+ async function cleanupFilesystem(cfHomeDir) {
954
+ try {
955
+ await rm(cfHomeDir, { recursive: true, force: true });
956
+ } catch {
957
+ }
958
+ }
959
+
960
+ // src/debug-session/sessions.ts
961
+ import { rm as rm2 } from "fs/promises";
962
+ import process5 from "process";
907
963
  async function stopDebugger(options) {
908
964
  const sessions = await pruneAndCleanupOrphans();
909
965
  let target;
@@ -916,7 +972,7 @@ async function stopDebugger(options) {
916
972
  if (target === void 0) {
917
973
  return void 0;
918
974
  }
919
- if (target.pid !== process3.pid) {
975
+ if (target.pid !== process5.pid) {
920
976
  try {
921
977
  await terminatePidOrGroup(target.pid);
922
978
  } catch {
@@ -927,7 +983,7 @@ async function stopDebugger(options) {
927
983
  }, PORT_CLEANUP_DELAY_MS);
928
984
  const removed = await removeSession(target.sessionId);
929
985
  try {
930
- await rm(target.cfHomeDir, { recursive: true, force: true });
986
+ await rm2(target.cfHomeDir, { recursive: true, force: true });
931
987
  } catch {
932
988
  }
933
989
  return removed ?? target;