@pacaf/wizard-ux 3.5.2 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,436 @@
1
+ // Step 5 - Environments. Discover environments via `pac env list --json` (using
2
+ // the tenant-level sign-in from Step 4), let the user pick Dev (required), Test,
3
+ // and Prod, then create the environment-scoped PAC profiles and write .env files.
4
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, chmodSync } from 'node:fs';
5
+ import { execFileSync } from 'node:child_process';
6
+ import { dirname, join, resolve } from 'node:path';
7
+ import { platform } from 'node:os';
8
+ import { fileURLToPath, pathToFileURL } from 'node:url';
9
+ import { getSecret, recoverSecret, persistSecretToCache, clearSecretCache } from '../lib/dataverse-bridge.mjs';
10
+
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+ // PACKAGE_DIR locates sibling @pacaf/wizard lib files (must stay __dirname-relative).
13
+ // PROJECT_DIR is the user's working directory (profile names, env files, git hooks).
14
+ const PACKAGE_DIR = resolve(__dirname, '..', '..', '..');
15
+ const PROJECT_DIR = process.cwd();
16
+ const SHELL = await import(pathToFileURL(resolve(PACKAGE_DIR, 'wizard', 'lib', 'shell.mjs')).href);
17
+ const CRYPTO = await import(pathToFileURL(resolve(PACKAGE_DIR, 'wizard', 'lib', 'crypto.mjs')).href);
18
+ const PAC_TARGET = await import(pathToFileURL(resolve(PACKAGE_DIR, 'wizard', 'lib', 'pac-target.mjs')).href);
19
+ const SCRUB = await import(pathToFileURL(resolve(PACKAGE_DIR, 'wizard', 'lib', 'scrub.mjs')).href);
20
+ const VALIDATE = await import(pathToFileURL(resolve(PACKAGE_DIR, 'wizard', 'lib', 'validate.mjs')).href);
21
+
22
+ // Sentinel select values.
23
+ const MANUAL_ENTRY = '__manual__';
24
+ const NONE = '__none__';
25
+ // Transient profile created in Step 4 so this step can run `pac env list`.
26
+ const DISCOVERY_PROFILE_NAME = 'pacaf-discovery';
27
+
28
+ function hasCommand(name) {
29
+ try {
30
+ execFileSync(platform() === 'win32' ? 'where' : 'which', [name], { stdio: 'ignore' });
31
+ return true;
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ function formatPacAuthCreateError(profileName, url, output = '') {
38
+ const scrubbed = SCRUB.scrubSecrets(String(output || ''));
39
+ const lines = scrubbed.trim().split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
40
+ const detail = lines.find((line) => /^Error:/i.test(line)) || lines.at(-1) || 'pac auth create failed without a readable error message.';
41
+ return [
42
+ `${profileName} profile failed to create for ${url}.`,
43
+ `PAC reported: ${detail}`,
44
+ 'Check that the app registration is added as an Application User, the client secret is current, and the credential source points at the intended environment.',
45
+ ].join('\n');
46
+ }
47
+
48
+ /**
49
+ * Remove any PP_CLIENT_SECRET=... line from .env.local. Called when the
50
+ * user picks 1Password storage so an encrypted blob from a previous run
51
+ * doesn't linger inside the (possibly cloud-synced) project folder.
52
+ */
53
+ function removeSecretFromEnvLocal() {
54
+ const envLocalPath = join(PROJECT_DIR, '.env.local');
55
+ if (!existsSync(envLocalPath)) return;
56
+ const original = readFileSync(envLocalPath, 'utf-8');
57
+ if (!/^PP_CLIENT_SECRET=/m.test(original)) return;
58
+ const cleaned = original.replace(/^PP_CLIENT_SECRET=.*\r?\n?/m, '');
59
+ writeFileSync(envLocalPath, cleaned, 'utf-8');
60
+ }
61
+
62
+ function installPreCommitHook(log) {
63
+ const hookDir = join(PROJECT_DIR, '.git', 'hooks');
64
+ const hookPath = join(hookDir, 'pre-commit');
65
+ const hookSource = join(PROJECT_DIR, 'scripts', 'pre-commit-hook.sh');
66
+ if (!existsSync(join(PROJECT_DIR, '.git')) || !existsSync(hookSource)) return;
67
+ if (existsSync(hookPath)) {
68
+ const existing = readFileSync(hookPath, 'utf-8');
69
+ if (!existing.includes('papps-secret-guard')) return;
70
+ }
71
+ try {
72
+ mkdirSync(hookDir, { recursive: true });
73
+ copyFileSync(hookSource, hookPath);
74
+ if (platform() !== 'win32') chmodSync(hookPath, 0o755);
75
+ log.ok('Installed pre-commit hook');
76
+ } catch {
77
+ log.warn('Could not install pre-commit hook. Install scripts/pre-commit-hook.sh manually if desired.');
78
+ }
79
+ }
80
+
81
+ function createPacProfile(log, pac, targetKey, url, appId, secret, tenantId) {
82
+ const profileName = PAC_TARGET.buildPacProfileName({ rootDir: PROJECT_DIR, targetKey, profileType: 'spn', url });
83
+ const result = SHELL.runSafeCapture(pac, [
84
+ 'auth', 'create', '--name', profileName, '--environment', url,
85
+ '--applicationId', appId, '--clientSecret', secret, '--tenant', tenantId,
86
+ ]);
87
+ if (!result.ok) throw new Error(formatPacAuthCreateError(profileName, url, result.stderr || result.stdout));
88
+ log.ok(`${profileName} profile created`);
89
+ }
90
+
91
+ /** Parse `pac env list --json` into a clean [{ url, name }] list. */
92
+ function discoverEnvironments(pac) {
93
+ const res = SHELL.runSafeCapture(pac, ['env', 'list', '--json']);
94
+ if (!res.ok) return [];
95
+ const out = String(res.stdout || '');
96
+ const start = out.indexOf('[');
97
+ if (start < 0) return [];
98
+ let arr;
99
+ try { arr = JSON.parse(out.slice(start)); } catch { return []; }
100
+ if (!Array.isArray(arr)) return [];
101
+ return arr
102
+ .filter((e) => e && e.EnvironmentUrl)
103
+ .map((e) => ({ url: VALIDATE.normalizeDataverseUrl(e.EnvironmentUrl), name: e.FriendlyName || e.UniqueName || e.EnvironmentUrl }))
104
+ .filter((e) => e.url)
105
+ .sort((a, b) => a.name.localeCompare(b.name));
106
+ }
107
+
108
+ /** Find a profile's bracketed index in `pac auth list` output by its name. */
109
+ function findProfileIndexByName(pac, name) {
110
+ const res = SHELL.runSafeCapture(pac, ['auth', 'list']);
111
+ if (!res.ok) return null;
112
+ for (const line of String(res.stdout || '').split(/\r?\n/)) {
113
+ const m = line.match(/^\s*\[(\d+)\]/);
114
+ if (m && line.includes(name)) return Number(m[1]);
115
+ }
116
+ return null;
117
+ }
118
+
119
+ export default {
120
+ meta: {
121
+ number: 5,
122
+ title: 'Environments',
123
+ description: 'Pick your Power Platform environments. We list them automatically using your sign-in from the previous step — choose Dev (required), and optionally Test and Prod. The matching PAC profiles and .env files are created for you.',
124
+ canRunInBrowser: true,
125
+ },
126
+
127
+ async questions(state) {
128
+ const pac = SHELL.pacPath();
129
+ const envs = pac ? discoverEnvironments(pac) : [];
130
+
131
+ if (envs.length > 0) {
132
+ const baseOptions = envs.map((e) => ({ value: e.url, label: `${e.name} — ${e.url.replace(/^https:\/\//, '')}` }));
133
+ const devOptions = [...baseOptions, { value: MANUAL_ENTRY, label: 'Enter a URL manually…' }];
134
+ const optionalOptions = [{ value: NONE, label: 'None' }, ...baseOptions, { value: MANUAL_ENTRY, label: 'Enter a URL manually…' }];
135
+ const known = (url) => url && baseOptions.some((o) => o.value === url);
136
+ return [
137
+ {
138
+ id: 'DEV_ENV',
139
+ type: 'select',
140
+ label: 'Development environment',
141
+ help: 'Where the app is built, scaffolded, and tested. Required.',
142
+ defaultValue: known(state.PP_ENV_DEV) ? state.PP_ENV_DEV : baseOptions[0].value,
143
+ options: devOptions,
144
+ required: true,
145
+ },
146
+ {
147
+ id: 'DEV_ENV_MANUAL',
148
+ type: 'url',
149
+ label: 'Development environment URL',
150
+ defaultValue: state.PP_ENV_DEV || '',
151
+ validatePattern: 'dataverseUrl',
152
+ required: true,
153
+ showIf: { id: 'DEV_ENV', equals: MANUAL_ENTRY },
154
+ },
155
+ {
156
+ id: 'TEST_ENV',
157
+ type: 'select',
158
+ label: 'Test environment (optional)',
159
+ defaultValue: known(state.PP_ENV_TEST) ? state.PP_ENV_TEST : NONE,
160
+ options: optionalOptions,
161
+ },
162
+ {
163
+ id: 'TEST_ENV_MANUAL',
164
+ type: 'url',
165
+ label: 'Test environment URL',
166
+ defaultValue: state.PP_ENV_TEST || '',
167
+ validatePattern: 'dataverseUrl',
168
+ showIf: { id: 'TEST_ENV', equals: MANUAL_ENTRY },
169
+ },
170
+ {
171
+ id: 'PROD_ENV',
172
+ type: 'select',
173
+ label: 'Production environment (optional)',
174
+ defaultValue: known(state.PP_ENV_PROD) ? state.PP_ENV_PROD : NONE,
175
+ options: optionalOptions,
176
+ },
177
+ {
178
+ id: 'PROD_ENV_MANUAL',
179
+ type: 'url',
180
+ label: 'Production environment URL',
181
+ defaultValue: state.PP_ENV_PROD || '',
182
+ validatePattern: 'dataverseUrl',
183
+ showIf: { id: 'PROD_ENV', equals: MANUAL_ENTRY },
184
+ },
185
+ ];
186
+ }
187
+
188
+ // Fallback: discovery returned nothing (e.g. a service principal without
189
+ // environment-list permissions). Fall back to manual URL entry.
190
+ return [
191
+ {
192
+ id: 'DEV_ENV_MANUAL',
193
+ type: 'url',
194
+ label: 'Development environment URL',
195
+ help: 'We could not list environments automatically. Paste the Dev environment URL.',
196
+ defaultValue: state.PP_ENV_DEV || '',
197
+ validatePattern: 'dataverseUrl',
198
+ required: true,
199
+ },
200
+ {
201
+ id: 'TEST_ENV_MANUAL',
202
+ type: 'url',
203
+ label: 'Test environment URL (optional)',
204
+ defaultValue: state.PP_ENV_TEST || '',
205
+ validatePattern: 'dataverseUrl',
206
+ },
207
+ {
208
+ id: 'PROD_ENV_MANUAL',
209
+ type: 'url',
210
+ label: 'Production environment URL (optional)',
211
+ defaultValue: state.PP_ENV_PROD || '',
212
+ validatePattern: 'dataverseUrl',
213
+ },
214
+ ];
215
+ },
216
+
217
+ async apply(answers, state, log) {
218
+ const pac = SHELL.pacPath();
219
+ if (!pac) throw new Error('PAC CLI was not found.');
220
+
221
+ const authProfileType = state.AUTH_PROFILE_TYPE || 'user';
222
+ const targetKey = state.WIZARD_TARGET_ENV || 'dev';
223
+ const dateStr = new Date().toISOString().slice(0, 10);
224
+
225
+ // Resolve each environment from its select value or manual override.
226
+ const resolveUrl = (selectId, manualId) => {
227
+ const sel = answers[selectId];
228
+ if (sel === NONE) return '';
229
+ if (sel === MANUAL_ENTRY || sel === undefined) {
230
+ const raw = String(answers[manualId] || '').trim();
231
+ return raw ? VALIDATE.normalizeDataverseUrl(raw) : '';
232
+ }
233
+ return VALIDATE.normalizeDataverseUrl(String(sel));
234
+ };
235
+ const devUrl = resolveUrl('DEV_ENV', 'DEV_ENV_MANUAL');
236
+ const testUrl = resolveUrl('TEST_ENV', 'TEST_ENV_MANUAL');
237
+ const prodUrl = resolveUrl('PROD_ENV', 'PROD_ENV_MANUAL');
238
+
239
+ if (!devUrl) throw new Error('A development environment is required.');
240
+ if (!VALIDATE.isValidDataverseUrl(devUrl)) throw new Error(`"${devUrl}" is not a valid Dataverse URL (expected https://<org>.crm.dynamics.com).`);
241
+ if (testUrl && !VALIDATE.isValidDataverseUrl(testUrl)) throw new Error(`"${testUrl}" is not a valid Dataverse URL.`);
242
+ if (prodUrl && !VALIDATE.isValidDataverseUrl(prodUrl)) throw new Error(`"${prodUrl}" is not a valid Dataverse URL.`);
243
+
244
+ log.ok(`Dev: ${devUrl}`);
245
+ if (testUrl) log.ok(`Test: ${testUrl}`);
246
+ if (prodUrl) log.ok(`Prod: ${prodUrl}`);
247
+
248
+ // ── User credentials flow ──
249
+ if (authProfileType === 'user') {
250
+ const userProfileName = PAC_TARGET.buildPacProfileName({ rootDir: PROJECT_DIR, targetKey, profileType: 'user', url: devUrl });
251
+
252
+ const discoveryIdx = findProfileIndexByName(pac, DISCOVERY_PROFILE_NAME);
253
+ if (discoveryIdx != null) {
254
+ // Retarget the (already-authenticated) discovery profile to Dev, then
255
+ // rename it to the environment-scoped name — no second sign-in.
256
+ SHELL.runSafeCapture(pac, ['auth', 'select', '--name', DISCOVERY_PROFILE_NAME]);
257
+ const sel = SHELL.runSafeCapture(pac, ['env', 'select', '--environment', devUrl]);
258
+ if (!sel.ok) throw new Error(`Could not target ${devUrl}: ${SCRUB.scrubSecrets(sel.stderr || sel.stdout || '')}`);
259
+ const renamed = SHELL.runSafeCapture(pac, ['auth', 'name', '--index', String(discoveryIdx), '--name', userProfileName]);
260
+ if (!renamed.ok) throw new Error(`Could not finalize the auth profile: ${SCRUB.scrubSecrets(renamed.stderr || renamed.stdout || '')}`);
261
+ log.ok(`Profile ${userProfileName} ready (targeting Dev).`);
262
+ } else if (findProfileIndexByName(pac, userProfileName) != null) {
263
+ // Re-run after a previous success: discovery was already renamed.
264
+ SHELL.runSafeCapture(pac, ['auth', 'select', '--name', userProfileName]);
265
+ SHELL.runSafeCapture(pac, ['env', 'select', '--environment', devUrl]);
266
+ log.ok(`Reusing existing profile ${userProfileName} (targeting Dev).`);
267
+ } else {
268
+ throw new Error('No sign-in was found. Go back to the previous step, sign in, then return here.');
269
+ }
270
+
271
+ // Write .env — 1Password references if configured, otherwise plain URLs.
272
+ const authMode = state.AUTH_MODE || '';
273
+ if (authMode === '1password' && state.OP_VAULT && state.OP_ITEM) {
274
+ const vault = state.OP_VAULT;
275
+ const itemName = state.OP_ITEM;
276
+ try { removeSecretFromEnvLocal(); } catch { /* best-effort */ }
277
+ try { clearSecretCache(); } catch { /* best-effort */ }
278
+ const envContent = [
279
+ '# .env - Safe to commit. Contains 1Password references, not secrets.',
280
+ `# Generated by setup wizard on ${dateStr}`,
281
+ '',
282
+ `PP_ENV_DEV=op://${vault}/${itemName}/env-dev`,
283
+ testUrl ? `PP_ENV_TEST=op://${vault}/${itemName}/env-test` : '',
284
+ prodUrl ? `PP_ENV_PROD=op://${vault}/${itemName}/env-prod` : '',
285
+ '',
286
+ ].filter((line) => line !== '').join('\n');
287
+ writeFileSync(join(PROJECT_DIR, '.env'), `${envContent}\n`, 'utf-8');
288
+ log.ok('Wrote .env with 1Password references');
289
+ } else {
290
+ const envContent = [
291
+ '# .env - Environment URLs for Power Platform.',
292
+ `# Generated by setup wizard on ${dateStr}`,
293
+ '',
294
+ `PP_ENV_DEV=${devUrl}`,
295
+ testUrl ? `PP_ENV_TEST=${testUrl}` : '',
296
+ prodUrl ? `PP_ENV_PROD=${prodUrl}` : '',
297
+ '',
298
+ ].filter((line) => line !== '').join('\n');
299
+ writeFileSync(join(PROJECT_DIR, '.env'), `${envContent}\n`, 'utf-8');
300
+ log.ok('Wrote .env with environment URLs');
301
+ }
302
+
303
+ installPreCommitHook(log);
304
+
305
+ const verification = PAC_TARGET.selectAndVerifyPacProfile({
306
+ pac,
307
+ rootDir: PROJECT_DIR,
308
+ wizardState: { WIZARD_TARGET_ENV: targetKey, PP_ENV_DEV: devUrl, PP_ENV_TEST: testUrl, PP_ENV_PROD: prodUrl },
309
+ targetKey,
310
+ profileType: 'user',
311
+ credentialValues: null,
312
+ powerConfigPath: join(PROJECT_DIR, 'power.config.json'),
313
+ requireCredentialMatch: false,
314
+ requirePowerConfig: false,
315
+ requirePowerConfigTarget: false,
316
+ });
317
+ log.ok(`Verified ${verification.profileName}`);
318
+ log.info(verification.whoInfo.raw.split('\n').map((line) => ` ${line}`).join('\n'));
319
+
320
+ return {
321
+ stateUpdate: { PP_ENV_DEV: devUrl, PP_ENV_TEST: testUrl, PP_ENV_PROD: prodUrl, AUTH_MODE: authMode },
322
+ completedStep: 5,
323
+ };
324
+ }
325
+
326
+ // ── Service Principal flow ──
327
+ const secret = getSecret() || recoverSecret();
328
+ if (!secret) throw new Error('Client secret could not be recovered. Go back to the sign-in step and re-enter it.');
329
+ const tenantId = String(state.PP_TENANT_ID || '').trim();
330
+ const clientId = String(state.PP_APP_ID || '').trim();
331
+ if (!tenantId || !clientId) throw new Error('Tenant ID and Client ID are required. Complete the earlier steps first.');
332
+ const authMode = String(state.AUTH_MODE || 'envlocal');
333
+
334
+ if (authMode === '1password') {
335
+ const vault = String(state.OP_VAULT || '').trim();
336
+ const itemName = String(state.OP_ITEM || '').trim();
337
+ if (!hasCommand('op')) throw new Error('1Password storage was selected, but the op CLI is not available.');
338
+ if (!vault || !itemName) throw new Error('1Password vault and item name are required.');
339
+ try { removeSecretFromEnvLocal(); } catch { /* best-effort */ }
340
+ try { clearSecretCache(); } catch { /* best-effort */ }
341
+ const envContent = [
342
+ '# .env - Safe to commit. Contains 1Password references, not secrets.',
343
+ `# Generated by setup wizard on ${dateStr}`,
344
+ '',
345
+ `PP_TENANT_ID=op://${vault}/${itemName}/tenant-id`,
346
+ `PP_APP_ID=op://${vault}/${itemName}/app-id`,
347
+ `PP_CLIENT_SECRET=op://${vault}/${itemName}/client-secret`,
348
+ `PP_ENV_DEV=op://${vault}/${itemName}/env-dev`,
349
+ testUrl ? `PP_ENV_TEST=op://${vault}/${itemName}/env-test` : '',
350
+ prodUrl ? `PP_ENV_PROD=op://${vault}/${itemName}/env-prod` : '',
351
+ '',
352
+ ].filter((line) => line !== '').join('\n');
353
+ writeFileSync(join(PROJECT_DIR, '.env'), `${envContent}\n`, 'utf-8');
354
+ log.ok('Wrote .env with 1Password references');
355
+ } else {
356
+ const gitignorePath = join(PROJECT_DIR, '.gitignore');
357
+ if (existsSync(gitignorePath)) {
358
+ const gitignore = readFileSync(gitignorePath, 'utf-8');
359
+ if (!gitignore.includes('.env.local')) appendFileSync(gitignorePath, '\n.env.local\n');
360
+ } else {
361
+ writeFileSync(gitignorePath, '.env.local\n', 'utf-8');
362
+ }
363
+ const envLocalContent = [
364
+ '# .env.local - DO NOT commit to Git.',
365
+ `# Generated by setup wizard on ${dateStr}`,
366
+ '',
367
+ `PP_TENANT_ID=${tenantId}`,
368
+ `PP_APP_ID=${clientId}`,
369
+ `PP_CLIENT_SECRET=${CRYPTO.encrypt(secret)}`,
370
+ `PP_ENV_DEV=${devUrl}`,
371
+ testUrl ? `PP_ENV_TEST=${testUrl}` : '',
372
+ prodUrl ? `PP_ENV_PROD=${prodUrl}` : '',
373
+ '',
374
+ ].filter((line) => line !== '').join('\n');
375
+ writeFileSync(join(PROJECT_DIR, '.env.local'), `${envLocalContent}\n`, 'utf-8');
376
+ if (platform() !== 'win32') {
377
+ try { chmodSync(join(PROJECT_DIR, '.env.local'), 0o600); } catch { /* best effort */ }
378
+ }
379
+ try { persistSecretToCache(secret); } catch { /* best-effort */ }
380
+ log.ok('Wrote .env.local');
381
+ }
382
+
383
+ log.info('Creating PAC SPN auth profiles...');
384
+ createPacProfile(log, pac, 'dev', devUrl, clientId, secret, tenantId);
385
+ if (testUrl) createPacProfile(log, pac, 'test', testUrl, clientId, secret, tenantId);
386
+ if (prodUrl) createPacProfile(log, pac, 'prod', prodUrl, clientId, secret, tenantId);
387
+
388
+ // Remove the transient discovery profile now that env-scoped ones exist.
389
+ SHELL.runSafeCapture(pac, ['auth', 'delete', '--name', DISCOVERY_PROFILE_NAME]);
390
+
391
+ installPreCommitHook(log);
392
+
393
+ const envTemplate = [
394
+ '# .env.template - Copy to .env.local and fill in values',
395
+ '# DO NOT commit .env.local to Git',
396
+ '',
397
+ 'PP_TENANT_ID=',
398
+ 'PP_APP_ID=',
399
+ 'PP_CLIENT_SECRET=',
400
+ `PP_ENV_DEV=${devUrl}`,
401
+ testUrl ? `PP_ENV_TEST=${testUrl}` : '',
402
+ prodUrl ? `PP_ENV_PROD=${prodUrl}` : '',
403
+ '',
404
+ ].filter((line) => line !== '').join('\n');
405
+ writeFileSync(join(PROJECT_DIR, '.env.template'), `${envTemplate}\n`, 'utf-8');
406
+ log.ok('Wrote .env.template');
407
+
408
+ const opBin = hasCommand('op') ? 'op' : null;
409
+ const credentialValues = PAC_TARGET.resolveCredentialValues({ rootDir: PROJECT_DIR, opBin, source: authMode });
410
+ const verification = PAC_TARGET.selectAndVerifyPacProfile({
411
+ pac,
412
+ rootDir: PROJECT_DIR,
413
+ wizardState: { WIZARD_TARGET_ENV: targetKey, PP_ENV_DEV: devUrl, PP_ENV_TEST: testUrl, PP_ENV_PROD: prodUrl },
414
+ targetKey,
415
+ profileType: 'spn',
416
+ credentialValues,
417
+ powerConfigPath: join(PROJECT_DIR, 'power.config.json'),
418
+ requireCredentialMatch: true,
419
+ requirePowerConfig: false,
420
+ requirePowerConfigTarget: false,
421
+ });
422
+ log.ok(`Verified ${verification.profileName}`);
423
+ log.info(verification.whoInfo.raw.split('\n').map((line) => ` ${line}`).join('\n'));
424
+
425
+ return {
426
+ stateUpdate: {
427
+ PP_ENV_DEV: devUrl,
428
+ PP_ENV_TEST: testUrl,
429
+ PP_ENV_PROD: prodUrl,
430
+ AUTH_MODE: authMode,
431
+ HAS_OP: opBin !== null,
432
+ },
433
+ completedStep: 5,
434
+ };
435
+ },
436
+ };
@@ -1,9 +1,10 @@
1
- // Step 5 — Solution & Publisher (solution-first, publisher auto-resolved).
1
+ // Step 6 — Solution & Publisher (solution-first, publisher auto-resolved).
2
2
  import { dirname, resolve, join } from 'node:path';
3
3
  import { writeFileSync, unlinkSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { fileURLToPath, pathToFileURL } from 'node:url';
6
6
  import { dvGet, dvPost, hasUsableSecret, setSecret, clearSecret } from '../lib/dataverse-bridge.mjs';
7
+ import { parsePacTabularRows } from '../lib/pac-parse.mjs';
7
8
 
8
9
  const __dirname = dirname(fileURLToPath(import.meta.url));
9
10
  const PACKAGE_DIR = resolve(__dirname, '..', '..', '..');
@@ -52,55 +53,6 @@ function extractSolutionIdFromUrl(url) {
52
53
  return m ? m[1].toLowerCase() : null;
53
54
  }
54
55
 
55
- /**
56
- * Parse PAC CLI column-aligned tabular output into an array of row objects.
57
- * Finds the header line (containing known column keywords), determines column
58
- * boundaries from header token positions, and slices each subsequent data line
59
- * by those boundaries. Handles multi-word values (e.g. "Climb Tracker") and
60
- * aliased columns (e.g. "pub.customizationprefix").
61
- */
62
- function parsePacTabularRows(output, headerHints) {
63
- const allLines = output.split(/\r?\n/);
64
- const hints = headerHints || ['uniquename', 'solutionid', 'friendlyname'];
65
-
66
- // Find the header line — first line containing at least one of the hints.
67
- let headerIdx = -1;
68
- for (let i = 0; i < allLines.length; i++) {
69
- const lower = allLines[i].toLowerCase();
70
- if (hints.some((h) => lower.includes(h))) {
71
- headerIdx = i;
72
- break;
73
- }
74
- }
75
- if (headerIdx < 0) return [];
76
-
77
- const headerLine = allLines[headerIdx];
78
-
79
- // Extract column names and their start positions from the header.
80
- const cols = [];
81
- const re = /\S+/g;
82
- let m;
83
- while ((m = re.exec(headerLine)) !== null) {
84
- cols.push({ name: m[0].toLowerCase(), start: m.index });
85
- }
86
-
87
- // Parse each data line after the header.
88
- const rows = [];
89
- for (let i = headerIdx + 1; i < allLines.length; i++) {
90
- const line = allLines[i];
91
- if (!line.trim()) continue;
92
- if (/^(Connected|Microsoft|Version:|Online|Feedback)/i.test(line.trim())) continue;
93
- const row = {};
94
- for (let c = 0; c < cols.length; c++) {
95
- const start = cols[c].start;
96
- const end = c < cols.length - 1 ? cols[c + 1].start : line.length;
97
- row[cols[c].name] = (start < line.length ? line.slice(start, end) : '').trim();
98
- }
99
- if (Object.values(row).some((v) => v)) rows.push(row);
100
- }
101
- return rows;
102
- }
103
-
104
56
  /**
105
57
  * Fetch solution + publisher by solution ID using a single joined FetchXML.
106
58
  * Uses <link-entity> to include publisher columns directly, avoiding the
@@ -248,7 +200,7 @@ async function fetchSolutionViaApi(solutionId) {
248
200
 
249
201
  export default {
250
202
  meta: {
251
- number: 5,
203
+ number: 6,
252
204
  title: 'Solution & Publisher',
253
205
  description: 'Select or create the Power Platform solution for your Code App. The publisher (prefix) is resolved automatically from the solution.',
254
206
  canRunInBrowser: true,
@@ -436,7 +388,7 @@ export default {
436
388
  async apply(answers, state, log) {
437
389
  if (answers.__resume) {
438
390
  log.ok(`Reusing solution: ${state.SOLUTION_DISPLAY_NAME} (prefix: ${state.PUBLISHER_PREFIX})`);
439
- return { stateUpdate: {}, completedStep: 5 };
391
+ return { stateUpdate: {}, completedStep: 6 };
440
392
  }
441
393
 
442
394
  const isUserAuth = (state.AUTH_PROFILE_TYPE || 'user') === 'user';
@@ -466,7 +418,7 @@ export default {
466
418
  log.ok(`Solution: ${sol.friendlyname || sol.uniquename}`);
467
419
  log.ok(`Publisher: ${sol.publisherFriendlyName || sol.publisherUniqueName || '?'} (prefix: ${sol.prefix})`);
468
420
  clearSecret();
469
- return { stateUpdate: buildStateUpdate(sol), completedStep: 6 };
421
+ return { stateUpdate: buildStateUpdate(sol), completedStep: 7 };
470
422
  }
471
423
 
472
424
  // ── Selected an existing solution from dropdown ──
@@ -489,7 +441,7 @@ export default {
489
441
  PUBLISHER_PREFIX: pub?.customizationprefix || '',
490
442
  CHOICE_VALUE_PREFIX: String(pub?.customizationoptionvalueprefix || ''),
491
443
  },
492
- completedStep: 6,
444
+ completedStep: 7,
493
445
  };
494
446
  }
495
447
  // User auth: selected from dropdown (came from pac env fetch)
@@ -498,7 +450,7 @@ export default {
498
450
  if (!sol?.solutionid) throw new Error('Could not fetch solution details. Try pasting the solution URL instead.');
499
451
  log.ok(`Solution: ${sol.friendlyname || sol.uniquename}`);
500
452
  log.ok(`Publisher prefix: ${sol.prefix || '?'}`);
501
- return { stateUpdate: buildStateUpdate(sol), completedStep: 6 };
453
+ return { stateUpdate: buildStateUpdate(sol), completedStep: 7 };
502
454
  }
503
455
 
504
456
  // ── Create new ──
@@ -527,7 +479,7 @@ export default {
527
479
  PUBLISHER_PREFIX: manualPrefix,
528
480
  CHOICE_VALUE_PREFIX: '',
529
481
  },
530
- completedStep: 6,
482
+ completedStep: 7,
531
483
  };
532
484
  }
533
485
 
@@ -559,7 +511,7 @@ export default {
559
511
  PUBLISHER_PREFIX: pubData.customizationprefix || '',
560
512
  CHOICE_VALUE_PREFIX: String(pubData.customizationoptionvalueprefix || ''),
561
513
  },
562
- completedStep: 6,
514
+ completedStep: 7,
563
515
  };
564
516
  },
565
517
  };
@@ -1,12 +1,12 @@
1
- // Step 6 — Solution confirmation (auto-skip).
2
- // Publisher is now resolved in Step 5. This step just confirms and moves on.
1
+ // Step 7 — Solution confirmation (auto-skip).
2
+ // Publisher is now resolved in Step 6. This step just confirms and moves on.
3
3
  import { clearSecret } from '../lib/dataverse-bridge.mjs';
4
4
 
5
5
  export default {
6
6
  meta: {
7
- number: 6,
7
+ number: 7,
8
8
  title: 'Solution Confirmed',
9
- description: 'Solution and publisher details were resolved in Step 5.',
9
+ description: 'Solution and publisher details were resolved in Step 6.',
10
10
  canRunInBrowser: true,
11
11
  },
12
12
 
@@ -17,25 +17,25 @@ export default {
17
17
  id: '__auto',
18
18
  type: 'confirm',
19
19
  label: `Solution: "${state.SOLUTION_DISPLAY_NAME}" — Publisher prefix: ${state.PUBLISHER_PREFIX}`,
20
- help: 'These were set in Step 5. Click Apply to continue.',
20
+ help: 'These were set in Step 6. Click Apply to continue.',
21
21
  defaultValue: true,
22
22
  }];
23
23
  }
24
24
  return [{
25
25
  id: '__missing',
26
26
  type: 'confirm',
27
- label: 'Go back to Step 5 to select a solution first.',
27
+ label: 'Go back to Step 6 to select a solution first.',
28
28
  defaultValue: false,
29
29
  }];
30
30
  },
31
31
 
32
32
  async apply(answers, state, log) {
33
33
  if (!state.SOLUTION_UNIQUE_NAME || !state.PUBLISHER_PREFIX) {
34
- throw new Error('Solution and publisher not set. Go back to Step 5.');
34
+ throw new Error('Solution and publisher not set. Go back to Step 6.');
35
35
  }
36
36
  log.ok(`Solution: ${state.SOLUTION_DISPLAY_NAME} (${state.SOLUTION_UNIQUE_NAME})`);
37
37
  log.ok(`Publisher prefix: ${state.PUBLISHER_PREFIX}`);
38
38
  clearSecret();
39
- return { stateUpdate: {}, completedStep: 6 };
39
+ return { stateUpdate: {}, completedStep: 7 };
40
40
  },
41
41
  };
@@ -1,4 +1,4 @@
1
- // Step 7 - Scaffold. Browser-native long-running scaffold with live logs.
1
+ // Step 8 - Scaffold. Browser-native long-running scaffold with live logs.
2
2
  import { existsSync, mkdirSync, readdirSync, writeFileSync, readFileSync } from 'node:fs';
3
3
  import { spawn, execFileSync } from 'node:child_process';
4
4
  import { dirname, join, resolve } from 'node:path';
@@ -187,13 +187,13 @@ pac code push
187
187
  |-------------|-----|
188
188
  ${envRows}
189
189
 
190
- Connector binding is intentionally deferred until the prototype is stable. Use WizardUX step 8 or \`pac code add-data-source\` when you are ready for real data.
190
+ Connector binding is intentionally deferred until the prototype is stable. Use WizardUX step 9 or \`pac code add-data-source\` when you are ready for real data.
191
191
  `, 'utf-8');
192
192
  }
193
193
 
194
194
  export default {
195
195
  meta: {
196
- number: 7,
196
+ number: 8,
197
197
  title: 'Scaffold the Code App',
198
198
  description: 'Generate the project, install dependencies, register with Power Platform, and run smoke tests.',
199
199
  canRunInBrowser: true,
@@ -290,7 +290,7 @@ export default {
290
290
  if (pnpm) {
291
291
  log.info('Detected pnpm — using it for faster installs and shared dependency cache.');
292
292
  } else {
293
- log.info('pnpm not found — using npm. Tip: `corepack enable && corepack prepare pnpm@latest --activate` makes Step 7 noticeably faster.');
293
+ log.info('pnpm not found — using npm. Tip: `corepack enable && corepack prepare pnpm@latest --activate` makes Step 8 noticeably faster.');
294
294
  }
295
295
 
296
296
  // pnpm refuses `pnpm add` at a workspace root (pnpm-workspace.yaml present)
@@ -342,7 +342,7 @@ export default {
342
342
  try {
343
343
  verifyPacTarget({ pac, projectDir, state, credentialValues, profileType: 'user', requirePowerConfig: false, requirePowerConfigTarget: false });
344
344
  } catch (error) {
345
- throw new Error(`${error.message}\n\npac code init requires the repo-scoped interactive PAC profile. Return to Step 4, enable user profile creation, complete browser/device sign-in, then retry Step 7.`);
345
+ throw new Error(`${error.message}\n\npac code init requires the repo-scoped interactive PAC profile. Return to Step 4, enable user profile creation, complete browser/device sign-in, then retry Step 8.`);
346
346
  }
347
347
  const powerConfigPath = join(projectDir, 'power.config.json');
348
348
  let skipInit = false;
@@ -366,7 +366,7 @@ export default {
366
366
  '--fileEntryPoint', 'index.html',
367
367
  ], { cwd: projectDir });
368
368
  if (!initOk) throw new Error('pac code init failed. Check the live output above, then retry this step.');
369
- if (!existsSync(powerConfigPath)) throw new Error('pac code init completed without creating power.config.json. Check the PAC output above, then retry Step 7 after resolving that PAC error.');
369
+ if (!existsSync(powerConfigPath)) throw new Error('pac code init completed without creating power.config.json. Check the PAC output above, then retry Step 8 after resolving that PAC error.');
370
370
  const repair = PAC_TARGET.repairPowerConfigDisplayNames(powerConfigPath);
371
371
  if (repair.changed) log.warn(`Repaired quoted display name fields in power.config.json: ${repair.fields.join(', ')}`);
372
372
  }
@@ -376,7 +376,7 @@ export default {
376
376
  log.warn('PAC CLI not found; skipping pac code init.');
377
377
  }
378
378
 
379
- log.info('Connector binding is deferred to step 8 after prototype validation.');
379
+ log.info('Connector binding is deferred to step 9 after prototype validation.');
380
380
 
381
381
  log.info('Running smoke tests...');
382
382
  if (await runCommand(log, 'npm run test:smoke', { cwd: projectDir })) log.ok('Smoke tests passed');
@@ -426,7 +426,7 @@ export default {
426
426
  PROJECT_DIR: projectDir,
427
427
  GIT_REMOTE: finalRemoteUrl || state.GIT_REMOTE || '',
428
428
  },
429
- completedStep: 7,
429
+ completedStep: 8,
430
430
  };
431
431
  },
432
432
  };