@salesforce/templates 66.12.0 → 66.13.1
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/lib/generators/uiEmbeddingGenerator.d.ts +6 -0
- package/lib/generators/{lightningEmbeddingGenerator.js → uiEmbeddingGenerator.js} +12 -12
- package/lib/generators/uiEmbeddingGenerator.js.map +1 -0
- package/lib/i18n/i18n.d.ts +6 -6
- package/lib/i18n/i18n.js +6 -6
- package/lib/i18n/i18n.js.map +1 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/lib/index.js.map +1 -1
- package/lib/templates/project/angularextapp/CHANGELOG.md +129 -0
- package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/.graphqlrc.yml +2 -0
- package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/angular.json +3 -1
- package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/codegen.yml +95 -0
- package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/esbuild/graphql-codegen.mjs +13 -0
- package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/package-lock.json +2495 -294
- package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/package.json +9 -2
- package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/scripts/get-graphql-schema.mjs +69 -0
- package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/src/app/api/account/account-search.service.ts +52 -140
- package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/src/app/api/graphql-operations-types.ts +29389 -0
- package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/src/app/features/object-search/api/object-search.service.ts +94 -0
- package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/src/app/features/object-search/utils/sort-utils.ts +3 -10
- package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/src/app/pages/account-search/account-search-page.ts +15 -8
- package/lib/templates/project/angularextapp/package.json +1 -1
- package/lib/templates/project/angularextapp/scripts/org-setup-utils.mjs +206 -36
- package/lib/templates/project/angularextapp/scripts/org-setup.mjs +180 -69
- package/lib/templates/project/angularintapp/CHANGELOG.md +129 -0
- package/lib/templates/project/angularintapp/_p_/_m_/_w_/_a_/.graphqlrc.yml +2 -0
- package/lib/templates/project/angularintapp/_p_/_m_/_w_/_a_/angular.json +3 -1
- package/lib/templates/project/angularintapp/_p_/_m_/_w_/_a_/codegen.yml +95 -0
- package/lib/templates/project/angularintapp/_p_/_m_/_w_/_a_/esbuild/graphql-codegen.mjs +13 -0
- package/lib/templates/project/angularintapp/_p_/_m_/_w_/_a_/package-lock.json +2498 -297
- package/lib/templates/project/angularintapp/_p_/_m_/_w_/_a_/package.json +9 -2
- package/lib/templates/project/angularintapp/_p_/_m_/_w_/_a_/scripts/get-graphql-schema.mjs +69 -0
- package/lib/templates/project/angularintapp/_p_/_m_/_w_/_a_/src/app/api/account/account-search.service.ts +52 -140
- package/lib/templates/project/angularintapp/_p_/_m_/_w_/_a_/src/app/api/graphql-operations-types.ts +29389 -0
- package/lib/templates/project/angularintapp/_p_/_m_/_w_/_a_/src/app/features/object-search/api/object-search.service.ts +94 -0
- package/lib/templates/project/angularintapp/_p_/_m_/_w_/_a_/src/app/features/object-search/utils/sort-utils.ts +3 -10
- package/lib/templates/project/angularintapp/_p_/_m_/_w_/_a_/src/app/pages/account-search/account-search-page.ts +15 -8
- package/lib/templates/project/angularintapp/package.json +1 -1
- package/lib/templates/project/angularintapp/scripts/org-setup-utils.mjs +206 -36
- package/lib/templates/project/angularintapp/scripts/org-setup.mjs +180 -69
- package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/package-lock.json +1820 -1815
- package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/package-lock.json +1820 -1815
- package/lib/templates/uiBundles/angularbasic/package-lock.json +2382 -272
- package/lib/templates/uiBundles/reactbasic/package-lock.json +1820 -1815
- package/lib/templates/{lightningembedding → uiembedding}/default/default.html +3 -3
- package/lib/templates/{lightningembedding → uiembedding}/default/default.js +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/lib/utils/types.d.ts +4 -4
- package/lib/utils/types.js +3 -3
- package/lib/utils/types.js.map +1 -1
- package/lib/utils/uiEmbedding.d.ts +12 -0
- package/lib/utils/{lightningEmbedding.js → uiEmbedding.js} +7 -7
- package/lib/utils/uiEmbedding.js.map +1 -0
- package/package.json +1 -1
- package/lib/generators/lightningEmbeddingGenerator.d.ts +0 -6
- package/lib/generators/lightningEmbeddingGenerator.js.map +0 -1
- package/lib/utils/lightningEmbedding.d.ts +0 -12
- package/lib/utils/lightningEmbedding.js.map +0 -1
- /package/lib/templates/{lightningembedding → uiembedding}/default/default.css +0 -0
- /package/lib/templates/{lightningembedding → uiembedding}/default/default.js-meta.xml +0 -0
|
@@ -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
|
-
|
|
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" ?
|
|
43
|
-
nulls:
|
|
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 {
|
|
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<
|
|
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<
|
|
181
|
+
protected readonly nodes = computed<AccountSearchNode[]>(() => {
|
|
175
182
|
const edges = this.result.data()?.edges ?? [];
|
|
176
|
-
const out:
|
|
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:
|
|
209
|
+
protected name(node: AccountSearchNode): string {
|
|
203
210
|
return fieldValue(node.Name) ?? "—";
|
|
204
211
|
}
|
|
205
212
|
|
|
206
|
-
protected secondaryLine(node:
|
|
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:
|
|
217
|
+
protected phone(node: AccountSearchNode): string {
|
|
211
218
|
return fieldValue(node.Phone) ?? "";
|
|
212
219
|
}
|
|
213
220
|
|
|
214
|
-
protected ownerName(node:
|
|
221
|
+
protected ownerName(node: AccountSearchNode): string {
|
|
215
222
|
return fieldValue(node.Owner?.Name) ?? "";
|
|
216
223
|
}
|
|
217
224
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared helpers for the org-setup scripts
|
|
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
|
|
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
|
|
31
|
-
* '<
|
|
32
|
-
*
|
|
33
|
-
* profile
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
`(
|
|
46
|
-
`
|
|
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
|
|
57
|
-
* compare two fields
|
|
58
|
-
* `LicenseDefinitionKey`, never the display Name
|
|
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
|
|
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
|
-
* (
|
|
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
|
|
140
|
-
*
|
|
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
|
|
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
|
|
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
|
-
*
|
|
242
|
-
* Explicit `--ui-bundle-name` short-circuits both (no prompt, no warning
|
|
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
|
|
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
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
* and
|
|
332
|
-
*
|
|
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
|
|
505
|
+
* message.
|
|
336
506
|
*/
|
|
337
507
|
export async function resolveTargetOrg(parsed) {
|
|
338
508
|
if (parsed.targetOrg) return parsed.targetOrg;
|