@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 +7 -71
- package/dist/cli.js +86 -48
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +76 -47
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ Signal the remote process, enable SSH if needed, forward `9229` to a free local
|
|
|
12
12
|
[](https://packagephobia.com/result?p=@saptools/cf-debugger)
|
|
13
13
|
[](https://www.typescriptlang.org)
|
|
14
14
|
|
|
15
|
-
[Install](#-install) • [Quick Start](#-quick-start) • [CLI](#-cli) • [
|
|
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
|
|
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
|
|
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** —
|
|
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
|
|
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
|
|
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
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
|
687
|
-
|
|
688
|
-
|
|
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
|
|
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
|
-
|
|
1162
|
-
const sessions = await pruneAndCleanupOrphans();
|
|
1163
|
-
let target;
|
|
1179
|
+
function findMatchingSession(sessions, options) {
|
|
1164
1180
|
if (options.sessionId !== void 0) {
|
|
1165
|
-
|
|
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 (
|
|
1171
|
-
|
|
1183
|
+
if (options.key !== void 0) {
|
|
1184
|
+
const key = options.key;
|
|
1185
|
+
return sessions.find((s) => matchesKey(s, key));
|
|
1172
1186
|
}
|
|
1173
|
-
|
|
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
|
|
1191
|
-
let stopped =
|
|
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
|
|
1230
|
+
return await readActiveSessions();
|
|
1202
1231
|
}
|
|
1203
1232
|
async function getSession(key) {
|
|
1204
|
-
const sessions = await
|
|
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(
|
|
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
|
`
|