@pacaf/wizard-ux 3.5.1 → 3.6.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.
- package/dist/assets/{index-BL1yODjj.js → index-COjPOI_E.js} +11 -11
- package/dist/index.html +1 -1
- package/package.json +2 -2
- package/server/lib/pac-parse.mjs +55 -0
- package/server/steps/01-prerequisites.mjs +133 -2
- package/server/steps/02-project-and-env.mjs +10 -73
- package/server/steps/04-auth-setup.mjs +42 -286
- package/server/steps/05-environments.mjs +436 -0
- package/server/steps/{05-publisher.mjs → 06-publisher.mjs} +9 -57
- package/server/steps/{06-solution.mjs → 07-solution.mjs} +8 -8
- package/server/steps/{07-scaffold.mjs → 08-scaffold.mjs} +8 -8
- package/server/steps/{08-connectors.mjs → 09-connectors.mjs} +13 -13
- package/server/steps/{09-verify-deploy.mjs → 10-verify-deploy.mjs} +6 -6
- package/server/steps/{10-add-to-solution.mjs → 11-add-to-solution.mjs} +2 -2
- package/server/steps/index.mjs +8 -7
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
// Step 5 - Environments. Discover environments via `pac env list --json` (using
|
|
2
|
+
// the tenant-level sign-in from Step 4), let the user pick Dev (required), Test,
|
|
3
|
+
// and Prod, then create the environment-scoped PAC profiles and write .env files.
|
|
4
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, chmodSync } from 'node:fs';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { dirname, join, resolve } from 'node:path';
|
|
7
|
+
import { platform } from 'node:os';
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
9
|
+
import { getSecret, recoverSecret, persistSecretToCache, clearSecretCache } from '../lib/dataverse-bridge.mjs';
|
|
10
|
+
|
|
11
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
// PACKAGE_DIR locates sibling @pacaf/wizard lib files (must stay __dirname-relative).
|
|
13
|
+
// PROJECT_DIR is the user's working directory (profile names, env files, git hooks).
|
|
14
|
+
const PACKAGE_DIR = resolve(__dirname, '..', '..', '..');
|
|
15
|
+
const PROJECT_DIR = process.cwd();
|
|
16
|
+
const SHELL = await import(pathToFileURL(resolve(PACKAGE_DIR, 'wizard', 'lib', 'shell.mjs')).href);
|
|
17
|
+
const CRYPTO = await import(pathToFileURL(resolve(PACKAGE_DIR, 'wizard', 'lib', 'crypto.mjs')).href);
|
|
18
|
+
const PAC_TARGET = await import(pathToFileURL(resolve(PACKAGE_DIR, 'wizard', 'lib', 'pac-target.mjs')).href);
|
|
19
|
+
const SCRUB = await import(pathToFileURL(resolve(PACKAGE_DIR, 'wizard', 'lib', 'scrub.mjs')).href);
|
|
20
|
+
const VALIDATE = await import(pathToFileURL(resolve(PACKAGE_DIR, 'wizard', 'lib', 'validate.mjs')).href);
|
|
21
|
+
|
|
22
|
+
// Sentinel select values.
|
|
23
|
+
const MANUAL_ENTRY = '__manual__';
|
|
24
|
+
const NONE = '__none__';
|
|
25
|
+
// Transient profile created in Step 4 so this step can run `pac env list`.
|
|
26
|
+
const DISCOVERY_PROFILE_NAME = 'pacaf-discovery';
|
|
27
|
+
|
|
28
|
+
function hasCommand(name) {
|
|
29
|
+
try {
|
|
30
|
+
execFileSync(platform() === 'win32' ? 'where' : 'which', [name], { stdio: 'ignore' });
|
|
31
|
+
return true;
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function formatPacAuthCreateError(profileName, url, output = '') {
|
|
38
|
+
const scrubbed = SCRUB.scrubSecrets(String(output || ''));
|
|
39
|
+
const lines = scrubbed.trim().split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
40
|
+
const detail = lines.find((line) => /^Error:/i.test(line)) || lines.at(-1) || 'pac auth create failed without a readable error message.';
|
|
41
|
+
return [
|
|
42
|
+
`${profileName} profile failed to create for ${url}.`,
|
|
43
|
+
`PAC reported: ${detail}`,
|
|
44
|
+
'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.',
|
|
45
|
+
].join('\n');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Remove any PP_CLIENT_SECRET=... line from .env.local. Called when the
|
|
50
|
+
* user picks 1Password storage so an encrypted blob from a previous run
|
|
51
|
+
* doesn't linger inside the (possibly cloud-synced) project folder.
|
|
52
|
+
*/
|
|
53
|
+
function removeSecretFromEnvLocal() {
|
|
54
|
+
const envLocalPath = join(PROJECT_DIR, '.env.local');
|
|
55
|
+
if (!existsSync(envLocalPath)) return;
|
|
56
|
+
const original = readFileSync(envLocalPath, 'utf-8');
|
|
57
|
+
if (!/^PP_CLIENT_SECRET=/m.test(original)) return;
|
|
58
|
+
const cleaned = original.replace(/^PP_CLIENT_SECRET=.*\r?\n?/m, '');
|
|
59
|
+
writeFileSync(envLocalPath, cleaned, 'utf-8');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function installPreCommitHook(log) {
|
|
63
|
+
const hookDir = join(PROJECT_DIR, '.git', 'hooks');
|
|
64
|
+
const hookPath = join(hookDir, 'pre-commit');
|
|
65
|
+
const hookSource = join(PROJECT_DIR, 'scripts', 'pre-commit-hook.sh');
|
|
66
|
+
if (!existsSync(join(PROJECT_DIR, '.git')) || !existsSync(hookSource)) return;
|
|
67
|
+
if (existsSync(hookPath)) {
|
|
68
|
+
const existing = readFileSync(hookPath, 'utf-8');
|
|
69
|
+
if (!existing.includes('papps-secret-guard')) return;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
mkdirSync(hookDir, { recursive: true });
|
|
73
|
+
copyFileSync(hookSource, hookPath);
|
|
74
|
+
if (platform() !== 'win32') chmodSync(hookPath, 0o755);
|
|
75
|
+
log.ok('Installed pre-commit hook');
|
|
76
|
+
} catch {
|
|
77
|
+
log.warn('Could not install pre-commit hook. Install scripts/pre-commit-hook.sh manually if desired.');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function createPacProfile(log, pac, targetKey, url, appId, secret, tenantId) {
|
|
82
|
+
const profileName = PAC_TARGET.buildPacProfileName({ rootDir: PROJECT_DIR, targetKey, profileType: 'spn', url });
|
|
83
|
+
const result = SHELL.runSafeCapture(pac, [
|
|
84
|
+
'auth', 'create', '--name', profileName, '--environment', url,
|
|
85
|
+
'--applicationId', appId, '--clientSecret', secret, '--tenant', tenantId,
|
|
86
|
+
]);
|
|
87
|
+
if (!result.ok) throw new Error(formatPacAuthCreateError(profileName, url, result.stderr || result.stdout));
|
|
88
|
+
log.ok(`${profileName} profile created`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Parse `pac env list --json` into a clean [{ url, name }] list. */
|
|
92
|
+
function discoverEnvironments(pac) {
|
|
93
|
+
const res = SHELL.runSafeCapture(pac, ['env', 'list', '--json']);
|
|
94
|
+
if (!res.ok) return [];
|
|
95
|
+
const out = String(res.stdout || '');
|
|
96
|
+
const start = out.indexOf('[');
|
|
97
|
+
if (start < 0) return [];
|
|
98
|
+
let arr;
|
|
99
|
+
try { arr = JSON.parse(out.slice(start)); } catch { return []; }
|
|
100
|
+
if (!Array.isArray(arr)) return [];
|
|
101
|
+
return arr
|
|
102
|
+
.filter((e) => e && e.EnvironmentUrl)
|
|
103
|
+
.map((e) => ({ url: VALIDATE.normalizeDataverseUrl(e.EnvironmentUrl), name: e.FriendlyName || e.UniqueName || e.EnvironmentUrl }))
|
|
104
|
+
.filter((e) => e.url)
|
|
105
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Find a profile's bracketed index in `pac auth list` output by its name. */
|
|
109
|
+
function findProfileIndexByName(pac, name) {
|
|
110
|
+
const res = SHELL.runSafeCapture(pac, ['auth', 'list']);
|
|
111
|
+
if (!res.ok) return null;
|
|
112
|
+
for (const line of String(res.stdout || '').split(/\r?\n/)) {
|
|
113
|
+
const m = line.match(/^\s*\[(\d+)\]/);
|
|
114
|
+
if (m && line.includes(name)) return Number(m[1]);
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export default {
|
|
120
|
+
meta: {
|
|
121
|
+
number: 5,
|
|
122
|
+
title: 'Environments',
|
|
123
|
+
description: 'Pick your Power Platform environments. We list them automatically using your sign-in from the previous step — choose Dev (required), and optionally Test and Prod. The matching PAC profiles and .env files are created for you.',
|
|
124
|
+
canRunInBrowser: true,
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
async questions(state) {
|
|
128
|
+
const pac = SHELL.pacPath();
|
|
129
|
+
const envs = pac ? discoverEnvironments(pac) : [];
|
|
130
|
+
|
|
131
|
+
if (envs.length > 0) {
|
|
132
|
+
const baseOptions = envs.map((e) => ({ value: e.url, label: `${e.name} — ${e.url.replace(/^https:\/\//, '')}` }));
|
|
133
|
+
const devOptions = [...baseOptions, { value: MANUAL_ENTRY, label: 'Enter a URL manually…' }];
|
|
134
|
+
const optionalOptions = [{ value: NONE, label: 'None' }, ...baseOptions, { value: MANUAL_ENTRY, label: 'Enter a URL manually…' }];
|
|
135
|
+
const known = (url) => url && baseOptions.some((o) => o.value === url);
|
|
136
|
+
return [
|
|
137
|
+
{
|
|
138
|
+
id: 'DEV_ENV',
|
|
139
|
+
type: 'select',
|
|
140
|
+
label: 'Development environment',
|
|
141
|
+
help: 'Where the app is built, scaffolded, and tested. Required.',
|
|
142
|
+
defaultValue: known(state.PP_ENV_DEV) ? state.PP_ENV_DEV : baseOptions[0].value,
|
|
143
|
+
options: devOptions,
|
|
144
|
+
required: true,
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
id: 'DEV_ENV_MANUAL',
|
|
148
|
+
type: 'url',
|
|
149
|
+
label: 'Development environment URL',
|
|
150
|
+
defaultValue: state.PP_ENV_DEV || '',
|
|
151
|
+
validatePattern: 'dataverseUrl',
|
|
152
|
+
required: true,
|
|
153
|
+
showIf: { id: 'DEV_ENV', equals: MANUAL_ENTRY },
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
id: 'TEST_ENV',
|
|
157
|
+
type: 'select',
|
|
158
|
+
label: 'Test environment (optional)',
|
|
159
|
+
defaultValue: known(state.PP_ENV_TEST) ? state.PP_ENV_TEST : NONE,
|
|
160
|
+
options: optionalOptions,
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
id: 'TEST_ENV_MANUAL',
|
|
164
|
+
type: 'url',
|
|
165
|
+
label: 'Test environment URL',
|
|
166
|
+
defaultValue: state.PP_ENV_TEST || '',
|
|
167
|
+
validatePattern: 'dataverseUrl',
|
|
168
|
+
showIf: { id: 'TEST_ENV', equals: MANUAL_ENTRY },
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
id: 'PROD_ENV',
|
|
172
|
+
type: 'select',
|
|
173
|
+
label: 'Production environment (optional)',
|
|
174
|
+
defaultValue: known(state.PP_ENV_PROD) ? state.PP_ENV_PROD : NONE,
|
|
175
|
+
options: optionalOptions,
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
id: 'PROD_ENV_MANUAL',
|
|
179
|
+
type: 'url',
|
|
180
|
+
label: 'Production environment URL',
|
|
181
|
+
defaultValue: state.PP_ENV_PROD || '',
|
|
182
|
+
validatePattern: 'dataverseUrl',
|
|
183
|
+
showIf: { id: 'PROD_ENV', equals: MANUAL_ENTRY },
|
|
184
|
+
},
|
|
185
|
+
];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Fallback: discovery returned nothing (e.g. a service principal without
|
|
189
|
+
// environment-list permissions). Fall back to manual URL entry.
|
|
190
|
+
return [
|
|
191
|
+
{
|
|
192
|
+
id: 'DEV_ENV_MANUAL',
|
|
193
|
+
type: 'url',
|
|
194
|
+
label: 'Development environment URL',
|
|
195
|
+
help: 'We could not list environments automatically. Paste the Dev environment URL.',
|
|
196
|
+
defaultValue: state.PP_ENV_DEV || '',
|
|
197
|
+
validatePattern: 'dataverseUrl',
|
|
198
|
+
required: true,
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
id: 'TEST_ENV_MANUAL',
|
|
202
|
+
type: 'url',
|
|
203
|
+
label: 'Test environment URL (optional)',
|
|
204
|
+
defaultValue: state.PP_ENV_TEST || '',
|
|
205
|
+
validatePattern: 'dataverseUrl',
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
id: 'PROD_ENV_MANUAL',
|
|
209
|
+
type: 'url',
|
|
210
|
+
label: 'Production environment URL (optional)',
|
|
211
|
+
defaultValue: state.PP_ENV_PROD || '',
|
|
212
|
+
validatePattern: 'dataverseUrl',
|
|
213
|
+
},
|
|
214
|
+
];
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
async apply(answers, state, log) {
|
|
218
|
+
const pac = SHELL.pacPath();
|
|
219
|
+
if (!pac) throw new Error('PAC CLI was not found.');
|
|
220
|
+
|
|
221
|
+
const authProfileType = state.AUTH_PROFILE_TYPE || 'user';
|
|
222
|
+
const targetKey = state.WIZARD_TARGET_ENV || 'dev';
|
|
223
|
+
const dateStr = new Date().toISOString().slice(0, 10);
|
|
224
|
+
|
|
225
|
+
// Resolve each environment from its select value or manual override.
|
|
226
|
+
const resolveUrl = (selectId, manualId) => {
|
|
227
|
+
const sel = answers[selectId];
|
|
228
|
+
if (sel === NONE) return '';
|
|
229
|
+
if (sel === MANUAL_ENTRY || sel === undefined) {
|
|
230
|
+
const raw = String(answers[manualId] || '').trim();
|
|
231
|
+
return raw ? VALIDATE.normalizeDataverseUrl(raw) : '';
|
|
232
|
+
}
|
|
233
|
+
return VALIDATE.normalizeDataverseUrl(String(sel));
|
|
234
|
+
};
|
|
235
|
+
const devUrl = resolveUrl('DEV_ENV', 'DEV_ENV_MANUAL');
|
|
236
|
+
const testUrl = resolveUrl('TEST_ENV', 'TEST_ENV_MANUAL');
|
|
237
|
+
const prodUrl = resolveUrl('PROD_ENV', 'PROD_ENV_MANUAL');
|
|
238
|
+
|
|
239
|
+
if (!devUrl) throw new Error('A development environment is required.');
|
|
240
|
+
if (!VALIDATE.isValidDataverseUrl(devUrl)) throw new Error(`"${devUrl}" is not a valid Dataverse URL (expected https://<org>.crm.dynamics.com).`);
|
|
241
|
+
if (testUrl && !VALIDATE.isValidDataverseUrl(testUrl)) throw new Error(`"${testUrl}" is not a valid Dataverse URL.`);
|
|
242
|
+
if (prodUrl && !VALIDATE.isValidDataverseUrl(prodUrl)) throw new Error(`"${prodUrl}" is not a valid Dataverse URL.`);
|
|
243
|
+
|
|
244
|
+
log.ok(`Dev: ${devUrl}`);
|
|
245
|
+
if (testUrl) log.ok(`Test: ${testUrl}`);
|
|
246
|
+
if (prodUrl) log.ok(`Prod: ${prodUrl}`);
|
|
247
|
+
|
|
248
|
+
// ── User credentials flow ──
|
|
249
|
+
if (authProfileType === 'user') {
|
|
250
|
+
const userProfileName = PAC_TARGET.buildPacProfileName({ rootDir: PROJECT_DIR, targetKey, profileType: 'user', url: devUrl });
|
|
251
|
+
|
|
252
|
+
const discoveryIdx = findProfileIndexByName(pac, DISCOVERY_PROFILE_NAME);
|
|
253
|
+
if (discoveryIdx != null) {
|
|
254
|
+
// Retarget the (already-authenticated) discovery profile to Dev, then
|
|
255
|
+
// rename it to the environment-scoped name — no second sign-in.
|
|
256
|
+
SHELL.runSafeCapture(pac, ['auth', 'select', '--name', DISCOVERY_PROFILE_NAME]);
|
|
257
|
+
const sel = SHELL.runSafeCapture(pac, ['env', 'select', '--environment', devUrl]);
|
|
258
|
+
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 || '')}`);
|
|
261
|
+
log.ok(`Profile ${userProfileName} ready (targeting Dev).`);
|
|
262
|
+
} else if (findProfileIndexByName(pac, userProfileName) != null) {
|
|
263
|
+
// Re-run after a previous success: discovery was already renamed.
|
|
264
|
+
SHELL.runSafeCapture(pac, ['auth', 'select', '--name', userProfileName]);
|
|
265
|
+
SHELL.runSafeCapture(pac, ['env', 'select', '--environment', devUrl]);
|
|
266
|
+
log.ok(`Reusing existing profile ${userProfileName} (targeting Dev).`);
|
|
267
|
+
} else {
|
|
268
|
+
throw new Error('No sign-in was found. Go back to the previous step, sign in, then return here.');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Write .env — 1Password references if configured, otherwise plain URLs.
|
|
272
|
+
const authMode = state.AUTH_MODE || '';
|
|
273
|
+
if (authMode === '1password' && state.OP_VAULT && state.OP_ITEM) {
|
|
274
|
+
const vault = state.OP_VAULT;
|
|
275
|
+
const itemName = state.OP_ITEM;
|
|
276
|
+
try { removeSecretFromEnvLocal(); } catch { /* best-effort */ }
|
|
277
|
+
try { clearSecretCache(); } catch { /* best-effort */ }
|
|
278
|
+
const envContent = [
|
|
279
|
+
'# .env - Safe to commit. Contains 1Password references, not secrets.',
|
|
280
|
+
`# Generated by setup wizard on ${dateStr}`,
|
|
281
|
+
'',
|
|
282
|
+
`PP_ENV_DEV=op://${vault}/${itemName}/env-dev`,
|
|
283
|
+
testUrl ? `PP_ENV_TEST=op://${vault}/${itemName}/env-test` : '',
|
|
284
|
+
prodUrl ? `PP_ENV_PROD=op://${vault}/${itemName}/env-prod` : '',
|
|
285
|
+
'',
|
|
286
|
+
].filter((line) => line !== '').join('\n');
|
|
287
|
+
writeFileSync(join(PROJECT_DIR, '.env'), `${envContent}\n`, 'utf-8');
|
|
288
|
+
log.ok('Wrote .env with 1Password references');
|
|
289
|
+
} else {
|
|
290
|
+
const envContent = [
|
|
291
|
+
'# .env - Environment URLs for Power Platform.',
|
|
292
|
+
`# Generated by setup wizard on ${dateStr}`,
|
|
293
|
+
'',
|
|
294
|
+
`PP_ENV_DEV=${devUrl}`,
|
|
295
|
+
testUrl ? `PP_ENV_TEST=${testUrl}` : '',
|
|
296
|
+
prodUrl ? `PP_ENV_PROD=${prodUrl}` : '',
|
|
297
|
+
'',
|
|
298
|
+
].filter((line) => line !== '').join('\n');
|
|
299
|
+
writeFileSync(join(PROJECT_DIR, '.env'), `${envContent}\n`, 'utf-8');
|
|
300
|
+
log.ok('Wrote .env with environment URLs');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
installPreCommitHook(log);
|
|
304
|
+
|
|
305
|
+
const verification = PAC_TARGET.selectAndVerifyPacProfile({
|
|
306
|
+
pac,
|
|
307
|
+
rootDir: PROJECT_DIR,
|
|
308
|
+
wizardState: { WIZARD_TARGET_ENV: targetKey, PP_ENV_DEV: devUrl, PP_ENV_TEST: testUrl, PP_ENV_PROD: prodUrl },
|
|
309
|
+
targetKey,
|
|
310
|
+
profileType: 'user',
|
|
311
|
+
credentialValues: null,
|
|
312
|
+
powerConfigPath: join(PROJECT_DIR, 'power.config.json'),
|
|
313
|
+
requireCredentialMatch: false,
|
|
314
|
+
requirePowerConfig: false,
|
|
315
|
+
requirePowerConfigTarget: false,
|
|
316
|
+
});
|
|
317
|
+
log.ok(`Verified ${verification.profileName}`);
|
|
318
|
+
log.info(verification.whoInfo.raw.split('\n').map((line) => ` ${line}`).join('\n'));
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
stateUpdate: { PP_ENV_DEV: devUrl, PP_ENV_TEST: testUrl, PP_ENV_PROD: prodUrl, AUTH_MODE: authMode },
|
|
322
|
+
completedStep: 5,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── Service Principal flow ──
|
|
327
|
+
const secret = getSecret() || recoverSecret();
|
|
328
|
+
if (!secret) throw new Error('Client secret could not be recovered. Go back to the sign-in step and re-enter it.');
|
|
329
|
+
const tenantId = String(state.PP_TENANT_ID || '').trim();
|
|
330
|
+
const clientId = String(state.PP_APP_ID || '').trim();
|
|
331
|
+
if (!tenantId || !clientId) throw new Error('Tenant ID and Client ID are required. Complete the earlier steps first.');
|
|
332
|
+
const authMode = String(state.AUTH_MODE || 'envlocal');
|
|
333
|
+
|
|
334
|
+
if (authMode === '1password') {
|
|
335
|
+
const vault = String(state.OP_VAULT || '').trim();
|
|
336
|
+
const itemName = String(state.OP_ITEM || '').trim();
|
|
337
|
+
if (!hasCommand('op')) throw new Error('1Password storage was selected, but the op CLI is not available.');
|
|
338
|
+
if (!vault || !itemName) throw new Error('1Password vault and item name are required.');
|
|
339
|
+
try { removeSecretFromEnvLocal(); } catch { /* best-effort */ }
|
|
340
|
+
try { clearSecretCache(); } catch { /* best-effort */ }
|
|
341
|
+
const envContent = [
|
|
342
|
+
'# .env - Safe to commit. Contains 1Password references, not secrets.',
|
|
343
|
+
`# Generated by setup wizard on ${dateStr}`,
|
|
344
|
+
'',
|
|
345
|
+
`PP_TENANT_ID=op://${vault}/${itemName}/tenant-id`,
|
|
346
|
+
`PP_APP_ID=op://${vault}/${itemName}/app-id`,
|
|
347
|
+
`PP_CLIENT_SECRET=op://${vault}/${itemName}/client-secret`,
|
|
348
|
+
`PP_ENV_DEV=op://${vault}/${itemName}/env-dev`,
|
|
349
|
+
testUrl ? `PP_ENV_TEST=op://${vault}/${itemName}/env-test` : '',
|
|
350
|
+
prodUrl ? `PP_ENV_PROD=op://${vault}/${itemName}/env-prod` : '',
|
|
351
|
+
'',
|
|
352
|
+
].filter((line) => line !== '').join('\n');
|
|
353
|
+
writeFileSync(join(PROJECT_DIR, '.env'), `${envContent}\n`, 'utf-8');
|
|
354
|
+
log.ok('Wrote .env with 1Password references');
|
|
355
|
+
} else {
|
|
356
|
+
const gitignorePath = join(PROJECT_DIR, '.gitignore');
|
|
357
|
+
if (existsSync(gitignorePath)) {
|
|
358
|
+
const gitignore = readFileSync(gitignorePath, 'utf-8');
|
|
359
|
+
if (!gitignore.includes('.env.local')) appendFileSync(gitignorePath, '\n.env.local\n');
|
|
360
|
+
} else {
|
|
361
|
+
writeFileSync(gitignorePath, '.env.local\n', 'utf-8');
|
|
362
|
+
}
|
|
363
|
+
const envLocalContent = [
|
|
364
|
+
'# .env.local - DO NOT commit to Git.',
|
|
365
|
+
`# Generated by setup wizard on ${dateStr}`,
|
|
366
|
+
'',
|
|
367
|
+
`PP_TENANT_ID=${tenantId}`,
|
|
368
|
+
`PP_APP_ID=${clientId}`,
|
|
369
|
+
`PP_CLIENT_SECRET=${CRYPTO.encrypt(secret)}`,
|
|
370
|
+
`PP_ENV_DEV=${devUrl}`,
|
|
371
|
+
testUrl ? `PP_ENV_TEST=${testUrl}` : '',
|
|
372
|
+
prodUrl ? `PP_ENV_PROD=${prodUrl}` : '',
|
|
373
|
+
'',
|
|
374
|
+
].filter((line) => line !== '').join('\n');
|
|
375
|
+
writeFileSync(join(PROJECT_DIR, '.env.local'), `${envLocalContent}\n`, 'utf-8');
|
|
376
|
+
if (platform() !== 'win32') {
|
|
377
|
+
try { chmodSync(join(PROJECT_DIR, '.env.local'), 0o600); } catch { /* best effort */ }
|
|
378
|
+
}
|
|
379
|
+
try { persistSecretToCache(secret); } catch { /* best-effort */ }
|
|
380
|
+
log.ok('Wrote .env.local');
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
log.info('Creating PAC SPN auth profiles...');
|
|
384
|
+
createPacProfile(log, pac, 'dev', devUrl, clientId, secret, tenantId);
|
|
385
|
+
if (testUrl) createPacProfile(log, pac, 'test', testUrl, clientId, secret, tenantId);
|
|
386
|
+
if (prodUrl) createPacProfile(log, pac, 'prod', prodUrl, clientId, secret, tenantId);
|
|
387
|
+
|
|
388
|
+
// Remove the transient discovery profile now that env-scoped ones exist.
|
|
389
|
+
SHELL.runSafeCapture(pac, ['auth', 'delete', '--name', DISCOVERY_PROFILE_NAME]);
|
|
390
|
+
|
|
391
|
+
installPreCommitHook(log);
|
|
392
|
+
|
|
393
|
+
const envTemplate = [
|
|
394
|
+
'# .env.template - Copy to .env.local and fill in values',
|
|
395
|
+
'# DO NOT commit .env.local to Git',
|
|
396
|
+
'',
|
|
397
|
+
'PP_TENANT_ID=',
|
|
398
|
+
'PP_APP_ID=',
|
|
399
|
+
'PP_CLIENT_SECRET=',
|
|
400
|
+
`PP_ENV_DEV=${devUrl}`,
|
|
401
|
+
testUrl ? `PP_ENV_TEST=${testUrl}` : '',
|
|
402
|
+
prodUrl ? `PP_ENV_PROD=${prodUrl}` : '',
|
|
403
|
+
'',
|
|
404
|
+
].filter((line) => line !== '').join('\n');
|
|
405
|
+
writeFileSync(join(PROJECT_DIR, '.env.template'), `${envTemplate}\n`, 'utf-8');
|
|
406
|
+
log.ok('Wrote .env.template');
|
|
407
|
+
|
|
408
|
+
const opBin = hasCommand('op') ? 'op' : null;
|
|
409
|
+
const credentialValues = PAC_TARGET.resolveCredentialValues({ rootDir: PROJECT_DIR, opBin, source: authMode });
|
|
410
|
+
const verification = PAC_TARGET.selectAndVerifyPacProfile({
|
|
411
|
+
pac,
|
|
412
|
+
rootDir: PROJECT_DIR,
|
|
413
|
+
wizardState: { WIZARD_TARGET_ENV: targetKey, PP_ENV_DEV: devUrl, PP_ENV_TEST: testUrl, PP_ENV_PROD: prodUrl },
|
|
414
|
+
targetKey,
|
|
415
|
+
profileType: 'spn',
|
|
416
|
+
credentialValues,
|
|
417
|
+
powerConfigPath: join(PROJECT_DIR, 'power.config.json'),
|
|
418
|
+
requireCredentialMatch: true,
|
|
419
|
+
requirePowerConfig: false,
|
|
420
|
+
requirePowerConfigTarget: false,
|
|
421
|
+
});
|
|
422
|
+
log.ok(`Verified ${verification.profileName}`);
|
|
423
|
+
log.info(verification.whoInfo.raw.split('\n').map((line) => ` ${line}`).join('\n'));
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
stateUpdate: {
|
|
427
|
+
PP_ENV_DEV: devUrl,
|
|
428
|
+
PP_ENV_TEST: testUrl,
|
|
429
|
+
PP_ENV_PROD: prodUrl,
|
|
430
|
+
AUTH_MODE: authMode,
|
|
431
|
+
HAS_OP: opBin !== null,
|
|
432
|
+
},
|
|
433
|
+
completedStep: 5,
|
|
434
|
+
};
|
|
435
|
+
},
|
|
436
|
+
};
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
// Step
|
|
1
|
+
// Step 6 — Solution & Publisher (solution-first, publisher auto-resolved).
|
|
2
2
|
import { dirname, resolve, join } from 'node:path';
|
|
3
3
|
import { writeFileSync, unlinkSync } from 'node:fs';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
6
6
|
import { dvGet, dvPost, hasUsableSecret, setSecret, clearSecret } from '../lib/dataverse-bridge.mjs';
|
|
7
|
+
import { parsePacTabularRows } from '../lib/pac-parse.mjs';
|
|
7
8
|
|
|
8
9
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
10
|
const PACKAGE_DIR = resolve(__dirname, '..', '..', '..');
|
|
@@ -52,55 +53,6 @@ function extractSolutionIdFromUrl(url) {
|
|
|
52
53
|
return m ? m[1].toLowerCase() : null;
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
/**
|
|
56
|
-
* Parse PAC CLI column-aligned tabular output into an array of row objects.
|
|
57
|
-
* Finds the header line (containing known column keywords), determines column
|
|
58
|
-
* boundaries from header token positions, and slices each subsequent data line
|
|
59
|
-
* by those boundaries. Handles multi-word values (e.g. "Climb Tracker") and
|
|
60
|
-
* aliased columns (e.g. "pub.customizationprefix").
|
|
61
|
-
*/
|
|
62
|
-
function parsePacTabularRows(output, headerHints) {
|
|
63
|
-
const allLines = output.split(/\r?\n/);
|
|
64
|
-
const hints = headerHints || ['uniquename', 'solutionid', 'friendlyname'];
|
|
65
|
-
|
|
66
|
-
// Find the header line — first line containing at least one of the hints.
|
|
67
|
-
let headerIdx = -1;
|
|
68
|
-
for (let i = 0; i < allLines.length; i++) {
|
|
69
|
-
const lower = allLines[i].toLowerCase();
|
|
70
|
-
if (hints.some((h) => lower.includes(h))) {
|
|
71
|
-
headerIdx = i;
|
|
72
|
-
break;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
if (headerIdx < 0) return [];
|
|
76
|
-
|
|
77
|
-
const headerLine = allLines[headerIdx];
|
|
78
|
-
|
|
79
|
-
// Extract column names and their start positions from the header.
|
|
80
|
-
const cols = [];
|
|
81
|
-
const re = /\S+/g;
|
|
82
|
-
let m;
|
|
83
|
-
while ((m = re.exec(headerLine)) !== null) {
|
|
84
|
-
cols.push({ name: m[0].toLowerCase(), start: m.index });
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// Parse each data line after the header.
|
|
88
|
-
const rows = [];
|
|
89
|
-
for (let i = headerIdx + 1; i < allLines.length; i++) {
|
|
90
|
-
const line = allLines[i];
|
|
91
|
-
if (!line.trim()) continue;
|
|
92
|
-
if (/^(Connected|Microsoft|Version:|Online|Feedback)/i.test(line.trim())) continue;
|
|
93
|
-
const row = {};
|
|
94
|
-
for (let c = 0; c < cols.length; c++) {
|
|
95
|
-
const start = cols[c].start;
|
|
96
|
-
const end = c < cols.length - 1 ? cols[c + 1].start : line.length;
|
|
97
|
-
row[cols[c].name] = (start < line.length ? line.slice(start, end) : '').trim();
|
|
98
|
-
}
|
|
99
|
-
if (Object.values(row).some((v) => v)) rows.push(row);
|
|
100
|
-
}
|
|
101
|
-
return rows;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
56
|
/**
|
|
105
57
|
* Fetch solution + publisher by solution ID using a single joined FetchXML.
|
|
106
58
|
* Uses <link-entity> to include publisher columns directly, avoiding the
|
|
@@ -248,7 +200,7 @@ async function fetchSolutionViaApi(solutionId) {
|
|
|
248
200
|
|
|
249
201
|
export default {
|
|
250
202
|
meta: {
|
|
251
|
-
number:
|
|
203
|
+
number: 6,
|
|
252
204
|
title: 'Solution & Publisher',
|
|
253
205
|
description: 'Select or create the Power Platform solution for your Code App. The publisher (prefix) is resolved automatically from the solution.',
|
|
254
206
|
canRunInBrowser: true,
|
|
@@ -436,7 +388,7 @@ export default {
|
|
|
436
388
|
async apply(answers, state, log) {
|
|
437
389
|
if (answers.__resume) {
|
|
438
390
|
log.ok(`Reusing solution: ${state.SOLUTION_DISPLAY_NAME} (prefix: ${state.PUBLISHER_PREFIX})`);
|
|
439
|
-
return { stateUpdate: {}, completedStep:
|
|
391
|
+
return { stateUpdate: {}, completedStep: 6 };
|
|
440
392
|
}
|
|
441
393
|
|
|
442
394
|
const isUserAuth = (state.AUTH_PROFILE_TYPE || 'user') === 'user';
|
|
@@ -466,7 +418,7 @@ export default {
|
|
|
466
418
|
log.ok(`Solution: ${sol.friendlyname || sol.uniquename}`);
|
|
467
419
|
log.ok(`Publisher: ${sol.publisherFriendlyName || sol.publisherUniqueName || '?'} (prefix: ${sol.prefix})`);
|
|
468
420
|
clearSecret();
|
|
469
|
-
return { stateUpdate: buildStateUpdate(sol), completedStep:
|
|
421
|
+
return { stateUpdate: buildStateUpdate(sol), completedStep: 7 };
|
|
470
422
|
}
|
|
471
423
|
|
|
472
424
|
// ── Selected an existing solution from dropdown ──
|
|
@@ -489,7 +441,7 @@ export default {
|
|
|
489
441
|
PUBLISHER_PREFIX: pub?.customizationprefix || '',
|
|
490
442
|
CHOICE_VALUE_PREFIX: String(pub?.customizationoptionvalueprefix || ''),
|
|
491
443
|
},
|
|
492
|
-
completedStep:
|
|
444
|
+
completedStep: 7,
|
|
493
445
|
};
|
|
494
446
|
}
|
|
495
447
|
// User auth: selected from dropdown (came from pac env fetch)
|
|
@@ -498,7 +450,7 @@ export default {
|
|
|
498
450
|
if (!sol?.solutionid) throw new Error('Could not fetch solution details. Try pasting the solution URL instead.');
|
|
499
451
|
log.ok(`Solution: ${sol.friendlyname || sol.uniquename}`);
|
|
500
452
|
log.ok(`Publisher prefix: ${sol.prefix || '?'}`);
|
|
501
|
-
return { stateUpdate: buildStateUpdate(sol), completedStep:
|
|
453
|
+
return { stateUpdate: buildStateUpdate(sol), completedStep: 7 };
|
|
502
454
|
}
|
|
503
455
|
|
|
504
456
|
// ── Create new ──
|
|
@@ -527,7 +479,7 @@ export default {
|
|
|
527
479
|
PUBLISHER_PREFIX: manualPrefix,
|
|
528
480
|
CHOICE_VALUE_PREFIX: '',
|
|
529
481
|
},
|
|
530
|
-
completedStep:
|
|
482
|
+
completedStep: 7,
|
|
531
483
|
};
|
|
532
484
|
}
|
|
533
485
|
|
|
@@ -559,7 +511,7 @@ export default {
|
|
|
559
511
|
PUBLISHER_PREFIX: pubData.customizationprefix || '',
|
|
560
512
|
CHOICE_VALUE_PREFIX: String(pubData.customizationoptionvalueprefix || ''),
|
|
561
513
|
},
|
|
562
|
-
completedStep:
|
|
514
|
+
completedStep: 7,
|
|
563
515
|
};
|
|
564
516
|
},
|
|
565
517
|
};
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
// Step
|
|
2
|
-
// Publisher is now resolved in Step
|
|
1
|
+
// Step 7 — Solution confirmation (auto-skip).
|
|
2
|
+
// Publisher is now resolved in Step 6. This step just confirms and moves on.
|
|
3
3
|
import { clearSecret } from '../lib/dataverse-bridge.mjs';
|
|
4
4
|
|
|
5
5
|
export default {
|
|
6
6
|
meta: {
|
|
7
|
-
number:
|
|
7
|
+
number: 7,
|
|
8
8
|
title: 'Solution Confirmed',
|
|
9
|
-
description: 'Solution and publisher details were resolved in Step
|
|
9
|
+
description: 'Solution and publisher details were resolved in Step 6.',
|
|
10
10
|
canRunInBrowser: true,
|
|
11
11
|
},
|
|
12
12
|
|
|
@@ -17,25 +17,25 @@ export default {
|
|
|
17
17
|
id: '__auto',
|
|
18
18
|
type: 'confirm',
|
|
19
19
|
label: `Solution: "${state.SOLUTION_DISPLAY_NAME}" — Publisher prefix: ${state.PUBLISHER_PREFIX}`,
|
|
20
|
-
help: 'These were set in Step
|
|
20
|
+
help: 'These were set in Step 6. Click Apply to continue.',
|
|
21
21
|
defaultValue: true,
|
|
22
22
|
}];
|
|
23
23
|
}
|
|
24
24
|
return [{
|
|
25
25
|
id: '__missing',
|
|
26
26
|
type: 'confirm',
|
|
27
|
-
label: 'Go back to Step
|
|
27
|
+
label: 'Go back to Step 6 to select a solution first.',
|
|
28
28
|
defaultValue: false,
|
|
29
29
|
}];
|
|
30
30
|
},
|
|
31
31
|
|
|
32
32
|
async apply(answers, state, log) {
|
|
33
33
|
if (!state.SOLUTION_UNIQUE_NAME || !state.PUBLISHER_PREFIX) {
|
|
34
|
-
throw new Error('Solution and publisher not set. Go back to Step
|
|
34
|
+
throw new Error('Solution and publisher not set. Go back to Step 6.');
|
|
35
35
|
}
|
|
36
36
|
log.ok(`Solution: ${state.SOLUTION_DISPLAY_NAME} (${state.SOLUTION_UNIQUE_NAME})`);
|
|
37
37
|
log.ok(`Publisher prefix: ${state.PUBLISHER_PREFIX}`);
|
|
38
38
|
clearSecret();
|
|
39
|
-
return { stateUpdate: {}, completedStep:
|
|
39
|
+
return { stateUpdate: {}, completedStep: 7 };
|
|
40
40
|
},
|
|
41
41
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Step
|
|
1
|
+
// Step 8 - Scaffold. Browser-native long-running scaffold with live logs.
|
|
2
2
|
import { existsSync, mkdirSync, readdirSync, writeFileSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { spawn, execFileSync } from 'node:child_process';
|
|
4
4
|
import { dirname, join, resolve } from 'node:path';
|
|
@@ -187,13 +187,13 @@ pac code push
|
|
|
187
187
|
|-------------|-----|
|
|
188
188
|
${envRows}
|
|
189
189
|
|
|
190
|
-
Connector binding is intentionally deferred until the prototype is stable. Use WizardUX step
|
|
190
|
+
Connector binding is intentionally deferred until the prototype is stable. Use WizardUX step 9 or \`pac code add-data-source\` when you are ready for real data.
|
|
191
191
|
`, 'utf-8');
|
|
192
192
|
}
|
|
193
193
|
|
|
194
194
|
export default {
|
|
195
195
|
meta: {
|
|
196
|
-
number:
|
|
196
|
+
number: 8,
|
|
197
197
|
title: 'Scaffold the Code App',
|
|
198
198
|
description: 'Generate the project, install dependencies, register with Power Platform, and run smoke tests.',
|
|
199
199
|
canRunInBrowser: true,
|
|
@@ -290,7 +290,7 @@ export default {
|
|
|
290
290
|
if (pnpm) {
|
|
291
291
|
log.info('Detected pnpm — using it for faster installs and shared dependency cache.');
|
|
292
292
|
} else {
|
|
293
|
-
log.info('pnpm not found — using npm. Tip: `corepack enable && corepack prepare pnpm@latest --activate` makes Step
|
|
293
|
+
log.info('pnpm not found — using npm. Tip: `corepack enable && corepack prepare pnpm@latest --activate` makes Step 8 noticeably faster.');
|
|
294
294
|
}
|
|
295
295
|
|
|
296
296
|
// pnpm refuses `pnpm add` at a workspace root (pnpm-workspace.yaml present)
|
|
@@ -342,7 +342,7 @@ export default {
|
|
|
342
342
|
try {
|
|
343
343
|
verifyPacTarget({ pac, projectDir, state, credentialValues, profileType: 'user', requirePowerConfig: false, requirePowerConfigTarget: false });
|
|
344
344
|
} catch (error) {
|
|
345
|
-
throw new Error(`${error.message}\n\npac code init requires the repo-scoped interactive PAC profile. Return to Step 4, enable user profile creation, complete browser/device sign-in, then retry Step
|
|
345
|
+
throw new Error(`${error.message}\n\npac code init requires the repo-scoped interactive PAC profile. Return to Step 4, enable user profile creation, complete browser/device sign-in, then retry Step 8.`);
|
|
346
346
|
}
|
|
347
347
|
const powerConfigPath = join(projectDir, 'power.config.json');
|
|
348
348
|
let skipInit = false;
|
|
@@ -366,7 +366,7 @@ export default {
|
|
|
366
366
|
'--fileEntryPoint', 'index.html',
|
|
367
367
|
], { cwd: projectDir });
|
|
368
368
|
if (!initOk) throw new Error('pac code init failed. Check the live output above, then retry this step.');
|
|
369
|
-
if (!existsSync(powerConfigPath)) throw new Error('pac code init completed without creating power.config.json. Check the PAC output above, then retry Step
|
|
369
|
+
if (!existsSync(powerConfigPath)) throw new Error('pac code init completed without creating power.config.json. Check the PAC output above, then retry Step 8 after resolving that PAC error.');
|
|
370
370
|
const repair = PAC_TARGET.repairPowerConfigDisplayNames(powerConfigPath);
|
|
371
371
|
if (repair.changed) log.warn(`Repaired quoted display name fields in power.config.json: ${repair.fields.join(', ')}`);
|
|
372
372
|
}
|
|
@@ -376,7 +376,7 @@ export default {
|
|
|
376
376
|
log.warn('PAC CLI not found; skipping pac code init.');
|
|
377
377
|
}
|
|
378
378
|
|
|
379
|
-
log.info('Connector binding is deferred to step
|
|
379
|
+
log.info('Connector binding is deferred to step 9 after prototype validation.');
|
|
380
380
|
|
|
381
381
|
log.info('Running smoke tests...');
|
|
382
382
|
if (await runCommand(log, 'npm run test:smoke', { cwd: projectDir })) log.ok('Smoke tests passed');
|
|
@@ -426,7 +426,7 @@ export default {
|
|
|
426
426
|
PROJECT_DIR: projectDir,
|
|
427
427
|
GIT_REMOTE: finalRemoteUrl || state.GIT_REMOTE || '',
|
|
428
428
|
},
|
|
429
|
-
completedStep:
|
|
429
|
+
completedStep: 8,
|
|
430
430
|
};
|
|
431
431
|
},
|
|
432
432
|
};
|