@pacaf/wizard-ux 3.0.12 → 3.1.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/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-BIqBKzCb.js"></script>
31
+ <script type="module" crossorigin src="/assets/index-C-wYJNnd.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.12",
3
+ "version": "3.1.0",
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.4"
41
+ "@pacaf/wizard": "3.2.0"
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 configSeedRoutes from './routes/config-seed.mjs';
17
18
  import { detectCloudSync, cloudSyncWarning } from '../../wizard/lib/cloud-sync-detect.mjs';
18
19
 
19
20
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -84,6 +85,7 @@ await app.register(stepsRoutes, { prefix: '/api/steps', rootDir: ROOT_DIR });
84
85
  await app.register(streamRoutes, { prefix: '/api/steps', rootDir: ROOT_DIR });
85
86
  await app.register(ptyRoutes, { rootDir: ROOT_DIR, csrfToken: CSRF_TOKEN });
86
87
  await app.register(onepasswordRoutes, { prefix: '/api/1password' });
88
+ await app.register(configSeedRoutes, { prefix: '/api/config-seed', rootDir: ROOT_DIR });
87
89
 
88
90
  // Serve the UI — prebuilt dist/ by default; vite middleware only in dev mode
89
91
  const distDir = join(UX_DIR, 'dist');
@@ -0,0 +1,125 @@
1
+ // wizard-ux/server/lib/config-seed-allowlist.mjs
2
+ // Single source of truth for which wizard-state keys are portable across projects.
3
+ // Anything not in this list is excluded from export and rejected on import.
4
+ // New state keys added in future steps are excluded by default — opt in explicitly.
5
+
6
+ export const SEED_VERSION = 1;
7
+
8
+ /** @typedef {'string'|'string[]'|'stringMap'} SeedType */
9
+ /** @typedef {{ key: string, type: SeedType, label: string }} SeedKeySpec */
10
+
11
+ /** @type {SeedKeySpec[]} */
12
+ export const CONFIG_SEED_KEYS = [
13
+ // Publisher (org-level)
14
+ { key: 'PUBLISHER_PREFIX', type: 'string', label: 'Publisher prefix' },
15
+ { key: 'PUBLISHER_ID', type: 'string', label: 'Publisher ID' },
16
+ { key: 'PUBLISHER_NAME', type: 'string', label: 'Publisher name' },
17
+ { key: 'PUBLISHER_DISPLAY_NAME', type: 'string', label: 'Publisher display name' },
18
+ { key: 'CHOICE_VALUE_PREFIX', type: 'string', label: 'Choice value prefix' },
19
+
20
+ // Environments
21
+ { key: 'PP_ENV_DEV', type: 'string', label: 'Dev environment URL' },
22
+ { key: 'PP_ENV_TEST', type: 'string', label: 'Test environment URL' },
23
+ { key: 'PP_ENV_PROD', type: 'string', label: 'Prod environment URL' },
24
+
25
+ // Auth profile references (NOT the secrets themselves)
26
+ { key: 'AUTH_PROFILE_TYPE', type: 'string', label: 'Auth profile type' },
27
+ { key: 'OP_VAULT', type: 'string', label: '1Password vault' },
28
+ { key: 'OP_ITEM', type: 'string', label: '1Password item' },
29
+
30
+ // Agent preference
31
+ { key: 'CODING_AGENT', type: 'string', label: 'Coding agent' },
32
+
33
+ // Connectors
34
+ { key: 'CONNECTOR_API_IDS', type: 'string[]', label: 'Selected connector apiIds' },
35
+ { key: 'CONNECTOR_CONNECTION_IDS', type: 'stringMap', label: 'Connection IDs by apiId' },
36
+ { key: 'CUSTOM_CONNECTORS', type: 'string[]', label: 'Custom connector entries' },
37
+ ];
38
+
39
+ const KEY_BY_NAME = new Map(CONFIG_SEED_KEYS.map((s) => [s.key, s]));
40
+
41
+ // Second-line defense: even if someone adds a key to the allow-list by mistake,
42
+ // reject any key name that looks like a secret.
43
+ const SECRET_KEY_PATTERN = /secret|token|password|client_id|client_secret|private_key|api[_-]?key/i;
44
+
45
+ /** Filter a state object down to the allow-listed keys. Drops empty / undefined values. */
46
+ export function filterToSeed(state) {
47
+ const out = {};
48
+ for (const spec of CONFIG_SEED_KEYS) {
49
+ const raw = state?.[spec.key];
50
+ if (raw === undefined || raw === null || raw === '') continue;
51
+ if (spec.type === 'string[]' && Array.isArray(raw) && raw.length === 0) continue;
52
+ if (spec.type === 'stringMap' && typeof raw === 'object' && Object.keys(raw).length === 0) continue;
53
+ out[spec.key] = raw;
54
+ }
55
+ return out;
56
+ }
57
+
58
+ /**
59
+ * Validate an inbound payload (the `values` object from an uploaded seed file).
60
+ * Returns { applied: {key,value}[], skipped: {key,reason}[] }.
61
+ * Never throws on per-key issues — collects them as skips so the UI can show them.
62
+ */
63
+ export function validateSeed(values) {
64
+ const applied = [];
65
+ const skipped = [];
66
+
67
+ if (!values || typeof values !== 'object' || Array.isArray(values)) {
68
+ return { applied: [], skipped: [{ key: '<root>', reason: 'Payload must be a JSON object.' }] };
69
+ }
70
+
71
+ for (const [key, value] of Object.entries(values)) {
72
+ if (SECRET_KEY_PATTERN.test(key)) {
73
+ skipped.push({ key, reason: 'Key name looks like a secret; refusing to import.' });
74
+ continue;
75
+ }
76
+ const spec = KEY_BY_NAME.get(key);
77
+ if (!spec) {
78
+ skipped.push({ key, reason: 'Key is not in the allow-list.' });
79
+ continue;
80
+ }
81
+ const checked = checkType(spec, value);
82
+ if (checked.ok) {
83
+ applied.push({ key, value: checked.value });
84
+ } else {
85
+ skipped.push({ key, reason: checked.reason });
86
+ }
87
+ }
88
+
89
+ return { applied, skipped };
90
+ }
91
+
92
+ function checkType(spec, value) {
93
+ if (spec.type === 'string') {
94
+ if (typeof value !== 'string') return { ok: false, reason: `Expected string, got ${typeOf(value)}.` };
95
+ if (value.length > 2048) return { ok: false, reason: 'String value exceeds 2048 chars.' };
96
+ return { ok: true, value };
97
+ }
98
+ if (spec.type === 'string[]') {
99
+ if (!Array.isArray(value)) return { ok: false, reason: `Expected array of strings, got ${typeOf(value)}.` };
100
+ if (value.length > 256) return { ok: false, reason: 'Array exceeds 256 entries.' };
101
+ for (const item of value) {
102
+ if (typeof item !== 'string') return { ok: false, reason: 'Array contains non-string entry.' };
103
+ }
104
+ return { ok: true, value };
105
+ }
106
+ if (spec.type === 'stringMap') {
107
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
108
+ return { ok: false, reason: `Expected object of string-to-string, got ${typeOf(value)}.` };
109
+ }
110
+ const keys = Object.keys(value);
111
+ if (keys.length > 256) return { ok: false, reason: 'Object exceeds 256 entries.' };
112
+ for (const k of keys) {
113
+ if (SECRET_KEY_PATTERN.test(k)) return { ok: false, reason: `Inner key ${k} looks like a secret.` };
114
+ if (typeof value[k] !== 'string') return { ok: false, reason: `Value at ${k} is not a string.` };
115
+ }
116
+ return { ok: true, value };
117
+ }
118
+ return { ok: false, reason: `Unknown allow-list type: ${spec.type}` };
119
+ }
120
+
121
+ function typeOf(v) {
122
+ if (Array.isArray(v)) return 'array';
123
+ if (v === null) return 'null';
124
+ return typeof v;
125
+ }
@@ -0,0 +1,94 @@
1
+ // Routes for /api/config-seed — export/import portable wizard configuration "seeds".
2
+ // A seed is an allow-listed subset of .wizard-state.json — never secrets.
3
+ import { readState, writeState } from '../lib/state-bridge.mjs';
4
+ import {
5
+ CONFIG_SEED_KEYS, SEED_VERSION, filterToSeed, validateSeed,
6
+ } from '../lib/config-seed-allowlist.mjs';
7
+
8
+ const MAX_IMPORT_BYTES = 64 * 1024;
9
+
10
+ export default async function configSeedRoutes(app, opts) {
11
+ const { rootDir } = opts;
12
+
13
+ // Metadata about what is exportable. Lets the UI render a preview/legend.
14
+ app.get('/keys', async () => ({
15
+ version: SEED_VERSION,
16
+ keys: CONFIG_SEED_KEYS.map(({ key, type, label }) => ({ key, type, label })),
17
+ }));
18
+
19
+ // Download the current state, filtered to the allow-list.
20
+ app.get('/export', async (_req, reply) => {
21
+ const state = readState(rootDir);
22
+ const values = filterToSeed(state);
23
+ const payload = {
24
+ $schema: 'https://aka.ms/pacaf/wizard-config/v1',
25
+ version: SEED_VERSION,
26
+ exportedAt: new Date().toISOString(),
27
+ values,
28
+ };
29
+ const filename = `pacaf-wizard-config-${new Date().toISOString().slice(0, 10)}.json`;
30
+ reply
31
+ .header('Content-Type', 'application/json; charset=utf-8')
32
+ .header('Content-Disposition', `attachment; filename="${filename}"`)
33
+ .send(JSON.stringify(payload, null, 2) + '\n');
34
+ });
35
+
36
+ // Import a seed file. Body = parsed seed JSON. Validates against allow-list and merges.
37
+ // Modes:
38
+ // merge (default) — only write keys whose current value is empty/missing
39
+ // replace-allowlisted — overwrite every allow-listed key the seed provides
40
+ app.post('/import', async (req, reply) => {
41
+ const raw = req.body;
42
+ const size = Buffer.byteLength(JSON.stringify(raw ?? {}), 'utf-8');
43
+ if (size > MAX_IMPORT_BYTES) {
44
+ return reply.code(413).send({ error: `Payload too large (${size} > ${MAX_IMPORT_BYTES} bytes).` });
45
+ }
46
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
47
+ return reply.code(400).send({ error: 'Body must be a JSON object.' });
48
+ }
49
+ const mode = String(raw.mode || 'merge');
50
+ if (mode !== 'merge' && mode !== 'replace-allowlisted') {
51
+ return reply.code(400).send({ error: `Unknown mode: ${mode}` });
52
+ }
53
+ const seed = raw.seed;
54
+ if (!seed || typeof seed !== 'object') {
55
+ return reply.code(400).send({ error: 'Body must include a `seed` object.' });
56
+ }
57
+ if (seed.version !== undefined && Number(seed.version) > SEED_VERSION) {
58
+ return reply.code(400).send({
59
+ error: `Seed version ${seed.version} is newer than this wizard supports (max ${SEED_VERSION}). Upgrade the wizard.`,
60
+ });
61
+ }
62
+ const { applied, skipped } = validateSeed(seed.values || {});
63
+
64
+ const current = readState(rootDir);
65
+ const partial = {};
66
+ const written = [];
67
+ const preserved = [];
68
+
69
+ for (const { key, value } of applied) {
70
+ const existing = current[key];
71
+ const isEmpty = existing === undefined
72
+ || existing === null
73
+ || existing === ''
74
+ || (Array.isArray(existing) && existing.length === 0)
75
+ || (typeof existing === 'object' && !Array.isArray(existing) && Object.keys(existing).length === 0);
76
+
77
+ if (mode === 'merge' && !isEmpty) {
78
+ preserved.push({ key, reason: 'Existing value preserved (merge mode).' });
79
+ continue;
80
+ }
81
+ partial[key] = value;
82
+ written.push({ key });
83
+ }
84
+
85
+ if (Object.keys(partial).length > 0) writeState(rootDir, partial);
86
+
87
+ return {
88
+ mode,
89
+ written,
90
+ preserved,
91
+ skipped,
92
+ };
93
+ });
94
+ }
@@ -16,6 +16,15 @@ function pickTargetEnvUrl(state) {
16
16
  return { target, environmentUrl: raw.replace(/\/$/, '') };
17
17
  }
