@syntax-syllogism/sfd 0.2.0 → 0.4.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/CHANGELOG.md +36 -0
- package/README.md +125 -15
- package/bin/sfd.mjs +144 -23
- package/package.json +2 -2
- package/src/client.mjs +2 -2
- package/src/commands.mjs +132 -0
- package/src/daemon.mjs +4 -1
- package/src/discover/claude.mjs +25 -0
- package/src/discover/codex.mjs +28 -0
- package/src/discover/gemini.mjs +32 -0
- package/src/discover/index.mjs +181 -0
- package/src/discover/shared.mjs +79 -0
- package/src/discover/worker.mjs +17 -0
- package/src/format.mjs +168 -7
- package/src/handlers.mjs +354 -16
- package/src/paths.mjs +23 -5
- package/src/rewrite.mjs +22 -3
- package/src/server.mjs +6 -3
- package/src/sf-root.mjs +15 -5
- package/src/snippet.mjs +42 -0
- package/src/usage-error.mjs +3 -1
package/src/format.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { relative, resolve } from "node:path";
|
|
2
|
+
|
|
1
3
|
// Compact, token-friendly renderings of Salesforce results.
|
|
2
4
|
|
|
3
|
-
const asArray = x => (x === undefined || x === null ? [] : Array.isArray(x) ? x : [x]);
|
|
5
|
+
export const asArray = x => (x === undefined || x === null ? [] : Array.isArray(x) ? x : [x]);
|
|
4
6
|
|
|
5
7
|
export function stripAttributes(value) {
|
|
6
8
|
if (Array.isArray(value)) return value.map(stripAttributes);
|
|
@@ -16,6 +18,31 @@ export function compactQuery(result) {
|
|
|
16
18
|
return { totalSize: result.totalSize, done: result.done, ...(result.nextRecordsUrl ? { nextRecordsUrl: result.nextRecordsUrl } : {}), records: stripAttributes(result.records ?? []) };
|
|
17
19
|
}
|
|
18
20
|
|
|
21
|
+
export function flattenRecord(record, prefix = "") {
|
|
22
|
+
const out = {};
|
|
23
|
+
for (const [key, value] of Object.entries(record)) {
|
|
24
|
+
if (key === "attributes") continue;
|
|
25
|
+
const column = prefix + key;
|
|
26
|
+
if (value === null || typeof value !== "object") out[column] = value;
|
|
27
|
+
else if (Array.isArray(value.records)) out[column] = JSON.stringify(stripAttributes(value.records));
|
|
28
|
+
else Object.assign(out, flattenRecord(value, column + "."));
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function toCsv(records) {
|
|
34
|
+
if (!records.length) return "";
|
|
35
|
+
const rows = records.map(record => flattenRecord(record));
|
|
36
|
+
const keys = [...new Set(rows.flatMap(row => Object.keys(row)))];
|
|
37
|
+
// A null parent supplies empty cells for its dotted columns, not an extra column.
|
|
38
|
+
const columns = keys.filter(key => !keys.some(other => other.startsWith(key + ".")) || rows.some(row => row[key] != null));
|
|
39
|
+
const escape = value => {
|
|
40
|
+
const text = value == null ? "" : String(value);
|
|
41
|
+
return /[",\r\n]/.test(text) ? '"' + text.replaceAll('"', '""') + '"' : text;
|
|
42
|
+
};
|
|
43
|
+
return [columns.map(escape).join(","), ...rows.map(row => columns.map(key => escape(row[key])).join(","))].join("\n");
|
|
44
|
+
}
|
|
45
|
+
|
|
19
46
|
export function compactDescribe(d) {
|
|
20
47
|
return {
|
|
21
48
|
name: d.name, label: d.label, keyPrefix: d.keyPrefix, custom: d.custom,
|
|
@@ -54,16 +81,56 @@ export function formatApex(result, { fullLog = false } = {}) {
|
|
|
54
81
|
if (fullLog) {
|
|
55
82
|
lines.push(log);
|
|
56
83
|
} else {
|
|
57
|
-
|
|
58
|
-
for (const line of log.split("\n")) {
|
|
59
|
-
const m = line.match(/\|USER_DEBUG\|\[(\d+)\]\|\w+\|(.*)$/);
|
|
60
|
-
if (m) lines.push(`[${m[1]}] ${m[2]}`);
|
|
61
|
-
else if (/\|FATAL_ERROR\|/.test(line)) lines.push(line.replace(/^.*\|FATAL_ERROR\|/, "FATAL: "));
|
|
62
|
-
}
|
|
84
|
+
lines.push(...filterLog(log, { apexOnly: true }));
|
|
63
85
|
}
|
|
64
86
|
return lines.join("\n");
|
|
65
87
|
}
|
|
66
88
|
|
|
89
|
+
// Anonymous Apex retains its original two-event rendering.
|
|
90
|
+
export function filterLog(text, { apexOnly = false } = {}) {
|
|
91
|
+
const lines = [];
|
|
92
|
+
let inLimits = false;
|
|
93
|
+
for (const line of text.split(/\r?\n/)) {
|
|
94
|
+
const debug = line.match(/\|USER_DEBUG\|\[(\d+|EXTERNAL)\]\|\w+\|(.*)$/);
|
|
95
|
+
if (debug && (!apexOnly || debug[1] !== "EXTERNAL")) { lines.push(`[${debug[1]}] ${debug[2]}`); continue; }
|
|
96
|
+
if (/\|FATAL_ERROR\|/.test(line)) { lines.push(line.replace(/^.*\|FATAL_ERROR\|/, "FATAL: ")); continue; }
|
|
97
|
+
if (apexOnly) continue;
|
|
98
|
+
const event = line.match(/\|(EXCEPTION_THROWN|VALIDATION_FAIL|VALIDATION_ERROR|FLOW_ELEMENT_ERROR)(?:\|(.*))?$/);
|
|
99
|
+
if (event) {
|
|
100
|
+
const label = event[1] === "EXCEPTION_THROWN" ? "EXCEPTION" : event[1].startsWith("FLOW") ? "FLOW" : "VALIDATION";
|
|
101
|
+
lines.push(`${label} ${(event[2] ?? "").replaceAll("|", " ")}`.trimEnd());
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (line.includes("|LIMIT_USAGE_FOR_NS|")) { inLimits = true; continue; }
|
|
105
|
+
if (inLimits && /^\s*Number of |^\s*(Maximum CPU time|Maximum heap size)/.test(line)) {
|
|
106
|
+
const usage = line.match(/^\s*(.+?):\s*(\d+) out of (\d+)/);
|
|
107
|
+
if (usage && Number(usage[3]) > 0 && Number(usage[2]) >= Number(usage[3]) / 2) {
|
|
108
|
+
const name = usage[1].replace(/^Number of /, "").replace(/ queries$/, "").replace(/^Maximum /, "");
|
|
109
|
+
lines.push(`LIMIT ${name} ${usage[2]}/${usage[3]}`);
|
|
110
|
+
}
|
|
111
|
+
} else if (line.includes("|")) inLimits = false;
|
|
112
|
+
}
|
|
113
|
+
if (apexOnly) return lines;
|
|
114
|
+
const deduped = [];
|
|
115
|
+
for (let i = 0; i < lines.length;) {
|
|
116
|
+
let end = i + 1;
|
|
117
|
+
while (lines[end] === lines[i]) end++;
|
|
118
|
+
deduped.push(lines[i] + (end - i > 1 ? ` ×${end - i}` : ""));
|
|
119
|
+
i = end;
|
|
120
|
+
}
|
|
121
|
+
return deduped;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const logSize = bytes => bytes >= 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${bytes ?? 0} B`;
|
|
125
|
+
|
|
126
|
+
export function formatLog(log) {
|
|
127
|
+
return [`${log.operation} ${log.status} ${log.duration ?? 0}ms ${logSize(log.length)}`, ...filterLog(log.body)].join("\n");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function formatLogList(rows) {
|
|
131
|
+
return rows.length ? rows.map(row => `${row.Id} ${row.StartTime.replace("T", " ").replace(/\.\d+Z$/, "")} ${row.Operation} ${row.Status} ${logSize(row.LogLength)} ${row.DurationMilliseconds}ms`).join("\n") : "No debug logs found.";
|
|
132
|
+
}
|
|
133
|
+
|
|
67
134
|
export function formatDeploy(summary, org) {
|
|
68
135
|
const action = summary.checkOnly ? "Validated" : "Deployed";
|
|
69
136
|
const tests = summary.numberTestsTotal > 0
|
|
@@ -134,5 +201,99 @@ export function formatTests(summary, org) {
|
|
|
134
201
|
for (const c of summary.coverage ?? []) {
|
|
135
202
|
lines.push(` ${c.percentage.padStart(4)} ${c.name}${c.uncoveredLines.length ? ` uncovered: ${c.uncoveredLines.join(",")}` : ""}`);
|
|
136
203
|
}
|
|
204
|
+
if (summary.logs?.length) {
|
|
205
|
+
lines.push("Logs:");
|
|
206
|
+
for (const log of summary.logs) {
|
|
207
|
+
lines.push(` ${log.id}`);
|
|
208
|
+
if (log.body !== undefined) lines.push(...formatLog(log).split("\n").map(line => ` ${line}`));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
137
211
|
return lines.join("\n");
|
|
138
212
|
}
|
|
213
|
+
|
|
214
|
+
// Normalize both SDR file responses and STL change/status rows at the boundary.
|
|
215
|
+
export function summarizeChanges(changes, projectDir, state) {
|
|
216
|
+
const files = changes.flatMap(c => (c.filenames?.length ? c.filenames : [c.filePath]).map(path => ({
|
|
217
|
+
state: state ?? c.state ?? (c.deleted ? "Deleted" : c.modified ? "Changed" : "Add"),
|
|
218
|
+
type: c.type, fullName: c.fullName ?? c.name,
|
|
219
|
+
path: path ? relative(projectDir, resolve(projectDir, path)) : undefined,
|
|
220
|
+
...(c.problem ? { problem: c.problem } : {})
|
|
221
|
+
})));
|
|
222
|
+
return [...new Map(files.map(f => [JSON.stringify([f.state, f.type, f.fullName, f.path, f.problem]), f])).values()];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function summarizeRetrieve(result, projectDir) {
|
|
226
|
+
const response = result.response;
|
|
227
|
+
const files = summarizeChanges(result.getFileResponses(), projectDir);
|
|
228
|
+
return {
|
|
229
|
+
success: Boolean(response.success) && !files.some(f => f.state === "Failed" || f.problem),
|
|
230
|
+
status: response.status, id: response.id, files,
|
|
231
|
+
warnings: asArray(response.messages), conflicts: [], errorMessage: response.errorMessage
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function componentLines(files, conflict = false) {
|
|
236
|
+
// Collapse only a source file's companion XML; retain bundle files and all failures.
|
|
237
|
+
const paths = new Set(files.filter(f => !f.problem).map(f => `${f.type}:${f.fullName}:${f.path}`));
|
|
238
|
+
return files.filter(f => f.problem || !f.path?.endsWith("-meta.xml") ||
|
|
239
|
+
!paths.has(`${f.type}:${f.fullName}:${f.path.slice(0, -9)}`)).map(f => {
|
|
240
|
+
const state = String(f.state).toLowerCase();
|
|
241
|
+
const mark = conflict ? "! conflict" : f.problem || state === "failed" ? "✖" :
|
|
242
|
+
["add", "added", "created"].includes(state) ? "+" : ["delete", "deleted"].includes(state) ? "-" : "~";
|
|
243
|
+
return ` ${mark} ${f.type} ${f.fullName}${f.path ? ` ${f.path}` : ""}${f.problem ? `: ${f.problem}` : ""}`;
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function formatRetrieve(summary, org) {
|
|
248
|
+
const count = new Set(summary.conflicts.map(c => `${c.type}:${c.fullName}`)).size;
|
|
249
|
+
const head = count
|
|
250
|
+
? `CONFLICTS: ${count} component${count === 1 ? "" : "s"} changed both locally and in ${org}; rerun with --ignore-conflicts to overwrite`
|
|
251
|
+
: summary.message ?? (summary.success
|
|
252
|
+
? `Retrieved ${summary.files.length} files from ${org}${summary.id ? ` (${summary.id})` : ""}`
|
|
253
|
+
: `FAILED (${summary.status}) ${org}${summary.id ? ` (${summary.id})` : ""}`);
|
|
254
|
+
const lines = [head, ...componentLines(summary.files), ...componentLines(summary.conflicts, true)];
|
|
255
|
+
for (const warning of summary.warnings) lines.push(` ! ${typeof warning === "string" ? warning : warning.problem ?? JSON.stringify(warning)}`);
|
|
256
|
+
if (summary.errorMessage) lines.push(` ✖ ${summary.errorMessage}`);
|
|
257
|
+
return lines.join("\n");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function formatTracking(summary) {
|
|
261
|
+
const lines = [];
|
|
262
|
+
for (const section of ["local", "remote", "conflicts"]) {
|
|
263
|
+
if (summary[section].length) lines.push(section, ...componentLines(summary[section], section === "conflicts"));
|
|
264
|
+
}
|
|
265
|
+
return lines.join("\n") || "No changes.";
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function formatOrgList({ orgs, hiddenExpired = 0 }) {
|
|
269
|
+
const aliasWidth = Math.max(1, ...orgs.map(org => (org.alias ?? "-").length));
|
|
270
|
+
const userWidth = Math.max(1, ...orgs.map(org => org.username.length));
|
|
271
|
+
const lines = orgs.map(org => {
|
|
272
|
+
const marker = `${org.isDefault ? "*" : " "}${org.isDefaultDevHub ? "H" : " "}`;
|
|
273
|
+
const expiry = org.type === "Scratch" ? org.expired ? " EXPIRED" : org.expirationDate ? ` expires ${org.expirationDate.slice(0, 10)}` : "" : "";
|
|
274
|
+
return `${marker} ${(org.alias ?? "-").padEnd(aliasWidth)} ${org.username.padEnd(userWidth)} ${org.type}${expiry}${org.status ? ` ${org.status}` : ""}`;
|
|
275
|
+
});
|
|
276
|
+
if (hiddenExpired) lines.push(`(${hiddenExpired} expired scratch org(s) hidden; use --all)`);
|
|
277
|
+
if (!lines.length) lines.push("No authenticated orgs. Run: sf org login web -a <alias>");
|
|
278
|
+
return lines.join("\n");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function formatMetadataTypes({ types, apiVersion }) {
|
|
282
|
+
return [`${types.length} metadata types (API v${apiVersion})`, ...types.map(type => {
|
|
283
|
+
const children = type.children.slice(0, 6);
|
|
284
|
+
if (type.children.length > 6) children.push(`…+${type.children.length - 6}`);
|
|
285
|
+
return `${type.name}${type.inFolder ? " (folder)" : ""}${children.length ? ` [${children.join(", ")}]` : ""}`;
|
|
286
|
+
})].join("\n");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function formatMetadataList({ groups }, { details = false } = {}) {
|
|
290
|
+
return groups.map(group => {
|
|
291
|
+
const header = group.folders
|
|
292
|
+
? `${group.folderType} (${group.members.length}): use --folder <name> to list members`
|
|
293
|
+
: groups.length > 1 ? `${group.type} (${group.members.length}):` : "";
|
|
294
|
+
const lines = group.members.map(member => details ? [member.fullName, member.lastModifiedDate?.slice(0, 10),
|
|
295
|
+
member.lastModifiedByName, member.namespacePrefix ? `ns:${member.namespacePrefix}` : "", member.manageableState].filter(Boolean).join(" ") : member.fullName);
|
|
296
|
+
if (!lines.length) lines.push("(none)");
|
|
297
|
+
return header ? [header, ...lines.map(line => ` ${line}`)].join("\n") : lines.join("\n");
|
|
298
|
+
}).join("\n");
|
|
299
|
+
}
|
package/src/handlers.mjs
CHANGED
|
@@ -1,8 +1,81 @@
|
|
|
1
|
-
import { statSync } from "node:fs";
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { UsageError } from "./usage-error.mjs";
|
|
4
5
|
import { sfRequire } from "./sf-root.mjs";
|
|
5
|
-
import { compactDescribe, compactQuery, summarizeDeploy, summarizeTests } from "./format.mjs";
|
|
6
|
+
import { asArray, compactDescribe, compactQuery, summarizeDeploy, summarizeTests, summarizeRetrieve, summarizeChanges } from "./format.mjs";
|
|
7
|
+
|
|
8
|
+
const soqlString = value => `'${String(value).replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
|
|
9
|
+
const LOG_FIELDS = "Id, LogUser.Username, Operation, Request, Status, LogLength, StartTime, DurationMilliseconds";
|
|
10
|
+
const LOG_CLOCK_SKEW_MS = 5 * 60 * 1000;
|
|
11
|
+
|
|
12
|
+
async function currentUser(conn) {
|
|
13
|
+
const fields = conn.getAuthInfoFields?.();
|
|
14
|
+
if (fields?.userId) return { id: fields.userId, username: fields.username ?? fields.userId };
|
|
15
|
+
const identity = await conn.identity();
|
|
16
|
+
return { id: identity.user_id, username: identity.username };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function logCount(count) {
|
|
20
|
+
if (!Number.isSafeInteger(count) || count < 1) throw new UsageError("Log count must be a positive integer.");
|
|
21
|
+
return count;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function logRows(conn, where = "", count = 10) {
|
|
25
|
+
return (await conn.query(`SELECT ${LOG_FIELDS} FROM ApexLog ${where ? `WHERE ${where}` : ""} ORDER BY StartTime DESC LIMIT ${logCount(count)}`)).records;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function logBody(conn, row) {
|
|
29
|
+
const body = await conn.request(`/services/data/v${conn.getApiVersion()}/sobjects/ApexLog/${row.Id}/Body`, { responseType: "text" });
|
|
30
|
+
return { id: row.Id, operation: row.Operation, status: row.Status, startTime: row.StartTime,
|
|
31
|
+
length: row.LogLength, duration: row.DurationMilliseconds, body };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Preserve the org-write notice even when the subsequent operation fails.
|
|
35
|
+
async function afterTrace(trace, fn) {
|
|
36
|
+
try { return { ...await fn(), ...(trace ? { trace } : {}) }; }
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (trace) error.trace = trace;
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function ensureTrace(conn, userId) {
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
const flags = await conn.tooling.query(`SELECT Id, ExpirationDate, StartDate FROM TraceFlag WHERE TracedEntityId = ${soqlString(userId)} AND LogType = 'DEVELOPER_LOG'`);
|
|
46
|
+
const flag = flags.records.find(f => Date.parse(f.StartDate) <= now && Date.parse(f.ExpirationDate) > now + 300000) ?? flags.records[0];
|
|
47
|
+
if (flag && Date.parse(flag.StartDate) <= now && Date.parse(flag.ExpirationDate) > now + 300000) {
|
|
48
|
+
return { action: "existing", id: flag.Id, expires: flag.ExpirationDate };
|
|
49
|
+
}
|
|
50
|
+
const dates = { StartDate: new Date(now).toISOString(), ExpirationDate: new Date(now + 1800000).toISOString() };
|
|
51
|
+
let result;
|
|
52
|
+
if (flag) {
|
|
53
|
+
result = await conn.tooling.sobject("TraceFlag").update({ Id: flag.Id, ...dates });
|
|
54
|
+
} else {
|
|
55
|
+
const levels = await conn.tooling.query("SELECT Id FROM DebugLevel WHERE DeveloperName = 'SFD' LIMIT 1");
|
|
56
|
+
let levelId = levels.records[0]?.Id;
|
|
57
|
+
if (!levelId) {
|
|
58
|
+
const level = await conn.tooling.sobject("DebugLevel").create({ DeveloperName: "SFD", MasterLabel: "SFD",
|
|
59
|
+
ApexCode: "FINE", ApexProfiling: "INFO", Callout: "INFO", Database: "INFO", System: "DEBUG",
|
|
60
|
+
Validation: "INFO", Visualforce: "NONE", Workflow: "INFO", Wave: "NONE", Nba: "NONE" });
|
|
61
|
+
if (!level.success) throw new Error(`Cannot create debug level: ${JSON.stringify(level.errors)}`);
|
|
62
|
+
levelId = level.id;
|
|
63
|
+
}
|
|
64
|
+
result = await conn.tooling.sobject("TraceFlag").create({ TracedEntityId: userId, LogType: "DEVELOPER_LOG", DebugLevelId: levelId, ...dates });
|
|
65
|
+
}
|
|
66
|
+
if (!result.success) throw new Error(`Cannot enable debug logging: ${JSON.stringify(result.errors)}`);
|
|
67
|
+
return { action: flag ? "extended" : "created", id: flag?.Id ?? result.id, expires: dates.ExpirationDate };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function withDeadline(fn) {
|
|
71
|
+
let timer;
|
|
72
|
+
try {
|
|
73
|
+
return await Promise.race([
|
|
74
|
+
Promise.resolve().then(fn),
|
|
75
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(Object.assign(new Error(), { name: "Timeout" })), 10000); })
|
|
76
|
+
]);
|
|
77
|
+
} finally { clearTimeout(timer); }
|
|
78
|
+
}
|
|
6
79
|
|
|
7
80
|
const SESSION_ERRORS = new Set(["INVALID_SESSION_ID", "INVALID_AUTH_HEADER"]);
|
|
8
81
|
|
|
@@ -23,6 +96,24 @@ function serial() {
|
|
|
23
96
|
};
|
|
24
97
|
}
|
|
25
98
|
|
|
99
|
+
function metadataSuggestion(name, names) {
|
|
100
|
+
const input = name.toLowerCase();
|
|
101
|
+
const distance = candidate => {
|
|
102
|
+
let row = Array.from({ length: input.length + 1 }, (_, i) => i);
|
|
103
|
+
for (const char of candidate.toLowerCase()) {
|
|
104
|
+
const next = [row[0] + 1];
|
|
105
|
+
for (let i = 1; i <= input.length; i++) {
|
|
106
|
+
next[i] = Math.min(next[i - 1] + 1, row[i] + 1, row[i - 1] + (input[i - 1] === char ? 0 : 1));
|
|
107
|
+
}
|
|
108
|
+
row = next;
|
|
109
|
+
}
|
|
110
|
+
return row[input.length];
|
|
111
|
+
};
|
|
112
|
+
return names.find(n => n.toLowerCase().startsWith(input)) ??
|
|
113
|
+
names.map(name => ({ name, distance: distance(name) })).filter(n => n.distance <= 2)
|
|
114
|
+
.sort((a, b) => a.distance - b.distance)[0]?.name;
|
|
115
|
+
}
|
|
116
|
+
|
|
26
117
|
export function createHandlers(sfRoot, { home = homedir(), load = sfRequire(sfRoot) } = {}) {
|
|
27
118
|
const req = load;
|
|
28
119
|
const core = req("@salesforce/core");
|
|
@@ -34,15 +125,34 @@ export function createHandlers(sfRoot, { home = homedir(), load = sfRequire(sfRo
|
|
|
34
125
|
const projectQueue = serial();
|
|
35
126
|
|
|
36
127
|
const orgs = new Map();
|
|
128
|
+
const typeCache = new Map();
|
|
129
|
+
const traces = new Map();
|
|
130
|
+
const traceQueue = serial();
|
|
131
|
+
const traceFor = (conn, alias, user, noTrace) => noTrace ? undefined : traceQueue(async () => {
|
|
132
|
+
const cached = traces.get(alias);
|
|
133
|
+
if (cached?.userId === user.id && Date.parse(cached.expires) > Date.now() + 300000) return undefined;
|
|
134
|
+
const trace = await ensureTrace(conn, user.id);
|
|
135
|
+
traces.set(alias, { ...trace, userId: user.id });
|
|
136
|
+
return trace.action === "existing" ? undefined : { ...trace, username: user.username };
|
|
137
|
+
});
|
|
37
138
|
let stamp = authStamp(home);
|
|
38
139
|
|
|
39
|
-
|
|
140
|
+
let refreshing;
|
|
141
|
+
async function refreshIfAuthChanged() {
|
|
142
|
+
if (refreshing) await refreshing;
|
|
40
143
|
const now = authStamp(home);
|
|
41
144
|
if (now !== stamp) {
|
|
42
145
|
stamp = now;
|
|
43
146
|
orgs.clear();
|
|
44
|
-
|
|
147
|
+
traces.clear();
|
|
148
|
+
typeCache.clear();
|
|
149
|
+
refreshing = core.StateAggregator.clearInstanceAsync();
|
|
150
|
+
try { await refreshing; } finally { refreshing = undefined; }
|
|
45
151
|
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function getOrg(alias) {
|
|
155
|
+
await refreshIfAuthChanged();
|
|
46
156
|
if (!orgs.has(alias)) {
|
|
47
157
|
const pending = core.Org.create({ aliasOrUsername: alias });
|
|
48
158
|
orgs.set(alias, pending);
|
|
@@ -59,11 +169,28 @@ export function createHandlers(sfRoot, { home = homedir(), load = sfRequire(sfRo
|
|
|
59
169
|
} catch (e) {
|
|
60
170
|
if (!SESSION_ERRORS.has(e.errorCode ?? e.name)) throw e;
|
|
61
171
|
orgs.delete(alias);
|
|
172
|
+
traces.delete(alias);
|
|
173
|
+
typeCache.delete(alias);
|
|
62
174
|
const org = await getOrg(alias);
|
|
63
175
|
return fn(org.getConnection(), org);
|
|
64
176
|
}
|
|
65
177
|
}
|
|
66
178
|
|
|
179
|
+
async function metadataTypes(alias, conn) {
|
|
180
|
+
const apiVersion = conn.getApiVersion();
|
|
181
|
+
let versions = typeCache.get(alias);
|
|
182
|
+
if (!versions) typeCache.set(alias, versions = new Map());
|
|
183
|
+
const cached = versions.has(apiVersion);
|
|
184
|
+
if (!cached) {
|
|
185
|
+
const pending = conn.metadata.describe(apiVersion).then(result => asArray(result.metadataObjects)
|
|
186
|
+
.map(t => ({ name: t.xmlName, inFolder: Boolean(t.inFolder), children: asArray(t.childXmlNames) }))
|
|
187
|
+
.sort((a, b) => a.name.localeCompare(b.name)));
|
|
188
|
+
versions.set(apiVersion, pending);
|
|
189
|
+
pending.catch(() => { if (versions.get(apiVersion) === pending) versions.delete(apiVersion); });
|
|
190
|
+
}
|
|
191
|
+
return { types: await versions.get(apiVersion), apiVersion, cached };
|
|
192
|
+
}
|
|
193
|
+
|
|
67
194
|
return {
|
|
68
195
|
ping: async () => ({ pong: true }),
|
|
69
196
|
|
|
@@ -75,6 +202,8 @@ export function createHandlers(sfRoot, { home = homedir(), load = sfRequire(sfRo
|
|
|
75
202
|
|
|
76
203
|
reload: async () => {
|
|
77
204
|
orgs.clear();
|
|
205
|
+
traces.clear();
|
|
206
|
+
typeCache.clear();
|
|
78
207
|
await core.StateAggregator.clearInstanceAsync();
|
|
79
208
|
return { reloaded: true };
|
|
80
209
|
},
|
|
@@ -83,17 +212,105 @@ export function createHandlers(sfRoot, { home = homedir(), load = sfRequire(sfRo
|
|
|
83
212
|
alias: org, username: o.getUsername(), orgId: o.getOrgId(), instanceUrl: conn.instanceUrl, apiVersion: conn.getApiVersion()
|
|
84
213
|
})),
|
|
85
214
|
|
|
86
|
-
|
|
215
|
+
orgList: async ({ all = false, status = false } = {}) => {
|
|
216
|
+
await refreshIfAuthChanged();
|
|
217
|
+
const state = await core.StateAggregator.getInstance();
|
|
218
|
+
const config = await core.ConfigAggregator.create();
|
|
219
|
+
await config.reload();
|
|
220
|
+
const isDefault = (username, key) => {
|
|
221
|
+
const value = config.getPropertyValue(key);
|
|
222
|
+
return value === username || state.aliases.getAll(username).includes(value);
|
|
223
|
+
};
|
|
224
|
+
let authorizations;
|
|
225
|
+
try { authorizations = await core.AuthInfo.listAllAuthorizations() ?? []; }
|
|
226
|
+
catch (error) {
|
|
227
|
+
if (error.name !== "NoAuthInfoFound") throw error;
|
|
228
|
+
authorizations = [];
|
|
229
|
+
}
|
|
230
|
+
const entries = await Promise.all(authorizations.map(async ({ username }) => {
|
|
231
|
+
try {
|
|
232
|
+
const auth = await core.AuthInfo.create({ username });
|
|
233
|
+
let fields;
|
|
234
|
+
try { fields = auth.getFields(true); } catch { fields = auth.getFields(); }
|
|
235
|
+
const defaultOrg = isDefault(username, "target-org");
|
|
236
|
+
if (fields.orgId && !defaultOrg) {
|
|
237
|
+
let primary;
|
|
238
|
+
try { primary = JSON.parse(readFileSync(join(home, ".sfdx", `${fields.orgId}.json`), "utf8")).usernames?.[0]; }
|
|
239
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
240
|
+
if (primary && primary !== username) return undefined;
|
|
241
|
+
}
|
|
242
|
+
const scratch = Boolean(fields.expirationDate || fields.devHubUsername);
|
|
243
|
+
const org = {
|
|
244
|
+
alias: state.aliases.getAll(username)[0] ?? null, username,
|
|
245
|
+
orgId: fields.orgId, instanceUrl: fields.instanceUrl,
|
|
246
|
+
type: scratch ? "Scratch" : fields.isDevHub ? "DevHub" : fields.isSandbox ? "Sandbox" : "Org",
|
|
247
|
+
isDefault: defaultOrg, isDefaultDevHub: isDefault(username, "target-dev-hub")
|
|
248
|
+
};
|
|
249
|
+
if (scratch) {
|
|
250
|
+
if (fields.expirationDate) org.expirationDate = fields.expirationDate;
|
|
251
|
+
org.expired = Boolean(fields.expirationDate && fields.expirationDate.slice(0, 10) < new Date().toISOString().slice(0, 10));
|
|
252
|
+
}
|
|
253
|
+
return { org, devHubUsername: fields.devHubUsername };
|
|
254
|
+
} catch {
|
|
255
|
+
// Never log auth fields or vendor error messages, which can contain secrets.
|
|
256
|
+
console.warn("sfd org list: skipping unreadable local authorization");
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
}));
|
|
260
|
+
const readable = entries.filter(Boolean);
|
|
261
|
+
if (status) {
|
|
262
|
+
const hubs = new Map();
|
|
263
|
+
const checks = readable.filter(({ org }) => org.type !== "Scratch").map(async ({ org }) => {
|
|
264
|
+
try {
|
|
265
|
+
await withDeadline(async () => (await getOrg(org.username)).refreshAuth());
|
|
266
|
+
org.status = "Connected";
|
|
267
|
+
} catch (error) {
|
|
268
|
+
orgs.delete(org.username);
|
|
269
|
+
org.status = error.name;
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
for (const entry of readable) {
|
|
273
|
+
if (entry.org.type !== "Scratch" || !entry.devHubUsername || !entry.org.orgId) continue;
|
|
274
|
+
if (!hubs.has(entry.devHubUsername)) hubs.set(entry.devHubUsername, []);
|
|
275
|
+
hubs.get(entry.devHubUsername).push(entry.org);
|
|
276
|
+
}
|
|
277
|
+
await Promise.all([...checks, ...[...hubs].map(async ([hub, scratchOrgs]) => {
|
|
278
|
+
try {
|
|
279
|
+
const result = await withDeadline(async () => {
|
|
280
|
+
const org = await getOrg(hub);
|
|
281
|
+
return org.getConnection().query(`SELECT ScratchOrg, Status, ExpirationDate FROM ScratchOrgInfo WHERE ScratchOrg IN (${scratchOrgs.map(o => soqlString(o.orgId.slice(0, 15))).join(",")})`);
|
|
282
|
+
});
|
|
283
|
+
for (const org of scratchOrgs) {
|
|
284
|
+
const row = result.records.find(r => r.ScratchOrg?.slice(0, 15) === org.orgId.slice(0, 15));
|
|
285
|
+
if (!row) continue;
|
|
286
|
+
org.status = row.Status;
|
|
287
|
+
if (row.ExpirationDate) org.expirationDate = row.ExpirationDate;
|
|
288
|
+
org.expired = row.Status === "Expired" || row.Status === "Deleted";
|
|
289
|
+
}
|
|
290
|
+
} catch { /* Local scratch details remain useful when a Dev Hub is unavailable. */ }
|
|
291
|
+
})]);
|
|
292
|
+
}
|
|
293
|
+
const listed = readable.map(entry => entry.org);
|
|
294
|
+
const visible = all ? listed : listed.filter(org => !org.expired);
|
|
295
|
+
visible.sort((a, b) => (a.alias ?? "").localeCompare(b.alias ?? "") || a.username.localeCompare(b.username));
|
|
296
|
+
return { orgs: visible, hiddenExpired: listed.length - visible.length };
|
|
297
|
+
},
|
|
298
|
+
|
|
299
|
+
query: ({ org, q, tooling, all, raw, limit }) => withConn(org, async conn => {
|
|
87
300
|
const api = tooling ? conn.tooling : conn;
|
|
88
301
|
const result = await api.query(q, { scanAll: Boolean(all) });
|
|
89
|
-
while (!result.done) {
|
|
302
|
+
while (!result.done && (limit === undefined || result.records.length < limit)) {
|
|
90
303
|
if (!result.nextRecordsUrl) throw new Error("Incomplete query result: missing nextRecordsUrl.");
|
|
91
304
|
const page = await api.queryMore(result.nextRecordsUrl);
|
|
92
305
|
result.records.push(...page.records);
|
|
93
306
|
result.done = page.done;
|
|
94
307
|
result.nextRecordsUrl = page.nextRecordsUrl;
|
|
95
308
|
}
|
|
96
|
-
|
|
309
|
+
const truncated = limit !== undefined && (!result.done || result.records.length > limit);
|
|
310
|
+
if (truncated) result.records = result.records.slice(0, limit);
|
|
311
|
+
const output = raw ? result : compactQuery(result);
|
|
312
|
+
if (truncated) output.truncated = true;
|
|
313
|
+
return output;
|
|
97
314
|
}),
|
|
98
315
|
|
|
99
316
|
apex: ({ org, body }) => withConn(org, conn => new (apex().ExecuteService)(conn).executeAnonymous({ apexCode: body })),
|
|
@@ -108,6 +325,37 @@ export function createHandlers(sfRoot, { home = homedir(), load = sfRequire(sfRo
|
|
|
108
325
|
return raw ? d : compactDescribe(d);
|
|
109
326
|
}),
|
|
110
327
|
|
|
328
|
+
listMetadataTypes: ({ org }) => withConn(org, conn => metadataTypes(org, conn)),
|
|
329
|
+
|
|
330
|
+
listMetadata: ({ org, types = [], folder }) => withConn(org, async conn => {
|
|
331
|
+
types = [...new Set(types)];
|
|
332
|
+
if (!types.length) throw new UsageError("list-metadata needs -m <Type>.");
|
|
333
|
+
if (folder !== undefined && types.length > 1) throw new UsageError("--folder requires exactly one metadata type.");
|
|
334
|
+
const description = await metadataTypes(org, conn);
|
|
335
|
+
const known = new Set(description.types.flatMap(t => [t.name, ...t.children]));
|
|
336
|
+
const groups = types.map(type => {
|
|
337
|
+
if (!known.has(type)) {
|
|
338
|
+
const suggestion = metadataSuggestion(type, [...known].sort());
|
|
339
|
+
throw new UsageError(`Unknown metadata type: ${type}.${suggestion ? ` Did you mean ${suggestion}?` : ""}`);
|
|
340
|
+
}
|
|
341
|
+
const folders = folder === undefined && Boolean(description.types.find(t => t.name === type)?.inFolder);
|
|
342
|
+
const folderType = known.has(`${type}Folder`) ? `${type}Folder` : type === "EmailTemplate" ? "EmailFolder" : `${type}Folder`;
|
|
343
|
+
return { type, ...(folders ? { folders: true, folderType } : {}), members: [] };
|
|
344
|
+
});
|
|
345
|
+
const queries = groups.map(group => ({ type: group.folderType ?? group.type, ...(folder !== undefined ? { folder } : {}) }));
|
|
346
|
+
const chunks = [];
|
|
347
|
+
for (let i = 0; i < queries.length; i += 3) chunks.push(queries.slice(i, i + 3));
|
|
348
|
+
const results = await Promise.all(chunks.map(chunk => conn.metadata.list(chunk, description.apiVersion)));
|
|
349
|
+
const members = results.flatMap(asArray);
|
|
350
|
+
for (const group of groups) {
|
|
351
|
+
group.members = members.filter(member => member.type === (group.folderType ?? group.type)).map(member => ({
|
|
352
|
+
fullName: member.fullName, lastModifiedDate: member.lastModifiedDate, lastModifiedByName: member.lastModifiedByName,
|
|
353
|
+
namespacePrefix: member.namespacePrefix, manageableState: member.manageableState
|
|
354
|
+
})).sort((a, b) => a.fullName.localeCompare(b.fullName));
|
|
355
|
+
}
|
|
356
|
+
return { groups };
|
|
357
|
+
}),
|
|
358
|
+
|
|
111
359
|
deploy: ({ org, projectDir, sourcePaths, metadata, checkOnly, testLevel, tests, waitMinutes = 33 }) =>
|
|
112
360
|
projectQueue(() => withConn(org, async (conn, o) => {
|
|
113
361
|
const { ComponentSetBuilder } = sdr();
|
|
@@ -135,15 +383,105 @@ export function createHandlers(sfRoot, { home = homedir(), load = sfRequire(sfRo
|
|
|
135
383
|
return summarizeDeploy(result.response);
|
|
136
384
|
})),
|
|
137
385
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
386
|
+
retrieve: ({ org, projectDir, sourcePaths, metadata, manifest, ignoreConflicts = false, waitMinutes = 33 }) =>
|
|
387
|
+
projectQueue(() => withConn(org, async (conn, o) => {
|
|
388
|
+
core.SfProject.clearInstances();
|
|
389
|
+
const project = await core.SfProject.resolve(projectDir);
|
|
390
|
+
const tracked = await o.tracksSource();
|
|
391
|
+
const changesOnly = !sourcePaths?.length && !metadata?.length && !manifest;
|
|
392
|
+
if (changesOnly && !tracked) throw new UsageError("retrieve needs -d, -m or -x on an org without source tracking.");
|
|
393
|
+
// Handle conflicts and tracking explicitly: daemon requests must not leave lifecycle listeners behind.
|
|
394
|
+
const st = tracked ? await tracking().SourceTracking.create({ org: o, project, ignoreConflicts, ignoreLocalCache: true }) : undefined;
|
|
395
|
+
const conflicts = st && !ignoreConflicts ? await st.getConflicts() : [];
|
|
396
|
+
let cs;
|
|
397
|
+
let deletedFiles = [];
|
|
398
|
+
const refusal = entries => ({ success: false, status: "Conflicts", files: [], warnings: [], conflicts: summarizeChanges(entries, projectDir, "Conflict") });
|
|
399
|
+
if (changesOnly) {
|
|
400
|
+
// Remote deletes write to disk, so refuse before asking STL to apply any of them.
|
|
401
|
+
if (conflicts.length) return refusal(conflicts);
|
|
402
|
+
const targets = await st.maybeApplyRemoteDeletesToLocal(true);
|
|
403
|
+
cs = targets.componentSetFromNonDeletes;
|
|
404
|
+
deletedFiles = targets.fileResponsesFromDelete;
|
|
405
|
+
} else {
|
|
406
|
+
const packageDirs = project.getUniquePackageDirectories().map(d => d.fullPath);
|
|
407
|
+
cs = await sdr().ComponentSetBuilder.build({
|
|
408
|
+
projectDir,
|
|
409
|
+
sourceapiversion: project.getSfProjectJson().get("sourceApiVersion"),
|
|
410
|
+
sourcepath: sourcePaths?.length ? sourcePaths : undefined,
|
|
411
|
+
metadata: metadata?.length ? { metadataEntries: metadata, directoryPaths: packageDirs } : undefined,
|
|
412
|
+
...(manifest ? { manifest: { manifestPath: manifest, directoryPaths: packageDirs } } : {})
|
|
413
|
+
});
|
|
414
|
+
const selectedConflicts = conflicts.filter(c => cs.has({ type: c.type, fullName: c.name }));
|
|
415
|
+
if (selectedConflicts.length) return refusal(selectedConflicts);
|
|
416
|
+
}
|
|
417
|
+
if (cs.size === 0) {
|
|
418
|
+
if (!changesOnly) throw new Error("Nothing to retrieve: no components matched.");
|
|
419
|
+
return { success: true, status: "Succeeded", files: summarizeChanges(deletedFiles, projectDir), warnings: [], conflicts: [],
|
|
420
|
+
...(!deletedFiles.length ? { message: "No remote changes to retrieve." } : {}) };
|
|
421
|
+
}
|
|
422
|
+
const retrieve = await cs.retrieve({ usernameOrConnection: conn, output: project.getDefaultPackage().fullPath, merge: true, format: "source" });
|
|
423
|
+
const result = await retrieve.pollStatus({ frequency: Duration.seconds(2), timeout: Duration.minutes(waitMinutes) });
|
|
424
|
+
if (st && result.response.success) await st.updateTrackingFromRetrieve(result);
|
|
425
|
+
const summary = summarizeRetrieve(result, projectDir);
|
|
426
|
+
summary.files.unshift(...summarizeChanges(deletedFiles, projectDir));
|
|
427
|
+
return summary;
|
|
428
|
+
})),
|
|
429
|
+
|
|
430
|
+
tracking: ({ org, projectDir, local = false, remote = false, conflicts: conflictsOnly = false }) =>
|
|
431
|
+
projectQueue(() => withConn(org, async (conn, o) => {
|
|
432
|
+
if (!(await o.tracksSource())) throw new UsageError("tracking requires an org with source tracking.");
|
|
433
|
+
core.SfProject.clearInstances();
|
|
434
|
+
const project = await core.SfProject.resolve(projectDir);
|
|
435
|
+
const st = await tracking().SourceTracking.create({ org: o, project, ignoreLocalCache: true });
|
|
436
|
+
const rows = conflictsOnly ? [] : await st.getStatus({ local: !remote, remote: !local });
|
|
437
|
+
return {
|
|
438
|
+
local: summarizeChanges(rows.filter(r => r.origin === "local" && !r.ignored), projectDir),
|
|
439
|
+
remote: summarizeChanges(rows.filter(r => r.origin === "remote" && !r.ignored), projectDir),
|
|
440
|
+
conflicts: summarizeChanges(await st.getConflicts(), projectDir, "Conflict")
|
|
441
|
+
};
|
|
442
|
+
})),
|
|
443
|
+
|
|
444
|
+
logs: ({ org, count = 10, user, noTrace }) => withConn(org, async conn => {
|
|
445
|
+
logCount(count);
|
|
446
|
+
const trace = await traceFor(conn, org, await currentUser(conn), noTrace);
|
|
447
|
+
return afterTrace(trace, async () => ({
|
|
448
|
+
records: await logRows(conn, user ? `LogUser.Username = ${soqlString(user)}` : "", count)
|
|
449
|
+
}));
|
|
450
|
+
}),
|
|
451
|
+
|
|
452
|
+
logGet: ({ org, id, noTrace }) => withConn(org, async conn => {
|
|
453
|
+
if (id !== "latest" && !/^07L[a-zA-Z0-9]{12}(?:[a-zA-Z0-9]{3})?$/.test(id)) throw new UsageError("logs get needs an ApexLog ID or latest.");
|
|
454
|
+
const user = await currentUser(conn);
|
|
455
|
+
const trace = await traceFor(conn, org, user, noTrace);
|
|
456
|
+
return afterTrace(trace, async () => {
|
|
457
|
+
const rows = await logRows(conn, id === "latest" ? `LogUserId = ${soqlString(user.id)}` : `Id = ${soqlString(id)}`, 1);
|
|
458
|
+
if (!rows.length) throw new Error("No debug logs found.");
|
|
459
|
+
return logBody(conn, rows[0]);
|
|
460
|
+
});
|
|
461
|
+
}),
|
|
462
|
+
|
|
463
|
+
test: ({ org, classNames, tests, suiteNames, testLevel, codeCoverage, logs, noTrace, waitMinutes = 60 }) => withConn(org, async conn => {
|
|
464
|
+
const user = logs ? await currentUser(conn) : undefined;
|
|
465
|
+
const trace = logs ? await traceFor(conn, org, user, noTrace) : undefined;
|
|
466
|
+
return afterTrace(trace, async () => {
|
|
467
|
+
const { TestService } = apex();
|
|
468
|
+
const service = new TestService(conn);
|
|
469
|
+
const level = testLevel ?? (classNames?.length || tests?.length || suiteNames?.length ? "RunSpecifiedTests" : "RunLocalTests");
|
|
470
|
+
// apex-node takes comma-separated strings, as the sf flags do.
|
|
471
|
+
const csv = list => (list?.length ? list.join(",") : undefined);
|
|
472
|
+
const payload = await service.buildAsyncPayload(level, csv(tests), csv(classNames), csv(suiteNames));
|
|
473
|
+
const startedAt = new Date(Math.floor((Date.now() - LOG_CLOCK_SKEW_MS) / 1000) * 1000).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
474
|
+
const result = await service.runTestAsynchronous(payload, Boolean(codeCoverage), false, undefined, undefined, Duration.minutes(waitMinutes));
|
|
475
|
+
const summary = summarizeTests(result);
|
|
476
|
+
if (logs) {
|
|
477
|
+
// Pad both bounds for clock skew, rounding outward to whole-second SOQL literals.
|
|
478
|
+
const finishedAt = new Date(Math.ceil((Date.now() + LOG_CLOCK_SKEW_MS) / 1000) * 1000).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
479
|
+
const rows = await logRows(conn, `LogUserId = ${soqlString(user.id)} AND StartTime >= ${startedAt} AND StartTime <= ${finishedAt}`, 10);
|
|
480
|
+
summary.logs = summary.failing > 0 || summary.failures.length || summary.setupFailures.length
|
|
481
|
+
? await Promise.all(rows.map(row => logBody(conn, row))) : rows.map(row => ({ id: row.Id }));
|
|
482
|
+
}
|
|
483
|
+
return summary;
|
|
484
|
+
});
|
|
147
485
|
})
|
|
148
486
|
};
|
|
149
487
|
}
|