sap-ai-dev-toolkit 0.9.9921 → 0.9.9923
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/sap-btp-infrastructure-admin.agent.md +23 -0
- package/.github/agents/sap-bw-datasphere-migration-specialist.agent.md +22 -0
- package/.github/agents/sap-datasphere-developer.agent.md +22 -0
- package/.github/agents/sap-devops-observability-specialist.agent.md +54 -0
- package/.github/agents/sap-process-automation-developer.agent.md +50 -0
- package/.github/skills/btp-platform-administration/SKILL.md +53 -0
- package/.github/skills/bw-datasphere-migration/SKILL.md +66 -0
- package/.github/skills/cloud-alm-operations/SKILL.md +135 -0
- package/.github/skills/datasphere-administration/SKILL.md +40 -0
- package/.github/skills/datasphere-development/SKILL.md +55 -0
- package/.github/skills/process-automation-development/SKILL.md +113 -0
- package/NOTICE +2 -1
- package/README.md +152 -8
- package/dist/checksums.txt +5 -5
- package/dist/vsp-darwin-arm64 +0 -0
- package/dist/vsp-darwin-x64 +0 -0
- package/dist/vsp-linux-arm64 +0 -0
- package/dist/vsp-linux-x64 +0 -0
- package/dist/vsp-win32-x64.exe +0 -0
- package/inventory.md +60 -0
- package/package.json +8 -2
- package/patches/vsp-namespaced-class-include-edit.patch +134 -0
- package/scripts/build-vsp.mjs +3 -1
- package/src/btp-cli.mjs +199 -0
- package/src/btp-server.mjs +125 -0
- package/src/btp-tools.mjs +776 -0
- package/src/cloud-alm-client.mjs +44 -0
- package/src/cloud-alm-config.mjs +92 -0
- package/src/cloud-alm-credentials.mjs +29 -0
- package/src/cloud-alm-data.mjs +129 -0
- package/src/cloud-alm-server.mjs +115 -0
- package/src/cloud-alm-tools.mjs +682 -0
- package/src/datasphere-auth.mjs +279 -0
- package/src/datasphere-cli.mjs +220 -0
- package/src/datasphere-command.mjs +236 -0
- package/src/datasphere-config.mjs +154 -0
- package/src/datasphere-credentials.mjs +37 -0
- package/src/datasphere-csn.mjs +73 -0
- package/src/datasphere-discovery.mjs +151 -0
- package/src/datasphere-server.mjs +249 -0
- package/src/datasphere-tools.mjs +809 -0
- package/src/hana-database.mjs +3 -3
- package/src/hana-inspector.mjs +2 -2
- package/src/harnesses.mjs +31 -1
- package/src/install-user-assets.mjs +1 -1
- package/src/integration-common.mjs +7 -7
- package/src/integration-config.mjs +5 -0
- package/src/integration-credentials.mjs +25 -16
- package/src/integration-http.mjs +24 -7
- package/src/launcher.mjs +56 -4
- package/src/mcp-config.mjs +37 -1
- package/src/process-automation-automation-tools.mjs +125 -0
- package/src/process-automation-client.mjs +36 -0
- package/src/process-automation-common.mjs +69 -0
- package/src/process-automation-config.mjs +142 -0
- package/src/process-automation-credentials.mjs +37 -0
- package/src/process-automation-inbox-tools.mjs +246 -0
- package/src/process-automation-server.mjs +139 -0
- package/src/process-automation-tools.mjs +727 -0
- package/src/process-automation-visibility-tools.mjs +182 -0
- package/src/setup.mjs +623 -11
- package/tools.md +138 -0
|
@@ -0,0 +1,809 @@
|
|
|
1
|
+
// MCP tools for SAP Datasphere: typed tools for the frequent flows (consumption
|
|
2
|
+
// queries, object lifecycle, task chains, replication flows, Data Marketplace,
|
|
3
|
+
// Open SQL) plus discovery-driven generic tools covering every other CLI command.
|
|
4
|
+
// Every write checks an exact confirmation string before any tenant call, and
|
|
5
|
+
// every result passes through secret redaction.
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import {
|
|
8
|
+
CREATE_ANNOTATIONS,
|
|
9
|
+
OVERWRITE_ANNOTATIONS,
|
|
10
|
+
READ_ONLY_ANNOTATIONS,
|
|
11
|
+
capText,
|
|
12
|
+
confirmation,
|
|
13
|
+
key,
|
|
14
|
+
redactSecrets,
|
|
15
|
+
requireConfirmation,
|
|
16
|
+
resultOf
|
|
17
|
+
} from './integration-common.mjs';
|
|
18
|
+
import { IntegrationError, odataKey } from './integration-http.mjs';
|
|
19
|
+
import { buildLocalTableCsn } from './datasphere-csn.mjs';
|
|
20
|
+
|
|
21
|
+
const CONSUMPTION = '/api/v1/datasphere/consumption';
|
|
22
|
+
const TASKS = '/api/v1/datasphere/tasks';
|
|
23
|
+
const DEFINITION_MAX_BYTES = 5 * 1024 * 1024;
|
|
24
|
+
const DATA_PRODUCT_MAX_BYTES = 1024 * 1024;
|
|
25
|
+
const SQL_QUERY_MAX = 20_000;
|
|
26
|
+
const SQL_EXECUTE_MAX = 100_000;
|
|
27
|
+
const SQL_QUERY_TIMEOUT_MS = 300_000;
|
|
28
|
+
|
|
29
|
+
const space = z.string().regex(/^[A-Z0-9_]{1,20}$/, 'Datasphere space IDs are 1-20 uppercase letters, digits or underscores');
|
|
30
|
+
const technicalName = z.string().regex(/^[A-Za-z0-9_][A-Za-z0-9_.]{0,99}$/, 'Technical names start with a letter, digit or underscore');
|
|
31
|
+
const OBJECT_TYPES = ['remote-tables', 'local-tables', 'views', 'intelligent-lookups', 'er-models', 'types', 'contexts', 'data-flows', 'replication-flows', 'transformation-flows', 'task-chains', 'analytic-models', 'business-entities', 'fact-models', 'consumption-models', 'data-access-controls', 'packages'];
|
|
32
|
+
const WRITABLE_OBJECT_TYPES = OBJECT_TYPES.filter(type => type !== 'types' && type !== 'contexts');
|
|
33
|
+
const CSN_DEFINITION_TYPES = ['local-tables', 'views', 'analytic-models'];
|
|
34
|
+
const uuid = z.string().uuid();
|
|
35
|
+
const objectType = z.enum(OBJECT_TYPES);
|
|
36
|
+
const writableObjectType = z.enum(WRITABLE_OBJECT_TYPES);
|
|
37
|
+
const assetParameters = z.record(z.string().regex(/^[A-Za-z0-9_]+$/), z.string().max(500)).optional();
|
|
38
|
+
|
|
39
|
+
const ACCEPT_BY_VERSION = {
|
|
40
|
+
'design-time': 'application/vnd.sap.datasphere.object.content.design-time+json',
|
|
41
|
+
'run-time': 'application/vnd.sap.datasphere.object.content.run-time+json',
|
|
42
|
+
complete: 'application/vnd.sap.datasphere.object.content+json'
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const TASK_LOG_ACCEPT = {
|
|
46
|
+
status: 'application/vnd.sap.datasphere.task.log.status.object+json',
|
|
47
|
+
details: 'application/vnd.sap.datasphere.task.log.details+json',
|
|
48
|
+
extended: 'application/vnd.sap.datasphere.task.log.details.extended+json'
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const DATASPHERE_TOOL_NAMES = Object.freeze([
|
|
52
|
+
'datasphere_connection_info',
|
|
53
|
+
'datasphere_list_spaces',
|
|
54
|
+
'datasphere_list_assets',
|
|
55
|
+
'datasphere_get_asset_metadata',
|
|
56
|
+
'datasphere_query_relational',
|
|
57
|
+
'datasphere_query_analytical',
|
|
58
|
+
'datasphere_reconcile',
|
|
59
|
+
'datasphere_list_objects',
|
|
60
|
+
'datasphere_read_object',
|
|
61
|
+
'datasphere_create_object',
|
|
62
|
+
'datasphere_update_object',
|
|
63
|
+
'datasphere_delete_object',
|
|
64
|
+
'datasphere_export_package',
|
|
65
|
+
'datasphere_list_connections',
|
|
66
|
+
'datasphere_validate_connection',
|
|
67
|
+
'datasphere_list_task_logs',
|
|
68
|
+
'datasphere_get_task_log',
|
|
69
|
+
'datasphere_run_task_chain',
|
|
70
|
+
'datasphere_retry_task_chain',
|
|
71
|
+
'datasphere_cancel_task_chain',
|
|
72
|
+
'datasphere_replication_flow',
|
|
73
|
+
'datasphere_list_data_providers',
|
|
74
|
+
'datasphere_list_data_products',
|
|
75
|
+
'datasphere_read_data_product',
|
|
76
|
+
'datasphere_create_data_product',
|
|
77
|
+
'datasphere_set_data_product_status',
|
|
78
|
+
'datasphere_install_data_product',
|
|
79
|
+
'datasphere_build_local_table_csn',
|
|
80
|
+
'datasphere_list_commands',
|
|
81
|
+
'datasphere_describe_command',
|
|
82
|
+
'datasphere_run_read_command',
|
|
83
|
+
'datasphere_run_command',
|
|
84
|
+
'datasphere_sql_query',
|
|
85
|
+
'datasphere_sql_execute'
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
const GENERIC_COMMAND = /^[a-z0-9-]+(?: [a-z0-9-]+)*$/;
|
|
89
|
+
const GENERIC_OPTION_KEY = /^[a-z][a-z0-9-]{0,63}$/;
|
|
90
|
+
const RESERVED_OPTIONS = new Set([
|
|
91
|
+
'host', 'secrets-file', 'options-file', 'client-id', 'client-secret', 'access-token', 'refresh-token',
|
|
92
|
+
'passcode', 'code', 'authorization-url', 'token-url', 'authorization-flow', 'browser', 'expires-in',
|
|
93
|
+
'output', 'file-path', 'input', 'login-id', 'verbose'
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
/** Register all Datasphere tools; SQL tools only when `sql` is configured. */
|
|
97
|
+
export function registerDatasphereTools(server, { rest, cli, sql, summary, verify, commands } = {}) {
|
|
98
|
+
const registeredNames = [];
|
|
99
|
+
const auth = summary?.auth ?? 'technical';
|
|
100
|
+
const register = (name, title, description, inputSchema, annotations, handler) => {
|
|
101
|
+
registeredNames.push(name);
|
|
102
|
+
server.registerTool(name, { title, description, inputSchema, annotations }, async args => resultOf(
|
|
103
|
+
async () => redactSecrets(await handler(args)),
|
|
104
|
+
{ product: 'SAP Datasphere', fallback: "SAP Datasphere request failed. Check the client's roles and tenant reachability." }
|
|
105
|
+
));
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const runCli = async (words, options, runOptions) => {
|
|
109
|
+
await cli.init().catch(() => {});
|
|
110
|
+
return cli.run(words, options, runOptions);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const catalog = async () => {
|
|
114
|
+
const entries = await commands();
|
|
115
|
+
return entries;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const objectRows = result => {
|
|
119
|
+
if (Array.isArray(result)) return result;
|
|
120
|
+
if (Array.isArray(result?.value)) return result.value;
|
|
121
|
+
if (Array.isArray(result?.objects)) return result.objects;
|
|
122
|
+
return [];
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const listObjectRows = async (objectSpace, objectTypeRequested, names) => {
|
|
126
|
+
const options = {
|
|
127
|
+
space: objectSpace,
|
|
128
|
+
select: 'technicalName,businessName,type,semanticUsage,status,deployedOn,changedOn'
|
|
129
|
+
};
|
|
130
|
+
if (names?.length) options['technical-names'] = names.join(',');
|
|
131
|
+
const result = await runCli(['objects', objectTypeRequested, 'list'], options);
|
|
132
|
+
return objectRows(result);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const checkDefinition = (definition, type, name, limit, limitText) => {
|
|
136
|
+
if (!definition || typeof definition !== 'object' || Array.isArray(definition)) {
|
|
137
|
+
throw new IntegrationError('definition must be a JSON object');
|
|
138
|
+
}
|
|
139
|
+
const bytes = Buffer.byteLength(JSON.stringify(definition), 'utf8');
|
|
140
|
+
if (bytes > limit) throw new IntegrationError(limitText);
|
|
141
|
+
const content = definition.definitions?.[name];
|
|
142
|
+
if (CSN_DEFINITION_TYPES.includes(type) && (!Object.hasOwn(definition.definitions || {}, name) || !content || typeof content !== 'object' || Array.isArray(content))) {
|
|
143
|
+
throw new IntegrationError(`definition.definitions must contain ${name}`);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// ------------------------------------------------------------ connection
|
|
148
|
+
|
|
149
|
+
register('datasphere_connection_info', 'Datasphere connection info',
|
|
150
|
+
'Report the connected tenant, auth mode, credential source, CLI package version and Open SQL configuration. Verification is read-only and never returns credentials.',
|
|
151
|
+
{ verify: z.boolean().default(false) },
|
|
152
|
+
READ_ONLY_ANNOTATIONS,
|
|
153
|
+
async ({ verify: online }) => {
|
|
154
|
+
const verification = online && verify ? await verify() : undefined;
|
|
155
|
+
return { ...summary, ...(verification ? { verification } : {}) };
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ------------------------------------------------------------ consumption API
|
|
159
|
+
|
|
160
|
+
register('datasphere_list_spaces', 'List Datasphere spaces',
|
|
161
|
+
'List the spaces visible to the OAuth client through the Consumption catalog API.',
|
|
162
|
+
{}, READ_ONLY_ANNOTATIONS,
|
|
163
|
+
async () => (await rest.request('GET', `${CONSUMPTION}/catalog/spaces`))?.value ?? []);
|
|
164
|
+
|
|
165
|
+
register('datasphere_list_assets', 'List consumption assets',
|
|
166
|
+
'List assets exposed for consumption: all visible assets, or the assets of one space.',
|
|
167
|
+
{ space: space.optional() },
|
|
168
|
+
READ_ONLY_ANNOTATIONS,
|
|
169
|
+
async ({ space: assetSpace }) => (await rest.request('GET', assetSpace
|
|
170
|
+
? `${CONSUMPTION}/catalog/spaces(${key(assetSpace)})/assets`
|
|
171
|
+
: `${CONSUMPTION}/catalog/assets`))?.value ?? []);
|
|
172
|
+
|
|
173
|
+
register('datasphere_get_asset_metadata', 'Get asset metadata',
|
|
174
|
+
'Fetch the $metadata document of a relational or analytical asset (XML, capped at 64 KiB).',
|
|
175
|
+
{
|
|
176
|
+
space,
|
|
177
|
+
asset: z.string().min(1).max(200),
|
|
178
|
+
kind: z.enum(['relational', 'analytical'])
|
|
179
|
+
},
|
|
180
|
+
READ_ONLY_ANNOTATIONS,
|
|
181
|
+
async ({ space: assetSpace, asset, kind }) => {
|
|
182
|
+
const text = await rest.request('GET', `${CONSUMPTION}/${kind}/${encodeURIComponent(assetSpace)}/${encodeURIComponent(asset)}/$metadata`, {
|
|
183
|
+
accept: 'application/xml',
|
|
184
|
+
responseType: 'text'
|
|
185
|
+
});
|
|
186
|
+
return { space: assetSpace, asset, kind, metadata: capText(String(text ?? ''), 64 * 1024) };
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const consumptionPath = (kind, assetSpace, asset, parameters) => {
|
|
190
|
+
const segment = parameters && Object.keys(parameters).length
|
|
191
|
+
? `${asset}(${Object.entries(parameters).map(([name, value]) => `${name}=${odataKey(value)}`).join(',')})/Set`
|
|
192
|
+
: asset;
|
|
193
|
+
return `${CONSUMPTION}/${kind}/${encodeURIComponent(assetSpace)}/${encodeURIComponent(asset)}/${segment}`;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
register('datasphere_query_relational', 'Query a relational asset',
|
|
197
|
+
'Read rows of a relational asset exposed for consumption (SELECT-style projection, filter, ordering, paging, count).',
|
|
198
|
+
{
|
|
199
|
+
space,
|
|
200
|
+
asset: z.string().min(1).max(200),
|
|
201
|
+
parameters: assetParameters,
|
|
202
|
+
select: z.array(z.string().min(1).max(200)).max(50).optional(),
|
|
203
|
+
filter: z.string().max(2000).optional(),
|
|
204
|
+
orderby: z.string().max(500).optional(),
|
|
205
|
+
top: z.number().int().min(1).max(200).default(100),
|
|
206
|
+
skip: z.number().int().min(0).max(100_000).default(0),
|
|
207
|
+
count: z.boolean().default(false)
|
|
208
|
+
},
|
|
209
|
+
READ_ONLY_ANNOTATIONS,
|
|
210
|
+
async ({ space: assetSpace, asset, parameters, select, filter, orderby, top, skip, count }) => {
|
|
211
|
+
const payload = await rest.request('GET', consumptionPath('relational', assetSpace, asset, parameters), {
|
|
212
|
+
query: {
|
|
213
|
+
...(select?.length ? { $select: select.join(',') } : {}),
|
|
214
|
+
...(filter ? { $filter: filter } : {}),
|
|
215
|
+
...(orderby ? { $orderby: orderby } : {}),
|
|
216
|
+
$top: top,
|
|
217
|
+
$skip: skip,
|
|
218
|
+
...(count ? { $count: 'true' } : {})
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
return {
|
|
222
|
+
rows: payload?.value ?? [],
|
|
223
|
+
count: payload?.['@odata.count'],
|
|
224
|
+
more: Boolean(payload?.['@odata.nextLink'])
|
|
225
|
+
};
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
register('datasphere_query_analytical', 'Query an analytical asset',
|
|
229
|
+
'Read aggregated data from an analytic model exposed for consumption; $select lists the requested measures and dimensions.',
|
|
230
|
+
{
|
|
231
|
+
space,
|
|
232
|
+
asset: z.string().min(1).max(200),
|
|
233
|
+
parameters: assetParameters,
|
|
234
|
+
select: z.array(z.string().min(1).max(200)).min(1).max(50),
|
|
235
|
+
filter: z.string().max(2000).optional(),
|
|
236
|
+
orderby: z.string().max(500).optional(),
|
|
237
|
+
top: z.number().int().min(1).max(200).default(200)
|
|
238
|
+
},
|
|
239
|
+
READ_ONLY_ANNOTATIONS,
|
|
240
|
+
async ({ space: assetSpace, asset, parameters, select, filter, orderby, top }) => {
|
|
241
|
+
const payload = await rest.request('GET', consumptionPath('analytical', assetSpace, asset, parameters), {
|
|
242
|
+
query: {
|
|
243
|
+
$select: select.join(','),
|
|
244
|
+
...(filter ? { $filter: filter } : {}),
|
|
245
|
+
...(orderby ? { $orderby: orderby } : {}),
|
|
246
|
+
$top: top
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
return { rows: payload?.value ?? [], more: Boolean(payload?.['@odata.nextLink']) };
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
register('datasphere_reconcile', 'Reconcile asset data against expected figures',
|
|
253
|
+
'Compare a Datasphere asset against expected numbers: row count for relational assets, measure totals for analytic models, each with a tolerance.',
|
|
254
|
+
{
|
|
255
|
+
space,
|
|
256
|
+
asset: z.string().min(1).max(200),
|
|
257
|
+
kind: z.enum(['relational', 'analytical']),
|
|
258
|
+
parameters: assetParameters,
|
|
259
|
+
expected_row_count: z.number().int().min(0).optional(),
|
|
260
|
+
measures: z.array(z.object({ name: z.string().min(1).max(200), expected: z.number() })).max(20).default([]),
|
|
261
|
+
filter: z.string().max(2000).optional(),
|
|
262
|
+
tolerance: z.number().min(0).default(0)
|
|
263
|
+
},
|
|
264
|
+
READ_ONLY_ANNOTATIONS,
|
|
265
|
+
async ({ space: assetSpace, asset, kind, parameters, expected_row_count: expectedRowCount, measures, filter, tolerance }) => {
|
|
266
|
+
const checks = [];
|
|
267
|
+
if (kind === 'relational') {
|
|
268
|
+
if (measures.length) throw new IntegrationError('Measure totals need kind analytical (an analytic model)');
|
|
269
|
+
if (expectedRowCount !== undefined) {
|
|
270
|
+
const payload = await rest.request('GET', consumptionPath('relational', assetSpace, asset, parameters), {
|
|
271
|
+
query: { $count: 'true', $top: 1, ...(filter ? { $filter: filter } : {}) }
|
|
272
|
+
});
|
|
273
|
+
const actual = payload?.['@odata.count'];
|
|
274
|
+
const difference = Number.isFinite(actual) ? actual - expectedRowCount : null;
|
|
275
|
+
checks.push({
|
|
276
|
+
metric: 'row_count',
|
|
277
|
+
expected: expectedRowCount,
|
|
278
|
+
actual: actual ?? null,
|
|
279
|
+
difference,
|
|
280
|
+
within_tolerance: difference !== null && Math.abs(difference) <= tolerance
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
} else {
|
|
284
|
+
if (expectedRowCount !== undefined) throw new IntegrationError('Row counts are not supported for analytical requests');
|
|
285
|
+
if (measures.length) {
|
|
286
|
+
const payload = await rest.request('GET', consumptionPath('analytical', assetSpace, asset, parameters), {
|
|
287
|
+
query: { $select: measures.map(measure => measure.name).join(','), $top: 1, ...(filter ? { $filter: filter } : {}) }
|
|
288
|
+
});
|
|
289
|
+
const first = payload?.value?.[0] ?? {};
|
|
290
|
+
for (const measure of measures) {
|
|
291
|
+
const raw = first[measure.name];
|
|
292
|
+
const actual = Number(raw);
|
|
293
|
+
const usable = raw !== undefined && raw !== null && Number.isFinite(actual);
|
|
294
|
+
const difference = usable ? actual - measure.expected : null;
|
|
295
|
+
checks.push({
|
|
296
|
+
metric: measure.name,
|
|
297
|
+
expected: measure.expected,
|
|
298
|
+
actual: usable ? actual : null,
|
|
299
|
+
difference,
|
|
300
|
+
within_tolerance: difference !== null && Math.abs(difference) <= tolerance
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
space: assetSpace,
|
|
307
|
+
asset,
|
|
308
|
+
kind,
|
|
309
|
+
filter: filter ?? null,
|
|
310
|
+
checks,
|
|
311
|
+
all_within_tolerance: checks.every(check => check.within_tolerance)
|
|
312
|
+
};
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// ------------------------------------------------------------ objects (CLI)
|
|
316
|
+
|
|
317
|
+
register('datasphere_list_objects', 'List Datasphere objects',
|
|
318
|
+
'List objects of one type in a space through the Datasphere CLI (technical/business name, type, semantic usage, status, deployment and change dates).',
|
|
319
|
+
{
|
|
320
|
+
space,
|
|
321
|
+
object_type: objectType,
|
|
322
|
+
technical_names: z.array(technicalName).max(50).optional(),
|
|
323
|
+
filter: z.string().max(1000).optional(),
|
|
324
|
+
top: z.number().int().min(1).max(200).default(25),
|
|
325
|
+
skip: z.number().int().min(0).max(100_000).default(0)
|
|
326
|
+
},
|
|
327
|
+
READ_ONLY_ANNOTATIONS,
|
|
328
|
+
async ({ space: objectSpace, object_type: type, technical_names: names, filter, top, skip }) => runCli(['objects', type, 'list'], {
|
|
329
|
+
space: objectSpace,
|
|
330
|
+
select: 'technicalName,businessName,type,semanticUsage,status,deployedOn,changedOn',
|
|
331
|
+
top,
|
|
332
|
+
skip,
|
|
333
|
+
...(names?.length ? { 'technical-names': names.join(',') } : {}),
|
|
334
|
+
...(filter ? { filter } : {})
|
|
335
|
+
}));
|
|
336
|
+
|
|
337
|
+
register('datasphere_read_object', 'Read a Datasphere object',
|
|
338
|
+
'Read one object of a space through the Datasphere CLI: design-time definition, run-time state, or the complete object.',
|
|
339
|
+
{
|
|
340
|
+
space,
|
|
341
|
+
object_type: objectType,
|
|
342
|
+
technical_name: technicalName,
|
|
343
|
+
version: z.enum(['design-time', 'run-time', 'complete']).default('design-time')
|
|
344
|
+
},
|
|
345
|
+
READ_ONLY_ANNOTATIONS,
|
|
346
|
+
async ({ space: objectSpace, object_type: type, technical_name: name, version }) => runCli(
|
|
347
|
+
['objects', type, 'read'],
|
|
348
|
+
{ space: objectSpace, 'technical-name': name, accept: ACCEPT_BY_VERSION[version] }
|
|
349
|
+
));
|
|
350
|
+
|
|
351
|
+
register('datasphere_create_object', 'Create a Datasphere object',
|
|
352
|
+
`Create an object from a CSN definition through the Datasphere CLI and read it back. Requires confirmation "CREATE DATASPHERE <object_type> <SPACE>.<TECHNICAL_NAME>".`,
|
|
353
|
+
{
|
|
354
|
+
space,
|
|
355
|
+
object_type: writableObjectType,
|
|
356
|
+
technical_name: technicalName,
|
|
357
|
+
definition: z.record(z.string(), z.unknown()),
|
|
358
|
+
deploy: z.boolean().default(true),
|
|
359
|
+
save_anyway: z.boolean().default(false),
|
|
360
|
+
confirmation
|
|
361
|
+
},
|
|
362
|
+
CREATE_ANNOTATIONS,
|
|
363
|
+
async ({ space: objectSpace, object_type: type, technical_name: name, definition, deploy, save_anyway: saveAnyway, confirmation: given }) => {
|
|
364
|
+
requireConfirmation(given, `CREATE DATASPHERE ${type} ${objectSpace}.${name}`);
|
|
365
|
+
checkDefinition(definition, type, name, DEFINITION_MAX_BYTES, 'Definition is larger than 5 MiB');
|
|
366
|
+
const existing = await listObjectRows(objectSpace, type, [name]);
|
|
367
|
+
if (existing.length) throw new IntegrationError(`${name} already exists in ${objectSpace}; use datasphere_update_object`);
|
|
368
|
+
const cliOutput = await runCli(['objects', type, 'create'], {
|
|
369
|
+
space: objectSpace,
|
|
370
|
+
'technical-name': name,
|
|
371
|
+
...(deploy ? {} : { 'no-deploy': true }),
|
|
372
|
+
...(saveAnyway ? { 'save-anyway': true } : {})
|
|
373
|
+
}, { input: definition });
|
|
374
|
+
const readBack = (await listObjectRows(objectSpace, type, [name]))[0] ?? null;
|
|
375
|
+
return { cli_output: cliOutput, read_back: readBack };
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
register('datasphere_update_object', 'Update a Datasphere object',
|
|
379
|
+
`Update an existing object from a CSN definition, guarded by expected_changed_on. Requires confirmation "UPDATE DATASPHERE <object_type> <SPACE>.<TECHNICAL_NAME>".`,
|
|
380
|
+
{
|
|
381
|
+
space,
|
|
382
|
+
object_type: writableObjectType,
|
|
383
|
+
technical_name: technicalName,
|
|
384
|
+
definition: z.record(z.string(), z.unknown()),
|
|
385
|
+
expected_changed_on: z.string().min(1).max(60),
|
|
386
|
+
deploy: z.boolean().default(true),
|
|
387
|
+
save_anyway: z.boolean().default(false),
|
|
388
|
+
confirmation
|
|
389
|
+
},
|
|
390
|
+
OVERWRITE_ANNOTATIONS,
|
|
391
|
+
async ({ space: objectSpace, object_type: type, technical_name: name, definition, expected_changed_on: expectedChangedOn, deploy, save_anyway: saveAnyway, confirmation: given }) => {
|
|
392
|
+
requireConfirmation(given, `UPDATE DATASPHERE ${type} ${objectSpace}.${name}`);
|
|
393
|
+
checkDefinition(definition, type, name, DEFINITION_MAX_BYTES, 'Definition is larger than 5 MiB');
|
|
394
|
+
const rows = await listObjectRows(objectSpace, type, [name]);
|
|
395
|
+
if (!rows.length) throw new IntegrationError(`${name} not found in ${objectSpace}; use datasphere_create_object`);
|
|
396
|
+
const changedOn = rows[0]?.changedOn ?? null;
|
|
397
|
+
if (changedOn !== expectedChangedOn) {
|
|
398
|
+
throw new IntegrationError(`${name} changed since it was read (changedOn ${changedOn}); read it again before updating`);
|
|
399
|
+
}
|
|
400
|
+
const cliOutput = await runCli(['objects', type, 'update'], {
|
|
401
|
+
space: objectSpace,
|
|
402
|
+
'technical-name': name,
|
|
403
|
+
...(deploy ? {} : { 'no-deploy': true }),
|
|
404
|
+
...(saveAnyway ? { 'save-anyway': true } : {})
|
|
405
|
+
}, { input: definition });
|
|
406
|
+
const readBack = (await listObjectRows(objectSpace, type, [name]))[0] ?? null;
|
|
407
|
+
return { cli_output: cliOutput, read_back: readBack };
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
register('datasphere_delete_object', 'Delete a Datasphere object',
|
|
411
|
+
`Delete an object from a space through the Datasphere CLI. Requires confirmation "DELETE DATASPHERE <object_type> <SPACE>.<TECHNICAL_NAME>".`,
|
|
412
|
+
{
|
|
413
|
+
space,
|
|
414
|
+
object_type: writableObjectType,
|
|
415
|
+
technical_name: technicalName,
|
|
416
|
+
delete_anyway: z.boolean().default(false),
|
|
417
|
+
confirmation
|
|
418
|
+
},
|
|
419
|
+
OVERWRITE_ANNOTATIONS,
|
|
420
|
+
async ({ space: objectSpace, object_type: type, technical_name: name, delete_anyway: deleteAnyway, confirmation: given }) => {
|
|
421
|
+
requireConfirmation(given, `DELETE DATASPHERE ${type} ${objectSpace}.${name}`);
|
|
422
|
+
const rows = await listObjectRows(objectSpace, type, [name]);
|
|
423
|
+
if (!rows.length) throw new IntegrationError(`${name} not found in ${objectSpace}`);
|
|
424
|
+
return runCli(['objects', type, 'delete'], {
|
|
425
|
+
space: objectSpace,
|
|
426
|
+
'technical-name': name,
|
|
427
|
+
force: true,
|
|
428
|
+
...(deleteAnyway ? { 'delete-anyway': true } : {})
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
register('datasphere_export_package', 'Export a Datasphere package',
|
|
433
|
+
`Export a package from a space through the Datasphere CLI. Requires confirmation "EXPORT DATASPHERE PACKAGE <SPACE>.<TECHNICAL_NAME>".`,
|
|
434
|
+
{
|
|
435
|
+
space,
|
|
436
|
+
technical_name: technicalName,
|
|
437
|
+
allow_overwrite: z.boolean().default(false),
|
|
438
|
+
confirmation
|
|
439
|
+
},
|
|
440
|
+
CREATE_ANNOTATIONS,
|
|
441
|
+
async ({ space: objectSpace, technical_name: name, allow_overwrite: allowOverwrite, confirmation: given }) => {
|
|
442
|
+
requireConfirmation(given, `EXPORT DATASPHERE PACKAGE ${objectSpace}.${name}`);
|
|
443
|
+
return runCli(['objects', 'packages', 'export'], {
|
|
444
|
+
space: objectSpace,
|
|
445
|
+
'technical-name': name,
|
|
446
|
+
...(allowOverwrite ? { 'allow-overwrite': true } : {})
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
// ------------------------------------------------------------ connections (CLI)
|
|
451
|
+
|
|
452
|
+
register('datasphere_list_connections', 'List space connections',
|
|
453
|
+
'List the connections of a space through the Datasphere CLI.',
|
|
454
|
+
{ space }, READ_ONLY_ANNOTATIONS,
|
|
455
|
+
async ({ space: objectSpace }) => runCli(['spaces', 'connections', 'list'], { space: objectSpace }));
|
|
456
|
+
|
|
457
|
+
register('datasphere_validate_connection', 'Validate a space connection',
|
|
458
|
+
'Validate one connection of a space through the Datasphere CLI.',
|
|
459
|
+
{ space, name: z.string().min(1).max(200) }, READ_ONLY_ANNOTATIONS,
|
|
460
|
+
async ({ space: objectSpace, name }) => runCli(['spaces', 'connections', 'validate'], { space: objectSpace, name }));
|
|
461
|
+
|
|
462
|
+
// ------------------------------------------------------------ tasks (REST + CLI)
|
|
463
|
+
|
|
464
|
+
register('datasphere_list_task_logs', 'List task logs',
|
|
465
|
+
'List the task logs of one object (task chain, replication flow) in a space through the Tasks REST API.',
|
|
466
|
+
{
|
|
467
|
+
space,
|
|
468
|
+
object_id: z.string().min(1).max(200),
|
|
469
|
+
top: z.number().int().min(1).max(100).default(20)
|
|
470
|
+
},
|
|
471
|
+
READ_ONLY_ANNOTATIONS,
|
|
472
|
+
async ({ space: objectSpace, object_id: objectId, top }) => {
|
|
473
|
+
const payload = await rest.request('GET', `${TASKS}/logs/${encodeURIComponent(objectSpace)}/objects/${encodeURIComponent(objectId)}`);
|
|
474
|
+
const logs = Array.isArray(payload) ? payload : payload?.value ?? [];
|
|
475
|
+
return { logs: logs.slice(0, top), total: logs.length };
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
register('datasphere_get_task_log', 'Get a task log',
|
|
479
|
+
'Read one task log by id through the Tasks REST API; detail level status, details or extended.',
|
|
480
|
+
{
|
|
481
|
+
space,
|
|
482
|
+
log_id: z.number().int().min(1),
|
|
483
|
+
detail: z.enum(['status', 'details', 'extended']).default('details')
|
|
484
|
+
},
|
|
485
|
+
READ_ONLY_ANNOTATIONS,
|
|
486
|
+
async ({ space: objectSpace, log_id: logId, detail }) => rest.request('GET', `${TASKS}/logs/${encodeURIComponent(objectSpace)}/${logId}`, {
|
|
487
|
+
accept: TASK_LOG_ACCEPT[detail]
|
|
488
|
+
}));
|
|
489
|
+
|
|
490
|
+
register('datasphere_run_task_chain', 'Run a task chain',
|
|
491
|
+
`Start a task chain run through the Tasks REST API and return its log id. Requires confirmation "RUN DATASPHERE TASK CHAIN <SPACE>.<TASK_CHAIN>".`,
|
|
492
|
+
{
|
|
493
|
+
space,
|
|
494
|
+
task_chain: technicalName,
|
|
495
|
+
input_parameters: z.record(z.string().min(1).max(200), z.string().max(1000)).optional(),
|
|
496
|
+
confirmation
|
|
497
|
+
},
|
|
498
|
+
CREATE_ANNOTATIONS,
|
|
499
|
+
async ({ space: objectSpace, task_chain: taskChain, input_parameters: inputParameters, confirmation: given }) => {
|
|
500
|
+
requireConfirmation(given, `RUN DATASPHERE TASK CHAIN ${objectSpace}.${taskChain}`);
|
|
501
|
+
const payload = await rest.request('POST', `${TASKS}/chains/${encodeURIComponent(objectSpace)}/run/${encodeURIComponent(taskChain)}`, {
|
|
502
|
+
...(inputParameters ? { json: { inputParameters } } : {}),
|
|
503
|
+
expect: [200, 201, 202]
|
|
504
|
+
});
|
|
505
|
+
return { logId: payload?.logId ?? payload?.log_id ?? payload?.d?.logId ?? null };
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
register('datasphere_retry_task_chain', 'Retry a task chain',
|
|
509
|
+
`Retry the last failed run of a task chain. Requires confirmation "RETRY DATASPHERE TASK CHAIN <SPACE>.<TASK_CHAIN>".`,
|
|
510
|
+
{ space, task_chain: technicalName, confirmation },
|
|
511
|
+
CREATE_ANNOTATIONS,
|
|
512
|
+
async ({ space: objectSpace, task_chain: taskChain, confirmation: given }) => {
|
|
513
|
+
requireConfirmation(given, `RETRY DATASPHERE TASK CHAIN ${objectSpace}.${taskChain}`);
|
|
514
|
+
const payload = await rest.request('POST', `${TASKS}/chains/${encodeURIComponent(objectSpace)}/retry/${encodeURIComponent(taskChain)}`, {
|
|
515
|
+
expect: [200, 201, 202]
|
|
516
|
+
});
|
|
517
|
+
return { logId: payload?.logId ?? payload?.log_id ?? payload?.d?.logId ?? null };
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
register('datasphere_cancel_task_chain', 'Cancel a task chain run',
|
|
521
|
+
`Cancel one task chain run by log id. Requires confirmation "CANCEL DATASPHERE TASK CHAIN RUN <SPACE>.<LOG_ID>".`,
|
|
522
|
+
{ space, log_id: z.number().int().min(1), confirmation },
|
|
523
|
+
OVERWRITE_ANNOTATIONS,
|
|
524
|
+
async ({ space: objectSpace, log_id: logId, confirmation: given }) => {
|
|
525
|
+
requireConfirmation(given, `CANCEL DATASPHERE TASK CHAIN RUN ${objectSpace}.${logId}`);
|
|
526
|
+
return rest.request('POST', `${TASKS}/chains/${encodeURIComponent(objectSpace)}/cancel/${logId}`, { expect: [200, 201, 202] });
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
register('datasphere_replication_flow', 'Control a replication flow',
|
|
530
|
+
`Run, pause, resume, stop or inspect a replication flow, or pause/resume/restart a single object within it. State-changing actions require confirmation "<ACTION> DATASPHERE REPLICATION FLOW <SPACE>.<TECHNICAL_NAME>".`,
|
|
531
|
+
{
|
|
532
|
+
space,
|
|
533
|
+
technical_name: technicalName,
|
|
534
|
+
action: z.enum(['status', 'run', 'pause', 'resume', 'stop', 'pause-object', 'resume-object', 'restart-object']),
|
|
535
|
+
object: z.string().min(1).max(200).optional(),
|
|
536
|
+
no_cleanup: z.boolean().default(false),
|
|
537
|
+
confirmation: confirmation.optional()
|
|
538
|
+
},
|
|
539
|
+
OVERWRITE_ANNOTATIONS,
|
|
540
|
+
async ({ space: objectSpace, technical_name: name, action, object: flowObject, no_cleanup: noCleanup, confirmation: given }) => {
|
|
541
|
+
if (action !== 'status') {
|
|
542
|
+
requireConfirmation(given, `${action.toUpperCase()} DATASPHERE REPLICATION FLOW ${objectSpace}.${name}`);
|
|
543
|
+
}
|
|
544
|
+
if (action.endsWith('-object') && !flowObject) throw new IntegrationError(`${action} needs object`);
|
|
545
|
+
if (noCleanup && action !== 'stop') throw new IntegrationError('no_cleanup is only valid with action stop');
|
|
546
|
+
return runCli(['tasks', 'replication-flows', action], {
|
|
547
|
+
space: objectSpace,
|
|
548
|
+
'technical-name': name,
|
|
549
|
+
...(flowObject ? { object: flowObject } : {}),
|
|
550
|
+
...(noCleanup ? { 'no-cleanup': true } : {})
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
// ------------------------------------------------------------ Data Marketplace (CLI)
|
|
555
|
+
|
|
556
|
+
register('datasphere_list_data_providers', 'List Data Marketplace providers',
|
|
557
|
+
'List the Data Marketplace providers visible to the tenant through the Datasphere CLI.',
|
|
558
|
+
{}, READ_ONLY_ANNOTATIONS,
|
|
559
|
+
async () => runCli(['marketplace', 'providers', 'list']));
|
|
560
|
+
|
|
561
|
+
register('datasphere_list_data_products', 'List Data Marketplace products',
|
|
562
|
+
'List Data Marketplace products through the Datasphere CLI.',
|
|
563
|
+
{}, READ_ONLY_ANNOTATIONS,
|
|
564
|
+
async () => runCli(['marketplace', 'products', 'list']));
|
|
565
|
+
|
|
566
|
+
register('datasphere_read_data_product', 'Read a Data Marketplace product',
|
|
567
|
+
'Read one Data Marketplace product definition by uuid (a template for datasphere_create_data_product).',
|
|
568
|
+
{ data_product_uuid: uuid }, READ_ONLY_ANNOTATIONS,
|
|
569
|
+
async ({ data_product_uuid: dataProductUuid }) => runCli(['marketplace', 'products', 'read'], { 'data-product-uuid': dataProductUuid }));
|
|
570
|
+
|
|
571
|
+
register('datasphere_create_data_product', 'Create a Data Marketplace data product',
|
|
572
|
+
`Create a data product in status Draft from a definition (template: datasphere_read_data_product). Requires confirmation "CREATE DATASPHERE DATA PRODUCT".`,
|
|
573
|
+
{ definition: z.record(z.string(), z.unknown()), confirmation },
|
|
574
|
+
CREATE_ANNOTATIONS,
|
|
575
|
+
async ({ definition, confirmation: given }) => {
|
|
576
|
+
requireConfirmation(given, 'CREATE DATASPHERE DATA PRODUCT');
|
|
577
|
+
if (!definition || typeof definition !== 'object' || Array.isArray(definition)) throw new IntegrationError('definition must be a JSON object');
|
|
578
|
+
if (Buffer.byteLength(JSON.stringify(definition), 'utf8') > DATA_PRODUCT_MAX_BYTES) {
|
|
579
|
+
throw new IntegrationError('Definition is larger than 1 MiB');
|
|
580
|
+
}
|
|
581
|
+
return runCli(['marketplace', 'products', 'create'], {}, { input: definition });
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
register('datasphere_set_data_product_status', 'Set a data product lifecycle status',
|
|
585
|
+
`List, delist or deactivate a data product. Requires confirmation "SET DATASPHERE DATA PRODUCT <UUID> <LIFECYCLE_STATUS>".`,
|
|
586
|
+
{
|
|
587
|
+
data_product_uuid: uuid,
|
|
588
|
+
lifecycle_status: z.enum(['Listed', 'Delisted', 'Deactivated']),
|
|
589
|
+
confirmation
|
|
590
|
+
},
|
|
591
|
+
OVERWRITE_ANNOTATIONS,
|
|
592
|
+
async ({ data_product_uuid: dataProductUuid, lifecycle_status: lifecycleStatus, confirmation: given }) => {
|
|
593
|
+
requireConfirmation(given, `SET DATASPHERE DATA PRODUCT ${dataProductUuid} ${lifecycleStatus}`);
|
|
594
|
+
return runCli(['marketplace', 'products', 'change-lifecycle-status'], {
|
|
595
|
+
'data-product-uuid': dataProductUuid,
|
|
596
|
+
'lifecycle-status': lifecycleStatus
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
register('datasphere_install_data_product', 'Install a data product',
|
|
601
|
+
`Install a data product into a space. Requires confirmation "INSTALL DATASPHERE DATA PRODUCT <UUID> INTO <SPACE>".`,
|
|
602
|
+
{
|
|
603
|
+
data_product_uuid: uuid,
|
|
604
|
+
space,
|
|
605
|
+
update_type: z.enum(['Manual', 'Automatic']).default('Manual'),
|
|
606
|
+
license_key: uuid.optional(),
|
|
607
|
+
confirmation
|
|
608
|
+
},
|
|
609
|
+
CREATE_ANNOTATIONS,
|
|
610
|
+
async ({ data_product_uuid: dataProductUuid, space: targetSpace, update_type: updateType, license_key: licenseKey, confirmation: given }) => {
|
|
611
|
+
requireConfirmation(given, `INSTALL DATASPHERE DATA PRODUCT ${dataProductUuid} INTO ${targetSpace}`);
|
|
612
|
+
return runCli(['marketplace', 'products', 'install'], {
|
|
613
|
+
'data-product-uuid': dataProductUuid,
|
|
614
|
+
'space-id': targetSpace,
|
|
615
|
+
'update-type': updateType,
|
|
616
|
+
...(licenseKey ? { 'license-key': licenseKey } : {})
|
|
617
|
+
});
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
// ------------------------------------------------------------ CSN builder (pure)
|
|
621
|
+
|
|
622
|
+
register('datasphere_build_local_table_csn', 'Build a local-table CSN',
|
|
623
|
+
'Convert a field list (BW/Data Dictionary style: name, label, ddic_type, length, decimals, key) into a Datasphere local-table CSN definition. Pure conversion, no tenant call.',
|
|
624
|
+
{
|
|
625
|
+
technical_name: technicalName,
|
|
626
|
+
business_name: z.string().min(1).max(200),
|
|
627
|
+
fields: z.array(z.object({
|
|
628
|
+
name: z.string().min(1).max(200),
|
|
629
|
+
label: z.string().max(200).optional(),
|
|
630
|
+
ddic_type: z.string().min(1).max(30),
|
|
631
|
+
length: z.number().int().min(1).max(9999).optional(),
|
|
632
|
+
decimals: z.number().int().min(0).max(99).optional(),
|
|
633
|
+
key: z.boolean().optional()
|
|
634
|
+
})).min(1).max(1000)
|
|
635
|
+
},
|
|
636
|
+
{ ...READ_ONLY_ANNOTATIONS, openWorldHint: false },
|
|
637
|
+
async ({ technical_name: technicalNameArg, business_name: businessName, fields }) =>
|
|
638
|
+
buildLocalTableCsn({ technicalName: technicalNameArg, businessName, fields }));
|
|
639
|
+
|
|
640
|
+
// ------------------------------------------------------------ generic CLI commands
|
|
641
|
+
|
|
642
|
+
const findEntry = async command => {
|
|
643
|
+
const entries = await catalog();
|
|
644
|
+
return entries.find(entry => entry.command === command) ?? null;
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
const runGeneric = async (command, options, input) => {
|
|
648
|
+
if (!GENERIC_COMMAND.test(command)) throw new IntegrationError(`Invalid Datasphere command "${command}"`);
|
|
649
|
+
const entry = await findEntry(command);
|
|
650
|
+
if (!entry) throw new IntegrationError(`Unknown Datasphere command ${command}; call datasphere_list_commands`);
|
|
651
|
+
if (auth !== 'interactive' && !entry.technical_user_supported) {
|
|
652
|
+
throw new IntegrationError(`${command} needs an Interactive Usage OAuth client; rerun sap-ai-dev --setup-datasphere and choose "Your user"`);
|
|
653
|
+
}
|
|
654
|
+
const words = command.split(' ');
|
|
655
|
+
const cliOptions = {};
|
|
656
|
+
for (const [name, value] of Object.entries(options || {})) {
|
|
657
|
+
if (!GENERIC_OPTION_KEY.test(name)) throw new IntegrationError(`Invalid option --${name}`);
|
|
658
|
+
if (RESERVED_OPTIONS.has(name)) throw new IntegrationError(`Option --${name} is managed by the toolkit`);
|
|
659
|
+
if (value === undefined || value === null) continue;
|
|
660
|
+
if (typeof value === 'string' && value.length > 10_000) throw new IntegrationError(`Option --${name} value is too long`);
|
|
661
|
+
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
|
|
662
|
+
throw new IntegrationError(`Option --${name} must be a string, number or boolean`);
|
|
663
|
+
}
|
|
664
|
+
cliOptions[name] = value;
|
|
665
|
+
}
|
|
666
|
+
if (input !== undefined && !entry.has_body) throw new IntegrationError(`${command} takes no request body`);
|
|
667
|
+
return runCli(words, cliOptions, input !== undefined ? { input } : {});
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
register('datasphere_list_commands', 'List Datasphere CLI commands',
|
|
671
|
+
'List the Datasphere CLI command catalog discovered from the tenant, optionally filtered by prefix, with the auth mode each command supports. Parameter lists are omitted by default to keep the full catalog within the output limit; use include_parameters with a prefix or describe one command for its options.',
|
|
672
|
+
{
|
|
673
|
+
prefix: z.string().regex(/^[a-z0-9-]*(?: [a-z0-9-]*)*$/).max(120).optional(),
|
|
674
|
+
include_parameters: z.boolean().default(false)
|
|
675
|
+
},
|
|
676
|
+
READ_ONLY_ANNOTATIONS,
|
|
677
|
+
async ({ prefix, include_parameters: includeParameters }) => (await catalog())
|
|
678
|
+
.filter(entry => !prefix || entry.command.startsWith(prefix))
|
|
679
|
+
.map(({ parameters, ...entry }) => ({
|
|
680
|
+
...entry,
|
|
681
|
+
...(includeParameters ? { parameters } : {}),
|
|
682
|
+
usable_with_current_auth: auth === 'interactive' || entry.technical_user_supported
|
|
683
|
+
})));
|
|
684
|
+
|
|
685
|
+
register('datasphere_describe_command', 'Describe a Datasphere CLI command',
|
|
686
|
+
'Show the authoritative --help text of one Datasphere CLI command from the tenant catalog.',
|
|
687
|
+
{ command: z.string().min(1).max(200) },
|
|
688
|
+
READ_ONLY_ANNOTATIONS,
|
|
689
|
+
async ({ command }) => {
|
|
690
|
+
const entry = await findEntry(command);
|
|
691
|
+
if (!entry) throw new IntegrationError(`Unknown Datasphere command ${command}; call datasphere_list_commands`);
|
|
692
|
+
const help = await runCli(command.split(' '), { help: true });
|
|
693
|
+
return { command, catalog: entry, help };
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
register('datasphere_run_read_command', 'Run a read-only Datasphere CLI command',
|
|
697
|
+
'Run any discovered read-only Datasphere CLI command (spaces list, objects read, marketplace, tasks status, ...) with validated options.',
|
|
698
|
+
{
|
|
699
|
+
command: z.string().min(1).max(200),
|
|
700
|
+
options: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
|
|
701
|
+
input: z.union([z.record(z.string(), z.unknown()), z.array(z.unknown())]).optional()
|
|
702
|
+
},
|
|
703
|
+
READ_ONLY_ANNOTATIONS,
|
|
704
|
+
async ({ command, options, input }) => {
|
|
705
|
+
if (!GENERIC_COMMAND.test(command)) throw new IntegrationError(`Invalid Datasphere command "${command}"`);
|
|
706
|
+
const entry = await findEntry(command);
|
|
707
|
+
if (!entry) throw new IntegrationError(`Unknown Datasphere command ${command}; call datasphere_list_commands`);
|
|
708
|
+
if (entry.access !== 'read') throw new IntegrationError(`${command} changes the tenant; use datasphere_run_command`);
|
|
709
|
+
return runGeneric(command, options, input);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
register('datasphere_run_command', 'Run any Datasphere CLI command',
|
|
713
|
+
`Run any discovered Datasphere CLI command. Changing commands require confirmation "RUN DATASPHERE <COMMAND>" (or "DELETE DATASPHERE <COMMAND>" for deletions).`,
|
|
714
|
+
{
|
|
715
|
+
command: z.string().min(1).max(200),
|
|
716
|
+
options: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
|
|
717
|
+
input: z.union([z.record(z.string(), z.unknown()), z.array(z.unknown())]).optional(),
|
|
718
|
+
confirmation
|
|
719
|
+
},
|
|
720
|
+
OVERWRITE_ANNOTATIONS,
|
|
721
|
+
async ({ command, options, input, confirmation: given }) => {
|
|
722
|
+
if (!GENERIC_COMMAND.test(command)) throw new IntegrationError(`Invalid Datasphere command "${command}"`);
|
|
723
|
+
const entry = await findEntry(command);
|
|
724
|
+
if (!entry) throw new IntegrationError(`Unknown Datasphere command ${command}; call datasphere_list_commands`);
|
|
725
|
+
if (entry.access === 'write') requireConfirmation(given, `RUN DATASPHERE ${command}`);
|
|
726
|
+
if (entry.access === 'delete') requireConfirmation(given, `DELETE DATASPHERE ${command}`);
|
|
727
|
+
const cliOptions = { ...(options || {}) };
|
|
728
|
+
if (entry.confirm_required) cliOptions.force = true;
|
|
729
|
+
return runGeneric(command, cliOptions, input);
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
// ------------------------------------------------------------ Open SQL
|
|
733
|
+
|
|
734
|
+
if (sql) {
|
|
735
|
+
let databasePromise;
|
|
736
|
+
const getDatabase = () => {
|
|
737
|
+
if (!databasePromise) {
|
|
738
|
+
databasePromise = Promise.resolve(sql.connect(sql.config, { queryTimeoutMs: SQL_QUERY_TIMEOUT_MS })).catch(error => {
|
|
739
|
+
databasePromise = undefined;
|
|
740
|
+
throw error;
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
return databasePromise;
|
|
744
|
+
};
|
|
745
|
+
const query = async statement => {
|
|
746
|
+
try {
|
|
747
|
+
return await (await getDatabase()).query(statement);
|
|
748
|
+
} catch (error) {
|
|
749
|
+
throw new IntegrationError(error?.message || 'Datasphere Open SQL query failed');
|
|
750
|
+
}
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
const stripComments = statement => {
|
|
754
|
+
let text = String(statement || '').trim();
|
|
755
|
+
for (;;) {
|
|
756
|
+
if (text.startsWith('--')) text = text.slice(text.indexOf('\n') + 1).trim();
|
|
757
|
+
else if (text.startsWith('/*')) {
|
|
758
|
+
const end = text.indexOf('*/');
|
|
759
|
+
if (end < 0) break;
|
|
760
|
+
text = text.slice(end + 2).trim();
|
|
761
|
+
} else break;
|
|
762
|
+
}
|
|
763
|
+
return text;
|
|
764
|
+
};
|
|
765
|
+
|
|
766
|
+
const singleStatement = statement => {
|
|
767
|
+
const text = stripComments(statement);
|
|
768
|
+
const withoutTrailing = text.replace(/;+\s*$/, '').trim();
|
|
769
|
+
if (withoutTrailing.includes(';')) throw new IntegrationError('Only a single SQL statement is allowed');
|
|
770
|
+
return withoutTrailing;
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
register('datasphere_sql_query', 'Query the Datasphere Open SQL schema',
|
|
774
|
+
'Run one read-only SELECT statement against the space Open SQL schema of the configured database user; rows are capped by max_rows.',
|
|
775
|
+
{
|
|
776
|
+
sql: z.string().min(1).max(SQL_QUERY_MAX),
|
|
777
|
+
max_rows: z.number().int().min(1).max(200).default(100)
|
|
778
|
+
},
|
|
779
|
+
READ_ONLY_ANNOTATIONS,
|
|
780
|
+
async ({ sql: statement, max_rows: maxRows }) => {
|
|
781
|
+
const text = stripComments(statement);
|
|
782
|
+
if (!/^select\b/i.test(text)) throw new IntegrationError('Only SELECT statements are allowed here; use datasphere_sql_execute');
|
|
783
|
+
const single = singleStatement(statement);
|
|
784
|
+
const rows = await query(`SELECT * FROM (${single}) AS Q LIMIT ${maxRows + 1}`);
|
|
785
|
+
const list = Array.isArray(rows) ? rows : rows?.rows ?? [];
|
|
786
|
+
return { rows: list.slice(0, maxRows), truncated: list.length > maxRows };
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
register('datasphere_sql_execute', 'Execute a statement on the Open SQL schema',
|
|
790
|
+
`Execute one SQL statement (DDL, DML, or a documented "DWC_GLOBAL" data-lake procedure call) as the configured database user. Requires confirmation "EXECUTE DATASPHERE SQL AS <USER>".`,
|
|
791
|
+
{
|
|
792
|
+
sql: z.string().min(1).max(SQL_EXECUTE_MAX),
|
|
793
|
+
confirmation
|
|
794
|
+
},
|
|
795
|
+
OVERWRITE_ANNOTATIONS,
|
|
796
|
+
async ({ sql: statement, confirmation: given }) => {
|
|
797
|
+
requireConfirmation(given, `EXECUTE DATASPHERE SQL AS ${sql.config.user}`);
|
|
798
|
+
const single = singleStatement(statement);
|
|
799
|
+
const result = await query(single);
|
|
800
|
+
if (Array.isArray(result)) {
|
|
801
|
+
return { rows: result.slice(0, 200), truncated: result.length > 200 };
|
|
802
|
+
}
|
|
803
|
+
return { affected_rows: result?.affectedRows ?? 0 };
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
return registeredNames;
|
|
808
|
+
}
|
|
809
|
+
|