18
18
 
19
+ // Append ?hideNavBar=true (or & form) so the Power Apps "purple bar" is hidden
20
+ // by default for every Code App built from this template. See issue #44 and
21
+ // 04-deployment.instructions.md ("Default play URL: ?hideNavBar=true").
22
+ function withHideNavBar(url) {
23
+ if (!url) return url;
24
+ if (/[?&]hideNavBar=/i.test(url)) return url;
25
+ return url + (url.includes('?') ? '&' : '?') + 'hideNavBar=true';
26
+ }
27
+
19
28
  function readPowerAppInfo(rootDir, state) {
20
29
  const projectDir = resolve(rootDir, String(state.PROJECT_DIR || '.'));
21
30
  const powerConfigPath = join(projectDir, 'power.config.json');
@@ -43,7 +52,7 @@ function readPowerAppInfo(rootDir, state) {
43
52
  appId,
44
53
  targetEnv: target,
45
54
  environmentUrl,
46
- launchUrl: deployedUrl,
55
+ launchUrl: withHideNavBar(deployedUrl),
47
56
  };
48
57
  }
49
58
 
@@ -40,7 +40,9 @@ function runCommand(log, command, opts = {}) {
40
40
  function runFile(log, file, args, opts = {}) {
41
41
  return new Promise((resolvePromise) => {
42
42
  log.info(`$ ${SHELL.formatCommandForLog(file, args)}`);
43
- const child = SHELL.spawnSafe(file, args, { cwd: opts.cwd || process.cwd(), stdio: ['ignore', 'pipe', 'pipe'] });
43
+ const spawnOpts = { cwd: opts.cwd || process.cwd(), stdio: ['ignore', 'pipe', 'pipe'] };
44
+ if (opts.env) spawnOpts.env = opts.env;
45
+ const child = SHELL.spawnSafe(file, args, spawnOpts);
44
46
  child.stdout.setEncoding('utf-8');
45
47
  child.stderr.setEncoding('utf-8');
46
48
  child.stdout.on('data', (chunk) => log.info(String(chunk).trimEnd()));
@@ -53,6 +55,47 @@ function runFile(log, file, args, opts = {}) {
53
55
  });
54
56
  }
55
57
 
58
+ // Build a noisy-but-reassuring env for `npm install` / `pnpm install` so the
59
+ // SSE-piped output stops looking frozen during long cold installs:
60
+ // - `npm_config_loglevel=http` is added at the CLI level (so it shows the
61
+ // `npm http fetch ...` per-package line).
62
+ // - `npm_config_progress=true` keeps progress hints on (npm still suppresses
63
+ // its TTY bar but at least won't strip extra hints).
64
+ // - `FORCE_COLOR=0` keeps ANSI escapes out of the SSE stream.
65
+ // - `CI` is unset to avoid npm dropping output thinking it is in CI.
66
+ function installEnv() {
67
+ const env = { ...process.env, npm_config_progress: 'true', FORCE_COLOR: '0' };
68
+ delete env.CI;
69
+ return env;
70
+ }
71
+
72
+ // Detect whether `pnpm` is available on PATH. pnpm prints staged progress
73
+ // (`Progress: resolved X, reused Y, downloaded Z`) even in non-TTY mode and
74
+ // is materially faster on a cold cache, so prefer it when present.
75
+ function detectPnpm() {
76
+ try {
77
+ execFileSync(process.platform === 'win32' ? 'where' : 'which', ['pnpm'], { stdio: 'ignore' });
78
+ return true;
79
+ } catch {
80
+ return false;
81
+ }
82
+ }
83
+
84
+ async function runInstall(log, { stage, label, projectDir, pnpm, mode, packages = [] }) {
85
+ log.info('');
86
+ log.info(`[${stage}] ${label} (typically 30s–3min on a cold cache)…`);
87
+ const bin = pnpm
88
+ ? toolCommand('pnpm')
89
+ : toolCommand('npm');
90
+ const baseArgs = pnpm
91
+ ? (mode === 'base' ? ['install'] : mode === 'dev' ? ['add', '-D', ...packages] : ['add', ...packages])
92
+ : (mode === 'base' ? ['install'] : mode === 'dev' ? ['install', '-D', ...packages] : ['install', ...packages]);
93
+ const noisyArgs = pnpm
94
+ ? ['--reporter=append-only', ...baseArgs]
95
+ : ['--loglevel=http', '--no-audit', '--no-fund', ...baseArgs];
96
+ return runFile(log, bin, noisyArgs, { cwd: projectDir, env: installEnv() });
97
+ }
98
+
56
99
  function toolCommand(name) {
57
100
  return process.platform === 'win32' ? `${name}.cmd` : name;
58
101
  }
@@ -233,16 +276,32 @@ export default {
233
276
  }
234
277
 
235
278
  log.info('Installing dependencies...');
236
- if (await runFile(log, toolCommand('npm'), ['install'], { cwd: projectDir })) log.ok('Base dependencies installed');
237
- else log.warn('Base dependency install reported errors; continuing to merge required packages.');
279
+ const pnpm = detectPnpm();
280
+ if (pnpm) {
281
+ log.info('Detected pnpm — using it for faster installs and shared dependency cache.');
282
+ } else {
283
+ log.info('pnpm not found — using npm. Tip: `corepack enable && corepack prepare pnpm@latest --activate` makes Step 7 noticeably faster.');
284
+ }
285
+
286
+ if (await runInstall(log, { stage: '1/3', label: 'Installing base dependencies', projectDir, pnpm, mode: 'base' })) {
287
+ log.ok('[1/3] Base dependencies installed');
288
+ } else {
289
+ log.warn('[1/3] Base dependency install reported errors; continuing to merge required packages.');
290
+ }
238
291
 
239
292
  const prodPkgs = SCAFFOLD.packageSpecs(SCAFFOLD.REQUIRED_RUNTIME_PACKAGES);
240
- if (await runFile(log, toolCommand('npm'), ['install', ...prodPkgs], { cwd: projectDir })) log.ok('Runtime packages installed');
241
- else log.warn('Some runtime packages failed to install.');
293
+ if (await runInstall(log, { stage: '2/3', label: 'Installing runtime packages (React, Fluent UI, TanStack Query, SDK)', projectDir, pnpm, mode: 'prod', packages: prodPkgs })) {
294
+ log.ok('[2/3] Runtime packages installed');
295
+ } else {
296
+ log.warn('[2/3] Some runtime packages failed to install.');
297
+ }
242
298
 
243
299
  const devPkgs = SCAFFOLD.packageSpecs(SCAFFOLD.REQUIRED_DEV_PACKAGES);
244
- if (await runFile(log, toolCommand('npm'), ['install', '-D', ...devPkgs], { cwd: projectDir })) log.ok('Dev packages installed');
245
- else log.warn('Some dev packages failed to install.');
300
+ if (await runInstall(log, { stage: '3/3', label: 'Installing dev dependencies (Vitest, ESLint, Playwright, @pacaf/scripts)', projectDir, pnpm, mode: 'dev', packages: devPkgs })) {
301
+ log.ok('[3/3] Dev packages installed');
302
+ } else {
303
+ log.warn('[3/3] Some dev packages failed to install.');
304
+ }
246
305
 
247
306
  SCAFFOLD.writeConfig(projectDir, foundationLogger);
248
307
  SCAFFOLD.mergePackageJsonScripts(projectDir, foundationLogger);
@@ -82,6 +82,16 @@ function runFileCapture(log, file, args, opts = {}) {
82
82
 
83
83
  const PAC_HTTP_ERROR_RE = /HTTP error status:\s*[45]\d\d/i;
84
84
 
85
+ // Append ?hideNavBar=true (or &hideNavBar=true if the URL already has a query
86
+ // string) so the Power Apps "purple bar" — the top chrome rendered by the
87
+ // Power Apps host around any Code App — is hidden by default. See
88
+ // .github/instructions/04-deployment.instructions.md and issue #44.
89
+ function withHideNavBar(url) {
90
+ if (!url) return url;
91
+ if (/[?&]hideNavBar=/i.test(url)) return url;
92
+ return url + (url.includes('?') ? '&' : '?') + 'hideNavBar=true';
93
+ }
94
+
85
95
  function resolveCredentialValues(state) {
86
96
  return PAC_TARGET.resolveCredentialValues({
87
97
  rootDir: PROJECT_DIR,
@@ -198,10 +208,12 @@ export default {
198
208
  // "The app was successfully published. URL: https://apps.powerapps.com/play/e/<envId>/a/<appId>"
199
209
  // Capture the first matching apps.powerapps.com/play URL and persist it
200
210
  // to wizard state so the Summary can show the real launch URL.
211
+ // Always append ?hideNavBar=true so the deployed app hides the Power
212
+ // Apps "purple bar" by default (see issue #44 and 04-deployment.instructions.md).
201
213
  const deployedUrlMatch = pushOutput.match(/https:\/\/apps\.powerapps\.com\/play\/[^\s'"<>)]+/i);
202
214
  const stateUpdate = { PROJECT_DIR: projectDir };
203
215
  if (deployedUrlMatch) {
204
- const deployedUrl = deployedUrlMatch[0].replace(/[.,;]+$/, '');
216
+ const deployedUrl = withHideNavBar(deployedUrlMatch[0].replace(/[.,;]+$/, ''));
205
217
  stateUpdate.DEPLOYED_APP_URL = deployedUrl;
206
218
  log.ok(`App URL: ${deployedUrl}`);
207
219
  } else {