@pacaf/wizard-ux 3.0.8 → 3.0.10

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/index.html CHANGED
@@ -28,7 +28,7 @@
28
28
  }
29
29
  @keyframes spin { to { transform: rotate(360deg); } }
30
30
  </style>
31
- <script type="module" crossorigin src="/assets/index-zSgX3W7E.js"></script>
31
+ <script type="module" crossorigin src="/assets/index-DODkyiWs.js"></script>
32
32
  </head>
33
33
  <body>
34
34
  <div id="root"><div id="boot"><div class="ring"></div></div></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pacaf/wizard-ux",
3
- "version": "3.0.8",
3
+ "version": "3.0.10",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Browser-based setup wizard for Power Apps Code Apps (parallel to @pacaf/wizard CLI).",
@@ -38,7 +38,7 @@
38
38
  "react-dom": "^19.0.0",
39
39
  "react-resizable-panels": "^2.1.7",
40
40
  "react-router-dom": "^7.1.0",
41
- "@pacaf/wizard": "3.1.1"
41
+ "@pacaf/wizard": "3.1.3"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@types/react": "^19.0.0",
package/server/index.mjs CHANGED
@@ -14,6 +14,7 @@ import stepsRoutes from './routes/steps.mjs';
14
14
  import streamRoutes from './routes/stream.mjs';
15
15
  import ptyRoutes from './routes/pty.mjs';
16
16
  import onepasswordRoutes from './routes/onepassword.mjs';
17
+ import { detectCloudSync, cloudSyncWarning } from '../../wizard/lib/cloud-sync-detect.mjs';
17
18
 
18
19
  const __dirname = dirname(fileURLToPath(import.meta.url));
19
20
  const UX_DIR = resolve(__dirname, '..');
