@salesforce/afv-skills 1.50.0 → 1.51.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.
Files changed (29) hide show
  1. package/package.json +1 -1
  2. package/skills/experience-ui-bundle-deploy/SKILL.md +43 -2
  3. package/skills/experience-ui-bundle-deploy/references/config-scaffold.md +16 -3
  4. package/skills/experience-ui-bundle-deploy/references/logout-url.md +97 -0
  5. package/skills/experience-ui-bundle-deploy/scripts/set-logout-url.mjs +304 -0
  6. package/skills/experience-ui-bundle-localize/SKILL.md +107 -90
  7. package/skills/experience-ui-bundle-localize/references/angular/check-i18n-wired.sh +159 -0
  8. package/skills/experience-ui-bundle-localize/references/angular/i18n-setup.md +250 -0
  9. package/skills/experience-ui-bundle-localize/references/angular/interpolation.md +156 -0
  10. package/skills/experience-ui-bundle-localize/references/angular/localize.md +111 -0
  11. package/skills/experience-ui-bundle-localize/references/{gotchas.md → common/gotchas.md} +27 -43
  12. package/skills/experience-ui-bundle-localize/references/{label-xml.md → common/label-xml.md} +27 -17
  13. package/skills/experience-ui-bundle-localize/references/common/platform-sdk-i18n.md +169 -0
  14. package/skills/experience-ui-bundle-localize/references/{verifying.md → common/verifying.md} +26 -16
  15. package/skills/experience-ui-bundle-localize/{scripts → references/react}/check-i18n-wired.sh +8 -3
  16. package/skills/experience-ui-bundle-localize/references/{i18n-setup.md → react/i18n-setup.md} +48 -10
  17. package/skills/experience-ui-bundle-localize/references/{interpolation.md → react/interpolation.md} +4 -4
  18. package/skills/experience-ui-bundle-localize/references/react/localize.md +76 -0
  19. package/skills/experience-ui-bundle-localize/scripts/check-manifest-registered.sh +92 -24
  20. package/skills/experience-ui-bundle-localize/scripts/detect-framework.sh +73 -0
  21. package/skills/experience-ui-bundle-site-generate/SKILL.md +1 -1
  22. package/skills/service-digital-engagement-channel-configure/SKILL.md +20 -37
  23. package/skills/service-digital-engagement-channel-configure/assets/messaging_channel_template.xml +3 -2
  24. package/skills/service-digital-engagement-channel-configure/examples/asa_agent_channel.xml +4 -4
  25. package/skills/service-helpagent-coordinate/SKILL.md +37 -29
  26. package/skills/service-helpagent-coordinate/assets/help-agent-spec.md +33 -28
  27. package/skills/service-helpagent-coordinate/references/agent-script.md +4 -1
  28. package/skills/service-helpagent-coordinate/references/channel-voice.md +1 -1
  29. package/skills/service-helpagent-coordinate/references/channel-web-chat.md +10 -12
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/afv-skills",
3
- "version": "1.50.0",
3
+ "version": "1.51.0",
4
4
  "description": "Salesforce skills for Agentforce Vibes",
5
5
  "license": "CC-BY-NC-4.0",
