2026-03-31 04:50:18 +08:00
#!/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 :
2026-04-01 11:28:14 +08:00
* node scripts / org - setup . mjs -- target - org < alias > # interactive step picker ( all selected )
* node scripts / org - setup . mjs -- target - org < alias > -- 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
2026-03-31 04:50:18 +08:00
*
* 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 < alias > ( requires dist for entity deployment )
2026-04-09 01:00:47 +08:00
* 4. permset — assign permsets per org - setup . config . json ( skip with -- skip - permset ; override via -- permset - name )
2026-03-31 04:50:18 +08:00
* 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 )
2026-04-09 01:00:47 +08:00
*
* Permset assignment config ( scripts / org - setup . config . json ) :
* {
* "permsetAssignments" : {
* "assignments" : {
2026-06-03 15:13:28 +08:00
* "My_Permset" : { "assignee" : "currentUser" } ,
* "Guest_Permset" : { "assignee" : "guestUser" , "siteName" : "mysite" } ,
2026-04-09 01:00:47 +08:00
* "Internal_Only" : { "assignee" : "skip" }
* }
* }
* }
2026-06-03 15:13:28 +08:00
* Assignee values : "currentUser" , "skip" , "guestUser" ( requires siteName ) , or a specific username .
* Only permsets explicitly listed in assignments are assigned ; unlisted permsets are skipped .
2026-03-31 04:50:18 +08:00
* /
2026-06-03 15:13:28 +08:00
import { spawnSync , spawn as nodeSpawn } from 'node:child_process' ;
2026-03-31 04:50:18 +08:00
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 , '..' ) ;
2026-04-01 11:28:14 +08:00
/ * *
* 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 } ` ) ;
}
}
2026-03-31 04:50:18 +08:00
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 ,
2026-06-03 15:13:28 +08:00
skipRole : false ,
2026-03-31 04:50:18 +08:00
skipData : false ,
skipGraphql : false ,
skipUIBundleBuild : false ,
2026-06-03 15:13:28 +08:00
skipSelfReg : false ,
2026-03-31 04:50:18 +08:00
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 ;
2026-06-03 15:13:28 +08:00
else if ( args [ i ] === '--skip-role' ) flags . skipRole = true ;
2026-03-31 04:50:18 +08:00
else if ( args [ i ] === '--skip-data' ) flags . skipData = true ;
2026-06-03 15:13:28 +08:00
else if ( args [ i ] === '--skip-self-reg' ) flags . skipSelfReg = true ;
2026-03-31 04:50:18 +08:00
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 :
2026-04-01 11:28:14 +08:00
node scripts / org - setup . mjs -- target - org < alias > [ options ]
2026-03-31 04:50:18 +08:00
Required :
-- target - org < alias > Target Salesforce org alias ( e . g . myorg )
Options :
-- ui - bundle - name < name > UI bundle folder name under uiBundles / ( default : auto - detect )
-- permset - name < 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
2026-04-09 01:00:47 +08:00
Permset config ( scripts / org - setup . config . json ) :
Control per - permset assignment via a config file . Example :
{
"permsetAssignments" : {
"assignments" : {
2026-06-03 15:13:28 +08:00
"My_Permset" : { "assignee" : "currentUser" } ,
"Guest_Permset" : { "assignee" : "guestUser" , "siteName" : "mysite" } ,
2026-04-09 01:00:47 +08:00
"Internal_Only" : { "assignee" : "skip" }
}
}
}
2026-06-03 15:13:28 +08:00
Assignee values : "currentUser" , "skip" , "guestUser" ( requires siteName ) , or a specific username .
Only permsets explicitly listed in assignments are assigned ; unlisted permsets are skipped .
2026-03-31 04:50:18 +08:00
` );
process . exit ( 0 ) ;
}
}
if ( ! targetOrg ) {
console . error ( 'Error: --target-org <alias> 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 ( ) ;
}
2026-04-09 01:00:47 +08:00
/ * *
* Load permset assignment configuration from org - setup . config . json .
*
* Config shape :
* {
* "permsetAssignments" : {
* "assignments" : {
2026-06-03 15:13:28 +08:00
* "My_Permset" : { "assignee" : "currentUser" } ,
* "My_Guest_Permset" : { "assignee" : "guestUser" , "siteName" : "mysite" } ,
2026-04-09 01:00:47 +08:00
* "Internal_Only" : { "assignee" : "skip" }
* }
* }
* }
*
* Assignee values :
2026-06-03 15:13:28 +08:00
* "currentUser" — assign to the user running the script
2026-04-09 01:00:47 +08:00
* "skip" — do not assign this permset
2026-06-03 15:13:28 +08:00
* "guestUser" — resolve the site guest user automatically ( requires siteName )
2026-04-09 01:00:47 +08:00
* "<username>" — assign to a specific user via -- on - behalf - of
*
2026-06-03 15:13:28 +08:00
* Only permsets explicitly listed in assignments are assigned ; unlisted permsets are skipped .
*
* Returns { assignments : Record < string , { assignee : string , siteName ? : string } > }
2026-04-09 01:00:47 +08:00
* /
function loadPermsetConfig ( ) {
const configPath = resolve ( _ _dirname , 'org-setup.config.json' ) ;
2026-06-03 15:13:28 +08:00
const defaults = { assignments : { } } ;
2026-04-09 01:00:47 +08:00
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 ;
}
}
2026-06-03 15:13:28 +08:00
/** Resolve the effective assignment config for a given permset name. */
function resolveAssignment ( permsetName , permsetConfig ) {
2026-04-09 01:00:47 +08:00
const override = permsetConfig . assignments [ permsetName ] ;
2026-06-03 15:13:28 +08:00
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 ( ` <profile> \\ s* ${ profileEscaped } \\ s*</profile> ` ) ;
if ( profileRegex . test ( xml ) ) {
console . log ( ` Profile " ${ selfRegProfile } " already in networkMemberGroups; no update needed. ` ) ;
return ;
}
// Add the profile to networkMemberGroups
const updatedXml = xml . replace (
/(<networkMemberGroups>)/ ,
` $ 1 \n <profile> ${ selfRegProfile } </profile> `
) ;
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 = /<selfRegistration>true<\/selfRegistration>/ . test ( xml ) ;
const alreadyHasProfile = /<selfRegProfile>/ . 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 (
/<selfRegistration>false<\/selfRegistration>/ ,
'<selfRegistration>true</selfRegistration>'
) ;
updatedXml = updatedXml . replace (
/(\s*)(<selfRegistration>)/ ,
` $ 1<selfRegProfile> ${ selfRegProfile } </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 ;
}
2026-04-09 01:00:47 +08:00
}
2026-03-31 04:50:18 +08:00
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' ;
2026-06-03 15:13:28 +08:00
/** 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 ;
}
2026-03-31 04:50:18 +08:00
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 } ` ;
} ) ;
}
2026-06-03 15:13:28 +08:00
let prevRows = 0 ;
2026-03-31 04:50:18 +08:00
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' ) ;
2026-06-03 15:13:28 +08:00
const initialLines = render ( ) ;
prevRows = terminalRows ( initialLines ) ;
process . stdout . write ( initialLines . join ( '\n' ) + '\n' ) ;
2026-03-31 04:50:18 +08:00
function redraw ( ) {
2026-06-03 15:13:28 +08:00
process . stdout . write ( ` \x 1B[ ${ prevRows } A ` ) ;
const lines = render ( ) ;
for ( const line of lines ) process . stdout . write ( ` \x 1B[2K ${ line } \n ` ) ;
prevRows = terminalRows ( lines ) ;
2026-03-31 04:50:18 +08:00
}
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 ( ` \n Setup failed at step: ${ name } ` ) ;
process . exit ( result . status ? ? 1 ) ;
}
return result ;
}
2026-06-03 15:13:28 +08:00
/** 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 ( ` \n Setup failed at step: ${ name } ` ) ;
process . exit ( result . status ? ? 1 ) ;
}
return result ;
}
2026-03-31 04:50:18 +08:00
async function main ( ) {
2026-04-01 11:28:14 +08:00
// 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 ) ;
}
}
}
}
2026-03-31 04:50:18 +08:00
const {
targetOrg ,
uiBundleName ,
permsetNamesExplicit ,
yes ,
skipLogin : argSkipLogin ,
skipDeploy : argSkipDeploy ,
skipPermset : argSkipPermset ,
2026-06-03 15:13:28 +08:00
skipRole : argSkipRole ,
skipSelfReg : argSkipSelfReg ,
2026-03-31 04:50:18 +08:00
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 ) ;
2026-06-03 15:13:28 +08:00
const roleConfig = loadRoleConfig ( ) ;
const hasRoleConfig = roleConfig !== null ;
const selfRegConfig = loadSelfRegConfig ( ) ;
const hasSelfRegConfig = selfRegConfig !== null ;
2026-03-31 04:50:18 +08:00
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 } ,
2026-06-03 15:13:28 +08:00
{ 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 } ,
2026-03-31 04:50:18 +08:00
{ 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 ;
2026-06-03 15:13:28 +08:00
const skipRole = ! on . role ;
const skipSelfReg = ! on . selfReg ;
2026-03-31 04:50:18 +08:00
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 (
2026-06-03 15:13:28 +08:00
'Steps: login=%s deploy=%s permset=%s role=%s selfReg=%s data=%s graphql=%s uiBundle=%s dev=%s' ,
2026-03-31 04:50:18 +08:00
! skipLogin ,
! skipDeploy ,
! skipPermset ,
2026-06-03 15:13:28 +08:00
! skipRole ,
! skipSelfReg ,
2026-03-31 04:50:18 +08:00
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 } ) ;
}
}
2026-06-03 15:13:28 +08:00
// 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 ) ;
}
2026-03-31 04:50:18 +08:00
// 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 ) {
2026-04-09 01:00:47 +08:00
const permsetConfig = loadPermsetConfig ( ) ;
2026-03-31 04:50:18 +08:00
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 ---' ) ;
2026-06-03 15:13:28 +08:00
// Resolve assignments (guest user lookups etc.) then run all sf assign calls in parallel.
const assignmentJobs = [ ] ;
2026-03-31 04:50:18 +08:00
for ( const permsetName of permsetNames ) {
2026-06-03 15:13:28 +08:00
const assignment = resolveAssignment ( permsetName , permsetConfig ) ;
if ( assignment . assignee === 'skip' ) {
2026-04-09 01:00:47 +08:00
console . log ( ` Permission set " ${ permsetName } " — skipped (config). ` ) ;
continue ;
}
2026-06-03 15:13:28 +08:00
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 } ) => {
2026-04-09 01:00:47 +08:00
const sfArgs = [ 'org' , 'assign' , 'permset' , '--name' , permsetName , '--target-org' , targetOrg ] ;
2026-06-03 15:13:28 +08:00
if ( effectiveUsername ) {
sfArgs . push ( '--on-behalf-of' , effectiveUsername ) ;
2026-04-09 01:00:47 +08:00
}
2026-06-03 15:13:28 +08:00
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 ) {
2026-04-09 01:00:47 +08:00
console . log ( ` Permission set " ${ permsetName } " assigned to ${ assigneeLabel } . ` ) ;
2026-03-31 04:50:18 +08:00
} else {
2026-06-03 15:13:28 +08:00
const out = ( result . stderr || '' ) + ( result . stdout || '' ) ;
2026-03-31 04:50:18 +08:00
if ( out . includes ( 'Duplicate' ) && out . includes ( 'PermissionSet' ) ) {
2026-04-09 01:00:47 +08:00
console . log ( ` Permission set " ${ permsetName } " already assigned to ${ assigneeLabel } ; skipping. ` ) ;
2026-03-31 04:50:18 +08:00
} else if ( out . includes ( 'not found' ) && out . includes ( 'target org' ) ) {
console . log ( ` Permission set " ${ permsetName } " not in org; skipping. ` ) ;
} else {
2026-06-03 15:13:28 +08:00
if ( result . stdout ) process . stdout . write ( result . stdout ) ;
if ( result . stderr ) process . stderr . write ( result . stderr ) ;
2026-03-31 04:50:18 +08:00
console . error ( ` \n Setup failed at step: Assign permission set ( ${ permsetName } ) ` ) ;
2026-06-03 15:13:28 +08:00
process . exit ( result . status ? ? 1 ) ;
2026-03-31 04:50:18 +08:00
}
}
}
}
}
2026-06-03 15:13:28 +08:00
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 ) ;
}
}
2026-03-31 04:50:18 +08:00
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<SObject> 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 ( ` \n Setup 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 ) {
2026-06-03 15:13:28 +08:00
run ( 'UI Bundle npm install' , 'npm' , [ 'install' ] , { cwd : uiBundleDir } ) ;
2026-03-31 04:50:18 +08:00
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 } ) ;
2026-06-03 15:13:28 +08:00
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 } ) ;
2026-03-31 04:50:18 +08:00
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 ) ;
} ) ;