@@ -49,11 +50,15 @@ await app.register(cors, {
49
50
 
50
51
  // Expose the CSRF token to the UI on a single endpoint. UI stores it in memory and
51
52
  // echoes it on mutating calls. Token rotates per server start.
52
- app.get('/api/handshake', async () => ({
53
- csrfToken: CSRF_TOKEN,
54
- rootDir: ROOT_DIR,
55
- startedAt: new Date().toISOString(),
56
- }));
53
+ app.get('/api/handshake', async () => {
54
+ const cloud = detectCloudSync(ROOT_DIR);
55
+ return {
56
+ csrfToken: CSRF_TOKEN,
57
+ rootDir: ROOT_DIR,
58
+ startedAt: new Date().toISOString(),
59
+ cloudSync: cloud ? { detected: true, provider: cloud.provider } : { detected: false },
60
+ };
61
+ });
57
62
 
58
63
  // Guard mutating routes
59
64
  app.addHook('onRequest', async (req, reply) => {
@@ -130,6 +135,15 @@ if (!IS_DEV && haveDist) {
130
135
 
131
136
  await app.listen({ host: HOST, port: PORT });
132
137
 
138
+ // Cloud-sync warning surfaced in the server console at startup. The UI also
139
+ // reads /api/handshake -> cloudSync to render an in-browser MessageBar.
140
+ {
141
+ const cloud = detectCloudSync(ROOT_DIR);
142
+ if (cloud) {
143
+ console.log(cloudSyncWarning(cloud.provider, ROOT_DIR));
144
+ }
145
+ }
146
+
133
147
  const url = `http://${HOST}:${PORT}`;
134
148
  console.log('');
135
149
  console.log(' ╭─────────────────────────────────────────────╮');
@@ -49,3 +49,11 @@ export function hasUsableSecret() {
49
49
  if (secretsMod.getSecret()) return true;
50
50
  return Boolean(secretsMod.recoverSecret());
51
51
  }
52
+
53
+ export function persistSecretToCache(value) {
54
+ return secretsMod.persistSecretToCache(value);
55
+ }
56
+
57
+ export function clearSecretCache() {
58
+ return secretsMod.clearSecretCache();
59
+ }
@@ -4,6 +4,11 @@
4
4
  import { spawn } from 'node:child_process';
5
5
  import { EventEmitter } from 'node:events';
6
6
  import { randomBytes } from 'node:crypto';
7
+ import { dirname, resolve } from 'node:path';
8
+ import { fileURLToPath, pathToFileURL } from 'node:url';
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const SCRUB = await import(pathToFileURL(resolve(__dirname, '..', '..', '..', 'wizard', 'lib', 'scrub.mjs')).href);
7
12
 
8
13
  const runs = new Map(); // runId -> Run
9
14
 
@@ -21,13 +26,17 @@ class Run extends EventEmitter {
21
26
  }
22
27
 
23
28
  push(stream, text) {
24
- const evt = { stream, text, ts: Date.now() };
29
+ // Defense-in-depth: scrub any secret-shaped values from text before it
30
+ // hits the SSE log buffer. The buffer is streamed to the browser and
31
+ // rendered in the UI; never let a real secret land there.
32
+ const safe = SCRUB.scrubSecrets(text);
33
+ const evt = { stream, text: safe, ts: Date.now() };
25
34
  this.lines.push(evt);
26
35
  if (this.lines.length > 1000) this.lines.shift();
27
36
  this.emit('line', evt);
28
37
  // Detect device code prompts from pac auth create
29
- const codeMatch = text.match(/enter the code\s+([A-Z0-9]{6,12})/i);
30
- const urlMatch = text.match(/(https:\/\/microsoft\.com\/devicelogin)/i);
38
+ const codeMatch = safe.match(/enter the code\s+([A-Z0-9]{6,12})/i);
39
+ const urlMatch = safe.match(/(https:\/\/microsoft\.com\/devicelogin)/i);
31
40
  if (codeMatch) {
32
41
  this.deviceCode = { code: codeMatch[1], url: 'https://microsoft.com/devicelogin', ts: Date.now() };
33
42
  this.emit('deviceCode', this.deviceCode);
@@ -61,7 +70,9 @@ export function spawnInRun(run, file, args, opts = {}) {
61
70
  return new Promise((resolveP) => {
62
71
  run.status = 'running';
63
72
  run.startedAt = Date.now();
64
- run.push('stdout', `$ ${file} ${args.join(' ')}\n`);
73
+ // Display args with sensitive flag values redacted (e.g. --clientSecret ****)
74
+ const displayArgs = SCRUB.scrubArgs(args);
75
+ run.push('stdout', `$ ${file} ${displayArgs.join(' ')}\n`);
65
76
  const child = spawn(file, args, { ...opts, stdio: ['ignore', 'pipe', 'pipe'] });
66
77
  run.child = child;
67
78
  child.stdout.setEncoding('utf-8');
@@ -4,6 +4,7 @@ import { existsSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { homedir, platform, release } from 'node:os';
6
6
  import { pacPath as resolvePacPath, runSafe } from '@pacaf/wizard/lib/shell.mjs';
7
+ import { detectCloudSync } from '@pacaf/wizard/lib/cloud-sync-detect.mjs';
7
8
 
8
9
  function safeRun(cmd) {
9
10
  try { return execSync(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); }
@@ -29,15 +30,19 @@ function pacVersion() {
29
30
  }
30
31
 
31
32
  export default async function systemRoutes(app, { rootDir }) {
32
- app.get('/', async () => ({
33
- os: { platform: platform(), release: release() },
34
- node: process.version,
35
- git: safeRun('git --version')?.replace('git version ', '') || null,
36
- dotnet: safeRun('dotnet --version'),
37
- pac: pacVersion(),
38
- op: which('op'),
39
- rootDir,
40
- branch: safeRun(`git -C "${rootDir}" rev-parse --abbrev-ref HEAD`) || null,
41
- repoIsClean: safeRun(`git -C "${rootDir}" status --porcelain`) === '',
42
- }));
33
+ app.get('/', async () => {
34
+ const cloud = detectCloudSync(rootDir);
35
+ return {
36
+ os: { platform: platform(), release: release() },
37
+ node: process.version,
38
+ git: safeRun('git --version')?.replace('git version ', '') || null,
39
+ dotnet: safeRun('dotnet --version'),
40
+ pac: pacVersion(),
41
+ op: which('op'),
42
+ rootDir,
43
+ cloudSync: cloud ? { detected: true, provider: cloud.provider } : { detected: false },
44
+ branch: safeRun(`git -C "${rootDir}" rev-parse --abbrev-ref HEAD`) || null,
45
+ repoIsClean: safeRun(`git -C "${rootDir}" status --porcelain`) === '',
46
+ };
47
+ });
43
48
  }
@@ -1,10 +1,9 @@
1
1
  // Step 3 - Authentication. Browser-native auth method selection with optional 1Password sync.
2
2
  import { execFileSync } from 'node:child_process';
3
- import { readFileSync, writeFileSync, existsSync } from 'node:fs';
4
- import { dirname, resolve, join } from 'node:path';
3
+ import { dirname, resolve } from 'node:path';
5
4
  import { platform } from 'node:os';
6
5
  import { fileURLToPath, pathToFileURL } from 'node:url';
7
- import { setSecret, hasUsableSecret } from '../lib/dataverse-bridge.mjs';
6
+ import { setSecret, hasUsableSecret, persistSecretToCache } from '../lib/dataverse-bridge.mjs';
8
7
 
9
8
  const __dirname = dirname(fileURLToPath(import.meta.url));
10
9
  // PACKAGE_DIR locates sibling @pacaf/wizard lib files (must stay __dirname-relative).
@@ -429,21 +428,16 @@ export default {
429
428
 
430
429
  setSecret(clientSecret);
431
430
 
432
- // Persist encrypted secret to .env.local so it survives server restarts
431
+ // Persist the encrypted secret to a per-machine OS-temp cache (NOT .env.local)
432
+ // so it survives wizard server restarts without ever placing the value
433
+ // inside the project folder. The project folder may be cloud-synced
434
+ // (OneDrive/Dropbox/iCloud), and content scanners pattern-match
435
+ // "PP_CLIENT_SECRET=" lines regardless of whether the value is encrypted.
433
436
  try {
434
- const CRYPTO = await import(pathToFileURL(resolve(PACKAGE_DIR, 'wizard', 'lib', 'crypto.mjs')).href);
435
- const envLocalPath = join(PROJECT_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');
437
+ persistSecretToCache(clientSecret);
438
+ log.ok('Secret cached securely outside the project folder');
445
439
  } catch (err) {
446
- log.warn(`Could not persist secret to .env.local: ${err.message}`);
440
+ log.warn(`Could not cache secret across restarts: ${err.message}`);
447
441
  }
448
442
 
449
443
  log.ok('Credential values captured');
@@ -4,7 +4,7 @@ import { execFileSync } from 'node:child_process';
4
4
  import { dirname, join, resolve } from 'node:path';
5
5
  import { platform } from 'node:os';
6
6
  import { fileURLToPath, pathToFileURL } from 'node:url';
7
- import { getSecret, hasUsableSecret, recoverSecret, setSecret } from '../lib/dataverse-bridge.mjs';
7
+ import { getSecret, hasUsableSecret, recoverSecret, setSecret, persistSecretToCache, clearSecretCache } from '../lib/dataverse-bridge.mjs';
8
8
 
9
9
  const __dirname = dirname(fileURLToPath(import.meta.url));
10
10
  // PACKAGE_DIR locates sibling @pacaf/wizard lib files (must stay __dirname-relative).
@@ -14,6 +14,7 @@ const PROJECT_DIR = process.cwd();
14
14
  const SHELL = await import(pathToFileURL(resolve(PACKAGE_DIR, 'wizard', 'lib', 'shell.mjs')).href);
15
15
  const CRYPTO = await import(pathToFileURL(resolve(PACKAGE_DIR, 'wizard', 'lib', 'crypto.mjs')).href);
16
16
  const PAC_TARGET = await import(pathToFileURL(resolve(PACKAGE_DIR, 'wizard', 'lib', 'pac-target.mjs')).href);
17
+ const SCRUB = await import(pathToFileURL(resolve(PACKAGE_DIR, 'wizard', 'lib', 'scrub.mjs')).href);
17
18
 
18
19
  function hasCommand(name) {
19
20
  try {
@@ -24,6 +25,20 @@ function hasCommand(name) {
24
25
  }
25
26
  }
26
27
 
28
+ /**
29
+ * Remove any PP_CLIENT_SECRET=... line from .env.local. Called when the
30
+ * user picks 1Password storage so an encrypted blob from a previous run
31
+ * doesn't linger inside the (possibly cloud-synced) project folder.
32
+ */
33
+ function removeSecretFromEnvLocal() {
34
+ const envLocalPath = join(PROJECT_DIR, '.env.local');
35
+ if (!existsSync(envLocalPath)) return;
36
+ const original = readFileSync(envLocalPath, 'utf-8');
37
+ if (!/^PP_CLIENT_SECRET=/m.test(original)) return;
38
+ const cleaned = original.replace(/^PP_CLIENT_SECRET=.*\r?\n?/m, '');
39
+ writeFileSync(envLocalPath, cleaned, 'utf-8');
40
+ }
41
+
27
42
  function normalizeUrl(value) {
28
43
  return String(value || '').trim().replace(/\/+$/, '').toLowerCase();
29
44
  }
@@ -34,7 +49,8 @@ function warnOnUrlDrift(log, key, stateUrl, credentialUrl) {
34
49
  }
35
50
 
36
51
  function formatPacAuthCreateError(profileName, url, output = '') {
37
- const lines = String(output || '').trim().split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
52
+ const scrubbed = SCRUB.scrubSecrets(String(output || ''));
53
+ const lines = scrubbed.trim().split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
38
54
  const detail = lines.find((line) => /^Error:/i.test(line)) || lines.at(-1) || 'pac auth create failed without a readable error message.';
39
55
  return [
40
56
  `${profileName} profile failed to create for ${url}.`,
@@ -93,7 +109,7 @@ function installPreCommitHook(log) {
93
109
 
94
110
  function runLivePac(log, pac, args, opts = {}) {
95
111
  return new Promise((resolvePromise) => {
96
- log.info(`$ ${SHELL.formatCommandForLog(pac, args)}`);
112
+ log.info(`$ ${SCRUB.scrubSecrets(SHELL.formatCommandForLog(pac, args))}`);
97
113
  const child = SHELL.spawnSafe(pac, args, { cwd: PROJECT_DIR, stdio: ['ignore', 'pipe', 'pipe'] });
98
114
  let settled = false;
99
115
  const timeout = opts.timeoutMs
@@ -260,6 +276,10 @@ export default {
260
276
  if (authMode === '1password' && state.OP_VAULT && state.OP_ITEM) {
261
277
  const vault = state.OP_VAULT;
262
278
  const itemName = state.OP_ITEM;
279
+ // 1Password mode: ensure no encrypted PP_CLIENT_SECRET lingers in .env.local
280
+ // and remove the out-of-tree secret cache.
281
+ try { removeSecretFromEnvLocal(); } catch { /* best-effort */ }
282
+ try { clearSecretCache(); } catch { /* best-effort */ }
263
283
  const envContent = [
264
284
  '# .env - Safe to commit. Contains 1Password references, not secrets.',
265
285
  `# Generated by setup wizard on ${dateStr}`,
@@ -326,6 +346,9 @@ export default {
326
346
  const itemName = String(answers.OP_ITEM || state.OP_ITEM || '').trim();
327
347
  if (!hasCommand('op')) throw new Error('1Password storage was selected, but op CLI is not available.');
328
348
  if (!vault || !itemName) throw new Error('1Password vault and item name are required.');
349
+ // 1Password mode: scrub any prior PP_CLIENT_SECRET line and OS-temp cache.
350
+ try { removeSecretFromEnvLocal(); } catch { /* best-effort */ }
351
+ try { clearSecretCache(); } catch { /* best-effort */ }
329
352
  const envContent = [
330
353
  '# .env - Safe to commit. Contains 1Password references, not secrets.',
331
354
  `# Generated by setup wizard on ${dateStr}`,
@@ -364,6 +387,9 @@ export default {
364
387
  if (platform() !== 'win32') {
365
388
  try { chmodSync(join(PROJECT_DIR, '.env.local'), 0o600); } catch { /* best effort */ }
366
389
  }
390
+ // Mirror the encrypted secret to the OS-temp cache so a wizard restart
391
+ // can recover without prompting (and without touching .env.local).
392
+ try { persistSecretToCache(secret); } catch { /* best-effort */ }
367
393
  log.ok('Wrote .env.local');
368
394
  }
369
395