sap-ai-dev-toolkit 0.5.6 → 0.6.0
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/.github/agents/abap-developer.agent.md +7 -7
- package/.github/agents/abap-runtime-debugger.agent.md +4 -4
- package/.github/agents/hana-cloud-hdi-specialist.agent.md +1 -1
- package/.github/agents/rap-service-developer.agent.md +5 -5
- package/.github/agents/sap-solution-architect.agent.md +2 -2
- package/.github/skills/abap-debugging/SKILL.md +1 -1
- package/.github/skills/abap-development/SKILL.md +3 -3
- package/.github/skills/abap-runtime-analysis/SKILL.md +2 -2
- package/.github/skills/abap-testing-quality/SKILL.md +3 -3
- package/.github/skills/cds-development/SKILL.md +3 -3
- package/.github/skills/clean-core-extensibility/SKILL.md +1 -1
- package/.github/skills/hana-cloud-inspection/SKILL.md +2 -2
- package/.github/skills/hana-cloud-native-development/SKILL.md +1 -1
- package/.github/skills/hana-cloud-validation/SKILL.md +1 -1
- package/.github/skills/rap-development/SKILL.md +3 -3
- package/.github/skills/rap-service-delivery/SKILL.md +2 -2
- package/.github/skills/sap-sdlc-orchestration/SKILL.md +1 -1
- package/.github/skills/sap-standard-api-analysis/SKILL.md +1 -1
- package/.github/skills/sap-transport-release/SKILL.md +2 -2
- package/README.md +31 -26
- package/inventory.md +5 -3
- package/package.json +1 -1
- package/src/bas-destination-relay.mjs +13 -41
- package/src/launcher.mjs +11 -17
- package/src/mcp-proxy.mjs +56 -29
- package/src/setup.mjs +2 -110
- package/src/terminal-ui.mjs +3 -1
- package/tools.md +10 -2
- package/src/credential-overrides.mjs +0 -75
- package/src/credentials-store.mjs +0 -113
package/src/setup.mjs
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline/promises';
|
|
2
2
|
import { stdin, stdout } from 'node:process';
|
|
3
|
-
import { checkboxPrompt, colorText, formatStatus
|
|
3
|
+
import { checkboxPrompt, colorText, formatStatus } from './terminal-ui.mjs';
|
|
4
4
|
import { discoverDestinations, remediation } from './bas-discovery.mjs';
|
|
5
5
|
import { discoverCloudFoundryDestinations, getCloudFoundryTarget, deleteManagedCloudFoundryServiceKeys, findOrphanedCloudFoundryServiceKeys } from './cf-destination.mjs';
|
|
6
6
|
import { SAP_DEVELOPMENT_MCP_SERVERS, collectCloudFoundryKeyReferencesFromAllEntries, collectManagedCloudFoundryKeyReferences, installMcpConfig, readMcpConfig, resolveMcpConfigPath } from './mcp-config.mjs';
|
|
7
|
-
import { readCredentials, removeDestinationCredentials, resolveCredentialsPath, storeDestinationCredentials } from './credentials-store.mjs';
|
|
8
|
-
import { canPromptForCredentials, credentialKeyForDestination, credentialModeForDestination } from './credential-overrides.mjs';
|
|
9
|
-
import { createBasDestinationRelay } from './bas-destination-relay.mjs';
|
|
10
7
|
|
|
11
8
|
const SETUP_COMMAND = 'sap-ai-dev --setup';
|
|
12
9
|
|
|
@@ -26,89 +23,6 @@ async function confirmCloudFoundryImport({ input = stdin, output = stdout } = {}
|
|
|
26
23
|
}
|
|
27
24
|
}
|
|
28
25
|
|
|
29
|
-
async function probeBasOnPremCredentialRoute({ destination, user, password, env }) {
|
|
30
|
-
const relay = createBasDestinationRelay({
|
|
31
|
-
...destination,
|
|
32
|
-
credentials: { user, password, mode: 'bas-tunnel' }
|
|
33
|
-
}, { env, log: () => {} });
|
|
34
|
-
try {
|
|
35
|
-
await relay.ready;
|
|
36
|
-
return await relay.probeCsrfSession();
|
|
37
|
-
} finally {
|
|
38
|
-
await relay.close();
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
async function promptForDestinationCredentials({ destinations, credentialsPath, existing = {}, input = stdin, output = stdout, env = process.env, verifyOnPremCredentialRoute = probeBasOnPremCredentialRoute } = {}) {
|
|
43
|
-
const eligible = destinations.filter(canPromptForCredentials);
|
|
44
|
-
const retained = new Set();
|
|
45
|
-
if (!eligible.length) return retained;
|
|
46
|
-
|
|
47
|
-
print(output, '');
|
|
48
|
-
print(output, formatStatus('Credential overrides are optional and apply only to selected BasicAuthentication destinations.', 'step', output, 'Credentials'));
|
|
49
|
-
print(output, ` Overrides are stored beside your MCP config in ${credentialsPath} with owner-only permissions (never inside mcp.json).`);
|
|
50
|
-
print(output, ' For OnPremise destinations, BAS/Cloud Foundry proxy credentials remain separate from the SAP backend credentials.');
|
|
51
|
-
print(output, '');
|
|
52
|
-
|
|
53
|
-
for (const destination of eligible) {
|
|
54
|
-
const key = credentialKeyForDestination(destination);
|
|
55
|
-
if (!key) continue;
|
|
56
|
-
const mode = credentialModeForDestination(destination);
|
|
57
|
-
const known = existing[key];
|
|
58
|
-
const routeLabel = `${destination.source === 'cloud-foundry' ? 'Cloud Foundry' : 'BAS'} ${destination.proxyType} — ${destination.name}`;
|
|
59
|
-
const answer = await textPrompt({
|
|
60
|
-
message: colorText(`🔐 ${routeLabel} — override SAP credentials? (y/n)`, 'cyan', output),
|
|
61
|
-
required: true,
|
|
62
|
-
validate: value => /^(?:y|yes|n|no)$/i.test(value.trim()) || 'Enter y or n.'
|
|
63
|
-
}, { input, output });
|
|
64
|
-
if (/^n/i.test(answer.trim())) {
|
|
65
|
-
await removeDestinationCredentials(credentialsPath, [key]);
|
|
66
|
-
print(output, formatStatus(`Credential override disabled for ${destination.name}.`, 'info', output, 'Credentials'));
|
|
67
|
-
continue;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
if (known?.user && known?.password) {
|
|
71
|
-
print(output, formatStatus(`Credentials for ${destination.name} are already stored. Press Enter at both prompts to keep them.`, 'info', output, 'Credentials'));
|
|
72
|
-
}
|
|
73
|
-
const userAnswer = await textPrompt({
|
|
74
|
-
message: colorText(`👤 ${routeLabel} — SAP user`, 'cyan', output),
|
|
75
|
-
required: false,
|
|
76
|
-
placeholder: known ? 'Enter keeps the stored user' : 'SAP user name'
|
|
77
|
-
}, { input, output });
|
|
78
|
-
const user = userAnswer.trim() || known?.user || '';
|
|
79
|
-
const passwordAnswer = await textPrompt({
|
|
80
|
-
message: colorText(`🔒 ${routeLabel} — SAP password${known ? ' (Enter keeps stored password)' : ''}`, 'cyan', output),
|
|
81
|
-
secret: true,
|
|
82
|
-
required: false
|
|
83
|
-
}, { input, output });
|
|
84
|
-
const password = passwordAnswer || known?.password || '';
|
|
85
|
-
if (!user || !password) {
|
|
86
|
-
await removeDestinationCredentials(credentialsPath, [key]);
|
|
87
|
-
print(output, formatStatus(`No complete credentials for ${destination.name}; no override was stored.`, 'warning', output, 'Credentials'));
|
|
88
|
-
continue;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
if (mode === 'bas-tunnel') {
|
|
92
|
-
let route;
|
|
93
|
-
try {
|
|
94
|
-
route = await verifyOnPremCredentialRoute({ destination, user, password, env });
|
|
95
|
-
} catch {
|
|
96
|
-
route = null;
|
|
97
|
-
}
|
|
98
|
-
if (!(route?.httpStatus >= 200 && route.httpStatus < 300) || !route.tokenReceived || !(route.cookieCount > 0)) {
|
|
99
|
-
await removeDestinationCredentials(credentialsPath, [key]);
|
|
100
|
-
print(output, formatStatus(`No override stored for ${destination.name}: the BAS/Cloud Connector route did not return both a CSRF token and session cookie.`, 'warning', output, 'Credentials'));
|
|
101
|
-
continue;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const host = mode === 'direct' ? (destination.backendUrl || known?.host || '') : '';
|
|
106
|
-
await storeDestinationCredentials(credentialsPath, key, { host, user, password, mode });
|
|
107
|
-
retained.add(key);
|
|
108
|
-
print(output, formatStatus(`Stored credential override for ${destination.name}.`, 'success', output, 'Credentials'));
|
|
109
|
-
}
|
|
110
|
-
return retained;
|
|
111
|
-
}
|
|
112
26
|
|
|
113
27
|
function safeProbe(probe) {
|
|
114
28
|
const result = {};
|
|
@@ -187,7 +101,6 @@ export async function runSetup({
|
|
|
187
101
|
discoverCf = discoverCloudFoundryDestinations,
|
|
188
102
|
confirmCfImport = confirmCloudFoundryImport,
|
|
189
103
|
install = installMcpConfig,
|
|
190
|
-
verifyOnPremCredentialRoute = probeBasOnPremCredentialRoute,
|
|
191
104
|
includeSapDevelopmentToolsPrompt = false
|
|
192
105
|
} = {}) {
|
|
193
106
|
if (!env.H2O_URL) {
|
|
@@ -209,7 +122,6 @@ export async function runSetup({
|
|
|
209
122
|
}
|
|
210
123
|
const destinations = basDestinations.map(safeBasDestination);
|
|
211
124
|
const warnings = [];
|
|
212
|
-
const credentialWarnings = [];
|
|
213
125
|
let createdKeys = [];
|
|
214
126
|
let configPath;
|
|
215
127
|
let managedKeys = [];
|
|
@@ -352,25 +264,6 @@ export async function runSetup({
|
|
|
352
264
|
}, { input, output });
|
|
353
265
|
}
|
|
354
266
|
|
|
355
|
-
// Ask for an explicit credential override choice for each selected supported destination.
|
|
356
|
-
const credentialsPath = await resolveCredentialsPath(env, configPath);
|
|
357
|
-
try {
|
|
358
|
-
const existingMap = (await readCredentials(credentialsPath).catch(() => ({ destinations: {} }))).destinations || {};
|
|
359
|
-
const retained = await promptForDestinationCredentials({
|
|
360
|
-
destinations: selected,
|
|
361
|
-
credentialsPath,
|
|
362
|
-
existing: existingMap,
|
|
363
|
-
input,
|
|
364
|
-
output,
|
|
365
|
-
env,
|
|
366
|
-
verifyOnPremCredentialRoute
|
|
367
|
-
});
|
|
368
|
-
const stale = Object.keys(existingMap).filter(name => !retained.has(name));
|
|
369
|
-
if (stale.length) await removeDestinationCredentials(credentialsPath, stale);
|
|
370
|
-
} catch (error) {
|
|
371
|
-
if (/Prompt interrupted/.test(error.message)) throw error;
|
|
372
|
-
credentialWarnings.push(`Credential overrides could not be stored: ${error.message}`);
|
|
373
|
-
}
|
|
374
267
|
try {
|
|
375
268
|
const result = await install(selected, { env, sapDevelopmentServers });
|
|
376
269
|
const location = result?.path ? ` in ${result.path}` : '';
|
|
@@ -391,9 +284,8 @@ export async function runSetup({
|
|
|
391
284
|
}
|
|
392
285
|
}
|
|
393
286
|
await reportWarnings(warnings);
|
|
394
|
-
await reportWarnings(credentialWarnings);
|
|
395
287
|
await reportWarnings(cleanupWarnings);
|
|
396
|
-
return { selected, ...(result || {}), warnings: [...warnings, ...
|
|
288
|
+
return { selected, ...(result || {}), warnings: [...warnings, ...cleanupWarnings] };
|
|
397
289
|
} catch (error) {
|
|
398
290
|
const cleanupWarnings = [];
|
|
399
291
|
if (createdKeys.length) {
|
package/src/terminal-ui.mjs
CHANGED
|
@@ -139,7 +139,9 @@ export async function checkboxPrompt({ message, choices, required = false, short
|
|
|
139
139
|
for (let index = top; index < end; index += 1) {
|
|
140
140
|
const choice = choices[index];
|
|
141
141
|
const pointer = index === cursor ? '❯' : ' ';
|
|
142
|
-
const marker = selected.has(index)
|
|
142
|
+
const marker = selected.has(index)
|
|
143
|
+
? colorText('✓', 'green', output)
|
|
144
|
+
: colorText('✗', 'red', output);
|
|
143
145
|
const disabled = choice.disabled ? ` — ${choice.disabled}` : '';
|
|
144
146
|
const label = `${pointer}${marker} ${choice.name}${disabled}`;
|
|
145
147
|
lines.push(truncateToColumns(label, columns));
|
package/tools.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The proxy no longer advertises every VSP tool. To keep the developer-lifecycle MCP surface manageable, each generated one-destination server exposes a cherry-picked set of VSP tools plus local workflow tools (currently 59 tools when all curated VSP capabilities are registered).
|
|
4
4
|
|
|
5
|
-
The names below are the logical tool names. Publicly
|
|
5
|
+
The names below are the logical tool names. Publicly they are exposed in lowercase snake_case without a prefix (`get_table_contents`, `run_query`) because each generated MCP server is scoped to one destination; only when a single server fronts multiple destinations does each name gain its destination slug (`demo-abap_get_table_contents`) to stay unambiguous. BAS/VS Code chat tool references bind only lowercase identifiers, so mixed-case names never reach the model.
|
|
6
6
|
|
|
7
7
|
Hidden upstream VSP tools are not directly callable through the proxy. Local workflow tools may still call hidden or non-advertised upstream operations internally when they are required for a bounded workflow.
|
|
8
8
|
|
|
@@ -75,7 +75,15 @@ The proxy adds local multi-step tools to each destination when their required VS
|
|
|
75
75
|
- `PlanABAPCloudMigration` — batch SAP API release-state checks and prioritize recognized unreleased results.
|
|
76
76
|
- `GenerateRAPRegressionSuite` and `RunRAPRegressionSuite` — create and run reusable OData GET-only checks under the selected service root.
|
|
77
77
|
|
|
78
|
-
`sap-ai-dev
|
|
78
|
+
`sap-ai-dev --doctor` checks destination probing, VSP startup, the `get_system_info` probe, and MCP tool listing. `sap-ai-dev --demo` runs the sample tools and OData fixtures without contacting SAP; demo writes and transport creation remain in memory until that process exits.
|
|
79
|
+
|
|
80
|
+
### Read-only mode
|
|
81
|
+
|
|
82
|
+
Set `SAP_AI_DEV_TOOLKIT_READ_ONLY=true` (or legacy `BAS_VSP_READ_ONLY`) on a destination server to run it strictly read-only: `WriteSource`, `EditSource`, `Activate`, `ActivateMultiple`, `CreateTransport`, `SetBreakpoint`, and the change-set workflow tools are removed from the surface, and the VSP child starts with `--transport-read-only` so transport writes are rejected upstream as well. Inspection, queries, linting, and checks remain available.
|
|
83
|
+
|
|
84
|
+
### Request timeouts
|
|
85
|
+
|
|
86
|
+
Calls forwarded to a VSP child time out after `SAP_AI_DEV_TOOLKIT_REQUEST_TIMEOUT_MS` (default 600000 ms; `0` disables). A stalled request fails with a timeout error; the child keeps running so unrelated in-flight requests are unaffected.
|
|
79
87
|
|
|
80
88
|
## Separate optional HANA Cloud inspector
|
|
81
89
|
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import { readCredentials, resolveCredentialsPath } from './credentials-store.mjs';
|
|
2
|
-
|
|
3
|
-
const CLOUD_FOUNDRY_KEY_PREFIX = 'cloud-foundry:';
|
|
4
|
-
|
|
5
|
-
function isBasicAuthentication(value) {
|
|
6
|
-
return /^basic(?:authentication)?$/i.test(String(value || '').replace(/\s+/g, ''));
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export function credentialKeyForDestination(destination = {}) {
|
|
10
|
-
if (destination.source === 'cloud-foundry') {
|
|
11
|
-
const serverName = String(destination.serverName || '').trim();
|
|
12
|
-
return serverName ? `${CLOUD_FOUNDRY_KEY_PREFIX}${serverName}` : '';
|
|
13
|
-
}
|
|
14
|
-
return String(destination.name || '').trim();
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function credentialModeForDestination(destination = {}) {
|
|
18
|
-
if (!isBasicAuthentication(destination.authentication)) return null;
|
|
19
|
-
const proxyType = String(destination.proxyType || '').trim().toLowerCase();
|
|
20
|
-
if (destination.source === 'cloud-foundry') {
|
|
21
|
-
return proxyType === 'onpremise' ? 'cf-connectivity' : null;
|
|
22
|
-
}
|
|
23
|
-
if (proxyType === 'internet') return 'direct';
|
|
24
|
-
if (proxyType === 'onpremise') return 'bas-tunnel';
|
|
25
|
-
return null;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function canPromptForCredentials(destination = {}) {
|
|
29
|
-
const mode = credentialModeForDestination(destination);
|
|
30
|
-
return Boolean(mode && (mode !== 'direct' || destination.backendUrl));
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export async function enrichWithStoredCredentials(destinations, env = process.env, log = message => console.error(message)) {
|
|
34
|
-
const needingCredentials = destinations.filter(destination => {
|
|
35
|
-
const mode = credentialModeForDestination(destination);
|
|
36
|
-
if (!mode || !canPromptForCredentials(destination)) return false;
|
|
37
|
-
if (mode === 'cf-connectivity') return Boolean(destination.serverName);
|
|
38
|
-
return Boolean(destination.url && String(destination.url).includes('.dest'));
|
|
39
|
-
});
|
|
40
|
-
if (!needingCredentials.length) return destinations;
|
|
41
|
-
let stored;
|
|
42
|
-
try {
|
|
43
|
-
const path = await resolveCredentialsPath(env);
|
|
44
|
-
stored = await readCredentials(path);
|
|
45
|
-
} catch (error) {
|
|
46
|
-
log(`[sap-ai-dev] stored credentials could not be read: ${String(error.message).slice(0, 200)}`);
|
|
47
|
-
return destinations;
|
|
48
|
-
}
|
|
49
|
-
return destinations.map(destination => {
|
|
50
|
-
const mode = credentialModeForDestination(destination);
|
|
51
|
-
const key = credentialKeyForDestination(destination);
|
|
52
|
-
const entry = key ? stored.destinations?.[key] : undefined;
|
|
53
|
-
if (!mode || !entry?.user || !entry?.password) return destination;
|
|
54
|
-
if (mode === 'cf-connectivity') {
|
|
55
|
-
if (entry.mode !== mode || destination.source !== 'cloud-foundry') return destination;
|
|
56
|
-
return {
|
|
57
|
-
...destination,
|
|
58
|
-
childEnv: {
|
|
59
|
-
...(destination.childEnv || {}),
|
|
60
|
-
SAP_USER: entry.user,
|
|
61
|
-
SAP_PASSWORD: entry.password,
|
|
62
|
-
SAP_VERBOSE: 'false'
|
|
63
|
-
}
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
if (mode === 'bas-tunnel') {
|
|
67
|
-
if (entry.mode !== mode) return destination;
|
|
68
|
-
return { ...destination, credentials: { user: entry.user, password: entry.password, mode } };
|
|
69
|
-
}
|
|
70
|
-
if (!destination.backendUrl || !destination.url || !String(destination.url).includes('.dest')) return destination;
|
|
71
|
-
if (entry.mode && entry.mode !== 'direct') return destination;
|
|
72
|
-
const host = entry.host || destination.backendUrl;
|
|
73
|
-
return { ...destination, credentials: { host, user: entry.user, password: entry.password, mode: 'direct' } };
|
|
74
|
-
});
|
|
75
|
-
}
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
-
import { homedir } from 'node:os';
|
|
4
|
-
import { basename, dirname, join } from 'node:path';
|
|
5
|
-
import { brandedEnvValue } from './branding.mjs';
|
|
6
|
-
|
|
7
|
-
// Optional SAP backend credential overrides live next to the MCP config
|
|
8
|
-
// (never inside it), with 0600 permissions and no tokens or cookies. Entries
|
|
9
|
-
// are keyed by a stable destination identity and may include a route mode.
|
|
10
|
-
const CREDENTIALS_FILENAME = 'sap-ai-dev-toolkit-credentials.json';
|
|
11
|
-
const CREDENTIAL_MODES = new Set(['direct', 'bas-tunnel', 'cf-connectivity']);
|
|
12
|
-
|
|
13
|
-
async function exists(path) {
|
|
14
|
-
try {
|
|
15
|
-
await stat(path);
|
|
16
|
-
return true;
|
|
17
|
-
} catch (error) {
|
|
18
|
-
if (error?.code === 'ENOENT') return false;
|
|
19
|
-
throw error;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export async function resolveCredentialsPath(env = process.env, mcpConfigPath) {
|
|
24
|
-
const configured = brandedEnvValue(env, 'CREDENTIALS_FILE');
|
|
25
|
-
if (configured) return configured;
|
|
26
|
-
if (mcpConfigPath) return join(dirname(mcpConfigPath), CREDENTIALS_FILENAME);
|
|
27
|
-
const home = env.HOME || homedir();
|
|
28
|
-
const candidates = [
|
|
29
|
-
join(home, '.vscode', 'data', 'User'),
|
|
30
|
-
join(home, '.vscode-server', 'data', 'User'),
|
|
31
|
-
join(home, '.code-server', 'data', 'User')
|
|
32
|
-
];
|
|
33
|
-
for (const candidate of candidates) if (await exists(candidate)) return join(candidate, CREDENTIALS_FILENAME);
|
|
34
|
-
return join(candidates[0], CREDENTIALS_FILENAME);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function sanitizeEntry(entry) {
|
|
38
|
-
const result = {};
|
|
39
|
-
if (typeof entry?.host === 'string' && entry.host) result.host = entry.host;
|
|
40
|
-
if (typeof entry?.user === 'string' && entry.user) result.user = entry.user;
|
|
41
|
-
if (typeof entry?.password === 'string' && entry.password) result.password = entry.password;
|
|
42
|
-
if (CREDENTIAL_MODES.has(entry?.mode)) result.mode = entry.mode;
|
|
43
|
-
if (typeof entry?.updatedAt === 'string') result.updatedAt = entry.updatedAt;
|
|
44
|
-
return result;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export async function readCredentials(path) {
|
|
48
|
-
let raw;
|
|
49
|
-
try {
|
|
50
|
-
raw = await readFile(path, 'utf8');
|
|
51
|
-
} catch (error) {
|
|
52
|
-
if (error?.code === 'ENOENT') return {};
|
|
53
|
-
throw new Error(`Credentials file ${path} could not be read: ${error.message}`);
|
|
54
|
-
}
|
|
55
|
-
let parsed;
|
|
56
|
-
try {
|
|
57
|
-
parsed = JSON.parse(raw);
|
|
58
|
-
} catch (error) {
|
|
59
|
-
throw new Error(`Credentials file ${path} contains invalid JSON: ${error.message}`);
|
|
60
|
-
}
|
|
61
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
62
|
-
throw new Error(`Credentials file ${path} must contain a top-level JSON object`);
|
|
63
|
-
}
|
|
64
|
-
const destinations = {};
|
|
65
|
-
for (const [name, entry] of Object.entries(parsed.destinations || {})) {
|
|
66
|
-
const clean = sanitizeEntry(entry);
|
|
67
|
-
if (clean.user && clean.password) destinations[name] = clean;
|
|
68
|
-
}
|
|
69
|
-
return { destinations };
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
async function writeCredentials(path, destinations) {
|
|
73
|
-
const directory = dirname(path);
|
|
74
|
-
await mkdir(directory, { recursive: true });
|
|
75
|
-
const temporary = join(directory, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
|
|
76
|
-
try {
|
|
77
|
-
await writeFile(temporary, `${JSON.stringify({ version: 1, destinations }, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
78
|
-
await rename(temporary, path);
|
|
79
|
-
await chmod(path, 0o600).catch(() => {});
|
|
80
|
-
} catch (error) {
|
|
81
|
-
await rm(temporary, { force: true }).catch(() => {});
|
|
82
|
-
throw new Error(`Credentials file ${path} could not be written: ${error.message}`);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export async function storeDestinationCredentials(path, name, { host, user, password, mode }) {
|
|
87
|
-
if (mode != null && !CREDENTIAL_MODES.has(mode)) throw new Error('Credential override route mode is unsupported');
|
|
88
|
-
const current = await readCredentials(path).catch(error => { throw error; });
|
|
89
|
-
const destinations = { ...current.destinations };
|
|
90
|
-
destinations[String(name)] = {
|
|
91
|
-
host: String(host || ''),
|
|
92
|
-
user: String(user || ''),
|
|
93
|
-
password: String(password || ''),
|
|
94
|
-
...(mode ? { mode } : {}),
|
|
95
|
-
updatedAt: new Date().toISOString()
|
|
96
|
-
};
|
|
97
|
-
await writeCredentials(path, destinations);
|
|
98
|
-
return { path, name };
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export async function removeDestinationCredentials(path, names) {
|
|
102
|
-
const current = await readCredentials(path);
|
|
103
|
-
const destinations = { ...current.destinations };
|
|
104
|
-
const removed = [];
|
|
105
|
-
for (const name of names) {
|
|
106
|
-
if (Object.hasOwn(destinations, String(name))) {
|
|
107
|
-
delete destinations[String(name)];
|
|
108
|
-
removed.push(String(name));
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
if (removed.length) await writeCredentials(path, destinations);
|
|
112
|
-
return { path, removed };
|
|
113
|
-
}
|