6
6
  "files": [
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  name: experience-ui-bundle-deploy
3
- description: "MUST activate when the project has a uiBundles/*/src/ directory and the task involves deploying to an org or post-deploy org setup. Deploys a UI bundle app and runs ordered setup: org auth, build, metadata deploy, permission-set and role assignment, Experience Cloud self-registration, social login / SSO / IDP linking (Auth Providers + SAML SSO configs on a React site), seed-data import, and GraphQL schema fetch + codegen. Trigger signals: *.network-meta.xml, org-setup.config.json (with a socialLogin block), data-plan.json, sfdx-project.json, or mentions of deploy, org setup, or social login / SSO / IDP linking on an Experience site. DO NOT TRIGGER when: creating a new UI bundle project (use experience-ui-bundle-project-generate); styling pages without deploying (use experience-ui-bundle-frontend-generate); adding a feature such as auth, search, or file upload without deploying (use the matching experience-ui-bundle-*-generate skill); configuring MFA permission sets (use experience-ui-bundle-mfa-configure)."
3
+ description: "MUST activate when the project has a uiBundles/*/src/ directory and the task involves deploying to an org or post-deploy org setup. Deploys a UI bundle app and runs ordered setup: org auth, build, metadata deploy, permission-set and role assignment, Experience Cloud self-registration, social login / SSO / IDP linking (Auth Providers + SAML on a React site), site logout URL, seed-data import, and GraphQL schema fetch + codegen. Trigger signals: *.network-meta.xml, org-setup.config.json (socialLogin/logoutUrl), data-plan.json, sfdx-project.json, or mentions of deploy, org setup, social login/SSO, or logout URL on an Experience site. DO NOT TRIGGER when: creating a new UI bundle project (use experience-ui-bundle-project-generate); styling pages without deploying (use experience-ui-bundle-frontend-generate); adding a feature such as auth, search, or file upload without deploying (use the matching experience-ui-bundle-*-generate skill); configuring MFA permission sets (use experience-ui-bundle-mfa-configure)."
4
4
  metadata:
5
- version: "1.2"
5
+ version: "1.3"
6
6
  domains: ["Experience", "Developer Experience"]
7
7
  relatedSkills:
8
8
  - "experience-ui-bundle-frontend-generate"
@@ -112,6 +112,46 @@ of this flow.
112
112
  Timeout 180s. Must complete before permission assignment and schema fetch —
113
113
  objects, fields, and permission sets appear in the org only after deploy.
114
114
 
115
+ ## Step 3b — Set the site logout URL (config-gated)
116
+
117
+ Run only when `org-setup.config.json` has a top-level `logoutUrl`. If absent,
118
+ no-op cleanly and say so. **Non-destructive and idempotent** — no ask needed.
119
+
120
+ Runs **here, after the deploy** (not folded into it) because the platform rejects
121
+ a relative logout URL (*"The logout page URL must be an absolute URL."*), and a
122
+ shipped site-relative path (e.g. `/propertyrentalapp/`) is resolved to absolute
123
+ against the site's Experience Cloud origin — which only exists once the site is
124
+ deployed.
125
+
126
+ Why it matters: a site with no `<logoutUrl>` sends a logging-out member to the
127
+ **org default-site login page** — in a multi-site org that's a *different*
128
+ community, so Sign Out lands on the wrong site's login page. Setting it steers
129
+ logout back to this site (reload as Guest).
130
+
131
+ Steps:
132
+
133
+ 1. **Read** `logoutUrl` from config; absent → skip.
134
+ 2. **Derive the site** — `scripts/derive-site-name.sh` (single `*.network-meta.xml`;
135
+ skip if zero/ambiguous).
136
+ 3. **Resolve, set, and deploy — one command.** Invoke the helper, which resolves
137
+ the value to an absolute URL (site-relative → matched against the community
138
+ `siteUrl` **path**, never guessing), writes `<logoutUrl>` idempotently in the
139
+ canonical position, and deploys only that file:
140
+
141
+ ```bash
142
+ node scripts/set-logout-url.mjs \
143
+ --logout-url "<logoutUrl from config>" \
144
+ --network-file <sourceRoot>/networks/<site>.network-meta.xml \
145
+ --target-org <org> --site <site> --deploy
146
+ ```
147
+
148
+ **Best-effort:** the helper exits **0** on success or already-set, and **3** on a
149
+ recoverable skip (network file missing, origin unresolvable, XML-special char, or
150
+ deploy failure). Treat **exit 3 as a loud skip, not a setup failure** — continue
151
+ the rest of setup and tell the user to set the logout URL manually in the site's
152
+ Administration settings. Usage, exit-code contract, and port-provenance:
153
+ `references/logout-url.md`.
154
+
115
155
  ## Step 4 — Assign permission sets
116
156
 
117
157
  Discover permission sets under `<packageDir>/main/default/permissionsets/`. If
@@ -305,6 +345,7 @@ developer action, not part of setup; if the user asks to preview the site, see
305
345
  1. Authenticate org
306
346
  2. Build UI bundles (pre-deploy)
307
347
  3. Deploy metadata (deploy-license gate if self-reg configured)
348
+ 3b. Set the site logout URL (if `logoutUrl` configured — post-deploy, idempotent, best-effort)
308
349
  4. Assign permission sets (config-driven assignee)
309
350
  5. Assign role (if configured)
310
351
  6. Enable self-registration (if configured — ask first)
@@ -35,7 +35,8 @@ Every top-level key is optional; each object is strict (no extra keys).
35
35
  "accountName": "<display name>" },
