@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,438 @@
1
+ // Step 8 - Connectors. Browser-native connector selection and optional data-source binding.
2
+ import { existsSync } from 'node:fs';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { fileURLToPath, pathToFileURL } from 'node:url';
6
+ import { dvGet, dvPost, hasUsableSecret, setSecret } from '../lib/dataverse-bridge.mjs';
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const ROOT_DIR = resolve(__dirname, '..', '..', '..');
10
+ const VALIDATE = await import(pathToFileURL(resolve(ROOT_DIR, 'wizard', 'lib', 'validate.mjs')).href);
11
+ const CONNECTIONS = await import(pathToFileURL(resolve(ROOT_DIR, 'wizard', 'lib', 'connection-discovery.mjs')).href);
12
+ const SHELL = await import(pathToFileURL(resolve(ROOT_DIR, 'wizard', 'lib', 'shell.mjs')).href);
13
+ const PAC_TARGET = await import(pathToFileURL(resolve(ROOT_DIR, 'wizard', 'lib', 'pac-target.mjs')).href);
14
+
15
+ const CREATE_MANUAL = '__manual__';
16
+ const SKIP_CONNECTION = '__skip__';
17
+ const BAP_PERMISSION_RE = /does not have permission to access|checkAccess|HTTP error status: 403/i;
18
+
19
+ const COMMON_CONNECTORS = [
20
+ { apiId: 'shared_commondataserviceforapps', name: 'Dataverse' },
21
+ { apiId: 'shared_office365users', name: 'Office 365 Users' },
22
+ { apiId: 'shared_sharepointonline', name: 'SharePoint' },
23
+ { apiId: 'shared_office365', name: 'Office 365 Outlook' },
24
+ { apiId: 'shared_teams', name: 'Microsoft Teams' },
25
+ { apiId: 'shared_sql', name: 'SQL Server' },
26
+ { apiId: 'shared_azureblob', name: 'Azure Blob Storage' },
27
+ ];
28
+
29
+ function hasCommand(name) {
30
+ try {
31
+ execFileSync(process.platform === 'win32' ? 'where' : 'which', [name], { stdio: 'ignore' });
32
+ return true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ function normalizeList(value) {
39
+ if (Array.isArray(value)) return value.map((entry) => String(entry).trim()).filter(Boolean);
40
+ return String(value || '').split(/[\n,]/).map((entry) => entry.trim()).filter(Boolean);
41
+ }
42
+
43
+ function connectionQuestionId(apiId) {
44
+ return `CONNECTION_${apiId.replace(/[^a-z0-9_]/gi, '_')}`;
45
+ }
46
+
47
+ function connectorToggleId(apiId) {
48
+ return `CONNECTOR_${apiId.replace(/[^a-z0-9_]/gi, '_')}`;
49
+ }
50
+
51
+ function manualQuestionId(apiId) {
52
+ return `${connectionQuestionId(apiId)}_MANUAL`;
53
+ }
54
+
55
+ async function listConnectionReferences(prefix) {
56
+ if (!prefix) return [];
57
+ const data = await dvGet(
58
+ `connectionreferences?$filter=startswith(connectionreferencelogicalname,'${prefix}_')` +
59
+ '&$select=connectionreferenceid,connectionreferencelogicalname,connectorid,connectionreferencedisplayname',
60
+ );
61
+ return data.value || [];
62
+ }
63
+
64
+ function connectorApiIdFromReference(reference) {
65
+ return String(reference.connectorid || '').split('/').pop() || '';
66
+ }
67
+
68
+ function connectorLabel(connector, existingApiIds) {
69
+ return existingApiIds.has(connector.apiId) ? `${connector.name} (connection reference exists)` : connector.name;
70
+ }
71
+
72
+ function connectorGroup(connector) {
73
+ return {
74
+ id: `connector-${connector.apiId}`,
75
+ label: connector.name,
76
+ help: 'Choose whether this app should bind this connector, then pick its environment connection when data-source registration is enabled.',
77
+ };
78
+ }
79
+
80
+ function discoverConnections(pac, apiId) {
81
+ if (!pac) return [];
82
+ try {
83
+ return CONNECTIONS.discoverConnectionsForApiId(pac, apiId);
84
+ } catch {
85
+ return [];
86
+ }
87
+ }
88
+
89
+ function connectionOptions(connections, savedConnectionId = '') {
90
+ const savedOption = savedConnectionId && !connections.some((entry) => entry.connectionId === savedConnectionId)
91
+ ? [{ value: savedConnectionId, label: 'Saved connection from prior setup' }]
92
+ : [];
93
+ return [
94
+ ...savedOption,
95
+ ...connections.map((entry) => ({
96
+ value: entry.connectionId,
97
+ label: entry.displayName || 'Environment connection',
98
+ })),
99
+ { value: CREATE_MANUAL, label: 'Paste a connection URL or ID' },
100
+ { value: SKIP_CONNECTION, label: 'Create connection reference only' },
101
+ ];
102
+ }
103
+
104
+ function resolveCredentialValues(state) {
105
+ return PAC_TARGET.resolveCredentialValues({
106
+ rootDir: ROOT_DIR,
107
+ opBin: process.env.OP_BIN || (hasCommand('op') ? 'op' : null),
108
+ source: state.AUTH_MODE || 'auto',
109
+ });
110
+ }
111
+
112
+ function verifyUserProfile(pac, projectDir, state, credentialValues) {
113
+ return PAC_TARGET.selectAndVerifyPacProfile({
114
+ pac,
115
+ rootDir: ROOT_DIR,
116
+ wizardState: {
117
+ WIZARD_TARGET_ENV: state.WIZARD_TARGET_ENV || 'dev',
118
+ PP_ENV_DEV: state.PP_ENV_DEV || '',
119
+ PP_ENV_TEST: state.PP_ENV_TEST || '',
120
+ PP_ENV_PROD: state.PP_ENV_PROD || '',
121
+ },
122
+ targetKey: state.WIZARD_TARGET_ENV || 'dev',
123
+ profileType: 'user',
124
+ credentialValues,
125
+ powerConfigPath: join(projectDir, 'power.config.json'),
126
+ requireCredentialMatch: credentialValues !== null,
127
+ requirePowerConfig: true,
128
+ requirePowerConfigTarget: true,
129
+ });
130
+ }
131
+
132
+ function runFileCapture(log, file, args, opts = {}) {
133
+ return new Promise((resolvePromise) => {
134
+ log.info(`$ ${SHELL.formatCommandForLog(file, args)}`);
135
+ const child = SHELL.spawnSafe(file, args, { cwd: opts.cwd || ROOT_DIR, stdio: ['ignore', 'pipe', 'pipe'] });
136
+ let stdout = '';
137
+ let stderr = '';
138
+ child.stdout.setEncoding('utf-8');
139
+ child.stderr.setEncoding('utf-8');
140
+ child.stdout.on('data', (chunk) => {
141
+ stdout += String(chunk);
142
+ log.info(String(chunk).trimEnd());
143
+ });
144
+ child.stderr.on('data', (chunk) => {
145
+ stderr += String(chunk);
146
+ log.warn(String(chunk).trimEnd());
147
+ });
148
+ child.on('error', (error) => {
149
+ log.fail(`Failed to start ${file}: ${error.message}`);
150
+ resolvePromise({ ok: false, stdout, stderr: `${stderr}\n${error.message}` });
151
+ });
152
+ child.on('close', (code) => resolvePromise({ ok: code === 0, stdout, stderr }));
153
+ });
154
+ }
155
+
156
+ function parseCustomConnectors(rawEntries) {
157
+ const connectors = [];
158
+ const seen = new Set();
159
+
160
+ for (const raw of rawEntries) {
161
+ const parsed = VALIDATE.parseConnectionUrl(raw);
162
+ const apiId = parsed.apiId || VALIDATE.extractConnectorApiId(raw);
163
+ if (!apiId || seen.has(apiId)) continue;
164
+ seen.add(apiId);
165
+ connectors.push({
166
+ apiId,
167
+ name: VALIDATE.humanizeConnectorApiId(apiId),
168
+ connectionId: parsed.connectionId || '',
169
+ raw,
170
+ });
171
+ }
172
+
173
+ return connectors;
174
+ }
175
+
176
+ async function createConnectionReference(log, connector, prefix, solutionName, existingRefs) {
177
+ const logicalName = `${prefix}_${connector.apiId}`;
178
+ const existing = existingRefs.find((reference) => reference.connectionreferencelogicalname === logicalName);
179
+ if (existing) {
180
+ log.ok(`${connector.name} - connection reference already exists`);
181
+ return existing;
182
+ }
183
+
184
+ const result = await dvPost('connectionreferences', {
185
+ connectionreferencedisplayname: connector.name,
186
+ connectionreferencelogicalname: logicalName,
187
+ connectorid: `/providers/Microsoft.PowerApps/apis/${connector.apiId}`,
188
+ }, { solutionName });
189
+ log.ok(`${connector.name} - connection reference created`);
190
+ return result;
191
+ }
192
+
193
+ function connectionIdFromAnswers(answers, connector) {
194
+ const selected = String(answers[connectionQuestionId(connector.apiId)] || '').trim();
195
+ if (selected && selected !== CREATE_MANUAL && selected !== SKIP_CONNECTION) return selected;
196
+ if (selected === CREATE_MANUAL) return VALIDATE.extractConnectionId(answers[manualQuestionId(connector.apiId)]);
197
+ return connector.connectionId || '';
198
+ }
199
+
200
+ export default {
201
+ meta: {
202
+ number: 8,
203
+ title: 'Bind Connectors',
204
+ description: 'Choose connector references and optionally register connector data sources for the Code App.',
205
+ canRunInBrowser: true,
206
+ optional: true,
207
+ needsSecret: true,
208
+ },
209
+
210
+ async questions(state) {
211
+ const questions = [];
212
+ const prefix = state.PUBLISHER_PREFIX || '';
213
+ const pac = SHELL.pacPath();
214
+ const isUserAuth = (state.AUTH_PROFILE_TYPE || 'user') === 'user';
215
+ const hasSecret = !isUserAuth && hasUsableSecret();
216
+ let existingRefs = [];
217
+
218
+ if (hasSecret) {
219
+ try {
220
+ existingRefs = await listConnectionReferences(prefix);
221
+ } catch (err) {
222
+ // Missing discovery should not block users from choosing connector intent.
223
+ }
224
+ }
225
+
226
+ const existingApiIds = new Set(existingRefs.map(connectorApiIdFromReference).filter(Boolean));
227
+ const savedApiIds = Array.isArray(state.CONNECTOR_API_IDS) ? state.CONNECTOR_API_IDS : [];
228
+ const selectedDefaults = Array.from(new Set([...savedApiIds, ...existingApiIds]));
229
+
230
+ questions.push({
231
+ id: 'DEFER_CONNECTORS',
232
+ type: 'confirm',
233
+ label: 'Keep real connector binding deferred',
234
+ help: 'Recommended until the prototype and planning payload are stable. Turn this off to choose connectors now.',
235
+ defaultValue: state.CONNECTOR_BINDING_DEFERRED !== false,
236
+ });
237
+
238
+ // Secret is never asked here — it's recovered from .env.local or 1Password.
239
+ // If recovery fails, apply() will direct the user back to Step 3.
240
+
241
+ questions.push({
242
+ id: 'REGISTER_DATA_SOURCES',
243
+ type: 'confirm',
244
+ label: 'Register selected non-Dataverse connectors as Code App data sources now',
245
+ help: 'Requires the user PAC auth profile from Step 4 and power.config.json from Step 7. Leave off to create solution connection references only.',
246
+ defaultValue: false,
247
+ showIf: { id: 'DEFER_CONNECTORS', equals: false },
248
+ });
249
+
250
+ for (const connector of COMMON_CONNECTORS) {
251
+ const toggleId = connectorToggleId(connector.apiId);
252
+ const referenceExists = existingApiIds.has(connector.apiId);
253
+ const group = connectorGroup(connector);
254
+ questions.push({
255
+ id: toggleId,
256
+ type: 'confirm',
257
+ label: `Set up ${connectorLabel(connector, existingApiIds)}`,
258
+ help: referenceExists
259
+ ? 'This connector already has a connection reference in the selected solution. Leave on to keep it in this app setup.'
260
+ : 'Creates a solution connection reference for this connector when you save Step 8.',
261
+ defaultValue: selectedDefaults.includes(connector.apiId),
262
+ group,
263
+ showIf: { id: 'DEFER_CONNECTORS', equals: false },
264
+ });
265
+
266
+ if (connector.apiId === 'shared_commondataserviceforapps') continue;
267
+
268
+ const discovered = discoverConnections(pac, connector.apiId);
269
+ const savedConnectionId = state.CONNECTOR_CONNECTION_IDS?.[connector.apiId] || '';
270
+ const defaultValue = savedConnectionId || (discovered.length === 1 ? discovered[0].connectionId : SKIP_CONNECTION);
271
+ questions.push({
272
+ id: connectionQuestionId(connector.apiId),
273
+ type: 'select',
274
+ label: `${connector.name} connection`,
275
+ help: discovered.length > 0
276
+ ? 'Choose an existing environment connection, paste one manually, or create only the connection reference for now.'
277
+ : 'No existing environment connection was discovered. Paste a connection URL/ID, or create only the connection reference for now.',
278
+ defaultValue,
279
+ options: connectionOptions(discovered, savedConnectionId),
280
+ group,
281
+ showIf: [
282
+ { id: 'DEFER_CONNECTORS', equals: false },
283
+ { id: 'REGISTER_DATA_SOURCES', equals: true },
284
+ { id: toggleId, equals: true },
285
+ ],
286
+ });
287
+ questions.push({
288
+ id: manualQuestionId(connector.apiId),
289
+ type: 'text',
290
+ label: `${connector.name} connection URL or ID`,
291
+ help: 'Paste the full Maker Portal connection details URL or connection ID.',
292
+ defaultValue: '',
293
+ group,
294
+ showIf: [
295
+ { id: 'DEFER_CONNECTORS', equals: false },
296
+ { id: 'REGISTER_DATA_SOURCES', equals: true },
297
+ { id: toggleId, equals: true },
298
+ { id: connectionQuestionId(connector.apiId), equals: CREATE_MANUAL },
299
+ ],
300
+ });
301
+ }
302
+
303
+ questions.push({
304
+ id: 'CUSTOM_CONNECTORS',
305
+ type: 'multiselect',
306
+ label: 'Other connector URLs or apiIds',
307
+ help: 'Optional. Paste uncommon/custom connector apiIds or full Maker Portal connection URLs. Full URLs let WizardUX capture both the connector and connection ID.',
308
+ defaultValue: Array.isArray(state.CUSTOM_CONNECTORS) ? state.CUSTOM_CONNECTORS : [],
309
+ showIf: { id: 'DEFER_CONNECTORS', equals: false },
310
+ });
311
+
312
+ return questions;
313
+ },
314
+
315
+ async apply(answers, state, log) {
316
+ const selectedCommon = COMMON_CONNECTORS
317
+ .filter((connector) => answers[connectorToggleId(connector.apiId)] === true)
318
+ .map((connector) => connector.apiId);
319
+ const customRaw = normalizeList(answers.CUSTOM_CONNECTORS);
320
+
321
+ if (answers.DEFER_CONNECTORS !== false) {
322
+ log.ok('Connector binding deferred until prototype validation is complete');
323
+ if (customRaw.length > 0) log.info(`Recorded connector notes: ${customRaw.join(', ')}`);
324
+ return {
325
+ stateUpdate: {
326
+ CONNECTOR_BINDING_DEFERRED: true,
327
+ CUSTOM_CONNECTORS: customRaw,
328
+ },
329
+ completedStep: 8,
330
+ };
331
+ }
332
+
333
+ const isUserAuth = (state.AUTH_PROFILE_TYPE || 'user') === 'user';
334
+ if (!isUserAuth && !hasUsableSecret()) {
335
+ throw new Error('Client secret could not be recovered from .env.local or 1Password. Go back to Step 3 (App Registration) and re-enter your credentials.');
336
+ }
337
+
338
+ const connectorMap = new Map(COMMON_CONNECTORS.map((connector) => [connector.apiId, { ...connector }]));
339
+ const customConnectors = parseCustomConnectors(customRaw);
340
+ for (const connector of customConnectors) connectorMap.set(connector.apiId, connector);
341
+
342
+ const selectedApiIds = Array.from(new Set([
343
+ ...selectedCommon,
344
+ ...customConnectors.map((connector) => connector.apiId),
345
+ ])).filter((apiId) => connectorMap.has(apiId));
346
+
347
+ if (selectedApiIds.length === 0) {
348
+ log.warn('No connectors selected. Nothing to bind.');
349
+ return {
350
+ stateUpdate: {
351
+ CONNECTOR_BINDING_DEFERRED: false,
352
+ CONNECTOR_API_IDS: [],
353
+ CUSTOM_CONNECTORS: customRaw,
354
+ },
355
+ completedStep: 8,
356
+ };
357
+ }
358
+
359
+ const prefix = state.PUBLISHER_PREFIX || '';
360
+ const solutionName = state.SOLUTION_UNIQUE_NAME || '';
361
+ if (!prefix) throw new Error('Publisher prefix is missing. Complete Step 5 before binding connectors.');
362
+ if (!solutionName) throw new Error('Solution unique name is missing. Complete Step 6 before binding connectors.');
363
+
364
+ log.info('Checking existing connection references...');
365
+ let existingRefs = [];
366
+ if (!isUserAuth) {
367
+ try {
368
+ existingRefs = await listConnectionReferences(prefix);
369
+ } catch (err) {
370
+ log.warn(`Could not query connection references: ${err.message}`);
371
+ }
372
+ }
373
+
374
+ if (isUserAuth) {
375
+ log.warn('User auth does not support automated connection reference creation via the Dataverse API.');
376
+ log.info('Create connection references manually in the Maker Portal:');
377
+ log.info(` 1. Go to make.powerapps.com → your Dev environment → Solutions → ${solutionName}`);
378
+ log.info(' 2. Add existing → Connection reference → for each connector');
379
+ log.info(' 3. Or create them during pac code add-data-source.');
380
+ } else {
381
+ log.info('Creating missing connection references in the selected solution...');
382
+ for (const apiId of selectedApiIds) {
383
+ const connector = connectorMap.get(apiId);
384
+ try {
385
+ const created = await createConnectionReference(log, connector, prefix, solutionName, existingRefs);
386
+ existingRefs.push(created);
387
+ } catch (err) {
388
+ if (/already exists|database constraint/i.test(err.message)) log.ok(`${connector.name} - connection reference already exists`);
389
+ else log.warn(`${connector.name} - connection reference failed: ${err.message}`);
390
+ }
391
+ }
392
+ }
393
+
394
+ const connectionIds = {};
395
+ if (answers.REGISTER_DATA_SOURCES === true) {
396
+ const pac = SHELL.pacPath();
397
+ if (!pac) throw new Error('PAC CLI was not found. Install PAC CLI before registering data sources.');
398
+
399
+ const projectDir = resolve(String(state.PROJECT_DIR || ROOT_DIR));
400
+ if (!existsSync(join(projectDir, 'power.config.json'))) {
401
+ throw new Error(`power.config.json was not found in ${projectDir}. Complete Step 7 before registering data sources.`);
402
+ }
403
+
404
+ const credentialValues = isUserAuth ? null : resolveCredentialValues(state);
405
+ const verification = verifyUserProfile(pac, projectDir, state, credentialValues);
406
+ log.ok(`Verified user profile ${verification.profileName}`);
407
+
408
+ for (const apiId of selectedApiIds.filter((id) => id !== 'shared_commondataserviceforapps')) {
409
+ const connector = connectorMap.get(apiId);
410
+ const connectionId = connectionIdFromAnswers(answers, connector);
411
+ if (!connectionId) {
412
+ log.info(`${connector.name} - data-source registration skipped. Add later with: pac code add-data-source -a ${apiId} -c <connection_id>`);
413
+ continue;
414
+ }
415
+
416
+ connectionIds[apiId] = connectionId;
417
+ const result = await runFileCapture(log, pac, ['code', 'add-data-source', '-a', apiId, '-c', connectionId], { cwd: projectDir });
418
+ if (!result.ok || BAP_PERMISSION_RE.test(result.stderr)) {
419
+ log.warn(`${connector.name} - data-source registration failed. You can retry later with pac code add-data-source -a ${apiId} -c ${connectionId}`);
420
+ } else {
421
+ log.ok(`${connector.name} - data source registered`);
422
+ }
423
+ }
424
+ } else {
425
+ log.info('Data-source registration skipped. Connection references were handled in the solution.');
426
+ }
427
+
428
+ return {
429
+ stateUpdate: {
430
+ CONNECTOR_BINDING_DEFERRED: false,
431
+ CONNECTOR_API_IDS: selectedApiIds,
432
+ CUSTOM_CONNECTORS: customRaw,
433
+ CONNECTOR_CONNECTION_IDS: connectionIds,
434
+ },
435
+ completedStep: 8,
436
+ };
437
+ },
438
+ };
@@ -0,0 +1,212 @@
1
+ // Step 9 - Verify & Deploy. Browser-native build and optional pac code push.
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { spawn, execFileSync } from 'node:child_process';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { fileURLToPath, pathToFileURL } from 'node:url';
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const ROOT_DIR = resolve(__dirname, '..', '..', '..');
9
+ const SHELL = await import(pathToFileURL(resolve(ROOT_DIR, 'wizard', 'lib', 'shell.mjs')).href);
10
+ const PAC_TARGET = await import(pathToFileURL(resolve(ROOT_DIR, 'wizard', 'lib', 'pac-target.mjs')).href);
11
+
12
+ function hasCommand(name) {
13
+ try {
14
+ execFileSync(process.platform === 'win32' ? 'where' : 'which', [name], { stdio: 'ignore' });
15
+ return true;
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ function runCommand(log, command, opts = {}) {
22
+ return new Promise((resolvePromise) => {
23
+ log.info(`$ ${command}`);
24
+ const child = process.platform === 'win32'
25
+ ? spawn(process.env.COMSPEC || 'cmd.exe', ['/d', '/s', '/c', command], { cwd: opts.cwd || ROOT_DIR, stdio: ['ignore', 'pipe', 'pipe'] })
26
+ : spawn('sh', ['-c', command], { cwd: opts.cwd || ROOT_DIR, stdio: ['ignore', 'pipe', 'pipe'] });
27
+ child.stdout.setEncoding('utf-8');
28
+ child.stderr.setEncoding('utf-8');
29
+ child.stdout.on('data', (chunk) => log.info(String(chunk).trimEnd()));
30
+ child.stderr.on('data', (chunk) => log.warn(String(chunk).trimEnd()));
31
+ child.on('error', (error) => {
32
+ log.fail(`Failed to start command: ${error.message}`);
33
+ resolvePromise(false);
34
+ });
35
+ child.on('close', (code) => resolvePromise(code === 0));
36
+ });
37
+ }
38
+
39
+ function runFile(log, file, args, opts = {}) {
40
+ return new Promise((resolvePromise) => {
41
+ log.info(`$ ${SHELL.formatCommandForLog(file, args)}`);
42
+ const child = SHELL.spawnSafe(file, args, { cwd: opts.cwd || ROOT_DIR, stdio: ['ignore', 'pipe', 'pipe'] });
43
+ child.stdout.setEncoding('utf-8');
44
+ child.stderr.setEncoding('utf-8');
45
+ child.stdout.on('data', (chunk) => log.info(String(chunk).trimEnd()));
46
+ child.stderr.on('data', (chunk) => log.warn(String(chunk).trimEnd()));
47
+ child.on('error', (error) => {
48
+ log.fail(`Failed to start ${file}: ${error.message}`);
49
+ resolvePromise(false);
50
+ });
51
+ child.on('close', (code) => resolvePromise(code === 0));
52
+ });
53
+ }
54
+
55
+ function runFileCapture(log, file, args, opts = {}) {
56
+ return new Promise((resolvePromise) => {
57
+ log.info(`$ ${SHELL.formatCommandForLog(file, args)}`);
58
+ const child = SHELL.spawnSafe(file, args, { cwd: opts.cwd || ROOT_DIR, stdio: ['ignore', 'pipe', 'pipe'] });
59
+ let stdout = '';
60
+ let stderr = '';
61
+ child.stdout.setEncoding('utf-8');
62
+ child.stderr.setEncoding('utf-8');
63
+ child.stdout.on('data', (chunk) => {
64
+ stdout += String(chunk);
65
+ log.info(String(chunk).trimEnd());
66
+ });
67
+ child.stderr.on('data', (chunk) => {
68
+ stderr += String(chunk);
69
+ log.warn(String(chunk).trimEnd());
70
+ });
71
+ child.on('error', (error) => {
72
+ stderr += `\n${error.message}`;
73
+ log.fail(`Failed to start ${file}: ${error.message}`);
74
+ resolvePromise({ ok: false, stdout, stderr });
75
+ });
76
+ child.on('close', (code) => resolvePromise({ ok: code === 0, stdout, stderr }));
77
+ });
78
+ }
79
+
80
+ const PAC_HTTP_ERROR_RE = /HTTP error status:\s*[45]\d\d/i;
81
+
82
+ function resolveCredentialValues(state) {
83
+ return PAC_TARGET.resolveCredentialValues({
84
+ rootDir: ROOT_DIR,
85
+ opBin: process.env.OP_BIN || (hasCommand('op') ? 'op' : null),
86
+ source: state.AUTH_MODE || 'auto',
87
+ });
88
+ }
89
+
90
+ function verifyUserProfile(pac, projectDir, state, credentialValues) {
91
+ return PAC_TARGET.selectAndVerifyPacProfile({
92
+ pac,
93
+ rootDir: ROOT_DIR,
94
+ wizardState: {
95
+ WIZARD_TARGET_ENV: state.WIZARD_TARGET_ENV || 'dev',
96
+ PP_ENV_DEV: state.PP_ENV_DEV || '',
97
+ PP_ENV_TEST: state.PP_ENV_TEST || '',
98
+ PP_ENV_PROD: state.PP_ENV_PROD || '',
99
+ },
100
+ targetKey: state.WIZARD_TARGET_ENV || 'dev',
101
+ profileType: 'user',
102
+ credentialValues,
103
+ powerConfigPath: join(projectDir, 'power.config.json'),
104
+ requireCredentialMatch: credentialValues !== null,
105
+ requirePowerConfig: true,
106
+ requirePowerConfigTarget: true,
107
+ });
108
+ }
109
+
110
+ async function addAppToSolution(log, pac, projectDir, solutionName) {
111
+ if (!solutionName) return;
112
+ let appId = '';
113
+ try {
114
+ appId = JSON.parse(readFileSync(join(projectDir, 'power.config.json'), 'utf-8')).appId || '';
115
+ } catch {
116
+ // ignore
117
+ }
118
+ if (!appId) {
119
+ log.warn('Could not read appId from power.config.json; skipping solution component registration.');
120
+ return;
121
+ }
122
+ const ok = await runFile(log, pac, ['solution', 'add-solution-component', '-sn', solutionName, '-c', appId, '-ct', '300'], { cwd: projectDir });
123
+ if (ok) log.ok(`App added to solution ${solutionName}`);
124
+ else log.warn(`Could not add app to solution ${solutionName}. It may already be present, or you can add it manually.`);
125
+ }
126
+
127
+ export default {
128
+ meta: {
129
+ number: 9,
130
+ title: 'Verify & Deploy',
131
+ description: 'Build the project, optionally push it to Power Platform, and surface the live app URL when available.',
132
+ canRunInBrowser: true,
133
+ },
134
+
135
+ questions() {
136
+ return [
137
+ {
138
+ id: 'PUSH_TO_POWER_PLATFORM',
139
+ type: 'confirm',
140
+ label: 'Push to Power Platform after a successful build',
141
+ help: 'Requires the user auth profile created in Step 4. Leave off to only verify the build.',
142
+ defaultValue: false,
143
+ },
144
+ {
145
+ id: 'CODE_APPS_FEATURES_ENABLED',
146
+ type: 'confirm',
147
+ label: 'Code Apps features are enabled in the target environment',
148
+ help: 'Before first push, enable Code components for canvas apps and publishing code components in Power Platform Admin Center.',
149
+ defaultValue: false,
150
+ hideIf: { id: 'PUSH_TO_POWER_PLATFORM', equals: false },
151
+ },
152
+ ];
153
+ },
154
+
155
+ async apply(answers, state, log) {
156
+ const projectDir = resolve(String(state.PROJECT_DIR || ROOT_DIR));
157
+ if (!existsSync(join(projectDir, 'package.json'))) throw new Error(`No package.json found in ${projectDir}. Run Step 7 first.`);
158
+
159
+ log.info('Building project...');
160
+ const buildOk = await runCommand(log, 'npm run build', { cwd: projectDir });
161
+ const distExists = existsSync(join(projectDir, 'dist', 'index.html'));
162
+ if (!buildOk || !distExists) {
163
+ log.warn('Build did not produce dist/index.html. Fix build errors before deploying.');
164
+ return { stateUpdate: { PROJECT_DIR: projectDir }, completedStep: 9 };
165
+ }
166
+ log.ok('Build succeeded and dist/index.html exists');
167
+
168
+ if (answers.PUSH_TO_POWER_PLATFORM !== true) {
169
+ log.info('Push skipped. You can deploy later from WizardUX or with pac code push.');
170
+ return { stateUpdate: { PROJECT_DIR: projectDir }, completedStep: 9 };
171
+ }
172
+
173
+ if (answers.CODE_APPS_FEATURES_ENABLED !== true) {
174
+ throw new Error('Confirm Code Apps features are enabled in Power Platform Admin Center before first push.');
175
+ }
176
+
177
+ const pac = SHELL.pacPath();
178
+ if (!pac) throw new Error('PAC CLI was not found. Install it before deploying.');
179
+ const powerConfigPath = join(projectDir, 'power.config.json');
180
+ const repair = PAC_TARGET.repairPowerConfigDisplayNames(powerConfigPath);
181
+ if (repair.changed) log.warn(`Repaired quoted display name fields in power.config.json: ${repair.fields.join(', ')}`);
182
+ const isUserAuth = (state.AUTH_PROFILE_TYPE || 'user') === 'user';
183
+ const credentialValues = isUserAuth ? null : resolveCredentialValues(state);
184
+ const verification = verifyUserProfile(pac, projectDir, state, credentialValues);
185
+ log.ok(`Verified user profile ${verification.profileName}`);
186
+
187
+ const pushArgs = ['code', 'push'];
188
+ if (state.SOLUTION_DISPLAY_NAME) pushArgs.push('-s', state.SOLUTION_DISPLAY_NAME);
189
+ const pushResult = await runFileCapture(log, pac, pushArgs, { cwd: projectDir });
190
+ const pushOutput = `${pushResult.stdout}\n${pushResult.stderr}`;
191
+ if (!pushResult.ok || PAC_HTTP_ERROR_RE.test(pushOutput)) throw new Error('pac code push failed. Check the live output above, then retry.');
192
+ log.ok('Code App pushed to Power Platform');
193
+
194
+ // pac code push prints the deployed Code App URL in stdout, e.g.
195
+ // "The app was successfully published. URL: https://apps.powerapps.com/play/e/<envId>/a/<appId>"
196
+ // Capture the first matching apps.powerapps.com/play URL and persist it
197
+ // to wizard state so the Summary can show the real launch URL.
198
+ const deployedUrlMatch = pushOutput.match(/https:\/\/apps\.powerapps\.com\/play\/[^\s'"<>)]+/i);
199
+ const stateUpdate = { PROJECT_DIR: projectDir };
200
+ if (deployedUrlMatch) {
201
+ const deployedUrl = deployedUrlMatch[0].replace(/[.,;]+$/, '');
202
+ stateUpdate.DEPLOYED_APP_URL = deployedUrl;
203
+ log.ok(`App URL: ${deployedUrl}`);
204
+ } else {
205
+ log.warn('Could not detect deployed app URL in pac output. Open the app from Power Apps Maker Portal.');
206
+ }
207
+
208
+ await addAppToSolution(log, pac, projectDir, state.SOLUTION_UNIQUE_NAME || '');
209
+
210
+ return { stateUpdate, completedStep: 9 };
211
+ },
212
+ };
@@ -0,0 +1,24 @@
1
+ // wizard-ux/server/steps/index.mjs — Step registry for WizardUX.
2
+ //
3
+ // Each step module exports:
4
+ // meta: { number, title, description, canRunInBrowser }
5
+ // questions: (state) => Question[] — pure, called on every render
6
+ // apply: async (answers, state, log) => Partial<State> — side effects
7
+ import step1 from './01-prerequisites.mjs';
8
+ import step2 from './02-project-and-env.mjs';
9
+ import step3 from './03-app-registration.mjs';
10
+ import step4 from './04-auth-setup.mjs';
11
+ import step5 from './05-publisher.mjs';
12
+ import step6 from './06-solution.mjs';
13
+ import step7 from './07-scaffold.mjs';
14
+ import step8 from './08-connectors.mjs';
15
+ import step9 from './09-verify-deploy.mjs';
16
+
17
+ export const STEPS = [step1, step2, step3, step4, step5, step6, step7, step8, step9];
18
+ export const TOTAL_STEPS = STEPS.length;
19
+
20
+ export function getStep(n) {
21
+ const s = STEPS[n - 1];
22
+ if (!s) throw new Error(`Unknown step ${n}`);
23
+ return s;
24
+ }