@pacaf/wizard-ux 3.6.2 → 3.6.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pacaf/wizard-ux",
3
- "version": "3.6.2",
3
+ "version": "3.6.4",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Browser-based setup wizard for Power Apps Code Apps (parallel to @pacaf/wizard CLI).",
@@ -38,7 +38,7 @@
38
38
  "react-dom": "^19.0.0",
39
39
  "react-resizable-panels": "^2.1.7",
40
40
  "react-router-dom": "^7.1.0",
41
- "@pacaf/wizard": "3.4.6"
41
+ "@pacaf/wizard": "3.4.7"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@types/react": "^19.0.0",
@@ -63,6 +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
67
  "postinstall": "node scripts/fix-pty-perms.mjs"
67
68
  }
68
69
  }
@@ -116,6 +116,88 @@ function findProfileIndexByName(pac, name) {
116
116
  return null;
117
117
  }
118
118
 
119
+ /**
120
+ * Parse the raw stdout of `pac auth list` into structured rows. Columns are:
121
+ * [index] [active*] Kind Name User ...
122
+ * The active flag is a `*` in the second column; it is absent for inactive
123
+ * profiles. Pure (no I/O) so it can be unit-tested against captured fixtures.
124
+ */
125
+ export function parseAuthListOutput(stdout) {
126
+ const rows = [];
127
+ for (const line of String(stdout || '').split(/\r?\n/)) {
128
+ const m = line.match(/^\s*\[(\d+)\]\s*(\*?)\s+(\S+)\s+(\S+)\s+(\S+)/);
129
+ if (!m) continue;
130
+ rows.push({
131
+ index: Number(m[1]),
132
+ active: m[2] === '*',
133
+ kind: m[3],
134
+ name: m[4],
135
+ user: m[5].trim(),
136
+ });
137
+ }
138
+ return rows;
139
+ }
140
+
141
+ /**
142
+ * Given parsed profiles and the name we intend to keep, return the stale
143
+ * duplicates: other profiles sharing the same user + kind as the kept profile.
144
+ * Pure so the duplicate-detection rules can be unit-tested directly.
145
+ */
146
+ export function selectDuplicateProfiles(profiles, keepName) {
147
+ const keep = profiles.find((p) => p.name === keepName);
148
+ if (!keep) return [];
149
+ return profiles.filter(
150
+ (p) => p.name !== keepName && p.user === keep.user && p.kind === keep.kind,
151
+ );
152
+ }
153
+
154
+ /**
155
+ * Detect the signature of the corrupted-auth-store crash so we can surface an
156
+ * actionable remediation instead of the raw pac stack trace. The
157
+ * "Sequence contains..." line lands in pac-log.txt; the console shows the
158
+ * generic non-recoverable error / InvalidOperationException.
159
+ */
160
+ export function isDuplicateAuthStoreError(output) {
161
+ const s = String(output || '');
162
+ return /Sequence contains more than one matching element/i.test(s)
163
+ || /non-recoverable error/i.test(s)
164
+ || /InvalidOperationException/i.test(s);
165
+ }
166
+
167
+ /** Fetch and parse `pac auth list` into structured rows. */
168
+ function parseAuthProfiles(pac) {
169
+ const res = SHELL.runSafeCapture(pac, ['auth', 'list']);
170
+ if (!res.ok) return [];
171
+ return parseAuthListOutput(res.stdout);
172
+ }
173
+
174
+ /**
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.
180
+ *
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).
186
+ */
187
+ function pruneDuplicateAuthProfiles(log, pac, keepName) {
188
+ 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.`);
194
+ for (const d of dupes) {
195
+ const del = SHELL.runSafeCapture(pac, ['auth', 'delete', '--name', d.name]);
196
+ if (del.ok) log.ok(`Removed stale auth profile ${d.name}`);
197
+ else log.warn(`Could not remove stale auth profile ${d.name}. If finalize still fails, run \`pac auth clear\` and retry.`);
198
+ }
199
+ }
200
+
119
201
  export default {
120
202
  meta: {
121
203
  number: 5,
@@ -256,8 +338,24 @@ export default {
256
338
  SHELL.runSafeCapture(pac, ['auth', 'select', '--name', DISCOVERY_PROFILE_NAME]);
257
339
  const sel = SHELL.runSafeCapture(pac, ['env', 'select', '--environment', devUrl]);
258
340
  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 || '')}`);
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)) {
349
+ 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:',
353
+ ' pac auth clear',
354
+ '(After clearing you will be asked to sign in once more to recreate a single clean profile.)',
355
+ ].join('\n'));
356
+ }
357
+ throw new Error(`Could not finalize the auth profile: ${detail}`);
358
+ }
261
359
  log.ok(`Profile ${userProfileName} ready (targeting Dev).`);
262
360
  } else if (findProfileIndexByName(pac, userProfileName) != null) {
263
361
  // Re-run after a previous success: discovery was already renamed.
@@ -0,0 +1,57 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ parseAuthListOutput,
5
+ selectDuplicateProfiles,
6
+ isDuplicateAuthStoreError,
7
+ } from './05-environments.mjs';
8
+
9
+ // Reproduces the corrupted store from issue #102: two profiles flagged active
10
+ // (`*`) that are duplicates of the same user + environment.
11
+ const DUAL_ACTIVE_OUTPUT = `
12
+ Index Active Kind Name User
13
+ [1] UNIVERSAL pp-papps4ff-d-s-6ae3eeaf 00000000-0000-0000-0000-000000000000 Application
14
+ [2] * UNIVERSAL pp-check6f6-d-u-6ae3eeaf user@contoso.com User
15
+ [3] * UNIVERSAL pacaf-discovery user@contoso.com User
16
+ `;
17
+
18
+ test('parseAuthListOutput parses index, active flag, kind, name, and user', () => {
19
+ const rows = parseAuthListOutput(DUAL_ACTIVE_OUTPUT);
20
+ assert.equal(rows.length, 3);
21
+ assert.deepEqual(rows[0], { index: 1, active: false, kind: 'UNIVERSAL', name: 'pp-papps4ff-d-s-6ae3eeaf', user: '00000000-0000-0000-0000-000000000000' });
22
+ assert.deepEqual(rows[1], { index: 2, active: true, kind: 'UNIVERSAL', name: 'pp-check6f6-d-u-6ae3eeaf', user: 'user@contoso.com' });
23
+ assert.deepEqual(rows[2], { index: 3, active: true, kind: 'UNIVERSAL', name: 'pacaf-discovery', user: 'user@contoso.com' });
24
+ });
25
+
26
+ test('parseAuthListOutput ignores the header and blank lines', () => {
27
+ assert.deepEqual(parseAuthListOutput(''), []);
28
+ assert.deepEqual(parseAuthListOutput('Index Active Kind Name User'), []);
29
+ });
30
+
31
+ test('selectDuplicateProfiles returns same-user/kind profiles other than the kept one', () => {
32
+ const profiles = parseAuthListOutput(DUAL_ACTIVE_OUTPUT);
33
+ const dupes = selectDuplicateProfiles(profiles, 'pacaf-discovery');
34
+ assert.deepEqual(dupes.map((p) => p.name), ['pp-check6f6-d-u-6ae3eeaf']);
35
+ });
36
+
37
+ test('selectDuplicateProfiles leaves a different user untouched', () => {
38
+ const output = `
39
+ [1] * UNIVERSAL pacaf-discovery me@contoso.com User
40
+ [2] UNIVERSAL pp-other-d-u-1234 someone@else.com User
41
+ `;
42
+ const dupes = selectDuplicateProfiles(parseAuthListOutput(output), 'pacaf-discovery');
43
+ assert.equal(dupes.length, 0);
44
+ });
45
+
46
+ test('selectDuplicateProfiles returns nothing when the kept profile is absent', () => {
47
+ const dupes = selectDuplicateProfiles(parseAuthListOutput(DUAL_ACTIVE_OUTPUT), 'nonexistent');
48
+ assert.deepEqual(dupes, []);
49
+ });
50
+
51
+ test('isDuplicateAuthStoreError matches the known pac crash signatures', () => {
52
+ assert.equal(isDuplicateAuthStoreError('FTL | Sequence contains more than one matching element'), true);
53
+ assert.equal(isDuplicateAuthStoreError('Sorry, the app encountered a non-recoverable error'), true);
54
+ assert.equal(isDuplicateAuthStoreError('System.InvalidOperationException: ...'), true);
55
+ assert.equal(isDuplicateAuthStoreError('Error: environment not found'), false);
56
+ assert.equal(isDuplicateAuthStoreError(''), false);
57
+ });