UNPKG

@tywalk/pcf-helper

Version:

Command line helper for building and publishing PCF controls to Dataverse.

458 lines (457 loc) 26.4 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.runSession = runSession; exports.loadConfig = loadConfig; const color_logger_1 = __importDefault(require("@tywalk/color-logger")); const path_1 = __importDefault(require("path")); const fs_1 = __importDefault(require("fs")); const readline_1 = __importDefault(require("readline")); const playwright_1 = require("playwright"); const child_process_1 = require("child_process"); const configUtil_1 = require("../util/configUtil"); function promptForWatchRestart(code, signal, attempt) { return __awaiter(this, void 0, void 0, function* () { if (!process.stdin.isTTY || !process.stdout.isTTY) { color_logger_1.default.warn('⚠️ Watch failed but terminal is non-interactive. Cannot prompt for restart.'); return false; } const reason = code !== null ? `exit code ${code}` : `signal ${signal !== null && signal !== void 0 ? signal : 'unknown'}`; const rl = readline_1.default.createInterface({ input: process.stdin, output: process.stdout, }); const prompt = `❓ PCF watch process failed (${reason}). Restart it? (y/N) [attempt ${attempt}]: `; try { const answer = yield new Promise((resolve) => { rl.question(prompt, resolve); }); const normalized = answer.trim().toLowerCase(); return normalized === 'y' || normalized === 'yes'; } catch (error) { color_logger_1.default.warn('⚠️ Could not read restart response. Not restarting watch process.', error); return false; } finally { rl.close(); } }); } /** * Loads configuration for the session task, supporting a combination of * config file, environment variables, and CLI arguments. * * Precedence (highest wins, field-level): * CLI args (handled by caller) > env vars > active profile session block * > unified config session block > legacy session.config.json > defaults * * The legacy `session.config.json` path keeps working so existing setups do * not break when upgrading. * * @param config Optional path to a legacy JSON config file. If not provided, * looks for `session.config.json` in the current working directory. * @param profileName Optional profile name. If provided, that profile's * session block layers over the top-level session block. * @returns Resolved session config values. */ function loadConfig(config, profileName) { var _a, _b, _c, _d, _e; // Legacy: load session.config.json (or user-specified config path) if it exists. let fileConfig = {}; let fileConfigLoaded = false; const configPath = path_1.default.join(process.cwd(), config || 'session.config.json'); color_logger_1.default.log(`📁 Looking for config file at: ${configPath}`); if (fs_1.default.existsSync(configPath)) { try { fileConfig = JSON.parse(fs_1.default.readFileSync(configPath, 'utf8')); fileConfigLoaded = true; } catch (e) { const message = e instanceof Error ? e.message : String(e); color_logger_1.default.error(`❌ Failed to parse config file at ${configPath}: ${message}`); return {}; } color_logger_1.default.log(`✅ Loaded config file: ${JSON.stringify(fileConfig, null, 2)}`); } // Unified pcf-helper.config.json: load top-level session block and // profile-specific session block (if a profile is in use). let unifiedSession = {}; let profileSession = {}; try { const loaded = (0, configUtil_1.loadPcfHelperConfig)(); unifiedSession = (_a = loaded.merged.session) !== null && _a !== void 0 ? _a : {}; const { profile } = (0, configUtil_1.resolveProfile)(profileName, loaded.merged); profileSession = (_b = profile === null || profile === void 0 ? void 0 : profile.session) !== null && _b !== void 0 ? _b : {}; } catch (e) { // resolveProfile throws on unknown profile name; surface but don't abort — // callers that strictly need the profile will fail later in command layer. const message = e instanceof Error ? e.message : String(e); color_logger_1.default.warn(`⚠️ Unified config profile resolution failed: ${message}`); } // Merge (lowest precedence first): legacy file → unified → profile-specific. const mergedSession = (0, configUtil_1.mergeSessionConfig)(fileConfig, unifiedSession, profileSession); if (!fileConfigLoaded && Object.keys(unifiedSession).length === 0 && Object.keys(profileSession).length === 0 && !process.env.REMOTE_ENVIRONMENT_URL) { return {}; // nothing to offer; use defaults } if (!fileConfigLoaded && process.env.REMOTE_ENVIRONMENT_URL) { color_logger_1.default.warn(`⚠️ Config file not found, using defaults or CLI/env options.`); } // Use the merged view from here down. Field-level env overrides below still // preserve env-var precedence over file-based values. const effective = mergedSession; fileConfig = effective; // Get the base URL first const remoteEnvironmentUrl = process.env.REMOTE_ENVIRONMENT_URL || fileConfig.remoteEnvironmentUrl; // Handle script argument - support both relative paths and full URLs let remoteScriptToIntercept = process.env.REMOTE_SCRIPT_TO_INTERCEPT || fileConfig.remoteScriptToIntercept; // If script is a relative path (doesn't start with http/https), combine with base URL if (remoteScriptToIntercept && remoteEnvironmentUrl && !remoteScriptToIntercept.startsWith('http')) { // Normalize the base URL (remove trailing slash) const baseUrl = remoteEnvironmentUrl.replace(/\/$/, ''); // Normalize the script path (ensure it starts with /) const scriptPath = remoteScriptToIntercept.startsWith('/') ? remoteScriptToIntercept : '/' + remoteScriptToIntercept; remoteScriptToIntercept = `${baseUrl}${scriptPath}`; } let remoteStylesheetToIntercept = process.env.REMOTE_STYLESHEET_TO_INTERCEPT || fileConfig.remoteStylesheetToIntercept; // If stylesheet is a relative path (doesn't start with http/https), combine with base URL if (remoteStylesheetToIntercept && remoteEnvironmentUrl && !remoteStylesheetToIntercept.startsWith('http')) { // Normalize the base URL (remove trailing slash) const baseUrl = remoteEnvironmentUrl.replace(/\/$/, ''); // Normalize the stylesheet path (ensure it starts with /) const stylesheetPath = remoteStylesheetToIntercept.startsWith('/') ? remoteStylesheetToIntercept : '/' + remoteStylesheetToIntercept; remoteStylesheetToIntercept = `${baseUrl}${stylesheetPath}`; } // Priority: CLI args > env vars > config file > defaults return { remoteEnvironmentUrl: remoteEnvironmentUrl, remoteScriptToIntercept: remoteScriptToIntercept, remoteStylesheetToIntercept: remoteStylesheetToIntercept, localCssPath: (_c = process.env.LOCAL_CSS_PATH) !== null && _c !== void 0 ? _c : fileConfig.localCssPath, localBundlePath: (_d = process.env.LOCAL_BUNDLE_PATH) !== null && _d !== void 0 ? _d : fileConfig.localBundlePath, startWatch: process.env.START_WATCH === 'true' || fileConfig.startWatch || false, watchRetry: process.env.WATCH_RETRY !== undefined ? process.env.WATCH_RETRY === 'true' : (_e = fileConfig.watchRetry) !== null && _e !== void 0 ? _e : true, }; } /** * Runs an ephemeral browser session that intercepts requests to the specified remote script and stylesheet URLs, serving local files instead. * It also manages session state by saving cookies and local storage to a file, allowing for persistent login sessions across runs. * The session will automatically clean up and save state on exit, including handling various exit signals and browser events. * @param remoteEnvironmentUrl The URL of the remote environment to navigate to. * @param remoteScriptToIntercept The full URL of the remote script to intercept (e.g., https://app.your-remote-environment.com/static/js/remote-control-bundle.js). * @param remoteStylesheetToIntercept The full URL of the remote stylesheet to intercept (e.g., https://app.your-remote-environment.com/static/css/remote-control-styles.css). * @param localBundlePath The local file path to the JavaScript bundle that should be served when the remote script URL is requested. * @param localCssPath The local file path to the CSS file that should be served when the remote stylesheet URL is requested. * @param startWatch Optional flag to start the session in watch mode. If true, the process will kick off "pcf-scripts start watch" in parallel to automatically rebuild the bundle on changes. * @param watchRetry Optional flag to automatically restart the watch process when it exits with a failure code. Defaults to true. * @returns A promise that resolves when the session is set up and running. The session will continue to run until the process is exited, at which point it will clean up and save state. */ function runSession(remoteEnvironmentUrl_1, remoteScriptToIntercept_1, remoteStylesheetToIntercept_1, localBundlePath_1, localCssPath_1, startWatch_1) { return __awaiter(this, arguments, void 0, function* (remoteEnvironmentUrl, remoteScriptToIntercept, remoteStylesheetToIntercept, localBundlePath, localCssPath, startWatch, watchRetry = true) { if (!remoteEnvironmentUrl) { color_logger_1.default.error('❌ Remote environment URL is required. Please provide it via CLI, config file, or environment variable.'); process.exit(1); } if (!remoteScriptToIntercept) { color_logger_1.default.error('❌ Remote script URL to intercept is required. Please provide it via CLI, config file, or environment variable.'); process.exit(1); } if (localBundlePath) { if (!fs_1.default.existsSync(localBundlePath)) { color_logger_1.default.error(`❌ Local bundle path specified does not exist at path: ${localBundlePath}`); process.exit(1); } } else { color_logger_1.default.error('❌ Local bundle path is required. Please provide it via CLI, config file, or environment variable.'); process.exit(1); } if (localCssPath && !fs_1.default.existsSync(localCssPath)) { color_logger_1.default.error(`❌ Local CSS path specified does not exist at path: ${localCssPath}`); process.exit(1); } const REMOTE_ENVIRONMENT_URL = remoteEnvironmentUrl; const REMOTE_SCRIPT_TO_INTERCEPT = remoteScriptToIntercept; const REMOTE_STYLESHEET_TO_INTERCEPT = remoteStylesheetToIntercept !== null && remoteStylesheetToIntercept !== void 0 ? remoteStylesheetToIntercept : ''; const LOCAL_BUNDLE_PATH = path_1.default.resolve(localBundlePath); const LOCAL_CSS_PATH = localCssPath ? path_1.default.resolve(localCssPath) : ''; // Debug logging for URL construction color_logger_1.default.debug('🔍 Debug - Final URLs:'); color_logger_1.default.debug(` Remote Environment: ${REMOTE_ENVIRONMENT_URL}`); color_logger_1.default.debug(` Script to intercept: ${REMOTE_SCRIPT_TO_INTERCEPT}`); color_logger_1.default.debug(` CSS to intercept: ${REMOTE_STYLESHEET_TO_INTERCEPT}`); color_logger_1.default.debug(` Local bundle path: ${LOCAL_BUNDLE_PATH}`); color_logger_1.default.debug(` Local CSS path: ${LOCAL_CSS_PATH}`); color_logger_1.default.debug(''); // Path to store your session cookies const AUTH_DIR = path_1.default.join(process.cwd(), '.auth'); const STATE_FILE = path_1.default.join(AUTH_DIR, 'state.json'); // Start watch process if requested let watchProcess; let watchRestartAttempts = 0; let isShuttingDown = false; let terminateSession; const startWatchProcess = (isRestart = false) => { var _a, _b; color_logger_1.default.log('🔧 Starting pcf-scripts watch process...'); const child = (0, child_process_1.spawn)('pcf-scripts', ['start', 'watch'], { cwd: process.cwd(), stdio: ['inherit', 'pipe', 'pipe'], shell: true }); watchProcess = child; (_a = child.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (data) => { color_logger_1.default.log(`📦 [PCF Watch] ${data.toString().trim()}`); }); (_b = child.stderr) === null || _b === void 0 ? void 0 : _b.on('data', (data) => { color_logger_1.default.warn(`⚠️ [PCF Watch] ${data.toString().trim()}`); }); child.on('exit', (code, signal) => __awaiter(this, void 0, void 0, function* () { if (watchProcess === child) { watchProcess = undefined; } if (isShuttingDown) { color_logger_1.default.log('✅ PCF watch process ended'); return; } const failed = code !== 0; if (!failed) { color_logger_1.default.log('✅ PCF watch process ended'); return; } color_logger_1.default.error(`❌ PCF watch process exited unexpectedly (code: ${code}, signal: ${signal !== null && signal !== void 0 ? signal : 'none'})`); if (!watchRetry) { const nextAttempt = watchRestartAttempts + 1; if (nextAttempt > 5) { color_logger_1.default.error('🛑 Watch restart limit of 5 reached. Terminating session.'); if (terminateSession) { yield terminateSession('watch restart limit reached'); } process.exit(1); } const shouldRestart = yield promptForWatchRestart(code, signal, nextAttempt); if (!shouldRestart) { color_logger_1.default.error('🛑 Watch restart declined. Terminating session.'); if (terminateSession) { yield terminateSession('watch restart declined'); } process.exit(1); } watchRestartAttempts = nextAttempt; color_logger_1.default.log(`🔁 Restarting PCF watch process (manual attempt ${watchRestartAttempts})...`); startWatchProcess(true); return; } watchRestartAttempts += 1; color_logger_1.default.log(`🔁 Restarting PCF watch process (attempt ${watchRestartAttempts})...`); startWatchProcess(true); })); child.on('error', (error) => __awaiter(this, void 0, void 0, function* () { color_logger_1.default.error('❌ Failed to start PCF watch process:', error); if (isRestart) { color_logger_1.default.error('❌ Restart attempt failed. Terminating session.'); if (terminateSession) { yield terminateSession('watch restart failed'); } process.exit(1); } })); }; if (startWatch) { startWatchProcess(); } yield (() => __awaiter(this, void 0, void 0, function* () { color_logger_1.default.log('🚀 Starting ephemeral browser session...'); // 1. Prepare context options (load session if it exists) const contextOptions = {}; if (fs_1.default.existsSync(STATE_FILE)) { color_logger_1.default.log('🔓 Loading previous login session...'); contextOptions.storageState = STATE_FILE; } else { color_logger_1.default.log('⚠️ No previous session found. You may need to log in.'); } // 2. Launch browser and apply context const browser = yield playwright_1.chromium.launch({ headless: false, args: ['--auto-open-devtools-for-tabs'] }); const context = yield browser.newContext(Object.assign(Object.assign({}, contextOptions), { viewport: null, serviceWorkers: 'block' })); // Guard to prevent multiple concurrent or duplicate cleanups let isCleaningUp = false; // Shared cleanup function to save state and close browser const cleanup = (...args_1) => __awaiter(this, [...args_1], void 0, function* (reason = 'unknown') { if (isCleaningUp) return; isCleaningUp = true; isShuttingDown = true; try { color_logger_1.default.log(`💾 Saving session state (${reason})...`); // Kill the watch process if it's running if (watchProcess && !watchProcess.killed) { color_logger_1.default.log('🛑 Terminating PCF watch process...'); watchProcess.kill('SIGTERM'); // Give it a chance to exit gracefully, then force kill if needed setTimeout(() => { if (watchProcess && !watchProcess.killed) { color_logger_1.default.warn('⚠️ Force killing PCF watch process...'); watchProcess.kill('SIGKILL'); } }, 2000); } // Ensure the .auth directory exists before saving if (!fs_1.default.existsSync(AUTH_DIR)) { fs_1.default.mkdirSync(AUTH_DIR, { recursive: true }); } if (!browser.isConnected()) { color_logger_1.default.log('Browser already disconnected.'); } else { // Save the cookies and local storage to the JSON file yield context.storageState({ path: STATE_FILE }); color_logger_1.default.log('🛑 Tearing down rules and session.'); yield browser.close(); } } catch (error) { color_logger_1.default.debug('Error during cleanup:', error); } }); terminateSession = cleanup; // Handle process exit signals — use .then() chaining since Node does not await async handlers process.on('SIGINT', () => { cleanup('SIGINT').then(() => process.exit(0)); }); process.on('SIGTERM', () => { cleanup('SIGTERM').then(() => process.exit(0)); }); process.on('beforeExit', () => { cleanup('beforeExit').catch((err) => color_logger_1.default.debug('Cleanup error on beforeExit:', err)); }); // Handle uncaught exceptions process.on('uncaughtException', (error) => { color_logger_1.default.error('Uncaught exception:', error); cleanup('uncaughtException').then(() => process.exit(1)); }); process.on('unhandledRejection', (reason) => { color_logger_1.default.error('Unhandled promise rejection:', reason); cleanup('unhandledRejection').then(() => process.exit(1)); }); // Handle browser disconnect browser.on('disconnected', () => { color_logger_1.default.log('Browser disconnected'); cleanup('browser disconnected').catch((err) => color_logger_1.default.debug('Cleanup error on browser disconnect:', err)); }); // Handle context close (when all pages in context are closed) context.on('close', () => { color_logger_1.default.log('Browser context closed'); cleanup('context closed').then(() => process.exit(0)); }); // 3. Programmatically apply your network interception rule with pattern matching // Handle dynamic version segments in CRM URLs like /version?/webresources/... const scriptPattern = REMOTE_SCRIPT_TO_INTERCEPT.replace(/^https?:\/\/[^\/]+/, ''); const stylesheetPattern = REMOTE_STYLESHEET_TO_INTERCEPT.replace(/^https?:\/\/[^\/]+/, ''); color_logger_1.default.debug(`📡 Setting up interception patterns:`); color_logger_1.default.debug(` Script pattern: **${scriptPattern}`); color_logger_1.default.debug(` CSS pattern: **${stylesheetPattern}`); yield context.route(route => { if (!route.href) { return false; } // Match script URLs that end with the same path structure return route.href.includes(scriptPattern); }, (route) => __awaiter(this, void 0, void 0, function* () { color_logger_1.default.log(`✅ Intercepted script request: ${route.request().url()}`); color_logger_1.default.log(` Serving local file: ${LOCAL_BUNDLE_PATH}`); try { const body = fs_1.default.readFileSync(LOCAL_BUNDLE_PATH); route.fulfill({ status: 200, contentType: 'application/javascript', body }); } catch (e) { const message = e instanceof Error ? e.message : String(e); color_logger_1.default.error(`❌ Failed to read local bundle at ${LOCAL_BUNDLE_PATH}: ${message}`); route.fulfill({ status: 500, body: `// Bundle file not found: ${LOCAL_BUNDLE_PATH}` }); } })); // Only intercept the remote stylesheet request if both the local CSS path and the remote URL to intercept have been provided if (LOCAL_CSS_PATH && REMOTE_STYLESHEET_TO_INTERCEPT) { yield context.route(route => { if (!route.href) { return false; } // Match CSS URLs that end with the same path structure return route.href.includes(stylesheetPattern); }, (route) => __awaiter(this, void 0, void 0, function* () { color_logger_1.default.log(`✅ Intercepted CSS request: ${route.request().url()}`); color_logger_1.default.log(` Serving local file: ${LOCAL_CSS_PATH}`); try { const body = fs_1.default.readFileSync(LOCAL_CSS_PATH); route.fulfill({ status: 200, contentType: 'text/css', body }); } catch (e) { const message = e instanceof Error ? e.message : String(e); color_logger_1.default.error(`❌ Failed to read local CSS at ${LOCAL_CSS_PATH}: ${message}`); route.fulfill({ status: 500, body: `/* CSS file not found: ${LOCAL_CSS_PATH} */` }); } })); } // 4. Open a new tab and navigate to your remote environment const page = yield context.newPage(); yield page.goto(REMOTE_ENVIRONMENT_URL); // 5. Clean up and save state when the page is closed (but others may still be open) page.on('close', () => __awaiter(this, void 0, void 0, function* () { const pages = context.pages(); if (pages.length <= 1) { // This was the last page, trigger full cleanup yield cleanup('last page closed'); process.exit(0); } else { color_logger_1.default.debug(`Page closed, but ${pages.length - 1} pages still open. Keeping session alive.`); } })); // 6. Watch the local bundle for changes and auto-reload let reloadTimeout; fs_1.default.watch(LOCAL_BUNDLE_PATH, (eventType) => { if (eventType === 'change') { // Clear the previous timer if the file changes again quickly clearTimeout(reloadTimeout); // Wait 300ms for the bundler to finish writing the file before reloading reloadTimeout = setTimeout(() => __awaiter(this, void 0, void 0, function* () { color_logger_1.default.log(`\n🔄 Local bundle updated! Reloading the page...`); try { yield page.reload(); } catch (err) { color_logger_1.default.error('⚠️ Could not reload page (browser might be closed).', err); } }), 300); } }); }))(); }); }