@knowttl/mg-axi 0.1.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/README.md +1138 -0
- package/dist/api.js +953 -0
- package/dist/app-auth.js +47 -0
- package/dist/auth.js +59 -0
- package/dist/bin/mg-axi.js +7 -0
- package/dist/catalogue.js +2191 -0
- package/dist/cli.js +1314 -0
- package/dist/docs.js +559 -0
- package/dist/doctor.js +75 -0
- package/dist/entra-access-reviews.js +425 -0
- package/dist/entra-apps.js +374 -0
- package/dist/entra-audit-logs.js +381 -0
- package/dist/entra-auth-methods.js +296 -0
- package/dist/entra-ca-policy-update.js +353 -0
- package/dist/entra-certificate-auth.js +384 -0
- package/dist/entra-conditional-access.js +317 -0
- package/dist/entra-contacts.js +827 -0
- package/dist/entra-contracts.js +252 -0
- package/dist/entra-custom-security-attributes.js +464 -0
- package/dist/entra-data-policy-operations.js +274 -0
- package/dist/entra-delegated-admin.js +468 -0
- package/dist/entra-deleted-items.js +461 -0
- package/dist/entra-directory-objects.js +274 -0
- package/dist/entra-directory.js +411 -0
- package/dist/entra-domains.js +399 -0
- package/dist/entra-entitlement-management.js +645 -0
- package/dist/entra-federation-configurations.js +290 -0
- package/dist/entra-grants.js +272 -0
- package/dist/entra-group-lifecycle.js +349 -0
- package/dist/entra-group-member-add.js +278 -0
- package/dist/entra-groups.js +336 -0
- package/dist/entra-identity-providers.js +297 -0
- package/dist/entra-lifecycle-workflows.js +673 -0
- package/dist/entra-multi-tenant-organization.js +309 -0
- package/dist/entra-on-premises-synchronization.js +231 -0
- package/dist/entra-organization.js +398 -0
- package/dist/entra-risk-dismiss.js +191 -0
- package/dist/entra-risk.js +404 -0
- package/dist/entra-roles.js +310 -0
- package/dist/entra-subscriptions.js +289 -0
- package/dist/entra-tenant-information.js +152 -0
- package/dist/entra-terms-of-use.js +380 -0
- package/dist/entra-user-revoke-sessions.js +170 -0
- package/dist/entra-user-update.js +170 -0
- package/dist/entra-users.js +211 -0
- package/dist/graph-session.js +1034 -0
- package/dist/msal-app-provider.js +42 -0
- package/dist/msal-provider.js +82 -0
- package/dist/mutations.js +529 -0
- package/dist/profiles.js +139 -0
- package/dist/setup.js +116 -0
- package/dist/version.js +2 -0
- package/docs/coverage.md +1028 -0
- package/inventory/operations.json +4 -0
- package/inventory/schema.json +71 -0
- package/package.json +32 -0
- package/skills/mg-axi/SKILL.md +293 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { AxiError } from "axi-sdk-js";
|
|
2
|
+
// READ-03: the Conditional Access read mapping behind
|
|
3
|
+
// `mg-axi entra conditional-access policy list/show` and
|
|
4
|
+
// `mg-axi entra conditional-access named-location list/show` as separate
|
|
5
|
+
// az-style grammar. Operation construction stays beside its command; the
|
|
6
|
+
// shared session owns URLs, credentials, paging, retries and error
|
|
7
|
+
// translation, and the SDK owns TOON rendering. This module only maps flags
|
|
8
|
+
// to session calls and projects rows for compact output. No policy mutation
|
|
9
|
+
// lives here; that belongs to WRITE-04.
|
|
10
|
+
//
|
|
11
|
+
// Reviewed against the v1.0 conditionalaccessroot-list-policies,
|
|
12
|
+
// conditionalaccesspolicy-get, conditionalaccessroot-list-namedlocations and
|
|
13
|
+
// countrynamedlocation-get operation documentation on 2026-10-04. All four
|
|
14
|
+
// read D/A Policy.Read.All; delegated callers additionally need a supported
|
|
15
|
+
// directory role (Conditional Access Administrator, Global Reader, Global
|
|
16
|
+
// Secure Access Administrator, Security Administrator or Security Reader).
|
|
17
|
+
// Conditional Access needs P1; risk-based Conditional Access needs P2. The
|
|
18
|
+
// reviewed raw surface in src/api.ts carries exactly these routes, fields
|
|
19
|
+
// and access choices; the named commands below reuse that contract.
|
|
20
|
+
// Every policy property this slice may request or display, matching the
|
|
21
|
+
// reviewed raw surface. Anything else fails before credentials.
|
|
22
|
+
export const KNOWN_POLICY_FIELDS = [
|
|
23
|
+
"id",
|
|
24
|
+
"displayName",
|
|
25
|
+
"state",
|
|
26
|
+
"createdDateTime",
|
|
27
|
+
"modifiedDateTime",
|
|
28
|
+
"conditions",
|
|
29
|
+
"grantControls",
|
|
30
|
+
"sessionControls",
|
|
31
|
+
];
|
|
32
|
+
// Every named-location property this slice may request or display, matching
|
|
33
|
+
// the reviewed raw surface. Anything else fails before credentials.
|
|
34
|
+
export const KNOWN_LOCATION_FIELDS = [
|
|
35
|
+
"id",
|
|
36
|
+
"displayName",
|
|
37
|
+
"createdDateTime",
|
|
38
|
+
"modifiedDateTime",
|
|
39
|
+
"isTrusted",
|
|
40
|
+
"ipRanges",
|
|
41
|
+
"countriesAndRegions",
|
|
42
|
+
"includeUnknownCountriesAndRegions",
|
|
43
|
+
];
|
|
44
|
+
const POLICY_KNOWN = new Set(KNOWN_POLICY_FIELDS);
|
|
45
|
+
const LOCATION_KNOWN = new Set(KNOWN_LOCATION_FIELDS);
|
|
46
|
+
// @odata.type is preserved on named-location rows without being selectable:
|
|
47
|
+
// it names the location kind (ipNamedLocation versus countryNamedLocation).
|
|
48
|
+
const LOCATION_TYPE_PROPERTY = "@odata.type";
|
|
49
|
+
// Compact policy rows: identifier, name and enforcement state. The state
|
|
50
|
+
// rides in the list because an enabled policy and a disabled one read the
|
|
51
|
+
// same but act differently.
|
|
52
|
+
const DEFAULT_POLICY_LIST_SELECT = ["id", "displayName", "state"];
|
|
53
|
+
// Fetch the reviewed condition and control blocks for policy inspection;
|
|
54
|
+
// local projection and text truncation still apply unless overridden.
|
|
55
|
+
const DEFAULT_POLICY_SHOW_SELECT = [...KNOWN_POLICY_FIELDS];
|
|
56
|
+
// Compact location rows: identifier and name; the kind rides as @odata.type.
|
|
57
|
+
const DEFAULT_LOCATION_LIST_SELECT = ["id", "displayName"];
|
|
58
|
+
// Show rows: the full reviewed location set.
|
|
59
|
+
const DEFAULT_LOCATION_SHOW_SELECT = [...KNOWN_LOCATION_FIELDS];
|
|
60
|
+
// Policy.Read.All covers both families in both modes.
|
|
61
|
+
export const DEFAULT_DELEGATED_SCOPES = ["https://graph.microsoft.com/Policy.Read.All"];
|
|
62
|
+
const TRUNCATE_AT = 500;
|
|
63
|
+
function fieldList(raw, flag, known, knownList, noun, help) {
|
|
64
|
+
const parts = String(raw)
|
|
65
|
+
.split(",")
|
|
66
|
+
.map(part => part.trim())
|
|
67
|
+
.filter(part => part.length > 0);
|
|
68
|
+
if (!parts.length)
|
|
69
|
+
throw new AxiError(`--${flag} needs at least one property`, "VALIDATION_ERROR", [help]);
|
|
70
|
+
const fields = [];
|
|
71
|
+
for (const part of parts) {
|
|
72
|
+
if (!known.has(part)) {
|
|
73
|
+
throw new AxiError(`Unknown ${noun} property ${part} in --${flag}`, "VALIDATION_ERROR", [
|
|
74
|
+
help,
|
|
75
|
+
`Known ${noun} properties: ${knownList.join(", ")}`,
|
|
76
|
+
]);
|
|
77
|
+
}
|
|
78
|
+
if (!fields.includes(part))
|
|
79
|
+
fields.push(part);
|
|
80
|
+
}
|
|
81
|
+
return fields;
|
|
82
|
+
}
|
|
83
|
+
function scopesFor(flags, profile, help) {
|
|
84
|
+
if (profile.mode === "application") {
|
|
85
|
+
if (flags.scopes !== undefined) {
|
|
86
|
+
throw new AxiError("Application profiles use the configured Graph .default audience; delegated scopes are unavailable", "VALIDATION_ERROR", [help, "mg-axi profile show --profile <name>"]);
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
if (flags.scopes === undefined)
|
|
91
|
+
return [...DEFAULT_DELEGATED_SCOPES];
|
|
92
|
+
const scopes = String(flags.scopes)
|
|
93
|
+
.split(",")
|
|
94
|
+
.map(scope => scope.trim())
|
|
95
|
+
.filter(scope => scope.length > 0);
|
|
96
|
+
return scopes;
|
|
97
|
+
}
|
|
98
|
+
// Policy conditions and location ranges nest, so truncation recurses through
|
|
99
|
+
// objects and arrays. Every truncated value carries a --full hint in help.
|
|
100
|
+
function truncateValue(value, full) {
|
|
101
|
+
const truncate = (text) => full || text.length <= TRUNCATE_AT
|
|
102
|
+
? { value: text, truncated: false }
|
|
103
|
+
: { value: `${text.slice(0, TRUNCATE_AT)}... (truncated, ${text.length} chars total)`, truncated: true };
|
|
104
|
+
if (typeof value === "string")
|
|
105
|
+
return truncate(value);
|
|
106
|
+
if (Array.isArray(value)) {
|
|
107
|
+
const projected = value.map(entry => truncateValue(entry, full));
|
|
108
|
+
return { value: projected.map(entry => entry.value), truncated: projected.some(entry => entry.truncated) };
|
|
109
|
+
}
|
|
110
|
+
if (value !== null && typeof value === "object") {
|
|
111
|
+
const projected = Object.entries(value).map(([key, entry]) => [key, truncateValue(entry, full)]);
|
|
112
|
+
return {
|
|
113
|
+
value: Object.fromEntries(projected.map(([key, entry]) => [key, entry.value])),
|
|
114
|
+
truncated: projected.some(([, entry]) => entry.truncated),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return { value, truncated: false };
|
|
118
|
+
}
|
|
119
|
+
// Local projection preserves Graph's null/missing distinction: an explicit
|
|
120
|
+
// null stays null, an absent property stays absent and is never synthesized.
|
|
121
|
+
function project(row, fields, full) {
|
|
122
|
+
const source = row !== null && typeof row === "object" && !Array.isArray(row) ? row : {};
|
|
123
|
+
const projected = {};
|
|
124
|
+
let truncated = false;
|
|
125
|
+
for (const field of fields) {
|
|
126
|
+
if (!Object.hasOwn(source, field))
|
|
127
|
+
continue;
|
|
128
|
+
const result = truncateValue(source[field], full);
|
|
129
|
+
projected[field] = result.value;
|
|
130
|
+
truncated = truncated || result.truncated;
|
|
131
|
+
}
|
|
132
|
+
return { row: projected, truncated };
|
|
133
|
+
}
|
|
134
|
+
// Named-location projection additionally preserves @odata.type so the
|
|
135
|
+
// location kind survives local projection.
|
|
136
|
+
function projectLocation(row, fields, full) {
|
|
137
|
+
const { row: projected, truncated } = project(row, fields, full);
|
|
138
|
+
const source = row !== null && typeof row === "object" && !Array.isArray(row) ? row : {};
|
|
139
|
+
if (typeof source[LOCATION_TYPE_PROPERTY] === "string")
|
|
140
|
+
projected[LOCATION_TYPE_PROPERTY] = source[LOCATION_TYPE_PROPERTY];
|
|
141
|
+
return { row: projected, truncated };
|
|
142
|
+
}
|
|
143
|
+
function selectedFields(flags, defaults, known, knownList, noun, help) {
|
|
144
|
+
const select = flags.select === undefined ? [...defaults] : fieldList(flags.select, "select", known, knownList, noun, help);
|
|
145
|
+
const fields = flags.fields === undefined ? [...select] : fieldList(flags.fields, "fields", known, knownList, noun, help);
|
|
146
|
+
const missing = fields.find(field => !select.includes(field));
|
|
147
|
+
if (missing) {
|
|
148
|
+
throw new AxiError(`--fields ${missing} was not fetched; request it with --select`, "VALIDATION_ERROR", [help]);
|
|
149
|
+
}
|
|
150
|
+
return { select, fields };
|
|
151
|
+
}
|
|
152
|
+
function profileHint(profileName) {
|
|
153
|
+
return `--profile ${shellValue(profileName)}`;
|
|
154
|
+
}
|
|
155
|
+
function shellValue(value) {
|
|
156
|
+
return /^[A-Za-z0-9_.,:/@=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
157
|
+
}
|
|
158
|
+
function fullHint(command, flags, profileName) {
|
|
159
|
+
const args = Object.entries({ ...flags, ...(flags.cursor === undefined ? {} : { cursor: "-" }), profile: profileName, full: true })
|
|
160
|
+
.map(([name, value]) => value === true ? `--${name}` : `--${name} ${shellValue(String(value))}`);
|
|
161
|
+
return `mg-axi ${command} ${args.join(" ")}`;
|
|
162
|
+
}
|
|
163
|
+
// Denials carry the session's generic grant/role/licence cause; each family
|
|
164
|
+
// adds the roles and licensing that actually unlock it, because a 403 alone
|
|
165
|
+
// never says which prerequisite is missing.
|
|
166
|
+
function withGuidance(hints, run) {
|
|
167
|
+
return run().catch(error => {
|
|
168
|
+
if (error instanceof AxiError && error.code === "GRAPH_ERROR" && /\(403\)/.test(error.message)) {
|
|
169
|
+
throw new AxiError(error.message, "GRAPH_ERROR", [...hints, ...error.suggestions]);
|
|
170
|
+
}
|
|
171
|
+
throw error;
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
const POLICY_DENIAL_HINTS = [
|
|
175
|
+
"Conditional-access policy reads need Policy.Read.All plus a supported directory role: Conditional Access Administrator, Global Reader, Global Secure Access Administrator, Security Administrator or Security Reader for delegated access, or admin-consented Policy.Read.All for application access",
|
|
176
|
+
"Conditional Access needs P1; risk-based Conditional Access needs P2",
|
|
177
|
+
];
|
|
178
|
+
const LOCATION_DENIAL_HINTS = [
|
|
179
|
+
"Named-location reads need Policy.Read.All plus a supported directory role: Conditional Access Administrator, Global Reader, Global Secure Access Administrator, Security Administrator or Security Reader for delegated access, or admin-consented Policy.Read.All for application access",
|
|
180
|
+
"Conditional Access and named locations need P1; risk-based Conditional Access needs P2",
|
|
181
|
+
];
|
|
182
|
+
const POLICY_LIST = {
|
|
183
|
+
command: "entra conditional-access policy list",
|
|
184
|
+
key: "policies",
|
|
185
|
+
noun: "policy",
|
|
186
|
+
known: POLICY_KNOWN,
|
|
187
|
+
knownList: KNOWN_POLICY_FIELDS,
|
|
188
|
+
defaultSelect: DEFAULT_POLICY_LIST_SELECT,
|
|
189
|
+
denialHints: POLICY_DENIAL_HINTS,
|
|
190
|
+
showHint: "mg-axi entra conditional-access policy show --id <policy-id>",
|
|
191
|
+
emptyHint: "0 conditional-access policies matched; the absence of results is the answer, not an error",
|
|
192
|
+
preserveType: false,
|
|
193
|
+
};
|
|
194
|
+
const LOCATION_LIST = {
|
|
195
|
+
command: "entra conditional-access named-location list",
|
|
196
|
+
key: "namedLocations",
|
|
197
|
+
noun: "named-location",
|
|
198
|
+
known: LOCATION_KNOWN,
|
|
199
|
+
knownList: KNOWN_LOCATION_FIELDS,
|
|
200
|
+
defaultSelect: DEFAULT_LOCATION_LIST_SELECT,
|
|
201
|
+
denialHints: LOCATION_DENIAL_HINTS,
|
|
202
|
+
showHint: "mg-axi entra conditional-access named-location show --id <named-location-id>",
|
|
203
|
+
emptyHint: "0 named locations matched; the absence of results is the answer, not an error",
|
|
204
|
+
preserveType: true,
|
|
205
|
+
};
|
|
206
|
+
async function listCollection(shape, session, flags, profile, operation, help, profileName) {
|
|
207
|
+
const cursor = flags.cursor === undefined ? undefined : String(flags.cursor);
|
|
208
|
+
if (cursor !== undefined && !cursor.trim())
|
|
209
|
+
throw new AxiError("--cursor needs the opaque cursor from a partial result", "VALIDATION_ERROR", [help]);
|
|
210
|
+
const savedSelect = cursor === undefined ? undefined : session.cursorQuery(operation, cursor).$select;
|
|
211
|
+
const { select, fields } = selectedFields(flags, savedSelect === undefined ? shape.defaultSelect : fieldList(savedSelect, "select", shape.known, shape.knownList, shape.noun, help), shape.known, shape.knownList, shape.noun, help);
|
|
212
|
+
const scopes = scopesFor(flags, profile, help);
|
|
213
|
+
const full = flags.full === true;
|
|
214
|
+
const query = { $select: select.join(",") };
|
|
215
|
+
if (flags.filter !== undefined)
|
|
216
|
+
query.$filter = String(flags.filter);
|
|
217
|
+
const args = { profile, operation, query, scopes };
|
|
218
|
+
if (cursor !== undefined)
|
|
219
|
+
args.cursor = cursor;
|
|
220
|
+
if (flags.all === true) {
|
|
221
|
+
if (flags.limit !== undefined)
|
|
222
|
+
throw new AxiError("--limit and --all cannot be combined", "VALIDATION_ERROR", [help]);
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
args.limit = flags.limit === undefined ? 100 : Number(flags.limit);
|
|
226
|
+
}
|
|
227
|
+
const result = await withGuidance(shape.denialHints, () => session.collect(args));
|
|
228
|
+
const effectiveFlags = { ...flags, select: result.query.$select ?? shape.defaultSelect.join(",") };
|
|
229
|
+
if (result.query.$filter !== undefined)
|
|
230
|
+
effectiveFlags.filter = result.query.$filter;
|
|
231
|
+
const rows = [];
|
|
232
|
+
let truncated = false;
|
|
233
|
+
for (const row of result.value) {
|
|
234
|
+
const projected = shape.preserveType ? projectLocation(row, fields, full) : project(row, fields, full);
|
|
235
|
+
rows.push(projected.row);
|
|
236
|
+
truncated = truncated || projected.truncated;
|
|
237
|
+
}
|
|
238
|
+
const showHint = `${shape.showHint} ${profileHint(profileName)}`;
|
|
239
|
+
const truncationHints = truncated ? [fullHint(shape.command, effectiveFlags, profileName)] : [];
|
|
240
|
+
if (truncated && cursor !== undefined)
|
|
241
|
+
truncationHints.push("Supply the original input cursor on stdin to replay this result with --full");
|
|
242
|
+
if (!result.complete) {
|
|
243
|
+
return {
|
|
244
|
+
[shape.key]: rows,
|
|
245
|
+
count: { returned: rows.length, complete: false, reason: result.reason },
|
|
246
|
+
cursor: result.cursor,
|
|
247
|
+
help: [...truncationHints, `Resume losslessly with the same flags plus --cursor - ${profileHint(profileName)} and supply the returned cursor on stdin`, showHint],
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
const count = { returned: rows.length, complete: true };
|
|
251
|
+
if (!rows.length) {
|
|
252
|
+
return {
|
|
253
|
+
[shape.key]: rows,
|
|
254
|
+
count,
|
|
255
|
+
help: [
|
|
256
|
+
`mg-axi ${shape.command} --filter <odata-filter> ${profileHint(profileName)}`,
|
|
257
|
+
shape.emptyHint,
|
|
258
|
+
],
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
return { [shape.key]: rows, count, help: [...truncationHints, showHint] };
|
|
262
|
+
}
|
|
263
|
+
const POLICY_SHOW = {
|
|
264
|
+
command: "entra conditional-access policy show",
|
|
265
|
+
key: "policy",
|
|
266
|
+
param: "conditionalAccessPolicy-id",
|
|
267
|
+
noun: "policy",
|
|
268
|
+
known: POLICY_KNOWN,
|
|
269
|
+
knownList: KNOWN_POLICY_FIELDS,
|
|
270
|
+
defaultSelect: DEFAULT_POLICY_SHOW_SELECT,
|
|
271
|
+
denialHints: POLICY_DENIAL_HINTS,
|
|
272
|
+
preserveType: false,
|
|
273
|
+
};
|
|
274
|
+
const LOCATION_SHOW = {
|
|
275
|
+
command: "entra conditional-access named-location show",
|
|
276
|
+
key: "namedLocation",
|
|
277
|
+
param: "namedLocation-id",
|
|
278
|
+
noun: "named-location",
|
|
279
|
+
known: LOCATION_KNOWN,
|
|
280
|
+
knownList: KNOWN_LOCATION_FIELDS,
|
|
281
|
+
defaultSelect: DEFAULT_LOCATION_SHOW_SELECT,
|
|
282
|
+
denialHints: LOCATION_DENIAL_HINTS,
|
|
283
|
+
preserveType: true,
|
|
284
|
+
};
|
|
285
|
+
async function showSingle(shape, session, flags, profile, operation, help, profileName) {
|
|
286
|
+
const { select, fields } = selectedFields(flags, shape.defaultSelect, shape.known, shape.knownList, shape.noun, help);
|
|
287
|
+
const scopes = scopesFor(flags, profile, help);
|
|
288
|
+
const full = flags.full === true;
|
|
289
|
+
const raw = await withGuidance(shape.denialHints, () => session.execute({
|
|
290
|
+
profile,
|
|
291
|
+
operation,
|
|
292
|
+
params: { [shape.param]: String(flags.id) },
|
|
293
|
+
query: { $select: select.join(",") },
|
|
294
|
+
scopes,
|
|
295
|
+
}));
|
|
296
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
297
|
+
throw new AxiError("Graph returned a malformed Conditional Access body", "GRAPH_ERROR", [
|
|
298
|
+
"Single-object reads carry one object; treat anything else as unknown, not empty",
|
|
299
|
+
]);
|
|
300
|
+
}
|
|
301
|
+
const { row, truncated } = shape.preserveType ? projectLocation(raw, fields, full) : project(raw, fields, full);
|
|
302
|
+
if (truncated)
|
|
303
|
+
return { [shape.key]: row, help: [fullHint(shape.command, flags, profileName)] };
|
|
304
|
+
return { [shape.key]: row };
|
|
305
|
+
}
|
|
306
|
+
export async function listPolicies(session, flags, profile, operation, help, profileName) {
|
|
307
|
+
return listCollection(POLICY_LIST, session, flags, profile, operation, help, profileName);
|
|
308
|
+
}
|
|
309
|
+
export async function showPolicy(session, flags, profile, operation, help, profileName) {
|
|
310
|
+
return showSingle(POLICY_SHOW, session, flags, profile, operation, help, profileName);
|
|
311
|
+
}
|
|
312
|
+
export async function listNamedLocations(session, flags, profile, operation, help, profileName) {
|
|
313
|
+
return listCollection(LOCATION_LIST, session, flags, profile, operation, help, profileName);
|
|
314
|
+
}
|
|
315
|
+
export async function showNamedLocation(session, flags, profile, operation, help, profileName) {
|
|
316
|
+
return showSingle(LOCATION_SHOW, session, flags, profile, operation, help, profileName);
|
|
317
|
+
}
|