@salesforce/templates 66.13.0 → 66.13.2

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 (37) hide show
  1. package/lib/templates/project/angularextapp/CHANGELOG.md +129 -0
  2. package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/.graphqlrc.yml +2 -0
  3. package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/angular.json +3 -1
  4. package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/codegen.yml +95 -0
  5. package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/esbuild/graphql-codegen.mjs +13 -0
  6. package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/package-lock.json +2495 -294
  7. package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/package.json +9 -2
  8. package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/scripts/get-graphql-schema.mjs +69 -0
  9. package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/src/app/api/account/account-search.service.ts +52 -140
  10. package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/src/app/api/graphql-operations-types.ts +29389 -0
  11. package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/src/app/features/object-search/api/object-search.service.ts +94 -0
  12. package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/src/app/features/object-search/utils/sort-utils.ts +3 -10
  13. package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/src/app/pages/account-search/account-search-page.ts +15 -8
  14. package/lib/templates/project/angularextapp/package.json +1 -1
  15. package/lib/templates/project/angularextapp/scripts/org-setup-utils.mjs +206 -36
  16. package/lib/templates/project/angularextapp/scripts/org-setup.mjs +180 -69
  17. package/lib/templates/project/angularintapp/CHANGELOG.md +129 -0
  18. package/lib/templates/project/angularintapp/_p_/_m_/_w_/_a_/.graphqlrc.yml +2 -0
  19. package/lib/templates/project/angularintapp/_p_/_m_/_w_/_a_/angular.json +3 -1
  20. package/lib/templates/project/angularintapp/_p_/_m_/_w_/_a_/codegen.yml +95 -0
  21. package/lib/templates/project/angularintapp/_p_/_m_/_w_/_a_/esbuild/graphql-codegen.mjs +13 -0
  22. package/lib/templates/project/angularintapp/_p_/_m_/_w_/_a_/package-lock.json +2498 -297
  23. package/lib/templates/project/angularintapp/_p_/_m_/_w_/_a_/package.json +9 -2
  24. package/lib/templates/project/angularintapp/_p_/_m_/_w_/_a_/scripts/get-graphql-schema.mjs +69 -0
  25. package/lib/templates/project/angularintapp/_p_/_m_/_w_/_a_/src/app/api/account/account-search.service.ts +52 -140
  26. package/lib/templates/project/angularintapp/_p_/_m_/_w_/_a_/src/app/api/graphql-operations-types.ts +29389 -0
  27. package/lib/templates/project/angularintapp/_p_/_m_/_w_/_a_/src/app/features/object-search/api/object-search.service.ts +94 -0
  28. package/lib/templates/project/angularintapp/_p_/_m_/_w_/_a_/src/app/features/object-search/utils/sort-utils.ts +3 -10
  29. package/lib/templates/project/angularintapp/_p_/_m_/_w_/_a_/src/app/pages/account-search/account-search-page.ts +15 -8
  30. package/lib/templates/project/angularintapp/package.json +1 -1
  31. package/lib/templates/project/angularintapp/scripts/org-setup-utils.mjs +206 -36
  32. package/lib/templates/project/angularintapp/scripts/org-setup.mjs +180 -69
  33. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/package-lock.json +1808 -1804
  34. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/package-lock.json +1808 -1804
  35. package/lib/templates/uiBundles/angularbasic/package-lock.json +2382 -272
  36. package/lib/templates/uiBundles/reactbasic/package-lock.json +1808 -1804
  37. package/package.json +2 -2
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Copyright (c) 2026, Salesforce, Inc.,
3
+ * All rights reserved.
4
+ * For full license text, see the LICENSE.txt file
5
+ */
6
+ import { Injectable, inject } from "@angular/core";
7
+ import { DataClient } from "../../../api/data-client.service";
8
+
9
+ /** Search args */
10
+ export interface ObjectSearchOptions<TWhere, TOrderBy> {
11
+ where?: TWhere;
12
+ orderBy?: TOrderBy;
13
+ first?: number;
14
+ after?: string;
15
+ }
16
+
17
+ /** Picklist option shape shared with filter components. */
18
+ export type PicklistOption = { value: string; label: string };
19
+
20
+ /**
21
+ * Generic object-search data service
22
+ *
23
+ * Provides reusable, object-agnostic methods for:
24
+ * - Searching objects via GraphQL `uiapi.query.<ObjectName>`
25
+ * - Fetching distinct picklist values via `uiapi.aggregate.<ObjectName>`
26
+ *
27
+ * Domain-specific services (e.g. `AccountSearchService`) delegate here with
28
+ * typed parameters rather than reimplementing the query/extraction logic.
29
+ */
30
+ @Injectable({ providedIn: "root" })
31
+ export class ObjectSearchService {
32
+ private readonly client = inject(DataClient);
33
+
34
+ /**
35
+ * Executes a GraphQL search query and extracts the result for the given
36
+ * object name from the standard `uiapi.query.<ObjectName>` response shape.
37
+ */
38
+ async searchObjects<TResult, TQuery, TVariables>(
39
+ query: string,
40
+ objectName: string,
41
+ options: ObjectSearchOptions<unknown, unknown> = {},
42
+ ): Promise<TResult> {
43
+ const { where, orderBy, first = 20, after } = options;
44
+ const data = await this.client.execute<TQuery, TVariables>(query, {
45
+ first,
46
+ after,
47
+ where,
48
+ orderBy,
49
+ } as TVariables);
50
+
51
+ const uiapi = (data as Record<string, unknown>)?.["uiapi"] as
52
+ | Record<string, unknown>
53
+ | undefined;
54
+ const queryResult = (uiapi?.["query"] as Record<string, unknown> | undefined)?.[objectName] as
55
+ | TResult
56
+ | undefined;
57
+
58
+ if (!queryResult) {
59
+ throw new Error(`No ${objectName} data returned`);
60
+ }
61
+ return queryResult;
62
+ }
63
+
64
+ /**
65
+ * Executes a GraphQL aggregate/groupBy query and extracts picklist options
66
+ * from the standard `uiapi.aggregate.<ObjectName>` response shape.
67
+ */
68
+ async fetchDistinctValues<TQuery>(
69
+ query: string,
70
+ objectName: string,
71
+ fieldName: string,
72
+ ): Promise<PicklistOption[]> {
73
+ const data = await this.client.execute<TQuery>(query);
74
+
75
+ const uiapi = (data as Record<string, unknown>)?.["uiapi"] as
76
+ | Record<string, unknown>
77
+ | undefined;
78
+ const aggregate = (uiapi?.["aggregate"] as Record<string, unknown> | undefined)?.[
79
+ objectName
80
+ ] as { edges?: Array<{ node?: { aggregate?: Record<string, unknown> } }> } | undefined;
81
+
82
+ const edges = aggregate?.edges ?? [];
83
+ return edges
84
+ .map((edge) => {
85
+ const field = edge?.node?.aggregate?.[fieldName] as
86
+ | { value?: string | null; displayValue?: string | null; label?: string | null }
87
+ | undefined;
88
+ const value = field?.value;
89
+ if (!value) return null;
90
+ return { value, label: field.label ?? field.displayValue ?? value };
91
+ })
92
+ .filter((opt): opt is PicklistOption => opt !== null);
93
+ }
94
+ }
@@ -4,14 +4,7 @@
4
4
  * For full license text, see the LICENSE.txt file
