@pacaf/wizard-ux 3.6.6 → 3.6.8

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/dist/index.html CHANGED
@@ -28,7 +28,7 @@
28
28
  }
29
29
  @keyframes spin { to { transform: rotate(360deg); } }
30
30
  </style>
31
- <script type="module" crossorigin src="/assets/index-CKpAviup.js"></script>
31
+ <script type="module" crossorigin src="/assets/index-BId92dEF.js"></script>
32
32
  </head>
33
33
  <body>
34
34
  <div id="root"><div id="boot"><div class="ring"></div></div></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pacaf/wizard-ux",
3
- "version": "3.6.6",
3
+ "version": "3.6.8",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Browser-based setup wizard for Power Apps Code Apps.",
@@ -63,7 +63,7 @@
63
63
  "dev": "node server/index.mjs",
64
64
  "build": "vite build",
65
65
  "preview": "vite preview --port 5174",
66
- "test": "node --test server/steps/05-environments.test.mjs",
66
+ "test": "node --test server/steps/05-environments.test.mjs server/lib/process-runner.test.mjs",
67
67
  "postinstall": "node scripts/fix-pty-perms.mjs"
68
68
  }
69
69
  }
@@ -16,7 +16,7 @@ class Run extends EventEmitter {
16
16
  constructor(id) {
17
17
  super();
18
18
  this.id = id;
19
- this.lines = []; // { stream: 'stdout'|'stderr', text, ts }
19
+ this.lines = []; // { stream: 'stdout'|'stderr', level: 'info'|'warn'|'error', text, ts }
20
20
  this.status = 'pending'; // pending | running | done | error
21
21
  this.exitCode = null;
22
22
  this.error = null;
@@ -25,12 +25,17 @@ class Run extends EventEmitter {
25
25
  this.child = null;
26
26
  }
27
27
 
28
- push(stream, text) {
28
+ push(stream, text, level = 'info') {
29
29
  // Defense-in-depth: scrub any secret-shaped values from text before it
30
30
  // hits the SSE log buffer. The buffer is streamed to the browser and
31
31
  // rendered in the UI; never let a real secret land there.
32
32
  const safe = SCRUB.scrubSecrets(text);
33
- const evt = { stream, text: safe, ts: Date.now() };
33
+ // `level` reflects INTENT, not the OS pipe. Raw subprocess stderr is
34
+ // 'info' by default — git, npm, vitest and pac all write normal progress
35
+ // to stderr, so the pipe alone must never imply a warning. Only the
36
+ // wizard's own log.warn/log.fail set 'warn'/'error'. The UI keys its
37
+ // warning banner and red coloring off `level`, not `stream`.
38
+ const evt = { stream, level, text: safe, ts: Date.now() };
34
39
  this.lines.push(evt);
35
40
  if (this.lines.length > 1000) this.lines.shift();
36
41
  this.emit('line', evt);
@@ -106,8 +111,8 @@ export async function runInline(run, fn) {
106
111
  const log = {
107
112
  info: (msg) => run.push('stdout', `${msg}\n`),
108
113
  ok: (msg) => run.push('stdout', `✓ ${msg}\n`),
109
- warn: (msg) => run.push('stderr', `⚠ ${msg}\n`),
110
- fail: (msg) => run.push('stderr', `✗ ${msg}\n`),
114
+ warn: (msg) => run.push('stderr', `⚠ ${msg}\n`, 'warn'),
115
+ fail: (msg) => run.push('stderr', `✗ ${msg}\n`, 'error'),
111
116
  };
112
117
  try {
113
118
  const result = await fn(log);
@@ -0,0 +1,46 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { newRun, runInline } from './process-runner.mjs';
4
+
5
+ // The UI decides whether to show the "review items" / agent-triage banner from
6
+ // each log line's `level`, NOT its OS pipe. Raw subprocess stderr (git, npm,
7
+ // vitest, pac all write normal progress to stderr) must stay 'info', or every
8
+ // successful step falsely looks like it finished with warnings.
9
+
10
+ test('raw stderr lines are tagged info, not warn', () => {
11
+ const run = newRun();
12
+ run.push('stderr', 'Cloning into ...\n'); // typical git/npm stderr chatter
13
+ const line = run.lines.at(-1);
14
+ assert.equal(line.stream, 'stderr');
15
+ assert.equal(line.level, 'info');
16
+ });
17
+
18
+ test('raw stdout lines are tagged info', () => {
19
+ const run = newRun();
20
+ run.push('stdout', 'hello\n');
21
+ assert.equal(run.lines.at(-1).level, 'info');
22
+ });
23
+
24
+ test('log.warn raises level to warn; log.fail to error; log.ok/info stay info', async () => {
25
+ const run = newRun();
26
+ await runInline(run, async (log) => {
27
+ log.ok('all good');
28
+ log.info('fyi');
29
+ log.warn('optional thing missing');
30
+ log.fail('something broke');
31
+ });
32
+ const byText = (needle) => run.lines.find((l) => l.text.includes(needle));
33
+ assert.equal(byText('all good').level, 'info');
34
+ assert.equal(byText('fyi').level, 'info');
35
+ assert.equal(byText('optional thing missing').level, 'warn');
36
+ assert.equal(byText('something broke').level, 'error');
37
+ });
38
+
39
+ test('only warn/error lines would trigger the warning banner', async () => {
40
+ const run = newRun();
41
+ // Simulate a successful step that shelled out (stderr chatter) and logged ok.
42
+ run.push('stderr', 'npm notice ...\n');
43
+ await runInline(run, async (log) => { log.ok('done'); });
44
+ const hasWarnings = run.lines.some((l) => l.level === 'warn' || l.level === 'error');
45
+ assert.equal(hasWarnings, false);
46
+ });
@@ -207,8 +207,12 @@ export default {
207
207
  // Note: The Dataverse-skills plugin manages its own Python SDK installation
208
208
  // via the dv-connect skill. No separate pip install needed here.
209
209
 
210
- // Dataverse Python SDK (PowerPlatform-Dataverse-Client + pandas) — warning only.
211
- // dv-connect can install this, so it does not block, but we surface it.
210
+ // Dataverse Python SDK (PowerPlatform-Dataverse-Client + pandas) — purely
211
+ // informational. It is NOT needed for scaffold/build/deploy, and the
212
+ // Dataverse-skills plugin installs it on demand via dv-connect. Emit at
213
+ // info level (not warn) so its absence never trips the step's warning /
214
+ // triage banner — telling the user to pip-install something they don't
215
+ // need yet is exactly the noise we want to avoid.
212
216
  const sdkOk = detectDataverseSdk(pythonCmd);
213
217
  if (sdkOk) {
214
218
  checks.push({ name: 'Dataverse Python SDK', ok: true, value: 'available', hint: null });
@@ -218,10 +222,10 @@ export default {
218
222
  name: 'Dataverse Python SDK',
219
223
  ok: false,
220
224
  value: null,
221
- hint: 'Run: pip install PowerPlatform-Dataverse-Client pandas',
225
+ hint: 'Optional — dv-connect installs it when you start Dataverse work (pip install PowerPlatform-Dataverse-Client pandas)',
222
226
  optional: true,
223
227
  });
224
- log.warn('Dataverse Python SDK not found (pip install PowerPlatform-Dataverse-Client pandas)');
228
+ log.info('Dataverse Python SDK not found (optional dv-connect installs it later when you start Dataverse work)');
225
229
  }
226
230
 
227
231
  // Dataverse-skills plugin — HARD GATE. All Dataverse work in this template
@@ -151,6 +151,36 @@ export function selectDuplicateProfiles(profiles, keepName) {
151
151
  );
152
152
  }
153
153
 
154
+ /**
155
+ * Select every profile that must be removed before we finalize the discovery
156
+ * profile into its environment-scoped name. Two categories are unsafe:
157
+ *
158
+ * 1. Same-user + same-kind duplicates of the profile we are keeping. Once the
159
+ * discovery profile is retargeted onto Dev it shares the (user, env, tenant)
160
+ * key with these, which is what trips pac's SingleOrDefault crash (#102).
161
+ * 2. Any profile whose name already equals the target name we are about to
162
+ * rename to. `pac auth name` cannot rename onto a name that is already
163
+ * taken, and a leftover SPN/user profile from a prior run for the same Dev
164
+ * environment collides here even though its user/kind differ.
165
+ *
166
+ * Pure (no I/O) so the selection rules can be unit-tested against fixtures.
167
+ */
168
+ export function selectProfilesToPrune(profiles, keepName, targetName) {
169
+ const keep = profiles.find((p) => p.name === keepName);
170
+ const out = [];
171
+ const seen = new Set();
172
+ for (const p of profiles) {
173
+ if (p.name === keepName) continue;
174
+ const isUserKindDup = keep && p.user === keep.user && p.kind === keep.kind;
175
+ const isTargetCollision = targetName && p.name === targetName;
176
+ if ((isUserKindDup || isTargetCollision) && !seen.has(p.name)) {
177
+ seen.add(p.name);
178
+ out.push(p);
179
+ }
180
+ }
181
+ return out;
182
+ }
183
+
154
184
  /**
155
185
  * Detect the signature of the corrupted-auth-store crash so we can surface an
156
186
  * actionable remediation instead of the raw pac stack trace. The
@@ -164,6 +194,25 @@ export function isDuplicateAuthStoreError(output) {
164
194
  || /InvalidOperationException/i.test(s);
165
195
  }
166
196
 
197
+ /**
198
+ * Extract the most useful error text from a runSafeCapture result. pac writes
199
+ * its real error lines to STDOUT (e.g. "Error: The value 2 of --index is not
200
+ * valid..."), while runSafeCapture puts Node's generic "Command failed: ..."
201
+ * into `stderr` (from err.message). Reading stderr-first therefore shadows the
202
+ * real reason — so prefer an `Error:` line from stdout, then stdout, then
203
+ * stderr. Returns scrubbed text safe for display.
204
+ */
205
+ export function extractPacError(result) {
206
+ const stdout = String(result?.stdout || '');
207
+ const stderr = String(result?.stderr || '');
208
+ const errLine = stdout
209
+ .split(/\r?\n/)
210
+ .map((l) => l.trim())
211
+ .find((l) => /^Error:/i.test(l));
212
+ const detail = errLine || stdout.trim() || stderr.trim() || '';
213
+ return SCRUB.scrubSecrets(detail);
214
+ }
215
+
167
216
  /** Fetch and parse `pac auth list` into structured rows. */
168
217
  function parseAuthProfiles(pac) {
169
218
  const res = SHELL.runSafeCapture(pac, ['auth', 'list']);
@@ -172,30 +221,37 @@ function parseAuthProfiles(pac) {
172
221
  }
173
222
 
174
223
  /**
175
- * Pre-flight auth hygiene. `pac auth name` crashes with
176
- * "Sequence contains more than one matching element" when the local auth store
177
- * holds duplicate profiles for the same user (a known pac CLI bug — its
178
- * AuthProfiles.Update uses SingleOrDefault). Prior wizard runs in other folders
179
- * leave stale `pp-<slug>-*` profiles behind, which is exactly the trigger.
224
+ * Pre-flight auth hygiene, run BEFORE the discovery profile is retargeted onto
225
+ * Dev. `pac auth name`/`delete` crash with "Sequence contains more than one
226
+ * matching element" when the local auth store holds two profiles that resolve
227
+ * to the same (user, environment, tenant) key a known pac CLI bug whose
228
+ * AuthProfiles.Update/Delete uses SingleOrDefault.
229
+ *
230
+ * Two leftovers from prior wizard runs trigger this:
231
+ * - same-user/kind duplicates of the discovery profile, and
232
+ * - a profile already named exactly `targetName` (the env-scoped name we are
233
+ * about to rename onto).
180
234
  *
181
- * Before renaming the discovery profile, delete any *other* profile that shares
182
- * the same user + kind as the profile we're keeping. The discovery profile was
183
- * freshly authenticated for the current user in Step 4, so same-user leftovers
184
- * are stale and safe to remove. This both prevents the crash and stops profiles
185
- * accumulating across runs (issue #102, fixes 1 & 3).
235
+ * Crucially this must happen while keys are still DISTINCT i.e. before
236
+ * `pac env select` points the discovery profile at Dev. Removing them now
237
+ * prevents the ambiguous store from ever forming, which is the actual root
238
+ * cause behind the recurring "Could not finalize the auth profile" failure.
239
+ *
240
+ * Returns the number of profiles removed.
186
241
  */
187
- function pruneDuplicateAuthProfiles(log, pac, keepName) {
242
+ function pruneConflictingAuthProfiles(log, pac, keepName, targetName) {
188
243
  const profiles = parseAuthProfiles(pac);
189
- if (profiles.length === 0) return;
190
- const dupes = selectDuplicateProfiles(profiles, keepName);
191
- if (dupes.length === 0) return;
192
- const keep = profiles.find((p) => p.name === keepName);
193
- log.warn(`Found ${dupes.length} stale duplicate auth profile(s) for ${keep.user}; removing them to avoid a known pac CLI crash.`);
244
+ if (profiles.length === 0) return 0;
245
+ const dupes = selectProfilesToPrune(profiles, keepName, targetName);
246
+ if (dupes.length === 0) return 0;
247
+ log.warn(`Found ${dupes.length} conflicting auth profile(s) from a prior run; removing them to avoid a known pac CLI crash.`);
248
+ let removed = 0;
194
249
  for (const d of dupes) {
195
250
  const del = SHELL.runSafeCapture(pac, ['auth', 'delete', '--name', d.name]);
196
- if (del.ok) log.ok(`Removed stale auth profile ${d.name}`);
251
+ if (del.ok) { log.ok(`Removed stale auth profile ${d.name}`); removed += 1; }
197
252
  else log.warn(`Could not remove stale auth profile ${d.name}. If finalize still fails, run \`pac auth clear\` and retry.`);
198
253
  }
254
+ return removed;
199
255
  }
200
256
 
201
257
  export default {
@@ -333,30 +389,58 @@ export default {
333
389
 
334
390
  const discoveryIdx = findProfileIndexByName(pac, DISCOVERY_PROFILE_NAME);
335
391
  if (discoveryIdx != null) {
392
+ // Pre-flight hygiene FIRST, while auth-store keys are still distinct.
393
+ // Removing same-user/kind duplicates and any existing profile already
394
+ // named `userProfileName` BEFORE we retarget the discovery profile onto
395
+ // Dev prevents the ambiguous (user, env, tenant) store that crashes both
396
+ // `pac auth delete` and `pac auth name` (issues #102 and the recurring
397
+ // "Could not finalize the auth profile" failure). Doing this after
398
+ // `env select` is the original defect — by then the keys already collide.
399
+ pruneConflictingAuthProfiles(log, pac, DISCOVERY_PROFILE_NAME, userProfileName);
400
+
336
401
  // Retarget the (already-authenticated) discovery profile to Dev, then
337
402
  // rename it to the environment-scoped name — no second sign-in.
338
403
  SHELL.runSafeCapture(pac, ['auth', 'select', '--name', DISCOVERY_PROFILE_NAME]);
339
404
  const sel = SHELL.runSafeCapture(pac, ['env', 'select', '--environment', devUrl]);
340
- if (!sel.ok) throw new Error(`Could not target ${devUrl}: ${SCRUB.scrubSecrets(sel.stderr || sel.stdout || '')}`);
341
- // Pre-flight hygiene: clear stale duplicate profiles that crash `pac auth name` (issue #102).
342
- pruneDuplicateAuthProfiles(log, pac, DISCOVERY_PROFILE_NAME);
343
- // Deletions can shift indices, so re-resolve the discovery profile's index.
344
- const renameIdx = findProfileIndexByName(pac, DISCOVERY_PROFILE_NAME) ?? discoveryIdx;
345
- const renamed = SHELL.runSafeCapture(pac, ['auth', 'name', '--index', String(renameIdx), '--name', userProfileName]);
346
- if (!renamed.ok) {
347
- const detail = SCRUB.scrubSecrets(renamed.stderr || renamed.stdout || '');
348
- if (isDuplicateAuthStoreError(detail)) {
405
+ if (!sel.ok) throw new Error(`Could not target ${devUrl}: ${extractPacError(sel)}`);
406
+
407
+ // Re-resolve the discovery profile's index against the CURRENT store —
408
+ // never fall back to the pre-prune index, which may now be out of range.
409
+ const renameIdx = findProfileIndexByName(pac, DISCOVERY_PROFILE_NAME);
410
+ if (renameIdx == null) {
411
+ // Idempotent recovery: an interrupted prior run may have already
412
+ // renamed discovery into userProfileName. Accept that and move on.
413
+ if (findProfileIndexByName(pac, userProfileName) != null) {
414
+ SHELL.runSafeCapture(pac, ['auth', 'select', '--name', userProfileName]);
415
+ SHELL.runSafeCapture(pac, ['env', 'select', '--environment', devUrl]);
416
+ log.ok(`Reusing existing profile ${userProfileName} (targeting Dev).`);
417
+ } else {
349
418
  throw new Error([
350
- 'Could not finalize the auth profile: your local PAC auth store has duplicate or dual-active profiles.',
351
- 'This is a known pac CLI bug ("Sequence contains more than one matching element") that crashes `pac auth name`.',
352
- 'Fix it by clearing the local profiles, then click Save & run again:',
419
+ 'Could not finalize the auth profile: the sign-in profile disappeared from the local PAC auth store.',
420
+ 'This usually means the store still holds conflicting profiles. Clear them, then click Save & run again:',
353
421
  ' pac auth clear',
354
422
  '(After clearing you will be asked to sign in once more to recreate a single clean profile.)',
355
423
  ].join('\n'));
356
424
  }
357
- throw new Error(`Could not finalize the auth profile: ${detail}`);
425
+ } else {
426
+ const renamed = SHELL.runSafeCapture(pac, ['auth', 'name', '--index', String(renameIdx), '--name', userProfileName]);
427
+ // pac writes errors to stdout; check both the result flag and the store.
428
+ const finalized = renamed.ok || findProfileIndexByName(pac, userProfileName) != null;
429
+ if (!finalized) {
430
+ const detail = extractPacError(renamed);
431
+ if (isDuplicateAuthStoreError(detail)) {
432
+ throw new Error([
433
+ 'Could not finalize the auth profile: your local PAC auth store has duplicate or dual-active profiles.',
434
+ 'This is a known pac CLI bug ("Sequence contains more than one matching element") that crashes `pac auth name`.',
435
+ 'Fix it by clearing the local profiles, then click Save & run again:',
436
+ ' pac auth clear',
437
+ '(After clearing you will be asked to sign in once more to recreate a single clean profile.)',
438
+ ].join('\n'));
439
+ }
440
+ throw new Error(`Could not finalize the auth profile: ${detail}`);
441
+ }
442
+ log.ok(`Profile ${userProfileName} ready (targeting Dev).`);
358
443
  }
359
- log.ok(`Profile ${userProfileName} ready (targeting Dev).`);
360
444
  } else if (findProfileIndexByName(pac, userProfileName) != null) {
361
445
  // Re-run after a previous success: discovery was already renamed.
362
446
  SHELL.runSafeCapture(pac, ['auth', 'select', '--name', userProfileName]);
@@ -3,6 +3,8 @@ import assert from 'node:assert/strict';
3
3
  import {
4
4
  parseAuthListOutput,
5
5
  selectDuplicateProfiles,
6
+ selectProfilesToPrune,
7
+ extractPacError,
6
8
  isDuplicateAuthStoreError,
7
9
  } from './05-environments.mjs';
8
10
 
@@ -55,3 +57,56 @@ test('isDuplicateAuthStoreError matches the known pac crash signatures', () => {
55
57
  assert.equal(isDuplicateAuthStoreError('Error: environment not found'), false);
56
58
  assert.equal(isDuplicateAuthStoreError(''), false);
57
59
  });
60
+
61
+ // Reproduces the exact log from the recurring failure: a leftover env-scoped
62
+ // user profile from a prior run already holds the target name. Same user as
63
+ // discovery so it is also a same-user duplicate.
64
+ const TARGET_COLLISION_OUTPUT = `
65
+ Index Active Kind Name User
66
+ [1] * UNIVERSAL pp-prosp8dc-d-u-6ae3eeaf user@contoso.com User
67
+ [2] UNIVERSAL pacaf-discovery user@contoso.com User
68
+ `;
69
+
70
+ test('selectProfilesToPrune removes a profile already holding the target name', () => {
71
+ const profiles = parseAuthListOutput(TARGET_COLLISION_OUTPUT);
72
+ const prune = selectProfilesToPrune(profiles, 'pacaf-discovery', 'pp-prosp8dc-d-u-6ae3eeaf');
73
+ assert.deepEqual(prune.map((p) => p.name), ['pp-prosp8dc-d-u-6ae3eeaf']);
74
+ });
75
+
76
+ test('selectProfilesToPrune removes same-user/kind dupes plus a distinct target collision', () => {
77
+ const output = `
78
+ [1] * UNIVERSAL pacaf-discovery me@contoso.com User
79
+ [2] UNIVERSAL pp-dup-d-u-aaaa me@contoso.com User
80
+ [3] UNIVERSAL pp-target-d-u-bb someone@else.com User
81
+ `;
82
+ const prune = selectProfilesToPrune(parseAuthListOutput(output), 'pacaf-discovery', 'pp-target-d-u-bb');
83
+ assert.deepEqual(prune.map((p) => p.name).sort(), ['pp-dup-d-u-aaaa', 'pp-target-d-u-bb']);
84
+ });
85
+
86
+ test('selectProfilesToPrune does not double-list a profile that is both dup and target', () => {
87
+ const profiles = parseAuthListOutput(TARGET_COLLISION_OUTPUT);
88
+ const prune = selectProfilesToPrune(profiles, 'pacaf-discovery', 'pp-prosp8dc-d-u-6ae3eeaf');
89
+ assert.equal(prune.length, 1);
90
+ });
91
+
92
+ test('selectProfilesToPrune never returns the kept profile', () => {
93
+ const profiles = parseAuthListOutput(TARGET_COLLISION_OUTPUT);
94
+ const prune = selectProfilesToPrune(profiles, 'pacaf-discovery', 'pacaf-discovery');
95
+ assert.equal(prune.find((p) => p.name === 'pacaf-discovery'), undefined);
96
+ });
97
+
98
+ test('extractPacError prefers the pac Error: line from stdout over the generic stderr', () => {
99
+ // pac writes the real reason to stdout; runSafeCapture puts "Command failed"
100
+ // into stderr. Reading stderr-first (the old bug) would shadow the real error.
101
+ const result = {
102
+ ok: false,
103
+ stdout: 'Index Active Kind ...\n\nError: The value 2 of --index is not valid. It must be between 1 and 1.\n',
104
+ stderr: 'Command failed: pac auth name --index 2 --name foo',
105
+ };
106
+ assert.equal(extractPacError(result), 'Error: The value 2 of --index is not valid. It must be between 1 and 1.');
107
+ });
108
+
109
+ test('extractPacError falls back to stderr when stdout is empty', () => {
110
+ assert.equal(extractPacError({ ok: false, stdout: '', stderr: 'boom' }), 'boom');
111
+ assert.equal(extractPacError({}), '');
112
+ });