@cspeach/cli 1.1.21 → 1.1.23

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.
Files changed (54) hide show
  1. package/dist/agent/tool-dispatch.js +4 -2
  2. package/dist/cli.js +13 -0
  3. package/dist/commands/plan-audit-evidence.js +61 -4
  4. package/dist/commands/plan-audit.js +13 -1
  5. package/dist/commands/sap-link.js +52 -0
  6. package/dist/lib/shutdown-hooks.js +87 -0
  7. package/dist/one-shot.js +4 -0
  8. package/dist/projects/plan-run.js +5 -1
  9. package/dist/projects/plan-schema.js +2 -1
  10. package/dist/renderer/tool-labels.js +5 -1
  11. package/dist/repl/credential-handover-gate.js +12 -6
  12. package/dist/repl.js +4 -0
  13. package/dist/sap/connection-manager.js +30 -24
  14. package/dist/sap/standalone-profile.js +1 -1
  15. package/dist/sap/system-info.js +50 -27
  16. package/dist/sap-link/policy.js +185 -0
  17. package/dist/sap-link/project.js +163 -0
  18. package/dist/sap-link/registry.js +259 -0
  19. package/dist/sap-link/server.js +351 -0
  20. package/dist/sap-link/upstream.js +367 -0
  21. package/dist/sap-link/write-log.js +60 -0
  22. package/dist/skills/bundled-skills.js +118 -118
  23. package/dist/tools/cap/_fetch-errors.js +20 -0
  24. package/dist/tools/cap/link-smoke.js +703 -0
  25. package/dist/tools/cap/metadata-import.js +325 -0
  26. package/dist/tools/cap/sap-link-tool.js +133 -0
  27. package/dist/tools/cap/service-catalog.js +248 -0
  28. package/dist/tools/fiori/preview/registry.js +30 -24
  29. package/dist/tools/local-build.js +11 -0
  30. package/dist/tools/subagent/_background-shared.js +59 -37
  31. package/dist/tools/subagent/background_run.js +9 -5
  32. package/dist/upgrade.js +4 -2
  33. package/package.json +1 -1
  34. package/dist/commands/project.js +0 -301
  35. package/dist/tools/local-files.js +0 -31
  36. package/dist/tools/subagent/schedule_create.js +0 -131
  37. package/dist/ui/error-emitter.js +0 -21
  38. package/dist/ui/header.js +0 -41
  39. package/dist/ui/ink-classifier-route.js +0 -19
  40. package/dist/ui/rich-error-box.js +0 -9
  41. package/dist/ui/sidebar.js +0 -12
  42. package/dist/ui/status-emitter.js +0 -19
  43. package/dist/ui/status-line.js +0 -62
  44. package/dist/ui/status-row.js +0 -38
  45. package/dist/ui/todo-panel.js +0 -85
  46. package/dist/ui/widgets/ask-form.js +0 -250
  47. package/dist/ui/widgets/ask-question-modal.js +0 -178
  48. package/fiori/zlas-inspection-lr/package.json +0 -26
  49. package/fiori/zlas-inspection-lr/ui5-local.yaml +0 -45
  50. package/fiori/zlas-inspection-lr/ui5.yaml +0 -41
  51. package/fiori/zlas-inspection-lr/webapp/Component.js +0 -12
  52. package/fiori/zlas-inspection-lr/webapp/i18n/i18n.properties +0 -9
  53. package/fiori/zlas-inspection-lr/webapp/index.html +0 -35
  54. package/fiori/zlas-inspection-lr/webapp/manifest.json +0 -145