5
5
  */
6
6
 
7
- // NOTE: The React reference imports `ResultOrder` / `NullOrder` from generated
8
- // GraphQL types (graphql-operations-types.ts). Codegen is parked for the
9
- // Angular port, so the enum string values are inlined here. When codegen lands,
10
- // swap these constants for the generated enums.
11
- /** GraphQL `ResultOrder` values. */
12
- const RESULT_ORDER = { asc: "ASC", desc: "DESC" } as const;
13
- /** GraphQL `NullOrder` values. */
14
- const NULL_ORDER = { first: "FIRST", last: "LAST" } as const;
7
+ import { ResultOrder, NullOrder } from "../../../api/graphql-operations-types";
15
8
 
16
9
  export interface SortFieldConfig<TFieldName extends string = string> {
17
10
  field: TFieldName;
@@ -39,8 +32,8 @@ export function buildOrderBy<TOrderBy>(sort: SortState | null): TOrderBy | undef
39
32
  if (!sort) return undefined;
40
33
  return {
41
34
  [sort.field]: {
42
- order: sort.direction === "ASC" ? RESULT_ORDER.asc : RESULT_ORDER.desc,
43
- nulls: NULL_ORDER.last,
35
+ order: sort.direction === "ASC" ? ResultOrder.Asc : ResultOrder.Desc,
36
+ nulls: NullOrder.Last,
44
37
  },
45
38
  } as TOrderBy;
46
39
  }
