@cspeach/cli 1.1.22 → 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.
- package/dist/agent/tool-dispatch.js +4 -2
- package/dist/cli.js +13 -0
- package/dist/commands/plan-audit-evidence.js +61 -4
- package/dist/commands/plan-audit.js +13 -1
- package/dist/commands/sap-link.js +52 -0
- package/dist/lib/shutdown-hooks.js +87 -0
- package/dist/one-shot.js +4 -0
- package/dist/projects/plan-run.js +5 -1
- package/dist/projects/plan-schema.js +2 -1
- package/dist/renderer/tool-labels.js +5 -1
- package/dist/repl/credential-handover-gate.js +12 -6
- package/dist/repl.js +4 -0
- package/dist/sap/connection-manager.js +30 -24
- package/dist/sap-link/policy.js +185 -0
- package/dist/sap-link/project.js +163 -0
- package/dist/sap-link/registry.js +259 -0
- package/dist/sap-link/server.js +351 -0
- package/dist/sap-link/upstream.js +367 -0
- package/dist/sap-link/write-log.js +60 -0
- package/dist/skills/bundled-skills.js +1 -1
- package/dist/tools/cap/_fetch-errors.js +20 -0
- package/dist/tools/cap/link-smoke.js +703 -0
- package/dist/tools/cap/metadata-import.js +325 -0
- package/dist/tools/cap/sap-link-tool.js +133 -0
- package/dist/tools/cap/service-catalog.js +248 -0
- package/dist/tools/fiori/preview/registry.js +30 -24
- package/dist/tools/local-build.js +11 -0
- package/dist/tools/subagent/_background-shared.js +59 -37
- package/dist/tools/subagent/background_run.js +9 -5
- package/package.json +121 -121
|
@@ -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
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
//
|
|
92
|
-
//
|
|
93
|
-
|
|
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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
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
|
-
'
|
|
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,
|