@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
package/dist/index.html
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<meta name="color-scheme" content="light dark" />
|
|
7
|
+
<title>Foundations — Power Apps Code App Setup</title>
|
|
8
|
+
<style>
|
|
9
|
+
html, body, #root { height: 100%; margin: 0; padding: 0; }
|
|
10
|
+
body {
|
|
11
|
+
font-family: 'Segoe UI Variable', 'Segoe UI', system-ui, -apple-system, sans-serif;
|
|
12
|
+
background: #0E1620;
|
|
13
|
+
color: #e8e8ec;
|
|
14
|
+
overflow: hidden;
|
|
15
|
+
}
|
|
16
|
+
/* Loader shown until React mounts — mirrors the landing page tri-color hero gradient */
|
|
17
|
+
#boot {
|
|
18
|
+
position: fixed; inset: 0;
|
|
19
|
+
display: flex; align-items: center; justify-content: center;
|
|
20
|
+
background: radial-gradient(ellipse at center, #003E70 0%, #0E1620 70%);
|
|
21
|
+
}
|
|
22
|
+
#boot .ring {
|
|
23
|
+
width: 48px; height: 48px;
|
|
24
|
+
border: 3px solid #0078D4;
|
|
25
|
+
border-top-color: transparent;
|
|
26
|
+
border-radius: 50%;
|
|
27
|
+
animation: spin 0.8s linear infinite;
|
|
28
|
+
}
|
|
29
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
30
|
+
</style>
|
|
31
|
+
<script type="module" crossorigin src="./assets/index-BVelUveV.js"></script>
|
|
32
|
+
</head>
|
|
33
|
+
<body>
|
|
34
|
+
<div id="root"><div id="boot"><div class="ring"></div></div></div>
|
|
35
|
+
</body>
|
|
36
|
+
</html>
|
package/index.html
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<meta name="color-scheme" content="light dark" />
|
|
7
|
+
<title>Foundations — Power Apps Code App Setup</title>
|
|
8
|
+
<style>
|
|
9
|
+
html, body, #root { height: 100%; margin: 0; padding: 0; }
|
|
10
|
+
body {
|
|
11
|
+
font-family: 'Segoe UI Variable', 'Segoe UI', system-ui, -apple-system, sans-serif;
|
|
12
|
+
background: #0E1620;
|
|
13
|
+
color: #e8e8ec;
|
|
14
|
+
overflow: hidden;
|
|
15
|
+
}
|
|
16
|
+
/* Loader shown until React mounts — mirrors the landing page tri-color hero gradient */
|
|
17
|
+
#boot {
|
|
18
|
+
position: fixed; inset: 0;
|
|
19
|
+
display: flex; align-items: center; justify-content: center;
|
|
20
|
+
background: radial-gradient(ellipse at center, #003E70 0%, #0E1620 70%);
|
|
21
|
+
}
|
|
22
|
+
#boot .ring {
|
|
23
|
+
width: 48px; height: 48px;
|
|
24
|
+
border: 3px solid #0078D4;
|
|
25
|
+
border-top-color: transparent;
|
|
26
|
+
border-radius: 50%;
|
|
27
|
+
animation: spin 0.8s linear infinite;
|
|
28
|
+
}
|
|
29
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
30
|
+
</style>
|
|
31
|
+
</head>
|
|
32
|
+
<body>
|
|
33
|
+
<div id="root"><div id="boot"><div class="ring"></div></div></div>
|
|
34
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
35
|
+
</body>
|
|
36
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pacaf/wizard-ux",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Browser-based setup wizard for Power Apps Code Apps (parallel to @pacaf/wizard CLI).",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/martycarreras-psnl/PAppsCAFoundations.git",
|
|
11
|
+
"directory": "packages/wizard-ux"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://martycarreras-psnl.github.io/PAppsCAFoundations",
|
|
14
|
+
"bin": {
|
|
15
|
+
"pacaf-wizard-ux": "./bin/pacaf-wizard-ux.mjs"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"bin/",
|
|
19
|
+
"dist/",
|
|
20
|
+
"server/",
|
|
21
|
+
"scripts/",
|
|
22
|
+
"index.html",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"dev": "node server/index.mjs",
|
|
27
|
+
"build": "vite build",
|
|
28
|
+
"preview": "vite preview --port 5174",
|
|
29
|
+
"postinstall": "node scripts/fix-pty-perms.mjs"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@fastify/cors": "^9.0.1",
|
|
33
|
+
"@fastify/static": "^7.0.4",
|
|
34
|
+
"@fastify/websocket": "^10.0.1",
|
|
35
|
+
"@fluentui/react-components": "^9.56.0",
|
|
36
|
+
"@fluentui/react-icons": "^2.0.260",
|
|
37
|
+
"@tanstack/react-query": "^5.62.0",
|
|
38
|
+
"@xterm/addon-fit": "^0.10.0",
|
|
39
|
+
"@xterm/addon-web-links": "^0.11.0",
|
|
40
|
+
"@xterm/xterm": "^5.5.0",
|
|
41
|
+
"fastify": "^4.28.1",
|
|
42
|
+
"node-pty": "^1.1.0",
|
|
43
|
+
"react": "^19.0.0",
|
|
44
|
+
"react-dom": "^19.0.0",
|
|
45
|
+
"react-resizable-panels": "^2.1.7",
|
|
46
|
+
"react-router-dom": "^7.1.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/react": "^19.0.0",
|
|
50
|
+
"@types/react-dom": "^19.0.0",
|
|
51
|
+
"@vitejs/plugin-react": "^4.3.0",
|
|
52
|
+
"typescript": "^5.7.0",
|
|
53
|
+
"vite": "^6.0.0"
|
|
54
|
+
},
|
|
55
|
+
"engines": {
|
|
56
|
+
"node": ">=20"
|
|
57
|
+
},
|
|
58
|
+
"keywords": [
|
|
59
|
+
"power-apps",
|
|
60
|
+
"code-apps",
|
|
61
|
+
"power-platform",
|
|
62
|
+
"wizard",
|
|
63
|
+
"scaffold",
|
|
64
|
+
"browser",
|
|
65
|
+
"pacaf"
|
|
66
|
+
]
|
|
67
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Ensures node-pty's prebuilt `spawn-helper` binary is executable after npm install.
|
|
2
|
+
// Some npm/tarball extraction paths drop the +x bit on Linux/macOS, which causes
|
|
3
|
+
// `posix_spawnp failed` at runtime. Idempotent and silent on Windows.
|
|
4
|
+
import { chmodSync, statSync, existsSync } from 'node:fs';
|
|
5
|
+
import { resolve, dirname } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
if (process.platform === 'win32') process.exit(0);
|
|
9
|
+
|
|
10
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const ptyDir = resolve(__dirname, '..', 'node_modules', 'node-pty', 'prebuilds');
|
|
12
|
+
if (!existsSync(ptyDir)) process.exit(0);
|
|
13
|
+
|
|
14
|
+
const arch = process.arch;
|
|
15
|
+
const platform = process.platform === 'darwin' ? 'darwin' : 'linux';
|
|
16
|
+
const candidates = [
|
|
17
|
+
`${platform}-${arch}`,
|
|
18
|
+
`${platform}-x64`,
|
|
19
|
+
`${platform}-arm64`,
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
for (const folder of candidates) {
|
|
23
|
+
const helper = resolve(ptyDir, folder, 'spawn-helper');
|
|
24
|
+
if (!existsSync(helper)) continue;
|
|
25
|
+
try {
|
|
26
|
+
const mode = statSync(helper).mode;
|
|
27
|
+
if ((mode & 0o111) === 0) {
|
|
28
|
+
chmodSync(helper, mode | 0o755);
|
|
29
|
+
console.log(`[wizard-ux] chmod +x ${helper.replace(process.cwd(), '.')}`);
|
|
30
|
+
}
|
|
31
|
+
} catch (err) {
|
|
32
|
+
console.warn(`[wizard-ux] could not chmod ${helper}: ${err.message}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
package/server/index.mjs
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// wizard-ux/server/index.mjs — Fastify server bridging WizardUX UI to wizard internals.
|
|
2
|
+
// Binds to 127.0.0.1 only. Single CSRF token issued at startup; required on mutating routes.
|
|
3
|
+
import Fastify from 'fastify';
|
|
4
|
+
import cors from '@fastify/cors';
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { resolve, dirname, join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { randomBytes } from 'node:crypto';
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { createServer as createViteServer } from 'vite';
|
|
11
|
+
|
|
12
|
+
import stateRoutes from './routes/state.mjs';
|
|
13
|
+
import systemRoutes from './routes/system.mjs';
|
|
14
|
+
import stepsRoutes from './routes/steps.mjs';
|
|
15
|
+
import streamRoutes from './routes/stream.mjs';
|
|
16
|
+
import ptyRoutes from './routes/pty.mjs';
|
|
17
|
+
import onepasswordRoutes from './routes/onepassword.mjs';
|
|
18
|
+
|
|
19
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const UX_DIR = resolve(__dirname, '..');
|
|
21
|
+
const ROOT_DIR = resolve(UX_DIR, '..');
|
|
22
|
+
|
|
23
|
+
const HOST = '127.0.0.1';
|
|
24
|
+
const PORT = Number(process.env.WIZARD_UX_PORT || 5174);
|
|
25
|
+
const IS_PROD = process.env.NODE_ENV === 'production';
|
|
26
|
+
|
|
27
|
+
const CSRF_TOKEN = randomBytes(24).toString('hex');
|
|
28
|
+
|
|
29
|
+
const app = Fastify({
|
|
30
|
+
logger: { level: process.env.LOG_LEVEL || 'info' },
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
await app.register(cors, {
|
|
34
|
+
origin: (origin, cb) => {
|
|
35
|
+
// Same-origin requests have no Origin header; accept those.
|
|
36
|
+
// Otherwise only accept the well-known localhost origins.
|
|
37
|
+
if (!origin) return cb(null, true);
|
|
38
|
+
if (origin === `http://${HOST}:${PORT}` || origin === `http://localhost:${PORT}`) return cb(null, true);
|
|
39
|
+
if (!IS_PROD && (origin === `http://${HOST}:5175` || origin === 'http://localhost:5175')) return cb(null, true);
|
|
40
|
+
cb(new Error('Origin not allowed'), false);
|
|
41
|
+
},
|
|
42
|
+
credentials: true,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Expose the CSRF token to the UI on a single endpoint. UI stores it in memory and
|
|
46
|
+
// echoes it on mutating calls. Token rotates per server start.
|
|
47
|
+
app.get('/api/handshake', async () => ({
|
|
48
|
+
csrfToken: CSRF_TOKEN,
|
|
49
|
+
rootDir: ROOT_DIR,
|
|
50
|
+
startedAt: new Date().toISOString(),
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
// Guard mutating routes
|
|
54
|
+
app.addHook('onRequest', async (req, reply) => {
|
|
55
|
+
if (!req.url.startsWith('/api/')) return;
|
|
56
|
+
if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return;
|
|
57
|
+
if (req.url === '/api/handshake') return;
|
|
58
|
+
const token = req.headers['x-wizard-token'];
|
|
59
|
+
if (token !== CSRF_TOKEN) {
|
|
60
|
+
reply.code(403).send({ error: 'Invalid or missing CSRF token' });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Activity tracker for auto-shutdown (10 min idle)
|
|
65
|
+
let lastActivity = Date.now();
|
|
66
|
+
app.addHook('onRequest', async (req) => {
|
|
67
|
+
if (req.url.startsWith('/api/')) lastActivity = Date.now();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Routes
|
|
71
|
+
await app.register(stateRoutes, { prefix: '/api/state', rootDir: ROOT_DIR });
|
|
72
|
+
await app.register(systemRoutes, { prefix: '/api/system', rootDir: ROOT_DIR });
|
|
73
|
+
await app.register(stepsRoutes, { prefix: '/api/steps', rootDir: ROOT_DIR });
|
|
74
|
+
await app.register(streamRoutes, { prefix: '/api/steps', rootDir: ROOT_DIR });
|
|
75
|
+
await app.register(ptyRoutes, { rootDir: ROOT_DIR, csrfToken: CSRF_TOKEN });
|
|
76
|
+
await app.register(onepasswordRoutes, { prefix: '/api/1password' });
|
|
77
|
+
|
|
78
|
+
// Serve the UI — Vite middleware in dev, static dist/ in prod
|
|
79
|
+
if (IS_PROD) {
|
|
80
|
+
const distDir = join(UX_DIR, 'dist');
|
|
81
|
+
if (existsSync(distDir)) {
|
|
82
|
+
const fastifyStatic = (await import('@fastify/static')).default;
|
|
83
|
+
await app.register(fastifyStatic, { root: distDir, prefix: '/' });
|
|
84
|
+
app.setNotFoundHandler((req, reply) => {
|
|
85
|
+
if (req.url.startsWith('/api/')) return reply.code(404).send({ error: 'Not found' });
|
|
86
|
+
// SPA fallback
|
|
87
|
+
reply.sendFile('index.html');
|
|
88
|
+
});
|
|
89
|
+
} else {
|
|
90
|
+
app.log.warn('Production build not found in dist/. Run `npm run build` inside wizard-ux.');
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
const vite = await createViteServer({
|
|
94
|
+
root: UX_DIR,
|
|
95
|
+
server: { middlewareMode: true, hmr: { port: 5176 } },
|
|
96
|
+
appType: 'custom',
|
|
97
|
+
});
|
|
98
|
+
// Mount Vite's connect-style middleware
|
|
99
|
+
app.addHook('onRequest', async (req, reply) => {
|
|
100
|
+
if (req.url.startsWith('/api/') || req.url.startsWith('/ws/')) return;
|
|
101
|
+
return new Promise((resolveP) => {
|
|
102
|
+
vite.middlewares(req.raw, reply.raw, () => resolveP());
|
|
103
|
+
reply.raw.on('finish', () => resolveP());
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
app.setNotFoundHandler(async (req, reply) => {
|
|
107
|
+
if (req.url.startsWith('/api/') || req.url.startsWith('/ws/')) return reply.code(404).send({ error: 'Not found' });
|
|
108
|
+
try {
|
|
109
|
+
const indexHtml = readFileSync(join(UX_DIR, 'index.html'), 'utf-8');
|
|
110
|
+
const html = await vite.transformIndexHtml(req.url, indexHtml);
|
|
111
|
+
reply.type('text/html').send(html);
|
|
112
|
+
} catch (e) {
|
|
113
|
+
reply.code(500).send(String(e));
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
await app.listen({ host: HOST, port: PORT });
|
|
119
|
+
|
|
120
|
+
const url = `http://${HOST}:${PORT}`;
|
|
121
|
+
console.log('');
|
|
122
|
+
console.log(' ╭─────────────────────────────────────────────╮');
|
|
123
|
+
console.log(' │ WizardUX is running │');
|
|
124
|
+
console.log(` │ ${url.padEnd(43)}│`);
|
|
125
|
+
console.log(' ╰─────────────────────────────────────────────╯');
|
|
126
|
+
console.log('');
|
|
127
|
+
|
|
128
|
+
if (process.env.WIZARD_UX_OPEN !== '0') {
|
|
129
|
+
// Best-effort browser open
|
|
130
|
+
const opener = process.platform === 'win32'
|
|
131
|
+
? spawn('cmd', ['/c', 'start', '', url], { detached: true, stdio: 'ignore' })
|
|
132
|
+
: spawn(process.platform === 'darwin' ? 'open' : 'xdg-open', [url], { detached: true, stdio: 'ignore' });
|
|
133
|
+
opener.on('error', () => { /* best effort */ });
|
|
134
|
+
opener.unref();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Idle auto-shutdown
|
|
138
|
+
const IDLE_MS = 10 * 60 * 1000;
|
|
139
|
+
setInterval(() => {
|
|
140
|
+
if (Date.now() - lastActivity > IDLE_MS) {
|
|
141
|
+
app.log.info('Idle for 10 minutes — shutting down.');
|
|
142
|
+
app.close().then(() => process.exit(0));
|
|
143
|
+
}
|
|
144
|
+
}, 60_000).unref();
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// wizard-ux/server/lib/dataverse-bridge.mjs
|
|
2
|
+
// Refreshes wizard/lib/state.mjs from disk before each Dataverse call so the
|
|
3
|
+
// existing dvGet/dvPost helpers see the latest state written by WizardUX.
|
|
4
|
+
import { dirname, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const WIZARD_LIB = resolve(__dirname, '..', '..', '..', 'wizard', 'lib');
|
|
9
|
+
|
|
10
|
+
const stateMod = await import(pathToFileURL(resolve(WIZARD_LIB, 'state.mjs')).href);
|
|
11
|
+
const dvMod = await import(pathToFileURL(resolve(WIZARD_LIB, 'dataverse.mjs')).href);
|
|
12
|
+
const secretsMod = await import(pathToFileURL(resolve(WIZARD_LIB, 'secrets.mjs')).href);
|
|
13
|
+
|
|
14
|
+
function refresh() {
|
|
15
|
+
// Reload disk state into the wizard's in-memory singleton
|
|
16
|
+
stateMod.loadState();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function dvGet(path) {
|
|
20
|
+
refresh();
|
|
21
|
+
dvMod.clearTokenCache();
|
|
22
|
+
return dvMod.dvGet(path);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function dvPost(path, body, opts) {
|
|
26
|
+
refresh();
|
|
27
|
+
dvMod.clearTokenCache();
|
|
28
|
+
return dvMod.dvPost(path, body, opts);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function setSecret(value) {
|
|
32
|
+
secretsMod.setSecret(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getSecret() {
|
|
36
|
+
return secretsMod.getSecret();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function recoverSecret() {
|
|
40
|
+
refresh();
|
|
41
|
+
return secretsMod.recoverSecret();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function clearSecret() {
|
|
45
|
+
secretsMod.clearSecret();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function hasUsableSecret() {
|
|
49
|
+
if (secretsMod.getSecret()) return true;
|
|
50
|
+
return Boolean(secretsMod.recoverSecret());
|
|
51
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// wizard-ux/server/lib/process-runner.mjs
|
|
2
|
+
// Wraps child_process.spawn and emits stdout/stderr lines on an EventEmitter so SSE
|
|
3
|
+
// routes can subscribe. Holds at most the last 1000 lines per run for late joiners.
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { EventEmitter } from 'node:events';
|
|
6
|
+
import { randomBytes } from 'node:crypto';
|
|
7
|
+
|
|
8
|
+
const runs = new Map(); // runId -> Run
|
|
9
|
+
|
|
10
|
+
class Run extends EventEmitter {
|
|
11
|
+
constructor(id) {
|
|
12
|
+
super();
|
|
13
|
+
this.id = id;
|
|
14
|
+
this.lines = []; // { stream: 'stdout'|'stderr', text, ts }
|
|
15
|
+
this.status = 'pending'; // pending | running | done | error
|
|
16
|
+
this.exitCode = null;
|
|
17
|
+
this.error = null;
|
|
18
|
+
this.startedAt = null;
|
|
19
|
+
this.endedAt = null;
|
|
20
|
+
this.child = null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
push(stream, text) {
|
|
24
|
+
const evt = { stream, text, ts: Date.now() };
|
|
25
|
+
this.lines.push(evt);
|
|
26
|
+
if (this.lines.length > 1000) this.lines.shift();
|
|
27
|
+
this.emit('line', evt);
|
|
28
|
+
// 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);
|
|
31
|
+
if (codeMatch) {
|
|
32
|
+
this.deviceCode = { code: codeMatch[1], url: 'https://microsoft.com/devicelogin', ts: Date.now() };
|
|
33
|
+
this.emit('deviceCode', this.deviceCode);
|
|
34
|
+
} else if (urlMatch && !this.deviceCode) {
|
|
35
|
+
this.deviceCode = { code: null, url: urlMatch[1], ts: Date.now() };
|
|
36
|
+
this.emit('deviceCode', this.deviceCode);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
cancel() {
|
|
41
|
+
if (this.child && !this.child.killed) {
|
|
42
|
+
try { this.child.kill('SIGINT'); } catch { /* best-effort */ }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function newRun() {
|
|
48
|
+
const id = randomBytes(8).toString('hex');
|
|
49
|
+
const run = new Run(id);
|
|
50
|
+
runs.set(id, run);
|
|
51
|
+
// GC: clean up runs older than 30 minutes
|
|
52
|
+
setTimeout(() => runs.delete(id), 30 * 60 * 1000).unref?.();
|
|
53
|
+
return run;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function getRun(id) {
|
|
57
|
+
return runs.get(id);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function spawnInRun(run, file, args, opts = {}) {
|
|
61
|
+
return new Promise((resolveP) => {
|
|
62
|
+
run.status = 'running';
|
|
63
|
+
run.startedAt = Date.now();
|
|
64
|
+
run.push('stdout', `$ ${file} ${args.join(' ')}\n`);
|
|
65
|
+
const child = spawn(file, args, { ...opts, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
66
|
+
run.child = child;
|
|
67
|
+
child.stdout.setEncoding('utf-8');
|
|
68
|
+
child.stderr.setEncoding('utf-8');
|
|
69
|
+
child.stdout.on('data', (chunk) => run.push('stdout', chunk));
|
|
70
|
+
child.stderr.on('data', (chunk) => run.push('stderr', chunk));
|
|
71
|
+
child.on('error', (err) => {
|
|
72
|
+
run.status = 'error';
|
|
73
|
+
run.error = err.message;
|
|
74
|
+
run.endedAt = Date.now();
|
|
75
|
+
run.emit('end');
|
|
76
|
+
resolveP({ ok: false, code: -1, error: err.message });
|
|
77
|
+
});
|
|
78
|
+
child.on('close', (code) => {
|
|
79
|
+
run.exitCode = code;
|
|
80
|
+
run.status = code === 0 ? 'done' : 'error';
|
|
81
|
+
run.endedAt = Date.now();
|
|
82
|
+
run.emit('end');
|
|
83
|
+
resolveP({ ok: code === 0, code });
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Run an async function with a "logger" that pushes lines into the run.
|
|
90
|
+
* Use for steps that don't shell out — e.g. Dataverse API calls, file writes.
|
|
91
|
+
*/
|
|
92
|
+
export async function runInline(run, fn) {
|
|
93
|
+
run.status = 'running';
|
|
94
|
+
run.startedAt = Date.now();
|
|
95
|
+
const log = {
|
|
96
|
+
info: (msg) => run.push('stdout', `${msg}\n`),
|
|
97
|
+
ok: (msg) => run.push('stdout', `✓ ${msg}\n`),
|
|
98
|
+
warn: (msg) => run.push('stderr', `⚠ ${msg}\n`),
|
|
99
|
+
fail: (msg) => run.push('stderr', `✗ ${msg}\n`),
|
|
100
|
+
};
|
|
101
|
+
try {
|
|
102
|
+
const result = await fn(log);
|
|
103
|
+
run.status = 'done';
|
|
104
|
+
run.exitCode = 0;
|
|
105
|
+
run.endedAt = Date.now();
|
|
106
|
+
run.emit('end');
|
|
107
|
+
return { ok: true, result };
|
|
108
|
+
} catch (err) {
|
|
109
|
+
run.status = 'error';
|
|
110
|
+
run.error = err.message;
|
|
111
|
+
run.exitCode = -1;
|
|
112
|
+
run.endedAt = Date.now();
|
|
113
|
+
log.fail(err.message);
|
|
114
|
+
run.emit('end');
|
|
115
|
+
return { ok: false, error: err.message };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// wizard-ux/server/lib/state-bridge.mjs
|
|
2
|
+
// Reads/writes the shared .wizard-state.json file. Mirrors wizard/lib/state.mjs but
|
|
3
|
+
// keeps zero global state — every call reads from disk to stay correct across multiple
|
|
4
|
+
// browser windows and concurrent CLI runs.
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, chmodSync } from 'node:fs';
|
|
6
|
+
import { platform } from 'node:os';
|
|
7
|
+
import { resolve } from 'node:path';
|
|
8
|
+
|
|
9
|
+
export function stateFilePath(rootDir) {
|
|
10
|
+
return resolve(rootDir, '.wizard-state.json');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function readState(rootDir) {
|
|
14
|
+
const path = stateFilePath(rootDir);
|
|
15
|
+
if (!existsSync(path)) return {};
|
|
16
|
+
try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return {}; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function writeState(rootDir, partial) {
|
|
20
|
+
const merged = { ...readState(rootDir), ...partial };
|
|
21
|
+
const path = stateFilePath(rootDir);
|
|
22
|
+
writeFileSync(path, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
|
|
23
|
+
if (platform() !== 'win32') {
|
|
24
|
+
try { chmodSync(path, 0o600); } catch { /* best-effort */ }
|
|
25
|
+
}
|
|
26
|
+
return merged;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function resetStateFile(rootDir) {
|
|
30
|
+
const path = stateFilePath(rootDir);
|
|
31
|
+
if (existsSync(path)) unlinkSync(path);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getCompletedStep(state) {
|
|
35
|
+
return parseInt(state.COMPLETED_STEP ?? '0', 10);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function setCompletedStep(rootDir, step) {
|
|
39
|
+
return writeState(rootDir, { COMPLETED_STEP: step });
|
|
40
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Routes for /api/1password — dynamic vault/item discovery
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const STEP3_PATH = resolve(__dirname, '..', 'steps', '03-app-registration.mjs');
|
|
7
|
+
const step3 = await import(pathToFileURL(STEP3_PATH).href);
|
|
8
|
+
|
|
9
|
+
export default async function onepasswordRoutes(fastify, opts) {
|
|
10
|
+
fastify.get('/items', async (req) => {
|
|
11
|
+
const vault = String(req.query.vault || '').trim();
|
|
12
|
+
if (!vault) return { items: [] };
|
|
13
|
+
const items = step3.listOpItems(vault);
|
|
14
|
+
return { items };
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// wizard-ux/server/routes/pty.mjs
|
|
2
|
+
// WebSocket-backed PTY bridge. Spawns a real shell in the repo root so wizard
|
|
3
|
+
// commands like `pac auth create`, `op signin`, and `pac code push` get the
|
|
4
|
+
// device-code prompts, biometric prompts, and progress bars they need.
|
|
5
|
+
//
|
|
6
|
+
// Protocol:
|
|
7
|
+
// - Binary frames (Buffer) from client = stdin bytes
|
|
8
|
+
// - Binary frames (Buffer) from server = stdout/stderr bytes
|
|
9
|
+
// - JSON text frames are control messages:
|
|
10
|
+
// client -> server: { type: 'resize', cols, rows }
|
|
11
|
+
// { type: 'init', cols, rows, cmd? } // optional cmd auto-typed once shell is ready
|
|
12
|
+
// server -> client: { type: 'exit', code, signal }
|
|
13
|
+
//
|
|
14
|
+
// Security:
|
|
15
|
+
// - Connection upgrade requires `?token=<csrf>` query parameter that matches
|
|
16
|
+
// the CSRF token issued by /api/handshake.
|
|
17
|
+
// - Server is bound to 127.0.0.1; CORS already restricts origins.
|
|
18
|
+
|
|
19
|
+
import websocketPlugin from '@fastify/websocket';
|
|
20
|
+
import * as pty from 'node-pty';
|
|
21
|
+
|
|
22
|
+
export default async function ptyRoutes(app, opts) {
|
|
23
|
+
const { rootDir, csrfToken } = opts;
|
|
24
|
+
if (!csrfToken) throw new Error('ptyRoutes requires csrfToken');
|
|
25
|
+
|
|
26
|
+
await app.register(websocketPlugin, {
|
|
27
|
+
options: { maxPayload: 1024 * 1024 },
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
app.get('/ws/pty', { websocket: true }, (socket, req) => {
|
|
31
|
+
const url = new URL(req.url, 'http://localhost');
|
|
32
|
+
const token = url.searchParams.get('token');
|
|
33
|
+
if (token !== csrfToken) {
|
|
34
|
+
app.log.warn({ ip: req.ip }, 'pty: rejected — bad token');
|
|
35
|
+
try { socket.close(4401, 'unauthorized'); } catch { /* ignore */ }
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const isWin = process.platform === 'win32';
|
|
40
|
+
const shell = isWin
|
|
41
|
+
? (process.env.COMSPEC || 'powershell.exe')
|
|
42
|
+
: (process.env.SHELL || '/bin/zsh');
|
|
43
|
+
const shellArgs = isWin ? [] : ['-l'];
|
|
44
|
+
|
|
45
|
+
let term;
|
|
46
|
+
try {
|
|
47
|
+
term = pty.spawn(shell, shellArgs, {
|
|
48
|
+
name: 'xterm-256color',
|
|
49
|
+
cols: 80,
|
|
50
|
+
rows: 24,
|
|
51
|
+
cwd: rootDir,
|
|
52
|
+
env: { ...process.env, TERM: 'xterm-256color', WIZARD_UX: '1' },
|
|
53
|
+
});
|
|
54
|
+
} catch (err) {
|
|
55
|
+
app.log.error({ err }, 'pty: spawn failed');
|
|
56
|
+
try {
|
|
57
|
+
socket.send(JSON.stringify({ type: 'error', message: `Failed to spawn shell: ${err.message}` }));
|
|
58
|
+
socket.close(1011, 'spawn-failed');
|
|
59
|
+
} catch { /* ignore */ }
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
app.log.info({ pid: term.pid, shell, cwd: rootDir }, 'pty: spawned');
|
|
64
|
+
|
|
65
|
+
term.onData((data) => {
|
|
66
|
+
try { socket.send(data); } catch { /* socket likely closed */ }
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
term.onExit(({ exitCode, signal }) => {
|
|
70
|
+
try {
|
|
71
|
+
socket.send(JSON.stringify({ type: 'exit', code: exitCode, signal }));
|
|
72
|
+
socket.close(1000, 'exit');
|
|
73
|
+
} catch { /* ignore */ }
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
socket.on('message', (raw, isBinary) => {
|
|
77
|
+
if (isBinary) {
|
|
78
|
+
try { term.write(raw); } catch { /* ignore */ }
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const text = raw.toString('utf8');
|
|
82
|
+
let msg;
|
|
83
|
+
try { msg = JSON.parse(text); } catch {
|
|
84
|
+
try { term.write(text); } catch { /* ignore */ }
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (!msg || typeof msg !== 'object') return;
|
|
88
|
+
|
|
89
|
+
switch (msg.type) {
|
|
90
|
+
case 'init': {
|
|
91
|
+
const cols = Number.isFinite(msg.cols) ? Math.max(1, msg.cols | 0) : 80;
|
|
92
|
+
const rows = Number.isFinite(msg.rows) ? Math.max(1, msg.rows | 0) : 24;
|
|
93
|
+
try { term.resize(cols, rows); } catch { /* ignore */ }
|
|
94
|
+
if (typeof msg.cmd === 'string' && msg.cmd.trim()) {
|
|
95
|
+
const cmd = msg.cmd;
|
|
96
|
+
setTimeout(() => {
|
|
97
|
+
try { term.write(`${cmd}\r`); } catch { /* ignore */ }
|
|
98
|
+
}, 350);
|
|
99
|
+
}
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
case 'resize': {
|
|
103
|
+
const cols = Math.max(1, (msg.cols | 0) || 80);
|
|
104
|
+
const rows = Math.max(1, (msg.rows | 0) || 24);
|
|
105
|
+
try { term.resize(cols, rows); } catch { /* ignore */ }
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
case 'data': {
|
|
109
|
+
if (typeof msg.data === 'string') {
|
|
110
|
+
try { term.write(msg.data); } catch { /* ignore */ }
|
|
111
|
+
}
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
default:
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
socket.on('close', () => {
|
|
120
|
+
app.log.info({ pid: term.pid }, 'pty: socket closed; killing shell');
|
|
121
|
+
try { term.kill(); } catch { /* ignore */ }
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|