@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,435 @@
1
+ // Step 4 - Auth Setup. Browser-native credential persistence and PAC profile setup.
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, chmodSync } from 'node:fs';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { platform } from 'node:os';
6
+ import { fileURLToPath, pathToFileURL } from 'node:url';
7
+ import { getSecret, hasUsableSecret, recoverSecret, setSecret } from '../lib/dataverse-bridge.mjs';
8
+
9
+ const __dirname = dirname(fileURLToPath(import.meta.url));
10
+ const ROOT_DIR = resolve(__dirname, '..', '..', '..');
11
+ const SHELL = await import(pathToFileURL(resolve(ROOT_DIR, 'wizard', 'lib', 'shell.mjs')).href);
12
+ const CRYPTO = await import(pathToFileURL(resolve(ROOT_DIR, 'wizard', 'lib', 'crypto.mjs')).href);
13
+ const PAC_TARGET = await import(pathToFileURL(resolve(ROOT_DIR, 'wizard', 'lib', 'pac-target.mjs')).href);
14
+
15
+ function hasCommand(name) {
16
+ try {
17
+ execFileSync(platform() === 'win32' ? 'where' : 'which', [name], { stdio: 'ignore' });
18
+ return true;
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ function normalizeUrl(value) {
25
+ return String(value || '').trim().replace(/\/+$/, '').toLowerCase();
26
+ }
27
+
28
+ function warnOnUrlDrift(log, key, stateUrl, credentialUrl) {
29
+ if (!credentialUrl || normalizeUrl(stateUrl) === normalizeUrl(credentialUrl)) return;
30
+ log.warn(`${key} in your credential source (${credentialUrl}) differs from wizard state (${stateUrl}). Update the credential source so downstream scripts stay in sync.`);
31
+ }
32
+
33
+ function formatPacAuthCreateError(profileName, url, output = '') {
34
+ const lines = String(output || '').trim().split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
35
+ const detail = lines.find((line) => /^Error:/i.test(line)) || lines.at(-1) || 'pac auth create failed without a readable error message.';
36
+ return [
37
+ `${profileName} profile failed to create for ${url}.`,
38
+ `PAC reported: ${detail}`,
39
+ '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.',
40
+ ].join('\n');
41
+ }
42
+
43
+ function createPacProfile(log, pac, targetKey, url, appId, secret, tenantId) {
44
+ const profileName = PAC_TARGET.buildPacProfileName({ rootDir: ROOT_DIR, targetKey, profileType: 'spn', url });
45
+ const result = SHELL.runSafeCapture(pac, [
46
+ 'auth', 'create', '--name', profileName, '--environment', url,
47
+ '--applicationId', appId, '--clientSecret', secret, '--tenant', tenantId,
48
+ ]);
49
+ if (!result.ok) throw new Error(formatPacAuthCreateError(profileName, url, result.stderr || result.stdout));
50
+ log.ok(`${profileName} profile created`);
51
+ }
52
+
53
+ function createProfiles(log, pac, credentialValues, state) {
54
+ const tenantId = credentialValues.PP_TENANT_ID;
55
+ const clientId = credentialValues.PP_APP_ID;
56
+ const secret = credentialValues.PP_CLIENT_SECRET;
57
+ const devUrl = String(state.PP_ENV_DEV || '').trim();
58
+ const testUrl = String(state.PP_ENV_TEST || '').trim();
59
+ const prodUrl = String(state.PP_ENV_PROD || '').trim();
60
+ if (!tenantId || !clientId || !secret || !devUrl) {
61
+ throw new Error('Resolved credentials are incomplete. Expected tenant ID, app ID, client secret, and Dev environment URL.');
62
+ }
63
+ warnOnUrlDrift(log, 'PP_ENV_DEV', devUrl, credentialValues.PP_ENV_DEV);
64
+ if (testUrl) warnOnUrlDrift(log, 'PP_ENV_TEST', testUrl, credentialValues.PP_ENV_TEST);
65
+ if (prodUrl) warnOnUrlDrift(log, 'PP_ENV_PROD', prodUrl, credentialValues.PP_ENV_PROD);
66
+ log.info('Creating PAC SPN auth profiles...');
67
+ createPacProfile(log, pac, 'dev', devUrl, clientId, secret, tenantId);
68
+ if (testUrl) createPacProfile(log, pac, 'test', testUrl, clientId, secret, tenantId);
69
+ if (prodUrl) createPacProfile(log, pac, 'prod', prodUrl, clientId, secret, tenantId);
70
+ }
71
+
72
+ function installPreCommitHook(log) {
73
+ const hookDir = join(ROOT_DIR, '.git', 'hooks');
74
+ const hookPath = join(hookDir, 'pre-commit');
75
+ const hookSource = join(ROOT_DIR, 'scripts', 'pre-commit-hook.sh');
76
+ if (!existsSync(join(ROOT_DIR, '.git')) || !existsSync(hookSource)) return;
77
+ if (existsSync(hookPath)) {
78
+ const existing = readFileSync(hookPath, 'utf-8');
79
+ if (!existing.includes('papps-secret-guard')) return;
80
+ }
81
+ try {
82
+ mkdirSync(hookDir, { recursive: true });
83
+ copyFileSync(hookSource, hookPath);
84
+ if (platform() !== 'win32') chmodSync(hookPath, 0o755);
85
+ log.ok('Installed pre-commit hook');
86
+ } catch {
87
+ log.warn('Could not install pre-commit hook. Install scripts/pre-commit-hook.sh manually if desired.');
88
+ }
89
+ }
90
+
91
+ function runLivePac(log, pac, args, opts = {}) {
92
+ return new Promise((resolvePromise) => {
93
+ log.info(`$ ${SHELL.formatCommandForLog(pac, args)}`);
94
+ const child = SHELL.spawnSafe(pac, args, { cwd: ROOT_DIR, stdio: ['ignore', 'pipe', 'pipe'] });
95
+ let settled = false;
96
+ const timeout = opts.timeoutMs
97
+ ? setTimeout(() => {
98
+ if (settled) return;
99
+ settled = true;
100
+ try { child.kill('SIGINT'); } catch { /* best effort */ }
101
+ log.warn(`${args.slice(0, 2).join(' ')} timed out.`);
102
+ resolvePromise(false);
103
+ }, opts.timeoutMs)
104
+ : null;
105
+ timeout?.unref?.();
106
+ child.stdout.setEncoding('utf-8');
107
+ child.stderr.setEncoding('utf-8');
108
+ child.stdout.on('data', (chunk) => log.info(String(chunk).trimEnd()));
109
+ child.stderr.on('data', (chunk) => log.warn(String(chunk).trimEnd()));
110
+ child.on('error', (error) => {
111
+ if (settled) return;
112
+ settled = true;
113
+ if (timeout) clearTimeout(timeout);
114
+ log.fail(`Failed to start PAC: ${error.message}`);
115
+ resolvePromise(false);
116
+ });
117
+ child.on('close', (code) => {
118
+ if (settled) return;
119
+ settled = true;
120
+ if (timeout) clearTimeout(timeout);
121
+ resolvePromise(code === 0);
122
+ });
123
+ });
124
+ }
125
+
126
+ export default {
127
+ meta: {
128
+ number: 4,
129
+ title: 'PAC Auth Profiles',
130
+ description: 'The PAC CLI needs its own auth profiles to communicate with your Power Platform environment. This step creates those profiles using the credentials from Step 3, writes configuration files, and verifies the connection works.',
131
+ canRunInBrowser: true,
132
+ },
133
+
134
+ questions(state) {
135
+ const authProfileType = state.AUTH_PROFILE_TYPE || 'user';
136
+ const isUser = authProfileType === 'user';
137
+ const hasOp = hasCommand('op') || state.HAS_OP === true;
138
+ const defaultAuthMode = state.AUTH_MODE === '1password' && hasOp ? '1password' : 'envlocal';
139
+ const questions = [];
140
+
141
+ // ── User credentials flow ──
142
+ if (isUser) {
143
+ questions.push(
144
+ {
145
+ id: 'USER_SIGN_IN_METHOD',
146
+ type: 'select',
147
+ label: 'Sign-in method',
148
+ help: 'This opens a one-time interactive sign-in so the PAC CLI can run commands (pac code init, pac code push, etc.) against your environment. Your browser or a device code will authenticate you — no passwords are stored.',
149
+ defaultValue: 'deviceCode',
150
+ options: [
151
+ { value: 'deviceCode', label: 'Device code — most reliable' },
152
+ { value: 'browser', label: 'Browser — fastest when callback works' },
153
+ ],
154
+ },
155
+ );
156
+ return questions;
157
+ }
158
+
159
+ // ── Service Principal flow ──
160
+ if (!hasUsableSecret()) {
161
+ questions.push({
162
+ id: 'PP_CLIENT_SECRET',
163
+ type: 'secret',
164
+ label: 'Client secret',
165
+ help: 'Only needed if WizardUX cannot recover it from this session, .env.local, or 1Password.',
166
+ defaultValue: '',
167
+ });
168
+ }
169
+ questions.push(
170
+ {
171
+ id: 'AUTH_MODE',
172
+ type: 'select',
173
+ label: 'Credential storage mode',
174
+ help: 'Controls how the PAC CLI retrieves your Service Principal credentials. 1Password uses op:// references (safe to commit). Local file encrypts the secret on disk (gitignored).',
175
+ defaultValue: defaultAuthMode,
176
+ options: [
177
+ ...(hasOp ? [{ value: '1password', label: '1Password references in .env' }] : []),
178
+ { value: 'envlocal', label: '.env.local file (encrypted secret, gitignored)' },
179
+ ],
180
+ help: state.AUTH_MODE ? `Currently set to ${state.AUTH_MODE} from Step 3.` : undefined,
181
+ },
182
+ {
183
+ id: 'OP_VAULT',
184
+ type: 'text',
185
+ label: '1Password vault name',
186
+ defaultValue: state.OP_VAULT || 'Engineering',
187
+ hideIf: [{ id: 'AUTH_MODE', equals: 'envlocal' }],
188
+ help: state.OP_VAULT ? `Using "${state.OP_VAULT}" from Step 3.` : undefined,
189
+ },
190
+ {
191
+ id: 'OP_ITEM',
192
+ type: 'text',
193
+ label: '1Password item name',
194
+ defaultValue: state.OP_ITEM || `PowerApps CodeApps - ${state.APP_NAME || 'My App'}`,
195
+ hideIf: [{ id: 'AUTH_MODE', equals: 'envlocal' }],
196
+ help: state.OP_ITEM ? `Using "${state.OP_ITEM}" from Step 3.` : undefined,
197
+ },
198
+ {
199
+ id: 'CREATE_USER_PROFILE',
200
+ type: 'confirm',
201
+ label: 'Create the interactive user auth profile now',
202
+ help: 'Required once for pac code run, pac code push, and pac code add-data-source.',
203
+ defaultValue: true,
204
+ },
205
+ {
206
+ id: 'USER_SIGN_IN_METHOD',
207
+ type: 'select',
208
+ label: 'User sign-in method',
209
+ defaultValue: 'deviceCode',
210
+ options: [
211
+ { value: 'deviceCode', label: 'Device code — most reliable' },
212
+ { value: 'browser', label: 'Browser — fastest when callback works' },
213
+ ],
214
+ hideIf: { id: 'CREATE_USER_PROFILE', equals: false },
215
+ },
216
+ );
217
+ return questions;
218
+ },
219
+
220
+ async apply(answers, state, log) {
221
+ const pac = SHELL.pacPath();
222
+ if (!pac) throw new Error('PAC CLI was not found. Install it before creating auth profiles.');
223
+
224
+ const authProfileType = state.AUTH_PROFILE_TYPE || 'user';
225
+ const devUrl = String(state.PP_ENV_DEV || '').trim();
226
+ const testUrl = String(state.PP_ENV_TEST || '').trim();
227
+ const prodUrl = String(state.PP_ENV_PROD || '').trim();
228
+ const dateStr = new Date().toISOString().slice(0, 10);
229
+ const targetKey = state.WIZARD_TARGET_ENV || 'dev';
230
+
231
+ if (!devUrl) throw new Error('Dev environment URL must be set (Step 2) before auth setup.');
232
+
233
+ // ── User credentials flow ──
234
+ if (authProfileType === 'user') {
235
+ const method = answers.USER_SIGN_IN_METHOD === 'browser' ? 'browser' : 'deviceCode';
236
+ const userProfileName = PAC_TARGET.buildPacProfileName({ rootDir: ROOT_DIR, targetKey, profileType: 'user', url: devUrl });
237
+
238
+ log.info('Creating user auth profile via interactive sign-in...');
239
+ const userArgs = ['auth', 'create', '--name', userProfileName, '--environment', devUrl];
240
+ if (method === 'deviceCode') userArgs.push('--deviceCode');
241
+
242
+ let userOk = await runLivePac(log, pac, userArgs, { timeoutMs: method === 'browser' ? 180000 : 0 });
243
+
244
+ // Fallback to device code if browser timed out
245
+ if (!userOk && method === 'browser') {
246
+ log.warn('Browser sign-in did not complete. Falling back to device code...');
247
+ userOk = await runLivePac(log, pac, [
248
+ 'auth', 'create', '--name', userProfileName, '--environment', devUrl, '--deviceCode',
249
+ ]);
250
+ }
251
+
252
+ if (!userOk) throw new Error(`Could not create user profile. Run later: ${pac} auth create --name ${userProfileName} --environment ${devUrl} --deviceCode`);
253
+ log.ok(`User profile ${userProfileName} created`);
254
+
255
+ // Write .env — with op:// references if 1Password, or plain URLs otherwise
256
+ const authMode = state.AUTH_MODE || '';
257
+ if (authMode === '1password' && state.OP_VAULT && state.OP_ITEM) {
258
+ const vault = state.OP_VAULT;
259
+ const itemName = state.OP_ITEM;
260
+ const envContent = [
261
+ '# .env - Safe to commit. Contains 1Password references, not secrets.',
262
+ `# Generated by setup wizard on ${dateStr}`,
263
+ '',
264
+ `PP_ENV_DEV=op://${vault}/${itemName}/env-dev`,
265
+ testUrl ? `PP_ENV_TEST=op://${vault}/${itemName}/env-test` : '',
266
+ prodUrl ? `PP_ENV_PROD=op://${vault}/${itemName}/env-prod` : '',
267
+ '',
268
+ ].filter((line) => line !== '').join('\n');
269
+ writeFileSync(join(ROOT_DIR, '.env'), `${envContent}\n`, 'utf-8');
270
+ log.ok('Wrote .env with 1Password references');
271
+ } else {
272
+ const envContent = [
273
+ '# .env - Environment URLs for Power Platform.',
274
+ `# Generated by setup wizard on ${dateStr}`,
275
+ '',
276
+ `PP_ENV_DEV=${devUrl}`,
277
+ testUrl ? `PP_ENV_TEST=${testUrl}` : '',
278
+ prodUrl ? `PP_ENV_PROD=${prodUrl}` : '',
279
+ '',
280
+ ].filter((line) => line !== '').join('\n');
281
+ writeFileSync(join(ROOT_DIR, '.env'), `${envContent}\n`, 'utf-8');
282
+ log.ok('Wrote .env with environment URLs');
283
+ }
284
+
285
+ installPreCommitHook(log);
286
+
287
+ // Verify the user profile
288
+ const verification = PAC_TARGET.selectAndVerifyPacProfile({
289
+ pac,
290
+ rootDir: ROOT_DIR,
291
+ wizardState: { WIZARD_TARGET_ENV: targetKey, PP_ENV_DEV: devUrl, PP_ENV_TEST: testUrl, PP_ENV_PROD: prodUrl },
292
+ targetKey,
293
+ profileType: 'user',
294
+ credentialValues: null,
295
+ powerConfigPath: join(ROOT_DIR, 'power.config.json'),
296
+ requireCredentialMatch: false,
297
+ requirePowerConfig: false,
298
+ requirePowerConfigTarget: false,
299
+ });
300
+ log.ok(`Verified ${verification.profileName}`);
301
+ log.info(verification.whoInfo.raw.split('\n').map((line) => ` ${line}`).join('\n'));
302
+
303
+ return {
304
+ stateUpdate: { AUTH_MODE: authMode },
305
+ completedStep: 4,
306
+ };
307
+ }
308
+
309
+ // ── Service Principal flow (existing behavior) ──
310
+ const enteredSecret = String(answers.PP_CLIENT_SECRET || '').trim();
311
+ if (enteredSecret) setSecret(enteredSecret);
312
+ const secret = getSecret() || recoverSecret();
313
+ if (!secret) throw new Error('Client secret is required. Enter it in this step or complete Step 3 first.');
314
+
315
+ const authMode = String(answers.AUTH_MODE || state.AUTH_MODE || 'envlocal');
316
+ const tenantId = String(state.PP_TENANT_ID || '').trim();
317
+ const clientId = String(state.PP_APP_ID || '').trim();
318
+
319
+ if (!tenantId || !clientId) throw new Error('Step 3 must be complete with Tenant ID and Client ID before auth setup.');
320
+
321
+ if (authMode === '1password') {
322
+ const vault = String(answers.OP_VAULT || state.OP_VAULT || '').trim();
323
+ const itemName = String(answers.OP_ITEM || state.OP_ITEM || '').trim();
324
+ if (!hasCommand('op')) throw new Error('1Password storage was selected, but op CLI is not available.');
325
+ if (!vault || !itemName) throw new Error('1Password vault and item name are required.');
326
+ const envContent = [
327
+ '# .env - Safe to commit. Contains 1Password references, not secrets.',
328
+ `# Generated by setup wizard on ${dateStr}`,
329
+ '',
330
+ `PP_TENANT_ID=op://${vault}/${itemName}/tenant-id`,
331
+ `PP_APP_ID=op://${vault}/${itemName}/app-id`,
332
+ `PP_CLIENT_SECRET=op://${vault}/${itemName}/client-secret`,
333
+ `PP_ENV_DEV=op://${vault}/${itemName}/env-dev`,
334
+ testUrl ? `PP_ENV_TEST=op://${vault}/${itemName}/env-test` : '',
335
+ prodUrl ? `PP_ENV_PROD=op://${vault}/${itemName}/env-prod` : '',
336
+ '',
337
+ ].filter((line) => line !== '').join('\n');
338
+ writeFileSync(join(ROOT_DIR, '.env'), `${envContent}\n`, 'utf-8');
339
+ log.ok('Wrote .env with 1Password references');
340
+ } else {
341
+ const gitignorePath = join(ROOT_DIR, '.gitignore');
342
+ if (existsSync(gitignorePath)) {
343
+ const gitignore = readFileSync(gitignorePath, 'utf-8');
344
+ if (!gitignore.includes('.env.local')) appendFileSync(gitignorePath, '\n.env.local\n');
345
+ } else {
346
+ writeFileSync(gitignorePath, '.env.local\n', 'utf-8');
347
+ }
348
+ const envLocalContent = [
349
+ '# .env.local - DO NOT commit to Git.',
350
+ `# Generated by setup wizard on ${dateStr}`,
351
+ '',
352
+ `PP_TENANT_ID=${tenantId}`,
353
+ `PP_APP_ID=${clientId}`,
354
+ `PP_CLIENT_SECRET=${CRYPTO.encrypt(secret)}`,
355
+ `PP_ENV_DEV=${devUrl}`,
356
+ testUrl ? `PP_ENV_TEST=${testUrl}` : '',
357
+ prodUrl ? `PP_ENV_PROD=${prodUrl}` : '',
358
+ '',
359
+ ].filter((line) => line !== '').join('\n');
360
+ writeFileSync(join(ROOT_DIR, '.env.local'), `${envLocalContent}\n`, 'utf-8');
361
+ if (platform() !== 'win32') {
362
+ try { chmodSync(join(ROOT_DIR, '.env.local'), 0o600); } catch { /* best effort */ }
363
+ }
364
+ log.ok('Wrote .env.local');
365
+ }
366
+
367
+ const opBin = hasCommand('op') ? 'op' : null;
368
+ const credentialValues = PAC_TARGET.resolveCredentialValues({ rootDir: ROOT_DIR, opBin, source: authMode });
369
+ createProfiles(log, pac, credentialValues, state);
370
+ installPreCommitHook(log);
371
+
372
+ const envTemplate = [
373
+ '# .env.template - Copy to .env.local and fill in values',
374
+ '# DO NOT commit .env.local to Git',
375
+ '',
376
+ 'PP_TENANT_ID=',
377
+ 'PP_APP_ID=',
378
+ 'PP_CLIENT_SECRET=',
379
+ `PP_ENV_DEV=${devUrl}`,
380
+ testUrl ? `PP_ENV_TEST=${testUrl}` : '',
381
+ prodUrl ? `PP_ENV_PROD=${prodUrl}` : '',
382
+ '',
383
+ ].filter((line) => line !== '').join('\n');
384
+ writeFileSync(join(ROOT_DIR, '.env.template'), `${envTemplate}\n`, 'utf-8');
385
+ log.ok('Wrote .env.template');
386
+
387
+ const wizardState = {
388
+ WIZARD_TARGET_ENV: targetKey,
389
+ PP_ENV_DEV: devUrl,
390
+ PP_ENV_TEST: testUrl,
391
+ PP_ENV_PROD: prodUrl,
392
+ };
393
+ const verification = PAC_TARGET.selectAndVerifyPacProfile({
394
+ pac,
395
+ rootDir: ROOT_DIR,
396
+ wizardState,
397
+ targetKey,
398
+ profileType: 'spn',
399
+ credentialValues,
400
+ powerConfigPath: join(ROOT_DIR, 'power.config.json'),
401
+ requireCredentialMatch: true,
402
+ requirePowerConfig: false,
403
+ requirePowerConfigTarget: false,
404
+ });
405
+ log.ok(`Verified ${verification.profileName}`);
406
+ log.info(verification.whoInfo.raw.split('\n').map((line) => ` ${line}`).join('\n'));
407
+
408
+ if (answers.CREATE_USER_PROFILE === true) {
409
+ const userProfileName = PAC_TARGET.buildPacProfileName({ rootDir: ROOT_DIR, targetKey, profileType: 'user', url: devUrl });
410
+ const method = answers.USER_SIGN_IN_METHOD === 'browser' ? 'browser' : 'deviceCode';
411
+ log.warn('PAC code commands require a user auth profile. Starting user sign-in now.');
412
+ const userArgs = ['auth', 'create', '--name', userProfileName, '--environment', devUrl];
413
+ if (method === 'deviceCode') userArgs.push('--deviceCode');
414
+ const userOk = await runLivePac(log, pac, userArgs, { timeoutMs: method === 'browser' ? 180000 : 0 });
415
+ if (userOk) {
416
+ log.ok(`User profile ${userProfileName} created`);
417
+ const spnProfileName = PAC_TARGET.buildPacProfileName({ rootDir: ROOT_DIR, targetKey, profileType: 'spn', url: devUrl });
418
+ SHELL.runSafeCapture(pac, ['auth', 'select', '--name', spnProfileName]);
419
+ log.ok('Switched back to SPN profile for the next setup steps');
420
+ } else {
421
+ log.warn(`Could not create user profile. Run later: ${pac} auth create --name ${userProfileName} --environment ${devUrl} --deviceCode`);
422
+ }
423
+ }
424
+
425
+ return {
426
+ stateUpdate: {
427
+ AUTH_MODE: authMode,
428
+ HAS_OP: opBin !== null,
429
+ OP_VAULT: authMode === '1password' ? answers.OP_VAULT : state.OP_VAULT,
430
+ OP_ITEM: authMode === '1password' ? answers.OP_ITEM : state.OP_ITEM,
431
+ },
432
+ completedStep: 4,
433
+ };
434
+ },
435
+ };