@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.
- package/README.md +84 -0
- package/bin/pacaf-wizard-ux.mjs +20 -0
- package/dist/assets/index-BVelUveV.js +127 -0
- package/dist/index.html +36 -0
- package/index.html +36 -0
- package/package.json +67 -0
- package/scripts/fix-pty-perms.mjs +34 -0
- package/server/index.mjs +144 -0
- package/server/lib/dataverse-bridge.mjs +51 -0
- package/server/lib/process-runner.mjs +117 -0
- package/server/lib/state-bridge.mjs +40 -0
- package/server/routes/onepassword.mjs +16 -0
- package/server/routes/pty.mjs +124 -0
- package/server/routes/state.mjs +88 -0
- package/server/routes/steps.mjs +62 -0
- package/server/routes/stream.mjs +49 -0
- package/server/routes/system.mjs +43 -0
- package/server/steps/01-prerequisites.mjs +127 -0
- package/server/steps/02-project-and-env.mjs +108 -0
- package/server/steps/03-app-registration.mjs +482 -0
- package/server/steps/04-auth-setup.mjs +435 -0
- package/server/steps/05-publisher.mjs +581 -0
- package/server/steps/06-solution.mjs +41 -0
- package/server/steps/07-scaffold.mjs +356 -0
- package/server/steps/08-connectors.mjs +438 -0
- package/server/steps/09-verify-deploy.mjs +212 -0
- package/server/steps/index.mjs +24 -0
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
// Step 3 - Authentication. Browser-native auth method selection with optional 1Password sync.
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
4
|
+
import { dirname, resolve, join } from 'node:path';
|
|
5
|
+
import { platform } from 'node:os';
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
7
|
+
import { setSecret, hasUsableSecret } from '../lib/dataverse-bridge.mjs';
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const ROOT_DIR = resolve(__dirname, '..', '..', '..');
|
|
11
|
+
const VALIDATE = await import(pathToFileURL(resolve(ROOT_DIR, 'wizard', 'lib', 'validate.mjs')).href);
|
|
12
|
+
|
|
13
|
+
function hasCommand(name) {
|
|
14
|
+
try {
|
|
15
|
+
execFileSync(platform() === 'win32' ? 'where' : 'which', [name], { stdio: 'ignore' });
|
|
16
|
+
return true;
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function runSafe(file, args) {
|
|
23
|
+
try {
|
|
24
|
+
return execFileSync(file, args, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], cwd: ROOT_DIR }).trim();
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function runSafeWithTimeout(file, args, timeoutMs = 2000) {
|
|
31
|
+
try {
|
|
32
|
+
return execFileSync(file, args, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], cwd: ROOT_DIR, timeout: timeoutMs }).trim();
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readOpField(vault, itemName, field) {
|
|
39
|
+
return (runSafe('op', ['read', `op://${vault}/${itemName}/${field}`]) || '').trim();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const CREATE_NEW_VAULT = '__create_new_vault__';
|
|
43
|
+
const CREATE_NEW_ITEM = '__create_new_item__';
|
|
44
|
+
const ENTER_MANUALLY = '__enter_manually__';
|
|
45
|
+
|
|
46
|
+
function listOpVaults() {
|
|
47
|
+
const raw = runSafeWithTimeout('op', ['vault', 'list', '--format=json']);
|
|
48
|
+
if (!raw) return [];
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(raw)
|
|
51
|
+
.map((v) => ({ value: v.name, label: `${v.name} (${v.items} item${v.items === 1 ? '' : 's'})` }))
|
|
52
|
+
.sort((a, b) => a.value.localeCompare(b.value));
|
|
53
|
+
} catch { return []; }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function listOpItems(vaultName) {
|
|
57
|
+
if (!vaultName) return [];
|
|
58
|
+
const raw = runSafeWithTimeout('op', ['item', 'list', '--vault', vaultName, '--format=json']);
|
|
59
|
+
if (!raw) return [];
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(raw)
|
|
62
|
+
.map((i) => ({ value: i.title, label: `${i.title} (${i.category.toLowerCase().replace(/_/g, ' ')})` }))
|
|
63
|
+
.sort((a, b) => a.value.localeCompare(b.value));
|
|
64
|
+
} catch { return []; }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Exported for the API route to call
|
|
68
|
+
export { listOpItems };
|
|
69
|
+
|
|
70
|
+
function createOpVault(log, name) {
|
|
71
|
+
const result = runSafe('op', ['vault', 'create', name, '--format=json']);
|
|
72
|
+
if (result) { log.ok(`Created 1Password vault "${name}"`); return true; }
|
|
73
|
+
log.warn(`Could not create vault "${name}". Create it manually in 1Password.`);
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function createOpItem(log, vault, title, fields) {
|
|
78
|
+
const args = ['item', 'create', '--vault', vault, '--category', 'Secure Note', '--title', title];
|
|
79
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
80
|
+
if (value) args.push(`${key}=${value}`);
|
|
81
|
+
}
|
|
82
|
+
const result = runSafe('op', args);
|
|
83
|
+
if (result !== null) { log.ok(`Created 1Password item "${title}" in vault "${vault}"`); return true; }
|
|
84
|
+
log.warn(`Could not create item "${title}". Create it manually in 1Password.`);
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function save1PasswordItem(log, vault, itemName, values, had) {
|
|
89
|
+
const existing = runSafe('op', ['item', 'get', itemName, '--vault', vault, '--format', 'json']);
|
|
90
|
+
const envFields = [];
|
|
91
|
+
if (values.devUrl) envFields.push(`env-dev[text]=${values.devUrl}`);
|
|
92
|
+
if (values.testUrl) envFields.push(`env-test[text]=${values.testUrl}`);
|
|
93
|
+
if (values.prodUrl) envFields.push(`env-prod[text]=${values.prodUrl}`);
|
|
94
|
+
|
|
95
|
+
if (existing) {
|
|
96
|
+
const editArgs = ['item', 'edit', itemName, '--vault', vault];
|
|
97
|
+
if (!had.tenantId) editArgs.push(`tenant-id[text]=${values.tenantId}`);
|
|
98
|
+
if (!had.clientId) editArgs.push(`app-id[text]=${values.clientId}`);
|
|
99
|
+
if (!had.clientSecret) editArgs.push(`client-secret[password]=${values.clientSecret}`);
|
|
100
|
+
editArgs.push(...envFields);
|
|
101
|
+
if (runSafe('op', editArgs) !== null) log.ok('1Password item updated');
|
|
102
|
+
else log.warn('Could not update 1Password item. Save tenant-id, app-id, client-secret, and env-* fields manually.');
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const createArgs = [
|
|
107
|
+
'item', 'create',
|
|
108
|
+
'--vault', vault,
|
|
109
|
+
'--category', 'Secure Note',
|
|
110
|
+
'--title', itemName,
|
|
111
|
+
`tenant-id[text]=${values.tenantId}`,
|
|
112
|
+
`app-id[text]=${values.clientId}`,
|
|
113
|
+
`client-secret[password]=${values.clientSecret}`,
|
|
114
|
+
...envFields,
|
|
115
|
+
];
|
|
116
|
+
if (runSafe('op', createArgs) !== null) log.ok('1Password item created');
|
|
117
|
+
else log.warn('Could not create 1Password item. Create it manually with tenant-id, app-id, client-secret, and env-* fields.');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function sync1PasswordEnvFields(log, vault, itemName, values) {
|
|
121
|
+
const normalize = (value) => String(value || '').trim().replace(/\/+$/, '').toLowerCase();
|
|
122
|
+
const updates = [];
|
|
123
|
+
if (values.devUrl && normalize(readOpField(vault, itemName, 'env-dev')) !== normalize(values.devUrl)) updates.push(`env-dev[text]=${values.devUrl}`);
|
|
124
|
+
if (values.testUrl && normalize(readOpField(vault, itemName, 'env-test')) !== normalize(values.testUrl)) updates.push(`env-test[text]=${values.testUrl}`);
|
|
125
|
+
if (values.prodUrl && normalize(readOpField(vault, itemName, 'env-prod')) !== normalize(values.prodUrl)) updates.push(`env-prod[text]=${values.prodUrl}`);
|
|
126
|
+
if (updates.length === 0) return;
|
|
127
|
+
if (runSafe('op', ['item', 'edit', itemName, '--vault', vault, ...updates]) !== null) {
|
|
128
|
+
log.ok(`Synced ${updates.length} environment URL field(s) in 1Password`);
|
|
129
|
+
} else {
|
|
130
|
+
log.warn('Could not update 1Password env-* fields. Edit the item manually so env URLs match this wizard run.');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export default {
|
|
135
|
+
meta: {
|
|
136
|
+
number: 3,
|
|
137
|
+
title: 'Authentication',
|
|
138
|
+
description: 'Choose how to authenticate with your Power Platform environment — user credentials or a service principal.',
|
|
139
|
+
canRunInBrowser: true,
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
questions(state) {
|
|
143
|
+
const appName = state.APP_NAME || 'My App';
|
|
144
|
+
const appRegName = `PowerApps-CodeApps-${String(appName).replace(/ /g, '-')}`;
|
|
145
|
+
const hasOp = hasCommand('op') || state.HAS_OP === true;
|
|
146
|
+
const currentAuthType = state.AUTH_PROFILE_TYPE || 'user';
|
|
147
|
+
|
|
148
|
+
// ── 1Password vault/item discovery ──
|
|
149
|
+
const vaults = hasOp ? listOpVaults() : [];
|
|
150
|
+
const defaultVault = state.OP_VAULT || vaults[0]?.value || '';
|
|
151
|
+
|
|
152
|
+
return [
|
|
153
|
+
{
|
|
154
|
+
id: 'AUTH_PROFILE_TYPE',
|
|
155
|
+
type: 'select',
|
|
156
|
+
label: 'Authentication method',
|
|
157
|
+
help: 'User credentials: sign in interactively — no App Registration needed. Service Principal: headless auth via an App Registration — required for CI/CD but needs tenant admin access to create.',
|
|
158
|
+
defaultValue: currentAuthType,
|
|
159
|
+
options: [
|
|
160
|
+
{ value: 'user', label: 'User credentials (interactive sign-in)' },
|
|
161
|
+
{ value: 'spn', label: 'Service Principal (App Registration)' },
|
|
162
|
+
],
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
// ── 1Password (available for both auth types) ──
|
|
166
|
+
{
|
|
167
|
+
id: 'USE_1PASSWORD',
|
|
168
|
+
type: 'confirm',
|
|
169
|
+
label: 'Use 1Password to store credentials',
|
|
170
|
+
help: hasOp
|
|
171
|
+
? 'Store environment URLs and credentials as 1Password references. Secrets never touch disk.'
|
|
172
|
+
: '1Password CLI was not detected. Leave this off unless op is available in your shell.',
|
|
173
|
+
defaultValue: hasOp && state.AUTH_MODE === '1password',
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
id: 'OP_VAULT',
|
|
177
|
+
type: 'select',
|
|
178
|
+
label: '1Password vault',
|
|
179
|
+
help: vaults.length > 0
|
|
180
|
+
? 'Select an existing vault, create a new one, or enter a name manually.'
|
|
181
|
+
: 'Could not discover vaults (1Password may be locked or offline). If you just authenticated to 1Password, refresh this page to reload the vault list. Otherwise, enter a vault name manually or create a new one.',
|
|
182
|
+
defaultValue: state.OP_VAULT && vaults.some((v) => v.value === state.OP_VAULT)
|
|
183
|
+
? state.OP_VAULT
|
|
184
|
+
: (vaults[0]?.value || ENTER_MANUALLY),
|
|
185
|
+
options: [
|
|
186
|
+
...vaults,
|
|
187
|
+
{ value: ENTER_MANUALLY, label: 'Enter vault name manually' },
|
|
188
|
+
{ value: CREATE_NEW_VAULT, label: '+ Create new vault' },
|
|
189
|
+
],
|
|
190
|
+
hideIf: { id: 'USE_1PASSWORD', equals: false },
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
id: 'OP_VAULT_MANUAL',
|
|
194
|
+
type: 'text',
|
|
195
|
+
label: 'Vault name',
|
|
196
|
+
help: 'Type the exact 1Password vault name. It must already exist in your 1Password account.',
|
|
197
|
+
defaultValue: state.OP_VAULT || '',
|
|
198
|
+
showIf: { id: 'OP_VAULT', equals: ENTER_MANUALLY },
|
|
199
|
+
hideIf: { id: 'USE_1PASSWORD', equals: false },
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
id: 'OP_NEW_VAULT_NAME',
|
|
203
|
+
type: 'text',
|
|
204
|
+
label: 'New vault name',
|
|
205
|
+
help: 'Name for the new 1Password vault.',
|
|
206
|
+
defaultValue: 'Power Platform Environments',
|
|
207
|
+
showIf: { id: 'OP_VAULT', equals: CREATE_NEW_VAULT },
|
|
208
|
+
hideIf: { id: 'USE_1PASSWORD', equals: false },
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
id: 'OP_ITEM',
|
|
212
|
+
type: 'select',
|
|
213
|
+
label: '1Password item',
|
|
214
|
+
help: 'Items are loaded from the selected vault. Select an existing item, create a new one, or enter a name manually.',
|
|
215
|
+
defaultValue: state.OP_ITEM || ENTER_MANUALLY,
|
|
216
|
+
options: [
|
|
217
|
+
{ value: ENTER_MANUALLY, label: 'Enter item name manually' },
|
|
218
|
+
{ value: CREATE_NEW_ITEM, label: '+ Create new item' },
|
|
219
|
+
],
|
|
220
|
+
dynamicOptions: {
|
|
221
|
+
endpoint: '/api/1password/items',
|
|
222
|
+
param: 'vault',
|
|
223
|
+
dependsOn: 'OP_VAULT',
|
|
224
|
+
responseKey: 'items',
|
|
225
|
+
},
|
|
226
|
+
hideIf: [{ id: 'USE_1PASSWORD', equals: false }],
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
id: 'OP_ITEM_MANUAL',
|
|
230
|
+
type: 'text',
|
|
231
|
+
label: 'Item name',
|
|
232
|
+
help: 'Type the exact 1Password item name (e.g. the Secure Note title).',
|
|
233
|
+
defaultValue: state.OP_ITEM || `PowerApps CodeApps - ${appName}`,
|
|
234
|
+
showIf: { id: 'OP_ITEM', equals: ENTER_MANUALLY },
|
|
235
|
+
hideIf: { id: 'USE_1PASSWORD', equals: false },
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
id: 'OP_NEW_ITEM_NAME',
|
|
239
|
+
type: 'text',
|
|
240
|
+
label: 'New item name',
|
|
241
|
+
help: 'Name for the new 1Password Secure Note.',
|
|
242
|
+
defaultValue: state.OP_ITEM || `PowerApps CodeApps - ${appName}`,
|
|
243
|
+
showIf: { id: 'OP_ITEM', equals: CREATE_NEW_ITEM },
|
|
244
|
+
hideIf: { id: 'USE_1PASSWORD', equals: false },
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
// ── SPN-only fields ──
|
|
248
|
+
// These show when: SPN is selected AND (1Password is off, OR creating a new 1Password item).
|
|
249
|
+
// When 1Password is on with an existing item, credentials are read from 1Password automatically.
|
|
250
|
+
{
|
|
251
|
+
id: 'PP_TENANT_ID',
|
|
252
|
+
type: 'text',
|
|
253
|
+
label: 'Tenant ID (Directory ID)',
|
|
254
|
+
help: 'Required for the App Registration. Leave blank only if your existing 1Password item already has a tenant-id field.',
|
|
255
|
+
defaultValue: state.PP_TENANT_ID || '',
|
|
256
|
+
showIf: { id: 'AUTH_PROFILE_TYPE', equals: 'spn' },
|
|
257
|
+
why: [
|
|
258
|
+
'Azure Portal steps:',
|
|
259
|
+
'1. Open https://portal.azure.com',
|
|
260
|
+
'2. Microsoft Entra ID -> App registrations -> New registration',
|
|
261
|
+
`3. Name: ${appRegName}`,
|
|
262
|
+
'4. Supported account types: Single tenant',
|
|
263
|
+
'5. Redirect URI: leave blank, then Register',
|
|
264
|
+
'6. Copy Directory (tenant) ID from Overview',
|
|
265
|
+
].join('\n'),
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
id: 'PP_APP_ID',
|
|
269
|
+
type: 'text',
|
|
270
|
+
label: 'Client ID (Application ID)',
|
|
271
|
+
help: 'Required for the App Registration. Leave blank only if your existing 1Password item already has an app-id field.',
|
|
272
|
+
defaultValue: state.PP_APP_ID || '',
|
|
273
|
+
showIf: { id: 'AUTH_PROFILE_TYPE', equals: 'spn' },
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
id: 'PP_CLIENT_SECRET',
|
|
277
|
+
type: 'secret',
|
|
278
|
+
label: 'Client secret value',
|
|
279
|
+
help: hasUsableSecret()
|
|
280
|
+
? 'A secret is already held in memory from a previous run. Leave blank to keep it.'
|
|
281
|
+
: 'Required for the App Registration. Leave blank only if your existing 1Password item already has a client-secret field.',
|
|
282
|
+
defaultValue: '',
|
|
283
|
+
savedHint: hasUsableSecret() ? 'Secret saved from previous run' : undefined,
|
|
284
|
+
showIf: { id: 'AUTH_PROFILE_TYPE', equals: 'spn' },
|
|
285
|
+
why: [
|
|
286
|
+
'Client secret steps:',
|
|
287
|
+
'1. In the App Registration, open Certificates & secrets',
|
|
288
|
+
'2. New client secret -> Description: Power Platform CLI',
|
|
289
|
+
'3. Expiration: 12 months',
|
|
290
|
+
'4. Copy the secret value immediately; it is shown only once',
|
|
291
|
+
].join('\n'),
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
id: 'APPLICATION_USER_DONE',
|
|
295
|
+
type: 'confirm',
|
|
296
|
+
label: 'Please confirm that the Application User is registered in the Dev environment',
|
|
297
|
+
defaultValue: false,
|
|
298
|
+
showIf: { id: 'AUTH_PROFILE_TYPE', equals: 'spn' },
|
|
299
|
+
why: [
|
|
300
|
+
'Power Platform Admin Center steps:',
|
|
301
|
+
'1. Open https://admin.powerplatform.microsoft.com',
|
|
302
|
+
'2. Select your Dev environment -> Settings',
|
|
303
|
+
'3. Users + permissions -> Application users',
|
|
304
|
+
'4. New app user -> Add an app',
|
|
305
|
+
`5. Search for ${appRegName}`,
|
|
306
|
+
'6. Assign System Administrator for Dev/Test or least privilege for Production',
|
|
307
|
+
'7. Create the app user before continuing',
|
|
308
|
+
].join('\n'),
|
|
309
|
+
},
|
|
310
|
+
];
|
|
311
|
+
},
|
|
312
|
+
|
|
313
|
+
async apply(answers, state, log) {
|
|
314
|
+
const authProfileType = answers.AUTH_PROFILE_TYPE || 'user';
|
|
315
|
+
const use1Password = answers.USE_1PASSWORD === true;
|
|
316
|
+
|
|
317
|
+
// ── Resolve vault and item names (shared by both auth flows) ──
|
|
318
|
+
let vault = '';
|
|
319
|
+
let itemName = '';
|
|
320
|
+
if (use1Password) {
|
|
321
|
+
if (!hasCommand('op')) throw new Error('1Password was selected, but the op CLI is not available to the WizardUX server process.');
|
|
322
|
+
|
|
323
|
+
// Vault: existing selection, manual entry, or create new
|
|
324
|
+
if (answers.OP_VAULT === CREATE_NEW_VAULT) {
|
|
325
|
+
vault = String(answers.OP_NEW_VAULT_NAME || '').trim();
|
|
326
|
+
if (!vault) throw new Error('New vault name is required.');
|
|
327
|
+
createOpVault(log, vault);
|
|
328
|
+
} else if (answers.OP_VAULT === ENTER_MANUALLY) {
|
|
329
|
+
vault = String(answers.OP_VAULT_MANUAL || '').trim();
|
|
330
|
+
if (!vault) throw new Error('Vault name is required.');
|
|
331
|
+
} else {
|
|
332
|
+
vault = String(answers.OP_VAULT || state.OP_VAULT || '').trim();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Item: existing selection, manual entry, or create new
|
|
336
|
+
if (answers.OP_ITEM === CREATE_NEW_ITEM) {
|
|
337
|
+
itemName = String(answers.OP_NEW_ITEM_NAME || '').trim();
|
|
338
|
+
if (!itemName) throw new Error('New item name is required.');
|
|
339
|
+
// Item creation happens below after we have field values
|
|
340
|
+
} else if (answers.OP_ITEM === ENTER_MANUALLY) {
|
|
341
|
+
itemName = String(answers.OP_ITEM_MANUAL || '').trim();
|
|
342
|
+
if (!itemName) throw new Error('Item name is required.');
|
|
343
|
+
} else {
|
|
344
|
+
itemName = String(answers.OP_ITEM || state.OP_ITEM || '').trim();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (!vault || !itemName) throw new Error('1Password vault and item name are required.');
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ── User credentials + 1Password ──
|
|
351
|
+
if (authProfileType === 'user') {
|
|
352
|
+
if (use1Password) {
|
|
353
|
+
const devUrl = state.PP_ENV_DEV || '';
|
|
354
|
+
const testUrl = state.PP_ENV_TEST || '';
|
|
355
|
+
const prodUrl = state.PP_ENV_PROD || '';
|
|
356
|
+
|
|
357
|
+
// Create item if needed (env URLs only — no secrets for user auth)
|
|
358
|
+
if (answers.OP_ITEM === CREATE_NEW_ITEM) {
|
|
359
|
+
const fields = {};
|
|
360
|
+
if (devUrl) fields['env-dev[text]'] = devUrl;
|
|
361
|
+
if (testUrl) fields['env-test[text]'] = testUrl;
|
|
362
|
+
if (prodUrl) fields['env-prod[text]'] = prodUrl;
|
|
363
|
+
createOpItem(log, vault, itemName, fields);
|
|
364
|
+
} else {
|
|
365
|
+
// Sync env URLs on existing item
|
|
366
|
+
sync1PasswordEnvFields(log, vault, itemName, { devUrl, testUrl, prodUrl });
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
log.ok(`User credentials with 1Password selected — environment URLs stored in "${vault}" → "${itemName}".`);
|
|
370
|
+
return {
|
|
371
|
+
stateUpdate: {
|
|
372
|
+
AUTH_PROFILE_TYPE: 'user',
|
|
373
|
+
PP_TENANT_ID: '',
|
|
374
|
+
PP_APP_ID: '',
|
|
375
|
+
AUTH_MODE: '1password',
|
|
376
|
+
HAS_OP: true,
|
|
377
|
+
OP_VAULT: vault,
|
|
378
|
+
OP_ITEM: itemName,
|
|
379
|
+
},
|
|
380
|
+
completedStep: 3,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// User credentials without 1Password
|
|
385
|
+
log.ok('User credentials selected — you will sign in interactively in the next step.');
|
|
386
|
+
return {
|
|
387
|
+
stateUpdate: {
|
|
388
|
+
AUTH_PROFILE_TYPE: 'user',
|
|
389
|
+
PP_TENANT_ID: '',
|
|
390
|
+
PP_APP_ID: '',
|
|
391
|
+
AUTH_MODE: state.AUTH_MODE === '1password' ? '' : (state.AUTH_MODE || ''),
|
|
392
|
+
},
|
|
393
|
+
completedStep: 3,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ── Service Principal flow ──
|
|
398
|
+
const devUrl = state.PP_ENV_DEV || '';
|
|
399
|
+
const testUrl = state.PP_ENV_TEST || '';
|
|
400
|
+
const prodUrl = state.PP_ENV_PROD || '';
|
|
401
|
+
|
|
402
|
+
let tenantId = String(answers.PP_TENANT_ID || '').trim();
|
|
403
|
+
let clientId = String(answers.PP_APP_ID || '').trim();
|
|
404
|
+
let clientSecret = String(answers.PP_CLIENT_SECRET || '').trim();
|
|
405
|
+
const had = { tenantId: false, clientId: false, clientSecret: false };
|
|
406
|
+
|
|
407
|
+
if (use1Password) {
|
|
408
|
+
log.info(`Looking for credentials in 1Password item "${itemName}"...`);
|
|
409
|
+
if (answers.OP_ITEM !== CREATE_NEW_ITEM) {
|
|
410
|
+
// Read from existing item
|
|
411
|
+
const opTenantId = readOpField(vault, itemName, 'tenant-id');
|
|
412
|
+
const opClientId = readOpField(vault, itemName, 'app-id');
|
|
413
|
+
const opSecret = readOpField(vault, itemName, 'client-secret');
|
|
414
|
+
if (opTenantId) { tenantId = opTenantId; had.tenantId = true; }
|
|
415
|
+
if (opClientId) { clientId = opClientId; had.clientId = true; }
|
|
416
|
+
if (opSecret) { clientSecret = opSecret; had.clientSecret = true; }
|
|
417
|
+
const found = [had.tenantId && 'tenant-id', had.clientId && 'app-id', had.clientSecret && 'client-secret'].filter(Boolean);
|
|
418
|
+
if (found.length > 0) log.ok(`Found ${found.join(', ')} in 1Password`);
|
|
419
|
+
if (found.length < 3) log.warn('Some 1Password fields were missing; using the values entered in WizardUX for the rest.');
|
|
420
|
+
} else {
|
|
421
|
+
log.info('New item will be created after validating credentials.');
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (!VALIDATE.isValidUUID(tenantId)) throw new Error('Tenant ID must be a valid UUID.');
|
|
426
|
+
if (!VALIDATE.isValidUUID(clientId)) throw new Error('Client ID must be a valid UUID.');
|
|
427
|
+
if (!clientSecret) throw new Error('Client secret is required.');
|
|
428
|
+
if (answers.APPLICATION_USER_DONE !== true) throw new Error('Register the App Registration as an Application User in Dev before continuing.');
|
|
429
|
+
|
|
430
|
+
setSecret(clientSecret);
|
|
431
|
+
|
|
432
|
+
// Persist encrypted secret to .env.local so it survives server restarts
|
|
433
|
+
try {
|
|
434
|
+
const CRYPTO = await import(pathToFileURL(resolve(ROOT_DIR, 'wizard', 'lib', 'crypto.mjs')).href);
|
|
435
|
+
const envLocalPath = join(ROOT_DIR, '.env.local');
|
|
436
|
+
let envContent = existsSync(envLocalPath) ? readFileSync(envLocalPath, 'utf-8') : '';
|
|
437
|
+
const encrypted = CRYPTO.encrypt(clientSecret);
|
|
438
|
+
if (envContent.match(/^PP_CLIENT_SECRET=.*$/m)) {
|
|
439
|
+
envContent = envContent.replace(/^PP_CLIENT_SECRET=.*$/m, `PP_CLIENT_SECRET=${encrypted}`);
|
|
440
|
+
} else {
|
|
441
|
+
envContent += `${envContent.endsWith('\n') || !envContent ? '' : '\n'}PP_CLIENT_SECRET=${encrypted}\n`;
|
|
442
|
+
}
|
|
443
|
+
writeFileSync(envLocalPath, envContent, 'utf-8');
|
|
444
|
+
log.ok('Secret encrypted and saved to .env.local');
|
|
445
|
+
} catch (err) {
|
|
446
|
+
log.warn(`Could not persist secret to .env.local: ${err.message}`);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
log.ok('Credential values captured');
|
|
450
|
+
|
|
451
|
+
if (use1Password) {
|
|
452
|
+
if (answers.OP_ITEM === CREATE_NEW_ITEM) {
|
|
453
|
+
// Create a brand new item with all fields
|
|
454
|
+
createOpItem(log, vault, itemName, {
|
|
455
|
+
'tenant-id[text]': tenantId,
|
|
456
|
+
'app-id[text]': clientId,
|
|
457
|
+
'client-secret[password]': clientSecret,
|
|
458
|
+
...(devUrl ? { 'env-dev[text]': devUrl } : {}),
|
|
459
|
+
...(testUrl ? { 'env-test[text]': testUrl } : {}),
|
|
460
|
+
...(prodUrl ? { 'env-prod[text]': prodUrl } : {}),
|
|
461
|
+
});
|
|
462
|
+
} else {
|
|
463
|
+
const values = { tenantId, clientId, clientSecret, devUrl, testUrl, prodUrl };
|
|
464
|
+
if (had.tenantId && had.clientId && had.clientSecret) sync1PasswordEnvFields(log, vault, itemName, values);
|
|
465
|
+
else save1PasswordItem(log, vault, itemName, values, had);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
stateUpdate: {
|
|
471
|
+
AUTH_PROFILE_TYPE: 'spn',
|
|
472
|
+
PP_TENANT_ID: tenantId,
|
|
473
|
+
PP_APP_ID: clientId,
|
|
474
|
+
AUTH_MODE: use1Password ? '1password' : (state.AUTH_MODE || 'envlocal'),
|
|
475
|
+
HAS_OP: hasCommand('op'),
|
|
476
|
+
OP_VAULT: use1Password ? vault : state.OP_VAULT,
|
|
477
|
+
OP_ITEM: use1Password ? itemName : state.OP_ITEM,
|
|
478
|
+
},
|
|
479
|
+
completedStep: 3,
|
|
480
|
+
};
|
|
481
|
+
},
|
|
482
|
+
};
|