@@ -0,0 +1,248 @@
1
+ // cspeach-cli/src/tools/cap/service-catalog.ts
2
+ //
3
+ // CAP phase 2, Task 9 — `sap_service_catalog`: list the OData services ACTIVE on
4
+ // the connected SAP system, from the Gateway V2 and V4 catalogs. The design
5
+ // step uses it to map a spec's S/4 data to a service that really exists.
6
+ //
7
+ // Read-only: the only requests are GETs to V2_CATALOG_BASE and V4_CATALOG_URL.
8
+ // Field names follow the real S4H captures (probe note P3, fixtures/catalog-*.json):
9
+ // - V2: on that box `TechnicalServiceName` is Z-prefixed (ZAPI_…) while
10
+ // `ServiceUrl` (absolute) ends in the real name, so the name comes from the
11
+ // last ServiceUrl segment (matrix `;v=..` ignored), then Title, then
12
+ // TechnicalServiceName.
13
+ // - V4: `ServiceUrl` is RELATIVE and carries `?sap-client=…`; the query is dropped.
14
+ import { registerTool } from '../index.js';
15
+ import { loadConfig } from '../../config/loader.js';
16
+ import { PlainFetchError, plainSapFetchError } from './_fetch-errors.js';
17
+ const V2_CATALOG_BASE = '/sap/opu/odata/IWFND/CATALOGSERVICE;v=2/ServiceCollection?$format=json' +
18
+ '&$select=ID,TechnicalServiceName,TechnicalServiceVersion,Title,ServiceUrl';
19
+ export const V4_CATALOG_URL = '/sap/opu/odata4/iwfnd/config/default/iwfnd/catalog/0002/ServiceGroups?$expand=DefaultSystem($expand=Services)';
20
+ /** V2 page size and the safety cap on pages (321 `API_` services on S4H → 4 pages). */
21
+ export const V2_PAGE_SIZE = 100;
22
+ export const V2_MAX_PAGES = 20;
23
+ const DEFAULT_MAX = 50;
24
+ const MAX_MAX = 500;
25
+ const SEARCH_MAX_LEN = 60;
26
+ const SEARCH_RE = /^[A-Za-z0-9_ /-]*$/;
27
+ /** Pathname of an absolute or relative URL: no scheme, host, port, query or fragment; no trailing slash. */
28
+ function pathOf(u) {
29
+ try {
30
+ return new URL(u, 'http://catalog.invalid').pathname.replace(/\/+$/, '');
31
+ }
32
+ catch {
33
+ return undefined;
34
+ }
35
+ }
36
+ /** Last path segment, matrix parameters (`;v=0002`) removed. */
37
+ function lastSegment(p) {
38
+ const seg = p.split('/').pop() ?? '';
39
+ return seg.split(';')[0] ?? '';
40
+ }
41
+ /** One row, one line: SAP-supplied text loses control characters (tabs, newlines, ESC). */
42
+ // eslint-disable-next-line no-control-regex
43
+ const oneLine = (s) => String(s ?? '').replace(/[\u0000-\u001F\u007F]+/g, ' ').replace(/\s+/g, ' ').trim();
44
+ const str = (v) => (typeof v === 'string' ? v : '');
45
+ /**
46
+ * The V2 catalog URL for one page. `search` is escaped for an OData string
47
+ * literal (single quotes doubled) and percent-encoded; `$` stays literal the way
48
+ * sap_odata_test does. The handler validates the term before it gets here.
49
+ */
50
+ export function v2CatalogUrl(search, skip = 0) {
51
+ let url = `${V2_CATALOG_BASE}&$top=${V2_PAGE_SIZE}&$skip=${Math.max(0, Math.floor(skip))}`;
52
+ if (search) {
53
+ const filter = `substringof('${search.replace(/'/g, "''")}',TechnicalServiceName)`;
54
+ url += `&$filter=${encodeURIComponent(filter)}`;
55
+ }
56
+ return url;
57
+ }
58
+ export function parseV2Catalog(json) {
59
+ return parseV2Page(json).rows;
60
+ }
61
+ /** Rows plus the raw result count; paging decides on the raw count, not the kept rows. */
62
+ function parseV2Page(json) {
63
+ const d = JSON.parse(json);
64
+ const results = Array.isArray(d?.d?.results) ? d.d.results : [];
65
+ const out = [];
66
+ for (const r of results) {
67
+ if (!r || typeof r !== 'object')
68
+ continue;
69
+ const servicePath = pathOf(str(r.ServiceUrl));
70
+ if (!servicePath || !servicePath.startsWith('/sap/opu/odata/'))
71
+ continue;
72
+ const name = lastSegment(servicePath) || str(r.Title) || str(r.TechnicalServiceName);
73
+ if (!name)
74
+ continue;
75
+ const v = r.TechnicalServiceVersion;
76
+ out.push({
77
+ name: oneLine(name),
78
+ version: v === undefined || v === null || v === '' ? '' : oneLine(v).padStart(4, '0'),
79
+ title: oneLine(r.Title),
80
+ servicePath,
81
+ odata: 'v2',
82
+ });
83
+ }
84
+ return { rows: out, raw: results.length };
85
+ }
86
+ export function parseV4Catalog(json) {
87
+ const d = JSON.parse(json);
88
+ const groups = Array.isArray(d?.value) ? d.value : [];
89
+ const out = [];
90
+ for (const g of groups) {
91
+ const sys = g?.DefaultSystem;
92
+ const services = Array.isArray(sys?.Services) ? sys.Services : [];
93
+ for (const s of services) {
94
+ if (!s || typeof s !== 'object')
95
+ continue;
96
+ const servicePath = pathOf(str(s.ServiceUrl));
97
+ if (!servicePath || !servicePath.startsWith('/sap/opu/odata4/'))
98
+ continue;
99
+ const name = str(s.ServiceId);
100
+ if (!name)
101
+ continue;
102
+ out.push({
103
+ name: oneLine(name),
104
+ version: oneLine(s.ServiceVersion),
105
+ title: oneLine(str(s.Description) || str(g.GroupId)),
106
+ servicePath,
107
+ odata: 'v4',
108
+ });
109
+ }
110
+ }
111
+ return out;
112
+ }
113
+ export async function sapServiceCatalogHandler(args, ctx, deps = {}) {
114
+ const rawSearch = typeof args.search === 'string' ? args.search.trim() : '';
115
+ if (rawSearch.length > SEARCH_MAX_LEN) {
116
+ return { content: `The search term is too long. Use at most ${SEARCH_MAX_LEN} characters.`, is_error: true };
117
+ }
118
+ if (!SEARCH_RE.test(rawSearch)) {
119
+ return {
120
+ content: 'The search term may only use letters, digits, underscore, space, slash and hyphen.',
121
+ is_error: true,
122
+ };
123
+ }
124
+ const want = args.version ?? 'both';
125
+ if (want !== 'v2' && want !== 'v4' && want !== 'both') {
126
+ return { content: 'version must be "v2", "v4" or "both".', is_error: true };
127
+ }
128
+ const n = Number(args.max);
129
+ const max = Number.isFinite(n) && n >= 1 ? Math.min(Math.floor(n), MAX_MAX) : DEFAULT_MAX;
130
+ const term = rawSearch.toLowerCase();
131
+ const get = deps.get ?? (async (p) => {
132
+ const { getConnection } = await import('../../sap/connection-manager.js');
133
+ const cfg = await loadConfig();
134
+ const sys = cfg.sap[ctx.sapAlias];
135
+ if (!sys)
136
+ throw new PlainFetchError(`No SAP system "${ctx.sapAlias}" is configured.`);
137
+ try {
138
+ const conn = await getConnection(ctx.sapAlias, sys);
139
+ const r = await conn.get(p, 'application/json');
140
+ return { status: r.status, body: r.body };
141
+ }
142
+ catch (err) {
143
+ // Never the raw message: connection errors can carry headers, cookies or the user name.
144
+ // An empty fallback lets fetchRows use its own per-catalog sentence.
145
+ throw new PlainFetchError(plainSapFetchError(err, ctx.sapAlias, sys.host, sys.port, ''));
146
+ }
147
+ });
148
+ const matches = (x) => !term || x.name.toLowerCase().includes(term) || x.title.toLowerCase().includes(term);
149
+ /** One GET → rows, or a plain one-line error. Never echoes the answer body or a stack. */
150
+ const fetchRows = async (label, url, parse) => {
151
+ let r;
152
+ try {
153
+ r = await get(url);
154
+ }
155
+ catch (err) {
156
+ // Only a PlainFetchError's own fixed line is shown; anything else gets a fixed sentence.
157
+ if (err instanceof PlainFetchError && err.message)
158
+ return err.message;
159
+ return `${label} catalog could not be read from ${ctx.sapAlias}. Check the SAP login for this system and try again.`;
160
+ }
161
+ if (r.status !== 200)
162
+ return `${label} catalog answered HTTP ${r.status}.`;
163
+ try {
164
+ return parse(r.body);
165
+ }
166
+ catch {
167
+ return `${label} catalog answer was not JSON.`;
168
+ }
169
+ };
170
+ const out = [];
171
+ const notes = [];
172
+ const errors = [];
173
+ const emit = (rows) => {
174
+ for (const x of rows)
175
+ out.push(`${x.servicePath}\t${x.name}\t${x.odata}\t${x.version}\t${x.title}`);
176
+ };
177
+ if (want !== 'v4') {
178
+ // The server filter matches TechnicalServiceName, which is upper case (and
179
+ // Z-prefixed on some boxes, e.g. ZAPI_…); substringof still finds the name.
180
+ const serverTerm = rawSearch ? rawSearch.toUpperCase() : undefined;
181
+ const rows = [];
182
+ let page = 0;
183
+ let failed = false;
184
+ let lastFull = false;
185
+ while (page < V2_MAX_PAGES && rows.length < max) {
186
+ const res = await fetchRows('V2', v2CatalogUrl(serverTerm, page * V2_PAGE_SIZE), parseV2Page);
187
+ page++;
188
+ if (typeof res === 'string') {
189
+ errors.push(res);
190
+ failed = true;
191
+ break;
192
+ }
193
+ rows.push(...res.rows.filter(matches));
194
+ lastFull = res.raw >= V2_PAGE_SIZE;
195
+ if (!lastFull)
196
+ break;
197
+ }
198
+ if (!failed && page >= V2_MAX_PAGES && lastFull && rows.length < max) {
199
+ notes.push(`Stopped after ${V2_MAX_PAGES} pages of the V2 catalog. The V2 list may be incomplete; narrow the search.`);
200
+ }
201
+ if (rows.length > max)
202
+ notes.push(`V2: showing the first ${max}. Narrow the search or raise max.`);
203
+ emit(rows.slice(0, max));
204
+ }
205
+ if (want !== 'v2') {
206
+ const res = await fetchRows('V4', V4_CATALOG_URL, parseV4Catalog);
207
+ if (typeof res === 'string')
208
+ errors.push(res);
209
+ else {
210
+ const rows = res.filter(matches);
211
+ if (rows.length > max)
212
+ notes.push(`V4: showing the first ${max} of ${rows.length}. Narrow the search or raise max.`);
213
+ emit(rows.slice(0, max));
214
+ }
215
+ }
216
+ const content = [
217
+ 'PATH\tNAME\tODATA\tVERSION\tTITLE',
218
+ ...out,
219
+ `(${out.length} active services${rawSearch ? ` matching "${rawSearch}"` : ''}. Active means registered in the ` +
220
+ 'Gateway catalog. Release state is separate; check it before you use a service.)',
221
+ ...notes,
222
+ ...[...new Set(errors)], // one reach line, not one per catalog
223
+ ].join('\n');
224
+ return { content, ...(out.length === 0 && errors.length ? { is_error: true } : {}) };
225
+ }
226
+ registerTool({
227
+ name: 'sap_service_catalog',
228
+ description: 'List OData services ACTIVE on the connected SAP system (Gateway catalog, V2 and V4): path, service name, ' +
229
+ 'OData version, service version, title. Use it in design to map a spec\'s S/4 data to a service that really ' +
230
+ 'exists on this system. Active is not the same as released: check release state separately. Read-only.',
231
+ isMutating: false,
232
+ requiresSap: true,
233
+ // R25: visible only with local_build on (the CAP lane already needs it), so
234
+ // the tool list of non-CAP users stays unchanged.
235
+ flagGated: true,
236
+ input_schema: {
237
+ type: 'object',
238
+ properties: {
239
+ search: {
240
+ type: 'string',
241
+ description: 'Part of the service name or title, e.g. "BUSINESS_PARTNER". Letters, digits, _ space / - only; max 60.',
242
+ },
243
+ version: { type: 'string', enum: ['v2', 'v4', 'both'], description: 'Which catalog to read. Default both.' },
244
+ max: { type: 'number', description: 'Rows per catalog, default 50, max 500.' },
245
+ },
246
+ },
247
+ handler: (args, ctx) => sapServiceCatalogHandler(args, ctx),
248
+ });
@@ -56,6 +56,7 @@ import * as net from 'node:net';
56
56
  import * as os from 'node:os';
