@pacaf/wizard-ux 2.0.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.
@@ -0,0 +1,581 @@
1
+ // Step 5 — Solution & Publisher (solution-first, publisher auto-resolved).
2
+ import { dirname, resolve } from 'node:path';
3
+ import { fileURLToPath, pathToFileURL } from 'node:url';
4
+ import { dvGet, dvPost, hasUsableSecret, setSecret, clearSecret } from '../lib/dataverse-bridge.mjs';
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ const ROOT_DIR = resolve(__dirname, '..', '..', '..');
8
+ const VALIDATE = await import(pathToFileURL(resolve(ROOT_DIR, 'wizard', 'lib', 'validate.mjs')).href);
9
+ const SHELL = await import(pathToFileURL(resolve(ROOT_DIR, 'wizard', 'lib', 'shell.mjs')).href);
10
+ const PAC_TARGET = await import(pathToFileURL(resolve(ROOT_DIR, 'wizard', 'lib', 'pac-target.mjs')).href);
11
+
12
+ const CREATE_NEW = '__create_new__';
13
+ const PASTE_URL = '__paste_url__';
14
+
15
+ // ─── Helpers ─────────────────────────────────────────────────────────
16
+
17
+ /** Get the Maker Portal solutions deep-link via `pac org who`. */
18
+ function getMakerPortalLink() {
19
+ try {
20
+ const pac = SHELL.pacPath();
21
+ const whoOut = SHELL.runSafe(pac, ['org', 'who']);
22
+ if (whoOut) {
23
+ const whoInfo = PAC_TARGET.parsePacOrgWho(whoOut);
24
+ if (whoInfo.environmentId) {
25
+ return `https://make.powerapps.com/e/${whoInfo.environmentId}/solutions`;
26
+ }
27
+ }
28
+ } catch { /* fall through */ }
29
+ return 'https://make.powerapps.com';
30
+ }
31
+
32
+ /** Extract solution GUID from a Maker Portal URL. */
33
+ function extractSolutionIdFromUrl(url) {
34
+ const m = String(url).match(/\/solutions\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
35
+ return m ? m[1].toLowerCase() : null;
36
+ }
37
+
38
+ /**
39
+ * Use `pac env fetch` to get solution + publisher by solution ID.
40
+ * Works with both user auth and SPN — uses whatever PAC profile is active.
41
+ * Returns { solutionid, uniquename, friendlyname, version, publisherid, prefix, publisherFriendlyName, publisherUniqueName }
42
+ */
43
+ function fetchSolutionViaPac(solutionId) {
44
+ const pac = SHELL.pacPath();
45
+ if (!pac) return null;
46
+ // Use separate queries: one for solution, one for publisher (avoids tabular parse issues)
47
+ const solXml = `<fetch><entity name="solution"><attribute name="uniquename"/><attribute name="friendlyname"/><attribute name="version"/><attribute name="solutionid"/><attribute name="publisherid"/><filter><condition attribute="solutionid" operator="eq" value="${solutionId}"/></filter></entity></fetch>`;
48
+ const solOutput = SHELL.runSafe(pac, ['env', 'fetch', '--xml', solXml]);
49
+ if (!solOutput) return null;
50
+
51
+ // Parse the tabular output for the solution
52
+ const solId = extractGuid(solOutput, solutionId) || solutionId;
53
+ const version = extractPattern(solOutput, /(\d+\.\d+\.\d+\.\d+)/);
54
+
55
+ // The publisherid in the solution output is a GUID (the lookup value)
56
+ const guids = [...solOutput.matchAll(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi)]
57
+ .map((m) => m[0].toLowerCase());
58
+ // publisherid is the GUID that is NOT the solutionid
59
+ const pubGuid = guids.find((g) => g !== solId) || '';
60
+
61
+ // Extract unique name: it's a single PascalCase/camelCase token (no spaces, not a GUID, not a version)
62
+ const tokens = extractDataTokens(solOutput);
63
+ const uniquename = tokens.find((t) => !isGuid(t) && !/^\d+\.\d+/.test(t) && t.length > 1) || '';
64
+
65
+ // For friendlyname, we need to be smarter. The text between known tokens.
66
+ // Approach: remove GUIDs, version, uniquename, and "Connected..." lines → what's left is the friendly name
67
+ const friendlyname = extractFriendlyName(solOutput, [solId, pubGuid, version, uniquename]);
68
+
69
+ // Now fetch the publisher details using the pubGuid
70
+ let prefix = '';
71
+ let publisherFriendlyName = '';
72
+ let publisherUniqueName = '';
73
+ let choiceValuePrefix = '';
74
+
75
+ if (pubGuid) {
76
+ const pubXml = `<fetch><entity name="publisher"><attribute name="customizationprefix"/><attribute name="friendlyname"/><attribute name="uniquename"/><attribute name="publisherid"/><attribute name="customizationoptionvalueprefix"/><filter><condition attribute="publisherid" operator="eq" value="${pubGuid}"/></filter></entity></fetch>`;
77
+ const pubOutput = SHELL.runSafe(pac, ['env', 'fetch', '--xml', pubXml]);
78
+ if (pubOutput) {
79
+ const pubTokens = extractDataTokens(pubOutput);
80
+ // customizationprefix is a short lowercase string
81
+ prefix = pubTokens.find((t) => /^[a-z]{2,8}$/.test(t) && !['and', 'for', 'the'].includes(t)) || '';
82
+ // customizationoptionvalueprefix is a numeric string
83
+ choiceValuePrefix = pubTokens.find((t) => /^\d{3,6}$/.test(t)) || '';
84
+ // publisher uniquename is a single token (not GUID, not prefix, not choiceValuePrefix)
85
+ publisherUniqueName = pubTokens.find((t) =>
86
+ !isGuid(t) && t !== prefix && t !== choiceValuePrefix && t.length > 1 && !/^\d+$/.test(t),
87
+ ) || '';
88
+ publisherFriendlyName = extractFriendlyName(pubOutput, [pubGuid, prefix, publisherUniqueName, choiceValuePrefix]);
89
+ }
90
+ }
91
+
92
+ return {
93
+ solutionid: solId,
94
+ uniquename,
95
+ friendlyname,
96
+ version,
97
+ publisherid: pubGuid,
98
+ prefix,
99
+ publisherFriendlyName,
100
+ publisherUniqueName,
101
+ choiceValuePrefix,
102
+ };
103
+ }
104
+
105
+ /**
106
+ * List unmanaged solutions via `pac env fetch`. Returns basic solution list.
107
+ * Each entry has { solutionid, label }.
108
+ */
109
+ function listSolutionSummariesViaPac() {
110
+ const pac = SHELL.pacPath();
111
+ if (!pac) return [];
112
+ const xml = `<fetch><entity name="solution"><attribute name="uniquename"/><attribute name="friendlyname"/><attribute name="solutionid"/><link-entity name="publisher" from="publisherid" to="publisherid" link-type="inner" alias="pub"><attribute name="customizationprefix"/></link-entity><filter><condition attribute="ismanaged" operator="eq" value="0"/><condition attribute="isvisible" operator="eq" value="1"/></filter><order attribute="friendlyname"/></entity></fetch>`;
113
+ const output = SHELL.runSafe(pac, ['env', 'fetch', '--xml', xml], { timeout: 15000 });
114
+ if (!output) return [];
115
+
116
+ // Parse each data row. The output has: uniquename, friendlyname, solutionid, pub.customizationprefix
117
+ // Each row has a GUID. Group by GUID.
118
+ const guids = [...output.matchAll(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/gi)]
119
+ .map((m) => m[1].toLowerCase());
120
+ const uniqueGuids = [...new Set(guids)];
121
+
122
+ // Filter out well-known system solution GUIDs
123
+ const systemGuids = new Set([
124
+ 'fd140aaf-4df4-11dd-bd17-0019b9312238', // Default Solution
125
+ '00000001-0000-0000-0001-00000000009b', // Common Data Services
126
+ ]);
127
+
128
+ // For each solution GUID, we need the display label.
129
+ // Since parsing multi-row tabular output is fragile, use a simple approach:
130
+ // split output into segments by GUID, extract the text around each.
131
+ const lines = output.split(/\r?\n/).filter((l) =>
132
+ l.trim() &&
133
+ !l.startsWith('Connected') &&
134
+ !l.startsWith('Microsoft') &&
135
+ !l.startsWith('Version:') &&
136
+ !l.startsWith('Online') &&
137
+ !l.startsWith('Feedback'),
138
+ );
139
+
140
+ // The header line contains column names. Skip it.
141
+ const headerKeywords = ['uniquename', 'friendlyname', 'solutionid'];
142
+ const dataLines = lines.filter((l) => !headerKeywords.some((kw) => l.toLowerCase().includes(kw)));
143
+ const fullData = dataLines.join(' ');
144
+
145
+ const results = [];
146
+ for (const guid of uniqueGuids) {
147
+ if (systemGuids.has(guid)) continue;
148
+
149
+ // Find the text around this GUID to extract a label
150
+ const idx = fullData.indexOf(guid);
151
+ if (idx < 0) continue;
152
+
153
+ // Look for a short lowercase prefix near the GUID (the pub.customizationprefix)
154
+ const nearby = fullData.slice(Math.max(0, idx - 200), idx + 200);
155
+ const prefixMatch = nearby.match(/\b([a-z]{2,8})\b/);
156
+ const prefix = prefixMatch ? prefixMatch[1] : '?';
157
+
158
+ // The friendlyname is harder to extract from the concatenated data.
159
+ // We'll use the GUID as the value and fetch full details on selection.
160
+ results.push({ value: guid, label: `Solution ${guid.slice(0, 8)}… (${prefix}_)` });
161
+ }
162
+
163
+ // If we got results but labels are poor, try fetching details for each
164
+ // (only if there are few solutions)
165
+ if (results.length > 0 && results.length <= 20) {
166
+ for (const r of results) {
167
+ const details = fetchSolutionViaPac(r.value);
168
+ if (details) {
169
+ const name = details.friendlyname || details.uniquename || r.value;
170
+ r.label = `${name} (${details.prefix || '?'}_)`;
171
+ }
172
+ }
173
+ }
174
+
175
+ return results.filter((r) => r.label && !r.label.includes('Default Solution') && !r.label.includes('Common Data'));
176
+ }
177
+
178
+ // ─── Parse utilities ─────────────────────────────────────────────────
179
+
180
+ function isGuid(s) {
181
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
182
+ }
183
+
184
+ function extractGuid(text, hint) {
185
+ const guids = [...text.matchAll(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi)]
186
+ .map((m) => m[0].toLowerCase());
187
+ if (hint) return guids.find((g) => g === hint.toLowerCase()) || guids[0] || '';
188
+ return guids[0] || '';
189
+ }
190
+
191
+ function extractPattern(text, re) {
192
+ const m = text.match(re);
193
+ return m ? m[1] : '';
194
+ }
195
+
196
+ /** Extract data tokens from PAC CLI output (skip header/banner lines). */
197
+ function extractDataTokens(output) {
198
+ const lines = output.split(/\r?\n/).filter((l) =>
199
+ l.trim() &&
200
+ !l.startsWith('Connected') &&
201
+ !l.startsWith('Microsoft') &&
202
+ !l.startsWith('Version:') &&
203
+ !l.startsWith('Online') &&
204
+ !l.startsWith('Feedback'),
205
+ );
206
+ const headerKeywords = ['uniquename', 'friendlyname', 'solutionid', 'publisherid', 'customizationprefix', 'version'];
207
+ const dataLines = lines.filter((l) => !headerKeywords.some((kw) => l.toLowerCase().includes(kw)));
208
+ return dataLines.join(' ').split(/\s+/).filter(Boolean);
209
+ }
210
+
211
+ /** Extract the friendly name from PAC output by removing known tokens. */
212
+ function extractFriendlyName(output, knownTokens) {
213
+ const lines = output.split(/\r?\n/).filter((l) =>
214
+ l.trim() &&
215
+ !l.startsWith('Connected') &&
216
+ !l.startsWith('Microsoft') &&
217
+ !l.startsWith('Version:') &&
218
+ !l.startsWith('Online') &&
219
+ !l.startsWith('Feedback'),
220
+ );
221
+ const headerKeywords = ['uniquename', 'friendlyname', 'solutionid', 'publisherid', 'customizationprefix', 'version', 'customizationoptionvalueprefix'];
222
+ const dataLines = lines.filter((l) => !headerKeywords.some((kw) => l.toLowerCase().includes(kw)));
223
+ let text = dataLines.join(' ');
224
+ // Remove known tokens (GUIDs, version, uniquename, prefix)
225
+ for (const token of knownTokens) {
226
+ if (token) text = text.replace(new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'), '');
227
+ }
228
+ // Clean up multiple spaces and trim
229
+ return text.replace(/\s+/g, ' ').trim();
230
+ }
231
+
232
+ // ─── Dataverse API helpers (SPN) ─────────────────────────────────────
233
+
234
+ async function listSolutionsViaApi() {
235
+ const data = await dvGet(
236
+ 'solutions?$filter=' + encodeURIComponent('ismanaged eq false and isvisible eq true') +
237
+ '&$select=solutionid,uniquename,friendlyname,version,_publisherid_value' +
238
+ '&$expand=publisherid($select=publisherid,uniquename,friendlyname,customizationprefix,customizationoptionvalueprefix)' +
239
+ '&$orderby=friendlyname',
240
+ );
241
+ return (data.value || []).filter((s) =>
242
+ s.uniquename !== 'Default' &&
243
+ !s.uniquename.startsWith('msdyn') &&
244
+ !s.uniquename.startsWith('Mscrm'),
245
+ );
246
+ }
247
+
248
+ async function fetchSolutionViaApi(solutionId) {
249
+ return dvGet(
250
+ `solutions(${solutionId})?$select=solutionid,uniquename,friendlyname,version` +
251
+ '&$expand=publisherid($select=publisherid,uniquename,friendlyname,customizationprefix,customizationoptionvalueprefix)',
252
+ );
253
+ }
254
+
255
+ // ─── Step definition ─────────────────────────────────────────────────
256
+
257
+ export default {
258
+ meta: {
259
+ number: 5,
260
+ title: 'Solution & Publisher',
261
+ description: 'Select or create the Power Platform solution for your Code App. The publisher (prefix) is resolved automatically from the solution.',
262
+ canRunInBrowser: true,
263
+ needsSecret: true,
264
+ },
265
+
266
+ async questions(state) {
267
+ const questions = [];
268
+ const isUserAuth = (state.AUTH_PROFILE_TYPE || 'user') === 'user';
269
+ const hasSecret = !isUserAuth && hasUsableSecret();
270
+ const hasSaved = state.SOLUTION_UNIQUE_NAME && state.PUBLISHER_PREFIX;
271
+
272
+ // ── Resume ──
273
+ if (hasSaved) {
274
+ questions.push({
275
+ id: '__resume',
276
+ type: 'confirm',
277
+ label: `Keep "${state.SOLUTION_DISPLAY_NAME}" (publisher prefix: ${state.PUBLISHER_PREFIX})?`,
278
+ help: 'Selecting Yes skips solution selection and reuses the saved values.',
279
+ defaultValue: true,
280
+ });
281
+ }
282
+
283
+ // ── Secret for SPN ──
284
+ if (!hasSecret && !isUserAuth) {
285
+ questions.push({
286
+ id: 'PP_CLIENT_SECRET',
287
+ type: 'secret',
288
+ label: 'Client secret',
289
+ help: 'Needed once to load solutions from Dataverse. Held in memory only.',
290
+ required: true,
291
+ defaultValue: '',
292
+ hideIf: { id: '__resume', equals: true },
293
+ });
294
+ }
295
+
296
+ // ── Build solution options ──
297
+ let solutions = [];
298
+ let loadHelp = '';
299
+
300
+ if (hasSecret) {
301
+ try {
302
+ const apiSolutions = await listSolutionsViaApi();
303
+ solutions = apiSolutions.map((s) => ({
304
+ value: s.solutionid,
305
+ label: `${s.friendlyname} (${s.publisherid?.customizationprefix || '?'}_)`,
306
+ }));
307
+ } catch (err) {
308
+ loadHelp = `Could not load solutions: ${err.message}`;
309
+ }
310
+ } else if (isUserAuth) {
311
+ try {
312
+ solutions = listSolutionSummariesViaPac();
313
+ } catch {
314
+ loadHelp = 'Could not auto-discover solutions.';
315
+ }
316
+ }
317
+
318
+ const makerLink = getMakerPortalLink();
319
+
320
+ const defaultSelection = state.SOLUTION_ID && solutions.some((s) => s.value === state.SOLUTION_ID)
321
+ ? state.SOLUTION_ID
322
+ : solutions.length > 0 ? solutions[0].value : (isUserAuth ? PASTE_URL : CREATE_NEW);
323
+
324
+ questions.push({
325
+ id: 'SOLUTION_SELECTION',
326
+ type: 'select',
327
+ label: 'Solution',
328
+ help: loadHelp || 'Select an existing solution or create a new one. The publisher prefix is resolved from the solution automatically.',
329
+ defaultValue: defaultSelection,
330
+ options: [
331
+ ...solutions,
332
+ { value: PASTE_URL, label: 'Paste solution URL from Maker Portal' },
333
+ { value: CREATE_NEW, label: '+ Create new solution' },
334
+ ],
335
+ hideIf: { id: '__resume', equals: true },
336
+ });
337
+
338
+ // ── Paste URL ──
339
+ questions.push({
340
+ id: 'SOLUTION_URL',
341
+ type: 'text',
342
+ label: 'Solution URL from Maker Portal',
343
+ help: `Open your solution at ${makerLink}, copy the browser URL from your browser's address bar, and paste it here.`,
344
+ defaultValue: '',
345
+ showIf: { id: 'SOLUTION_SELECTION', equals: PASTE_URL },
346
+ why: [
347
+ 'How to get the solution URL:',
348
+ `1. Open ${makerLink}`,
349
+ '2. If you need to create a new solution, click + New Solution, pick a publisher, and save it',
350
+ '3. Click on the solution to open it',
351
+ '4. Copy the URL from your browser\'s address bar',
352
+ '5. Paste it here — it looks like:',
353
+ ' https://make.powerapps.com/e/.../solutions/{guid}',
354
+ ].join('\n'),
355
+ });
356
+
357
+ // ── Create new: solution name ──
358
+ questions.push({
359
+ id: 'NEW_SOLUTION_NAME',
360
+ type: 'text',
361
+ label: 'New solution display name',
362
+ help: 'Human-readable name for the new solution.',
363
+ defaultValue: state.SOLUTION_DISPLAY_NAME || state.APP_NAME || '',
364
+ showIf: { id: 'SOLUTION_SELECTION', equals: CREATE_NEW },
365
+ });
366
+
367
+ // ── Create new SPN: publisher selection ──
368
+ if (hasSecret) {
369
+ let publishers = [];
370
+ try {
371
+ const pubData = await dvGet(
372
+ 'publishers?$filter=isreadonly eq false' +
373
+ '&$select=publisherid,uniquename,friendlyname,customizationprefix,customizationoptionvalueprefix' +
374
+ '&$orderby=friendlyname',
375
+ );
376
+ publishers = (pubData.value || []).filter((p) => p.customizationprefix && !p.uniquename.startsWith('DefaultPublisherFor'));
377
+ } catch { /* ignore */ }
378
+
379
+ if (publishers.length > 0) {
380
+ questions.push({
381
+ id: 'NEW_SOLUTION_PUBLISHER',
382
+ type: 'select',
383
+ label: 'Publisher for new solution',
384
+ help: 'The publisher whose prefix will namespace your tables and columns.',
385
+ defaultValue: state.PUBLISHER_ID || publishers[0]?.publisherid || '',
386
+ options: publishers.map((p) => ({
387
+ value: p.publisherid,
388
+ label: `${p.friendlyname} (${p.customizationprefix})`,
389
+ })),
390
+ showIf: { id: 'SOLUTION_SELECTION', equals: CREATE_NEW },
391
+ });
392
+ }
393
+ }
394
+
395
+ // ── Create new user auth: link to Maker Portal + URL paste back ──
396
+ if (isUserAuth) {
397
+ questions.push({
398
+ id: 'SOLUTION_CREATED_MANUALLY',
399
+ type: 'confirm',
400
+ label: 'I have created the solution in the Maker Portal',
401
+ help: `Create the solution at ${makerLink}, then come back here. You can either paste the URL above (switch to "Paste solution URL") or toggle this and enter details manually.`,
402
+ defaultValue: false,
403
+ showIf: { id: 'SOLUTION_SELECTION', equals: CREATE_NEW },
404
+ why: [
405
+ 'Create your solution in the Maker Portal:',
406
+ `1. Open ${makerLink}`,
407
+ '2. Click + New Solution',
408
+ '3. Enter the display name',
409
+ '4. Select (or create) a publisher',
410
+ '5. Save the solution',
411
+ '',
412
+ 'Then come back here and either:',
413
+ '• Switch the dropdown to "Paste solution URL" and paste the URL (recommended)',
414
+ '• OR toggle this confirmation and enter the details manually below',
415
+ ].join('\n'),
416
+ });
417
+
418
+ questions.push({
419
+ id: 'MANUAL_SOLUTION_UNIQUE_NAME',
420
+ type: 'text',
421
+ label: 'Solution unique name',
422
+ help: 'The internal name (no spaces). Find this in the solution details in the Maker Portal.',
423
+ defaultValue: '',
424
+ showIf: { id: 'SOLUTION_CREATED_MANUALLY', equals: true },
425
+ });
426
+
427
+ questions.push({
428
+ id: 'MANUAL_PUBLISHER_PREFIX',
429
+ type: 'text',
430
+ label: 'Publisher prefix',
431
+ help: '2–8 lowercase letters. Find this in the Maker Portal under the publisher you selected.',
432
+ defaultValue: state.PUBLISHER_PREFIX || '',
433
+ showIf: { id: 'SOLUTION_CREATED_MANUALLY', equals: true },
434
+ });
435
+ }
436
+
437
+ return questions;
438
+ },
439
+
440
+ async apply(answers, state, log) {
441
+ if (answers.__resume) {
442
+ log.ok(`Reusing solution: ${state.SOLUTION_DISPLAY_NAME} (prefix: ${state.PUBLISHER_PREFIX})`);
443
+ return { stateUpdate: {}, completedStep: 5 };
444
+ }
445
+
446
+ const isUserAuth = (state.AUTH_PROFILE_TYPE || 'user') === 'user';
447
+ if (answers.PP_CLIENT_SECRET) setSecret(answers.PP_CLIENT_SECRET);
448
+
449
+ const selection = String(answers.SOLUTION_SELECTION || '').trim();
450
+
451
+ // ── Paste URL → extract GUID → fetch via PAC ──
452
+ if (selection === PASTE_URL) {
453
+ const url = String(answers.SOLUTION_URL || '').trim();
454
+ const solutionId = extractSolutionIdFromUrl(url);
455
+ if (!solutionId) throw new Error('Could not find a solution GUID in that URL. Paste the full Maker Portal solution URL (…/solutions/{guid}).');
456
+
457
+ log.info(`Extracted solution ID: ${solutionId}`);
458
+
459
+ // Try PAC CLI first (works for both auth types)
460
+ log.info('Fetching solution & publisher details…');
461
+ const sol = fetchSolutionViaPac(solutionId);
462
+ if (!sol || !sol.solutionid) {
463
+ throw new Error('Could not fetch solution details. Make sure you are signed in (pac auth) and the URL is from your active environment.');
464
+ }
465
+ if (!sol.prefix) {
466
+ throw new Error('Could not resolve the publisher prefix. Try the manual entry option instead.');
467
+ }
468
+
469
+ log.ok(`Solution: ${sol.friendlyname || sol.uniquename}`);
470
+ log.ok(`Publisher: ${sol.publisherFriendlyName || sol.publisherUniqueName || '?'} (prefix: ${sol.prefix})`);
471
+ clearSecret();
472
+ return { stateUpdate: buildStateUpdate(sol), completedStep: 6 };
473
+ }
474
+
475
+ // ── Selected an existing solution from dropdown ──
476
+ if (selection && selection !== CREATE_NEW) {
477
+ if (!isUserAuth && hasUsableSecret()) {
478
+ log.info('Loading solution details…');
479
+ const sol = await fetchSolutionViaApi(selection);
480
+ const pub = sol.publisherid;
481
+ log.ok(`Solution: ${sol.friendlyname} (${sol.uniquename})`);
482
+ log.ok(`Publisher: ${pub?.friendlyname || '?'} (prefix: ${pub?.customizationprefix || '?'})`);
483
+ clearSecret();
484
+ return {
485
+ stateUpdate: {
486
+ SOLUTION_ID: sol.solutionid,
487
+ SOLUTION_UNIQUE_NAME: sol.uniquename,
488
+ SOLUTION_DISPLAY_NAME: sol.friendlyname,
489
+ PUBLISHER_ID: pub?.publisherid || '',
490
+ PUBLISHER_NAME: pub?.uniquename || '',
491
+ PUBLISHER_DISPLAY_NAME: pub?.friendlyname || '',
492
+ PUBLISHER_PREFIX: pub?.customizationprefix || '',
493
+ CHOICE_VALUE_PREFIX: String(pub?.customizationoptionvalueprefix || ''),
494
+ },
495
+ completedStep: 6,
496
+ };
497
+ }
498
+ // User auth: selected from dropdown (came from pac env fetch)
499
+ log.info('Fetching solution & publisher details…');
500
+ const sol = fetchSolutionViaPac(selection);
501
+ if (!sol?.solutionid) throw new Error('Could not fetch solution details. Try pasting the solution URL instead.');
502
+ log.ok(`Solution: ${sol.friendlyname || sol.uniquename}`);
503
+ log.ok(`Publisher prefix: ${sol.prefix || '?'}`);
504
+ return { stateUpdate: buildStateUpdate(sol), completedStep: 6 };
505
+ }
506
+
507
+ // ── Create new ──
508
+ const solName = String(answers.NEW_SOLUTION_NAME || '').trim();
509
+ if (!solName) throw new Error('Solution display name is required.');
510
+ const solUnique = solName.replace(/[\s\-]+/g, '');
511
+
512
+ if (isUserAuth) {
513
+ if (answers.SOLUTION_CREATED_MANUALLY !== true) {
514
+ throw new Error('Create the solution in the Maker Portal first, then confirm — or switch to "Paste solution URL".');
515
+ }
516
+ const manualUnique = String(answers.MANUAL_SOLUTION_UNIQUE_NAME || '').trim();
517
+ const manualPrefix = String(answers.MANUAL_PUBLISHER_PREFIX || '').trim();
518
+ if (!manualUnique) throw new Error('Solution unique name is required.');
519
+ if (!VALIDATE.isValidPrefix(manualPrefix)) throw new Error('Publisher prefix must be 2–8 lowercase letters.');
520
+ log.ok(`Solution: "${solName}" (${manualUnique})`);
521
+ log.ok(`Publisher prefix: ${manualPrefix}`);
522
+ return {
523
+ stateUpdate: {
524
+ SOLUTION_ID: '',
525
+ SOLUTION_UNIQUE_NAME: manualUnique,
526
+ SOLUTION_DISPLAY_NAME: solName,
527
+ PUBLISHER_ID: '',
528
+ PUBLISHER_NAME: '',
529
+ PUBLISHER_DISPLAY_NAME: '',
530
+ PUBLISHER_PREFIX: manualPrefix,
531
+ CHOICE_VALUE_PREFIX: '',
532
+ },
533
+ completedStep: 6,
534
+ };
535
+ }
536
+
537
+ // SPN: create via API
538
+ const publisherId = String(answers.NEW_SOLUTION_PUBLISHER || '').trim();
539
+ if (!publisherId) throw new Error('Select a publisher for the new solution.');
540
+
541
+ log.info(`Creating solution "${solName}"…`);
542
+ const created = await dvPost('solutions', {
543
+ uniquename: solUnique,
544
+ friendlyname: solName,
545
+ version: '1.0.0.0',
546
+ 'publisherid@odata.bind': `/publishers(${publisherId})`,
547
+ });
548
+
549
+ const pubData = await dvGet(`publishers(${publisherId})?$select=publisherid,uniquename,friendlyname,customizationprefix,customizationoptionvalueprefix`);
550
+ log.ok(`Solution created: "${solName}" (${solUnique})`);
551
+ log.ok(`Publisher: ${pubData.friendlyname} (prefix: ${pubData.customizationprefix})`);
552
+ clearSecret();
553
+
554
+ return {
555
+ stateUpdate: {
556
+ SOLUTION_ID: created.solutionid || '',
557
+ SOLUTION_UNIQUE_NAME: solUnique,
558
+ SOLUTION_DISPLAY_NAME: solName,
559
+ PUBLISHER_ID: pubData.publisherid || publisherId,
560
+ PUBLISHER_NAME: pubData.uniquename || '',
561
+ PUBLISHER_DISPLAY_NAME: pubData.friendlyname || '',
562
+ PUBLISHER_PREFIX: pubData.customizationprefix || '',
563
+ CHOICE_VALUE_PREFIX: String(pubData.customizationoptionvalueprefix || ''),
564
+ },
565
+ completedStep: 6,
566
+ };
567
+ },
568
+ };
569
+
570
+ function buildStateUpdate(sol) {
571
+ return {
572
+ SOLUTION_ID: sol.solutionid || '',
573
+ SOLUTION_UNIQUE_NAME: sol.uniquename || '',
574
+ SOLUTION_DISPLAY_NAME: sol.friendlyname || '',
575
+ PUBLISHER_ID: sol.publisherid || '',
576
+ PUBLISHER_NAME: sol.publisherUniqueName || '',
577
+ PUBLISHER_DISPLAY_NAME: sol.publisherFriendlyName || '',
578
+ PUBLISHER_PREFIX: sol.prefix || '',
579
+ CHOICE_VALUE_PREFIX: sol.choiceValuePrefix || '',
580
+ };
581
+ }
@@ -0,0 +1,41 @@
1
+ // Step 6 — Solution confirmation (auto-skip).
2
+ // Publisher is now resolved in Step 5. This step just confirms and moves on.
3
+ import { clearSecret } from '../lib/dataverse-bridge.mjs';
4
+
5
+ export default {
6
+ meta: {
7
+ number: 6,
8
+ title: 'Solution Confirmed',
9
+ description: 'Solution and publisher details were resolved in Step 5.',
10
+ canRunInBrowser: true,
11
+ },
12
+
13
+ async questions(state) {
14
+ const hasSolution = state.SOLUTION_UNIQUE_NAME && state.PUBLISHER_PREFIX;
15
+ if (hasSolution) {
16
+ return [{
17
+ id: '__auto',
18
+ type: 'confirm',
19
+ label: `Solution: "${state.SOLUTION_DISPLAY_NAME}" — Publisher prefix: ${state.PUBLISHER_PREFIX}`,
20
+ help: 'These were set in Step 5. Click Apply to continue.',
21
+ defaultValue: true,
22
+ }];
23
+ }
24
+ return [{
25
+ id: '__missing',
26
+ type: 'confirm',
27
+ label: 'Go back to Step 5 to select a solution first.',
28
+ defaultValue: false,
29
+ }];
30
+ },
31
+
32
+ async apply(answers, state, log) {
33
+ if (!state.SOLUTION_UNIQUE_NAME || !state.PUBLISHER_PREFIX) {
34
+ throw new Error('Solution and publisher not set. Go back to Step 5.');
35
+ }
36
+ log.ok(`Solution: ${state.SOLUTION_DISPLAY_NAME} (${state.SOLUTION_UNIQUE_NAME})`);
37
+ log.ok(`Publisher prefix: ${state.PUBLISHER_PREFIX}`);
38
+ clearSecret();
39
+ return { stateUpdate: {}, completedStep: 6 };
40
+ },
41
+ };