36
36
  "socialLogin": { "communityMemberProfile": "<Profile name>",
37
37
  "authProviderNames": ["<DeveloperName>", "..."],
38
- "communityUserPermset": "<PermissionSet API name>" }
38
+ "communityUserPermset": "<PermissionSet API name>" },
39
+ "logoutUrl": "/<site-path>/ or https://<absolute-url>"
39
40
  }
40
41
  ```
41
42
 
@@ -60,6 +61,14 @@ Every top-level key is optional; each object is strict (no extra keys).
60
61
  `selfRegistration`, there is **no `siteName`** — the site is derived from the
61
62
  single `networks/<siteName>.network-meta.xml`. Full step detail:
62
63
  `references/social-login.md`.
64
+ - **`logoutUrl`** (optional) drives step 3b. A **site-relative path** (e.g.
65
+ `/propertyrentalapp/`, recommended — domain-independent) or an absolute
66
+ `http(s)` URL. Setup resolves a relative value to the absolute URL the platform
67
+ requires (against the site's Experience Cloud origin) and writes it to the
68
+ site's `<logoutUrl>` Network metadata after deploy, so Sign Out returns members
69
+ to *this* site instead of the org default site. No `siteName` — the site is
70
+ derived from the single `networks/<siteName>.network-meta.xml`. Full step
71
+ detail: `references/logout-url.md`.
63
72
 
64
73
  ## Scaffolding flow (permset folder present, config missing)
65
74
 
@@ -68,8 +77,12 @@ Every top-level key is optional; each object is strict (no extra keys).
68
77
  2. **Ask the user, per permset, who to assign it to** — `currentUser`,
69
78
  `guestUser`, or `skip`. Default a whole-set answer to `currentUser` (not
70
79
  `skip`) since the folder exists on purpose; only use `guestUser` for permsets
71
- meant for the site's guest user. Also ask whether they want a `role` and/or
72
- `selfRegistration` block while you're writing the file (both optional).
80
+ meant for the site's guest user. Also ask whether they want a `role`,
81
+ `selfRegistration`, and/or `logoutUrl` block while you're writing the file (all
82
+ optional). For `logoutUrl`, phrase it as "point Sign Out back to this site?" —
83
+ if yes, default the value to the site-relative path `/<site-path>/` (step 3b
84
+ resolves it to absolute post-deploy). Skipping it leaves logout on the org
85
+ default site (see the `logoutUrl` note above).
73
86
  3. **Offer two equivalent paths** — let the user pick:
74
87
  - **Write the file**: copy `assets/org-setup.config.template.json` to
75
88
  `org-setup.config.json` at the project root and fill in the answers. This is
@@ -0,0 +1,97 @@
1
+ # Logout URL — steer Sign Out back to this site
2
+
3
+ Detail for the **logoutUrl** step (SKILL.md step 3b). The step is executed by the
4
+ deterministic helper `scripts/set-logout-url.mjs` (a faithful port of the
5
+ reference `org-setup.mjs` logout-URL logic); this file covers **why** it exists
6
+ and **how** to run it, not a re-specification of the algorithm.
7
+
8
+ Source of truth for the behavior: reference `org-setup.mjs` `ensureLogoutUrl`
9
+ (1103-1169) and the `main()` logout-URL step (2137-2148); the pure resolution
10
+ helpers in `org-setup-url.mjs` (`isAbsoluteLogoutUrl`, `firstPathSegment`,
11
+ `pickCommunityBaseUrl`, `resolveLogoutUrl`) and the XML helpers in
12
+ `org-setup-xml.mjs` (`assertSafeLogoutUrl`, `setLogoutUrl`) — all reproduced in
13
+ `scripts/set-logout-url.mjs`. Config schema: `org-setup-config-schema.mjs`
14
+ (93, `logoutUrl: z.string().min(1).optional()`).
15
+
16
+ Provenance: this step ports the "logout redirects to the login page of a
17
+ different site" fix, added to `org-setup.mjs` but not previously reflected in this
18
+ skill.
19
+
20
+ Run this step **only** when a top-level `logoutUrl` is set in
21
+ `org-setup.config.json`. If it is absent, the step is a clean no-op — say so.
22
+ Unlike self-registration and data import, it is **non-destructive and
23
+ idempotent**, so it does **not** require asking first.
24
+
25
+ ## Why it matters
26
+
27
+ An Experience Cloud site with no `<logoutUrl>` on its Network sends a logging-out
28
+ member to the **org default-site login page**. In an org hosting more than one
29
+ Experience/CLWR site, that default is a *different* community — so Sign Out drops
30
+ the user on the wrong site's login page. Setting `<logoutUrl>` to this site's own
31
+ URL steers logout back here (the user reloads as Guest on the same site).
32
+
33
+ ## Why it runs AFTER deploy (step 3b, not folded into deploy)
34
+
35
+ The platform **rejects a relative logout URL** at deploy time: *"The logout page
36
+ URL must be an absolute URL."* But a shipped template must stay
37
+ domain-independent, so apps ship a **site-relative** path (e.g.
38
+ `"logoutUrl": "/propertyrentalapp/"`) that is valid on every org they deploy to.
39
+ Resolving that to the absolute URL the platform requires needs the site's
40
+ Experience Cloud **origin**, which is only discoverable (via the Connect
41
+ communities API) once the site exists — i.e. after the main deploy. An
42
+ already-absolute config value (`http(s)://…`) is used as-is, with no lookup.
43
+
44
+ ## How to run
45
+
46
+ ```bash
47
+ node scripts/set-logout-url.mjs \
48
+ --logout-url "<logoutUrl from org-setup.config.json>" \
49
+ --network-file <sourceRoot>/networks/<site>.network-meta.xml \
50
+ --target-org <org> \
51
+ --site <site> \
52
+ --deploy
53
+ ```
54
+
55
+ Derive `<site>` first with `scripts/derive-site-name.sh` (it prints the base name
56
+ of the single `*.network-meta.xml`, or exits non-zero when zero or more than one
57
+ exist — an ambiguous site is skipped, never guessed). Omit `--deploy` to resolve
58
+ and write the file **without** deploying (e.g. to inspect the change first).
59
+
60
+ ### What the helper does (so you don't re-derive it)
61
+
62
+ - **Resolves** the config value to an absolute URL: already-absolute → used
63
+ verbatim; site-relative → its origin is matched from the org's communities
64
+ (`/connect/communities`, at the org's current API version), **path-first** then by site name, and it
65
+ refuses to fall back to an arbitrary community (resolving against the wrong
66
+ origin would silently produce a valid-but-wrong URL).
67
+ - **Writes** `<logoutUrl>` into the network metadata idempotently — replaces an
68
+ existing value in place, or inserts a new node in the canonical alphabetical
69
+ position; an already-correct value is a byte-for-byte no-op (no deploy).
70
+ - **Rejects** a URL containing an XML-special character (`& < > " '`) rather than
71
+ escaping it.
72
+ - **Deploys** only the one network file (with `--deploy`).
73
+
74
+ ### Exit-code contract (best-effort)
75
+
76
+ | Exit | Meaning | What the step should do |
77
+ |------|---------|-------------------------|
78
+ | `0` | Applied (changed), already-correct (unchanged), or deployed | Report success; continue. |
79
+ | `3` | **Recoverable skip** — network file missing, origin unresolvable, XML-special char, or deploy failed | **Do not treat as a setup failure.** Continue the rest of setup; tell the user to set the logout URL manually. |
80
+ | `1` | Usage / argument error | Fix the invocation. |
81
+
82
+ On a skip, tell the user to **set the logout URL manually in the site's
83
+ Administration settings** (Setup → Digital Experiences → the site →
84
+ Administration → Login & Registration → Logout URL).
85
+
86
+ ## Config shape
87
+
88
+ ```json
89
+ { "logoutUrl": "/propertyrentalapp/" }
90
+ ```
91
+
92
+ - Top-level key (a sibling of `permsetAssignments` / `socialLogin`), optional.
93
+ - A **site-relative path** (recommended — domain-independent) or an absolute
94
+ `http(s)` URL. A relative value is resolved to absolute at deploy time; an
95
+ absolute value is used as-is.
96
+ - Like `selfRegistration` / `socialLogin`, there is **no `siteName`** — the site
97
+ is derived from the single `networks/<site>.network-meta.xml`.
@@ -0,0 +1,304 @@
1
+ #!/usr/bin/env node
2
+ // Resolve + set the Experience Cloud site logout URL (deploy step 3b).
3
+ //
4
+ // Deterministic helper for the "site logout URL" step: it resolves the shipped
5
+ // (site-relative or absolute) logoutUrl config value to the ABSOLUTE URL the
6
+ // platform requires, writes it idempotently into the site's network metadata,
7
+ // and (optionally) deploys just that file. The SKILL.md step invokes this
8
+ // instead of re-deriving the algorithm in prose. Faithful port of the reference
9
+ // org-setup.mjs helpers (org-setup-url.mjs + org-setup-xml.mjs).
10
+ //
11
+ // Why absolute + post-deploy: the platform rejects a relative logout URL at
12
+ // deploy time ("The logout page URL must be an absolute URL."), and a shipped
13
+ // site-relative path is resolved against the site's Experience Cloud origin,
14
+ // which only exists once the site is deployed.
15
+ //
16
+ // Usage:
17
+ // node scripts/set-logout-url.mjs \
18
+ // --logout-url "/myapp/" \
19
+ // --network-file <sourceRoot>/networks/<site>.network-meta.xml \
20
+ // --target-org <org> \
21
+ // [--site <siteName>] [--deploy]
22
+ //
23
+ // Exit codes:
24
+ // 0 applied (changed), already-correct (unchanged), or deployed
25
+ // 3 best-effort SKIP (file missing / origin unresolvable / XML-special char /
26
+ // deploy failure) — NOT a setup failure; the caller logs loudly and continues
27
+ // 1 usage / argument error
28
+ //
29
+ // A skip (exit 3) means: tell the user to set the logout URL manually in the
30
+ // site's Administration settings (Setup -> Digital Experiences -> the site ->
31
+ // Administration -> Login & Registration -> Logout URL).
32
+
33
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
34
+ import { execFileSync } from 'node:child_process';
35
+
36
+ // Connect "communities" REST resource. Its API version is resolved from the org
37
+ // at runtime (see resolveApiVersion) so it never goes stale as versions
38
+ // increment; this pinned floor — the version used by the reference org-setup.mjs
39
+ // port — is the fallback used only when that lookup fails. This step reads just
40
+ // `siteUrl`/`name`, whose shape is stable across versions, so the exact version
41
+ // is not load-bearing.
42
+ const FALLBACK_API_VERSION = 'v62.0';
43
+ const XML_SPECIAL_CHARS = /[&<>"']/;
44
+
45
+ // ---- argument parsing -------------------------------------------------------
46
+
47
+ function parseArgs(argv) {
48
+ const args = { deploy: false };
49
+ for (let i = 0; i < argv.length; i += 1) {
50
+ const a = argv[i];
51
+ switch (a) {
52
+ case '--logout-url': args.logoutUrl = argv[++i]; break;
53
+ case '--network-file': args.networkFile = argv[++i]; break;
54
+ case '--target-org': args.targetOrg = argv[++i]; break;
55
+ case '--site': args.site = argv[++i]; break;
56
+ case '--deploy': args.deploy = true; break;
57
+ default:
58
+ fail(1, `unknown argument: ${a}`);
59
+ }
60
+ }
61
+ if (!args.logoutUrl) fail(1, 'missing --logout-url');
62
+ if (!args.networkFile) fail(1, 'missing --network-file');
63
+ if (!args.targetOrg) fail(1, 'missing --target-org');
64
+ return args;
65
+ }
66
+
67
+ function fail(code, message) {
68
+ console.error(`set-logout-url: ${message}`);
69
+ process.exit(code);
70
+ }
71
+
72
+ function skip(message) {
73
+ console.error(
74
+ `set-logout-url: SKIP — ${message}. Set the logout URL manually in the site's ` +
75
+ `Administration settings (Login & Registration -> Logout URL).`,
76
+ );
77
+ process.exit(3);
78
+ }
79
+
80
+ // ---- pure URL helpers (port of org-setup-url.mjs) ---------------------------
81
+
82
+ /** True when `value` is an absolute http(s) URL — already deployable as-is. */
83
+ function isAbsoluteLogoutUrl(value) {
84
+ return /^https?:\/\//i.test(String(value).trim());
85
+ }
86
+
87
+ /** Leading path segment of a URL or path: "/app/" -> "app", "/" -> "". */
88
+ function firstPathSegment(pathOrUrl) {
89
+ let pathname;
90
+ try {
91
+ pathname = new URL(pathOrUrl).pathname; // absolute URL
92
+ } catch {
93
+ pathname = String(pathOrUrl).split(/[?#]/)[0]; // relative path
94
+ }
95
+ return pathname.replace(/^\/+/, '').split('/')[0];
96
+ }
97
+
98
+ /**
99
+ * Choose the community whose siteUrl anchors a relative logout path — match on
100
+ * the siteUrl PATH first, then on `name` == siteName. Never guesses an arbitrary
101
+ * community (resolving against the wrong origin yields a valid-but-wrong URL).
102
+ */
103
+ function pickCommunityBaseUrl(communities, configLogoutUrl, siteName) {
104
+ const list = Array.isArray(communities) ? communities.filter((c) => c && c.siteUrl) : [];
105
+ const seg = firstPathSegment(configLogoutUrl);
106
+ if (seg) {
107
+ const byPath = list.find((c) => firstPathSegment(c.siteUrl) === seg);
108
+ if (byPath) return byPath.siteUrl;
109
+ }
110
+ if (siteName) {
111
+ const byName = list.find((c) => c.name === siteName);
112
+ if (byName) return byName.siteUrl;
113
+ }
114
+ return null;
115
+ }
116
+
117
+ /** Resolve a config value to an absolute URL (relative -> against baseUrl origin). */
118
+ function resolveLogoutUrl(configLogoutUrl, baseUrl) {
119
+ const value = String(configLogoutUrl).trim();
120
+ if (isAbsoluteLogoutUrl(value)) return value;
121
+ if (!baseUrl) {
122
+ throw new Error(
123
+ `logout URL "${configLogoutUrl}" is site-relative but no Experience Cloud community ` +
124
+ `site URL was found to resolve it into an absolute URL`,
125
+ );
126
+ }
127
+ let resolved;
128
+ try {
129
+ resolved = new URL(value, baseUrl).href;
130
+ } catch (e) {
131
+ throw new Error(`could not resolve "${configLogoutUrl}" against "${baseUrl}": ${e.message}`);
132
+ }
133
+ if (!isAbsoluteLogoutUrl(resolved)) {
134
+ throw new Error(`resolved logout URL "${resolved}" is not an absolute http(s) URL`);
135
+ }
136
+ return resolved;
137
+ }
138
+
139
+ // ---- XML helpers (port of org-setup-xml.mjs setLogoutUrl) -------------------
140
+
141
+ /** Direct child element names of <Network> (depth-aware, no XML deps). */
142
+ function networkChildNames(xml) {
143
+ const open = xml.match(/<Network\b[^>]*>/);
144
+ const close = xml.lastIndexOf('</Network>');
145
+ if (!open || close === -1) return [];
146
+ const inner = xml.slice(open.index + open[0].length, close);
147
+ const tag = /<(\/?)([A-Za-z_][\w.-]*)\b[^>]*?(\/?)>/g;
148
+ const names = new Set();
149
+ let depth = 0;
150
+ let m;
151
+ while ((m = tag.exec(inner)) !== null) {
152
+ const [, closing, name, selfClose] = m;
153
+ if (closing) {
154
+ depth -= 1;
155
+ } else if (selfClose) {
156
+ if (depth === 0) names.add(name);
157
+ } else {
158
+ if (depth === 0) names.add(name);
159
+ depth += 1;
160
+ }
161
+ }
162
+ return [...names];
163
+ }
164
+
165
+ function firstChildIndent(xml) {
166
+ const m = xml.match(/\n([ \t]+)<[A-Za-z_]/);
167
+ return m ? m[1] : ' ';
168
+ }
169
+
170
+ /**
171
+ * Returns { xml, changed }. Idempotent (unchanged when already equal); replaces
172
+ * an existing value in place; else inserts <logoutUrl> in the canonical
173
+ * alphabetical position among Network's top-level children. Throws on an
174
+ * XML-special char in the URL (reject, don't escape).
175
+ */
176
+ function setLogoutUrl(xml, url) {
177
+ if (XML_SPECIAL_CHARS.test(String(url))) {
178
+ throw new Error(`logoutUrl "${url}" contains an XML-special character (& < > " ')`);
179
+ }
180
+ const node = `<logoutUrl>${url}</logoutUrl>`;
181
+ const existing = xml.match(/<logoutUrl>([^<]*)<\/logoutUrl>/);
182
+ if (existing) {
183
+ if (existing[1] === url) return { xml, changed: false };
184
+ return { xml: xml.replace(/<logoutUrl>[^<]*<\/logoutUrl>/, node), changed: true };
185
+ }
186
+ // Absent — insert before the first top-level sibling that sorts after "logoutUrl".
187
+ const successor = networkChildNames(xml)
188
+ .filter((name) => name > 'logoutUrl')
189
+ .sort()[0];
190
+ if (successor) {
191
+ const beforeSuccessor = new RegExp(`(\\n[ \\t]*)(<${successor}\\b)`);
192
+ if (beforeSuccessor.test(xml)) {
193
+ return { xml: xml.replace(beforeSuccessor, `$1${node}$1$2`), changed: true };
194
+ }
195
+ }
196
+ const indent = firstChildIndent(xml);
197
+ const beforeClose = /(\n)([ \t]*<\/Network>)/;
198
+ if (beforeClose.test(xml)) {
199
+ return { xml: xml.replace(beforeClose, `\n${indent}${node}$1$2`), changed: true };
200
+ }
201
+ return { xml: xml.replace(/(<Network\b[^>]*>)/, `$1\n${indent}${node}`), changed: true };
202
+ }
203
+
204
+ // ---- org I/O ----------------------------------------------------------------
205
+
206
+ /**
207
+ * Resolve the org's highest supported REST API version (e.g. "v66.0") via the
208
+ * version-less `/services/data/` endpoint. Falls back to FALLBACK_API_VERSION
209
+ * if the lookup fails or returns nothing usable, so resolution is never worse
210
+ * than a fixed pin.
211
+ */
212
+ function resolveApiVersion(targetOrg) {
213
+ try {
214
+ const out = execFileSync(
215
+ 'sf',
216
+ ['api', 'request', 'rest', '/services/data/', '--target-org', targetOrg],
217
+ { encoding: 'utf8', timeout: 120000 },
218
+ );
219
+ const versions = JSON.parse(out);
220
+ const max = Array.isArray(versions)
221
+ ? versions
222
+ .map((v) => parseFloat(v && v.version))
223
+ .filter((n) => !Number.isNaN(n))
224
+ .sort((a, b) => a - b)
225
+ .pop()
226
+ : undefined;
227
+ if (max) return `v${max.toFixed(1)}`;
228
+ } catch {
229
+ // Unreachable org, unexpected shape, etc. — fall through to the pinned floor.
230
+ }
231
+ return FALLBACK_API_VERSION;
232
+ }
233
+
234
+ function fetchCommunities(targetOrg) {
235
+ const apiVersion = resolveApiVersion(targetOrg);
236
+ const out = execFileSync(
237
+ 'sf',
238
+ ['api', 'request', 'rest', `/services/data/${apiVersion}/connect/communities`, '--target-org', targetOrg],
239
+ { encoding: 'utf8', timeout: 120000 },
240
+ );
241
+ const parsed = JSON.parse(out);
242
+ return Array.isArray(parsed.communities) ? parsed.communities : [];
243
+ }
244
+
245
+ function deployNetworkFile(networkFile, targetOrg) {
246
+ execFileSync(
247
+ 'sf',
248
+ ['project', 'deploy', 'start', '--source-dir', networkFile, '--target-org', targetOrg],
249
+ { stdio: 'inherit', timeout: 120000 },
250
+ );
251
+ }
252
+
253
+ // ---- main -------------------------------------------------------------------
254
+
255
+ function main() {
256
+ const args = parseArgs(process.argv.slice(2));
257
+
258
+ if (!existsSync(args.networkFile)) {
259
+ skip(`network metadata not found: ${args.networkFile}`);
260
+ }
261
+
262
+ // 1. Resolve to an absolute URL.
263
+ let absoluteUrl;
264
+ try {
265
+ let baseUrl = null;
266
+ if (!isAbsoluteLogoutUrl(args.logoutUrl)) {
267
+ baseUrl = pickCommunityBaseUrl(fetchCommunities(args.targetOrg), args.logoutUrl, args.site);
268
+ }
269
+ absoluteUrl = resolveLogoutUrl(args.logoutUrl, baseUrl);
270
+ } catch (e) {
271
+ skip(`cannot resolve an absolute logout URL — ${e.message}`);
272
+ }
273
+
274
+ // 2. Write it into the network metadata (idempotent).
275
+ let result;
276
+ try {
277
+ result = setLogoutUrl(readFileSync(args.networkFile, 'utf8'), absoluteUrl);
278
+ } catch (e) {
279
+ skip(`cannot set <logoutUrl> in ${args.networkFile} — ${e.message}`);
280
+ }
281
+
282
+ if (!result.changed) {
283
+ console.log(`logout URL already set to "${absoluteUrl}"; no change (skipping deploy).`);
284
+ process.exit(0);
285
+ }
286
+ writeFileSync(args.networkFile, result.xml);
287
+ console.log(`set <logoutUrl>${absoluteUrl}</logoutUrl> in ${args.networkFile}`);
288
+
289
+ // 3. Deploy only that file (best-effort).
290
+ if (args.deploy) {
291
+ try {
292
+ deployNetworkFile(args.networkFile, args.targetOrg);
293
+ console.log(`deployed <logoutUrl> for "${args.site || args.networkFile}".`);
294
+ } catch (e) {
295
+ skip(
296
+ `failed to deploy the network file (${e.message}). If the org's Network ` +
297
+ `emailSenderAddress differs from the shipped value it can block this deploy`,
298
+ );
299
+ }
300
+ }
301
+ process.exit(0);
302
+ }
303
+
304
+ main();