@@ -31,7 +31,11 @@ import { createAsyncData } from "../../utils/async-data";
31
31
  import { fieldValue } from "../../features/object-search/utils/field-utils";
32
32
  import type { FilterFieldConfig } from "../../features/object-search/utils/filter-utils";
33
33
  import type { SortFieldConfig } from "../../features/object-search/utils/sort-utils";
34
- import { AccountSearchService, type AccountNode } from "../../api/account/account-search.service";
34
+ import {
35
+ AccountSearchService,
36
+ type AccountSearchNode,
37
+ type AccountSearchOptions,
38
+ } from "../../api/account/account-search.service";
35
39
 
36
40
  /** Cursor-pagination config for the account search (mirrors the React reference). */
37
41
  const PAGINATION_CONFIG: PaginationConfig = {
@@ -124,7 +128,10 @@ export class AccountSearchPageComponent {
124
128
  protected readonly filtersOpen = signal<boolean>(true);
125
129
 
126
130
  /** Derived, API-ready `where` / `orderBy` signals from the shared state. */
127
- private readonly query = this.state.init<unknown, unknown>(FILTER_CONFIGS, PAGINATION_CONFIG);
131
+ private readonly query = this.state.init<
132
+ AccountSearchOptions["where"],
133
+ AccountSearchOptions["orderBy"]
134
+ >(FILTER_CONFIGS, PAGINATION_CONFIG);
128
135
 
129
136
  /** Live page-size / cursor signals from the shared state. */
130
137
  protected readonly pageSize = this.state.pageSize;
@@ -171,9 +178,9 @@ export class AccountSearchPageComponent {
171
178
  protected readonly endCursor = computed(() => this.pageInfo()?.endCursor ?? undefined);
172
179
 
173
180
  /** Non-null account nodes from the current page's edges. */
174
- protected readonly nodes = computed<AccountNode[]>(() => {
181
+ protected readonly nodes = computed<AccountSearchNode[]>(() => {
175
182
  const edges = this.result.data()?.edges ?? [];
176
- const out: AccountNode[] = [];
183
+ const out: AccountSearchNode[] = [];
177
184
  for (const edge of edges) {
178
185
  if (edge?.node) out.push(edge.node);
179
186
  }
@@ -199,19 +206,19 @@ export class AccountSearchPageComponent {
199
206
  });
200
207
 
201
208
  /** Display helpers for the results list (mirrors `fieldValue` in the template). */
202
- protected name(node: AccountNode): string {
209
+ protected name(node: AccountSearchNode): string {
203
210
  return fieldValue(node.Name) ?? "—";
204
211
  }
205
212
 
206
- protected secondaryLine(node: AccountNode): string {
213
+ protected secondaryLine(node: AccountSearchNode): string {
207
214
  return [fieldValue(node.Industry), fieldValue(node.Type)].filter(Boolean).join(" · ") || "—";
208
215
  }
209
216
 
210
- protected phone(node: AccountNode): string {
217
+ protected phone(node: AccountSearchNode): string {
211
218
  return fieldValue(node.Phone) ?? "";
212
219
  }
213
220
 
214
- protected ownerName(node: AccountNode): string {
221
+ protected ownerName(node: AccountSearchNode): string {
215
222
  return fieldValue(node.Owner?.Name) ?? "";
216
223
  }
217
224
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/ui-bundle-template-base-sfdx-project",
3
- "version": "11.31.13",
3
+ "version": "11.37.0",
4
4
  "description": "Base SFDX project template",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "publishConfig": {
@@ -1,11 +1,11 @@
1
1
  /**
2
- * Shared helpers for the org-setup scripts (W-23043735, spec §5.2/§5.6/Q9).
2
+ * Shared helpers for the org-setup scripts.
3
3
  *
4
4
  * Factored out of org-setup.mjs so BOTH `org-setup.mjs` and `org-setup-dev.mjs` import the
5
5
  * same primitives instead of duplicating them:
6
6
  * - process runners (`run`, `runAsync`, `spawnAsync`)
7
7
  * - SFDX path + UI-bundle discovery (`resolveSfdxSource`, `discoverAllUIBundleDirs`,
8
- * `discoverUIBundleDir` — with the multi-bundle acknowledgment of §5.1 item 2)
8
+ * `discoverUIBundleDir` — with the multi-bundle acknowledgment)
9
9
  * - the interactive `--target-org` fallback (`resolveTargetOrg`) and its
10
10
  * single-select picker (`promptSelect`)
11
11
  * - pure, unit-testable logic for the license gate, SOQL-name validation, org-list
@@ -27,35 +27,207 @@ import { resolve } from 'node:path';
27
27
  // ---------------------------------------------------------------------------
28
28
 
29
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.
30
+ * Validate that a config-authored identifier is safe to interpolate into a SOQL
31
+ * `WHERE <field> = '<value>'` clause (validate-and-fail with a strict whitelist,
32
+ * NOT an escaper). These values (role name, site name,
33
+ * self-reg profile name) are developer-authored identifiers the app ships, not
34
+ * end-user data, and legitimate identifiers are drawn from letters, digits,
35
+ * spaces, hyphens, and underscores. Anything outside that set — a `'`, `\`, `=`,
36
+ * `"`, `&`, `<`, `>`, punctuation, or a control char — is treated as a config
37
+ * mistake to surface loudly rather than a value to silently escape. The
38
+ * whitelist is deliberately broader than the injection characters alone so a
39
+ * single positive rule covers every SOQL/quoting hazard at once.
40
+ *
41
+ * NOTE: this is for config *identifiers*. Free-form display values that
42
+ * legitimately contain punctuation (e.g. an Account name like `O'Brien Rentals`)
43
+ * are escaped at their call site instead — see org-setup.mjs's Account path.
44
+ *
45
+ * @param {*} value the value to validate (coerced to string)
46
+ * @param {string} fieldLabel how to name the field in an error (e.g. "roleName")
47
+ * @param {object} [opts]
48
+ * @param {string} [opts.remediation] where the author fixes it, e.g.
49
+ * "org-setup.config.json" or "the networks/<siteName>.network-meta.xml filename".
50
+ * Defaults to org-setup.config.json.
51
+ * @returns {string} the value unchanged when valid; throws otherwise.
52
+ */
53
+ export function validateSoqlName(value, fieldLabel, opts = {}) {
54
+ const { remediation = 'org-setup.config.json' } = opts;
55
+ const s = String(value);
56
+ if (!/^[A-Za-z0-9 _-]+$/.test(s)) {
57
+ throw new Error(
58
+ `${fieldLabel} "${value}" contains an unsupported character ` +
59
+ `(only letters, digits, spaces, hyphens, and underscores are allowed). ` +
60
+ `Fix it in ${remediation}.`,
61
+ );
62
+ }
63
+ return s;
64
+ }
65
+
66
+ /**
67
+ * Back-compat wrapper for the self-reg profile name (the original call site).
68
+ * Delegates to the strict whitelist above; kept as a named export so
69
+ * existing importers and tests need no change beyond the tightened rule.
37
70
  */
38
71
  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)) {
72
+ return validateSoqlName(name, 'selfRegProfile name');
73
+ }
74
+
75
+ /**
76
+ * Escape a free-form value for safe interpolation inside a single-quoted SOQL
77
+ * string literal. Unlike validateSoqlName, this is for display
78
+ * values that legitimately contain punctuation — e.g. an Account name like
79
+ * `O'Brien Rentals & Co.` — where rejecting the value would be wrong. Escapes
80
+ * the backslash FIRST (so an already-present `\` isn't double-counted) then the
81
+ * quote, matching SOQL's `\'` / `\\` escape rules. Returns the escaped inner
82
+ * string (no surrounding quotes — the caller supplies those).
83
+ */
84
+ export function escapeSoqlString(value) {
85
+ return String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
86
+ }
87
+
88
+ /**
89
+ * Validate a Salesforce API name (an SObject type or a field API name) that is
90
+ * interpolated as a bare identifier into generated Apex / dynamic SOQL — e.g.
91
+ * `new ${sobject}()`, `r.put('${field}', …)`, or `Database.query('SELECT Id FROM
92
+ * ${sobject} …')`. Unlike a WHERE value, an identifier can't be
93
+ * quoted-and-escaped, so the only safe treatment is a strict whitelist. Valid API
94
+ * names are letters, digits, and underscores only (the `__c` / `__r` custom and
95
+ * namespace suffixes are already covered by that set); anything else — a quote,
96
+ * space, dot, or `)` — would let a crafted seed-data field name or sobject break
97
+ * out of the surrounding statement, so it is rejected loudly. These identifiers
98
+ * come from the app-shipped data plan / seed JSON, so a violation is a fixture
99
+ * bug, not user input; this keeps the identifier hardening symmetric with the
100
+ * value escaping (apexLiteral / escapeSoqlString) already applied around them.
101
+ *
102
+ * @param {*} value the identifier to validate (coerced to string)
103
+ * @param {string} fieldLabel how to name it in an error (e.g. "sobject type")
104
+ * @returns {string} the value unchanged when valid; throws otherwise.
105
+ */
106
+ export function validateApiName(value, fieldLabel) {
107
+ const s = String(value);
108
+ if (!/^[A-Za-z0-9_]+$/.test(s)) {
43
109
  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.`,
110
+ `${fieldLabel} "${value}" is not a valid Salesforce API name ` +
111
+ `(only letters, digits, and underscores are allowed). ` +
112
+ `Check the seed data plan (data-plan.json) and record field names.`,
47
113
  );
48
114
  }
49
115
  return s;
50
116
  }
51
117
 
118
+ /**
119
+ * Parse the single `SETUP_RESULT_JSON:` marker line that buildApexInsert emits
120
+ * from anonymous Apex. Replaces the old per-record REF:/ERR: debug
121
+ * scraping. The marker carries a JSON array of `{ ref, id? , err? }` — one entry
122
+ * per inserted record.
123
+ *
124
+ * Robustness is the whole point: a debug log can be truncated, so a missing
125
+ * marker or a payload that doesn't parse as the expected array is reported as a
126
+ * hard failure via `ok: false` rather than silently reading as "0 records, 0
127
+ * errors" (which would let a partial import look successful).
128
+ *
129
+ * @param {string} apexOut raw stdout from `sf apex run`
130
+ * @returns {{ ok: boolean, error: string|null,
131
+ * successes: Array<{ref:string, id:string}>,
132
+ * errors: Array<{ref:string, message:string}> }}
133
+ */
134
+ export function parseApexInsertResults(apexOut) {
135
+ const text = String(apexOut ?? '');
136
+ // The line looks like: `...|DEBUG|SETUP_RESULT_JSON:[{"ref":"a","id":"001..."}]`.
137
+ // Anchor on the marker and take the rest of that line.
138
+ const markerIdx = text.indexOf('SETUP_RESULT_JSON:');
139
+ if (markerIdx === -1) {
140
+ return {
141
+ ok: false,
142
+ error: 'no SETUP_RESULT_JSON marker found in Apex output (log may be truncated or the insert did not run)',
143
+ successes: [],
144
+ errors: [],
145
+ };
146
+ }
147
+ const afterMarker = text.slice(markerIdx + 'SETUP_RESULT_JSON:'.length);
148
+ // The payload runs to the end of the debug line; stop at the first newline.
149
+ const payload = afterMarker.split('\n')[0].trim();
150
+
151
+ let parsed;
152
+ try {
153
+ parsed = JSON.parse(payload);
154
+ } catch {
155
+ return {
156
+ ok: false,
157
+ error: 'SETUP_RESULT_JSON payload was not valid JSON (log line may be truncated)',
158
+ successes: [],
159
+ errors: [],
160
+ };
161
+ }
162
+ if (!Array.isArray(parsed)) {
163
+ return {
164
+ ok: false,
165
+ error: 'SETUP_RESULT_JSON payload was not a JSON array',
166
+ successes: [],
167
+ errors: [],
168
+ };
169
+ }
170
+
171
+ const successes = [];
172
+ const errors = [];
173
+ for (const entry of parsed) {
174
+ if (!entry || typeof entry !== 'object') continue;
175
+ const ref = entry.ref != null ? String(entry.ref) : '';
176
+ if (entry.err != null) {
177
+ errors.push({ ref, message: String(entry.err) });
178
+ } else if (entry.id != null) {
179
+ successes.push({ ref, id: String(entry.id) });
180
+ }
181
+ }
182
+ return { ok: true, error: null, successes, errors };
183
+ }
184
+
185
+ /**
186
+ * Group serialized-record sizes into batches whose combined Apex character count
187
+ * (fixed per-batch overhead + each record's rendered size) stays under
188
+ * `charLimit`, subject to `maxBatch` records per batch. Replaces the
189
+ * old `estCharsPerRec = 40 + fields*55` heuristic with a measurement the caller
190
+ * supplies via `recordSizes`.
191
+ *
192
+ * Guarantees at least one record per batch even when a single record's size (plus
193
+ * overhead) already exceeds `charLimit` — otherwise a large record would loop
194
+ * forever or emit an empty batch. That oversized record is placed alone in its
195
+ * own batch and left for Apex to accept or reject.
196
+ *
197
+ * @param {number[]} recordSizes rendered Apex char length of each record, in order
198
+ * @param {object} opts
199
+ * @param {number} opts.charLimit max total chars per batch (record chars + overhead)
200
+ * @param {number} opts.maxBatch max records per batch
201
+ * @param {number} [opts.overhead] fixed per-batch char cost (DML boilerplate); default 0
202
+ * @returns {Array<{start:number, count:number}>} batch slices over the input order
203
+ */
204
+ export function planApexBatches(recordSizes, { charLimit, maxBatch, overhead = 0 }) {
205
+ const batches = [];
206
+ let start = 0;
207
+ const n = recordSizes.length;
208
+ while (start < n) {
209
+ let count = 0;
210
+ let total = overhead;
211
+ while (start + count < n && count < maxBatch) {
212
+ const next = recordSizes[start + count];
213
+ // Always take at least one record, even if it alone busts the limit.
214
+ if (count > 0 && total + next > charLimit) break;
215
+ total += next;
216
+ count += 1;
217
+ }
218
+ batches.push({ start, count });
219
+ start += count;
220
+ }
221
+ return batches;
222
+ }
223
+
52
224
  /**
53
225
  * Decide whether the self-reg profile's UserLicense is satisfied, from the rows
54
226
  * a `SELECT UserLicense.LicenseDefinitionKey, UserLicense.Name, UserLicense.Status,
55
227
  * 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).
228
+ * query returns. The seat math MUST be done here in JS — SOQL cannot
229
+ * compare two fields. Matches/report on the stable
230
+ * `LicenseDefinitionKey`, never the display Name.
59
231
  *
60
232
  * satisfied ⇔ Status === 'Active' && (total === -1 [unlimited sentinel] || total - used > 0).
61
233
  * 0 rows / null UserLicense ⇒ not satisfied, with a reason naming the profile.
@@ -85,7 +257,7 @@ export function evaluateLicenseRows(rows, profileName) {
85
257
  if (!Number.isFinite(total) || !Number.isFinite(used)) {
86
258
  return { satisfied: false, reason: `could not read seat counts for license ${label} (${key})`, license };
87
259
  }
88
- // -1 is the documented "unlimited" sentinel (defensive — unverified live, §5.3).
260
+ // -1 is the documented "unlimited" sentinel (defensive — unverified live).
89
261
  if (total === -1 || total - used > 0) {
90
262
  return { satisfied: true, reason: null, license };
91
263
  }
@@ -98,7 +270,7 @@ export function evaluateLicenseRows(rows, profileName) {
98
270
 
99
271
  /**
100
272
  * 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
273
+ * (mirrors isOrgConnected's try/catch tolerance). Collects
102
274
  * every array under `result` (nonScratchOrgs, scratchOrgs, sandboxes, devHubs, …)
103
275
  * so the picker sees all authenticated orgs; the default is the entry marked
104
276
  * `isDefaultUsername`. Malformed / unexpected JSON ⇒ empty result (caller then
@@ -136,8 +308,8 @@ export function parseOrgList(jsonText) {
136
308
  }
137
309
 
138
310
  /**
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
311
+ * Multi-line warning for the non-TTY multi-bundle case: there is nothing to
312
+ * interactively acknowledge, so keep the deterministic
141
313
  * `all[0]` pick but never do it silently — name every bundle, the chosen one, and
142
314
  * the `--ui-bundle-name` remedy.
143
315
  */
@@ -233,13 +405,12 @@ export function discoverAllUIBundleDirs(uiBundlesDir, uiBundleName) {
233
405
 
234
406
  /**
235
407
  * 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:
408
+ * acknowledgment. When >1 bundle exists and no `--ui-bundle-name` was given:
238
409
  * - TTY: present a single-select picker (all bundles, alphabetical all[0]
239
- * preselected) — enter accepts all[0], arrows choose another (AC-4).
410
+ * preselected) — enter accepts all[0], arrows choose another.
240
411
  * - 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).
412
+ * — never a silent pick.
413
+ * Explicit `--ui-bundle-name` short-circuits both (no prompt, no warning).
243
414
  */
244
415
  export async function discoverUIBundleDir(uiBundlesDir, uiBundleName) {
245
416
  const all = discoverAllUIBundleDirs(uiBundlesDir, uiBundleName);
@@ -265,7 +436,7 @@ export async function discoverUIBundleDir(uiBundlesDir, uiBundleName) {
265
436
  * Interactive single-select: arrow keys navigate, enter confirms. Returns the
266
437
  * chosen index. Falls through to `preselectedIndex` immediately when stdin is not
267
438
  * 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).
439
+ * but selects exactly one item.
269
440
  */
270
441
  export function promptSelect(options, { prompt = 'Select an option:', preselectedIndex = 0 } = {}) {
271
442
  if (!process.stdin.isTTY || options.length === 0) return Promise.resolve(preselectedIndex);
@@ -325,14 +496,13 @@ export function promptSelect(options, { prompt = 'Select an option:', preselecte
325
496
  }
326
497
 
327
498
  /**
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.
499
+ * Resolve the target org. When `--target-org` is supplied it is returned
500
+ * verbatim with NO `sf org list` call (byte-for-byte the scripted path).
501
+ * Otherwise discover authenticated orgs via `sf org list --json` and:
502
+ * - TTY: present a picker with the default org at the top and preselected;
503
+ * enter selects the default.
334
504
  * - non-TTY: use the default org if resolvable, else exit 1 with a clear
335
- * message (AC-6).
505
+ * message.
336
506
  */
337
507
  export async function resolveTargetOrg(parsed) {
338
508
  if (parsed.targetOrg) return parsed.targetOrg;