@saptools/cf-debugger 0.1.12 → 0.1.14

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 CHANGED
@@ -12,7 +12,7 @@ Signal the remote process, enable SSH if needed, forward `9229` to a free local
12
12
  [![install size](https://packagephobia.com/badge?p=@saptools/cf-debugger)](https://packagephobia.com/result?p=@saptools/cf-debugger)
13
13
  [![types](https://img.shields.io/npm/types/@saptools/cf-debugger.svg?style=flat&color=3178C6&logo=typescript&logoColor=white)](https://www.typescriptlang.org)
14
14
 
15
- [Install](#-install) • [Quick Start](#-quick-start) • [CLI](#-cli) • [API](#-programmatic-usage) • [How it works](#-how-it-works) • [FAQ](#-faq)
15
+ [Install](#-install) • [Quick Start](#-quick-start) • [CLI](#-cli) • [How it works](#-how-it-works) • [FAQ](#-faq)
16
16
 
17
17
  </div>
18
18
 
@@ -23,7 +23,7 @@ Signal the remote process, enable SSH if needed, forward `9229` to a free local
23
23
  - 🚀 **One-shot tunnel** — auth, target, SSH-enable, USR1 signal, port forward, readiness probe — all hidden behind `cf-debugger start`
24
24
  - 🧵 **Multi-debugger concurrency** — run N debuggers for N apps at once; each session gets its own local port, isolated `CF_HOME`, and an entry in the shared state file
25
25
  - 🛡️ **Duplicate-session protection** — the same `region/org/space/app` cannot be debugged twice simultaneously (returns `SESSION_ALREADY_RUNNING`)
26
- - 🧹 **Crash-proof state** — stale session entries are auto-pruned on next read using PID liveness checks
26
+ - 🧹 **Crash-proof state** — stale session entries are auto-pruned on list/status/start/stop using PID, ready-port health, and listener-owner checks
27
27
  - 🔌 **Deterministic ports** — auto-assigned from a safe range (`20000–20999`), or pick your own with `--port`
28
28
  - 🧩 **CLI & typed API** — every command has a zero-config Node.js equivalent with full TypeScript definitions
29
29
  - 🪶 **Small + boring** — one runtime dep (`commander`), no daemons, no magic
@@ -104,7 +104,7 @@ restart, and the one-shot SIGUSR1 SSH command) each allow up to 180 seconds.
104
104
 
105
105
  ### ⏹️ `cf-debugger stop`
106
106
 
107
- Stop a specific session or everything at once.
107
+ Stop a specific session or everything at once. If a matching state entry is already stale, `stop --session-id` removes it idempotently and reports the stale cleanup instead of failing. For bare app names, matching still uses the current CF target; use `--session-id` or the full `region/org/space/app` selector when the listed session belongs to a different target.
108
108
 
109
109
  ```bash
110
110
  cf-debugger stop --region eu10 --org my-org --space dev --app my-app
@@ -120,7 +120,7 @@ cf-debugger stop --all
120
120
 
121
121
  ### 📋 `cf-debugger list`
122
122
 
123
- Print every active session this machine owns as JSON.
123
+ Print healthy active sessions as JSON. `list` first prunes current-host entries whose PID is gone or whose `ready` local inspector port no longer accepts TCP connections.
124
124
 
125
125
  ```bash
126
126
  cf-debugger list | jq '.[] | {app, localPort, status}'
@@ -128,7 +128,7 @@ cf-debugger list | jq '.[] | {app, localPort, status}'
128
128
 
129
129
  ### 🔍 `cf-debugger status`
130
130
 
131
- Print one session by key (or `null` if no active session matches).
131
+ Print one healthy session by key (or `null` if no active session matches after the same pruning used by `list`).
132
132
 
133
133
  ```bash
134
134
  cf-debugger status --region eu10 --org my-org --space dev --app my-app
@@ -136,70 +136,6 @@ cf-debugger status --region eu10 --org my-org --space dev --app my-app
136
136
 
137
137
  ---
138
138
 
139
- ## 🧑‍💻 Programmatic Usage
140
-
141
- ```ts
142
- import {
143
- startDebugger,
144
- stopDebugger,
145
- listSessions,
146
- getSession,
147
- resolveApiEndpoint,
148
- } from "@saptools/cf-debugger";
149
-
150
- const handle = await startDebugger({
151
- region: "eu10",
152
- org: "my-org",
153
- space: "dev",
154
- app: "my-app",
155
- email: process.env["SAP_EMAIL"],
156
- password: process.env["SAP_PASSWORD"],
157
- verbose: true,
158
- onStatus: (status, message) => {
159
- console.log(`[${status}]`, message ?? "");
160
- },
161
- });
162
-
163
- console.log(`Attach your debugger to localhost:${handle.session.localPort}`);
164
-
165
- // Later — shut the tunnel down and clean up state:
166
- await handle.dispose();
167
- ```
168
-
169
- <details>
170
- <summary><b>📚 Full export list</b></summary>
171
-
172
- | Export | Description |
173
- | --- | --- |
174
- | `startDebugger(options)` | Open a tunnel; returns a `DebuggerHandle` |
175
- | `stopDebugger({ sessionId?, key? })` | Stop one session by id or by key |
176
- | `stopAllDebuggers()` | Stop every session owned by this process/machine |
177
- | `listSessions()` | Return every live session as `ActiveSession[]` |
178
- | `getSession(key)` | Return one session matching `{ region, org, space, app }` |
179
- | `resolveApiEndpoint(key, override?)` | Map a region key to its API endpoint |
180
- | `sessionKeyString(key)` | Stable string form of a session key |
181
- | `CfDebuggerError` | Rich error class with typed `code` |
182
-
183
- </details>
184
-
185
- <details>
186
- <summary><b>🧪 Error codes</b></summary>
187
-
188
- | Code | When |
189
- | --- | --- |
190
- | `MISSING_CREDENTIALS` | No `SAP_EMAIL` / `SAP_PASSWORD` in env or options |
191
- | `SESSION_ALREADY_RUNNING` | A session already exists for the same `region/org/space/app` |
192
- | `CF_LOGIN_FAILED` | `cf api` / `cf auth` rejected the credentials |
193
- | `CF_TARGET_FAILED` | Org or space not reachable |
194
- | `SSH_NOT_ENABLED` | SSH disabled at space or app level and could not be enabled |
195
- | `USR1_SIGNAL_FAILED` | Remote `kill -s USR1` failed, timed out, or was terminated by a signal |
196
- | `TUNNEL_NOT_READY` | Inspector didn't respond on port 9229 before timeout |
197
- | `PORT_UNAVAILABLE` | Preferred local port is taken and could not be freed |
198
-
199
- </details>
200
-
201
- ---
202
-
203
139
  ## 🔭 How it works
204
140
 
205
141
  ```
@@ -220,8 +156,8 @@ Each step emits a status update (`logging-in`, `targeting`, `ssh-enabling`, `sig
220
156
  - **Atomic state** — `~/.saptools/cf-debugger-state.json` is written via temp-file + `rename`, guarded by a short-lived `.lock` file (`open(..., "wx")`).
221
157
  - **Port allocation** — on register, ports already used by other sessions are excluded; the first free port in `20000–20999` wins.
222
158
  - **Isolated CF homes** — each session runs with its own `CF_HOME` (`~/.saptools/cf-debugger-homes/<sessionId>/`), so `cf target` in one terminal can't clobber another.
223
- - **Stale pruning** — reading the state file checks every recorded PID with `process.kill(pid, 0)`; dead entries are dropped before returning the list.
224
- - **Duplicate guard** — trying to start a second tunnel for the same `region/org/space/app` fails fast with `SESSION_ALREADY_RUNNING` instead of racing for the port.
159
+ - **Stale pruning** — list/status/start/stop check current-host PIDs with `process.kill(pid, 0)` and verify that `ready` sessions still accept TCP on `127.0.0.1:<localPort>` and, when detectable, that the listener is still the recorded tunnel PID; unhealthy entries are dropped before results or duplicate checks are returned.
160
+ - **Duplicate guard** — trying to start a second healthy tunnel for the same `region/org/space/app` fails fast with `SESSION_ALREADY_RUNNING` instead of racing for the port; stale same-key entries are pruned so a fresh tunnel can recover.
225
161
 
226
162
  ---
227
163
 
package/dist/cli.js CHANGED
@@ -482,26 +482,29 @@ async function isPortFree(port) {
482
482
  server.listen(port, "127.0.0.1");
483
483
  });
484
484
  }
485
+ async function isPortListening(port, timeoutMs = 200) {
486
+ return await new Promise((resolve) => {
487
+ const socket = createConnection({ port, host: "127.0.0.1" });
488
+ socket.setTimeout(timeoutMs);
489
+ socket.once("connect", () => {
490
+ socket.destroy();
491
+ resolve(true);
492
+ });
493
+ socket.once("error", () => {
494
+ socket.destroy();
495
+ resolve(false);
496
+ });
497
+ socket.once("timeout", () => {
498
+ socket.destroy();
499
+ resolve(false);
500
+ });
501
+ });
502
+ }
485
503
  async function probeTunnelReady(port, timeoutMs) {
486
504
  const pollIntervalMs = 250;
487
505
  const started = Date.now();
488
506
  while (Date.now() - started < timeoutMs) {
489
- const connected = await new Promise((resolve) => {
490
- const socket = createConnection({ port, host: "127.0.0.1" });
491
- socket.setTimeout(200);
492
- socket.once("connect", () => {
493
- socket.destroy();
494
- resolve(true);
495
- });
496
- socket.once("error", () => {
497
- socket.destroy();
498
- resolve(false);
499
- });
500
- socket.once("timeout", () => {
501
- socket.destroy();
502
- resolve(false);
503
- });
504
- });
507
+ const connected = await isPortListening(port);
505
508
  if (connected) {
506
509
  return true;
507
510
  }
@@ -653,14 +656,31 @@ function isPidAlive(pid) {
653
656
  return true;
654
657
  }
655
658
  }
656
- function filterStaleSessions(sessions) {
659
+ async function isSessionHealthy(session, host) {
660
+ if (session.hostname !== host) {
661
+ return true;
662
+ }
663
+ if (!isPidAlive(session.pid)) {
664
+ return false;
665
+ }
666
+ if (session.status !== "ready") {
667
+ return true;
668
+ }
669
+ if (!await isPortListening(session.localPort)) {
670
+ return false;
671
+ }
672
+ const listeningPid = await findListeningProcessId(session.localPort);
673
+ return listeningPid === void 0 || listeningPid === session.pid;
674
+ }
675
+ async function filterStaleSessions(sessions) {
657
676
  const host = getHostname();
658
- return sessions.filter((session) => {
659
- if (session.hostname !== host) {
660
- return true;
661
- }
662
- return isPidAlive(session.pid);
663
- });
677
+ const checks = await Promise.all(
678
+ sessions.map(async (session) => [
679
+ session,
680
+ await isSessionHealthy(session, host)
681
+ ])
682
+ );
683
+ return checks.filter(([, healthy]) => healthy).map(([session]) => session);
664
684
  }
665
685
  async function readStateRaw() {
666
686
  const parsed = await readJsonFile(stateFilePath());
@@ -674,7 +694,7 @@ async function writeState(state) {
674
694
  }
675
695
  async function readAndPruneLocked() {
676
696
  const raw = await readStateRaw();
677
- const pruned = filterStaleSessions(raw.sessions);
697
+ const pruned = await filterStaleSessions(raw.sessions);
678
698
  const removed = raw.sessions.filter(
679
699
  (session) => !pruned.some((active) => active.sessionId === session.sessionId)
680
700
  );
@@ -683,11 +703,9 @@ async function readAndPruneLocked() {
683
703
  }
684
704
  return { sessions: pruned, removed };
685
705
  }
686
- async function readSessionSnapshot() {
687
- return await withFileLock(stateLockPath(), async () => {
688
- const raw = await readStateRaw();
689
- return raw.sessions;
690
- });
706
+ async function readActiveSessions() {
707
+ const result = await withFileLock(stateLockPath(), readAndPruneLocked);
708
+ return result.sessions;
691
709
  }
692
710
  async function readAndPruneActiveSessions() {
693
711
  return await withFileLock(stateLockPath(), readAndPruneLocked);
@@ -858,7 +876,7 @@ async function pruneAndCleanupOrphans() {
858
876
  void killProcessOnPort(removed.localPort);
859
877
  }
860
878
  }
861
- return result.sessions;
879
+ return result;
862
880
  }
863
881
 
864
882
  // src/debug-session/processes.ts
@@ -1158,19 +1176,18 @@ async function cleanupFilesystem(cfHomeDir) {
1158
1176
  // src/debug-session/sessions.ts
1159
1177
  import { rm as rm2 } from "fs/promises";
1160
1178
  import process5 from "process";
1161
- async function stopDebugger(options) {
1162
- const sessions = await pruneAndCleanupOrphans();
1163
- let target;
1179
+ function findMatchingSession(sessions, options) {
1164
1180
  if (options.sessionId !== void 0) {
1165
- target = sessions.find((s) => s.sessionId === options.sessionId);
1166
- } else if (options.key !== void 0) {
1167
- const key = options.key;
1168
- target = sessions.find((s) => matchesKey(s, key));
1181
+ return sessions.find((s) => s.sessionId === options.sessionId);
1169
1182
  }
1170
- if (target === void 0) {
1171
- return void 0;
1183
+ if (options.key !== void 0) {
1184
+ const key = options.key;
1185
+ return sessions.find((s) => matchesKey(s, key));
1172
1186
  }
1173
- if (target.pid !== process5.pid) {
1187
+ return void 0;
1188
+ }
1189
+ async function cleanupSession(target, stale) {
1190
+ if (!stale && target.pid !== process5.pid) {
1174
1191
  try {
1175
1192
  await terminatePidOrGroup(target.pid);
1176
1193
  } catch {
@@ -1179,17 +1196,29 @@ async function stopDebugger(options) {
1179
1196
  setTimeout(() => {
1180
1197
  void killProcessOnPort(target.localPort);
1181
1198
  }, PORT_CLEANUP_DELAY_MS);
1182
- const removed = await removeSession(target.sessionId);
1199
+ const removed = stale ? void 0 : await removeSession(target.sessionId);
1183
1200
  try {
1184
1201
  await rm2(target.cfHomeDir, { recursive: true, force: true });
1185
1202
  } catch {
1186
1203
  }
1187
- return removed ?? target;
1204
+ return { ...removed ?? target, stale };
1205
+ }
1206
+ async function stopDebugger(options) {
1207
+ const pruneResult = await pruneAndCleanupOrphans();
1208
+ const activeTarget = findMatchingSession(pruneResult.sessions, options);
1209
+ if (activeTarget !== void 0) {
1210
+ return await cleanupSession(activeTarget, false);
1211
+ }
1212
+ const staleTarget = findMatchingSession(pruneResult.removed, options);
1213
+ if (staleTarget !== void 0) {
1214
+ return await cleanupSession(staleTarget, true);
1215
+ }
1216
+ return void 0;
1188
1217
  }
1189
1218
  async function stopAllDebuggers() {
1190
- const sessions = await pruneAndCleanupOrphans();
1191
- let stopped = 0;
1192
- for (const session of sessions) {
1219
+ const pruneResult = await pruneAndCleanupOrphans();
1220
+ let stopped = pruneResult.removed.length;
1221
+ for (const session of pruneResult.sessions) {
1193
1222
  const result = await stopDebugger({ sessionId: session.sessionId });
1194
1223
  if (result) {
1195
1224
  stopped += 1;
@@ -1198,10 +1227,10 @@ async function stopAllDebuggers() {
1198
1227
  return stopped;
1199
1228
  }
1200
1229
  async function listSessions() {
1201
- return await readSessionSnapshot();
1230
+ return await readActiveSessions();
1202
1231
  }
1203
1232
  async function getSession(key) {
1204
- const sessions = await readSessionSnapshot();
1233
+ const sessions = await readActiveSessions();
1205
1234
  return sessions.find((s) => matchesKey(s, key));
1206
1235
  }
1207
1236
 
@@ -1404,9 +1433,18 @@ async function handleStop(selector, rawOpts) {
1404
1433
  ...key === void 0 ? {} : { key }
1405
1434
  });
1406
1435
  if (result === void 0) {
1407
- process6.stderr.write("No matching session found.\n");
1436
+ process6.stderr.write(
1437
+ "No matching session found. Use `cf-debugger list` and pass --session-id or region/org/space/app if the current CF target differs.\n"
1438
+ );
1408
1439
  process6.exit(1);
1409
1440
  }
1441
+ if (result.stale) {
1442
+ process6.stdout.write(
1443
+ `Removed stale session ${result.sessionId} (${result.app}, port ${result.localPort.toString()}).
1444
+ `
1445
+ );
1446
+ return;
1447
+ }
1410
1448
  process6.stdout.write(
1411
1449
  `Stopped session ${result.sessionId} (${result.app}, port ${result.localPort.toString()}).
1412
1450
  `