@salesforce/ui-bundle-template-feature-react-search 11.12.2 → 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.
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
+ ## [11.13.0](https://github.com/salesforce-experience-platform-emu/webapps/compare/v11.12.2...v11.13.0) (2026-07-09)
7
+
8
+
9
+ ### Features
10
+
11
+ * **org-setup:** @W-23043735@ org-setup.mjs UX enhancements ([#709](https://github.com/salesforce-experience-platform-emu/webapps/issues/709)) ([ad7f480](https://github.com/salesforce-experience-platform-emu/webapps/commit/ad7f480232a363a6f6dfd9df13bce6e32e24ac46))
12
+
13
+
14
+
6
15
  ## [11.12.2](https://github.com/salesforce-experience-platform-emu/webapps/compare/v11.12.1...v11.12.2) (2026-07-09)
7
16
 
8
17
 
package/dist/README.md CHANGED
@@ -12,6 +12,55 @@ npm run sf-project-setup
12
12
 
13
13
  This installs dependencies in the UI Bundle, builds it, and starts the Vite dev server. Use this after cloning or extracting the project.
14
14
 
15
+ ## Set up the org
16
+
17
+ `npm run setup` configures a target org from this project in one command: it logs in
18
+ (a required precondition — auto-skipped if the org is already connected), deploys
19
+ metadata, assigns permission sets, imports sample data, refreshes the GraphQL
20
+ schema/types, and builds the UI Bundle. When `--target-org` is omitted it uses your
21
+ default org or, in an interactive terminal, prompts you to pick one.
22
+
23
+ ```bash
24
+ npm run setup -- --target-org <alias> # or omit --target-org to use the default / pick
25
+ ```
26
+
27
+ Setup does **not** start the dev server. Once the org is set up, use `dev:preview` to
28
+ refresh the GraphQL types against the org and launch the Vite dev server on its own:
29
+
30
+ ```bash
31
+ npm run dev:preview
32
+ ```
33
+
34
+ ### Setup configuration (`scripts/org-setup.config.json`)
35
+
36
+ `org-setup.config.json` controls which steps run and how. Every top-level section is
37
+ optional; an absent section hides its step. It is validated against a shared schema
38
+ at build/CI time and again at the start of every `npm run setup`, so a malformed
39
+ config fails fast rather than misbehaving.
40
+
41
+ ```jsonc
42
+ {
43
+ "permsetAssignments": {
44
+ "assignments": {
45
+ "My_Access": { "assignee": "currentUser" }
46
+ }
47
+ },
48
+ "role": { "assignee": "currentUser", "roleName": "Admin" },
49
+ "selfRegistration": {
50
+ "selfRegProfile": "My Prospect Profile",
51
+ "accountName": "My Self-Registration"
52
+ }
53
+ }
54
+ ```
55
+
56
+ When a `selfRegistration` section is present, setup runs a **license pre-check**
57
+ before configuring self-registration: it derives the required `UserLicense` from the
58
+ configured `selfRegProfile` (matched on the stable `LicenseDefinitionKey`, not the
59
+ display name) and, if the org lacks that license or has no available seats, **warns
60
+ and skips** self-registration instead of reporting success — so prospect login can't
61
+ silently fail on an under-provisioned org. No separate license field is configured;
62
+ the requirement always follows the profile the flow assigns.
63
+
15
64
  ## How Do You Plan to Deploy Your Changes?
16
65
 
17
66
  Do you want to deploy a set of changes, or create a self-contained application? Choose a [development model](https://developer.salesforce.com/tools/vscode/en/user-guide/development-models).
@@ -18,8 +18,8 @@
18
18
  "graphql:schema": "node scripts/get-graphql-schema.mjs"
19
19
  },
20
20
  "dependencies": {
21
- "@salesforce/platform-sdk": "^11.12.2",
22
- "@salesforce/ui-bundle": "^11.12.2",
21
+ "@salesforce/platform-sdk": "^11.13.0",
22
+ "@salesforce/ui-bundle": "^11.13.0",
23
23
  "@tailwindcss/vite": "^4.1.17",
24
24
  "class-variance-authority": "^0.7.1",
25
25
  "clsx": "^2.1.1",
@@ -44,8 +44,8 @@
44
44
  "@graphql-eslint/eslint-plugin": "^4.1.0",
45
45
  "@graphql-tools/utils": "^11.0.0",
46
46
  "@playwright/test": "^1.49.0",
47
- "@salesforce/graphiti": "^11.12.2",
48
- "@salesforce/vite-plugin-ui-bundle": "^11.12.2",
47
+ "@salesforce/graphiti": "^11.13.0",
48
+ "@salesforce/vite-plugin-ui-bundle": "^11.13.0",
49
49
  "@testing-library/jest-dom": "^6.6.3",
50
50
  "@testing-library/react": "^16.1.0",
51
51
  "@testing-library/user-event": "^14.5.2",
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@salesforce/ui-bundle-template-base-sfdx-project",
3
- "version": "11.12.2",
3
+ "version": "11.13.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@salesforce/ui-bundle-template-base-sfdx-project",
9
- "version": "11.12.2",
9
+ "version": "11.13.0",
10
10
  "license": "SEE LICENSE IN LICENSE.txt",
11
11
  "dependencies": {
12
12
  "fast-xml-parser": "^5.9.3",
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/ui-bundle-template-base-sfdx-project",
3
- "version": "11.12.2",
3
+ "version": "11.13.0",
4
4
  "description": "Base SFDX project template",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "publishConfig": {
@@ -21,7 +21,8 @@
21
21
  "prettier": "prettier --write \"**/*.{cls,cmp,component,css,html,js,json,md,page,trigger,xml,yaml,yml}\"",
22
22
  "prettier:verify": "prettier --check \"**/*.{cls,cmp,component,css,html,js,json,md,page,trigger,xml,yaml,yml}\"",
23
23
  "precommit": "lint-staged",
24
- "setup": "node scripts/org-setup.mjs"
24
+ "setup": "node scripts/org-setup.mjs",
25
+ "dev:preview": "node scripts/org-setup-dev.mjs"
25
26
  },
26
27
  "dependencies": {
27
28
  "fast-xml-parser": "^5.9.3",
@@ -50,6 +50,13 @@ export const ConfigSchema = z
50
50
  // No siteName: the site is derived from the single
51
51
  // networks/<siteName>.network-meta.xml the app ships (see spec §5.2),
52
52
  // exactly like the guestUser permset path.
53
+ //
54
+ // selfRegProfile also drives the M1 license pre-check (W-23043735):
55
+ // setup derives the required UserLicense from this profile
56
+ // (Profile.UserLicense, matched on the stable LicenseDefinitionKey) and
57
+ // warns + skips self-registration when the org lacks it or has no seats.
58
+ // No separate license field is declared — the requirement can't drift
59
+ // from the profile the flow actually assigns.
53
60
  selfRegProfile: z.string().min(1),
54
61
  accountName: z.string().min(1),
55
62
  })
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Dev preview: refresh the GraphQL schema/types against a target org, then launch
4
+ * the Vite dev server (spec §5.5, M4). This is the step that used to be the final
5
+ * `dev` stage of org-setup.mjs — extracted here so `npm run setup` terminates
6
+ * cleanly after graphql, and the long-lived dev server is an explicit, separate
7
+ * `npm run dev:preview`.
8
+ *
9
+ * Unlike setup, this does NOT log in or deploy — it assumes the org is already
10
+ * set up. It shares org/bundle resolution and the process runner with org-setup
11
+ * via org-setup-utils.mjs.
12
+ *
13
+ * Usage:
14
+ * npm run dev:preview # prompt to pick an org + bundle
15
+ * npm run dev:preview -- --target-org myorg
16
+ * npm run dev:preview -- --target-org myorg --ui-bundle-name my-app
17
+ *
18
+ * Flow (in order):
19
+ * 1. resolve target org (explicit --target-org, else prompt / default org)
20
+ * 2. resolve UI bundle (explicit --ui-bundle-name, else picker / warning)
21
+ * 3. npm install (in the UI bundle)
22
+ * 4. npm run graphql:schema (SF_TARGET_ORG=<org>) — introspect the live schema
23
+ * 5. npm run graphql:codegen — regenerate typed operations
24
+ * 6. npm run dev (blocking, Ctrl+C to stop)
25
+ */
26
+
27
+ import { resolve, dirname } from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+
30
+ import { run, resolveSfdxSource, discoverUIBundleDir, resolveTargetOrg } from './org-setup-utils.mjs';
31
+
32
+ const __dirname = dirname(fileURLToPath(import.meta.url));
33
+ const ROOT = resolve(__dirname, '..');
34
+
35
+ function parseArgs() {
36
+ const args = process.argv.slice(2);
37
+ let targetOrg = null;
38
+ let uiBundleName = null;
39
+ for (let i = 0; i < args.length; i++) {
40
+ if (args[i] === '--target-org' && args[i + 1]) {
41
+ targetOrg = args[++i];
42
+ } else if (args[i] === '--ui-bundle-name' && args[i + 1]) {
43
+ uiBundleName = args[++i];
44
+ } else if (args[i] === '--help' || args[i] === '-h') {
45
+ console.log(`
46
+ Dev preview — refresh GraphQL types then launch the Vite dev server
47
+
48
+ Usage:
49
+ npm run dev:preview -- [--target-org <alias>] [--ui-bundle-name <name>]
50
+
51
+ Options:
52
+ --target-org <alias> Target Salesforce org alias. If omitted, you are prompted
53
+ to pick from authenticated orgs (or the default org is used).
54
+ --ui-bundle-name <name> UI bundle folder under uiBundles/ (default: auto-detect / prompt)
55
+ -h, --help Show this help
56
+ `);
57
+ process.exit(0);
58
+ }
59
+ }
60
+ return { targetOrg, uiBundleName };
61
+ }
62
+
63
+ async function main() {
64
+ const parsed = parseArgs();
65
+
66
+ const targetOrg = await resolveTargetOrg(parsed);
67
+ const uiBundlesDir = resolve(resolveSfdxSource(ROOT), 'uiBundles');
68
+ const uiBundleDir = await discoverUIBundleDir(uiBundlesDir, parsed.uiBundleName);
69
+
70
+ console.log('Dev preview — target org:', targetOrg, '| UI bundle:', uiBundleDir);
71
+
72
+ run('UI Bundle npm install', 'npm', ['install'], { cwd: uiBundleDir });
73
+ run('GraphQL schema (introspect)', 'npm', ['run', 'graphql:schema'], {
74
+ cwd: uiBundleDir,
75
+ env: { ...process.env, SF_TARGET_ORG: targetOrg },
76
+ });
77
+ run('GraphQL codegen', 'npm', ['run', 'graphql:codegen'], { cwd: uiBundleDir });
78
+
79
+ console.log('\n--- Launching dev server (Ctrl+C to stop) ---\n');
80
+ const devResult = run('Dev server', 'npm', ['run', 'dev'], { cwd: uiBundleDir, optional: true });
81
+ process.exit(devResult.status ?? 0);
82
+ }
83
+
84
+ main().catch((err) => {
85
+ console.error(err);
86
+ process.exit(1);
87
+ });
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Shared helpers for the org-setup scripts (W-23043735, spec §5.2/§5.6/Q9).
3
+ *
4
+ * Factored out of org-setup.mjs so BOTH `org-setup.mjs` and `org-setup-dev.mjs` import the
5
+ * same primitives instead of duplicating them:
6
+ * - process runners (`run`, `runAsync`, `spawnAsync`)
7
+ * - SFDX path + UI-bundle discovery (`resolveSfdxSource`, `discoverAllUIBundleDirs`,
8
+ * `discoverUIBundleDir` — with the multi-bundle acknowledgment of §5.1 item 2)
9
+ * - the interactive `--target-org` fallback (`resolveTargetOrg`) and its
10
+ * single-select picker (`promptSelect`)
11
+ * - pure, unit-testable logic for the license gate, SOQL-name validation, org-list
12
+ * parsing, and the non-TTY multi-bundle warning
13
+ *
14
+ * IMPORTANT: this module is side-effect-free at import time — no top-level
15
+ * readFileSync / process.exit / CLI run — so it can be imported by tests and by
16
+ * org-setup-dev.mjs without triggering org-setup's main(). All I/O happens inside the
17
+ * exported functions, only when they are called. Errors are plain `Error`s (not
18
+ * org-setup's StepError) to avoid an import cycle; runStep handles both alike.
19
+ */
20
+
21
+ import { spawnSync } from 'node:child_process';
22
+ import { readdirSync, existsSync, readFileSync } from 'node:fs';
23
+ import { resolve } from 'node:path';
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Pure logic (no I/O) — unit-testable in isolation.
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /**
30
+ * Validate that a profile name is safe to interpolate into a SOQL `WHERE Name =
31
+ * '<name>'` clause (spec §5.3, Q10 — validate-and-fail, NOT an escaper). The
32
+ * `selfRegProfile` is developer-authored config the app ships, and license /
33
+ * profile names are not expected to contain special characters, so a `'`, `\`,
34
+ * or control char is treated as a config mistake to surface loudly rather than a
35
+ * value to silently escape. Returns the name unchanged when valid; throws a
36
+ * clear config error otherwise. AC-8.
37
+ */
38
+ export function validateProfileNameForSoql(name) {
39
+ const s = String(name);
40
+ // SOQL string delimiter (') and escape char (\) would break/inject the query;
41
+ // control chars (C0 + DEL) have no legitimate place in a profile name.
42
+ if (/['\\]/.test(s) || /[\x00-\x1f\x7f]/.test(s)) {
43
+ throw new Error(
44
+ `selfRegProfile name "${name}" contains an unsupported character ` +
45
+ `(quotes, backslashes, and control characters are not allowed). ` +
46
+ `Fix the profile name in org-setup.config.json.`,
47
+ );
48
+ }
49
+ return s;
50
+ }
51
+
52
+ /**
53
+ * Decide whether the self-reg profile's UserLicense is satisfied, from the rows
54
+ * a `SELECT UserLicense.LicenseDefinitionKey, UserLicense.Name, UserLicense.Status,
55
+ * UserLicense.TotalLicenses, UserLicense.UsedLicenses FROM Profile WHERE Name = '…'`
56
+ * query returns (spec §5.3). The seat math MUST be done here in JS — SOQL cannot
57
+ * compare two fields (Q3, verified live). Matches/report on the stable
58
+ * `LicenseDefinitionKey`, never the display Name (AC-2b).
59
+ *
60
+ * satisfied ⇔ Status === 'Active' && (total === -1 [unlimited sentinel] || total - used > 0).
61
+ * 0 rows / null UserLicense ⇒ not satisfied, with a reason naming the profile.
62
+ *
63
+ * @returns {{ satisfied: boolean, reason: string|null, license: {key,label,status,total,used}|null }}
64
+ */
65
+ export function evaluateLicenseRows(rows, profileName) {
66
+ const lic = Array.isArray(rows) ? rows[0]?.UserLicense : null;
67
+ if (!lic) {
68
+ return {
69
+ satisfied: false,
70
+ reason: `no UserLicense found for profile "${profileName}" — the profile is missing in the org or its name is misspelled`,
71
+ license: null,
72
+ };
73
+ }
74
+ const key = lic.LicenseDefinitionKey ?? null;
75
+ const label = lic.Name ?? key ?? 'unknown license';
76
+ const total = Number(lic.TotalLicenses);
77
+ const used = Number(lic.UsedLicenses);
78
+ const license = { key, label, status: lic.Status, total, used };
79
+
80
+ if (lic.Status !== 'Active') {
81
+ return { satisfied: false, reason: `license ${label} (${key}) is not Active (Status=${lic.Status})`, license };
82
+ }
83
+ // Seat counts should always be integers in practice, but guard against a
84
+ // missing/non-numeric field so the skip reason stays legible (not "NaN/NaN").
85
+ if (!Number.isFinite(total) || !Number.isFinite(used)) {
86
+ return { satisfied: false, reason: `could not read seat counts for license ${label} (${key})`, license };
87
+ }
88
+ // -1 is the documented "unlimited" sentinel (defensive — unverified live, §5.3).
89
+ if (total === -1 || total - used > 0) {
90
+ return { satisfied: true, reason: null, license };
91
+ }
92
+ return {
93
+ satisfied: false,
94
+ reason: `license ${label} (${key}) has no available seats (${used}/${total} used)`,
95
+ license,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Parse `sf org list --json` defensively into a flat org list + the default org
101
+ * (spec §5.1 item 3; mirrors isOrgConnected's try/catch tolerance). Collects
102
+ * every array under `result` (nonScratchOrgs, scratchOrgs, sandboxes, devHubs, …)
103
+ * so the picker sees all authenticated orgs; the default is the entry marked
104
+ * `isDefaultUsername`. Malformed / unexpected JSON ⇒ empty result (caller then
105
+ * falls back to the clear error).
106
+ *
107
+ * @returns {{ orgs: Array<{alias:string|null, username:string, isDefault:boolean}>, defaultOrg: string|null }}
108
+ */
109
+ export function parseOrgList(jsonText) {
110
+ let parsed;
111
+ try {
112
+ parsed = JSON.parse(jsonText);
113
+ } catch {
114
+ return { orgs: [], defaultOrg: null };
115
+ }
116
+ const result = parsed?.result;
117
+ if (!result || typeof result !== 'object') return { orgs: [], defaultOrg: null };
118
+
119
+ const orgs = [];
120
+ const seen = new Set();
121
+ for (const val of Object.values(result)) {
122
+ if (!Array.isArray(val)) continue;
123
+ for (const entry of val) {
124
+ if (!entry || typeof entry !== 'object' || !entry.username) continue;
125
+ if (seen.has(entry.username)) continue;
126
+ seen.add(entry.username);
127
+ orgs.push({
128
+ alias: entry.alias ?? null,
129
+ username: entry.username,
130
+ isDefault: entry.isDefaultUsername === true,
131
+ });
132
+ }
133
+ }
134
+ const def = orgs.find((o) => o.isDefault);
135
+ return { orgs, defaultOrg: def ? (def.alias ?? def.username) : null };
136
+ }
137
+
138
+ /**
139
+ * Multi-line warning for the non-TTY multi-bundle case (spec §5.1 item 2, AC-4b):
140
+ * there is nothing to interactively acknowledge, so keep the deterministic
141
+ * `all[0]` pick but never do it silently — name every bundle, the chosen one, and
142
+ * the `--ui-bundle-name` remedy.
143
+ */
144
+ export function buildMultiBundleWarning(allBundleNames, chosen) {
145
+ return [
146
+ `⚠ Multiple UI bundles found under uiBundles/ (${allBundleNames.length}):`,
147
+ ...allBundleNames.map((n) => ` - ${n}${n === chosen ? ' ← using this one' : ''}`),
148
+ ` Using "${chosen}" (first alphabetically). To deploy a different bundle, re-run with:`,
149
+ ` --ui-bundle-name <name>`,
150
+ ].join('\n');
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Process runner (moved from org-setup.mjs; plain Error to avoid an import cycle).
155
+ // Only the synchronous `run` is shared — org-setup-dev.mjs runs its steps serially. The
156
+ // async spawn variants stay local to org-setup.mjs where the parallel permset
157
+ // path uses them, so this module exposes no caller-less surface.
158
+ // ---------------------------------------------------------------------------
159
+
160
+ export function run(name, cmd, args, opts = {}) {
161
+ const { cwd = process.cwd(), optional = false } = opts;
162
+ console.log('\n---', name, '---');
163
+ const result = spawnSync(cmd, args, {
164
+ cwd,
165
+ stdio: 'inherit',
166
+ shell: true,
167
+ ...(opts.env && { env: opts.env }),
168
+ ...(opts.timeout && { timeout: opts.timeout }),
169
+ });
170
+ if (result.status !== 0 && !optional) {
171
+ throw new Error(`${name} (exit ${result.status ?? 1})`);
172
+ }
173
+ return result;
174
+ }
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // SFDX paths + UI-bundle discovery.
178
+ // ---------------------------------------------------------------------------
179
+
180
+ /**
181
+ * Resolve the first package directory's source root (main/default) from
182
+ * sfdx-project.json under `root`. Exits non-zero with a clear message when the
183
+ * project file or packageDirectories path is missing. Called explicitly by each
184
+ * entry script (not at import) so this module stays side-effect-free.
185
+ */
186
+ export function resolveSfdxSource(root) {
187
+ const sfdxPath = resolve(root, 'sfdx-project.json');
188
+ if (!existsSync(sfdxPath)) {
189
+ console.error('Error: sfdx-project.json not found at project root.');
190
+ process.exit(1);
191
+ }
192
+ const sfdxProject = JSON.parse(readFileSync(sfdxPath, 'utf8'));
193
+ const pkgDir = sfdxProject?.packageDirectories?.[0]?.path;
194
+ if (!pkgDir) {
195
+ console.error('Error: No packageDirectories[].path found in sfdx-project.json.');
196
+ process.exit(1);
197
+ }
198
+ return resolve(root, pkgDir, 'main', 'default');
199
+ }
200
+
201
+ /** Basename of a bundle dir path, cross-platform. */
202
+ function bundleName(dir) {
203
+ return dir.split(/[/\\]/).pop();
204
+ }
205
+
206
+ /**
207
+ * All UI bundle directories under `uiBundlesDir` (alphabetical, hidden dirs
208
+ * excluded). When `uiBundleName` is given, returns just that one (erroring if it
209
+ * doesn't exist). Exits non-zero when the uiBundles dir or a named bundle is
210
+ * missing.
211
+ */
212
+ export function discoverAllUIBundleDirs(uiBundlesDir, uiBundleName) {
213
+ if (!existsSync(uiBundlesDir)) {
214
+ console.error(`Error: uiBundles directory not found: ${uiBundlesDir}`);
215
+ process.exit(1);
216
+ }
217
+ const entries = readdirSync(uiBundlesDir, { withFileTypes: true });
218
+ const dirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith('.'));
219
+ if (dirs.length === 0) {
220
+ console.error(`Error: No UI bundle folder found under ${uiBundlesDir}`);
221
+ process.exit(1);
222
+ }
223
+ if (uiBundleName) {
224
+ const requested = dirs.find((d) => d.name === uiBundleName);
225
+ if (!requested) {
226
+ console.error(`Error: UI bundle directory not found: ${uiBundleName}`);
227
+ process.exit(1);
228
+ }
229
+ return [resolve(uiBundlesDir, requested.name)];
230
+ }
231
+ return dirs.map((d) => resolve(uiBundlesDir, d.name));
232
+ }
233
+
234
+ /**
235
+ * Resolve the single UI bundle to operate on, with the multi-bundle
236
+ * acknowledgment of spec §5.1 item 2 (Q5). When >1 bundle exists and no
237
+ * `--ui-bundle-name` was given:
238
+ * - TTY: present a single-select picker (all bundles, alphabetical all[0]
239
+ * preselected) — enter accepts all[0], arrows choose another (AC-4).
240
+ * - non-TTY: keep the deterministic all[0] pick but emit the multi-line warning
241
+ * (AC-4b) — never a silent pick.
242
+ * Explicit `--ui-bundle-name` short-circuits both (no prompt, no warning; AC-4c).
243
+ */
244
+ export async function discoverUIBundleDir(uiBundlesDir, uiBundleName) {
245
+ const all = discoverAllUIBundleDirs(uiBundlesDir, uiBundleName);
246
+ if (all.length > 1 && !uiBundleName) {
247
+ const names = all.map(bundleName);
248
+ if (process.stdin.isTTY) {
249
+ const idx = await promptSelect(names, {
250
+ prompt: 'Multiple UI bundles found — select which one to deploy:',
251
+ preselectedIndex: 0,
252
+ });
253
+ return all[idx];
254
+ }
255
+ console.warn(buildMultiBundleWarning(names, names[0]));
256
+ }
257
+ return all[0];
258
+ }
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // Interactive single-select picker + --target-org fallback.
262
+ // ---------------------------------------------------------------------------
263
+
264
+ /**
265
+ * Interactive single-select: arrow keys navigate, enter confirms. Returns the
266
+ * chosen index. Falls through to `preselectedIndex` immediately when stdin is not
267
+ * a TTY. Built on the same raw-mode scaffolding as promptSteps (org-setup.mjs)
268
+ * but selects exactly one item (spec §5.1 items 2 & 3).
269
+ */
270
+ export function promptSelect(options, { prompt = 'Select an option:', preselectedIndex = 0 } = {}) {
271
+ if (!process.stdin.isTTY || options.length === 0) return Promise.resolve(preselectedIndex);
272
+
273
+ let cursor = Math.min(Math.max(0, preselectedIndex), options.length - 1);
274
+ const RST = '\x1B[0m';
275
+ const CYAN = '\x1B[36m';
276
+ const GREEN = '\x1B[32m';
277
+
278
+ function render() {
279
+ return options.map((label, row) => {
280
+ const ptr = row === cursor ? `${CYAN}❯${RST}` : ' ';
281
+ const chk = row === cursor ? `${GREEN}●${RST}` : '○';
282
+ return `${ptr} ${chk} ${label}`;
283
+ });
284
+ }
285
+
286
+ return new Promise((resolvePromise) => {
287
+ process.stdin.setRawMode(true);
288
+ process.stdin.resume();
289
+ process.stdin.setEncoding('utf8');
290
+ process.stdout.write('\x1B[?25l');
291
+ console.log(`\n${prompt} (↑↓ move, enter confirm):\n`);
292
+ let prevRows = options.length;
293
+ process.stdout.write(render().join('\n') + '\n');
294
+
295
+ function redraw() {
296
+ process.stdout.write(`\x1B[${prevRows}A`);
297
+ const lines = render();
298
+ for (const line of lines) process.stdout.write(`\x1B[2K${line}\n`);
299
+ prevRows = lines.length;
300
+ }
301
+
302
+ process.stdin.on('data', (key) => {
303
+ if (key === '\x03') {
304
+ process.stdout.write('\x1B[?25h\n');
305
+ process.exit(0);
306
+ }
307
+ if (key === '\r' || key === '\n') {
308
+ process.stdout.write('\x1B[?25h');
309
+ process.stdin.setRawMode(false);
310
+ process.stdin.pause();
311
+ process.stdin.removeAllListeners('data');
312
+ console.log();
313
+ resolvePromise(cursor);
314
+ return;
315
+ }
316
+ if (key === '\x1B[A' || key === 'k') {
317
+ cursor = Math.max(0, cursor - 1);
318
+ redraw();
319
+ } else if (key === '\x1B[B' || key === 'j') {
320
+ cursor = Math.min(options.length - 1, cursor + 1);
321
+ redraw();
322
+ }
323
+ });
324
+ });
325
+ }
326
+
327
+ /**
328
+ * Resolve the target org (spec §5.1 item 3, §15). When `--target-org` is supplied
329
+ * it is returned verbatim with NO `sf org list` call (AC-7, byte-for-byte the
330
+ * scripted path). Otherwise discover authenticated orgs via `sf org list --json`
331
+ * and:
332
+ * - TTY: present a picker with the default org at the top and preselected
333
+ * (AC-5); enter selects the default.
334
+ * - non-TTY: use the default org if resolvable, else exit 1 with a clear
335
+ * message (AC-6).
336
+ */
337
+ export async function resolveTargetOrg(parsed) {
338
+ if (parsed.targetOrg) return parsed.targetOrg;
339
+
340
+ const listResult = spawnSync('sf', ['org', 'list', '--json'], {
341
+ encoding: 'utf8',
342
+ shell: true,
343
+ });
344
+ const { orgs, defaultOrg } = parseOrgList(listResult.status === 0 ? listResult.stdout : '');
345
+
346
+ if (process.stdin.isTTY && orgs.length > 0) {
347
+ // Default org first + preselected; the rest keep sf's order.
348
+ const ordered = [...orgs].sort((a, b) => (b.isDefault ? 1 : 0) - (a.isDefault ? 1 : 0));
349
+ const labels = ordered.map((o) => {
350
+ const name = o.alias ?? o.username;
351
+ const suffix = o.alias && o.alias !== o.username ? ` (${o.username})` : '';
352
+ return `${name}${suffix}${o.isDefault ? ' [default]' : ''}`;
353
+ });
354
+ const idx = await promptSelect(labels, { prompt: 'Select the target org:', preselectedIndex: 0 });
355
+ const chosen = ordered[idx];
356
+ return chosen.alias ?? chosen.username;
357
+ }
358
+
359
+ if (defaultOrg) {
360
+ console.log(`No --target-org given; using the default org: ${defaultOrg}`);
361
+ return defaultOrg;
362
+ }
363
+
364
+ console.error(
365
+ 'Error: --target-org <alias> is required. No default org is set and this is not an ' +
366
+ 'interactive terminal. Set a default org (sf config set target-org=<alias>) or pass --target-org.',
367
+ );
368
+ process.exit(1);
369
+ }
@@ -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
  * {
@@ -57,6 +59,13 @@ import {
57
59
  enableSelfRegInXml,
58
60
  NetworkXmlError,
59
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';
60
69
 
61
70
  const __dirname = dirname(fileURLToPath(import.meta.url));
62
71
  const ROOT = resolve(__dirname, '..');
@@ -265,7 +274,6 @@ function parseArgs() {
265
274
  const permsetNamesExplicit = [];
266
275
  let yes = false;
267
276
  const flags = {
268
- skipLogin: false,
269
277
  skipDeploy: false,
270
278
  skipPermset: false,
271
279
  skipRole: false,
@@ -273,7 +281,6 @@ function parseArgs() {
273
281
  skipGraphql: false,
274
282
  skipUIBundleBuild: false,
275
283
  skipSelfReg: false,
276
- skipDev: false,
277
284
  };
278
285
  for (let i = 0; i < args.length; i++) {
279
286
  if (args[i] === '--target-org' && args[i + 1]) {
@@ -282,39 +289,42 @@ function parseArgs() {
282
289
  uiBundleName = args[++i];
283
290
  } else if (args[i] === '--permset-name' && args[i + 1]) {
284
291
  permsetNamesExplicit.push(args[++i]);
285
- } else if (args[i] === '--skip-login') flags.skipLogin = true;
286
- else if (args[i] === '--skip-deploy') flags.skipDeploy = true;
292
+ } else if (args[i] === '--skip-deploy') flags.skipDeploy = true;
287
293
  else if (args[i] === '--skip-permset') flags.skipPermset = true;
288
294
  else if (args[i] === '--skip-role') flags.skipRole = true;
289
295
  else if (args[i] === '--skip-data') flags.skipData = true;
290
296
  else if (args[i] === '--skip-self-reg') flags.skipSelfReg = true;
291
297
  else if (args[i] === '--skip-graphql') flags.skipGraphql = true;
292
298
  else if (args[i] === '--skip-ui-bundle-build') flags.skipUIBundleBuild = true;
293
- else if (args[i] === '--skip-dev') flags.skipDev = true;
294
- 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;
295
305
  else if (args[i] === '--help' || args[i] === '-h') {
296
306
  console.log(`
297
307
  Setup CLI — one-command setup for apps in this project
298
308
 
299
309
  Usage:
300
- node scripts/org-setup.mjs --target-org <alias> [options]
301
-
302
- Required:
303
- --target-org <alias> Target Salesforce org alias (e.g. myorg)
310
+ node scripts/org-setup.mjs [--target-org <alias>] [options]
304
311
 
305
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).
306
316
  --ui-bundle-name <name> UI bundle folder name under uiBundles/ (default: auto-detect)
307
317
  --permset-name <name> Assign only this permission set (repeatable). Default: all sets under permissionsets/
308
- --skip-login Skip login step (login is auto-skipped if org is already connected)
309
318
  --skip-deploy Do not deploy metadata
310
319
  --skip-permset Do not assign permission set
311
320
  --skip-data Do not prepare data or run data import
312
321
  --skip-graphql Do not fetch schema or run GraphQL codegen
313
322
  --skip-ui-bundle-build Do not npm install / build the UI bundle
314
- --skip-dev Do not launch the dev server at the end
315
323
  -y, --yes Skip interactive step picker; run all enabled steps immediately
316
324
  -h, --help Show this help
317
325
 
326
+ To launch the dev server after setup, run: npm run dev:preview
327
+
318
328
  Permset config (scripts/org-setup.config.json):
319
329
  Control per-permset assignment via a config file. Example:
320
330
  {
@@ -334,41 +344,22 @@ Permset config (scripts/org-setup.config.json):
334
344
  process.exit(0);
335
345
  }
336
346
  }
337
- if (!targetOrg) {
338
- console.error('Error: --target-org <alias> is required.');
339
- process.exit(1);
340
- }
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).
341
350
  return { targetOrg, uiBundleName, permsetNamesExplicit, yes, ...flags };
342
351
  }
343
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).
344
357
  function discoverAllUIBundleDirs(uiBundleName) {
345
- if (!existsSync(UIBUNDLES_DIR)) {
346
- console.error(`Error: uiBundles directory not found: ${UIBUNDLES_DIR}`);
347
- process.exit(1);
348
- }
349
- const entries = readdirSync(UIBUNDLES_DIR, { withFileTypes: true });
350
- const dirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith('.'));
351
- if (dirs.length === 0) {
352
- console.error(`Error: No UI bundle folder found under ${UIBUNDLES_DIR}`);
353
- process.exit(1);
354
- }
355
- if (uiBundleName) {
356
- const requested = dirs.find((d) => d.name === uiBundleName);
357
- if (!requested) {
358
- console.error(`Error: UI bundle directory not found: ${uiBundleName}`);
359
- process.exit(1);
360
- }
361
- return [resolve(UIBUNDLES_DIR, requested.name)];
362
- }
363
- return dirs.map((d) => resolve(UIBUNDLES_DIR, d.name));
358
+ return discoverAllUIBundleDirsIn(UIBUNDLES_DIR, uiBundleName);
364
359
  }
365
360
 
366
361
  function discoverUIBundleDir(uiBundleName) {
367
- const all = discoverAllUIBundleDirs(uiBundleName);
368
- if (all.length > 1 && !uiBundleName) {
369
- console.log(`Multiple UI bundles found; using first: ${all[0].split(/[/\\]/).pop()}`);
370
- }
371
- return all[0];
362
+ return discoverUIBundleDirIn(UIBUNDLES_DIR, uiBundleName);
372
363
  }
373
364
 
374
365
  /** API names from permissionsets/*.permissionset-meta.xml in the first package directory. */
@@ -831,6 +822,119 @@ function resolveGuestUsername(siteName, targetOrg) {
831
822
  }
832
823
  }
833
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
+
834
938
  function isOrgConnected(targetOrg) {
835
939
  const result = spawnSync('sf', ['org', 'display', '--target-org', targetOrg, '--json'], {
836
940
  cwd: ROOT,
@@ -1124,12 +1228,11 @@ async function main() {
1124
1228
  }
1125
1229
  }
1126
1230
 
1231
+ const parsed = parseArgs();
1127
1232
  const {
1128
- targetOrg,
1129
1233
  uiBundleName,
1130
1234
  permsetNamesExplicit,
1131
1235
  yes,
1132
- skipLogin: argSkipLogin,
1133
1236
  skipDeploy: argSkipDeploy,
1134
1237
  skipPermset: argSkipPermset,
1135
1238
  skipRole: argSkipRole,
@@ -1137,8 +1240,11 @@ async function main() {
1137
1240
  skipData: argSkipData,
1138
1241
  skipGraphql: argSkipGraphql,
1139
1242
  skipUIBundleBuild: argSkipUIBundleBuild,
1140
- skipDev: argSkipDev,
1141
- } = 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);
1142
1248
 
1143
1249
  // Per-target-org lock: acquired right after the start-of-run
1144
1250
  // sweep and before any step runs, so two runs against the same org cannot
@@ -1165,12 +1271,29 @@ async function main() {
1165
1271
  const selfRegConfig = loadSelfRegConfig(config);
1166
1272
  const hasSelfRegConfig = selfRegConfig !== null;
1167
1273
 
1168
- // failFast is a fixed, implementer-owned classification NOT
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
+
1288
+ // failFast is a fixed, implementer-owned classification (spec §5.2) — NOT
1169
1289
  // user-configurable. A fail-fast failure aborts the run immediately; a
1170
1290
  // skippable failure is recorded and the run continues. The exit code is
1171
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).
1172
1296
  const stepDefs = [
1173
- { key: 'login', label: 'Login — org authentication', enabled: !argSkipLogin, available: true, failFast: true },
1174
1297
  { key: 'uiBundleBuild', label: 'UI Bundle Build — npm install + build (pre-deploy)', enabled: !argSkipUIBundleBuild, available: true, failFast: true },
1175
1298
  { key: 'deploy', label: 'Deploy — sf project deploy start', enabled: !argSkipDeploy, available: true, failFast: true },
1176
1299
  { key: 'permset', label: permsetStepLabel, enabled: !argSkipPermset, available: true, failFast: false },
@@ -1178,7 +1301,6 @@ async function main() {
1178
1301
  { key: 'selfReg', label: 'Self-Registration — enable for site', enabled: !argSkipSelfReg && hasSelfRegConfig, available: hasSelfRegConfig, failFast: false },
1179
1302
  { key: 'data', label: 'Data — delete + import records via Apex', enabled: !argSkipData && hasDataPlan, available: hasDataPlan, failFast: true },
1180
1303
  { key: 'graphql', label: 'GraphQL — schema introspect + codegen', enabled: !argSkipGraphql, available: true, failFast: true },
1181
- { key: 'dev', label: 'Dev — launch dev server', enabled: !argSkipDev, available: true, failFast: false },
1182
1304
  ];
1183
1305
 
1184
1306
  const selections = yes ? stepDefs.map((s) => s.enabled) : await promptSteps(stepDefs);
@@ -1187,7 +1309,6 @@ async function main() {
1187
1309
  on[s.key] = selections[i];
1188
1310
  });
1189
1311
 
1190
- const skipLogin = !on.login;
1191
1312
  const skipUIBundleBuild = !on.uiBundleBuild;
1192
1313
  const skipDeploy = !on.deploy;
1193
1314
  const skipPermset = !on.permset;
@@ -1195,41 +1316,35 @@ async function main() {
1195
1316
  const skipSelfReg = !on.selfReg;
1196
1317
  const skipData = !on.data;
1197
1318
  const skipGraphql = !on.graphql;
1198
- const skipDev = !on.dev;
1199
1319
 
1200
- const needsUIBundle = !skipUIBundleBuild || !skipGraphql || !skipDev;
1201
- const uiBundleDir = needsUIBundle ? discoverUIBundleDir(uiBundleName) : null;
1320
+ const needsUIBundle = !skipUIBundleBuild || !skipGraphql;
1321
+ const uiBundleDir = needsUIBundle ? await discoverUIBundleDir(uiBundleName) : null;
1202
1322
  const doData = !skipData;
1203
1323
 
1204
1324
  console.log('Setup — target org:', targetOrg, '| UI bundle:', uiBundleDir ?? '(none)');
1205
1325
  console.log(
1206
- 'Steps: login=%s deploy=%s permset=%s role=%s selfReg=%s data=%s graphql=%s uiBundle=%s dev=%s',
1207
- !skipLogin,
1326
+ 'Steps: login=always deploy=%s permset=%s role=%s selfReg=%s data=%s graphql=%s uiBundle=%s',
1208
1327
  !skipDeploy,
1209
1328
  !skipPermset,
1210
1329
  !skipRole,
1211
1330
  !skipSelfReg,
1212
1331
  doData,
1213
1332
  !skipGraphql,
1214
- !skipUIBundleBuild,
1215
- !skipDev
1333
+ !skipUIBundleBuild
1216
1334
  );
1217
1335
 
1218
- const loginStep = stepDefs.find((s) => s.key === 'login');
1219
- if (!skipLogin) {
1220
- await runStep(loginStep, targetOrg, async () => {
1221
- if (isOrgConnected(targetOrg)) {
1222
- console.log('\n--- Login ---');
1223
- console.log(`Org ${targetOrg} is already authenticated; skipping browser login.`);
1224
- } else {
1225
- // Login is fail-fast: drop the previous { optional: true } so a
1226
- // failed browser login records `failed`, prints the summary, and aborts.
1227
- run('Login (browser)', 'sf', ['org', 'login', 'web', '--alias', targetOrg]);
1228
- }
1229
- });
1230
- } else {
1231
- recordSkipped(loginStep, 'not selected');
1232
- }
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
+ });
1233
1348
 
1234
1349
  // Ensure the self-reg profile is in networkMemberGroups before deploy so that
1235
1350
  // subsequent selfRegProfile / selfRegistration updates don't fail. This is
@@ -1274,6 +1389,19 @@ async function main() {
1274
1389
  const deployStep = stepDefs.find((s) => s.key === 'deploy');
1275
1390
  if (!skipDeploy) {
1276
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
+ }
1277
1405
  run('Deploy metadata', 'sf', ['project', 'deploy', 'start', '--target-org', targetOrg], {
1278
1406
  timeout: 180000,
1279
1407
  });
@@ -1382,20 +1510,32 @@ async function main() {
1382
1510
 
1383
1511
  const selfRegStep = stepDefs.find((s) => s.key === 'selfReg');
1384
1512
  if (!skipSelfReg) {
1385
- console.log('\n--- Enable self-registration ---');
1386
- // Config shape (selfRegProfile, accountName) is guaranteed by the schema.
1387
- // The site is derived inside the step body so a "multiple network files"
1388
- // StepError is recorded in the ledger rather than escaping. No manual
1389
- // recordSkipped inference.
1390
- await runStep(selfRegStep, targetOrg, () => {
1391
- const siteName = deriveSiteName();
1392
- if (!siteName) {
1393
- throw new StepError(
1394
- 'self-registration is configured but no networks/<siteName>.network-meta.xml was found to derive the site',
1395
- );
1396
- }
1397
- enableSelfRegistration(selfRegConfig, siteName, targetOrg);
1398
- });
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
+ }
1399
1539
  } else {
1400
1540
  recordSkipped(selfRegStep, selfRegStep.available ? 'not selected' : 'no config');
1401
1541
  }
@@ -1547,29 +1687,12 @@ async function main() {
1547
1687
  recordOk(uiBundleBuildStep);
1548
1688
  }
1549
1689
 
1550
- const devStep = stepDefs.find((s) => s.key === 'dev');
1551
- if (!skipDev) {
1552
- // dev is skippable and terminal: a failure here is a local runtime issue
1553
- // (port in use, tooling), not a broken setup. Print the summary BEFORE the
1554
- // (blocking, long-lived) dev server starts so the developer sees the setup
1555
- // outcome up front; the server then runs in the foreground until Ctrl+C.
1556
- recordOk(devStep);
1557
- printSummary(targetOrg);
1558
- console.log('\n--- Launching dev server (Ctrl+C to stop) ---\n');
1559
- const devResult = run('Dev server', 'npm', ['run', 'dev'], { cwd: uiBundleDir, optional: true });
1560
- if (devResult.status !== 0) {
1561
- // Replace the optimistic `ok` with a skippable failure and reprint.
1562
- const row = results.find((r) => r.key === 'dev');
1563
- row.status = 'failed';
1564
- row.reason = 'dev server failed to start — setup itself completed; check local runtime';
1565
- printSummary(targetOrg);
1566
- }
1567
- process.exit(finalExitCode());
1568
- } else {
1569
- recordSkipped(devStep, 'not selected');
1570
- }
1571
-
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.
1572
1692
  printSummary(targetOrg);
1693
+ if (finalExitCode() === 0) {
1694
+ console.log('\nTo launch the dev server, run: npm run dev:preview');
1695
+ }
1573
1696
  process.exit(finalExitCode());
1574
1697
  }
1575
1698
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/ui-bundle-template-feature-react-search",
3
- "version": "11.12.2",
3
+ "version": "11.13.0",
4
4
  "description": "[Beta] Configuration-driven multi-source search for UI Bundles",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "author": "",