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