@pacaf/wizard-ux 3.6.3 → 3.6.5
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 +5 -10
- package/package.json +4 -3
- package/server/steps/05-environments.mjs +100 -2
- package/server/steps/05-environments.test.mjs +57 -0
- package/server/steps/06-publisher.mjs +12 -66
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# WizardUX — Browser-Based Setup
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The browser-based setup wizard for PAppsCAFoundations. This is the supported way to scaffold and configure a Code App — run it with `npx @pacaf/wizard-ux@latest`.
|
|
4
4
|
|
|
5
|
-
>
|
|
5
|
+
> WizardUX keeps the full nine-step setup flow in the browser, using server-side runners and live logs for long-running commands.
|
|
6
6
|
|
|
7
7
|
## Quick start
|
|
8
8
|
|
|
@@ -36,7 +36,7 @@ This installs `wizard-ux` dependencies on first run, starts the Fastify server o
|
|
|
36
36
|
| 8. Connectors | Native defer/notes step |
|
|
37
37
|
| 9. Verify & deploy | Full form + live build/deploy output |
|
|
38
38
|
|
|
39
|
-
All nine steps now stay inside WizardUX. App registration values are collected in browser forms, credentials can be read from or synced to 1Password, PAC auth output streams through the live log, scaffolding runs server-side, and verify/deploy can build and push
|
|
39
|
+
All nine steps now stay inside WizardUX. App registration values are collected in browser forms, credentials can be read from or synced to 1Password, PAC auth output streams through the live log, scaffolding runs server-side, and verify/deploy can build and push — all in the browser.
|
|
40
40
|
|
|
41
41
|
## Architecture
|
|
42
42
|
|
|
@@ -59,14 +59,9 @@ wizard-ux/
|
|
|
59
59
|
- Client secrets are held in memory on the server only — never sent back to the browser, never written to logs.
|
|
60
60
|
- Auto-shutdown after 10 minutes of API inactivity.
|
|
61
61
|
|
|
62
|
-
##
|
|
62
|
+
## Architecture note — shared internals
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
1. Start in WizardUX, finish in the CLI.
|
|
66
|
-
2. Start in the CLI, finish in WizardUX.
|
|
67
|
-
3. Use WizardUX as a state inspector while the CLI runs in another terminal.
|
|
68
|
-
|
|
69
|
-
`.wizard-state.json` is the single source of truth for both.
|
|
64
|
+
WizardUX is the user-facing entry point. Internally it reuses the same `wizard/lib/*` helpers from the [`@pacaf/wizard`](../wizard) package (a workspace dependency), so the setup pipeline logic lives in one place. `.wizard-state.json` is the single source of truth.
|
|
70
65
|
|
|
71
66
|
## Build for production
|
|
72
67
|
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pacaf/wizard-ux",
|
|
3
|
-
"version": "3.6.
|
|
3
|
+
"version": "3.6.5",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
|
-
"description": "Browser-based setup wizard for Power Apps Code Apps
|
|
6
|
+
"description": "Browser-based setup wizard for Power Apps Code Apps.",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
@@ -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.
|
|
41
|
+
"@pacaf/wizard": "3.4.8"
|
|
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
|
-
|
|
260
|
-
|
|
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
|
+
});
|
|
@@ -8,7 +8,6 @@ import { parsePacTabularRows } from '../lib/pac-parse.mjs';
|
|
|
8
8
|
|
|
9
9
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
10
|
const PACKAGE_DIR = resolve(__dirname, '..', '..', '..');
|
|
11
|
-
const VALIDATE = await import(pathToFileURL(resolve(PACKAGE_DIR, 'wizard', 'lib', 'validate.mjs')).href);
|
|
12
11
|
const SHELL = await import(pathToFileURL(resolve(PACKAGE_DIR, 'wizard', 'lib', 'shell.mjs')).href);
|
|
13
12
|
const PAC_TARGET = await import(pathToFileURL(resolve(PACKAGE_DIR, 'wizard', 'lib', 'pac-target.mjs')).href);
|
|
14
13
|
|
|
@@ -278,7 +277,10 @@ export default {
|
|
|
278
277
|
options: [
|
|
279
278
|
...solutions,
|
|
280
279
|
{ value: PASTE_URL, label: 'Paste solution URL from Maker Portal' },
|
|
281
|
-
|
|
280
|
+
// "Create new solution" is only offered for service-principal auth, which
|
|
281
|
+
// can create solutions via the Dataverse API. User auth cannot, so they
|
|
282
|
+
// create the solution in the Maker Portal and paste its URL instead.
|
|
283
|
+
...(!isUserAuth ? [{ value: CREATE_NEW, label: '+ Create new solution' }] : []),
|
|
282
284
|
],
|
|
283
285
|
hideIf: { id: '__resume', equals: true },
|
|
284
286
|
});
|
|
@@ -340,47 +342,10 @@ export default {
|
|
|
340
342
|
}
|
|
341
343
|
}
|
|
342
344
|
|
|
343
|
-
//
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
type: 'confirm',
|
|
348
|
-
label: 'I have created the solution in the Maker Portal',
|
|
349
|
-
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.`,
|
|
350
|
-
defaultValue: false,
|
|
351
|
-
showIf: { id: 'SOLUTION_SELECTION', equals: CREATE_NEW },
|
|
352
|
-
why: [
|
|
353
|
-
'Create your solution in the Maker Portal:',
|
|
354
|
-
`1. Open ${makerLink}`,
|
|
355
|
-
'2. Click + New Solution',
|
|
356
|
-
'3. Enter the display name',
|
|
357
|
-
'4. Select (or create) a publisher',
|
|
358
|
-
'5. Save the solution',
|
|
359
|
-
'',
|
|
360
|
-
'Then come back here and either:',
|
|
361
|
-
'• Switch the dropdown to "Paste solution URL" and paste the URL (recommended)',
|
|
362
|
-
'• OR toggle this confirmation and enter the details manually below',
|
|
363
|
-
].join('\n'),
|
|
364
|
-
});
|
|
365
|
-
|
|
366
|
-
questions.push({
|
|
367
|
-
id: 'MANUAL_SOLUTION_UNIQUE_NAME',
|
|
368
|
-
type: 'text',
|
|
369
|
-
label: 'Solution unique name',
|
|
370
|
-
help: 'The internal name (no spaces). Find this in the solution details in the Maker Portal.',
|
|
371
|
-
defaultValue: '',
|
|
372
|
-
showIf: { id: 'SOLUTION_CREATED_MANUALLY', equals: true },
|
|
373
|
-
});
|
|
374
|
-
|
|
375
|
-
questions.push({
|
|
376
|
-
id: 'MANUAL_PUBLISHER_PREFIX',
|
|
377
|
-
type: 'text',
|
|
378
|
-
label: 'Publisher prefix',
|
|
379
|
-
help: '2–8 lowercase letters. Find this in the Maker Portal under the publisher you selected.',
|
|
380
|
-
defaultValue: state.PUBLISHER_PREFIX || '',
|
|
381
|
-
showIf: { id: 'SOLUTION_CREATED_MANUALLY', equals: true },
|
|
382
|
-
});
|
|
383
|
-
}
|
|
345
|
+
// Note: "+ Create new solution" is only offered to service-principal auth
|
|
346
|
+
// (see SOLUTION_SELECTION options above). User auth creates the solution in
|
|
347
|
+
// the Maker Portal and pastes the URL back via the PASTE_URL path, so no
|
|
348
|
+
// manual create-and-enter-details questions are needed here.
|
|
384
349
|
|
|
385
350
|
return questions;
|
|
386
351
|
},
|
|
@@ -453,34 +418,15 @@ export default {
|
|
|
453
418
|
return { stateUpdate: buildStateUpdate(sol), completedStep: 7 };
|
|
454
419
|
}
|
|
455
420
|
|
|
456
|
-
// ── Create new ──
|
|
421
|
+
// ── Create new (service-principal only) ──
|
|
457
422
|
const solName = String(answers.NEW_SOLUTION_NAME || '').trim();
|
|
458
423
|
if (!solName) throw new Error('Solution display name is required.');
|
|
459
424
|
const solUnique = solName.replace(/[\s\-]+/g, '');
|
|
460
425
|
|
|
461
426
|
if (isUserAuth) {
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
const manualUnique = String(answers.MANUAL_SOLUTION_UNIQUE_NAME || '').trim();
|
|
466
|
-
const manualPrefix = String(answers.MANUAL_PUBLISHER_PREFIX || '').trim();
|
|
467
|
-
if (!manualUnique) throw new Error('Solution unique name is required.');
|
|
468
|
-
if (!VALIDATE.isValidPrefix(manualPrefix)) throw new Error('Publisher prefix must be 2–8 lowercase letters.');
|
|
469
|
-
log.ok(`Solution: "${solName}" (${manualUnique})`);
|
|
470
|
-
log.ok(`Publisher prefix: ${manualPrefix}`);
|
|
471
|
-
return {
|
|
472
|
-
stateUpdate: {
|
|
473
|
-
SOLUTION_ID: '',
|
|
474
|
-
SOLUTION_UNIQUE_NAME: manualUnique,
|
|
475
|
-
SOLUTION_DISPLAY_NAME: solName,
|
|
476
|
-
PUBLISHER_ID: '',
|
|
477
|
-
PUBLISHER_NAME: '',
|
|
478
|
-
PUBLISHER_DISPLAY_NAME: '',
|
|
479
|
-
PUBLISHER_PREFIX: manualPrefix,
|
|
480
|
-
CHOICE_VALUE_PREFIX: '',
|
|
481
|
-
},
|
|
482
|
-
completedStep: 7,
|
|
483
|
-
};
|
|
427
|
+
// "Create new solution" isn't offered for user auth — it can't create a
|
|
428
|
+
// solution via the API. Direct the user to the Maker Portal + paste flow.
|
|
429
|
+
throw new Error('Creating a new solution requires service-principal auth. Create the solution in the Maker Portal, then choose "Paste solution URL".');
|
|
484
430
|
}
|
|
485
431
|
|
|
486
432
|
// SPN: create via API
|