@salesforce/ui-bundle-template-feature-react-search 11.12.1 → 11.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,19 +5,21 @@
5
5
  *
6
6
  * Usage:
7
7
  * node scripts/org-setup.mjs --target-org <alias> # interactive step picker (all selected)
8
+ * node scripts/org-setup.mjs # prompt to pick an authenticated org
8
9
  * node scripts/org-setup.mjs --target-org <alias> --yes # skip picker, run all steps
9
- * node scripts/org-setup.mjs --target-org afv5 --skip-login
10
10
  * node scripts/org-setup.mjs --target-org afv5 --skip-data --skip-ui-bundle-build
11
11
  * node scripts/org-setup.mjs --target-org myorg --ui-bundle-name my-app
12
12
  *
13
+ * Login is an unconditional precondition (not a toggleable step); the dev server
14
+ * is launched separately via `npm run dev:preview` (scripts/org-setup-dev.mjs).
15
+ *
13
16
  * Steps (in order):
14
- * 1. login — sf org login web only if org not already connected (skip with --skip-login)
15
- * 2. uiBundle — (all UI bundles) npm install && npm run build so dist exists for deploy (skip with --skip-ui-bundle-build)
16
- * 3. deploy — sf project deploy start --target-org <alias> (requires dist for entity deployment)
17
- * 4. permset — assign permsets per org-setup.config.json (skip with --skip-permset; override via --permset-name)
18
- * 5. data — prepare unique fields + sf data import tree (skipped if no data dir/plan)
19
- * 6. graphql — (in UI bundle) npm run graphql:schema then npm run graphql:codegen
20
- * 7. dev — (in UI bundle) npm run dev — launch dev server (skip with --skip-dev)
17
+ * login (precondition) — sf org login web only if org not already connected; always runs before deploy
18
+ * 1. uiBundle — (all UI bundles) npm install && npm run build so dist exists for deploy (skip with --skip-ui-bundle-build)
19
+ * 2. deploy — sf project deploy start --target-org <alias> (requires dist for entity deployment)
20
+ * 3. permset — assign permsets per org-setup.config.json (skip with --skip-permset; override via --permset-name)
21
+ * 4. data — prepare unique fields + sf data import tree (skipped if no data dir/plan)
22
+ * 5. graphql — (in UI bundle) npm run graphql:schema then npm run graphql:codegen
21
23
  *
22
24
  * Permset assignment config (scripts/org-setup.config.json):
