@salesforce/ui-bundle-template-app-react-template-b2e 10.23.0 → 10.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/CHANGELOG.md CHANGED
@@ -3,6 +3,15 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [10.24.0](https://github.com/salesforce-experience-platform-emu/webapps/compare/v10.23.0...v10.24.0) (2026-06-25)
7
+
8
+
9
+ ### Features
10
+
11
+ * **org-setup:** error handling, result ledger, and config validation @W-23043729 ([#665](https://github.com/salesforce-experience-platform-emu/webapps/issues/665)) ([f68ff9c](https://github.com/salesforce-experience-platform-emu/webapps/commit/f68ff9cf68df52efba1a9bea25f22f30246cb083))
12
+
13
+
14
+
6
15
  ## [10.23.0](https://github.com/salesforce-experience-platform-emu/webapps/compare/v10.22.0...v10.23.0) (2026-06-25)
7
16
 
8
17
  **Note:** Version bump only for package @salesforce/ui-bundle-template-base-sfdx-project
@@ -18,8 +18,8 @@
18
18
  "graphql:schema": "node scripts/get-graphql-schema.mjs"
19
19
  },
20
20
  "dependencies": {
21
- "@salesforce/platform-sdk": "^10.23.0",
22
- "@salesforce/ui-bundle": "^10.23.0",
21
+ "@salesforce/platform-sdk": "^10.24.0",
22
+ "@salesforce/ui-bundle": "^10.24.0",
23
23
  "@tailwindcss/vite": "^4.1.17",
24
24
  "class-variance-authority": "^0.7.1",
25
25
  "clsx": "^2.1.1",
@@ -45,8 +45,8 @@
45
45
  "@graphql-eslint/eslint-plugin": "^4.1.0",
46
46
  "@graphql-tools/utils": "^11.0.0",
47
47
  "@playwright/test": "^1.49.0",
48
- "@salesforce/graphiti": "^10.23.0",
49
- "@salesforce/vite-plugin-ui-bundle": "^10.23.0",
48
+ "@salesforce/graphiti": "^10.24.0",
49
+ "@salesforce/vite-plugin-ui-bundle": "^10.24.0",
50
50
  "@testing-library/jest-dom": "^6.6.3",
51
51
  "@testing-library/react": "^16.1.0",
52
52
  "@testing-library/user-event": "^14.5.2",
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@salesforce/webapp-template-base-sfdx-project-experimental",
3
- "version": "10.23.0",
3
+ "version": "10.24.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@salesforce/webapp-template-base-sfdx-project-experimental",
9
- "version": "10.23.0",
9
+ "version": "10.24.0",
10
10
  "license": "SEE LICENSE IN LICENSE.txt",
11
11
  "devDependencies": {
12
12
  "@lwc/eslint-plugin-lwc": "^3.3.0",
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/ui-bundle-template-base-sfdx-project",
3
- "version": "10.23.0",
3
+ "version": "10.24.0",
4
4
  "description": "Base SFDX project template",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "publishConfig": {
@@ -11,6 +11,7 @@
11
11
  "build": "echo 'No build required for base-sfdx-project'",
12
12
  "clean": "echo 'No clean required for base-sfdx-project'",
13
13
  "lint": "eslint --no-error-on-unmatched-pattern **/{aura,lwc}/**/*.js",
14
+ "validate:org-setup-config": "node scripts/validate-org-setup-config.mjs",
14
15
  "test": "npm run test:unit",
15
16
  "test:coverage": "npm run test",
16
17
  "test:unit": "sfdx-lwc-jest -- --passWithNoTests",
@@ -22,6 +23,9 @@
22
23
  "precommit": "lint-staged",
23
24
  "setup": "node scripts/org-setup.mjs"
24
25
  },
26
+ "dependencies": {
27
+ "zod": "^3.24.1"
28
+ },
25
29
  "devDependencies": {
26
30
  "@lwc/eslint-plugin-lwc": "^3.3.0",
27
31
  "@prettier/plugin-xml": "^3.2.2",
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Shared schema + validator for org-setup.config.json (W-23043729).
3
+ *
4
+ * Authored ONCE, used at TWO moments:
5
+ * (a) build / CI time — `scripts/validate-org-setup-config.mjs` validates every
6
+ * org-setup.config.json this repo ships, failing the build on any violation.
7
+ * (b) setup runtime — `org-setup.mjs` calls `validateConfig` once in main(),
8
+ * before any step runs, to catch the developer's local post-scaffold edits.
9
+ *
10
+ * `validateConfig` is pure: it parses + validates and RETURNS a result. Each call
11
+ * site owns its own print/exit, so the build gate can report every bad file and
12
+ * the runtime can exit immediately — and the validator stays unit-testable.
13
+ *
14
+ * The zod schema uses `.strict()` at every level so unknown keys (e.g. the
15
+ * `permsetAssignment` singular typo) are rejected rather than silently ignored.
16
+ */
17
+
18
+ import { z } from 'zod';
19
+
20
+ /** Closed set of assignee values — no arbitrary usernames. */
21
+ const Assignee = z.enum(['currentUser', 'guestUser', 'skip']);
22
+
23
+ // No siteName: for guestUser the site is derived from the single
24
+ // networks/<siteName>.network-meta.xml the app ships (see spec §5.2).
25
+ const Assignment = z
26
+ .object({
27
+ assignee: Assignee,
28
+ })
29
+ .strict();
30
+
31
+ export const ConfigSchema = z
32
+ .object({
33
+ permsetAssignments: z
34
+ .object({
35
+ // guestUser is valid here — siteName is derived, not per-assignment.
36
+ defaultAssignee: Assignee.default('skip'),
37
+ assignments: z.record(z.string(), Assignment).default({}),
38
+ })
39
+ .strict()
40
+ .optional(),
41
+ role: z
42
+ .object({
43
+ assignee: z.literal('currentUser'), // only value the role code honors
44
+ roleName: z.string().min(1),
45
+ })
46
+ .strict()
47
+ .optional(),
48
+ selfRegistration: z
49
+ .object({
50
+ // No siteName: the site is derived from the single
51
+ // networks/<siteName>.network-meta.xml the app ships (see spec §5.2),
52
+ // exactly like the guestUser permset path.
53
+ selfRegProfile: z.string().min(1),
54
+ accountName: z.string().min(1),
55
+ })
56
+ .strict()
57
+ .optional(),
58
+ })
59
+ .strict();
60
+
61
+ /**
62
+ * Render a single zod issue as a "path: message" line. Empty path (a root-level
63
+ * problem) renders as "<root>: message".
64
+ */
65
+ function formatIssue(issue) {
66
+ const path = issue.path.length > 0 ? issue.path.join('.') : '<root>';
67
+ return `${path}: ${issue.message}`;
68
+ }
69
+
70
+ /**
71
+ * Parse + validate a raw org-setup.config.json string.
72
+ *
73
+ * @param {string} rawText raw file contents (not yet JSON-parsed)
74
+ * @param {string} [configPath] path used only for error messages
75
+ * @returns {{ ok: true, data: object } | { ok: false, errors: string[] }}
76
+ * On success, `data` is the parsed + defaulted config. On failure, `errors`
77
+ * is a non-empty list of human-readable messages (JSON parse error or zod
78
+ * issues). The caller decides whether to print/exit.
79
+ */
80
+ export function validateConfig(rawText, configPath) {
81
+ const where = configPath ? ` (${configPath})` : '';
82
+
83
+ let parsed;
84
+ try {
85
+ parsed = JSON.parse(rawText);
86
+ } catch (err) {
87
+ return { ok: false, errors: [`malformed JSON${where}: ${err.message}`] };
88
+ }
89
+
90
+ const result = ConfigSchema.safeParse(parsed);
91
+ if (!result.success) {
92
+ return { ok: false, errors: result.error.issues.map(formatIssue) };
93
+ }
94
+
95
+ return { ok: true, data: result.data };
96
+ }
@@ -22,15 +22,18 @@
22
22
  * Permset assignment config (scripts/org-setup.config.json):
23
23
  * {
24
24
  * "permsetAssignments": {
25
+ * "defaultAssignee": "skip",
25
26
  * "assignments": {
26
27
  * "My_Permset": { "assignee": "currentUser" },
27
- * "Guest_Permset": { "assignee": "guestUser", "siteName": "mysite" },
28
+ * "Guest_Permset": { "assignee": "guestUser" },
28
29
  * "Internal_Only": { "assignee": "skip" }
29
30
  * }
30
31
  * }
31
32
  * }
32
- * Assignee values: "currentUser", "skip", "guestUser" (requires siteName), or a specific username.
33
- * Only permsets explicitly listed in assignments are assigned; unlisted permsets are skipped.
33
+ * Assignee values: "currentUser", "guestUser", or "skip". For "guestUser" the
34
+ * site is derived from the single networks/<siteName>.network-meta.xml the app
35
+ * ships — it is not restated per assignment.
36
+ * Unlisted permsets resolve to "defaultAssignee" (default "skip").
34
37
  */
35
38
 
36
39
  import { spawnSync, spawn as nodeSpawn } from 'node:child_process';
@@ -38,9 +41,18 @@ import { resolve, dirname } from 'node:path';
38
41
  import { fileURLToPath } from 'node:url';
39
42
  import { readdirSync, existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
40
43
 
44
+ import { validateConfig } from './org-setup-config-schema.mjs';
45
+
41
46
  const __dirname = dirname(fileURLToPath(import.meta.url));
42
47
  const ROOT = resolve(__dirname, '..');
43
48
 
49
+ /**
50
+ * Thrown by step runners (run/runAsync) when a subprocess fails. The per-step
51
+ * orchestration in main() catches it, records the failure in the result ledger,
52
+ * and either aborts (fail-fast steps) or continues (skippable steps).
53
+ */
54
+ class StepError extends Error {}
55
+
44
56
  /**
45
57
  * npm strips .gitignore from published packages — generate them on first run.
46
58
  * Templates are stored in scripts/gitignore-templates.json (generated at build
@@ -149,15 +161,17 @@ Permset config (scripts/org-setup.config.json):
149
161
  Control per-permset assignment via a config file. Example:
150
162
  {
151
163
  "permsetAssignments": {
164
+ "defaultAssignee": "skip",
152
165
  "assignments": {
153
166
  "My_Permset": { "assignee": "currentUser" },
154
- "Guest_Permset": { "assignee": "guestUser", "siteName": "mysite" },
167
+ "Guest_Permset": { "assignee": "guestUser" },
155
168
  "Internal_Only": { "assignee": "skip" }
156
169
  }
157
170
  }
158
171
  }
159
- Assignee values: "currentUser", "skip", "guestUser" (requires siteName), or a specific username.
160
- Only permsets explicitly listed in assignments are assigned; unlisted permsets are skipped.
172
+ Assignee values: "currentUser", "guestUser", or "skip". For "guestUser" the site
173
+ is derived from the single networks/<siteName>.network-meta.xml the app ships.
174
+ Unlisted permsets resolve to "defaultAssignee" (default "skip").
161
175
  `);
162
176
  process.exit(0);
163
177
  }
@@ -212,15 +226,64 @@ function discoverPermissionSetNames() {
212
226
  return names.sort();
213
227
  }
214
228
 
229
+ const CONFIG_PATH = resolve(__dirname, 'org-setup.config.json');
230
+
231
+ /**
232
+ * Read + validate org-setup.config.json ONCE, against the shared zod schema
233
+ * (the same `validateConfig` the build/CI gate uses). Exits non-zero with the
234
+ * precise zod issues if the config is invalid — before any step runs.
235
+ *
236
+ * Returns the validated config object (zod defaults applied), or an empty
237
+ * object when the file is absent (every section is optional).
238
+ *
239
+ * This is the single source of truth: loadPermsetConfig / loadRoleConfig /
240
+ * loadSelfRegConfig all read from the object it returns, so they no longer
241
+ * parse or defensively swallow errors.
242
+ */
243
+ function loadValidatedConfig() {
244
+ if (!existsSync(CONFIG_PATH)) return {};
245
+ const result = validateConfig(readFileSync(CONFIG_PATH, 'utf8'), CONFIG_PATH);
246
+ if (!result.ok) {
247
+ console.error('Invalid org-setup.config.json:');
248
+ for (const err of result.errors) console.error(` - ${err}`);
249
+ process.exit(1);
250
+ }
251
+ return result.data;
252
+ }
253
+
254
+ /**
255
+ * Derive the site name from the single networks/<siteName>.network-meta.xml the
256
+ * 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
258
+ * null when there is no networks dir or no .network-meta.xml file.
259
+ */
260
+ function deriveSiteName() {
261
+ const networksDir = resolve(SFDX_SOURCE, 'networks');
262
+ if (!existsSync(networksDir)) return null;
263
+ const files = readdirSync(networksDir)
264
+ .filter((f) => f.endsWith('.network-meta.xml'))
265
+ .sort();
266
+ if (files.length === 0) return null;
267
+ // An app ships exactly one site; if a developer added a second, derivation is
268
+ // ambiguous — fail loudly rather than silently bind to an arbitrary site.
269
+ if (files.length > 1) {
270
+ throw new StepError(
271
+ `cannot derive guest site: multiple network metadata files found in ${networksDir} (${files.join(', ')}); guestUser assignment requires exactly one`,
272
+ );
273
+ }
274
+ return files[0].replace(/\.network-meta\.xml$/, '');
275
+ }
276
+
215
277
  /**
216
- * Load permset assignment configuration from org-setup.config.json.
278
+ * Permset assignment configuration, read from the already-validated config.
217
279
  *
218
280
  * Config shape:
219
281
  * {
220
282
  * "permsetAssignments": {
283
+ * "defaultAssignee": "skip",
221
284
  * "assignments": {
222
285
  * "My_Permset": { "assignee": "currentUser" },
223
- * "My_Guest_Permset": { "assignee": "guestUser", "siteName": "mysite" },
286
+ * "My_Guest_Permset": { "assignee": "guestUser" },
224
287
  * "Internal_Only": { "assignee": "skip" }
225
288
  * }
226
289
  * }
@@ -229,68 +292,57 @@ function discoverPermissionSetNames() {
229
292
  * Assignee values:
230
293
  * "currentUser" — assign to the user running the script
231
294
  * "skip" — do not assign this permset
232
- * "guestUser" — resolve the site guest user automatically (requires siteName)
233
- * "<username>" — assign to a specific user via --on-behalf-of
295
+ * "guestUser" — resolve the site guest user automatically (site derived from
296
+ * the single networks/<siteName>.network-meta.xml the app ships)
234
297
  *
235
- * Only permsets explicitly listed in assignments are assigned; unlisted permsets are skipped.
298
+ * Unlisted permsets resolve to `defaultAssignee` (default "skip").
236
299
  *
237
- * Returns { assignments: Record<string, { assignee: string, siteName?: string }> }
300
+ * Returns { defaultAssignee: string, assignments: Record<string, { assignee: string }> }
238
301
  */
239
- function loadPermsetConfig() {
240
- const configPath = resolve(__dirname, 'org-setup.config.json');
241
- const defaults = { assignments: {} };
242
- if (!existsSync(configPath)) return defaults;
243
- try {
244
- const raw = JSON.parse(readFileSync(configPath, 'utf8'));
245
- const section = raw?.permsetAssignments;
246
- if (!section) return defaults;
247
- return {
248
- assignments: section.assignments || {},
249
- };
250
- } catch (err) {
251
- console.warn(`Warning: failed to parse org-setup.config.json: ${err.message}; using defaults.`);
252
- return defaults;
253
- }
302
+ function loadPermsetConfig(config) {
303
+ const section = config.permsetAssignments;
304
+ if (!section) return { defaultAssignee: 'skip', assignments: {} };
305
+ return {
306
+ defaultAssignee: section.defaultAssignee,
307
+ assignments: section.assignments,
308
+ };
254
309
  }
255
310
 
256
311
  /** Resolve the effective assignment config for a given permset name. */
257
312
  function resolveAssignment(permsetName, permsetConfig) {
258
313
  const override = permsetConfig.assignments[permsetName];
259
- if (!override) return { assignee: 'skip' };
260
- return { assignee: override.assignee || 'skip', siteName: override.siteName };
314
+ if (!override) return { assignee: permsetConfig.defaultAssignee };
315
+ return { assignee: override.assignee };
261
316
  }
262
317
 
263
318
  /**
264
- * Load role assignment config from org-setup.config.json.
319
+ * Role assignment config, read from the already-validated config.
265
320
  *
266
321
  * Config shape:
267
322
  * { "role": { "assignee": "currentUser", "roleName": "Admin" } }
268
323
  *
269
324
  * Returns null if no "role" section exists in config (the step is hidden).
270
325
  */
271
- function loadRoleConfig() {
272
- const configPath = resolve(__dirname, 'org-setup.config.json');
273
- if (!existsSync(configPath)) return null;
274
- try {
275
- const raw = JSON.parse(readFileSync(configPath, 'utf8'));
276
- const section = raw?.role;
277
- if (!section) return null;
278
- return {
279
- assignee: section.assignee || 'currentUser',
280
- roleName: section.roleName || null,
281
- };
282
- } catch {
283
- return null;
284
- }
326
+ function loadRoleConfig(config) {
327
+ const section = config.role;
328
+ if (!section) return null;
329
+ return {
330
+ assignee: section.assignee,
331
+ roleName: section.roleName,
332
+ };
285
333
  }
286
334
 
287
335
  /**
288
- * Load self-registration config from org-setup.config.json.
336
+ * Self-registration config, read from the already-validated config. The site is
337
+ * NOT stored here — it is derived from the single
338
+ * networks/<siteName>.network-meta.xml the app ships (spec §5.2), exactly like
339
+ * the guestUser permset path. deriveSiteName() is called lazily inside the
340
+ * selfReg step body so its "multiple network files" StepError is recorded in
341
+ * the ledger rather than escaping config load.
289
342
  *
290
343
  * Config shape:
291
344
  * {
292
345
  * "selfRegistration": {
293
- * "siteName": "myapp",
294
346
  * "selfRegProfile": "myapp Profile",
295
347
  * "accountName": "My Self-Reg Account"
296
348
  * }
@@ -298,21 +350,13 @@ function loadRoleConfig() {
298
350
  *
299
351
  * Returns null if no "selfRegistration" section exists in config (the step is hidden).
300
352
  */
301
- function loadSelfRegConfig() {
302
- const configPath = resolve(__dirname, 'org-setup.config.json');
303
- if (!existsSync(configPath)) return null;
304
- try {
305
- const raw = JSON.parse(readFileSync(configPath, 'utf8'));
306
- const section = raw?.selfRegistration;
307
- if (!section) return null;
308
- return {
309
- siteName: section.siteName || null,
310
- selfRegProfile: section.selfRegProfile || null,
311
- accountName: section.accountName || null,
312
- };
313
- } catch {
314
- return null;
315
- }
353
+ function loadSelfRegConfig(config) {
354
+ const section = config.selfRegistration;
355
+ if (!section) return null;
356
+ return {
357
+ selfRegProfile: section.selfRegProfile,
358
+ accountName: section.accountName,
359
+ };
316
360
  }
317
361
 
318
362
  /**
@@ -320,8 +364,8 @@ function loadSelfRegConfig() {
320
364
  * This must happen BEFORE the initial deploy so that the profile is a recognised
321
365
  * site member when subsequent steps (selfRegProfile, selfRegistration=true) are deployed.
322
366
  */
323
- function ensureNetworkMemberProfile(selfRegConfig) {
324
- const { siteName, selfRegProfile } = selfRegConfig;
367
+ function ensureNetworkMemberProfile(selfRegConfig, siteName) {
368
+ const { selfRegProfile } = selfRegConfig;
325
369
  if (!siteName || !selfRegProfile) return;
326
370
 
327
371
  const networkXmlPath = resolve(SFDX_SOURCE, 'networks', `${siteName}.network-meta.xml`);
@@ -356,14 +400,13 @@ function ensureNetworkMemberProfile(selfRegConfig) {
356
400
  * 3. Create an Account record (idempotent).
357
401
  * 4. Create a NetworkSelfRegistration record linking the Account to the Network (idempotent).
358
402
  */
359
- function enableSelfRegistration(selfRegConfig, targetOrg) {
360
- const { siteName, selfRegProfile, accountName } = selfRegConfig;
403
+ function enableSelfRegistration(selfRegConfig, siteName, targetOrg) {
404
+ const { selfRegProfile, accountName } = selfRegConfig;
361
405
 
362
406
  // 1. Modify network metadata XML
363
407
  const networkXmlPath = resolve(SFDX_SOURCE, 'networks', `${siteName}.network-meta.xml`);
364
408
  if (!existsSync(networkXmlPath)) {
365
- console.error(` Network metadata not found: ${networkXmlPath}`);
366
- return;
409
+ throw new StepError(`network metadata not found: ${networkXmlPath}`);
367
410
  }
368
411
  const xml = readFileSync(networkXmlPath, 'utf8');
369
412
 
@@ -393,8 +436,7 @@ function enableSelfRegistration(selfRegConfig, targetOrg) {
393
436
  '--source-dir', networkXmlPath,
394
437
  ], { cwd: ROOT, stdio: 'inherit', shell: true, timeout: 120000 });
395
438
  if (deployResult.status !== 0) {
396
- console.error(' Failed to deploy updated network metadata.');
397
- process.exit(deployResult.status ?? 1);
439
+ throw new StepError(`failed to deploy updated network metadata (exit ${deployResult.status ?? 1})`);
398
440
  }
399
441
  }
400
442
 
@@ -424,17 +466,15 @@ function enableSelfRegistration(selfRegConfig, targetOrg) {
424
466
  '--json',
425
467
  ], { cwd: ROOT, encoding: 'utf8' });
426
468
  if (createResult.status !== 0) {
427
- console.error(` Failed to create Account "${accountName}".`);
428
469
  if (createResult.stderr) console.error(createResult.stderr);
429
- return;
470
+ throw new StepError(`failed to create Account "${accountName}"`);
430
471
  }
431
472
  try {
432
473
  const json = JSON.parse(createResult.stdout);
433
474
  accountId = json.result?.id;
434
475
  console.log(` Created Account "${accountName}" (${accountId}).`);
435
476
  } catch {
436
- console.error(' Failed to parse Account creation result.');
437
- return;
477
+ throw new StepError('failed to parse Account creation result');
438
478
  }
439
479
  }
440
480
 
@@ -454,8 +494,7 @@ function enableSelfRegistration(selfRegConfig, targetOrg) {
454
494
  } catch { /* fall through */ }
455
495
  }
456
496
  if (!networkId) {
457
- console.error(` Could not find Network "${siteName}" in org.`);
458
- return;
497
+ throw new StepError(`could not find Network "${siteName}" in org`);
459
498
  }
460
499
  console.log(` Found Network "${siteName}" (${networkId}).`);
461
500
 
@@ -493,9 +532,8 @@ function enableSelfRegistration(selfRegConfig, targetOrg) {
493
532
  const apexOut = apexResult.stdout?.toString() || '';
494
533
  if (existsSync(tmpApex)) unlinkSync(tmpApex);
495
534
  if (apexResult.status !== 0 && !apexOut.includes('Compiled successfully')) {
496
- console.error(' Failed to create NetworkSelfRegistration record.');
497
535
  process.stderr.write(apexResult.stderr?.toString() || apexOut);
498
- return;
536
+ throw new StepError('failed to create NetworkSelfRegistration record');
499
537
  }
500
538
  const nsrMatch = apexOut.match(/NSR_CREATED:(\w+)/);
501
539
  if (nsrMatch) {
@@ -519,22 +557,20 @@ function assignRoleToCurrentUser(roleName, targetOrg) {
519
557
  '--json',
520
558
  ], { cwd: ROOT, encoding: 'utf8' });
521
559
  if (roleResult.status !== 0) {
522
- console.error(` Failed to query role "${roleName}" in org.`);
523
560
  if (roleResult.stderr) console.error(roleResult.stderr);
524
- return;
561
+ throw new StepError(`failed to query role "${roleName}" in org`);
525
562
  }
526
563
  let roleId;
527
564
  try {
528
565
  const json = JSON.parse(roleResult.stdout);
529
566
  const records = json.result?.records;
530
567
  if (!records || records.length === 0) {
531
- console.error(` Role "${roleName}" not found in org; skipping.`);
532
- return;
568
+ throw new StepError(`role "${roleName}" not found in org`);
533
569
  }
534
570
  roleId = records[0].Id;
535
- } catch {
536
- console.error(` Failed to parse role query result for "${roleName}".`);
537
- return;
571
+ } catch (err) {
572
+ if (err instanceof StepError) throw err;
573
+ throw new StepError(`failed to parse role query result for "${roleName}"`);
538
574
  }
539
575
 
540
576
  const orgResult = spawnSync('sf', [
@@ -543,20 +579,18 @@ function assignRoleToCurrentUser(roleName, targetOrg) {
543
579
  '--json',
544
580
  ], { cwd: ROOT, encoding: 'utf8' });
545
581
  if (orgResult.status !== 0) {
546
- console.error(' Failed to resolve current user from org.');
547
- return;
582
+ throw new StepError('failed to resolve current user from org');
548
583
  }
549
584
  let username;
550
585
  try {
551
586
  const json = JSON.parse(orgResult.stdout);
552
587
  username = json.result?.username;
553
588
  if (!username) {
554
- console.error(' Could not determine current username from org display.');
555
- return;
589
+ throw new StepError('could not determine current username from org display');
556
590
  }
557
- } catch {
558
- console.error(' Failed to parse org display result.');
559
- return;
591
+ } catch (err) {
592
+ if (err instanceof StepError) throw err;
593
+ throw new StepError('failed to parse org display result');
560
594
  }
561
595
 
562
596
  const userQuery = `SELECT Id, UserRoleId FROM User WHERE Username = '${username}'`;
@@ -589,8 +623,8 @@ function assignRoleToCurrentUser(roleName, targetOrg) {
589
623
  console.log(` Role "${roleName}" assigned to ${username}.`);
590
624
  } else {
591
625
  const out = (updateResult.stderr?.toString() || '') + (updateResult.stdout?.toString() || '');
592
- console.error(` Failed to assign role "${roleName}" to ${username}.`);
593
626
  if (out) console.error(out);
627
+ throw new StepError(`failed to assign role "${roleName}" to ${username}`);
594
628
  }
595
629
  }
596
630
 
@@ -677,9 +711,13 @@ function buildApexInsert(sobject, records, refIds) {
677
711
  async function promptSteps(steps) {
678
712
  if (!process.stdin.isTTY) return steps.map((s) => s.enabled);
679
713
 
714
+ // `selected` stays indexed by ORIGINAL step order (so the caller's
715
+ // selections[i] → stepDefs[i] mapping is unchanged); unavailable steps remain
716
+ // false. Only available steps are shown and navigable — unavailable steps are
717
+ // hidden entirely rather than rendered greyed-out.
680
718
  const selected = steps.map((s) => s.enabled);
719
+ const visible = steps.map((s, i) => ({ step: s, index: i })).filter(({ step }) => step.available);
681
720
  let cursor = 0;
682
- const DIM = '\x1B[2m';
683
721
  const RST = '\x1B[0m';
684
722
  const CYAN = '\x1B[36m';
685
723
  const GREEN = '\x1B[32m';
@@ -701,11 +739,10 @@ async function promptSteps(steps) {
701
739
  }
702
740
 
703
741
  function render() {
704
- return steps.map((s, i) => {
705
- const ptr = i === cursor ? `${CYAN}❯${RST}` : ' ';
706
- if (!s.available) return `${ptr} ${DIM} ${s.label} (n/a)${RST}`;
707
- const chk = selected[i] ? `${GREEN}●${RST}` : '○';
708
- return `${ptr} ${chk} ${s.label}`;
742
+ return visible.map(({ step, index }, row) => {
743
+ const ptr = row === cursor ? `${CYAN}❯${RST}` : ' ';
744
+ const chk = selected[index] ? `${GREEN}●${RST}` : '○';
745
+ return `${ptr} ${chk} ${step.label}`;
709
746
  });
710
747
  }
711
748
 
@@ -742,16 +779,17 @@ async function promptSteps(steps) {
742
779
  resolve(selected);
743
780
  return;
744
781
  }
782
+ // Note: `cursor` indexes `visible`; `selected` is indexed by ORIGINAL step
783
+ // order. Map through visible[cursor].index before touching `selected`.
745
784
  if (key === ' ') {
746
- if (steps[cursor].available) selected[cursor] = !selected[cursor];
785
+ const { index } = visible[cursor];
786
+ selected[index] = !selected[index];
747
787
  redraw();
748
788
  return;
749
789
  }
750
790
  if (key === 'a') {
751
- const allOn = steps.every((s, i) => !s.available || selected[i]);
752
- for (let i = 0; i < steps.length; i++) {
753
- if (steps[i].available) selected[i] = !allOn;
754
- }
791
+ const allOn = visible.every(({ index }) => selected[index]);
792
+ for (const { index } of visible) selected[index] = !allOn;
755
793
  redraw();
756
794
  return;
757
795
  }
@@ -759,7 +797,7 @@ async function promptSteps(steps) {
759
797
  cursor = Math.max(0, cursor - 1);
760
798
  redraw();
761
799
  } else if (key === '\x1B[B' || key === 'j') {
762
- cursor = Math.min(steps.length - 1, cursor + 1);
800
+ cursor = Math.min(visible.length - 1, cursor + 1);
763
801
  redraw();
764
802
  }
765
803
  });
@@ -777,8 +815,7 @@ function run(name, cmd, args, opts = {}) {
777
815
  ...(opts.timeout && { timeout: opts.timeout }),
778
816
  });
779
817
  if (result.status !== 0 && !optional) {
780
- console.error(`\nSetup failed at step: ${name}`);
781
- process.exit(result.status ?? 1);
818
+ throw new StepError(`${name} (exit ${result.status ?? 1})`);
782
819
  }
783
820
  return result;
784
821
  }
@@ -808,12 +845,91 @@ async function runAsync(name, cmd, args, opts = {}) {
808
845
  if (result.status !== 0 && !optional) {
809
846
  if (result.stdout) process.stdout.write(result.stdout);
810
847
  if (result.stderr) process.stderr.write(result.stderr);
811
- console.error(`\nSetup failed at step: ${name}`);
812
- process.exit(result.status ?? 1);
848
+ throw new StepError(`${name} (exit ${result.status ?? 1})`);
813
849
  }
814
850
  return result;
815
851
  }
816
852
 
853
+ /**
854
+ * In-memory result ledger. Each selected step records exactly one outcome:
855
+ * ok — the step ran and succeeded
856
+ * skipped — the step was not selected, or had no config section (intentional)
857
+ * failed — the step ran and failed (with a human-readable reason)
858
+ * The end-of-run summary is rendered from this ledger and the process exits
859
+ * non-zero whenever any step is `failed` (fail-fast or skippable alike).
860
+ *
861
+ * @typedef {{ key: string, label: string, status: 'ok'|'skipped'|'failed', reason?: string, failFast?: boolean }} StepResult
862
+ */
863
+ const results = [];
864
+ function recordOk(step) {
865
+ results.push({ key: step.key, label: step.label, status: 'ok', failFast: step.failFast });
866
+ }
867
+ function recordSkipped(step, reason) {
868
+ results.push({ key: step.key, label: step.label, status: 'skipped', reason, failFast: step.failFast });
869
+ }
870
+ function recordFailed(step, reason) {
871
+ results.push({ key: step.key, label: step.label, status: 'failed', reason, failFast: step.failFast });
872
+ }
873
+
874
+ /** True if any step in the ledger failed. */
875
+ function finalExitCode() {
876
+ return results.some((r) => r.status === 'failed') ? 1 : 0;
877
+ }
878
+
879
+ const SUMMARY_GLYPH = { ok: '✔', skipped: '–', failed: '✖' };
880
+
881
+ /**
882
+ * Render the end-of-run summary. Always called — on success, on a fail-fast
883
+ * abort (partial: only steps reached so far are present), and on a clean finish
884
+ * that had skippable failures. Failed rows are listed last so the real problem
885
+ * is the last thing the developer sees.
886
+ */
887
+ function printSummary(targetOrg) {
888
+ const ordered = [
889
+ ...results.filter((r) => r.status !== 'failed'),
890
+ ...results.filter((r) => r.status === 'failed'),
891
+ ];
892
+ const pad = Math.max(0, ...ordered.map((r) => r.key.length));
893
+ console.log(`\nSetup summary (target org: ${targetOrg})`);
894
+ for (const r of ordered) {
895
+ const glyph = SUMMARY_GLYPH[r.status] ?? ' ';
896
+ const key = r.key.padEnd(pad);
897
+ let line = ` ${glyph} ${key} ${r.status}`;
898
+ if (r.reason) line += ` (${r.reason})`;
899
+ if (r.status === 'failed') line += r.failFast ? ' [fail-fast — aborted]' : ' [skippable — continued]';
900
+ console.log(line);
901
+ }
902
+ const failures = results.filter((r) => r.status === 'failed').length;
903
+ if (failures > 0) {
904
+ console.log(`Setup completed with ${failures} failure(s). Exiting 1.`);
905
+ } else {
906
+ console.log('Setup complete.');
907
+ }
908
+ }
909
+
910
+ /**
911
+ * Run one step body, recording its outcome in the ledger and honoring the
912
+ * step's fixed `failFast` classification. The body either completes (→ ok),
913
+ * throws a StepError (→ failed), or throws something unexpected (→ failed,
914
+ * with the raw message). On a fail-fast failure this prints the partial
915
+ * summary and exits non-zero immediately; on a skippable failure it records
916
+ * and returns so the run continues.
917
+ */
918
+ async function runStep(step, targetOrg, body) {
919
+ try {
920
+ await body();
921
+ recordOk(step);
922
+ } catch (err) {
923
+ const reason = err instanceof StepError ? err.message : (err?.message ?? String(err));
924
+ recordFailed(step, reason);
925
+ console.error(`\nStep "${step.key}" failed: ${reason}`);
926
+ if (step.failFast) {
927
+ printSummary(targetOrg);
928
+ process.exit(1);
929
+ }
930
+ }
931
+ }
932
+
817
933
  async function main() {
818
934
  // Ensure .gitignore files exist (npm strips them from published packages).
819
935
  const gitignoreTemplates = loadGitignoreTemplates();
@@ -853,22 +969,30 @@ async function main() {
853
969
  ? `Permset — assign ${permsetNames.join(', ')}`
854
970
  : `Permset — assign ${permsetNames.length} permission sets`;
855
971
 
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.
974
+ const config = loadValidatedConfig();
975
+
856
976
  const hasDataPlan = existsSync(DATA_PLAN) && existsSync(DATA_DIR);
857
- const roleConfig = loadRoleConfig();
977
+ const roleConfig = loadRoleConfig(config);
858
978
  const hasRoleConfig = roleConfig !== null;
859
- const selfRegConfig = loadSelfRegConfig();
979
+ const selfRegConfig = loadSelfRegConfig(config);
860
980
  const hasSelfRegConfig = selfRegConfig !== null;
861
981
 
982
+ // failFast is a fixed, implementer-owned classification (spec §5.2) — NOT
983
+ // user-configurable. A fail-fast failure aborts the run immediately; a
984
+ // skippable failure is recorded and the run continues. The exit code is
985
+ // non-zero on any failure regardless of class.
862
986
  const stepDefs = [
863
- { key: 'login', label: 'Login — org authentication', enabled: !argSkipLogin, available: true },
864
- { key: 'uiBundleBuild', label: 'UI Bundle Build — npm install + build (pre-deploy)', enabled: !argSkipUIBundleBuild, available: true },
865
- { key: 'deploy', label: 'Deploy — sf project deploy start', enabled: !argSkipDeploy, available: true },
866
- { key: 'permset', label: permsetStepLabel, enabled: !argSkipPermset, available: true },
867
- { key: 'role', label: `Role — assign "${roleConfig?.roleName ?? '?'}" to current user`, enabled: !argSkipRole && hasRoleConfig, available: hasRoleConfig },
868
- { key: 'selfReg', label: `Self-Registration — enable for "${selfRegConfig?.siteName ?? '?'}"`, enabled: !argSkipSelfReg && hasSelfRegConfig, available: hasSelfRegConfig },
869
- { key: 'data', label: 'Data — delete + import records via Apex', enabled: !argSkipData && hasDataPlan, available: hasDataPlan },
870
- { key: 'graphql', label: 'GraphQL — schema introspect + codegen', enabled: !argSkipGraphql, available: true },
871
- { key: 'dev', label: 'Dev — launch dev server', enabled: !argSkipDev, available: true },
987
+ { key: 'login', label: 'Login — org authentication', enabled: !argSkipLogin, available: true, failFast: true },
988
+ { key: 'uiBundleBuild', label: 'UI Bundle Build — npm install + build (pre-deploy)', enabled: !argSkipUIBundleBuild, available: true, failFast: true },
989
+ { key: 'deploy', label: 'Deploy — sf project deploy start', enabled: !argSkipDeploy, available: true, failFast: true },
990
+ { key: 'permset', label: permsetStepLabel, enabled: !argSkipPermset, available: true, failFast: false },
991
+ { key: 'role', label: `Role — assign "${roleConfig?.roleName ?? '?'}" to current user`, enabled: !argSkipRole && hasRoleConfig, available: hasRoleConfig, failFast: false },
992
+ { key: 'selfReg', label: 'Self-Registration — enable for site', enabled: !argSkipSelfReg && hasSelfRegConfig, available: hasSelfRegConfig, failFast: false },
993
+ { key: 'data', label: 'Data — delete + import records via Apex', enabled: !argSkipData && hasDataPlan, available: hasDataPlan, failFast: true },
994
+ { 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 },
872
996
  ];
873
997
 
874
998
  const selections = yes ? stepDefs.map((s) => s.enabled) : await promptSteps(stepDefs);
@@ -905,48 +1029,94 @@ async function main() {
905
1029
  !skipDev
906
1030
  );
907
1031
 
1032
+ const loginStep = stepDefs.find((s) => s.key === 'login');
908
1033
  if (!skipLogin) {
909
- if (isOrgConnected(targetOrg)) {
910
- console.log('\n--- Login ---');
911
- console.log(`Org ${targetOrg} is already authenticated; skipping browser login.`);
912
- } else {
913
- run('Login (browser)', 'sf', ['org', 'login', 'web', '--alias', targetOrg], { optional: true });
914
- }
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');
915
1046
  }
916
1047
 
917
1048
  // Ensure the self-reg profile is in networkMemberGroups before deploy so that
918
- // subsequent selfRegProfile / selfRegistration updates don't fail.
1049
+ // subsequent selfRegProfile / selfRegistration updates don't fail. This is
1050
+ // best-effort prep: if the site can't be derived (no network file, or the
1051
+ // ambiguous multi-network case where deriveSiteName throws), skip the prep
1052
+ // silently here — the selfReg step records that derivation failure as a
1053
+ // skippable StepError, so it stays in the ledger instead of aborting pre-deploy.
919
1054
  if (!skipDeploy && selfRegConfig) {
920
- console.log('\n--- Ensure network member profile (pre-deploy) ---');
921
- ensureNetworkMemberProfile(selfRegConfig);
1055
+ let preDeploySiteName = null;
1056
+ try {
1057
+ preDeploySiteName = deriveSiteName();
1058
+ } catch {
1059
+ // ambiguous derivation — surfaced by the selfReg step below
1060
+ }
1061
+ if (preDeploySiteName) {
1062
+ console.log('\n--- Ensure network member profile (pre-deploy) ---');
1063
+ ensureNetworkMemberProfile(selfRegConfig, preDeploySiteName);
1064
+ }
922
1065
  }
923
1066
 
924
1067
  // Build all UI Bundles before deploy so dist exists for entity deployment
925
- if (!skipDeploy && !skipUIBundleBuild) {
926
- const allUIBundleDirs = discoverAllUIBundleDirs(uiBundleName);
927
- for (const dir of allUIBundleDirs) {
928
- const name = dir.split(/[/\\]/).pop();
929
- run(`UI Bundle install (${name})`, 'npm', ['install'], { cwd: dir });
930
- run(`UI Bundle build (${name})`, 'npm', ['run', 'build'], { cwd: dir });
1068
+ const uiBundleBuildStep = stepDefs.find((s) => s.key === 'uiBundleBuild');
1069
+ let preDeployBundlesBuilt = false;
1070
+ if (!skipUIBundleBuild) {
1071
+ if (!skipDeploy) {
1072
+ await runStep(uiBundleBuildStep, targetOrg, () => {
1073
+ const allUIBundleDirs = discoverAllUIBundleDirs(uiBundleName);
1074
+ for (const dir of allUIBundleDirs) {
1075
+ const name = dir.split(/[/\\]/).pop();
1076
+ run(`UI Bundle install (${name})`, 'npm', ['install'], { cwd: dir });
1077
+ run(`UI Bundle build (${name})`, 'npm', ['run', 'build'], { cwd: dir });
1078
+ }
1079
+ });
1080
+ preDeployBundlesBuilt = true;
931
1081
  }
1082
+ // When skipDeploy, the bundle build happens in the GraphQL section below;
1083
+ // its outcome is recorded there.
1084
+ } else {
1085
+ recordSkipped(uiBundleBuildStep, 'not selected');
932
1086
  }
933
1087
 
1088
+ const deployStep = stepDefs.find((s) => s.key === 'deploy');
934
1089
  if (!skipDeploy) {
935
- run('Deploy metadata', 'sf', ['project', 'deploy', 'start', '--target-org', targetOrg], {
936
- timeout: 180000,
1090
+ await runStep(deployStep, targetOrg, () => {
1091
+ run('Deploy metadata', 'sf', ['project', 'deploy', 'start', '--target-org', targetOrg], {
1092
+ timeout: 180000,
1093
+ });
937
1094
  });
1095
+ } else {
1096
+ recordSkipped(deployStep, 'not selected');
938
1097
  }
939
1098
 
1099
+ const permsetStep = stepDefs.find((s) => s.key === 'permset');
940
1100
  if (!skipPermset) {
941
- const permsetConfig = loadPermsetConfig();
942
- if (permsetNames.length === 0) {
943
- console.log('\n--- Assign permission sets ---');
944
- console.log('No permission sets found under permissionsets/ and none passed via --permset-name; skipping.');
945
- } else {
1101
+ await runStep(permsetStep, targetOrg, async () => {
1102
+ const permsetConfig = loadPermsetConfig(config);
1103
+ if (permsetNames.length === 0) {
1104
+ console.log('\n--- Assign permission sets ---');
1105
+ console.log('No permission sets found under permissionsets/ and none passed via --permset-name; skipping.');
1106
+ return;
1107
+ }
946
1108
  console.log('\n--- Assign permission sets ---');
947
1109
 
948
1110
  // Resolve assignments (guest user lookups etc.) then run all sf assign calls in parallel.
1111
+ //
1112
+ // A guest-user resolution failure (no derivable site, or no guest user yet)
1113
+ // is collected — NOT thrown mid-loop. Throwing here would unwind the whole
1114
+ // runStep body before Promise.all and silently drop every assignment already
1115
+ // queued (e.g. a currentUser permset that sorts ahead of a guestUser one and
1116
+ // never depended on the site at all). Resolvable assignments still run; the
1117
+ // combined failure is thrown at the end so the step is still recorded failed.
949
1118
  const assignmentJobs = [];
1119
+ const resolutionFailures = [];
950
1120
  for (const permsetName of permsetNames) {
951
1121
  const assignment = resolveAssignment(permsetName, permsetConfig);
952
1122
  if (assignment.assignee === 'skip') {
@@ -955,18 +1125,21 @@ async function main() {
955
1125
  }
956
1126
  let effectiveUsername = null;
957
1127
  if (assignment.assignee === 'guestUser') {
958
- if (!assignment.siteName) {
959
- console.error(`Permission set "${permsetName}" assignee is "guestUser" but no "siteName" configured; skipping.`);
1128
+ // Site name is derived from the single network metadata file the app
1129
+ // ships (spec §5.2)never restated per-assignment.
1130
+ const siteName = deriveSiteName();
1131
+ if (!siteName) {
1132
+ console.error(`Permission set "${permsetName}" — assignee is "guestUser" but no networks/<siteName>.network-meta.xml was found to derive the site; skipping.`);
1133
+ resolutionFailures.push(`${permsetName} (no network metadata to derive site)`);
960
1134
  continue;
961
1135
  }
962
- effectiveUsername = resolveGuestUsername(assignment.siteName, targetOrg);
1136
+ effectiveUsername = resolveGuestUsername(siteName, targetOrg);
963
1137
  if (!effectiveUsername) {
964
- console.error(`Permission set "${permsetName}" — could not resolve guest user for site "${assignment.siteName}"; skipping.`);
1138
+ console.error(`Permission set "${permsetName}" — could not resolve guest user for site "${siteName}"; skipping.`);
1139
+ resolutionFailures.push(`${permsetName} (could not resolve guest user for site "${siteName}")`);
965
1140
  continue;
966
1141
  }
967
- console.log(` Resolved guest user for site "${assignment.siteName}": ${effectiveUsername}`);
968
- } else if (assignment.assignee !== 'currentUser') {
969
- effectiveUsername = assignment.assignee;
1142
+ console.log(` Resolved guest user for site "${siteName}": ${effectiveUsername}`);
970
1143
  }
971
1144
  assignmentJobs.push({ permsetName, effectiveUsername });
972
1145
  }
@@ -982,6 +1155,7 @@ async function main() {
982
1155
  return { permsetName, assigneeLabel, result };
983
1156
  }));
984
1157
 
1158
+ const failures = [];
985
1159
  for (const { permsetName, assigneeLabel, result } of assignResults) {
986
1160
  if (result.status === 0) {
987
1161
  console.log(`Permission set "${permsetName}" assigned to ${assigneeLabel}.`);
@@ -994,35 +1168,55 @@ async function main() {
994
1168
  } else {
995
1169
  if (result.stdout) process.stdout.write(result.stdout);
996
1170
  if (result.stderr) process.stderr.write(result.stderr);
997
- console.error(`\nSetup failed at step: Assign permission set (${permsetName})`);
998
- process.exit(result.status ?? 1);
1171
+ failures.push(`${permsetName} (exit ${result.status ?? 1})`);
999
1172
  }
1000
1173
  }
1001
1174
  }
1002
- }
1175
+ const allFailures = [...resolutionFailures, ...failures];
1176
+ if (allFailures.length > 0) {
1177
+ throw new StepError(`failed to assign permission set(s): ${allFailures.join(', ')}`);
1178
+ }
1179
+ });
1180
+ } else {
1181
+ recordSkipped(permsetStep, 'not selected');
1003
1182
  }
1004
1183
 
1184
+ const roleStep = stepDefs.find((s) => s.key === 'role');
1005
1185
  if (!skipRole) {
1006
1186
  console.log('\n--- Assign role ---');
1007
- if (roleConfig?.assignee !== 'currentUser') {
1008
- console.error(`Role assignee "${roleConfig?.assignee}" is not supported; only "currentUser" is allowed. Skipping.`);
1009
- } else if (!roleConfig?.roleName) {
1010
- console.error('Role step enabled but no "roleName" specified in org-setup.config.json; skipping.');
1011
- } else {
1187
+ // Config shape (assignee === 'currentUser', non-empty roleName) is guaranteed
1188
+ // by the schema, so there is nothing to re-check here the step either ran
1189
+ // (ok) or its assignment threw (failed). No manual recordSkipped inference.
1190
+ await runStep(roleStep, targetOrg, () => {
1012
1191
  assignRoleToCurrentUser(roleConfig.roleName, targetOrg);
1013
- }
1192
+ });
1193
+ } else {
1194
+ recordSkipped(roleStep, roleStep.available ? 'not selected' : 'no config');
1014
1195
  }
1015
1196
 
1197
+ const selfRegStep = stepDefs.find((s) => s.key === 'selfReg');
1016
1198
  if (!skipSelfReg) {
1017
1199
  console.log('\n--- Enable self-registration ---');
1018
- if (!selfRegConfig?.siteName || !selfRegConfig?.selfRegProfile || !selfRegConfig?.accountName) {
1019
- console.error('Self-registration config is incomplete (need siteName, selfRegProfile, accountName); skipping.');
1020
- } else {
1021
- enableSelfRegistration(selfRegConfig, targetOrg);
1022
- }
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
+ });
1213
+ } else {
1214
+ recordSkipped(selfRegStep, selfRegStep.available ? 'not selected' : 'no config');
1023
1215
  }
1024
1216
 
1217
+ const dataStep = stepDefs.find((s) => s.key === 'data');
1025
1218
  if (doData) {
1219
+ await runStep(dataStep, targetOrg, () => {
1026
1220
  // Prepare data for uniqueness (run before import so repeat imports don't conflict)
1027
1221
  const prepareScript = resolve(__dirname, 'prepare-import-unique-fields.js');
1028
1222
  run('Prepare data (unique fields)', 'node', [prepareScript, '--data-dir', DATA_DIR], {
@@ -1114,9 +1308,8 @@ async function main() {
1114
1308
  const apexOut = apexResult.stdout?.toString() || '';
1115
1309
  const apexErr = apexResult.stderr?.toString() || '';
1116
1310
  if (apexResult.status !== 0 && !apexOut.includes('Compiled successfully')) {
1117
- console.error(` ${entry.sobject}: apex execution failed`);
1118
1311
  process.stderr.write(apexErr || apexOut);
1119
- process.exit(1);
1312
+ throw new StepError(`${entry.sobject}: apex execution failed`);
1120
1313
  }
1121
1314
  const okMatches = [...apexOut.matchAll(/\|DEBUG\|REF:([^:\n]+):(\w+)/g)];
1122
1315
  const errMatches = [...apexOut.matchAll(/\|DEBUG\|ERR:([^:\n]+):([^\n]+)/g)];
@@ -1125,8 +1318,7 @@ async function main() {
1125
1318
  console.error(` ${m[1]}: ${m[2].trim()}`);
1126
1319
  }
1127
1320
  if (errMatches.length > 5) console.error(` ... and ${errMatches.length - 5} more`);
1128
- console.error(`\nSetup failed at step: Data import tree (${entry.sobject})`);
1129
- process.exit(1);
1321
+ throw new StepError(`data import tree (${entry.sobject}) — ${errMatches.length} record error(s)`);
1130
1322
  }
1131
1323
  if (entry.saveRefs) {
1132
1324
  for (const m of okMatches) refMap.set(m[1], m[2]);
@@ -1137,28 +1329,66 @@ async function main() {
1137
1329
  }
1138
1330
  }
1139
1331
  if (existsSync(tmpApex)) unlinkSync(tmpApex);
1332
+ });
1333
+ } else {
1334
+ recordSkipped(dataStep, dataStep.available ? 'not selected' : 'no data plan');
1140
1335
  }
1141
1336
 
1337
+ const graphqlStep = stepDefs.find((s) => s.key === 'graphql');
1142
1338
  if (!skipGraphql) {
1143
- run('UI Bundle npm install', 'npm', ['install'], { cwd: uiBundleDir });
1144
- run('GraphQL schema (introspect)', 'npm', ['run', 'graphql:schema'], {
1145
- cwd: uiBundleDir,
1146
- env: { ...process.env, SF_TARGET_ORG: targetOrg },
1339
+ await runStep(graphqlStep, targetOrg, () => {
1340
+ run('UI Bundle npm install', 'npm', ['install'], { cwd: uiBundleDir });
1341
+ run('GraphQL schema (introspect)', 'npm', ['run', 'graphql:schema'], {
1342
+ cwd: uiBundleDir,
1343
+ env: { ...process.env, SF_TARGET_ORG: targetOrg },
1344
+ });
1345
+ run('GraphQL codegen', 'npm', ['run', 'graphql:codegen'], { cwd: uiBundleDir });
1346
+ run('UI Bundle build (post-codegen)', 'npm', ['run', 'build'], { cwd: uiBundleDir });
1147
1347
  });
1148
- run('GraphQL codegen', 'npm', ['run', 'graphql:codegen'], { cwd: uiBundleDir });
1149
- run('UI Bundle build (post-codegen)', 'npm', ['run', 'build'], { cwd: uiBundleDir });
1150
- } else if (!skipUIBundleBuild && skipDeploy) {
1151
- // Only build here if the pre-deploy build didn't already run
1152
- run('UI Bundle npm install', 'npm', ['install'], { cwd: uiBundleDir });
1153
- run('UI Bundle build', 'npm', ['run', 'build'], { cwd: uiBundleDir });
1348
+ } else {
1349
+ recordSkipped(graphqlStep, 'not selected');
1350
+ if (!skipUIBundleBuild && skipDeploy && !preDeployBundlesBuilt) {
1351
+ // The pre-deploy build never ran (deploy was skipped); build here and
1352
+ // record the uiBundleBuild outcome that the pre-deploy branch would have.
1353
+ await runStep(uiBundleBuildStep, targetOrg, () => {
1354
+ run('UI Bundle npm install', 'npm', ['install'], { cwd: uiBundleDir });
1355
+ run('UI Bundle build', 'npm', ['run', 'build'], { cwd: uiBundleDir });
1356
+ });
1357
+ preDeployBundlesBuilt = true;
1358
+ }
1154
1359
  }
1155
1360
 
1156
- console.log('\n--- Setup complete ---');
1361
+ // When uiBundleBuild was selected but the dedicated pre-deploy build never ran
1362
+ // (deploy skipped and graphql selected), the build happened inside the graphql
1363
+ // step — which is fail-fast, so reaching here means it succeeded. Record ok.
1364
+ if (!skipUIBundleBuild && !preDeployBundlesBuilt && !results.some((r) => r.key === 'uiBundleBuild')) {
1365
+ recordOk(uiBundleBuildStep);
1366
+ }
1157
1367
 
1368
+ const devStep = stepDefs.find((s) => s.key === 'dev');
1158
1369
  if (!skipDev) {
1370
+ // dev is skippable and terminal: a failure here is a local runtime issue
1371
+ // (port in use, tooling), not a broken setup. Print the summary BEFORE the
1372
+ // (blocking, long-lived) dev server starts so the developer sees the setup
1373
+ // outcome up front; the server then runs in the foreground until Ctrl+C.
1374
+ recordOk(devStep);
1375
+ printSummary(targetOrg);
1159
1376
  console.log('\n--- Launching dev server (Ctrl+C to stop) ---\n');
1160
- run('Dev server', 'npm', ['run', 'dev'], { cwd: uiBundleDir });
1377
+ const devResult = run('Dev server', 'npm', ['run', 'dev'], { cwd: uiBundleDir, optional: true });
1378
+ if (devResult.status !== 0) {
1379
+ // Replace the optimistic `ok` with a skippable failure and reprint.
1380
+ const row = results.find((r) => r.key === 'dev');
1381
+ row.status = 'failed';
1382
+ row.reason = 'dev server failed to start — setup itself completed; check local runtime';
1383
+ printSummary(targetOrg);
1384
+ }
1385
+ process.exit(finalExitCode());
1386
+ } else {
1387
+ recordSkipped(devStep, 'not selected');
1161
1388
  }
1389
+
1390
+ printSummary(targetOrg);
1391
+ process.exit(finalExitCode());
1162
1392
  }
1163
1393
 
1164
1394
  main().catch((err) => {
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Build / CI gate: validate this package's org-setup.config.json against the
3
+ * shared schema (W-23043729, spec §5.2).
4
+ *
5
+ * Ships inside the SFDX project template, so a generated app can self-check its
6
+ * org-setup.config.json in its own CI:
7
+ * npm run validate:org-setup-config
8
+ *
9
+ * Uses the SAME validateConfig + schema as org-setup.mjs runtime, so build-time
10
+ * and runtime checks can never drift. A missing config file is OK (not every
11
+ * project ships one); a present-but-invalid config fails the gate.
12
+ */
13
+
14
+ import { existsSync, readFileSync } from 'node:fs';
15
+ import { resolve, dirname } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ import { validateConfig } from './org-setup-config-schema.mjs';
19
+
20
+ const __dirname = dirname(fileURLToPath(import.meta.url));
21
+ const configPath = resolve(__dirname, 'org-setup.config.json');
22
+
23
+ if (!existsSync(configPath)) {
24
+ console.log('✓ org-setup.config.json: not present (nothing to validate)');
25
+ process.exit(0);
26
+ }
27
+
28
+ const result = validateConfig(readFileSync(configPath, 'utf8'), configPath);
29
+ if (result.ok) {
30
+ console.log('✓ org-setup.config.json');
31
+ process.exit(0);
32
+ }
33
+
34
+ console.error('❌ org-setup.config.json is invalid:');
35
+ for (const err of result.errors) {
36
+ console.error(` - ${err}`);
37
+ }
38
+ process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/ui-bundle-template-app-react-template-b2e",
3
- "version": "10.23.0",
3
+ "version": "10.24.0",
4
4
  "description": "Salesforce React internal app template",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "author": "",