@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.
@@ -0,0 +1,185 @@
1
+ const READS = new Set(['GET', 'HEAD']);
2
+ const WRITES = new Set(['POST', 'PUT', 'PATCH', 'MERGE', 'DELETE']);
3
+ const OVERRIDE_HEADERS = ['x-http-method', 'x-http-method-override'];
4
+ // eslint-disable-next-line no-control-regex
5
+ const CONTROL_CHAR = /[\u0000-\u001f\u007f]/;
6
+ /** All values for a header, matched case-insensitively; arrays are flattened. */
7
+ function headerValues(h, name) {
8
+ const out = [];
9
+ for (const [k, v] of Object.entries(h)) {
10
+ if (k.toLowerCase() !== name)
11
+ continue;
12
+ if (Array.isArray(v))
13
+ out.push(...v);
14
+ else if (v !== undefined)
15
+ out.push(v);
16
+ }
17
+ return out;
18
+ }
19
+ /**
20
+ * The method the request really performs, or an error when the override headers are
21
+ * unusable (unknown/empty verb, or conflicting values).
22
+ */
23
+ /** Echo a verb only when it is plain letters; never reflect arbitrary text. */
24
+ function unknownVerbMessage(verb) {
25
+ if (verb === '')
26
+ return 'The SAP link does not forward empty requests.';
27
+ if (/^[A-Z]{1,16}$/.test(verb))
28
+ return `The SAP link does not forward ${verb} requests.`;
29
+ return 'The SAP link does not forward that method.';
30
+ }
31
+ function effectiveMethod(realMethod, h) {
32
+ const real = realMethod.trim().toUpperCase();
33
+ if (!READS.has(real) && !WRITES.has(real)) {
34
+ return { error: unknownVerbMessage(real) };
35
+ }
36
+ const overrides = OVERRIDE_HEADERS.flatMap((n) => headerValues(h, n)).map((v) => v.trim().toUpperCase());
37
+ if (overrides.length === 0)
38
+ return { method: real, real };
39
+ const distinct = new Set(overrides);
40
+ if (distinct.size > 1) {
41
+ return { error: 'The SAP link refuses requests with conflicting method override headers.' };
42
+ }
43
+ const override = overrides[0];
44
+ if (!READS.has(override) && !WRITES.has(override)) {
45
+ return { error: unknownVerbMessage(override) };
46
+ }
47
+ return { method: override, real, override };
48
+ }
49
+ /** Each `%` must be `%XY` decoding to a printable ASCII char other than . / \ % ;. */
50
+ function pathEncodingIsSafe(rawPath) {
51
+ for (let i = 0; i < rawPath.length; i++) {
52
+ if (rawPath[i] !== '%')
53
+ continue;
54
+ const hex = rawPath.slice(i + 1, i + 3);
55
+ if (!/^[0-9a-fA-F]{2}$/.test(hex))
56
+ return false;
57
+ const code = parseInt(hex, 16);
58
+ if (code < 0x20 || code >= 0x7f)
59
+ return false;
60
+ if (code === 0x2e || code === 0x2f || code === 0x5c || code === 0x25 || code === 0x3b)
61
+ return false;
62
+ i += 2;
63
+ }
64
+ return true;
65
+ }
66
+ /** Lower-cased, slash-led, no trailing slash; null when the prefix is not a clean path. */
67
+ function normPrefix(p) {
68
+ const withSlash = p.startsWith('/') ? p : `/${p}`;
69
+ const trimmed = withSlash.replace(/\/+$/, '');
70
+ if (trimmed === '' || trimmed === '/')
71
+ return null;
72
+ if (/[%\\?#;]/.test(trimmed) || /[^\x20-\x7e]/.test(trimmed))
73
+ return null;
74
+ const segments = trimmed.slice(1).split('/');
75
+ if (segments.some((s) => s === '' || s === '.' || s === '..'))
76
+ return null;
77
+ return trimmed.toLowerCase();
78
+ }
79
+ function isBatchPath(pathname) {
80
+ return pathname.split('/').some((seg) => {
81
+ let decoded;
82
+ try {
83
+ decoded = decodeURIComponent(seg);
84
+ }
85
+ catch {
86
+ decoded = seg;
87
+ }
88
+ return decoded.split(';', 1)[0].trim().toLowerCase() === '$batch';
89
+ });
90
+ }
91
+ /**
92
+ * True when any query parameter name (decoded, trimmed) starts with `sap-`, except
93
+ * sap-language. 'malformed' when a name cannot be decoded (caller fails closed).
94
+ */
95
+ function hasSapQueryParam(rawQuery) {
96
+ for (const part of rawQuery.split(/[&;]/)) {
97
+ const rawName = part.split('=', 1)[0];
98
+ let name;
99
+ try {
100
+ name = decodeURIComponent(rawName.replace(/\+/g, ' '));
101
+ }
102
+ catch {
103
+ return 'malformed';
104
+ }
105
+ const n = name.trim().toLowerCase();
106
+ if (n.startsWith('sap-') && n !== 'sap-language')
107
+ return true;
108
+ }
109
+ return false;
110
+ }
111
+ export function writesBlockedMessage(alias, role) {
112
+ const now = role === undefined ? 'has no role set' : `has role ${role}`;
113
+ return `Writes to ${alias} need role dev. ${alias} ${now}. Set it with: cspeach config set sap.${alias}.role dev`;
114
+ }
115
+ export function decideRequest(input) {
116
+ const m = effectiveMethod(input.method, input.headers);
117
+ if ('error' in m)
118
+ return { allow: false, status: 400, message: m.error };
119
+ const method = m.method;
120
+ const verbIsWrite = WRITES.has(m.real) || (m.override !== undefined && WRITES.has(m.override));
121
+ const raw = input.rawUrl;
122
+ if (!raw.startsWith('/') || raw.startsWith('//')) {
123
+ return { allow: false, status: 400, message: 'The SAP link only accepts paths on this machine, not full URLs.' };
124
+ }
125
+ if (CONTROL_CHAR.test(raw)) {
126
+ return { allow: false, status: 400, message: 'The SAP link refuses paths with control characters.' };
127
+ }
128
+ const rawPath = raw.split(/[?#]/, 1)[0];
129
+ if (rawPath.includes('\\') || /[^\x00-\x7f]/.test(rawPath) || !pathEncodingIsSafe(rawPath)) {
130
+ return { allow: false, status: 400, message: 'The SAP link refuses encoded or backslash paths.' };
131
+ }
132
+ const url = new URL(raw, 'http://127.0.0.1');
133
+ if (url.host !== '127.0.0.1') {
134
+ return { allow: false, status: 400, message: 'The SAP link only accepts paths on this machine, not full URLs.' };
135
+ }
136
+ for (const seg of url.pathname.split('/')) {
137
+ let decoded;
138
+ try {
139
+ decoded = decodeURIComponent(seg);
140
+ }
141
+ catch {
142
+ return { allow: false, status: 400, message: 'The SAP link refuses encoded or backslash paths.' };
143
+ }
144
+ const bare = decoded.split(';', 1)[0].trim();
145
+ if (bare === '.' || bare === '..') {
146
+ return { allow: false, status: 400, message: 'The SAP link refuses dot segments with parameters.' };
147
+ }
148
+ }
149
+ const pathLower = url.pathname.toLowerCase();
150
+ const usable = input.allowedPrefixes
151
+ .map((original) => ({ original, norm: normPrefix(original) }))
152
+ .filter((p) => p.norm !== null);
153
+ const hit = usable.find((p) => pathLower === p.norm || pathLower.startsWith(`${p.norm}/`) || pathLower.startsWith(`${p.norm};`));
154
+ if (!hit) {
155
+ return {
156
+ allow: false,
157
+ status: 404,
158
+ message: `The SAP link only forwards the services this project imported: ` +
159
+ `${usable.map((p) => p.original).join(', ') || '(none)'}.`,
160
+ };
161
+ }
162
+ // Only `;v=<digits>` directly on the service segment; no other `;` anywhere.
163
+ const afterPrefix = pathLower.slice(hit.norm.length);
164
+ const version = /^;v=\d+(?=\/|$)/.exec(afterPrefix);
165
+ const rest = version ? afterPrefix.slice(version[0].length) : afterPrefix;
166
+ if (rest.includes(';')) {
167
+ return {
168
+ allow: false,
169
+ status: 400,
170
+ message: 'The SAP link only accepts ;v=<number> after the service name. Other path parameters are refused.',
171
+ };
172
+ }
173
+ if (hasSapQueryParam(url.search.replace(/^\?/, '')) !== false) {
174
+ return {
175
+ allow: false,
176
+ status: 400,
177
+ message: 'The SAP link refuses sap- query parameters. It uses the client and login from your SAP settings.',
178
+ };
179
+ }
180
+ const write = verbIsWrite || isBatchPath(url.pathname);
181
+ if (write && input.role !== 'dev') {
182
+ return { allow: false, status: 403, message: writesBlockedMessage(input.alias, input.role) };
183
+ }
184
+ return { allow: true, write, method, upstreamPath: url.pathname + url.search, servicePrefix: hit.original };
185
+ }
@@ -0,0 +1,163 @@
1
+ // cspeach-cli/src/sap-link/project.ts
2
+ import { promises as fs } from 'node:fs';
3
+ import * as path from 'node:path';
4
+ export class LinkConfigError extends Error {
5
+ }
6
+ const LOOPBACK = new Set(['127.0.0.1', 'localhost']);
7
+ /** Keys that carry a secret. Matched case-insensitively at any depth of an S/4 entry. None may sit in package.json. */
8
+ const SECRET_KEYS = new Set([
9
+ 'user', 'username', 'password', 'token', 'clientsecret', 'client_secret',
10
+ 'authentication', 'headers', 'key', 'apikey',
11
+ ]);
12
+ /** The only OData roots the SAP link may be pointed at, and how many segments name exactly one service. */
13
+ const ODATA_ROOTS = [
14
+ { root: '/sap/opu/odata/', segments: 2, version: 'v2' }, // <namespace>/<SERVICE>[;v=N]
15
+ { root: '/sap/opu/odata4/', segments: 6, version: 'v4' }, // <ns>/<group>/<repo>/<ns>/<service>/<version>
16
+ ];
17
+ const MAX_PACKAGE_BYTES = 5 * 1024 * 1024;
18
+ const MAX_DEPTH = 20;
19
+ // eslint-disable-next-line no-control-regex
20
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
21
+ /**
22
+ * Raw path chars allowed: printable ASCII 0x21-0x7e that the URL parser leaves as-is,
23
+ * i.e. without `"` `<` `>` backtick `{` `|` `}` `^` `%` and backslash.
24
+ */
25
+ const PATH_CHARS = /^[!#$&-;=?-[\]_a-z~]*$/;
26
+ /** A URL with a login in it (`scheme://user[:pass]@host`). */
27
+ const URL_USERINFO = /:\/\/[^/?#\s]*@/;
28
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
29
+ const loginMessage = (name) => `package.json holds a login for ${name}. Remove it — the SAP link adds the login from your keychain.`;
30
+ /** Walks a whole `requires` entry and refuses any secret-bearing key at any depth. Never reports values. */
31
+ function scanForSecrets(name, value, depth = 0, seen = new Set()) {
32
+ if (typeof value === 'string' && URL_USERINFO.test(value))
33
+ throw new LinkConfigError(loginMessage(name));
34
+ if (typeof value !== 'object' || value === null)
35
+ return;
36
+ if (depth > MAX_DEPTH)
37
+ throw new LinkConfigError(`The entry for ${name} in package.json is nested too deeply.`);
38
+ if (seen.has(value))
39
+ return;
40
+ seen.add(value);
41
+ if (Array.isArray(value)) {
42
+ for (const item of value)
43
+ scanForSecrets(name, item, depth + 1, seen);
44
+ return;
45
+ }
46
+ for (const [k, v] of Object.entries(value)) {
47
+ if (SECRET_KEYS.has(k.toLowerCase()))
48
+ throw new LinkConfigError(loginMessage(name));
49
+ scanForSecrets(name, v, depth + 1, seen);
50
+ }
51
+ }
52
+ /** Checks one service's `[s4]` URL and returns its port and allowlist prefix. Never echoes the URL. */
53
+ function checkUrl(name, raw) {
54
+ // Before parsing: `URL` silently strips tabs, newlines and leading/trailing controls.
55
+ if (CONTROL_CHARS.test(raw))
56
+ throw new LinkConfigError(`${name}: the [s4] URL holds control characters.`);
57
+ let u;
58
+ try {
59
+ u = new URL(raw);
60
+ }
61
+ catch {
62
+ throw new LinkConfigError(`${name}: the [s4] URL is not a valid URL.`);
63
+ }
64
+ if (u.username !== '' || u.password !== '')
65
+ throw new LinkConfigError(loginMessage(name));
66
+ const authority = /^[a-z][a-z0-9+.-]*:[/\\]{2}([^/\\?#]*)/i.exec(raw);
67
+ const rawAuthority = authority?.[1] ?? '';
68
+ // `URL` drops the default port, so an explicit :80 would otherwise read as "no port".
69
+ if (u.protocol === 'http:' && u.port === '' && /:0*80$/.test(rawAuthority)) {
70
+ throw new LinkConfigError(`${name}: port 80 is not supported for the SAP link. Use another port, such as 4005.`);
71
+ }
72
+ if (u.protocol !== 'http:' || !LOOPBACK.has(u.hostname) || u.port === '' || !authority) {
73
+ throw new LinkConfigError(`${name}: the [s4] URL must point at the SAP link on 127.0.0.1 with a port.`);
74
+ }
75
+ const port = Number(u.port);
76
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
77
+ throw new LinkConfigError(`${name}: the [s4] URL port must be a whole number from 0 to 65535.`);
78
+ }
79
+ // Check the raw string too: `URL` drops an empty `?` or `#`.
80
+ if (u.search !== '' || u.hash !== '' || raw.includes('?') || raw.includes('#')) {
81
+ throw new LinkConfigError(`${name}: the [s4] URL must not have a query or fragment.`);
82
+ }
83
+ const prefix = u.pathname.replace(/\/+$/, '');
84
+ const root = ODATA_ROOTS.find((r) => prefix.toLowerCase().startsWith(r.root));
85
+ if (!root || prefix.length <= root.root.length) {
86
+ throw new LinkConfigError(`${name}: the [s4] URL path must start with /sap/opu/odata/ or /sap/opu/odata4/ and name a service.`);
87
+ }
88
+ // Judge the RAW path, not what normalisation made of it.
89
+ const rawPath = raw.slice(authority[0].length).replace(/\/+$/, '');
90
+ const rawSegments = rawPath.split('/').slice(1);
91
+ if (rawPath.includes('%') || rawPath.includes('\\') ||
92
+ rawSegments.some((s) => {
93
+ // `..;v=1` climbs on servers that strip matrix params, so judge the part before `;`.
94
+ const bare = s.split(';')[0];
95
+ return bare === '' || bare === '.' || bare === '..';
96
+ })) {
97
+ throw new LinkConfigError(`${name}: the [s4] URL path must not hold dot segments, encoded characters, backslashes or empty segments.`);
98
+ }
99
+ // Anything the parser percent-encodes (space, quotes, braces, non-ASCII, ...) would come back
100
+ // as `%XY` in the prefix, which the policy drops. Refuse it on both sides of parsing.
101
+ if (!PATH_CHARS.test(rawPath) || u.pathname.includes('%')) {
102
+ throw new LinkConfigError(`${name}: the [s4] URL path may only hold plain ASCII letters, digits and URL punctuation.`);
103
+ }
104
+ const segments = prefix.slice(root.root.length).split('/');
105
+ if (segments.length !== root.segments || segments.some((s) => s === '')) {
106
+ throw new LinkConfigError(`${name}: the [s4] URL path must name exactly one service, like /sap/opu/odata/sap/API_BUSINESS_PARTNER.`);
107
+ }
108
+ const last = segments.length - 1;
109
+ const matrixOk = segments.every((s, i) => !s.includes(';') || (root.version === 'v2' && i === last && /^[^;]+;v=\d+$/.test(s)));
110
+ if (!matrixOk) {
111
+ throw new LinkConfigError(`${name}: the [s4] URL path may only carry ;v=<number> on the service name.`);
112
+ }
113
+ // The policy matches the bare service and accepts `;v=N` right after it, so the prefix drops it.
114
+ return { port, prefix: prefix.replace(/;v=\d+$/, '') };
115
+ }
116
+ async function readPackageJson(projectDir) {
117
+ const file = path.join(projectDir, 'package.json');
118
+ let size;
119
+ try {
120
+ size = (await fs.stat(file)).size;
121
+ }
122
+ catch {
123
+ throw new LinkConfigError(`No readable package.json in ${projectDir}.`);
124
+ }
125
+ if (size > MAX_PACKAGE_BYTES) {
126
+ throw new LinkConfigError(`package.json in ${projectDir} is larger than 5 MB. The SAP link will not read it.`);
127
+ }
128
+ try {
129
+ return JSON.parse(await fs.readFile(file, 'utf8'));
130
+ }
131
+ catch {
132
+ throw new LinkConfigError(`No readable package.json in ${projectDir}.`);
133
+ }
134
+ }
135
+ /** The generated app's `[s4]` profile points each remote service at the SAP link (spec §6.2). */
136
+ export async function readLinkTargets(projectDir) {
137
+ const pkg = await readPackageJson(projectDir);
138
+ const cds = isObject(pkg) ? pkg.cds : undefined;
139
+ const requires = isObject(cds) && isObject(cds.requires) ? cds.requires : {};
140
+ const services = [];
141
+ const ports = new Set();
142
+ for (const [name, entry] of Object.entries(requires)) {
143
+ if (!isObject(entry))
144
+ continue;
145
+ const profile = entry['[s4]'];
146
+ if (!isObject(profile))
147
+ continue;
148
+ const creds = profile.credentials;
149
+ if (!isObject(creds) || typeof creds.url !== 'string')
150
+ continue;
151
+ // Only S/4 entries are scanned: other entries (e.g. mocked auth users) legitimately hold test passwords.
152
+ scanForSecrets(name, entry);
153
+ const { port, prefix } = checkUrl(name, creds.url);
154
+ ports.add(port);
155
+ services.push({ name, prefix });
156
+ }
157
+ if (services.length === 0) {
158
+ throw new LinkConfigError('No S/4 services in this project. Import one first (cap_metadata_import, then cds import).');
159
+ }
160
+ if (ports.size !== 1)
161
+ throw new LinkConfigError('All [s4] URLs must use one port — the SAP link listens on one.');
162
+ return { port: [...ports][0], services };
163
+ }
@@ -0,0 +1,259 @@
1
+ // cspeach-cli/src/sap-link/registry.ts
2
+ /**
3
+ * CAP phase 2 — the SAP link registry (Task 8). One in-process link per CAP
4
+ * project. The link and its UpstreamSession live side by side, so stopping a
5
+ * link also drops the pooled authenticated sockets, the cookie jar and the
6
+ * CSRF token (UpstreamSession.close()).
7
+ *
8
+ * Security rules held here:
9
+ * - Never prompt for a password: the keychain lookup runs headless; a miss is
10
+ * a fast plain error.
11
+ * - TLS posture comes only from resolveSapTls(sys). A CA path is read and
12
+ * trusted; an alias the user already marked insecure is reported loudly.
13
+ * No new insecure path is introduced.
14
+ * - Every error thrown from here is a SapLinkError (or the project's
15
+ * LinkConfigError) with a fixed plain message. Raw keychain / fs / TLS text
16
+ * never passes through.
17
+ * - All links are closed when the process exits (best effort, installed once).
18
+ */
19
+ import { createHash } from 'node:crypto';
20
+ import { readFileSync } from 'node:fs';
21
+ import * as os from 'node:os';
22
+ import * as path from 'node:path';
23
+ import { loadConfig as realLoadConfig, resolveSapTls } from '../config/loader.js';
24
+ import { resolveSapPassword } from '../sap/connection-manager.js';
25
+ import { readLinkTargets } from './project.js';
26
+ import { startSapLink, SapLinkStartError } from './server.js';
27
+ import { UpstreamSession } from './upstream.js';
28
+ import { registerShutdownHook } from '../lib/shutdown-hooks.js';
29
+ /** A registry error. Its message is always a fixed, plain, secret-free sentence. */
30
+ export class SapLinkError extends Error {
31
+ }
32
+ const links = new Map();
33
+ /** Registry key: the resolved path, lower-cased on Windows (case-insensitive file system). */
34
+ function key(dir) {
35
+ const abs = path.resolve(dir);
36
+ return process.platform === 'win32' ? abs.toLowerCase() : abs;
37
+ }
38
+ export function writeLogFileFor(projectDir) {
39
+ const id = createHash('sha1').update(key(projectDir)).digest('hex').slice(0, 12);
40
+ return path.join(os.homedir(), '.cspeach', 'sap-link', id, 'write-log.jsonl');
41
+ }
42
+ export function findSapLink(projectDir) {
43
+ return links.get(key(projectDir))?.handle;
44
+ }
45
+ export function listSapLinks() {
46
+ return [...links.values()].map((e) => ({ projectDir: e.projectDir, url: e.handle.url, alias: e.alias }));
47
+ }
48
+ /** Same wording as server.ts's port-taken error, so the developer sees one message. */
49
+ function portTakenMessage(port) {
50
+ return `Port ${port} is in use. Stop the program using it, or change the port in package.json ([s4] credentials.url) and start the SAP link again.`;
51
+ }
52
+ export function roleNoteFor(alias, role) {
53
+ return role === 'dev' ? undefined : `Writes are blocked: ${alias} has no role dev (reads only).`;
54
+ }
55
+ function httpNoteFor(alias, sys) {
56
+ return sys.useSsl
57
+ ? undefined
58
+ : `HTTPS is off for ${alias}. Your SAP login crosses the network unencrypted. Turn on HTTPS (useSsl) for this system.`;
59
+ }
60
+ /** Everything that shapes the upstream connection. A change means a restart, never a reuse. */
61
+ function fingerprintFor(sys) {
62
+ const tls = resolveSapTls(sys);
63
+ return JSON.stringify([sys.host, sys.port, sys.client, sys.username, sys.authType, sys.useSsl === true, tls.mode, tls.caCertPath ?? '']);
64
+ }
65
+ function postureNotesFor(alias, sys) {
66
+ const tlsNote = tlsNoteFor(alias, sys);
67
+ const httpNote = httpNoteFor(alias, sys);
68
+ return { ...(tlsNote ? { tlsNote } : {}), ...(httpNote ? { httpNote } : {}) };
69
+ }
70
+ function tlsNoteFor(alias, sys) {
71
+ return resolveSapTls(sys).mode === 'insecure'
72
+ ? `TLS verification is OFF for ${alias} (insecure in config). Set sap.${alias}.ca_cert_path instead.`
73
+ : undefined;
74
+ }
75
+ function baseUrlFor(sys) {
76
+ const host = sys.host.includes(':') && !sys.host.startsWith('[') ? `[${sys.host}]` : sys.host;
77
+ return `${sys.useSsl ? 'https' : 'http'}://${host}:${sys.port}`;
78
+ }
79
+ // One registry operation at a time: two starts for one project must not race.
80
+ let chain = Promise.resolve();
81
+ function serial(fn) {
82
+ const run = chain.then(fn, fn);
83
+ chain = run.catch(() => undefined);
84
+ return run;
85
+ }
86
+ async function closeEntry(e) {
87
+ try {
88
+ e.upstream.close();
89
+ }
90
+ catch { /* best effort */ }
91
+ try {
92
+ await e.handle.close();
93
+ }
94
+ catch { /* best effort */ }
95
+ }
96
+ async function stopUnlocked(projectDir) {
97
+ const k = key(projectDir);
98
+ const e = links.get(k);
99
+ if (!e)
100
+ return false;
101
+ links.delete(k);
102
+ await closeEntry(e);
103
+ return true;
104
+ }
105
+ const sameList = (a, b) => a.length === b.length && a.every((v, i) => v === b[i]);
106
+ export function ensureSapLink(projectDir, alias, partial = {}) {
107
+ return serial(() => ensureUnlocked(projectDir, alias, partial));
108
+ }
109
+ async function ensureUnlocked(projectDir, alias, partial) {
110
+ const deps = {
111
+ loadConfig: realLoadConfig,
112
+ // Never prompt (spec §6.3, same rule as the Fiori preview): a missing keychain entry is a fast error.
113
+ password: (a, s) => resolveSapPassword(a, s, { headless: () => true }),
114
+ readCa: (p) => readFileSync(p),
115
+ start: startSapLink,
116
+ ...partial,
117
+ };
118
+ // package.json first: its errors are plain LinkConfigErrors and need no SAP settings.
119
+ const targets = await readLinkTargets(projectDir);
120
+ const services = targets.services.map((s) => s.name);
121
+ const prefixes = targets.services.map((s) => s.prefix);
122
+ let cfg;
123
+ try {
124
+ cfg = await deps.loadConfig();
125
+ }
126
+ catch {
127
+ throw new SapLinkError('Could not read the SAP system settings.');
128
+ }
129
+ const k = key(projectDir);
130
+ const sys = cfg.sap?.[alias];
131
+ if (!sys) {
132
+ // The alias is gone from config: a link still running on it goes too.
133
+ if (links.get(k)?.alias === alias)
134
+ await stopUnlocked(projectDir);
135
+ throw new SapLinkError(`No SAP system named ${alias}. Add it with: cspeach config add-sap`);
136
+ }
137
+ const roleNote = roleNoteFor(alias, sys.role);
138
+ const fingerprint = fingerprintFor(sys);
139
+ const existing = links.get(k);
140
+ if (existing && existing.alias === alias && existing.fingerprint === fingerprint &&
141
+ existing.requestedPort === targets.port && sameList(existing.prefixes, prefixes) &&
142
+ // A link whose upstream got a 401 refuses every send: start means restart (new session, new password).
143
+ !existing.upstream.isLoginRejected()) {
144
+ // TLS/http notes describe the running link; the role is read fresh (the server does too).
145
+ return { handle: existing.handle, reused: true, services, ...existing.postureNotes, ...(roleNote ? { roleNote } : {}) };
146
+ }
147
+ // Another alias, connection, auth type, TLS posture, port or service list, or a rejected login: the old link goes first.
148
+ if (existing)
149
+ await stopUnlocked(projectDir);
150
+ if (targets.port !== 0) {
151
+ for (const [otherKey, other] of links) {
152
+ if (otherKey !== k && other.handle.listenPort === targets.port)
153
+ throw new SapLinkError(portTakenMessage(targets.port));
154
+ }
155
+ }
156
+ if (sys.authType !== 'basic')
157
+ throw new SapLinkError(`The SAP link needs password login. ${alias} uses a certificate.`);
158
+ let password;
159
+ try {
160
+ password = await deps.password(alias, sys);
161
+ }
162
+ catch {
163
+ throw new SapLinkError(`No stored password for ${alias}. Store it with: cspeach config add-sap`);
164
+ }
165
+ if (!password)
166
+ throw new SapLinkError(`No stored password for ${alias}. Store it with: cspeach config add-sap`);
167
+ const tls = resolveSapTls(sys);
168
+ let ca;
169
+ if (tls.caCertPath) {
170
+ try {
171
+ ca = deps.readCa(tls.caCertPath);
172
+ }
173
+ catch {
174
+ throw new SapLinkError(`Could not read the CA certificate for ${alias}. Check ca_cert_path in your config.`);
175
+ }
176
+ }
177
+ let upstream;
178
+ try {
179
+ upstream = new UpstreamSession({
180
+ baseUrl: baseUrlFor(sys),
181
+ client: sys.client,
182
+ username: sys.username,
183
+ password,
184
+ ...(ca ? { ca } : {}),
185
+ rejectUnauthorized: !tls.insecureSkipTlsVerify,
186
+ });
187
+ }
188
+ catch {
189
+ throw new SapLinkError(`The SAP settings for ${alias} are not valid. Check host and port.`);
190
+ }
191
+ let handle;
192
+ try {
193
+ handle = await deps.start({
194
+ alias, host: sys.host, port: sys.port,
195
+ listenPort: targets.port,
196
+ allowedPrefixes: prefixes,
197
+ // Fresh per request (Review Focus 3): a role change in config applies at once.
198
+ getRole: async () => (await deps.loadConfig()).sap?.[alias]?.role,
199
+ upstream,
200
+ secret: { password, username: sys.username },
201
+ writeLogFile: writeLogFileFor(projectDir),
202
+ });
203
+ }
204
+ catch (e) {
205
+ try {
206
+ upstream.close();
207
+ }
208
+ catch { /* best effort */ }
209
+ if (e instanceof SapLinkStartError && e.code === 'port-taken')
210
+ throw new SapLinkError(portTakenMessage(targets.port));
211
+ throw new SapLinkError('The SAP link could not start.');
212
+ }
213
+ const postureNotes = postureNotesFor(alias, sys);
214
+ links.set(k, { projectDir: path.resolve(projectDir), alias, prefixes, requestedPort: targets.port, fingerprint, postureNotes, handle, upstream });
215
+ installSapLinkExitHandler();
216
+ return { handle, reused: false, services, ...postureNotes, ...(roleNote ? { roleNote } : {}) };
217
+ }
218
+ export function stopSapLink(projectDir) {
219
+ return serial(() => stopUnlocked(projectDir));
220
+ }
221
+ export function __resetSapLinksForTests() {
222
+ return serial(async () => {
223
+ for (const k of [...links.keys()]) {
224
+ const e = links.get(k);
225
+ links.delete(k);
226
+ await closeEntry(e);
227
+ }
228
+ });
229
+ }
230
+ // ── Process exit ────────────────────────────────────────────────────────────
231
+ let exitHandlerInstalled = false;
232
+ /** Synchronous best effort: drop upstream sessions and stop listening. */
233
+ function closeAllSapLinksSync() {
234
+ for (const e of links.values()) {
235
+ try {
236
+ e.upstream.close();
237
+ }
238
+ catch { /* best effort */ }
239
+ try {
240
+ void e.handle.close().catch(() => undefined);
241
+ }
242
+ catch { /* best effort */ }
243
+ }
244
+ links.clear();
245
+ }
246
+ /** Close every SAP link on CLI exit or a terminating signal. Idempotent. */
247
+ export function installSapLinkExitHandler() {
248
+ if (exitHandlerInstalled)
249
+ return;
250
+ exitHandlerInstalled = true;
251
+ // The shared handler (lib/shutdown-hooks.ts) runs this on exit and on
252
+ // SIGINT/SIGTERM/SIGHUP, alongside the preview registry's cleanup, and
253
+ // re-raises the signal when nothing else is waiting for it.
254
+ registerShutdownHook(closeAllSapLinksSync);
255
+ }
256
+ /** Test-only — observe whether installSapLinkExitHandler has fired. */
257
+ export function __isSapLinkExitHandlerInstalledForTests() {
258
+ return exitHandlerInstalled;
259
+ }