23
25
  * {
@@ -37,11 +39,33 @@
37
39
  */
38
40
 
39
41
  import { spawnSync, spawn as nodeSpawn } from 'node:child_process';
40
- import { resolve, dirname } from 'node:path';
42
+ import { resolve, dirname, join } from 'node:path';
41
43
  import { fileURLToPath } from 'node:url';
42
- import { readdirSync, existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
44
+ import {
45
+ readdirSync,
46
+ existsSync,
47
+ readFileSync,
48
+ writeFileSync,
49
+ mkdtempSync,
50
+ rmSync,
51
+ openSync,
52
+ closeSync,
53
+ } from 'node:fs';
54
+ import { tmpdir } from 'node:os';
43
55
 
44
56
  import { validateConfig } from './org-setup-config-schema.mjs';
57
+ import {
58
+ addProfileToMemberGroups,
59
+ enableSelfRegInXml,
60
+ NetworkXmlError,
61
+ } from './org-setup-xml.mjs';
62
+ import {
63
+ discoverAllUIBundleDirs as discoverAllUIBundleDirsIn,
64
+ discoverUIBundleDir as discoverUIBundleDirIn,
65
+ resolveTargetOrg,
66
+ evaluateLicenseRows,
67
+ validateProfileNameForSoql,
68
+ } from './org-setup-utils.mjs';
45
69
 
46
70
  const __dirname = dirname(fileURLToPath(import.meta.url));
47
71
  const ROOT = resolve(__dirname, '..');
@@ -53,6 +77,149 @@ const ROOT = resolve(__dirname, '..');
53
77
  */
54
78
  class StepError extends Error {}
55
79
 
80
+ const APEX_TMP_PREFIX = 'org-setup-';
81
+ // Legacy fixed-name temp files that older versions of this script wrote to ROOT
82
+ // and could leave behind on a hard kill. Swept at startup for backward cleanup.
83
+ const LEGACY_ROOT_TMP_FILES = ['.tmp-setup-selfreg.apex', '.tmp-setup-delete.apex'];
84
+
85
+ /**
86
+ * Remove leftover Apex temp artifacts from a prior hard-killed run (cleanup AT
87
+ * START). Two sources: per-run dirs under os.tmpdir() created by
88
+ * withApexTempDir, and legacy fixed-name files this script used to write to
89
+ * ROOT. Best-effort: a failure to remove one entry never blocks setup.
90
+ *
91
+ * Scoped to DIRECTORY entries only: the per-run artifacts are dirs created by
92
+ * withApexTempDir via mkdtempSync, whereas the per-org lock files share the same
93
+ * `org-setup-` prefix but are plain files (`org-setup-lock-<org>.lock`). Sweeping
94
+ * files too would delete a live run's lock before acquireOrgLock could see it,
95
+ * defeating the concurrency guard. The dir sweep is still intentionally broad
96
+ * across PIDs; the per-target-org lock acquired in main() is what makes that
97
+ * safe — only one live run per org.
98
+ */
99
+ function sweepStaleApexTempDirs() {
100
+ const base = tmpdir();
101
+ let entries = [];
102
+ try {
103
+ entries = readdirSync(base, { withFileTypes: true });
104
+ } catch {
105
+ /* tmpdir unreadable — nothing to sweep */
106
+ }
107
+ for (const entry of entries) {
108
+ if (entry.isDirectory() && entry.name.startsWith(APEX_TMP_PREFIX)) {
109
+ try {
110
+ rmSync(join(base, entry.name), { recursive: true, force: true });
111
+ } catch {
112
+ /* best effort */
113
+ }
114
+ }
115
+ }
116
+ for (const legacy of LEGACY_ROOT_TMP_FILES) {
117
+ const p = resolve(ROOT, legacy);
118
+ if (existsSync(p)) {
119
+ try {
120
+ rmSync(p, { force: true });
121
+ } catch {
122
+ /* best effort */
123
+ }
124
+ }
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Run `fn(writeApex)` with a private temp dir under os.tmpdir() that is ALWAYS
130
+ * removed afterwards (cleanup IN FINALLY), even when fn throws or
131
+ * the spawned Apex step fails. `writeApex(basename, contents)` writes a file in
132
+ * that dir and returns its absolute path. Replaces the old fixed-name
133
+ * `.tmp-setup-*.apex` files in ROOT, which collided across concurrent runs and
134
+ * leaked on throw.
135
+ */
136
+ function withApexTempDir(fn) {
137
+ const dir = mkdtempSync(join(tmpdir(), APEX_TMP_PREFIX));
138
+ try {
139
+ return fn((basename, contents) => {
140
+ const p = join(dir, basename);
141
+ writeFileSync(p, contents);
142
+ return p;
143
+ });
144
+ } finally {
145
+ try {
146
+ rmSync(dir, { recursive: true, force: true });
147
+ } catch {
148
+ /* best effort */
149
+ }
150
+ }
151
+ }
152
+
153
+ /** Filesystem-safe lock path for a target org. */
154
+ function orgLockPath(targetOrg) {
155
+ const safe = String(targetOrg).replace(/[^a-zA-Z0-9._-]/g, '_');
156
+ return join(tmpdir(), `org-setup-lock-${safe}.lock`);
157
+ }
158
+
159
+ /**
160
+ * Liveness probe: signal 0 throws ESRCH if the pid is gone, EPERM if it exists
161
+ * but we can't signal it (still alive, owned by another user).
162
+ */
163
+ function isProcessAlive(pid) {
164
+ try {
165
+ process.kill(pid, 0);
166
+ return true;
167
+ } catch (e) {
168
+ return e.code === 'EPERM';
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Acquire a single-host advisory per-target-org lock. If a LIVE
174
+ * run already holds it, exit early with a clear message rather than interleaving
175
+ * destructive Apex (deletes/imports) against the same org. A lock left by a
176
+ * hard-killed run (dead pid) is reclaimed. Different-org runs use different lock
177
+ * files and proceed in parallel.
178
+ *
179
+ * Released via process.on('exit') — NOT a finally block — because main() exits
180
+ * through process.exit() (the runStep fail-fast path, the end-of-run summary,
181
+ * and the top-level .catch), and process.exit() does not run finally blocks.
182
+ */
183
+ function acquireOrgLock(targetOrg) {
184
+ const lockPath = orgLockPath(targetOrg);
185
+ if (existsSync(lockPath)) {
186
+ const holder = Number(readFileSync(lockPath, 'utf8').trim());
187
+ if (holder && isProcessAlive(holder)) {
188
+ console.error(
189
+ `\nAnother org-setup run (pid ${holder}) is already targeting "${targetOrg}".\n` +
190
+ `Wait for it to finish, or if it was killed, remove ${lockPath} and retry.`,
191
+ );
192
+ process.exit(1);
193
+ }
194
+ // Stale lock (holder dead / hard-killed) — reclaim it.
195
+ rmSync(lockPath, { force: true });
196
+ }
197
+ // O_EXCL ('wx') create closes the check-then-write race between two
198
+ // near-simultaneous runs: the loser gets EEXIST.
199
+ let fd;
200
+ try {
201
+ fd = openSync(lockPath, 'wx');
202
+ } catch (e) {
203
+ if (e.code === 'EEXIST') {
204
+ console.error(
205
+ `\nAnother org-setup run just acquired the lock for "${targetOrg}". Retry shortly.`,
206
+ );
207
+ process.exit(1);
208
+ }
209
+ throw e;
210
+ }
211
+ writeFileSync(fd, String(process.pid));
212
+ closeSync(fd);
213
+
214
+ process.on('exit', () => {
215
+ try {
216
+ rmSync(lockPath, { force: true });
217
+ } catch {
218
+ /* best effort */
219
+ }
220
+ });
221
+ }
222
+
56
223
  /**
57
224
  * npm strips .gitignore from published packages — generate them on first run.
58
225
  * Templates are stored in scripts/gitignore-templates.json (generated at build
@@ -107,7 +274,6 @@ function parseArgs() {
107
274
  const permsetNamesExplicit = [];
108
275
  let yes = false;
109
276
  const flags = {
110
- skipLogin: false,
111
277
  skipDeploy: false,
112
278
  skipPermset: false,
113
279
  skipRole: false,
@@ -115,7 +281,6 @@ function parseArgs() {
115
281
  skipGraphql: false,
116
282
  skipUIBundleBuild: false,
117
283
  skipSelfReg: false,
118
- skipDev: false,
119
284
  };
120
285
  for (let i = 0; i < args.length; i++) {
121
286
  if (args[i] === '--target-org' && args[i + 1]) {
@@ -124,39 +289,42 @@ function parseArgs() {
124
289
  uiBundleName = args[++i];
125
290
  } else if (args[i] === '--permset-name' && args[i + 1]) {
126
291
  permsetNamesExplicit.push(args[++i]);
127
- } else if (args[i] === '--skip-login') flags.skipLogin = true;
128
- else if (args[i] === '--skip-deploy') flags.skipDeploy = true;
292
+ } else if (args[i] === '--skip-deploy') flags.skipDeploy = true;
129
293
  else if (args[i] === '--skip-permset') flags.skipPermset = true;
130
294
  else if (args[i] === '--skip-role') flags.skipRole = true;
131
295
  else if (args[i] === '--skip-data') flags.skipData = true;
132
296
  else if (args[i] === '--skip-self-reg') flags.skipSelfReg = true;
133
297
  else if (args[i] === '--skip-graphql') flags.skipGraphql = true;
134
298
  else if (args[i] === '--skip-ui-bundle-build') flags.skipUIBundleBuild = true;
135
- else if (args[i] === '--skip-dev') flags.skipDev = true;
136
- else if (args[i] === '--yes' || args[i] === '-y') yes = true;
299
+ // --skip-login and --skip-dev are retired (spec §5.5/§5.6, AC-14): login is now
300
+ // an unconditional precondition and the dev step moved to `npm run dev:preview`.
301
+ // Accept them silently as no-ops so existing invocations don't hard-error.
302
+ else if (args[i] === '--skip-login' || args[i] === '--skip-dev') {
303
+ /* no-op (retired flag) */
304
+ } else if (args[i] === '--yes' || args[i] === '-y') yes = true;
137
305
  else if (args[i] === '--help' || args[i] === '-h') {
138
306
  console.log(`
139
307
  Setup CLI — one-command setup for apps in this project
140
308
 
141
309
  Usage:
142
- node scripts/org-setup.mjs --target-org <alias> [options]
143
-
144
- Required:
145
- --target-org <alias> Target Salesforce org alias (e.g. myorg)
310
+ node scripts/org-setup.mjs [--target-org <alias>] [options]
146
311
 
147
312
  Options:
313
+ --target-org <alias> Target Salesforce org alias (e.g. myorg). If omitted, you
314
+ are prompted to pick from authenticated orgs (or the
315
+ default org is used when not running interactively).
148
316
  --ui-bundle-name <name> UI bundle folder name under uiBundles/ (default: auto-detect)
149
317
  --permset-name <name> Assign only this permission set (repeatable). Default: all sets under permissionsets/
150
- --skip-login Skip login step (login is auto-skipped if org is already connected)
151
318
  --skip-deploy Do not deploy metadata
152
319
  --skip-permset Do not assign permission set
153
320
  --skip-data Do not prepare data or run data import
154
321
  --skip-graphql Do not fetch schema or run GraphQL codegen
155
322
  --skip-ui-bundle-build Do not npm install / build the UI bundle
156
- --skip-dev Do not launch the dev server at the end
157
323
  -y, --yes Skip interactive step picker; run all enabled steps immediately
158
324
  -h, --help Show this help
159
325
 
326
+ To launch the dev server after setup, run: npm run dev:preview
327
+
160
328
  Permset config (scripts/org-setup.config.json):
161
329
  Control per-permset assignment via a config file. Example:
162
330
  {
@@ -176,41 +344,22 @@ Permset config (scripts/org-setup.config.json):
176
344
  process.exit(0);
177
345
  }
178
346
  }
179
- if (!targetOrg) {
180
- console.error('Error: --target-org <alias> is required.');
181
- process.exit(1);
182
- }
347
+ // NOTE: no hard-exit on a missing --target-org here (spec §5.4). Resolution is
348
+ // deferred to resolveTargetOrg(), which either prompts (TTY) or falls back to
349
+ // the default org / exits with a clear message (non-TTY).
183
350
  return { targetOrg, uiBundleName, permsetNamesExplicit, yes, ...flags };
184
351
  }
185
352
 
353
+ // Bundle discovery lives in org-setup-utils.mjs so org-setup-dev.mjs shares it verbatim.
354
+ // These thin wrappers bind the shared helpers to this script's UIBUNDLES_DIR and
355
+ // keep the existing call-site signatures. discoverUIBundleDir now carries the
356
+ // multi-bundle acknowledgment of spec §5.1 item 2 (TTY picker / non-TTY warning).
186
357
  function discoverAllUIBundleDirs(uiBundleName) {
187
- if (!existsSync(UIBUNDLES_DIR)) {
188
- console.error(`Error: uiBundles directory not found: ${UIBUNDLES_DIR}`);
189
- process.exit(1);
190
- }
191
- const entries = readdirSync(UIBUNDLES_DIR, { withFileTypes: true });
192
- const dirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith('.'));
193
- if (dirs.length === 0) {
194
- console.error(`Error: No UI bundle folder found under ${UIBUNDLES_DIR}`);
195
- process.exit(1);
196
- }
197
- if (uiBundleName) {
198
- const requested = dirs.find((d) => d.name === uiBundleName);
199
- if (!requested) {
200
- console.error(`Error: UI bundle directory not found: ${uiBundleName}`);
201
- process.exit(1);
202
- }
203
- return [resolve(UIBUNDLES_DIR, requested.name)];
204
- }
205
- return dirs.map((d) => resolve(UIBUNDLES_DIR, d.name));
358
+ return discoverAllUIBundleDirsIn(UIBUNDLES_DIR, uiBundleName);
206
359
  }
207
360
 
208
361
  function discoverUIBundleDir(uiBundleName) {
209
- const all = discoverAllUIBundleDirs(uiBundleName);
210
- if (all.length > 1 && !uiBundleName) {
211
- console.log(`Multiple UI bundles found; using first: ${all[0].split(/[/\\]/).pop()}`);
212
- }
213
- return all[0];
362
+ return discoverUIBundleDirIn(UIBUNDLES_DIR, uiBundleName);
214
363
  }
215
364
 
216
365
  /** API names from permissionsets/*.permissionset-meta.xml in the first package directory. */
@@ -254,7 +403,7 @@ function loadValidatedConfig() {
254
403
  /**
255
404
  * Derive the site name from the single networks/<siteName>.network-meta.xml the
256
405
  * app ships. An app ships exactly one site, so the site name is derivable from
257
- * deployed metadata rather than restated per-assignment (spec §5.2). Returns
406
+ * deployed metadata rather than restated per-assignment. Returns
258
407
  * null when there is no networks dir or no .network-meta.xml file.
259
408
  */
260
409
  function deriveSiteName() {
@@ -335,7 +484,7 @@ function loadRoleConfig(config) {
335
484
  /**
336
485
  * Self-registration config, read from the already-validated config. The site is
337
486
  * NOT stored here — it is derived from the single
338
- * networks/<siteName>.network-meta.xml the app ships (spec §5.2), exactly like
487
+ * networks/<siteName>.network-meta.xml the app ships, exactly like
339
488
  * the guestUser permset path. deriveSiteName() is called lazily inside the
340
489
  * selfReg step body so its "multiple network files" StepError is recorded in
341
490
  * the ledger rather than escaping config load.
@@ -375,20 +524,31 @@ function ensureNetworkMemberProfile(selfRegConfig, siteName) {
375
524
  }
376
525
  const xml = readFileSync(networkXmlPath, 'utf8');
377
526
 
378
- // Check if profile is already in networkMemberGroups
379
- const profileEscaped = selfRegProfile.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
380
- const profileRegex = new RegExp(`<profile>\\s*${profileEscaped}\\s*</profile>`);
381
- if (profileRegex.test(xml)) {
527
+ // Parse + assert the target node exists, then mutate via a targeted edit.
528
+ // This is the BEST-EFFORT pre-deploy prep (not inside runStep, see lines
529
+ // ~1050): on a missing <networkMemberGroups> node we surface a LOUD
530
+ // console.error but do NOT throw — a bare throw here aborts before deploy and
531
+ // bypasses the ledger. The authoritative failure is recorded later by the
532
+ // post-deploy selfReg step.
533
+ let result;
534
+ try {
535
+ result = addProfileToMemberGroups(xml, selfRegProfile);
536
+ } catch (e) {
537
+ if (e instanceof NetworkXmlError) {
538
+ console.error(
539
+ ` ERROR: cannot add self-reg profile to ${siteName}.network-meta.xml — ${e.message}. ` +
540
+ `Continuing pre-deploy; the self-registration step will record the authoritative failure.`,
541
+ );
542
+ return;
543
+ }
544
+ throw e;
545
+ }
546
+
547
+ if (!result.changed) {
382
548
  console.log(` Profile "${selfRegProfile}" already in networkMemberGroups; no update needed.`);
383
549
  return;
384
550
  }
385
-
386
- // Add the profile to networkMemberGroups
387
- const updatedXml = xml.replace(
388
- /(<networkMemberGroups>)/,
389
- `$1\n <profile>${selfRegProfile}</profile>`
390
- );
391
- writeFileSync(networkXmlPath, updatedXml);
551
+ writeFileSync(networkXmlPath, result.xml);
392
552
  console.log(` Added profile "${selfRegProfile}" to networkMemberGroups in ${siteName}.network-meta.xml`);
393
553
  }
394
554
 
@@ -410,23 +570,26 @@ function enableSelfRegistration(selfRegConfig, siteName, targetOrg) {
410
570
  }
411
571
  const xml = readFileSync(networkXmlPath, 'utf8');
412
572
 
413
- // Skip network modification and deploy if self-registration is already configured
414
- const alreadyEnabled = /<selfRegistration>true<\/selfRegistration>/.test(xml);
415
- const alreadyHasProfile = /<selfRegProfile>/.test(xml);
416
- if (alreadyEnabled || alreadyHasProfile) {
573
+ // Parse + assert the <selfRegistration> node exists, then mutate via a
574
+ // targeted edit. This runs inside
575
+ // runStep(selfReg, failFast:false) (line ~1211), so a missing node must throw
576
+ // a StepError caught, recorded `failed`, non-zero exit — rather than the
577
+ // old silent no-op. The helper's idempotency check
578
+ // (already true / selfRegProfile present) yields changed:false here.
579
+ let result;
580
+ try {
581
+ result = enableSelfRegInXml(xml, selfRegProfile);
582
+ } catch (e) {
583
+ if (e instanceof NetworkXmlError) {
584
+ throw new StepError(`${siteName}.network-meta.xml: ${e.message}`);
585
+ }
586
+ throw e;
587
+ }
588
+
589
+ if (!result.changed) {
417
590
  console.log(` Network "${siteName}" already has self-registration configured; skipping metadata update and deploy.`);
418
591
  } else {
419
- // Set selfRegistration to true and add selfRegProfile
420
- let updatedXml = xml.replace(
421
- /<selfRegistration>false<\/selfRegistration>/,
422
- '<selfRegistration>true</selfRegistration>'
423
- );
424
- updatedXml = updatedXml.replace(
425
- /(\s*)(<selfRegistration>)/,
426
- `$1<selfRegProfile>${selfRegProfile}</selfRegProfile>\n$1$2`
427
- );
428
-
429
- writeFileSync(networkXmlPath, updatedXml);
592
+ writeFileSync(networkXmlPath, result.xml);
430
593
  console.log(` Updated ${siteName}.network-meta.xml: selfRegistration=true, selfRegProfile=${selfRegProfile}`);
431
594
 
432
595
  // Re-deploy only the network file
@@ -516,7 +679,6 @@ function enableSelfRegistration(selfRegConfig, siteName, targetOrg) {
516
679
  if (nsrExists) {
517
680
  console.log(' NetworkSelfRegistration record already exists; skipping.');
518
681
  } else {
519
- const tmpApex = resolve(ROOT, '.tmp-setup-selfreg.apex');
520
682
  const apex = [
521
683
  `Account acct = [SELECT Id FROM Account WHERE Id = '${accountId}' LIMIT 1];`,
522
684
  `NetworkSelfRegistration nsr = new NetworkSelfRegistration();`,
@@ -525,16 +687,18 @@ function enableSelfRegistration(selfRegConfig, siteName, targetOrg) {
525
687
  `insert nsr;`,
526
688
  `System.debug('NSR_CREATED:' + nsr.Id);`,
527
689
  ].join('\n');
528
- writeFileSync(tmpApex, apex);
529
- const apexResult = spawnSync('sf', [
530
- 'apex', 'run', '--target-org', targetOrg, '--file', tmpApex,
531
- ], { cwd: ROOT, stdio: 'pipe', shell: true, timeout: 60000 });
532
- const apexOut = apexResult.stdout?.toString() || '';
533
- if (existsSync(tmpApex)) unlinkSync(tmpApex);
534
- if (apexResult.status !== 0 && !apexOut.includes('Compiled successfully')) {
535
- process.stderr.write(apexResult.stderr?.toString() || apexOut);
536
- throw new StepError('failed to create NetworkSelfRegistration record');
537
- }
690
+ const apexOut = withApexTempDir((writeApex) => {
691
+ const tmpApex = writeApex('selfreg.apex', apex);
692
+ const apexResult = spawnSync('sf', [
693
+ 'apex', 'run', '--target-org', targetOrg, '--file', tmpApex,
694
+ ], { cwd: ROOT, stdio: 'pipe', shell: true, timeout: 60000 });
695
+ const out = apexResult.stdout?.toString() || '';
696
+ if (apexResult.status !== 0 && !out.includes('Compiled successfully')) {
697
+ process.stderr.write(apexResult.stderr?.toString() || out);
698
+ throw new StepError('failed to create NetworkSelfRegistration record');
699
+ }
700
+ return out;
701
+ });
538
702
  const nsrMatch = apexOut.match(/NSR_CREATED:(\w+)/);
539
703
  if (nsrMatch) {
540
704
  console.log(` Created NetworkSelfRegistration (${nsrMatch[1]}).`);
@@ -658,6 +822,119 @@ function resolveGuestUsername(siteName, targetOrg) {
658
822
  }
659
823
  }
660
824
 
825
+ /**
826
+ * M1 license pre-check (spec §5.3). Query the UserLicense that the selfRegProfile
827
+ * belongs to and decide whether self-registration can proceed. The profile name
828
+ * is validate-and-fail (Q10, AC-8): a SOQL-special character throws a config
829
+ * error rather than being escaped — profile names are developer-authored config,
830
+ * not user input, so a `'` / `\` / control char is a mistake to surface loudly.
831
+ *
832
+ * Selection is on the stable LicenseDefinitionKey; the seat math lives in JS
833
+ * (evaluateLicenseRows) because SOQL cannot compare two fields. A query failure
834
+ * or 0 rows is treated as "not satisfied" (soft skip), not a hard error.
835
+ *
836
+ * @returns {{ satisfied: boolean, reason: string|null }}
837
+ */
838
+ function checkSelfRegLicense(selfRegConfig, targetOrg) {
839
+ const profileName = validateProfileNameForSoql(selfRegConfig.selfRegProfile);
840
+ const query =
841
+ `SELECT UserLicense.LicenseDefinitionKey, UserLicense.Name, UserLicense.Status, ` +
842
+ `UserLicense.TotalLicenses, UserLicense.UsedLicenses ` +
843
+ `FROM Profile WHERE Name = '${profileName}'`;
844
+ const result = spawnSync('sf', [
845
+ 'data', 'query',
846
+ '--query', query,
847
+ '--target-org', targetOrg,
848
+ '--json',
849
+ ], { cwd: ROOT, encoding: 'utf8' });
850
+
851
+ if (result.status !== 0) {
852
+ if (result.stderr) console.error(result.stderr);
853
+ return {
854
+ satisfied: false,
855
+ reason: `could not query the UserLicense for profile "${profileName}" (sf data query failed)`,
856
+ };
857
+ }
858
+ let rows;
859
+ try {
860
+ rows = JSON.parse(result.stdout).result?.records ?? [];
861
+ } catch {
862
+ return {
863
+ satisfied: false,
864
+ reason: `could not parse the UserLicense query result for profile "${profileName}"`,
865
+ };
866
+ }
867
+ const { satisfied, reason } = evaluateLicenseRows(rows, profileName);
868
+ return { satisfied, reason };
869
+ }
870
+
871
+ /**
872
+ * Read the UserLicense the selfRegProfile requires straight from the local
873
+ * profile metadata's <userLicense> element. The deploy pre-check runs BEFORE
874
+ * the profile is deployed, so querying the org's Profile table returns 0 rows
875
+ * and can only report "profile missing" — which never tells the user which
876
+ * license to add. The profile source we are about to deploy is the
877
+ * authoritative declaration of the required license.
878
+ *
879
+ * @returns {string|null} the license name, or null if the file / field is absent
880
+ */
881
+ function readProfileUserLicense(selfRegProfile) {
882
+ const profilePath = resolve(SFDX_SOURCE, 'profiles', `${selfRegProfile}.profile-meta.xml`);
883
+ if (!existsSync(profilePath)) return null;
884
+ const xml = readFileSync(profilePath, 'utf8');
885
+ const m = xml.match(/<userLicense>\s*([^<]+?)\s*<\/userLicense>/);
886
+ return m ? m[1].trim() : null;
887
+ }
888
+
889
+ /**
890
+ * Deploy license pre-check (spec §5.3). `sf project deploy start` fails with a
891
+ * cryptic error when the org lacks the UserLicense the selfRegProfile's metadata
892
+ * declares. Resolve the required license NAME from the local profile source, then
893
+ * query the org's UserLicense by that name so a miss names the actual license the
894
+ * admin must add — not the not-yet-deployed profile. Seat/status math is shared
895
+ * with the self-reg gate via evaluateLicenseRows.
896
+ *
897
+ * @returns {{ satisfied: boolean, reason: string|null }}
898
+ */
899
+ function checkDeployLicense(selfRegConfig, targetOrg) {
900
+ const licenseName = readProfileUserLicense(selfRegConfig.selfRegProfile);
901
+ // No <userLicense> in the profile source (or profile file absent) — nothing to
902
+ // gate on; let deploy proceed and surface any real error on its own.
903
+ if (!licenseName) return { satisfied: true, reason: null };
904
+
905
+ const escaped = licenseName.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
906
+ const query =
907
+ `SELECT LicenseDefinitionKey, Name, Status, TotalLicenses, UsedLicenses ` +
908
+ `FROM UserLicense WHERE Name = '${escaped}'`;
909
+ const result = spawnSync('sf', [
910
+ 'data', 'query',
911
+ '--query', query,
912
+ '--target-org', targetOrg,
913
+ '--json',
914
+ ], { cwd: ROOT, encoding: 'utf8' });
915
+
916
+ if (result.status !== 0) {
917
+ if (result.stderr) console.error(result.stderr);
918
+ return { satisfied: false, reason: `could not query UserLicense "${licenseName}" (sf data query failed)` };
919
+ }
920
+ let rows;
921
+ try {
922
+ rows = JSON.parse(result.stdout).result?.records ?? [];
923
+ } catch {
924
+ return { satisfied: false, reason: `could not parse the UserLicense query result for "${licenseName}"` };
925
+ }
926
+ if (rows.length === 0) {
927
+ return {
928
+ satisfied: false,
929
+ reason: `required license "${licenseName}" is not present in the org — add it before deploying`,
930
+ };
931
+ }
932
+ // evaluateLicenseRows expects the license nested under `.UserLicense` (its
933
+ // Profile-query shape); reshape the direct UserLicense rows to match so the
934
+ // seat/status logic — and its license-named messages — stay shared.
935
+ return evaluateLicenseRows(rows.map((r) => ({ UserLicense: r })), selfRegConfig.selfRegProfile);
936
+ }
937
+
661
938
  function isOrgConnected(targetOrg) {
662
939
  const result = spawnSync('sf', ['org', 'display', '--target-org', targetOrg, '--json'], {
663
940
  cwd: ROOT,
@@ -931,6 +1208,13 @@ async function runStep(step, targetOrg, body) {
931
1208
  }
932
1209
 
933
1210
  async function main() {
1211
+ // Cleanup AT START: remove Apex temp artifacts left by a prior hard-killed run.
1212
+ // Safe to run before the per-org lock is acquired because it only removes
1213
+ // org-setup-* temp DIRECTORIES and legacy ROOT temp files — never the
1214
+ // org-setup-lock-<org>.lock files (plain files), so it cannot clobber a
1215
+ // concurrent run's live lock.
1216
+ sweepStaleApexTempDirs();
1217
+
934
1218
  // Ensure .gitignore files exist (npm strips them from published packages).
935
1219
  const gitignoreTemplates = loadGitignoreTemplates();
936
1220
  if (gitignoreTemplates) {
@@ -944,12 +1228,11 @@ async function main() {
944
1228
  }
945
1229
  }
946
1230
 
1231
+ const parsed = parseArgs();
947
1232
  const {
948
- targetOrg,
949
1233
  uiBundleName,
950
1234
  permsetNamesExplicit,
951
1235
  yes,
952
- skipLogin: argSkipLogin,
953
1236
  skipDeploy: argSkipDeploy,
954
1237
  skipPermset: argSkipPermset,
955
1238
  skipRole: argSkipRole,
@@ -957,8 +1240,17 @@ async function main() {
957
1240
  skipData: argSkipData,
958
1241
  skipGraphql: argSkipGraphql,
959
1242
  skipUIBundleBuild: argSkipUIBundleBuild,
960
- skipDev: argSkipDev,
961
- } = parseArgs();
1243
+ } = parsed;
1244
+
1245
+ // Resolve the target org up front (spec §5.4): explicit --target-org is used
1246
+ // verbatim; otherwise prompt (TTY) or fall back to the default org / exit.
1247
+ const targetOrg = await resolveTargetOrg(parsed);
1248
+
1249
+ // Per-target-org lock: acquired right after the start-of-run
1250
+ // sweep and before any step runs, so two runs against the same org cannot
1251
+ // interleave destructive Apex. The startup sweep above deliberately skips lock
1252
+ // files, so it never races this. Released on process exit.
1253
+ acquireOrgLock(targetOrg);
962
1254
 
963
1255
  const permsetNames =
964
1256
  permsetNamesExplicit.length > 0 ? permsetNamesExplicit : discoverPermissionSetNames();
@@ -969,8 +1261,8 @@ async function main() {
969
1261
  ? `Permset — assign ${permsetNames.join(', ')}`
970
1262
  : `Permset — assign ${permsetNames.length} permission sets`;
971
1263
 
972
- // Validate org-setup.config.json ONCE, before any step runs (spec §5.2). The
973
- // three load*Config helpers read from this already-validated object.
1264
+ // Validate org-setup.config.json ONCE, before any step runs. The three
1265
+ // load*Config helpers read from this already-validated object.
974
1266
  const config = loadValidatedConfig();
975
1267
 
976
1268
  const hasDataPlan = existsSync(DATA_PLAN) && existsSync(DATA_DIR);
@@ -979,12 +1271,29 @@ async function main() {
979
1271
  const selfRegConfig = loadSelfRegConfig(config);
980
1272
  const hasSelfRegConfig = selfRegConfig !== null;
981
1273
 
1274
+ // Validate the selfRegProfile name for SOQL-safety up front (spec §5.3, AC-8),
1275
+ // alongside the config validation and BEFORE any org mutation (login/deploy).
1276
+ // A quote / backslash / control char is a config mistake, not a runtime value
1277
+ // to escape — fail fast here with a clean, actionable message rather than
1278
+ // letting the M1 gate throw a raw stack trace deep in the run after deploy.
1279
+ if (hasSelfRegConfig) {
1280
+ try {
1281
+ validateProfileNameForSoql(selfRegConfig.selfRegProfile);
1282
+ } catch (err) {
1283
+ console.error(`Invalid org-setup.config.json:\n - ${err.message}`);
1284
+ process.exit(1);
1285
+ }
1286
+ }
1287
+
982
1288
  // failFast is a fixed, implementer-owned classification (spec §5.2) — NOT
983
1289
  // user-configurable. A fail-fast failure aborts the run immediately; a
984
1290
  // skippable failure is recorded and the run continues. The exit code is
985
1291
  // non-zero on any failure regardless of class.
1292
+ // Login is NOT in this picker (spec §5.6, AC-13): it is an unconditional
1293
+ // precondition run before deploy, not a toggleable step. The dev step is also
1294
+ // gone (spec §5.5): launching the dev server moved to `npm run dev:preview`, so
1295
+ // setup terminates cleanly after graphql (AC-11).
986
1296
  const stepDefs = [
987
- { key: 'login', label: 'Login — org authentication', enabled: !argSkipLogin, available: true, failFast: true },
988
1297
  { key: 'uiBundleBuild', label: 'UI Bundle Build — npm install + build (pre-deploy)', enabled: !argSkipUIBundleBuild, available: true, failFast: true },
989
1298
  { key: 'deploy', label: 'Deploy — sf project deploy start', enabled: !argSkipDeploy, available: true, failFast: true },
990
1299
  { key: 'permset', label: permsetStepLabel, enabled: !argSkipPermset, available: true, failFast: false },
@@ -992,7 +1301,6 @@ async function main() {
992
1301
  { key: 'selfReg', label: 'Self-Registration — enable for site', enabled: !argSkipSelfReg && hasSelfRegConfig, available: hasSelfRegConfig, failFast: false },
993
1302
  { key: 'data', label: 'Data — delete + import records via Apex', enabled: !argSkipData && hasDataPlan, available: hasDataPlan, failFast: true },
994
1303
  { key: 'graphql', label: 'GraphQL — schema introspect + codegen', enabled: !argSkipGraphql, available: true, failFast: true },
995
- { key: 'dev', label: 'Dev — launch dev server', enabled: !argSkipDev, available: true, failFast: false },
996
1304
  ];
997
1305
 
998
1306
  const selections = yes ? stepDefs.map((s) => s.enabled) : await promptSteps(stepDefs);
@@ -1001,7 +1309,6 @@ async function main() {
1001
1309
  on[s.key] = selections[i];
1002
1310
  });
1003
1311
 
1004
- const skipLogin = !on.login;
1005
1312
  const skipUIBundleBuild = !on.uiBundleBuild;
1006
1313
  const skipDeploy = !on.deploy;
1007
1314
  const skipPermset = !on.permset;
@@ -1009,41 +1316,35 @@ async function main() {
1009
1316
  const skipSelfReg = !on.selfReg;
1010
1317
  const skipData = !on.data;
1011
1318
  const skipGraphql = !on.graphql;
1012
- const skipDev = !on.dev;
1013
1319
 
1014
- const needsUIBundle = !skipUIBundleBuild || !skipGraphql || !skipDev;
1015
- const uiBundleDir = needsUIBundle ? discoverUIBundleDir(uiBundleName) : null;
1320
+ const needsUIBundle = !skipUIBundleBuild || !skipGraphql;
1321
+ const uiBundleDir = needsUIBundle ? await discoverUIBundleDir(uiBundleName) : null;
1016
1322
  const doData = !skipData;
1017
1323
 
1018
1324
  console.log('Setup — target org:', targetOrg, '| UI bundle:', uiBundleDir ?? '(none)');
1019
1325
  console.log(
1020
- 'Steps: login=%s deploy=%s permset=%s role=%s selfReg=%s data=%s graphql=%s uiBundle=%s dev=%s',
1021
- !skipLogin,
1326
+ 'Steps: login=always deploy=%s permset=%s role=%s selfReg=%s data=%s graphql=%s uiBundle=%s',
1022
1327
  !skipDeploy,
1023
1328
  !skipPermset,
1024
1329
  !skipRole,
1025
1330
  !skipSelfReg,
1026
1331
  doData,
1027
1332
  !skipGraphql,
1028
- !skipUIBundleBuild,
1029
- !skipDev
1333
+ !skipUIBundleBuild
1030
1334
  );
1031
1335
 
1032
- const loginStep = stepDefs.find((s) => s.key === 'login');
1033
- if (!skipLogin) {
1034
- await runStep(loginStep, targetOrg, async () => {
1035
- if (isOrgConnected(targetOrg)) {
1036
- console.log('\n--- Login ---');
1037
- console.log(`Org ${targetOrg} is already authenticated; skipping browser login.`);
1038
- } else {
1039
- // Login is fail-fast (spec §5.2): drop the previous { optional: true } so a
1040
- // failed browser login records `failed`, prints the summary, and aborts.
1041
- run('Login (browser)', 'sf', ['org', 'login', 'web', '--alias', targetOrg]);
1042
- }
1043
- });
1044
- } else {
1045
- recordSkipped(loginStep, 'not selected');
1046
- }
1336
+ // Login is an unconditional precondition (spec §5.6, AC-13): it is not part of
1337
+ // the step picker and cannot be skipped. It still no-ops when the org is already
1338
+ // connected, and stays fail-fast a failed browser login aborts before deploy.
1339
+ const loginStep = { key: 'login', label: 'Login — org authentication', failFast: true };
1340
+ await runStep(loginStep, targetOrg, async () => {
1341
+ if (isOrgConnected(targetOrg)) {
1342
+ console.log('\n--- Login ---');
1343
+ console.log(`Org ${targetOrg} is already authenticated; skipping browser login.`);
1344
+ } else {
1345
+ run('Login (browser)', 'sf', ['org', 'login', 'web', '--alias', targetOrg]);
1346
+ }
1347
+ });
1047
1348
 
1048
1349
  // Ensure the self-reg profile is in networkMemberGroups before deploy so that
1049
1350
  // subsequent selfRegProfile / selfRegistration updates don't fail. This is
@@ -1088,6 +1389,19 @@ async function main() {
1088
1389
  const deployStep = stepDefs.find((s) => s.key === 'deploy');
1089
1390
  if (!skipDeploy) {
1090
1391
  await runStep(deployStep, targetOrg, () => {
1392
+ // License pre-check (spec §5.3): deploy fails with a cryptic error when the
1393
+ // org lacks the UserLicense the selfRegProfile's metadata requires. Verify a
1394
+ // seat is available BEFORE `sf project deploy start` so the run aborts with a
1395
+ // clean message NAMING the missing license. The required license is read from
1396
+ // the local profile source (the profile isn't in the org yet pre-deploy, so
1397
+ // querying it would only report "profile missing"). Only gates when self-reg
1398
+ // is configured — that config names the profile the license is derived from.
1399
+ if (selfRegConfig) {
1400
+ const licenseGate = checkDeployLicense(selfRegConfig, targetOrg);
1401
+ if (!licenseGate.satisfied) {
1402
+ throw new StepError(`deploy blocked — ${licenseGate.reason}`);
1403
+ }
1404
+ }
1091
1405
  run('Deploy metadata', 'sf', ['project', 'deploy', 'start', '--target-org', targetOrg], {
1092
1406
  timeout: 180000,
1093
1407
  });
@@ -1126,7 +1440,7 @@ async function main() {
1126
1440
  let effectiveUsername = null;
1127
1441
  if (assignment.assignee === 'guestUser') {
1128
1442
  // Site name is derived from the single network metadata file the app
1129
- // ships (spec §5.2) — never restated per-assignment.
1443
+ // ships — never restated per-assignment.
1130
1444
  const siteName = deriveSiteName();
1131
1445
  if (!siteName) {
1132
1446
  console.error(`Permission set "${permsetName}" — assignee is "guestUser" but no networks/<siteName>.network-meta.xml was found to derive the site; skipping.`);
@@ -1196,20 +1510,32 @@ async function main() {
1196
1510
 
1197
1511
  const selfRegStep = stepDefs.find((s) => s.key === 'selfReg');
1198
1512
  if (!skipSelfReg) {
1199
- console.log('\n--- Enable self-registration ---');
1200
- // Config shape (selfRegProfile, accountName) is guaranteed by the schema.
1201
- // The site is derived inside the step body so a "multiple network files"
1202
- // StepError is recorded in the ledger rather than escaping. No manual
1203
- // recordSkipped inference.
1204
- await runStep(selfRegStep, targetOrg, () => {
1205
- const siteName = deriveSiteName();
1206
- if (!siteName) {
1207
- throw new StepError(
1208
- 'self-registration is configured but no networks/<siteName>.network-meta.xml was found to derive the site',
1209
- );
1210
- }
1211
- enableSelfRegistration(selfRegConfig, siteName, targetOrg);
1212
- });
1513
+ // M1 license pre-check (spec §5.3): self-registration requires a seat on the
1514
+ // UserLicense the selfRegProfile belongs to. Verify it BEFORE running the step
1515
+ // recordSkipped is only reachable here, ahead of runStep (mirroring the
1516
+ // not-selected branch); once inside a step body an unmet precondition could
1517
+ // only record `failed`. A missing / inactive / seatless license is a soft
1518
+ // precondition, not a setup failure, so warn + recordSkipped and move on.
1519
+ const licenseGate = checkSelfRegLicense(selfRegConfig, targetOrg);
1520
+ if (!licenseGate.satisfied) {
1521
+ console.warn(`\n--- Self-registration skipped ---\n ⚠ ${licenseGate.reason}`);
1522
+ recordSkipped(selfRegStep, licenseGate.reason);
1523
+ } else {
1524
+ console.log('\n--- Enable self-registration ---');
1525
+ // Config shape (selfRegProfile, accountName) is guaranteed by the schema.
1526
+ // The site is derived inside the step body so a "multiple network files"
1527
+ // StepError is recorded in the ledger rather than escaping. No manual
1528
+ // recordSkipped inference.
1529
+ await runStep(selfRegStep, targetOrg, () => {
1530
+ const siteName = deriveSiteName();
1531
+ if (!siteName) {
1532
+ throw new StepError(
1533
+ 'self-registration is configured but no networks/<siteName>.network-meta.xml was found to derive the site',
1534
+ );
1535
+ }
1536
+ enableSelfRegistration(selfRegConfig, siteName, targetOrg);
1537
+ });
1538
+ }
1213
1539
  } else {
1214
1540
  recordSkipped(selfRegStep, selfRegStep.available ? 'not selected' : 'no config');
1215
1541
  }
@@ -1233,7 +1559,9 @@ async function main() {
1233
1559
  console.log('\n--- Clean existing data for fresh import ---');
1234
1560
  const planEntries = JSON.parse(readFileSync(DATA_PLAN, 'utf8'));
1235
1561
  const sobjectsReversed = [...planEntries.map((e) => e.sobject)].reverse();
1236
- const tmpApex = resolve(ROOT, '.tmp-setup-delete.apex');
1562
+ // One private temp dir for both the delete sweep and the import batches;
1563
+ // always removed in finally, even if a batch throws.
1564
+ withApexTempDir((writeApex) => {
1237
1565
  for (const sobject of sobjectsReversed) {
1238
1566
  const apexCode = [
1239
1567
  'try {',
@@ -1246,7 +1574,7 @@ async function main() {
1246
1574
  ' // non-deletable records (e.g. Contact linked to Case) are skipped via allOrNone=false',
1247
1575
  '}',
1248
1576
  ].join('\n');
1249
- writeFileSync(tmpApex, apexCode);
1577
+ const tmpApex = writeApex('data.apex', apexCode);
1250
1578
  spawnSync('sf', ['apex', 'run', '--target-org', targetOrg, '--file', tmpApex], {
1251
1579
  cwd: ROOT,
1252
1580
  stdio: 'pipe',
@@ -1255,7 +1583,6 @@ async function main() {
1255
1583
  });
1256
1584
  console.log(` ${sobject}: cleaned`);
1257
1585
  }
1258
- if (existsSync(tmpApex)) unlinkSync(tmpApex);
1259
1586
 
1260
1587
  // Import via Anonymous Apex with Database.DMLOptions.duplicateRuleHeader.allowSave = true.
1261
1588
  // This bypasses both duplicate-rule blocks AND matching-service timeouts that the REST
@@ -1294,7 +1621,7 @@ async function main() {
1294
1621
  const batch = records.slice(i, i + batchSize);
1295
1622
  const refIds = batch.map((r) => r.attributes?.referenceId || `_idx${i}`);
1296
1623
  const apex = buildApexInsert(entry.sobject, batch, refIds);
1297
- writeFileSync(tmpApex, apex);
1624
+ const tmpApex = writeApex('data.apex', apex);
1298
1625
  const apexResult = spawnSync(
1299
1626
  'sf',
1300
1627
  ['apex', 'run', '--target-org', targetOrg, '--file', tmpApex],
@@ -1323,7 +1650,7 @@ async function main() {
1323
1650
  console.log(` ${entry.sobject}: imported ${imported} records`);
1324
1651
  }
1325
1652
  }
1326
- if (existsSync(tmpApex)) unlinkSync(tmpApex);
1653
+ });
1327
1654
  });
1328
1655
  } else {
1329
1656
  recordSkipped(dataStep, dataStep.available ? 'not selected' : 'no data plan');
@@ -1360,29 +1687,12 @@ async function main() {
1360
1687
  recordOk(uiBundleBuildStep);
1361
1688
  }
1362
1689
 
1363
- const devStep = stepDefs.find((s) => s.key === 'dev');
1364
- if (!skipDev) {
1365
- // dev is skippable and terminal: a failure here is a local runtime issue
1366
- // (port in use, tooling), not a broken setup. Print the summary BEFORE the
1367
- // (blocking, long-lived) dev server starts so the developer sees the setup
1368
- // outcome up front; the server then runs in the foreground until Ctrl+C.
1369
- recordOk(devStep);
1370
- printSummary(targetOrg);
1371
- console.log('\n--- Launching dev server (Ctrl+C to stop) ---\n');
1372
- const devResult = run('Dev server', 'npm', ['run', 'dev'], { cwd: uiBundleDir, optional: true });
1373
- if (devResult.status !== 0) {
1374
- // Replace the optimistic `ok` with a skippable failure and reprint.
1375
- const row = results.find((r) => r.key === 'dev');
1376
- row.status = 'failed';
1377
- row.reason = 'dev server failed to start — setup itself completed; check local runtime';
1378
- printSummary(targetOrg);
1379
- }
1380
- process.exit(finalExitCode());
1381
- } else {
1382
- recordSkipped(devStep, 'not selected');
1383
- }
1384
-
1690
+ // Setup terminates cleanly here (spec §5.5, AC-11). Launching the dev server is
1691
+ // no longer part of setup — run `npm run dev:preview` (scripts/org-setup-dev.mjs) for that.
1385
1692
  printSummary(targetOrg);
1693
+ if (finalExitCode() === 0) {
1694
+ console.log('\nTo launch the dev server, run: npm run dev:preview');
1695
+ }
1386
1696
  process.exit(finalExitCode());
1387
1697
  }
1388
1698