@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/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
- // Keep only USER_DEBUG and fatal lines; the rest of a debug log is noise for agents.
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
- async function getOrg(alias) {
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
- await core.StateAggregator.clearInstanceAsync();
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
- query: ({ org, q, tooling, all, raw }) => withConn(org, async conn => {
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
- return raw ? result : compactQuery(result);
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
- test: ({ org, classNames, tests, suiteNames, testLevel, codeCoverage, waitMinutes = 60 }) => withConn(org, async conn => {
139
- const { TestService } = apex();
140
- const service = new TestService(conn);
141
- const level = testLevel ?? (classNames?.length || tests?.length || suiteNames?.length ? "RunSpecifiedTests" : "RunLocalTests");
142
- // apex-node takes comma-separated strings, as the sf flags do.
143
- const csv = list => (list?.length ? list.join(",") : undefined);
144
- const payload = await service.buildAsyncPayload(level, csv(tests), csv(classNames), csv(suiteNames));
145
- const result = await service.runTestAsynchronous(payload, Boolean(codeCoverage), false, undefined, undefined, Duration.minutes(waitMinutes));
146
- return summarizeTests(result);
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
  }