#!/usr/bin/env node /** * One-command setup: login, deploy, optional permset/data, GraphQL schema/codegen, UI bundle build. * Use this script to make setup easier for each app generated from this template. * * Usage: * node scripts/org-setup.mjs --target-org # interactive step picker (all selected) * node scripts/org-setup.mjs --target-org --yes # skip picker, run all steps * node scripts/org-setup.mjs --target-org afv5 --skip-login * node scripts/org-setup.mjs --target-org afv5 --skip-data --skip-ui-bundle-build * node scripts/org-setup.mjs --target-org myorg --ui-bundle-name my-app * * Steps (in order): * 1. login — sf org login web only if org not already connected (skip with --skip-login) * 2. uiBundle — (all UI bundles) npm install && npm run build so dist exists for deploy (skip with --skip-ui-bundle-build) * 3. deploy — sf project deploy start --target-org (requires dist for entity deployment) * 4. permset — assign permsets per org-setup.config.json (skip with --skip-permset; override via --permset-name) * 5. data — prepare unique fields + sf data import tree (skipped if no data dir/plan) * 6. graphql — (in UI bundle) npm run graphql:schema then npm run graphql:codegen * 7. dev — (in UI bundle) npm run dev — launch dev server (skip with --skip-dev) * * Permset assignment config (scripts/org-setup.config.json): * { * "permsetAssignments": { * "assignments": { * "My_Permset": { "assignee": "currentUser" }, * "Guest_Permset": { "assignee": "guestUser", "siteName": "mysite" }, * "Internal_Only": { "assignee": "skip" } * } * } * } * Assignee values: "currentUser", "skip", "guestUser" (requires siteName), or a specific username. * Only permsets explicitly listed in assignments are assigned; unlisted permsets are skipped. */ import { spawnSync, spawn as nodeSpawn } from 'node:child_process'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { readdirSync, existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, '..'); /** * npm strips .gitignore from published packages — generate them on first run. * Templates are stored in scripts/gitignore-templates.json (generated at build * time from the actual .gitignore files) so the content lives in one place. * The JSON may not exist in git-cloned distributions where .gitignore is * already present, so loading is best-effort. */ function loadGitignoreTemplates() { const templatesPath = resolve(__dirname, 'gitignore-templates.json'); if (!existsSync(templatesPath)) return null; try { return JSON.parse(readFileSync(templatesPath, 'utf8')); } catch { return null; } } function ensureGitignore(dir, content) { if (!content) return; const gitignorePath = resolve(dir, '.gitignore'); if (!existsSync(gitignorePath)) { writeFileSync(gitignorePath, content, 'utf8'); console.log(`Created .gitignore in ${dir}`); } } function resolveSfdxSource() { const sfdxPath = resolve(ROOT, 'sfdx-project.json'); if (!existsSync(sfdxPath)) { console.error('Error: sfdx-project.json not found at project root.'); process.exit(1); } const sfdxProject = JSON.parse(readFileSync(sfdxPath, 'utf8')); const pkgDir = sfdxProject?.packageDirectories?.[0]?.path; if (!pkgDir) { console.error('Error: No packageDirectories[].path found in sfdx-project.json.'); process.exit(1); } return resolve(ROOT, pkgDir, 'main', 'default'); } const SFDX_SOURCE = resolveSfdxSource(); const UIBUNDLES_DIR = resolve(SFDX_SOURCE, 'uiBundles'); const DATA_DIR = resolve(SFDX_SOURCE, 'data'); const DATA_PLAN = resolve(SFDX_SOURCE, 'data/data-plan.json'); function parseArgs() { const args = process.argv.slice(2); let targetOrg = null; let uiBundleName = null; /** If non-empty, only these names are assigned; otherwise all discovered from the project. */ const permsetNamesExplicit = []; let yes = false; const flags = { skipLogin: false, skipDeploy: false, skipPermset: false, skipRole: false, skipData: false, skipGraphql: false, skipUIBundleBuild: false, skipSelfReg: false, skipDev: false, }; for (let i = 0; i < args.length; i++) { if (args[i] === '--target-org' && args[i + 1]) { targetOrg = args[++i]; } else if (args[i] === '--ui-bundle-name' && args[i + 1]) { uiBundleName = args[++i]; } else if (args[i] === '--permset-name' && args[i + 1]) { permsetNamesExplicit.push(args[++i]); } else if (args[i] === '--skip-login') flags.skipLogin = true; else if (args[i] === '--skip-deploy') flags.skipDeploy = true; else if (args[i] === '--skip-permset') flags.skipPermset = true; else if (args[i] === '--skip-role') flags.skipRole = true; else if (args[i] === '--skip-data') flags.skipData = true; else if (args[i] === '--skip-self-reg') flags.skipSelfReg = true; else if (args[i] === '--skip-graphql') flags.skipGraphql = true; else if (args[i] === '--skip-ui-bundle-build') flags.skipUIBundleBuild = true; else if (args[i] === '--skip-dev') flags.skipDev = true; else if (args[i] === '--yes' || args[i] === '-y') yes = true; else if (args[i] === '--help' || args[i] === '-h') { console.log(` Setup CLI — one-command setup for apps in this project Usage: node scripts/org-setup.mjs --target-org [options] Required: --target-org Target Salesforce org alias (e.g. myorg) Options: --ui-bundle-name UI bundle folder name under uiBundles/ (default: auto-detect) --permset-name Assign only this permission set (repeatable). Default: all sets under permissionsets/ --skip-login Skip login step (login is auto-skipped if org is already connected) --skip-deploy Do not deploy metadata --skip-permset Do not assign permission set --skip-data Do not prepare data or run data import --skip-graphql Do not fetch schema or run GraphQL codegen --skip-ui-bundle-build Do not npm install / build the UI bundle --skip-dev Do not launch the dev server at the end -y, --yes Skip interactive step picker; run all enabled steps immediately -h, --help Show this help Permset config (scripts/org-setup.config.json): Control per-permset assignment via a config file. Example: { "permsetAssignments": { "assignments": { "My_Permset": { "assignee": "currentUser" }, "Guest_Permset": { "assignee": "guestUser", "siteName": "mysite" }, "Internal_Only": { "assignee": "skip" } } } } Assignee values: "currentUser", "skip", "guestUser" (requires siteName), or a specific username. Only permsets explicitly listed in assignments are assigned; unlisted permsets are skipped. `); process.exit(0); } } if (!targetOrg) { console.error('Error: --target-org is required.'); process.exit(1); } return { targetOrg, uiBundleName, permsetNamesExplicit, yes, ...flags }; } function discoverAllUIBundleDirs(uiBundleName) { if (!existsSync(UIBUNDLES_DIR)) { console.error(`Error: uiBundles directory not found: ${UIBUNDLES_DIR}`); process.exit(1); } const entries = readdirSync(UIBUNDLES_DIR, { withFileTypes: true }); const dirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith('.')); if (dirs.length === 0) { console.error(`Error: No UI bundle folder found under ${UIBUNDLES_DIR}`); process.exit(1); } if (uiBundleName) { const requested = dirs.find((d) => d.name === uiBundleName); if (!requested) { console.error(`Error: UI bundle directory not found: ${uiBundleName}`); process.exit(1); } return [resolve(UIBUNDLES_DIR, requested.name)]; } return dirs.map((d) => resolve(UIBUNDLES_DIR, d.name)); } function discoverUIBundleDir(uiBundleName) { const all = discoverAllUIBundleDirs(uiBundleName); if (all.length > 1 && !uiBundleName) { console.log(`Multiple UI bundles found; using first: ${all[0].split(/[/\\]/).pop()}`); } return all[0]; } /** API names from permissionsets/*.permissionset-meta.xml in the first package directory. */ function discoverPermissionSetNames() { const dir = resolve(SFDX_SOURCE, 'permissionsets'); if (!existsSync(dir)) return []; const names = []; for (const entry of readdirSync(dir, { withFileTypes: true })) { if (!entry.isFile()) continue; const m = entry.name.match(/^(.+)\.permissionset-meta\.xml$/); if (m) names.push(m[1]); } return names.sort(); } /** * Load permset assignment configuration from org-setup.config.json. * * Config shape: * { * "permsetAssignments": { * "assignments": { * "My_Permset": { "assignee": "currentUser" }, * "My_Guest_Permset": { "assignee": "guestUser", "siteName": "mysite" }, * "Internal_Only": { "assignee": "skip" } * } * } * } * * Assignee values: * "currentUser" — assign to the user running the script * "skip" — do not assign this permset * "guestUser" — resolve the site guest user automatically (requires siteName) * "" — assign to a specific user via --on-behalf-of * * Only permsets explicitly listed in assignments are assigned; unlisted permsets are skipped. * * Returns { assignments: Record } */ function loadPermsetConfig() { const configPath = resolve(__dirname, 'org-setup.config.json'); const defaults = { assignments: {} }; if (!existsSync(configPath)) return defaults; try { const raw = JSON.parse(readFileSync(configPath, 'utf8')); const section = raw?.permsetAssignments; if (!section) return defaults; return { assignments: section.assignments || {}, }; } catch (err) { console.warn(`Warning: failed to parse org-setup.config.json: ${err.message}; using defaults.`); return defaults; } } /** Resolve the effective assignment config for a given permset name. */ function resolveAssignment(permsetName, permsetConfig) { const override = permsetConfig.assignments[permsetName]; if (!override) return { assignee: 'skip' }; return { assignee: override.assignee || 'skip', siteName: override.siteName }; } /** * Load role assignment config from org-setup.config.json. * * Config shape: * { "role": { "assignee": "currentUser", "roleName": "Admin" } } * * Returns null if no "role" section exists in config (the step is hidden). */ function loadRoleConfig() { const configPath = resolve(__dirname, 'org-setup.config.json'); if (!existsSync(configPath)) return null; try { const raw = JSON.parse(readFileSync(configPath, 'utf8')); const section = raw?.role; if (!section) return null; return { assignee: section.assignee || 'currentUser', roleName: section.roleName || null, }; } catch { return null; } } /** * Load self-registration config from org-setup.config.json. * * Config shape: * { * "selfRegistration": { * "siteName": "myapp", * "selfRegProfile": "myapp Profile", * "accountName": "My Self-Reg Account" * } * } * * Returns null if no "selfRegistration" section exists in config (the step is hidden). */ function loadSelfRegConfig() { const configPath = resolve(__dirname, 'org-setup.config.json'); if (!existsSync(configPath)) return null; try { const raw = JSON.parse(readFileSync(configPath, 'utf8')); const section = raw?.selfRegistration; if (!section) return null; return { siteName: section.siteName || null, selfRegProfile: section.selfRegProfile || null, accountName: section.accountName || null, }; } catch { return null; } } /** * Ensure the self-registration profile is listed in networkMemberGroups. * This must happen BEFORE the initial deploy so that the profile is a recognised * site member when subsequent steps (selfRegProfile, selfRegistration=true) are deployed. */ function ensureNetworkMemberProfile(selfRegConfig) { const { siteName, selfRegProfile } = selfRegConfig; if (!siteName || !selfRegProfile) return; const networkXmlPath = resolve(SFDX_SOURCE, 'networks', `${siteName}.network-meta.xml`); if (!existsSync(networkXmlPath)) { console.log(` Network metadata not found: ${networkXmlPath}; skipping member group update.`); return; } const xml = readFileSync(networkXmlPath, 'utf8'); // Check if profile is already in networkMemberGroups const profileEscaped = selfRegProfile.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const profileRegex = new RegExp(`\\s*${profileEscaped}\\s*`); if (profileRegex.test(xml)) { console.log(` Profile "${selfRegProfile}" already in networkMemberGroups; no update needed.`); return; } // Add the profile to networkMemberGroups const updatedXml = xml.replace( /()/, `$1\n ${selfRegProfile}` ); writeFileSync(networkXmlPath, updatedXml); console.log(` Added profile "${selfRegProfile}" to networkMemberGroups in ${siteName}.network-meta.xml`); } /** * Enable self-registration for an Experience Cloud network. * * 1. Modify the network metadata XML to set selfRegistration=true and add selfRegProfile. * 2. Re-deploy the modified network metadata. * 3. Create an Account record (idempotent). * 4. Create a NetworkSelfRegistration record linking the Account to the Network (idempotent). */ function enableSelfRegistration(selfRegConfig, targetOrg) { const { siteName, selfRegProfile, accountName } = selfRegConfig; // 1. Modify network metadata XML const networkXmlPath = resolve(SFDX_SOURCE, 'networks', `${siteName}.network-meta.xml`); if (!existsSync(networkXmlPath)) { console.error(` Network metadata not found: ${networkXmlPath}`); return; } const xml = readFileSync(networkXmlPath, 'utf8'); // Skip network modification and deploy if self-registration is already configured const alreadyEnabled = /true<\/selfRegistration>/.test(xml); const alreadyHasProfile = //.test(xml); if (alreadyEnabled || alreadyHasProfile) { console.log(` Network "${siteName}" already has self-registration configured; skipping metadata update and deploy.`); } else { // Set selfRegistration to true and add selfRegProfile let updatedXml = xml.replace( /false<\/selfRegistration>/, 'true' ); updatedXml = updatedXml.replace( /(\s*)()/, `$1${selfRegProfile}\n$1$2` ); writeFileSync(networkXmlPath, updatedXml); console.log(` Updated ${siteName}.network-meta.xml: selfRegistration=true, selfRegProfile=${selfRegProfile}`); // Re-deploy only the network file const deployResult = spawnSync('sf', [ 'project', 'deploy', 'start', '--target-org', targetOrg, '--source-dir', networkXmlPath, ], { cwd: ROOT, stdio: 'inherit', shell: true, timeout: 120000 }); if (deployResult.status !== 0) { console.error(' Failed to deploy updated network metadata.'); process.exit(deployResult.status ?? 1); } } // 3. Create Account (idempotent) const acctQuery = `SELECT Id FROM Account WHERE Name = '${accountName.replace(/'/g, "\\'")}' LIMIT 1`; const acctQueryResult = spawnSync('sf', [ 'data', 'query', '--query', acctQuery, '--target-org', targetOrg, '--json', ], { cwd: ROOT, encoding: 'utf8' }); let accountId = null; if (acctQueryResult.status === 0) { try { const json = JSON.parse(acctQueryResult.stdout); accountId = json.result?.records?.[0]?.Id || null; } catch { /* proceed to create */ } } if (accountId) { console.log(` Account "${accountName}" already exists (${accountId}); skipping creation.`); } else { const createResult = spawnSync('sf', [ 'data', 'create', 'record', '--sobject', 'Account', '--values', `Name='${accountName}'`, '--target-org', targetOrg, '--json', ], { cwd: ROOT, encoding: 'utf8' }); if (createResult.status !== 0) { console.error(` Failed to create Account "${accountName}".`); if (createResult.stderr) console.error(createResult.stderr); return; } try { const json = JSON.parse(createResult.stdout); accountId = json.result?.id; console.log(` Created Account "${accountName}" (${accountId}).`); } catch { console.error(' Failed to parse Account creation result.'); return; } } // 4. Query Network Id const netQuery = `SELECT Id FROM Network WHERE Name = '${siteName}'`; const netResult = spawnSync('sf', [ 'data', 'query', '--query', netQuery, '--target-org', targetOrg, '--json', ], { cwd: ROOT, encoding: 'utf8' }); let networkId = null; if (netResult.status === 0) { try { const json = JSON.parse(netResult.stdout); networkId = json.result?.records?.[0]?.Id || null; } catch { /* fall through */ } } if (!networkId) { console.error(` Could not find Network "${siteName}" in org.`); return; } console.log(` Found Network "${siteName}" (${networkId}).`); // 5. Create NetworkSelfRegistration (idempotent) const nsrQuery = `SELECT Id FROM NetworkSelfRegistration WHERE NetworkId = '${networkId}'`; const nsrResult = spawnSync('sf', [ 'data', 'query', '--query', nsrQuery, '--target-org', targetOrg, '--json', ], { cwd: ROOT, encoding: 'utf8' }); let nsrExists = false; if (nsrResult.status === 0) { try { const json = JSON.parse(nsrResult.stdout); nsrExists = (json.result?.records?.length || 0) > 0; } catch { /* proceed to create */ } } if (nsrExists) { console.log(' NetworkSelfRegistration record already exists; skipping.'); } else { const tmpApex = resolve(ROOT, '.tmp-setup-selfreg.apex'); const apex = [ `Account acct = [SELECT Id FROM Account WHERE Id = '${accountId}' LIMIT 1];`, `NetworkSelfRegistration nsr = new NetworkSelfRegistration();`, `nsr.AccountId = acct.Id;`, `nsr.NetworkId = '${networkId}';`, `insert nsr;`, `System.debug('NSR_CREATED:' + nsr.Id);`, ].join('\n'); writeFileSync(tmpApex, apex); const apexResult = spawnSync('sf', [ 'apex', 'run', '--target-org', targetOrg, '--file', tmpApex, ], { cwd: ROOT, stdio: 'pipe', shell: true, timeout: 60000 }); const apexOut = apexResult.stdout?.toString() || ''; if (existsSync(tmpApex)) unlinkSync(tmpApex); if (apexResult.status !== 0 && !apexOut.includes('Compiled successfully')) { console.error(' Failed to create NetworkSelfRegistration record.'); process.stderr.write(apexResult.stderr?.toString() || apexOut); return; } const nsrMatch = apexOut.match(/NSR_CREATED:(\w+)/); if (nsrMatch) { console.log(` Created NetworkSelfRegistration (${nsrMatch[1]}).`); } else { console.log(' NetworkSelfRegistration creation executed.'); } } } /** * Assign a role to the current user so that Experience Cloud self-registration * works correctly. */ function assignRoleToCurrentUser(roleName, targetOrg) { const roleQuery = `SELECT Id FROM UserRole WHERE Name = '${roleName}'`; const roleResult = spawnSync('sf', [ 'data', 'query', '--query', roleQuery, '--target-org', targetOrg, '--json', ], { cwd: ROOT, encoding: 'utf8' }); if (roleResult.status !== 0) { console.error(` Failed to query role "${roleName}" in org.`); if (roleResult.stderr) console.error(roleResult.stderr); return; } let roleId; try { const json = JSON.parse(roleResult.stdout); const records = json.result?.records; if (!records || records.length === 0) { console.error(` Role "${roleName}" not found in org; skipping.`); return; } roleId = records[0].Id; } catch { console.error(` Failed to parse role query result for "${roleName}".`); return; } const orgResult = spawnSync('sf', [ 'org', 'display', '--target-org', targetOrg, '--json', ], { cwd: ROOT, encoding: 'utf8' }); if (orgResult.status !== 0) { console.error(' Failed to resolve current user from org.'); return; } let username; try { const json = JSON.parse(orgResult.stdout); username = json.result?.username; if (!username) { console.error(' Could not determine current username from org display.'); return; } } catch { console.error(' Failed to parse org display result.'); return; } const userQuery = `SELECT Id, UserRoleId FROM User WHERE Username = '${username}'`; const userResult = spawnSync('sf', [ 'data', 'query', '--query', userQuery, '--target-org', targetOrg, '--json', ], { cwd: ROOT, encoding: 'utf8' }); if (userResult.status === 0) { try { const json = JSON.parse(userResult.stdout); const userRecord = json.result?.records?.[0]; if (userRecord?.UserRoleId) { console.log(` User ${username} already has a role assigned; skipping to avoid overriding.`); return; } } catch { /* continue */ } } const updateResult = spawnSync('sf', [ 'data', 'update', 'record', '--sobject', 'User', '--where', `Username='${username}'`, '--values', `UserRoleId='${roleId}'`, '--target-org', targetOrg, '--json', ], { cwd: ROOT, encoding: 'utf8' }); if (updateResult.status === 0) { console.log(` Role "${roleName}" assigned to ${username}.`); } else { const out = (updateResult.stderr?.toString() || '') + (updateResult.stdout?.toString() || ''); console.error(` Failed to assign role "${roleName}" to ${username}.`); if (out) console.error(out); } } /** * Query the org for a guest user whose profile name matches the given site name. */ function resolveGuestUsername(siteName, targetOrg) { const query = `SELECT Username FROM User WHERE Profile.Name LIKE '%${siteName}%' AND UserType = 'Guest'`; const result = spawnSync('sf', [ 'data', 'query', '--query', query, '--target-org', targetOrg, '--json', ], { cwd: ROOT, encoding: 'utf8' }); if (result.status !== 0) { console.error(` Failed to query guest user for site "${siteName}".`); if (result.stderr) console.error(result.stderr); return null; } try { const json = JSON.parse(result.stdout); const records = json.result?.records; if (!records || records.length === 0) { console.error(` No guest user found for site "${siteName}".`); return null; } return records[0].Username; } catch { console.error(` Failed to parse guest user query result for site "${siteName}".`); return null; } } function isOrgConnected(targetOrg) { const result = spawnSync('sf', ['org', 'display', '--target-org', targetOrg, '--json'], { cwd: ROOT, stdio: 'pipe', shell: true, }); return result.status === 0; } function apexLiteral(value) { if (value === null || value === undefined) return 'null'; if (typeof value === 'boolean') return String(value); if (typeof value === 'number') return String(value); const s = String(value); if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return `Date.valueOf('${s}')`; if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(s)) { const dt = s.replace('T', ' ').replace(/\.\d+/, '').replace('Z', ''); return `DateTime.valueOf('${dt}')`; } return "'" + s.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'"; } function buildApexInsert(sobject, records, refIds) { const lines = [ 'Database.DMLOptions dmlOpts = new Database.DMLOptions();', 'dmlOpts.DuplicateRuleHeader.allowSave = true;', `List<${sobject}> recs = new List<${sobject}>();`, ]; for (const rec of records) { lines.push(`{ ${sobject} r = new ${sobject}();`); for (const [key, val] of Object.entries(rec)) { if (key === 'attributes') continue; lines.push(`r.put('${key}', ${apexLiteral(val)});`); } lines.push('recs.add(r); }'); } lines.push('Database.SaveResult[] results = Database.insert(recs, dmlOpts);'); const refArray = refIds.map((r) => `'${r}'`).join(','); lines.push(`String[] refs = new String[]{${refArray}};`); lines.push('for (Integer i = 0; i < results.size(); i++) {'); lines.push(" if (results[i].isSuccess()) System.debug('REF:' + refs[i] + ':' + results[i].getId());"); lines.push(" else System.debug('ERR:' + refs[i] + ':' + results[i].getErrors()[0].getMessage());"); lines.push('}'); return lines.join('\n'); } /** * Interactive multi-select: arrow keys navigate, space toggles, 'a' toggles all, enter confirms. * Returns a boolean[] matching the input order. Falls through immediately when stdin is not a TTY. */ async function promptSteps(steps) { if (!process.stdin.isTTY) return steps.map((s) => s.enabled); const selected = steps.map((s) => s.enabled); let cursor = 0; const DIM = '\x1B[2m'; const RST = '\x1B[0m'; const CYAN = '\x1B[36m'; const GREEN = '\x1B[32m'; /** Strip ANSI escape sequences to get visible character count. */ function visibleLength(str) { return str.replace(/\x1B\[[0-9;]*m/g, '').length; } /** Count how many terminal rows a set of lines occupies (accounting for wrapping). */ function terminalRows(lines) { const cols = process.stdout.columns || 80; let rows = 0; for (const line of lines) { const len = visibleLength(line); rows += len === 0 ? 1 : Math.ceil(len / cols); } return rows; } function render() { return steps.map((s, i) => { const ptr = i === cursor ? `${CYAN}❯${RST}` : ' '; if (!s.available) return `${ptr} ${DIM}○ ${s.label} (n/a)${RST}`; const chk = selected[i] ? `${GREEN}●${RST}` : '○'; return `${ptr} ${chk} ${s.label}`; }); } let prevRows = 0; return new Promise((resolve) => { process.stdin.setRawMode(true); process.stdin.resume(); process.stdin.setEncoding('utf8'); process.stdout.write('\x1B[?25l'); console.log('\nSelect steps (↑↓ move, space toggle, a all, enter confirm):\n'); const initialLines = render(); prevRows = terminalRows(initialLines); process.stdout.write(initialLines.join('\n') + '\n'); function redraw() { process.stdout.write(`\x1B[${prevRows}A`); const lines = render(); for (const line of lines) process.stdout.write(`\x1B[2K${line}\n`); prevRows = terminalRows(lines); } process.stdin.on('data', (key) => { if (key === '\x03') { process.stdout.write('\x1B[?25h\n'); process.exit(0); } if (key === '\r' || key === '\n') { process.stdout.write('\x1B[?25h'); process.stdin.setRawMode(false); process.stdin.pause(); process.stdin.removeAllListeners('data'); console.log(); resolve(selected); return; } if (key === ' ') { if (steps[cursor].available) selected[cursor] = !selected[cursor]; redraw(); return; } if (key === 'a') { const allOn = steps.every((s, i) => !s.available || selected[i]); for (let i = 0; i < steps.length; i++) { if (steps[i].available) selected[i] = !allOn; } redraw(); return; } if (key === '\x1B[A' || key === 'k') { cursor = Math.max(0, cursor - 1); redraw(); } else if (key === '\x1B[B' || key === 'j') { cursor = Math.min(steps.length - 1, cursor + 1); redraw(); } }); }); } function run(name, cmd, args, opts = {}) { const { cwd = ROOT, optional = false } = opts; console.log('\n---', name, '---'); const result = spawnSync(cmd, args, { cwd, stdio: 'inherit', shell: true, ...(opts.timeout && { timeout: opts.timeout }), }); if (result.status !== 0 && !optional) { console.error(`\nSetup failed at step: ${name}`); process.exit(result.status ?? 1); } return result; } /** Promise-based spawn for parallel execution. Always uses stdio: 'pipe'. */ function spawnAsync(cmd, args, opts = {}) { return new Promise((resolve, reject) => { const proc = nodeSpawn(cmd, args, { cwd: opts.cwd || ROOT, stdio: 'pipe', shell: true, ...(opts.timeout && { timeout: opts.timeout }), }); let stdout = ''; let stderr = ''; proc.stdout.on('data', (d) => { stdout += d.toString(); }); proc.stderr.on('data', (d) => { stderr += d.toString(); }); proc.on('close', (code) => resolve({ status: code, stdout, stderr })); proc.on('error', reject); }); } /** Async version of run() for parallel steps. Captures output and prints on failure. */ async function runAsync(name, cmd, args, opts = {}) { const { cwd = ROOT, optional = false } = opts; const result = await spawnAsync(cmd, args, { cwd, ...(opts.timeout && { timeout: opts.timeout }) }); if (result.status !== 0 && !optional) { if (result.stdout) process.stdout.write(result.stdout); if (result.stderr) process.stderr.write(result.stderr); console.error(`\nSetup failed at step: ${name}`); process.exit(result.status ?? 1); } return result; } async function main() { // Ensure .gitignore files exist (npm strips them from published packages). const gitignoreTemplates = loadGitignoreTemplates(); if (gitignoreTemplates) { ensureGitignore(ROOT, gitignoreTemplates.sfdx); if (existsSync(UIBUNDLES_DIR)) { for (const entry of readdirSync(UIBUNDLES_DIR, { withFileTypes: true })) { if (entry.isDirectory() && !entry.name.startsWith('.')) { ensureGitignore(resolve(UIBUNDLES_DIR, entry.name), gitignoreTemplates.webapp); } } } } const { targetOrg, uiBundleName, permsetNamesExplicit, yes, skipLogin: argSkipLogin, skipDeploy: argSkipDeploy, skipPermset: argSkipPermset, skipRole: argSkipRole, skipSelfReg: argSkipSelfReg, skipData: argSkipData, skipGraphql: argSkipGraphql, skipUIBundleBuild: argSkipUIBundleBuild, skipDev: argSkipDev, } = parseArgs(); const permsetNames = permsetNamesExplicit.length > 0 ? permsetNamesExplicit : discoverPermissionSetNames(); const permsetStepLabel = permsetNames.length === 0 ? 'Permset — (none under permissionsets/)' : permsetNames.length <= 3 ? `Permset — assign ${permsetNames.join(', ')}` : `Permset — assign ${permsetNames.length} permission sets`; const hasDataPlan = existsSync(DATA_PLAN) && existsSync(DATA_DIR); const roleConfig = loadRoleConfig(); const hasRoleConfig = roleConfig !== null; const selfRegConfig = loadSelfRegConfig(); const hasSelfRegConfig = selfRegConfig !== null; const stepDefs = [ { key: 'login', label: 'Login — org authentication', enabled: !argSkipLogin, available: true }, { key: 'uiBundleBuild', label: 'UI Bundle Build — npm install + build (pre-deploy)', enabled: !argSkipUIBundleBuild, available: true }, { key: 'deploy', label: 'Deploy — sf project deploy start', enabled: !argSkipDeploy, available: true }, { key: 'permset', label: permsetStepLabel, enabled: !argSkipPermset, available: true }, { key: 'role', label: `Role — assign "${roleConfig?.roleName ?? '?'}" to current user`, enabled: !argSkipRole && hasRoleConfig, available: hasRoleConfig }, { key: 'selfReg', label: `Self-Registration — enable for "${selfRegConfig?.siteName ?? '?'}"`, enabled: !argSkipSelfReg && hasSelfRegConfig, available: hasSelfRegConfig }, { key: 'data', label: 'Data — delete + import records via Apex', enabled: !argSkipData && hasDataPlan, available: hasDataPlan }, { key: 'graphql', label: 'GraphQL — schema introspect + codegen', enabled: !argSkipGraphql, available: true }, { key: 'dev', label: 'Dev — launch dev server', enabled: !argSkipDev, available: true }, ]; const selections = yes ? stepDefs.map((s) => s.enabled) : await promptSteps(stepDefs); const on = {}; stepDefs.forEach((s, i) => { on[s.key] = selections[i]; }); const skipLogin = !on.login; const skipUIBundleBuild = !on.uiBundleBuild; const skipDeploy = !on.deploy; const skipPermset = !on.permset; const skipRole = !on.role; const skipSelfReg = !on.selfReg; const skipData = !on.data; const skipGraphql = !on.graphql; const skipDev = !on.dev; const needsUIBundle = !skipUIBundleBuild || !skipGraphql || !skipDev; const uiBundleDir = needsUIBundle ? discoverUIBundleDir(uiBundleName) : null; const doData = !skipData; console.log('Setup — target org:', targetOrg, '| UI bundle:', uiBundleDir ?? '(none)'); console.log( 'Steps: login=%s deploy=%s permset=%s role=%s selfReg=%s data=%s graphql=%s uiBundle=%s dev=%s', !skipLogin, !skipDeploy, !skipPermset, !skipRole, !skipSelfReg, doData, !skipGraphql, !skipUIBundleBuild, !skipDev ); if (!skipLogin) { if (isOrgConnected(targetOrg)) { console.log('\n--- Login ---'); console.log(`Org ${targetOrg} is already authenticated; skipping browser login.`); } else { run('Login (browser)', 'sf', ['org', 'login', 'web', '--alias', targetOrg], { optional: true }); } } // Ensure the self-reg profile is in networkMemberGroups before deploy so that // subsequent selfRegProfile / selfRegistration updates don't fail. if (!skipDeploy && selfRegConfig) { console.log('\n--- Ensure network member profile (pre-deploy) ---'); ensureNetworkMemberProfile(selfRegConfig); } // Build all UI Bundles before deploy so dist exists for entity deployment if (!skipDeploy && !skipUIBundleBuild) { const allUIBundleDirs = discoverAllUIBundleDirs(uiBundleName); for (const dir of allUIBundleDirs) { const name = dir.split(/[/\\]/).pop(); run(`UI Bundle install (${name})`, 'npm', ['install'], { cwd: dir }); run(`UI Bundle build (${name})`, 'npm', ['run', 'build'], { cwd: dir }); } } if (!skipDeploy) { run('Deploy metadata', 'sf', ['project', 'deploy', 'start', '--target-org', targetOrg], { timeout: 180000, }); } if (!skipPermset) { const permsetConfig = loadPermsetConfig(); if (permsetNames.length === 0) { console.log('\n--- Assign permission sets ---'); console.log('No permission sets found under permissionsets/ and none passed via --permset-name; skipping.'); } else { console.log('\n--- Assign permission sets ---'); // Resolve assignments (guest user lookups etc.) then run all sf assign calls in parallel. const assignmentJobs = []; for (const permsetName of permsetNames) { const assignment = resolveAssignment(permsetName, permsetConfig); if (assignment.assignee === 'skip') { console.log(`Permission set "${permsetName}" — skipped (config).`); continue; } let effectiveUsername = null; if (assignment.assignee === 'guestUser') { if (!assignment.siteName) { console.error(`Permission set "${permsetName}" — assignee is "guestUser" but no "siteName" configured; skipping.`); continue; } effectiveUsername = resolveGuestUsername(assignment.siteName, targetOrg); if (!effectiveUsername) { console.error(`Permission set "${permsetName}" — could not resolve guest user for site "${assignment.siteName}"; skipping.`); continue; } console.log(` Resolved guest user for site "${assignment.siteName}": ${effectiveUsername}`); } else if (assignment.assignee !== 'currentUser') { effectiveUsername = assignment.assignee; } assignmentJobs.push({ permsetName, effectiveUsername }); } // Run all permset assignment calls in parallel. const assignResults = await Promise.all(assignmentJobs.map(async ({ permsetName, effectiveUsername }) => { const sfArgs = ['org', 'assign', 'permset', '--name', permsetName, '--target-org', targetOrg]; if (effectiveUsername) { sfArgs.push('--on-behalf-of', effectiveUsername); } const assigneeLabel = effectiveUsername || 'current user'; const result = await spawnAsync('sf', sfArgs); return { permsetName, assigneeLabel, result }; })); for (const { permsetName, assigneeLabel, result } of assignResults) { if (result.status === 0) { console.log(`Permission set "${permsetName}" assigned to ${assigneeLabel}.`); } else { const out = (result.stderr || '') + (result.stdout || ''); if (out.includes('Duplicate') && out.includes('PermissionSet')) { console.log(`Permission set "${permsetName}" already assigned to ${assigneeLabel}; skipping.`); } else if (out.includes('not found') && out.includes('target org')) { console.log(`Permission set "${permsetName}" not in org; skipping.`); } else { if (result.stdout) process.stdout.write(result.stdout); if (result.stderr) process.stderr.write(result.stderr); console.error(`\nSetup failed at step: Assign permission set (${permsetName})`); process.exit(result.status ?? 1); } } } } } if (!skipRole) { console.log('\n--- Assign role ---'); if (roleConfig?.assignee !== 'currentUser') { console.error(`Role assignee "${roleConfig?.assignee}" is not supported; only "currentUser" is allowed. Skipping.`); } else if (!roleConfig?.roleName) { console.error('Role step enabled but no "roleName" specified in org-setup.config.json; skipping.'); } else { assignRoleToCurrentUser(roleConfig.roleName, targetOrg); } } if (!skipSelfReg) { console.log('\n--- Enable self-registration ---'); if (!selfRegConfig?.siteName || !selfRegConfig?.selfRegProfile || !selfRegConfig?.accountName) { console.error('Self-registration config is incomplete (need siteName, selfRegProfile, accountName); skipping.'); } else { enableSelfRegistration(selfRegConfig, targetOrg); } } if (doData) { // Prepare data for uniqueness (run before import so repeat imports don't conflict) const prepareScript = resolve(__dirname, 'prepare-import-unique-fields.js'); run('Prepare data (unique fields)', 'node', [prepareScript, '--data-dir', DATA_DIR], { cwd: ROOT, }); // Normalize Lease__c Tenant refs to 1–15 so all refs resolve (Tenant__c.json has 15 records) const leasePath = resolve(DATA_DIR, 'Lease__c.json'); if (existsSync(leasePath)) { let leaseContent = readFileSync(leasePath, 'utf8'); leaseContent = leaseContent.replace(/@TenantRef(\d+)/g, (_m, n) => { const k = ((parseInt(n, 10) - 1) % 15) + 1; return `@TenantRef${k}`; }); writeFileSync(leasePath, leaseContent); } // Delete existing records so every run inserts the full dataset without duplicate conflicts. // Reverse plan order ensures children are removed before parents (FK safety). console.log('\n--- Clean existing data for fresh import ---'); const planEntries = JSON.parse(readFileSync(DATA_PLAN, 'utf8')); const sobjectsReversed = [...planEntries.map((e) => e.sobject)].reverse(); const tmpApex = resolve(ROOT, '.tmp-setup-delete.apex'); for (const sobject of sobjectsReversed) { const apexCode = [ 'try {', ` List recs = Database.query('SELECT Id FROM ${sobject} LIMIT 10000');`, ' if (!recs.isEmpty()) {', ' Database.delete(recs, false);', ' Database.emptyRecycleBin(recs);', ' }', '} catch (Exception e) {', ' // non-deletable records (e.g. Contact linked to Case) are skipped via allOrNone=false', '}', ].join('\n'); writeFileSync(tmpApex, apexCode); spawnSync('sf', ['apex', 'run', '--target-org', targetOrg, '--file', tmpApex], { cwd: ROOT, stdio: 'pipe', shell: true, timeout: 60000, }); console.log(` ${sobject}: cleaned`); } if (existsSync(tmpApex)) unlinkSync(tmpApex); // Import via Anonymous Apex with Database.DMLOptions.duplicateRuleHeader.allowSave = true. // This bypasses both duplicate-rule blocks AND matching-service timeouts that the REST // API headers (Sforce-Duplicate-Rule-Action) cannot override. console.log('\n--- Data import tree ---'); const refMap = new Map(); const APEX_CHAR_LIMIT = 25000; const APEX_MAX_BATCH = 200; for (const entry of planEntries) { for (const file of entry.files) { const data = JSON.parse(readFileSync(resolve(DATA_DIR, file), 'utf8')); const records = data.records || []; for (const rec of records) { for (const key of Object.keys(rec)) { if (key === 'attributes') continue; const val = rec[key]; if (typeof val === 'string' && val.startsWith('@')) { const actual = refMap.get(val.slice(1)); if (actual) { rec[key] = actual; } else if (refMap.size > 0) { console.warn(` Warning: unresolved ref ${val} in ${file}`); } } } } let imported = 0; const sampleRec = records[0] || {}; const fieldsPerRec = Object.keys(sampleRec).filter((k) => k !== 'attributes').length; const estCharsPerRec = 40 + fieldsPerRec * 55; const batchSize = Math.min(APEX_MAX_BATCH, Math.max(5, Math.floor(APEX_CHAR_LIMIT / estCharsPerRec))); for (let i = 0; i < records.length; i += batchSize) { const batch = records.slice(i, i + batchSize); const refIds = batch.map((r) => r.attributes?.referenceId || `_idx${i}`); const apex = buildApexInsert(entry.sobject, batch, refIds); writeFileSync(tmpApex, apex); const apexResult = spawnSync( 'sf', ['apex', 'run', '--target-org', targetOrg, '--file', tmpApex], { cwd: ROOT, stdio: 'pipe', shell: true, timeout: 120000 } ); const apexOut = apexResult.stdout?.toString() || ''; const apexErr = apexResult.stderr?.toString() || ''; if (apexResult.status !== 0 && !apexOut.includes('Compiled successfully')) { console.error(` ${entry.sobject}: apex execution failed`); process.stderr.write(apexErr || apexOut); process.exit(1); } const okMatches = [...apexOut.matchAll(/\|DEBUG\|REF:([^:\n]+):(\w+)/g)]; const errMatches = [...apexOut.matchAll(/\|DEBUG\|ERR:([^:\n]+):([^\n]+)/g)]; if (errMatches.length) { for (const m of errMatches.slice(0, 5)) { console.error(` ${m[1]}: ${m[2].trim()}`); } if (errMatches.length > 5) console.error(` ... and ${errMatches.length - 5} more`); console.error(`\nSetup failed at step: Data import tree (${entry.sobject})`); process.exit(1); } if (entry.saveRefs) { for (const m of okMatches) refMap.set(m[1], m[2]); } imported += okMatches.length; } console.log(` ${entry.sobject}: imported ${imported} records`); } } if (existsSync(tmpApex)) unlinkSync(tmpApex); } if (!skipGraphql) { run('UI Bundle npm install', 'npm', ['install'], { cwd: uiBundleDir }); run('Set default org for schema', 'sf', ['config', 'set', 'target-org', targetOrg, '--global']); run('GraphQL schema (introspect)', 'npm', ['run', 'graphql:schema'], { cwd: uiBundleDir }); run('GraphQL codegen', 'npm', ['run', 'graphql:codegen'], { cwd: uiBundleDir }); run('UI Bundle build (post-codegen)', 'npm', ['run', 'build'], { cwd: uiBundleDir }); } else if (!skipUIBundleBuild && skipDeploy) { // Only build here if the pre-deploy build didn't already run run('UI Bundle npm install', 'npm', ['install'], { cwd: uiBundleDir }); run('UI Bundle build', 'npm', ['run', 'build'], { cwd: uiBundleDir }); } console.log('\n--- Setup complete ---'); if (!skipDev) { console.log('\n--- Launching dev server (Ctrl+C to stop) ---\n'); run('Dev server', 'npm', ['run', 'dev'], { cwd: uiBundleDir }); } } main().catch((err) => { console.error(err); process.exit(1); });