57
57
  import * as path from 'node:path';
58
58
  import { KILL_GRACE_MS } from '../../_command-shared.js';
59
+ import { registerShutdownHook } from '../../../lib/shutdown-hooks.js';
59
60
  /** appDirKey → tracked preview. At most one per directory, by construction. */
60
61
  const registry = new Map();
61
62
  /**
@@ -108,7 +109,7 @@ export function childIsLive(child) {
108
109
  return !!child && child.exitCode === null && child.signalCode === null;
109
110
  }
110
111
  /** A pid we are allowed to signal. 0 = own group / idle process, 1 = everything. */
111
- function killablePid(pid) {
112
+ export function killablePid(pid) {
112
113
  return typeof pid === 'number' && Number.isInteger(pid) && pid > 1;
113
114
  }
114
115
  export function registerPreview(e, child) {
@@ -340,15 +341,30 @@ export async function stopPreview(previewId) {
340
341
  const portClosed = await waitForPortClosed(port);
341
342
  return { stopped: true, portClosed, port, ...outcome };
342
343
  }
344
+ /**
345
+ * Windows only: kill `pid` and every descendant SYNCHRONOUSLY with the absolute
346
+ * taskkill (`/T /F`), minimal env, cwd = tmpdir. Safe inside `process.on('exit')`.
347
+ * The CALLER owns the identity rule — call it only for a live child handle this
348
+ * process holds, and with `killablePid(pid)` true. Shared with the background
349
+ * registry (subagent/_background-shared.ts).
350
+ *
351
+ * Returns true when taskkill ran and reported success — exit 0, or 128 ("no such
352
+ * process": already gone). spawnSync never throws; a spawn failure or timeout
353
+ * shows up as `error`, and the caller decides on a fallback.
354
+ */
355
+ export function taskkillTreeSync(pid) {
356
+ const r = spawnSync(TASKKILL, ['/PID', String(pid), '/T', '/F'], {
357
+ windowsHide: true,
358
+ stdio: 'ignore',
359
+ cwd: os.tmpdir(),
360
+ env: TASKKILL_ENV,
361
+ timeout: TASKKILL_TIMEOUT_MS,
362
+ });
363
+ return !r.error && (r.status === 0 || r.status === 128);
364
+ }
343
365
  function realSyncKill(pid) {
344
366
  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
- });
367
+ taskkillTreeSync(pid);
352
368
  }
