@cspeach/cli 1.1.18 → 1.1.19
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 +3 -4
- package/dist/agent/loop.js +143 -25
- package/dist/agent/providers/local-provider.js +4 -1
- package/dist/agent/repair-partial.js +76 -19
- package/dist/agent/tool-dispatch.js +58 -21
- package/dist/agent/turn-error-ux.js +77 -26
- package/dist/agent/turn-stream.js +25 -3
- package/dist/approvals/adt-type.js +27 -0
- package/dist/approvals/advisory-prompt.js +48 -0
- package/dist/approvals/advisory-render.js +25 -11
- package/dist/approvals/approval-prompt.js +74 -24
- package/dist/approvals/jwt.js +2 -1
- package/dist/approvals/render.js +64 -36
- package/dist/approvals/risk-floor.js +53 -2
- package/dist/auth/api-key.js +30 -16
- package/dist/auth/keychain.js +0 -0
- package/dist/auth/me.js +25 -7
- package/dist/cli.js +25 -6
- package/dist/commands/compact.js +12 -9
- package/dist/commands/config-set.js +6 -2
- package/dist/commands/config-show.js +7 -5
- package/dist/commands/cost.js +41 -34
- package/dist/commands/help.js +88 -50
- package/dist/commands/login.js +28 -19
- package/dist/commands/logout.js +7 -3
- package/dist/commands/plan-chain.js +71 -77
- package/dist/commands/plan-continue.js +1 -0
- package/dist/commands/plan-resume.js +20 -12
- package/dist/commands/whoami.js +23 -9
- package/dist/config/loader.js +15 -3
- package/dist/cost/pricing.js +6 -5
- package/dist/doctor/checks/forge-rules.js +13 -3
- package/dist/doctor/checks/keychain.js +6 -4
- package/dist/doctor/run.js +101 -20
- package/dist/index.js +5 -2
- package/dist/lib/graceful-exit.js +56 -0
- package/dist/lib/piped-prompt.js +65 -0
- package/dist/lock-contention.js +2 -2
- package/dist/one-shot.js +135 -40
- package/dist/projects/answer-blockers.js +6 -1
- package/dist/projects/handover-md.js +15 -14
- package/dist/projects/plan-run.js +97 -54
- package/dist/projects/promote-command.js +11 -2
- package/dist/projects/save-command.js +3 -2
- package/dist/projects/status.js +2 -1
- package/dist/projects/yes-no.js +12 -0
- package/dist/renderer/abap-inline.js +18 -14
- package/dist/renderer/answer-trim.js +69 -0
- package/dist/renderer/banners.js +8 -8
- package/dist/renderer/brand-settled.js +19 -0
- package/dist/renderer/change-card.js +156 -0
- package/dist/renderer/checklist-format.js +130 -0
- package/dist/renderer/color-mode.js +45 -0
- package/dist/renderer/error-detail.js +104 -0
- package/dist/renderer/fence-state.js +46 -0
- package/dist/renderer/footer-line.js +96 -0
- package/dist/renderer/glyphs.js +25 -0
- package/dist/renderer/goodbye.js +38 -0
- package/dist/renderer/legacy-palette.js +41 -0
- package/dist/renderer/look.js +30 -0
- package/dist/renderer/markdown.js +299 -92
- package/dist/renderer/notice-log.js +72 -0
- package/dist/renderer/notice-shape.js +48 -0
- package/dist/renderer/notices.js +40 -8
- package/dist/renderer/panel-rows.js +16 -0
- package/dist/renderer/pipeline.js +66 -0
- package/dist/renderer/progress-chatter.js +23 -17
- package/dist/renderer/rendering-mode.js +34 -0
- package/dist/renderer/routing-line.js +14 -0
- package/dist/renderer/sanitize.js +12 -0
- package/dist/renderer/severity.js +2 -7
- package/dist/renderer/startup-lines.js +152 -0
- package/dist/renderer/status-footer.js +52 -34
- package/dist/renderer/steering-echo.js +41 -0
- package/dist/renderer/syntax.js +31 -12
- package/dist/renderer/tables.js +5 -1
- package/dist/renderer/theme.js +44 -0
- package/dist/renderer/thinking-heartbeat.js +33 -2
- package/dist/renderer/todo-block.js +10 -49
- package/dist/renderer/tool-labels.js +238 -0
- package/dist/renderer/tool-widget.js +245 -51
- package/dist/renderer/trace.js +42 -0
- package/dist/renderer/transcript-flow.js +132 -0
- package/dist/renderer/tty.js +34 -1
- package/dist/renderer/ui-width.js +42 -0
- package/dist/renderer/verify-chain.js +4 -2
- package/dist/renderer/widget-fallback.js +58 -65
- package/dist/repl/bracketed-paste.js +7 -1
- package/dist/repl/builtin-commands.js +19 -8
- package/dist/repl/credential-handover-gate.js +28 -0
- package/dist/repl/early-line-buffer.js +5 -2
- package/dist/repl/file-picker.js +54 -12
- package/dist/repl/ink-stdin-guard.js +66 -0
- package/dist/repl/inquirer-guard.js +59 -16
- package/dist/repl/inquirer-theme.js +27 -33
- package/dist/repl/paste-marker.js +19 -0
- package/dist/repl/plan-turn-end.js +20 -0
- package/dist/repl/post-turn-status.js +36 -11
- package/dist/repl/reroute-turn.js +21 -0
- package/dist/repl/reset-tty-stdin.js +44 -0
- package/dist/repl/restore-guard.js +22 -0
- package/dist/repl/resume-standalone.js +63 -0
- package/dist/repl/rule8-detector.js +11 -3
- package/dist/repl/safety-confirm.js +161 -93
- package/dist/repl/session-spend-line.js +8 -4
- package/dist/repl/themed-prompts.js +19 -0
- package/dist/repl/ui-look-command.js +75 -0
- package/dist/repl/update-method-preview-hook.js +48 -5
- package/dist/repl.js +795 -332
- package/dist/rewind/cli.js +3 -2
- package/dist/rewind/format.js +14 -9
- package/dist/rewind/restore.js +11 -0
- package/dist/router/classifier.js +47 -4
- package/dist/router/intent-extractor.js +24 -8
- package/dist/router/piped-routing.js +23 -0
- package/dist/router/resume-routing.js +16 -0
- package/dist/router/routing-failure.js +57 -0
- package/dist/sap/first-run-choice.js +1 -1
- package/dist/sap/onboarding.js +3 -2
- package/dist/sap/standalone-onboarding.js +1 -1
- package/dist/sap/system-info.js +4 -2
- package/dist/sap/unreachable.js +28 -0
- package/dist/sap-errors/clean-error-text.js +182 -0
- package/dist/session/interrupt-reason.js +39 -0
- package/dist/session/recap.js +36 -24
- package/dist/session/resume.js +104 -36
- package/dist/session/store.js +69 -27
- package/dist/session/user-prompt.js +21 -0
- package/dist/skill-catalog.js +7 -0
- package/dist/skills/bundled-skills.js +76 -69
- package/dist/standards/standards-init.js +1 -1
- package/dist/test-helpers/answer-any.js +46 -0
- package/dist/tools/approval.js +27 -11
- package/dist/tools/ask-question.js +47 -5
- package/dist/tools/filesystem/file-write.js +19 -2
- package/dist/tools/fiori/fe-extend.js +16 -1
- package/dist/tools/fiori/fe-scaffold.js +105 -3
- package/dist/tools/fiori/html-escapes.js +28 -0
- package/dist/tools/fiori/metadata/audit.js +605 -0
- package/dist/tools/fiori/metadata/references.js +104 -0
- package/dist/tools/fiori/metadata/types.js +8 -0
- package/dist/tools/fiori/preview/app-guard.js +215 -0
- package/dist/tools/fiori/preview/env.js +193 -0
- package/dist/tools/fiori/preview/readiness.js +127 -0
- package/dist/tools/fiori/preview/registry.js +412 -0
- package/dist/tools/fiori/preview/start.js +469 -0
- package/dist/tools/fiori/samples/loader.js +20 -5
- package/dist/tools/fiori/scaffold.js +18 -6
- package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
- package/dist/tools/fiori/smoke/app-driver.js +100 -0
- package/dist/tools/fiori/smoke/assertions.js +161 -24
- package/dist/tools/fiori/smoke/audit-columns.js +21 -0
- package/dist/tools/fiori/smoke/batch.js +273 -0
- package/dist/tools/fiori/smoke/draft-safety.js +241 -0
- package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
- package/dist/tools/fiori/smoke/driver.js +171 -4
- package/dist/tools/fiori/smoke/evidence.js +35 -0
- package/dist/tools/fiori/smoke/labels-codes.js +207 -0
- package/dist/tools/fiori/smoke/nav-error.js +26 -0
- package/dist/tools/fiori/smoke/run-smoke.js +155 -12
- package/dist/tools/fiori/tools.js +651 -7
- package/dist/tools/fiori/ui5-version.js +16 -0
- package/dist/tools/index.js +8 -0
- package/dist/tools/local-build.js +6 -0
- package/dist/tools/sap-read.js +16 -1
- package/dist/tools/sap-write.js +122 -10
- package/dist/tools/shell/shell_exec.js +30 -12
- package/dist/tools/syntax-state.js +67 -0
- package/dist/tools/todo.js +23 -18
- package/dist/tools/transport-fit.js +462 -0
- package/dist/tools/transport-resolution.js +3 -1
- package/dist/tools/transport.js +33 -7
- package/dist/tools/verify.js +184 -8
- package/dist/ui/app.js +284 -146
- package/dist/ui/approval-modal.js +41 -15
- package/dist/ui/ask-answer-text.js +17 -0
- package/dist/ui/body.js +25 -6
- package/dist/ui/card-slot.js +98 -0
- package/dist/ui/checklist.js +9 -0
- package/dist/ui/coaching-picker-classic.js +8 -5
- package/dist/ui/command-palette.js +6 -4
- package/dist/ui/confirm-request.js +18 -0
- package/dist/ui/context-grid.js +2 -1
- package/dist/ui/file-palette.js +6 -4
- package/dist/ui/files-card-emitter.js +19 -0
- package/dist/ui/footer-line.js +16 -0
- package/dist/ui/footer.js +105 -104
- package/dist/ui/input-wrap.js +122 -0
- package/dist/ui/interrupt.js +103 -0
- package/dist/ui/key-burst.js +117 -0
- package/dist/ui/line-resolution.js +4 -3
- package/dist/ui/live-area.js +71 -0
- package/dist/ui/login-banner.js +54 -43
- package/dist/ui/rewind-panel.js +76 -24
- package/dist/ui/role-props.js +47 -0
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/session-timeline.js +49 -3
- package/dist/ui/skill-picker.js +5 -47
- package/dist/ui/text-input.js +128 -24
- package/dist/ui/todo-emitter.js +19 -0
- package/dist/ui/turn-status-emitter.js +28 -2
- package/dist/ui/turn-status.js +16 -19
- package/dist/ui/use-card-request.js +44 -0
- package/dist/ui/widgets/ask-card.js +521 -0
- package/dist/ui/widgets/bar-chart.js +11 -5
- package/dist/ui/widgets/coaching-picker.js +50 -55
- package/dist/ui/widgets/confirm-card.js +193 -0
- package/dist/ui/widgets/dep-graph.js +3 -2
- package/dist/ui/widgets/diff-viewer.js +6 -5
- package/dist/ui/widgets/files-card.js +97 -0
- package/dist/ui/widgets/question-card.js +2 -1
- package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
- package/dist/ui/widgets/shortcuts-sheet.js +25 -0
- package/dist/ui/widgets/stack-frames.js +2 -1
- package/dist/upgrade-check.js +39 -8
- package/package.json +11 -2
- package/test-harness/fe-draft/app.ts +188 -0
- package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
- package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
- package/test-harness/fe-draft/routes.ts +224 -0
- package/test-harness/fe-draft/routes.unit.test.ts +45 -0
- package/test-harness/fe-draft/server.ts +225 -0
- package/test-harness/fe-draft/variants.ts +196 -0
- package/vitest.smoke-e2e.config.ts +20 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
// cspeach-cli/src/tools/fiori/preview/registry.ts
|
|
2
|
+
/**
|
|
3
|
+
* L1-6 — the in-process preview registry, keyed by ABSOLUTE app directory.
|
|
4
|
+
*
|
|
5
|
+
* WHY NOT subagent/_background-shared.ts
|
|
6
|
+
* --------------------------------------
|
|
7
|
+
* That registry is keyed by `bg_id` and has no notion of a port, a URL or
|
|
8
|
+
* reuse: two `background_run` calls for the same command are two independent
|
|
9
|
+
* children, and that is correct THERE. A preview is different — one app
|
|
10
|
+
* directory has at most ONE preview server, because a second one binds a second
|
|
11
|
+
* port and then nobody can say which of :8080 and :8081 the browser check is
|
|
12
|
+
* looking at. That ambiguity is the July bug.
|
|
13
|
+
*
|
|
14
|
+
* IDENTITY — WHOSE PROCESS IS IT? (review F-3)
|
|
15
|
+
* -------------------------------------------
|
|
16
|
+
* A pid is not an identity. Windows recycles pids aggressively once the process
|
|
17
|
+
* handle is closed, so `taskkill /PID <remembered pid> /T /F` hours later can
|
|
18
|
+
* hit the owner's editor. Therefore:
|
|
19
|
+
* - the registry listens for the child's own `'exit'` and forgets the entry
|
|
20
|
+
* the moment the preview dies, so a dead entry never lingers with its pid;
|
|
21
|
+
* - nothing is ever killed unless THIS process still holds a LIVE child
|
|
22
|
+
* handle for it (`exitCode === null && signalCode === null`);
|
|
23
|
+
* - `pid <= 1` or a non-integer pid is refused before any kill — `-1` is
|
|
24
|
+
* "every process of this user" on POSIX and `taskkill /PID 0` is the
|
|
25
|
+
* System Idle Process on Windows;
|
|
26
|
+
* - a preview started by a PREVIOUS CLI process is not ours to kill by pid.
|
|
27
|
+
*
|
|
28
|
+
* KILLING THE TREE
|
|
29
|
+
* ----------------
|
|
30
|
+
* The process we spawn is usually NOT the process holding the port. On Windows
|
|
31
|
+
* cross-spawn runs `npm.cmd` through cmd.exe, which runs node, which runs the
|
|
32
|
+
* ui5 server: three levels, and cmd.exe creates no job object, so its
|
|
33
|
+
* descendants outlive it.
|
|
34
|
+
* - Windows: the ABSOLUTE `%SystemRoot%\System32\taskkill.exe` (never the bare
|
|
35
|
+
* name — libuv searches the current directory first on Windows and the CLI's
|
|
36
|
+
* cwd is the model-writable workspace), with a minimal environment.
|
|
37
|
+
* - POSIX: the child leads its own process group (spawned detached), so
|
|
38
|
+
* `process.kill(-pid, …)` signals the whole group; SIGTERM, poll, SIGKILL.
|
|
39
|
+
*
|
|
40
|
+
* THE EXIT PATH (review F-1)
|
|
41
|
+
* --------------------------
|
|
42
|
+
* An asynchronous `spawn` inside `process.on('exit')` is a NO-OP on Windows:
|
|
43
|
+
* the taskkill child sits in libuv's KILL_ON_JOB_CLOSE job, which closes
|
|
44
|
+
* milliseconds later and takes it down before it has killed anything (measured:
|
|
45
|
+
* 8 of 8 targets survived). The exit path is therefore `spawnSync`, and the
|
|
46
|
+
* signal listeners are PREPENDED so they run before a handler that re-raises,
|
|
47
|
+
* and re-raise themselves when ours is the only listener so the CLI still exits
|
|
48
|
+
* on Ctrl+C.
|
|
49
|
+
*
|
|
50
|
+
* Per-process only, never persisted — the lifetime of a previewId is bounded by
|
|
51
|
+
* the CLI process that started it, and the exit path makes sure of it.
|
|
52
|
+
*/
|
|
53
|
+
import { spawn as nodeSpawn, spawnSync } from 'node:child_process';
|
|
54
|
+
import { realpathSync } from 'node:fs';
|
|
55
|
+
import * as net from 'node:net';
|
|
56
|
+
import * as os from 'node:os';
|
|
57
|
+
import * as path from 'node:path';
|
|
58
|
+
import { KILL_GRACE_MS } from '../../_command-shared.js';
|
|
59
|
+
/** appDirKey → tracked preview. At most one per directory, by construction. */
|
|
60
|
+
const registry = new Map();
|
|
61
|
+
/**
|
|
62
|
+
* Children spawned but not yet registered — a preview can sit in the readiness
|
|
63
|
+
* wait for up to two minutes, and quitting the CLI during that window used to
|
|
64
|
+
* orphan it (F-5). Everything here is swept by the exit path too.
|
|
65
|
+
*/
|
|
66
|
+
const pendingChildren = new Set();
|
|
67
|
+
let exitHandlerInstalled = false;
|
|
68
|
+
/** Absolute taskkill, with a minimal environment — never the bare name (F-6). */
|
|
69
|
+
// R1-11: an empty or RELATIVE value would make taskkill resolve against the
|
|
70
|
+
// cwd (os.tmpdir()), so only an absolute drive path is trusted.
|
|
71
|
+
export function resolveSystemRoot(env) {
|
|
72
|
+
for (const v of [env.SystemRoot, env.windir]) {
|
|
73
|
+
if (typeof v === 'string' && /^[A-Za-z]:[\\/]/.test(v))
|
|
74
|
+
return v;
|
|
75
|
+
}
|
|
76
|
+
return 'C:\\Windows';
|
|
77
|
+
}
|
|
78
|
+
const SYSTEM_ROOT = resolveSystemRoot(process.env);
|
|
79
|
+
const TASKKILL = path.join(SYSTEM_ROOT, 'System32', 'taskkill.exe');
|
|
80
|
+
const TASKKILL_ENV = { SystemRoot: SYSTEM_ROOT };
|
|
81
|
+
const TASKKILL_TIMEOUT_MS = 4_000;
|
|
82
|
+
/** How long `stopPreview` waits for the port to actually close. */
|
|
83
|
+
export const PORT_CLOSE_BUDGET_MS = 5_000;
|
|
84
|
+
/**
|
|
85
|
+
* The registry key. Absolute, then the REAL path (so a junction, an 8.3 short
|
|
86
|
+
* name and the long name collapse to one key), then lower-cased on Windows
|
|
87
|
+
* (where `apps/MyApp` and `apps/myapp` are the same directory but `resolveSafePath`
|
|
88
|
+
* is lexical and case-preserving). Falls back to the lexical path when the
|
|
89
|
+
* directory does not exist yet.
|
|
90
|
+
*/
|
|
91
|
+
export function previewKeyFor(appDir) {
|
|
92
|
+
return keyFor(appDir);
|
|
93
|
+
}
|
|
94
|
+
function keyFor(appDir) {
|
|
95
|
+
const abs = path.resolve(appDir);
|
|
96
|
+
let real = abs;
|
|
97
|
+
try {
|
|
98
|
+
real = realpathSync.native(abs);
|
|
99
|
+
}
|
|
100
|
+
catch { /* not on disk (yet) — lexical is fine */ }
|
|
101
|
+
let trimmed = real.length > 1 ? real.replace(/[\\/]+$/, '') : real;
|
|
102
|
+
if (/^[A-Za-z]:$/.test(trimmed))
|
|
103
|
+
trimmed += path.sep; // R1-13: a drive root stays a root
|
|
104
|
+
return process.platform === 'win32' ? trimmed.toLowerCase() : trimmed;
|
|
105
|
+
}
|
|
106
|
+
/** True only while THIS process holds a live handle on the child. */
|
|
107
|
+
export function childIsLive(child) {
|
|
108
|
+
return !!child && child.exitCode === null && child.signalCode === null;
|
|
109
|
+
}
|
|
110
|
+
/** A pid we are allowed to signal. 0 = own group / idle process, 1 = everything. */
|
|
111
|
+
function killablePid(pid) {
|
|
112
|
+
return typeof pid === 'number' && Number.isInteger(pid) && pid > 1;
|
|
113
|
+
}
|
|
114
|
+
export function registerPreview(e, child) {
|
|
115
|
+
const key = keyFor(e.appDir);
|
|
116
|
+
const previous = registry.get(key);
|
|
117
|
+
// F-5.3: replacing a live, DIFFERENT child would make its tree unreachable by
|
|
118
|
+
// stop/list/the exit path — the ':8080 beside :8081' shape. Kill it first.
|
|
119
|
+
if (previous && previous.child !== child && childIsLive(previous.child)) {
|
|
120
|
+
void killProcessTree(previous.entry.pid, previous.child);
|
|
121
|
+
}
|
|
122
|
+
registry.set(key, { entry: e, child });
|
|
123
|
+
if (child) {
|
|
124
|
+
pendingChildren.delete(child);
|
|
125
|
+
// F-3.1: the preview dying on its own (crash, port clash, user closes it)
|
|
126
|
+
// must empty the registry at once — otherwise `list` lies and a later stop
|
|
127
|
+
// fires a kill at a pid that may since have been recycled.
|
|
128
|
+
child.once('exit', () => {
|
|
129
|
+
pendingChildren.delete(child);
|
|
130
|
+
const tracked = registry.get(key);
|
|
131
|
+
if (tracked?.entry.previewId === e.previewId)
|
|
132
|
+
registry.delete(key);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** Track a child from spawn until it is registered (or dies) — see F-5. */
|
|
137
|
+
export function markPendingChild(child) {
|
|
138
|
+
pendingChildren.add(child);
|
|
139
|
+
child.once('exit', () => pendingChildren.delete(child));
|
|
140
|
+
}
|
|
141
|
+
export function unmarkPendingChild(child) {
|
|
142
|
+
pendingChildren.delete(child);
|
|
143
|
+
}
|
|
144
|
+
/** One entry per app dir — this is what removes the ':8080 beside :8081' ambiguity. */
|
|
145
|
+
export function findPreviewByAppDir(appDir) {
|
|
146
|
+
return registry.get(keyFor(appDir))?.entry;
|
|
147
|
+
}
|
|
148
|
+
export function findPreviewById(previewId) {
|
|
149
|
+
for (const t of registry.values())
|
|
150
|
+
if (t.entry.previewId === previewId)
|
|
151
|
+
return t.entry;
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
/** The live child handle for a previewId, when this process still holds one. */
|
|
155
|
+
export function findChildById(previewId) {
|
|
156
|
+
for (const t of registry.values())
|
|
157
|
+
if (t.entry.previewId === previewId)
|
|
158
|
+
return t.child;
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
export function listPreviews() {
|
|
162
|
+
return [...registry.values()].map((t) => t.entry);
|
|
163
|
+
}
|
|
164
|
+
/** Forget an entry WITHOUT killing anything (a preview that already exited). */
|
|
165
|
+
export function forgetPreview(previewId) {
|
|
166
|
+
for (const [k, t] of registry) {
|
|
167
|
+
if (t.entry.previewId === previewId) {
|
|
168
|
+
registry.delete(k);
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
/** True when the pid still exists (signal 0 probe). Undefined pid → false. */
|
|
175
|
+
export function isPidAlive(pid) {
|
|
176
|
+
if (pid === undefined)
|
|
177
|
+
return false;
|
|
178
|
+
try {
|
|
179
|
+
process.kill(pid, 0);
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Kill a process and everything it spawned. Resolves when the kill has been
|
|
188
|
+
* issued (and, on Windows, when taskkill has exited), reporting WHAT happened
|
|
189
|
+
* so the caller can claim only what it can show (F-7).
|
|
190
|
+
*/
|
|
191
|
+
async function realKillProcessTree(pid, child) {
|
|
192
|
+
if (!killablePid(pid)) {
|
|
193
|
+
// No usable pid. A live handle can still be closed politely.
|
|
194
|
+
if (childIsLive(child)) {
|
|
195
|
+
try {
|
|
196
|
+
child.kill('SIGKILL');
|
|
197
|
+
return { issued: true, exitCode: null };
|
|
198
|
+
}
|
|
199
|
+
catch { /* gone */ }
|
|
200
|
+
}
|
|
201
|
+
return { issued: false, exitCode: null, reason: `refusing to signal pid ${String(pid)}` };
|
|
202
|
+
}
|
|
203
|
+
if (process.platform === 'win32') {
|
|
204
|
+
// R1-1: once the child has exited, libuv closed its handle and Windows may
|
|
205
|
+
// already have handed its pid to another process. `taskkill /T` at a dead
|
|
206
|
+
// pid walks nothing of ours and could hit a stranger. (POSIX keeps the
|
|
207
|
+
// group signal below: survivors of a dead leader are still in the group,
|
|
208
|
+
// and a pgid is not reused while a member lives.)
|
|
209
|
+
if (child !== undefined && !childIsLive(child)) {
|
|
210
|
+
return { issued: false, exitCode: null, reason: 'the process already exited — its pid is no longer ours' };
|
|
211
|
+
}
|
|
212
|
+
return new Promise((resolve) => {
|
|
213
|
+
let done = false;
|
|
214
|
+
const finish = (o) => { if (!done) {
|
|
215
|
+
done = true;
|
|
216
|
+
resolve(o);
|
|
217
|
+
} };
|
|
218
|
+
try {
|
|
219
|
+
const tk = nodeSpawn(TASKKILL, ['/PID', String(pid), '/T', '/F'], {
|
|
220
|
+
windowsHide: true,
|
|
221
|
+
stdio: 'ignore',
|
|
222
|
+
cwd: os.tmpdir(),
|
|
223
|
+
env: TASKKILL_ENV,
|
|
224
|
+
});
|
|
225
|
+
tk.on('exit', (code) => finish({ issued: true, exitCode: code }));
|
|
226
|
+
tk.on('error', (err) => {
|
|
227
|
+
try {
|
|
228
|
+
if (childIsLive(child))
|
|
229
|
+
child.kill('SIGKILL');
|
|
230
|
+
}
|
|
231
|
+
catch { /* gone */ }
|
|
232
|
+
finish({ issued: false, exitCode: null, reason: err.message });
|
|
233
|
+
});
|
|
234
|
+
// Never hang the CLI on a wedged taskkill.
|
|
235
|
+
const t = setTimeout(() => finish({ issued: true, exitCode: null, reason: 'taskkill timed out' }), TASKKILL_TIMEOUT_MS);
|
|
236
|
+
t.unref();
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
try {
|
|
240
|
+
if (childIsLive(child))
|
|
241
|
+
child.kill('SIGKILL');
|
|
242
|
+
}
|
|
243
|
+
catch { /* gone */ }
|
|
244
|
+
finish({ issued: false, exitCode: null, reason: err instanceof Error ? err.message : String(err) });
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
// POSIX: the child leads its own process group (spawned detached), so the
|
|
249
|
+
// negative pid signals the group — the grandchildren included.
|
|
250
|
+
let issued = false;
|
|
251
|
+
try {
|
|
252
|
+
process.kill(-pid, 'SIGTERM');
|
|
253
|
+
issued = true;
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
try {
|
|
257
|
+
if (childIsLive(child)) {
|
|
258
|
+
child.kill('SIGTERM');
|
|
259
|
+
issued = true;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
catch { /* gone */ }
|
|
263
|
+
}
|
|
264
|
+
// F-14: poll instead of always sleeping the full grace...
|
|
265
|
+
const deadline = Date.now() + KILL_GRACE_MS;
|
|
266
|
+
while (Date.now() < deadline && isPidAlive(pid)) {
|
|
267
|
+
await new Promise((r) => { const t = setTimeout(r, 100); t.unref(); });
|
|
268
|
+
}
|
|
269
|
+
// ...and SIGKILL the GROUP unconditionally afterwards: a surviving group
|
|
270
|
+
// member whose leader already died would otherwise never be signalled.
|
|
271
|
+
try {
|
|
272
|
+
process.kill(-pid, 'SIGKILL');
|
|
273
|
+
}
|
|
274
|
+
catch { /* group already gone */ }
|
|
275
|
+
return { issued, exitCode: null };
|
|
276
|
+
}
|
|
277
|
+
let killImpl = realKillProcessTree;
|
|
278
|
+
export function killProcessTree(pid, child) {
|
|
279
|
+
return killImpl(pid, child);
|
|
280
|
+
}
|
|
281
|
+
/** Test-only — replace the kill so the unit suite never signals a real pid (F-4). */
|
|
282
|
+
export function __setKillForTests(fn) { killImpl = fn; }
|
|
283
|
+
export function __resetKillForTests() { killImpl = realKillProcessTree; }
|
|
284
|
+
// ───────────────────────────────── stop, with evidence ──────────────────────
|
|
285
|
+
/** True when something is listening on host:port. */
|
|
286
|
+
function portOpen(host, port, timeoutMs = 1_000) {
|
|
287
|
+
return new Promise((resolve) => {
|
|
288
|
+
const sock = net.connect({ port, host });
|
|
289
|
+
const done = (v) => { sock.destroy(); resolve(v); };
|
|
290
|
+
sock.once('connect', () => done(true));
|
|
291
|
+
sock.once('error', () => done(false));
|
|
292
|
+
sock.setTimeout(timeoutMs, () => done(false));
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
/** Poll BOTH loopback families until the port is closed, or the budget ends. */
|
|
296
|
+
async function waitForPortClosed(port, budgetMs = PORT_CLOSE_BUDGET_MS) {
|
|
297
|
+
const deadline = Date.now() + budgetMs;
|
|
298
|
+
for (;;) {
|
|
299
|
+
const v4 = await portOpen('127.0.0.1', port);
|
|
300
|
+
const v6 = v4 ? true : await portOpen('::1', port);
|
|
301
|
+
if (!v4 && !v6)
|
|
302
|
+
return true;
|
|
303
|
+
if (Date.now() >= deadline)
|
|
304
|
+
return false;
|
|
305
|
+
await new Promise((r) => { setTimeout(r, 150); });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Stop a preview: kill the tree of a child THIS process still holds, then prove
|
|
310
|
+
* the port closed. An entry with no live handle is forgotten, never killed by
|
|
311
|
+
* pid (F-3).
|
|
312
|
+
*/
|
|
313
|
+
export async function stopPreview(previewId) {
|
|
314
|
+
let found;
|
|
315
|
+
let key;
|
|
316
|
+
for (const [k, t] of registry) {
|
|
317
|
+
if (t.entry.previewId === previewId) {
|
|
318
|
+
found = t;
|
|
319
|
+
key = k;
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (!found || key === undefined) {
|
|
324
|
+
return { stopped: false, portClosed: false, issued: false, exitCode: null, reason: 'no such previewId' };
|
|
325
|
+
}
|
|
326
|
+
// Delete FIRST so a kill that throws still leaves a consistent registry and a
|
|
327
|
+
// concurrent second stop cannot fire twice at the same pid.
|
|
328
|
+
registry.delete(key);
|
|
329
|
+
const port = found.entry.port;
|
|
330
|
+
if (!childIsLive(found.child)) {
|
|
331
|
+
// It already exited, or it belongs to a previous CLI process. Either way its
|
|
332
|
+
// pid is not an identity we may act on.
|
|
333
|
+
const portClosed = await waitForPortClosed(port, 1_000);
|
|
334
|
+
return {
|
|
335
|
+
stopped: true, portClosed, port, issued: false, exitCode: null,
|
|
336
|
+
reason: 'this session holds no live process handle for that preview — nothing was killed',
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
const outcome = await killProcessTree(found.entry.pid, found.child);
|
|
340
|
+
const portClosed = await waitForPortClosed(port);
|
|
341
|
+
return { stopped: true, portClosed, port, ...outcome };
|
|
342
|
+
}
|
|
343
|
+
function realSyncKill(pid) {
|
|
344
|
+
if (process.platform === 'win32') {
|
|
345
|
+
spawnSync(TASKKILL, ['/PID', String(pid), '/T', '/F'], {
|
|
346
|
+
windowsHide: true,
|
|
347
|
+
stdio: 'ignore',
|
|
348
|
+
cwd: os.tmpdir(),
|
|
349
|
+
env: TASKKILL_ENV,
|
|
350
|
+
timeout: TASKKILL_TIMEOUT_MS,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
process.kill(-pid, 'SIGTERM');
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
let syncKillImpl = realSyncKill;
|
|
358
|
+
/** Test-only — replace the exit-path kill (R1-9). */
|
|
359
|
+
export function __setSyncKillForTests(fn) { syncKillImpl = fn; }
|
|
360
|
+
export function __resetSyncKillForTests() { syncKillImpl = realSyncKill; }
|
|
361
|
+
export function killAllPreviewsSync() {
|
|
362
|
+
const victims = [];
|
|
363
|
+
for (const t of registry.values())
|
|
364
|
+
victims.push({ pid: t.entry.pid, child: t.child });
|
|
365
|
+
for (const c of pendingChildren)
|
|
366
|
+
victims.push({ pid: c.pid, child: c });
|
|
367
|
+
for (const v of victims) {
|
|
368
|
+
// Same identity rule as everywhere else: a live handle we hold, a sane pid.
|
|
369
|
+
if (!childIsLive(v.child) || !killablePid(v.pid))
|
|
370
|
+
continue;
|
|
371
|
+
try {
|
|
372
|
+
syncKillImpl(v.pid);
|
|
373
|
+
}
|
|
374
|
+
catch { /* already gone */ }
|
|
375
|
+
}
|
|
376
|
+
registry.clear();
|
|
377
|
+
pendingChildren.clear();
|
|
378
|
+
}
|
|
379
|
+
/** Kill every live preview on CLI exit or on a terminating signal. Idempotent. */
|
|
380
|
+
export function installPreviewExitHandler() {
|
|
381
|
+
if (exitHandlerInstalled)
|
|
382
|
+
return;
|
|
383
|
+
exitHandlerInstalled = true;
|
|
384
|
+
process.on('exit', killAllPreviewsSync);
|
|
385
|
+
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
386
|
+
// PREPEND: ui/alt-screen.ts re-raises with process.kill(process.pid, sig),
|
|
387
|
+
// which on Windows is an immediate TerminateProcess — a listener appended
|
|
388
|
+
// after it would never run, and 'exit' would never fire either.
|
|
389
|
+
process.prependListener(sig, () => {
|
|
390
|
+
killAllPreviewsSync();
|
|
391
|
+
// In one-shot/classic mode ours may be the ONLY listener, and merely
|
|
392
|
+
// having one removes Node's default exit-on-signal. Re-raise so Ctrl+C
|
|
393
|
+
// still stops the CLI.
|
|
394
|
+
if (process.listenerCount(sig) === 1) {
|
|
395
|
+
process.removeAllListeners(sig);
|
|
396
|
+
process.kill(process.pid, sig);
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
export function __resetPreviewRegistryForTests() {
|
|
402
|
+
registry.clear();
|
|
403
|
+
pendingChildren.clear();
|
|
404
|
+
}
|
|
405
|
+
/** Test-only — observe whether installPreviewExitHandler has fired. */
|
|
406
|
+
export function __isPreviewExitHandlerInstalledForTests() {
|
|
407
|
+
return exitHandlerInstalled;
|
|
408
|
+
}
|
|
409
|
+
/** Test-only — how many spawned-but-not-yet-registered children are tracked. */
|
|
410
|
+
export function __pendingChildCountForTests() {
|
|
411
|
+
return pendingChildren.size;
|
|
412
|
+
}
|