@pacaf/wizard-ux 3.0.13 → 3.1.1
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-
|
|
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.
|
|
3
|
+
"version": "3.1.1",
|
|
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
|
|
41
|
+
"@pacaf/wizard": "3.2.1"
|
|
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
|
+
}
|