353
369
  else {
354
370
  process.kill(-pid, 'SIGTERM');
@@ -381,22 +397,12 @@ export function installPreviewExitHandler() {
381
397
  if (exitHandlerInstalled)
382
398
  return;
383
399
  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
+ // One shared exit/signal handler for every managed resource (Task 8 fix
401
+ // round 1, I-1): it runs on 'exit' and on SIGINT/SIGTERM/SIGHUP (prepended,
402
+ // so ui/alt-screen.ts's re-raise cannot pre-empt it), and re-raises when every
403
+ // remaining listener is its own. Two registries with their own prepended
404
+ // listeners each saw a count of 2 and nobody re-raised — Ctrl+C hung.
405
+ registerShutdownHook(killAllPreviewsSync);
400
406
  }
401
407
  export function __resetPreviewRegistryForTests() {
402
408
  registry.clear();
@@ -16,6 +16,9 @@
16
16
  * depends on), fiori_metadata_audit, fiori_draft_smoke
17
17
  * (L1-5b — a SAP write, approval-gated like every write),
18
18
  * fiori_sample_search, fiori_sample_get
19
+ * cap: cap_metadata_import (CAP phase 2 — writes srv/external/<API>.edmx),
20
+ * sap_service_catalog (read-only Gateway catalog, used in design),
21
+ * sap_link, cap_link_smoke (S/4 link check; its write leg is approval-gated)
19
22
  * extend: extend_model_insert (anchored CDS/DDLX edit — abap-extend-model)
20
23
  * subagent: agent_run (Task 7, 2026-07-03 — customers get subagent
21
24
  * dispatch when local_build is on; plan phases force its
@@ -71,6 +74,14 @@ export const LOCAL_BUILD_TOOLS = [
71
74
  'fiori_draft_smoke',
72
75
  'fiori_sample_search',
73
76
  'fiori_sample_get',
77
+ // CAP phase 2 (Task 10) — writes srv/external/<API>.edmx from a SAP $metadata.
78
+ 'cap_metadata_import',
79
+ // R25 (final wave) — Gateway catalog read for design; gated so non-CAP users' tool list is unchanged.
80
+ 'sap_service_catalog',
81
+ // CAP phase 2 (Task 8) — the SAP link: a local CAP app's [s4] profile reaches S/4 through it.
82
+ 'sap_link',
83
+ // CAP phase 2 (Task 11) — S/4 link check: live read vs direct S/4, dev-only update-then-restore.
84
+ 'cap_link_smoke',
74
85
  'extend_model_insert',
75
86
  'system_capability',
76
87
  'agent_run',
@@ -15,11 +15,27 @@
15
15
  * spawned in.
16
16
  *
17
17
  * Exit handler installed exactly once via installExitHandler(). When
18
- * Node exits (normal or signal), every tracked child is SIGTERMd, then
19
- * SIGKILLd after KILL_GRACE_MS ms. Both timers .unref() so they don't
20
- * keep Node alive past the legitimate shutdown.
18
+ * Node exits (normal or signal), every tracked LIVE child is stopped:
19
+ * - Windows: the whole tree, synchronously, with the absolute taskkill
20
+ * `/T /F` shared with the Fiori preview registry. `child.kill()` there is
21
+ * TerminateProcess on the direct child only — and cross-spawn runs a
22
+ * `.cmd` shim (`cds.cmd`, `npm.cmd`) through cmd.exe, which creates no job
23
+ * object, so the real server (e.g. `cds watch`'s `watched` grandchild)
24
+ * kept the port (task 14c finding 3).
25
+ * If taskkill cannot run or fails (not exit 0 / 128), fall back to the
26
+ * direct kill below.
27
+ * - POSIX: SIGTERM to the direct child, then SIGKILL after KILL_GRACE_MS
28
+ * (timer .unref()d).
29
+ * Identity rule (same as preview): only a child handle this process still
30
+ * holds live, and only a pid > 1.
31
+ *
32
+ * The handler is a hook on lib/shutdown-hooks.ts, NOT its own process
33
+ * listeners: our own appended SIGINT/SIGTERM listeners used to stop Node's
34
+ * default "exit on signal", so SIGTERM no longer ended the CLI.
21
35
  */
22
36
  import { KILL_GRACE_MS } from '../_command-shared.js';
37
+ import { registerShutdownHook } from '../../lib/shutdown-hooks.js';
38
+ import { childIsLive, killablePid, taskkillTreeSync } from '../fiori/preview/registry.js';
23
39
  export const MAX_EVENTS_PER_BG = 10_000;
24
40
  const registry = new Map();
25
41
  let exitHandlerInstalled = false;
@@ -83,46 +99,52 @@ export function consumeEvents(bg_id, max) {
83
99
  signal: entry.signal,
84
100
  };
85
101
  }
102
+ let treeKillImpl = taskkillTreeSync;
103
+ /** Stop one entry's process tree, synchronously where it matters (Windows). */
104
+ function stopEntrySync(entry) {
105
+ const child = entry.child;
106
+ if (entry.closed || !childIsLive(child))
107
+ return;
108
+ if (process.platform === 'win32' && killablePid(child.pid)) {
109
+ // taskkill failed to run, timed out, or exited with an error → fall back
110
+ // to the direct kill so at least the child itself goes.
111
+ if (treeKillImpl(child.pid))
112
+ return;
113
+ }
114
+ try {
115
+ child.kill('SIGTERM');
116
+ }
117
+ catch { /* ignore */ }
118
+ const t = setTimeout(() => {
119
+ try {
120
+ if (childIsLive(child))
121
+ child.kill('SIGKILL');
122
+ }
123
+ catch { /* ignore */ }
124
+ }, KILL_GRACE_MS);
125
+ t.unref();
126
+ }
127
+ /** Stop every live background child (and, on Windows, its descendants). */
128
+ export function killAllBackgroundSync() {
129
+ for (const entry of registry.values()) {
130
+ try {
131
+ stopEntrySync(entry);
132
+ }
133
+ catch { /* best effort: one failure never blocks the rest */ }
134
+ }
135
+ }
86
136
  /** Idempotent. Subsequent calls are silent no-ops. */
87
137
  export function installExitHandler() {
88
138
  if (exitHandlerInstalled)
89
139
  return;
90
140
  exitHandlerInstalled = true;
91
- // 'exit' fires synchronously during Node shutdown. Best-effort SIGTERM
92
- // each tracked child; the SIGKILL escalation timer cannot fire from
93
- // inside an 'exit' handler (event-loop is shutting down) so we
94
- // additionally hook SIGINT/SIGTERM/SIGHUP below for graceful paths
95
- // where the loop is still alive.
96
- process.on('exit', () => {
97
- for (const entry of registry.values()) {
98
- if (!entry.closed) {
99
- try {
100
- entry.child.kill('SIGTERM');
101
- }
102
- catch { /* ignore */ }
103
- }
104
- }
105
- });
106
- for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
107
- process.on(sig, () => {
108
- for (const entry of registry.values()) {
109
- if (!entry.closed) {
110
- try {
111
- entry.child.kill('SIGTERM');
112
- }
113
- catch { /* ignore */ }
114
- const t = setTimeout(() => {
115
- try {
116
- entry.child.kill('SIGKILL');
117
- }
118
- catch { /* ignore */ }
119
- }, KILL_GRACE_MS);
120
- t.unref();
121
- }
122
- }
123
- });
124
- }
141
+ // One shared exit/signal handler for every managed resource (runs on 'exit'
142
+ // and on SIGINT/SIGTERM/SIGHUP, and re-raises so the CLI still exits).
143
+ registerShutdownHook(killAllBackgroundSync);
125
144
  }
145
+ /** Test-only — replace / restore the Windows tree kill. */
146
+ export function __setTreeKillForTests(fn) { treeKillImpl = fn; }
147
+ export function __resetTreeKillForTests() { treeKillImpl = taskkillTreeSync; }
126
148
  /** Test-only — clears registry between cases. Does NOT uninstall the exit handler (handler is process-lifetime-scoped). */
127
149
  export function __resetRegistryForTests() {
128
150
  registry.clear();
@@ -8,9 +8,11 @@
8
8
  * Child stdout/stderr lines stream into the registry as BgEvents;
9
9
  * monitor_emit drains them.
10
10
  *
11
- * Child does NOT outlive the CLI process — the registry's exit
12
- * handler SIGTERMs every live child on Node exit and SIGKILLs after
13
- * KILL_GRACE_MS.
11
+ * Child does NOT outlive the CLI process. There is no per-job stop: when
12
+ * the CLI exits or gets SIGINT/SIGTERM/SIGHUP, the registry's shutdown hook
13
+ * stops every live child — on Windows the whole process tree (absolute
14
+ * taskkill /T /F, falling back to a direct kill), on POSIX SIGTERM to the
15
+ * direct child, then SIGKILL after KILL_GRACE_MS.
14
16
  *
15
17
  * Flag-gated: invisible to listTools() unless
16
18
  * CSPEACH_TOOL_BACKGROUND_RUN=on. isMutating: true — hooks the existing
@@ -135,14 +137,16 @@ export async function backgroundRunHandler(args, ctx) {
135
137
  `pid: ${child.pid ?? 'unknown'}\n` +
136
138
  `cwd: ${cwd}\n` +
137
139
  `note: child is running — call monitor_emit with this bg_id to drain stdout/stderr/exit events. ` +
138
- `child will be SIGTERMd on CLI exit (then SIGKILLd after grace).`,
140
+ `there is no per-job stop: the child runs until the CLI exits, which stops it ` +
141
+ `(on Windows the whole process tree; on POSIX SIGTERM to the child, SIGKILL after grace).`,
139
142
  };
140
143
  }
141
144
  registerTool({
142
145
  name: 'background_run',
143
146
  description: 'Spawn a safelisted command as a non-blocking background process. Returns a bg_id immediately; ' +
144
147
  'caller polls events via monitor_emit. Same safelist + argv-only + env scrub as shell_exec. ' +
145
- 'Child does NOT outlive the CLI process — SIGTERM on exit, SIGKILL after grace.',
148
+ 'No per-job stop: the child runs until the CLI exits or gets a signal, which stops it — ' +
149
+ 'the whole process tree on Windows; SIGTERM to the child (SIGKILL after grace) on POSIX.',
146
150
  isMutating: true,
147
151
  category: 'subagent',
148
152
  flagGated: true,
package/dist/upgrade.js CHANGED
@@ -1,13 +1,15 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { confirm } from '@inquirer/prompts';
3
3
  import chalk from 'chalk';
4
+ /** Our published package. Never the unscoped `cspeach` (not ours on npm). */
5
+ export const UPGRADE_PACKAGE = '@cspeach/cli@latest';
4
6
  export async function runUpgrade() {
5
7
  console.log(chalk.cyan('Upgrading cspeach to latest via npm…'));
6
- const yes = await confirm({ message: 'Run `npm i -g cspeach@latest`?', default: true });
8
+ const yes = await confirm({ message: `Run \`npm i -g ${UPGRADE_PACKAGE}\`?`, default: true });
7
9
  if (!yes)
8
10
  return 0;
9
11
  return new Promise((resolve) => {
10
- const proc = spawn('npm', ['i', '-g', 'cspeach@latest'], { stdio: 'inherit', shell: true });
12
+ const proc = spawn('npm', ['i', '-g', UPGRADE_PACKAGE], { stdio: 'inherit', shell: true });
11
13
  proc.on('exit', (code) => resolve(code ?? 1));
12
14
  });
13
15
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cspeach/cli",
3
- "version": "1.1.21",
3
+ "version": "1.1.23",
4
4
  "description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, 34 skills, safety gates.",
5
5
  "keywords": [
6
6
  "abap",