sfbench 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.ja.md +165 -0
  3. package/README.md +164 -0
  4. package/dist/server/ai/agent.js +132 -0
  5. package/dist/server/ai/agentTasks.js +296 -0
  6. package/dist/server/ai/anthropic.js +36 -0
  7. package/dist/server/ai/conversation.js +3 -0
  8. package/dist/server/ai/errors.js +75 -0
  9. package/dist/server/ai/openai.js +53 -0
  10. package/dist/server/ai/tools.js +249 -0
  11. package/dist/server/browser.js +26 -0
  12. package/dist/server/cli.js +151 -0
  13. package/dist/server/fsutil.js +17 -0
  14. package/dist/server/http/security.js +37 -0
  15. package/dist/server/http/server.js +778 -0
  16. package/dist/server/launcher.js +170 -0
  17. package/dist/server/log.js +33 -0
  18. package/dist/server/orgs.js +143 -0
  19. package/dist/server/paths.js +35 -0
  20. package/dist/server/records.js +118 -0
  21. package/dist/server/rest.js +120 -0
  22. package/dist/server/salesforce.js +348 -0
  23. package/dist/server/serve.js +121 -0
  24. package/dist/server/sf/apexRun.js +112 -0
  25. package/dist/server/sf/apiRequest.js +138 -0
  26. package/dist/server/sf/commands.js +143 -0
  27. package/dist/server/sf/errors.js +38 -0
  28. package/dist/server/sf/run.js +163 -0
  29. package/dist/server/state.js +103 -0
  30. package/dist/server/store.js +201 -0
  31. package/dist/server/version.js +8 -0
  32. package/dist/shared/api.js +31 -0
  33. package/dist/shared/namespace.js +28 -0
  34. package/dist/web/assets/index-CW8D9pEg.js +210 -0
  35. package/dist/web/assets/index-LqgSbazj.css +1 -0
  36. package/dist/web/favicon.svg +1 -0
  37. package/dist/web/index.html +14 -0
  38. package/dist/web/licenses.txt +1434 -0
  39. package/package.json +78 -0
@@ -0,0 +1,348 @@
1
+ // Calls the Salesforce REST API for one org. See docs/design.md
2
+ // ("呼び出しの手順", "代わりの経路への切り替え").
3
+ //
4
+ // The usual transport is fetch with the access token from the sf CLI. The
5
+ // fallback transport is `sf api request rest`, which is slow (about 3 seconds
6
+ // per call) but needs no token.
7
+ import { isServicesPath } from "../shared/api.js";
8
+ import { log } from "./log.js";
9
+ import { apiRequest, isInvalidSession } from "./sf/apiRequest.js";
10
+ import { getAccessToken } from "./sf/commands.js";
11
+ import { OrgAuthError, SfCliError, SfNotFoundError, SfTimeoutError, isAuthErrorMessage } from "./sf/errors.js";
12
+ /** Salesforce answered with an HTTP error. `body` is its parsed JSON when possible. */
13
+ export class SalesforceError extends Error {
14
+ status;
15
+ body;
16
+ constructor(status, body) {
17
+ super(salesforceMessage(status, body));
18
+ this.name = 'SalesforceError';
19
+ this.status = status;
20
+ this.body = body;
21
+ }
22
+ }
23
+ function salesforceMessage(status, body) {
24
+ if (Array.isArray(body) && body[0] && typeof body[0] === 'object' && 'message' in body[0]) {
25
+ return String(body[0].message);
26
+ }
27
+ return `Salesforce returned HTTP ${status}`;
28
+ }
29
+ /** Whether a request may change data: decided here only (docs/design.md "経路の選択"). */
30
+ export function mayChangeData(req) {
31
+ return req.changesData === true || (req.method !== 'GET' && req.method !== 'HEAD');
32
+ }
33
+ /** Salesforce did not answer in time. */
34
+ export class SalesforceTimeoutError extends Error {
35
+ constructor() {
36
+ super('Salesforce did not respond in time.');
37
+ this.name = 'SalesforceTimeoutError';
38
+ }
39
+ }
40
+ /**
41
+ * The connection broke while a request that changes data was in flight.
42
+ * Salesforce may have processed it, so it is not sent again.
43
+ */
44
+ export class SalesforceUnreachableError extends Error {
45
+ constructor() {
46
+ super('The connection to Salesforce broke while sending the request. Salesforce may already have processed it, so sfbench did not send it again. Check the result in Salesforce.');
47
+ this.name = 'SalesforceUnreachableError';
48
+ }
49
+ }
50
+ /** The screen asked for an API version the org does not support: nothing was sent. */
51
+ export class UnsupportedVersionError extends Error {
52
+ constructor(version) {
53
+ super(`The org does not support API version ${version}.`);
54
+ this.name = 'UnsupportedVersionError';
55
+ }
56
+ }
57
+ /** The fetch transport failed before or while talking to Salesforce. */
58
+ class FetchFailure extends Error {
59
+ /** False when the request was never sent (for example, no access token). */
60
+ mayHaveReachedSalesforce;
61
+ constructor(message, mayHaveReachedSalesforce) {
62
+ super(message);
63
+ this.name = 'FetchFailure';
64
+ this.mayHaveReachedSalesforce = mayHaveReachedSalesforce;
65
+ }
66
+ }
67
+ const RETRY_FETCH_AFTER_MS = 10 * 60 * 1000;
68
+ export class OrgClient {
69
+ username;
70
+ instanceUrl;
71
+ apiUsage;
72
+ tmpDir;
73
+ forceCli;
74
+ timeoutMs;
75
+ transport = 'fetch';
76
+ switchedToCliAt = 0;
77
+ token;
78
+ /** The value `token` resolved to, once it has. */
79
+ tokenValue;
80
+ /** The rejected token that the pending `token` run is replacing. */
81
+ replacing;
82
+ apiVersions;
83
+ constructor(options) {
84
+ this.username = options.username;
85
+ this.instanceUrl = options.instanceUrl;
86
+ this.tmpDir = options.tmpDir;
87
+ this.forceCli = options.forceCli;
88
+ this.timeoutMs = options.timeoutMs;
89
+ }
90
+ /**
91
+ * The API versions the org supports, oldest first, such as "66.0" last. An
92
+ * org can list a version twice (seen around a release), so each is kept once.
93
+ */
94
+ versions() {
95
+ this.apiVersions ??= this.request({ method: 'GET', path: '/services/data/' }).then((res) => [...new Set(res.body.map((v) => v.version))].sort((a, b) => Number(a) - Number(b)));
96
+ this.apiVersions.catch(() => (this.apiVersions = undefined));
97
+ return this.apiVersions;
98
+ }
99
+ /**
100
+ * The API version to use: `requested` if the org supports it, else the latest
101
+ * when none is requested. A version the org does not support is refused
102
+ * (UnsupportedVersionError), never replaced by another.
103
+ */
104
+ async version(requested) {
105
+ const versions = await this.versions();
106
+ if (requested === undefined)
107
+ return versions[versions.length - 1];
108
+ if (!versions.includes(requested))
109
+ throw new UnsupportedVersionError(requested);
110
+ return requested;
111
+ }
112
+ /** Sends a request. Throws SalesforceError for HTTP errors and OrgAuthError when the org needs a new login. */
113
+ async request(req) {
114
+ const raw = await this.requestRaw(req);
115
+ const body = parseBody(raw.body);
116
+ if (raw.status >= 400)
117
+ throw new SalesforceError(raw.status, body);
118
+ return { status: raw.status, headers: raw.headers, body };
119
+ }
120
+ /**
121
+ * Sends a request and returns what Salesforce answered, whatever its status
122
+ * (the REST API screen shows a 4xx as a response, docs/rest.md). Throws
123
+ * OrgAuthError only when Salesforce says the session is invalid even with a
124
+ * new token. With `maxBodyBytes`, a longer body is cut off while it is read.
125
+ */
126
+ async requestRaw(req, options = {}) {
127
+ // What was checked is what is sent, on either transport.
128
+ const checked = { ...req, path: salesforcePath(req.path, this.instanceUrl) };
129
+ const raw = await this.send(checked, options.maxBodyBytes);
130
+ this.recordApiUsage(raw.headers['sforce-limit-info']);
131
+ return raw;
132
+ }
133
+ async send(req, maxBodyBytes) {
134
+ if (this.forceCli)
135
+ return this.sendWithCli(req, maxBodyBytes);
136
+ const readOnly = !mayChangeData(req);
137
+ // After switching to sf, fetch is tried again after a while, but only with
138
+ // a read-only request: a request that changes data must not be the one
139
+ // that discovers fetch still fails, because it could not be sent again.
140
+ if (this.transport === 'cli' && (!readOnly || Date.now() - this.switchedToCliAt < RETRY_FETCH_AFTER_MS)) {
141
+ return this.sendWithCli(req, maxBodyBytes);
142
+ }
143
+ try {
144
+ const res = await this.sendWithFetch(req, maxBodyBytes);
145
+ this.transport = 'fetch';
146
+ return res;
147
+ }
148
+ catch (e) {
149
+ if (!(e instanceof FetchFailure))
150
+ throw e;
151
+ // A request that changes data is sent again only if nothing reached
152
+ // Salesforce yet (the token could not be obtained). After a network
153
+ // error, Salesforce may already have processed it.
154
+ if (e.mayHaveReachedSalesforce && !readOnly)
155
+ throw new SalesforceUnreachableError();
156
+ // Switch only if sf succeeds: when the network is down, sf fails too.
157
+ log.warn(`${this.username}: ${e.message}; trying sf api request rest`);
158
+ const res = await this.sendWithCli(req, maxBodyBytes);
159
+ if (this.transport !== 'cli')
160
+ log.info(`${this.username}: switched to sf api request rest`);
161
+ this.transport = 'cli';
162
+ this.switchedToCliAt = Date.now();
163
+ return res;
164
+ }
165
+ }
166
+ async sendWithFetch(req, maxBodyBytes) {
167
+ let token = await this.getTokenForFetch();
168
+ let res = await this.fetchOnce(req, token, maxBodyBytes);
169
+ // Only Salesforce saying the session is invalid sends the request again: any
170
+ // other 401 (such as one from the user's Apex REST, which may have changed
171
+ // data first) is the answer (docs/rest.md "Salesforce が返した 4xx と 5xx").
172
+ if (isInvalidSession(res)) {
173
+ // The access token expired. sf refreshes it; if the refresh token is no
174
+ // longer valid, sf returns the same stale token and the retry fails too.
175
+ token = await this.getTokenForFetch(token);
176
+ res = await this.fetchOnce(req, token, maxBodyBytes);
177
+ if (isInvalidSession(res)) {
178
+ this.token = undefined;
179
+ throw new OrgAuthError(this.username, 'Salesforce rejected the session (401).');
180
+ }
181
+ }
182
+ return res;
183
+ }
184
+ async getTokenForFetch(stale) {
185
+ try {
186
+ return await this.getToken(stale);
187
+ }
188
+ catch (e) {
189
+ if (e instanceof OrgAuthError || e instanceof SfNotFoundError)
190
+ throw e;
191
+ if (e instanceof SfCliError && isAuthErrorMessage(e.message))
192
+ throw new OrgAuthError(this.username, e.message);
193
+ throw new FetchFailure(`could not get an access token (${e.message})`, false);
194
+ }
195
+ }
196
+ async sendWithCli(req, maxBodyBytes) {
197
+ try {
198
+ const res = await apiRequest(this.username, { method: req.method, path: req.path, headers: requestHeaders(req), body: req.body }, this.tmpDir, this.timeoutMs());
199
+ return maxBodyBytes !== undefined ? cutBody(res, maxBodyBytes) : res;
200
+ }
201
+ catch (e) {
202
+ // The same answer as fetch's timeout: Salesforce may have processed the request.
203
+ throw e instanceof SfTimeoutError ? new SalesforceTimeoutError() : e;
204
+ }
205
+ }
206
+ /**
207
+ * The access token. Concurrent callers share one `sf org auth
208
+ * show-access-token` run. `stale` is the token Salesforce just rejected: a
209
+ * new one is fetched only if no other caller has already replaced it.
210
+ */
211
+ getToken(stale) {
212
+ // All checks are synchronous, so that concurrent callers see each other's run.
213
+ if (this.token) {
214
+ if (stale === undefined)
215
+ return this.token;
216
+ const alreadyReplaced = this.tokenValue !== undefined && this.tokenValue !== stale;
217
+ if (stale === this.replacing || alreadyReplaced)
218
+ return this.token;
219
+ }
220
+ const pending = getAccessToken(this.username, this.tmpDir);
221
+ this.token = pending;
222
+ this.tokenValue = undefined;
223
+ this.replacing = stale;
224
+ pending.then((value) => {
225
+ if (this.token === pending) {
226
+ this.tokenValue = value;
227
+ this.replacing = undefined;
228
+ }
229
+ }, () => {
230
+ if (this.token === pending) {
231
+ this.token = undefined;
232
+ this.replacing = undefined;
233
+ }
234
+ });
235
+ return pending;
236
+ }
237
+ async fetchOnce(req, token, maxBodyBytes) {
238
+ try {
239
+ const res = await fetch(new URL(req.path, this.instanceUrl), {
240
+ method: req.method,
241
+ // The token goes last, so that no header of the request can replace it.
242
+ headers: { ...requestHeaders(req), authorization: `Bearer ${token}` },
243
+ body: req.body,
244
+ redirect: 'manual',
245
+ signal: AbortSignal.timeout(this.timeoutMs()),
246
+ });
247
+ const headers = {};
248
+ res.headers.forEach((value, key) => (headers[key] = value));
249
+ return { status: res.status, headers, ...(await readBody(res, maxBodyBytes)) };
250
+ }
251
+ catch (e) {
252
+ if (e.name === 'TimeoutError')
253
+ throw new SalesforceTimeoutError();
254
+ throw new FetchFailure(`fetch failed (${e.message})`, true);
255
+ }
256
+ }
257
+ recordApiUsage(header) {
258
+ const m = header ? /api-usage=(\d+)\/(\d+)/.exec(header) : null;
259
+ if (m)
260
+ this.apiUsage = { used: Number(m[1]), max: Number(m[2]) };
261
+ }
262
+ }
263
+ /**
264
+ * The headers to send: the defaults, then the request's own, all with names in
265
+ * lower case, so that `content-type` from the request replaces the default
266
+ * rather than adding a second value to it (docs/rest.md "要求").
267
+ */
268
+ function requestHeaders(req) {
269
+ const headers = { accept: 'application/json' };
270
+ if (req.body !== undefined)
271
+ headers['content-type'] = 'application/json';
272
+ for (const [name, value] of Object.entries(req.headers ?? {}))
273
+ headers[name.toLowerCase()] = value;
274
+ return headers;
275
+ }
276
+ /**
277
+ * Reads the body, stopping once it is longer than `maxBodyBytes`. With a
278
+ * limit (the REST API screen), a body that stops arriving still returns the
279
+ * status, so that a processed request is not taken for one of unknown outcome.
280
+ */
281
+ async function readBody(res, maxBodyBytes) {
282
+ if (maxBodyBytes === undefined || !res.body) {
283
+ const body = await res.text();
284
+ return { body, size: Buffer.byteLength(body) };
285
+ }
286
+ const chunks = [];
287
+ let size = 0;
288
+ const reader = res.body.getReader();
289
+ for (;;) {
290
+ let next;
291
+ try {
292
+ next = await reader.read();
293
+ }
294
+ catch {
295
+ // Salesforce answered (the status is known) but the body stopped
296
+ // arriving, such as when time ran out: the request was processed.
297
+ return { body: '', size, incomplete: true };
298
+ }
299
+ const { done, value } = next;
300
+ if (done)
301
+ break;
302
+ size += value.byteLength;
303
+ if (size > maxBodyBytes) {
304
+ await reader.cancel();
305
+ return { body: '', size, truncated: true };
306
+ }
307
+ chunks.push(value);
308
+ }
309
+ return { body: Buffer.concat(chunks).toString('utf8'), size };
310
+ }
311
+ function cutBody(res, maxBodyBytes) {
312
+ const size = Buffer.byteLength(res.body);
313
+ return size > maxBodyBytes ? { ...res, body: '', size, truncated: true } : { ...res, size };
314
+ }
315
+ function parseBody(text) {
316
+ if (!text)
317
+ return undefined;
318
+ try {
319
+ return JSON.parse(text);
320
+ }
321
+ catch {
322
+ return text;
323
+ }
324
+ }
325
+ /**
326
+ * The path to send, as checked: only paths under /services/ on the org's own
327
+ * instance may be requested, so that the server can never be used to send the
328
+ * token anywhere else. An absolute URL of the instance becomes its path.
329
+ */
330
+ export function salesforcePath(path, instanceUrl) {
331
+ let url;
332
+ try {
333
+ url = new URL(path, instanceUrl);
334
+ }
335
+ catch {
336
+ throw new InvalidPathError(path);
337
+ }
338
+ if (url.origin !== new URL(instanceUrl).origin || !isServicesPath(url.pathname)) {
339
+ throw new InvalidPathError(path);
340
+ }
341
+ return url.pathname + url.search;
342
+ }
343
+ export class InvalidPathError extends Error {
344
+ constructor(path) {
345
+ super(`Only paths under /services/ on the org's instance are allowed: ${path}`);
346
+ this.name = 'InvalidPathError';
347
+ }
348
+ }
@@ -0,0 +1,121 @@
1
+ // The server process. In normal use the CLI entry point starts it detached in
2
+ // the background; `npm run dev:server` runs it in the foreground.
3
+ import { randomBytes } from 'node:crypto';
4
+ import { readdirSync, rmSync, statSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { readJson, writeFileAtomic } from "./fsutil.js";
7
+ import { App } from "./http/server.js";
8
+ import { initLog, log } from "./log.js";
9
+ import { OrgRegistry } from "./orgs.js";
10
+ import { ensureDirs, resolvePaths, webRoot } from "./paths.js";
11
+ import { removeServerInfo, writeServerInfo } from "./state.js";
12
+ import { SettingsStore } from "./store.js";
13
+ import { packageVersion } from "./version.js";
14
+ export const DEFAULT_PORT = 7717;
15
+ const LAST_PORT = 7736;
16
+ export const DEV_PORT = 17717;
17
+ const DEV_WEB_URL = 'http://localhost:5173/launch/dev';
18
+ function listen(server, port) {
19
+ return new Promise((resolve, reject) => {
20
+ const onError = (e) => {
21
+ server.off('listening', onListening);
22
+ if (e.code === 'EADDRINUSE')
23
+ resolve(false);
24
+ else
25
+ reject(e);
26
+ };
27
+ const onListening = () => {
28
+ server.off('error', onError);
29
+ resolve(true);
30
+ };
31
+ server.once('error', onError);
32
+ server.once('listening', onListening);
33
+ server.listen(port, '127.0.0.1');
34
+ });
35
+ }
36
+ /** In dev mode, a session token that survives restarts, so that `node --watch` restarts do not log the UI out. */
37
+ function devSessionToken(paths) {
38
+ const file = join(paths.home, 'dev-session.json');
39
+ const stored = readJson(file);
40
+ if (typeof stored?.token === 'string')
41
+ return stored.token;
42
+ const token = randomBytes(32).toString('base64url');
43
+ writeFileAtomic(file, JSON.stringify({ token }));
44
+ return token;
45
+ }
46
+ const TMP_MAX_AGE_MS = 60 * 60 * 1000;
47
+ /**
48
+ * Removes leftovers older than an hour. Newer files may belong to an older
49
+ * server that did not stop and is still running next to this one.
50
+ */
51
+ function cleanTmp(dir) {
52
+ const now = Date.now();
53
+ for (const name of readdirSync(dir)) {
54
+ const file = join(dir, name);
55
+ const mtime = statSync(file, { throwIfNoEntry: false })?.mtimeMs;
56
+ if (mtime !== undefined && now - mtime > TMP_MAX_AGE_MS)
57
+ rmSync(file, { recursive: true, force: true });
58
+ }
59
+ }
60
+ export async function serve(dev) {
61
+ const paths = resolvePaths(dev);
62
+ ensureDirs(paths);
63
+ initLog(paths.serverLog, dev);
64
+ cleanTmp(paths.tmpDir);
65
+ // Child processes inherit this directory. A fixed, private directory keeps
66
+ // cmd.exe on Windows from finding executables in the user's project.
67
+ process.chdir(paths.tmpDir);
68
+ const version = packageVersion();
69
+ const adminToken = randomBytes(32).toString('base64url');
70
+ const settings = new SettingsStore(paths.settings);
71
+ const orgs = new OrgRegistry(paths.tmpDir, process.env.SFBENCH_TRANSPORT === 'cli', () => settings.get().requestTimeoutSeconds * 1000);
72
+ let server;
73
+ const shutdown = () => {
74
+ log.info('shutting down');
75
+ removeServerInfo(paths.serverJson, process.pid);
76
+ server?.close();
77
+ server?.closeAllConnections();
78
+ setTimeout(() => process.exit(0), 100).unref();
79
+ };
80
+ const ports = dev ? [DEV_PORT] : Array.from({ length: LAST_PORT - DEFAULT_PORT + 1 }, (_, i) => DEFAULT_PORT + i);
81
+ // Created once: it reads the web UI's files into memory.
82
+ const app = new App({
83
+ version,
84
+ dev,
85
+ port: ports[0],
86
+ adminToken,
87
+ paths,
88
+ orgs,
89
+ settings,
90
+ webRoot: dev ? undefined : webRoot,
91
+ devSessionToken: dev ? devSessionToken(paths) : undefined,
92
+ onShutdown: shutdown,
93
+ });
94
+ for (const port of ports) {
95
+ app.port = port;
96
+ const candidate = app.createServer();
97
+ if (await listen(candidate, port)) {
98
+ server = candidate;
99
+ // Written after listening, and atomically, so that the CLI entry point
100
+ // never sees a server.json for a server that is not ready.
101
+ writeServerInfo(paths.serverJson, { port, adminToken, pid: process.pid, version });
102
+ log.info(`sfbench ${version} listening on 127.0.0.1:${port}${dev ? ' (dev)' : ''}`);
103
+ break;
104
+ }
105
+ }
106
+ if (!server) {
107
+ const message = dev
108
+ ? `Port ${DEV_PORT} is in use. Stop the other development server first.`
109
+ : `No free port between ${DEFAULT_PORT} and ${LAST_PORT}.`;
110
+ log.error(message);
111
+ process.exitCode = 1;
112
+ return;
113
+ }
114
+ process.on('SIGINT', shutdown);
115
+ process.on('SIGTERM', shutdown);
116
+ if (dev)
117
+ process.stderr.write(`\nOpen ${DEV_WEB_URL} (started by \`npm run dev\`, or by \`npm run dev:web\` alongside \`npm run dev:server\`).\n\n`);
118
+ // Load the org list in the background: it can take several seconds. Then
119
+ // check each org's connection, which takes longer still.
120
+ orgs.list().then(() => void orgs.checkConnections(), (e) => log.warn(`could not load the org list: ${e.message}`));
121
+ }
@@ -0,0 +1,112 @@
1
+ // `sf apex run`: runs anonymous Apex and returns the result with its debug
2
+ // log (docs/apex.md "実行の経路"). One of the documented exceptions to "depend
3
+ // only on sf's JSON output" being stable: sf reports compile errors and
4
+ // exceptions as failures whose `data` holds the result as strings. All of that
5
+ // reading lives here, tested against recorded output in __fixtures__.
6
+ import { randomBytes } from 'node:crypto';
7
+ import { rmSync, writeFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { OrgAuthError, isAuthErrorMessage } from "./errors.js";
10
+ import { lastErrorLine, redactUrls, runSf } from "./run.js";
11
+ /**
12
+ * sf failed before or while running, and it cannot be told whether the code
13
+ * ran. `sfName` is sf's error name. The message is for the screen only: it is
14
+ * never logged (docs/apex.md "実行の経路").
15
+ */
16
+ export class ApexRunError extends Error {
17
+ sfName;
18
+ constructor(sfName, message) {
19
+ super(message);
20
+ this.name = 'ApexRunError';
21
+ this.sfName = sfName;
22
+ }
23
+ }
24
+ /** The code could not be written to its file: sf was not started, so nothing ran. */
25
+ export class ApexFileError extends Error {
26
+ constructor(message) {
27
+ super(message);
28
+ this.name = 'ApexFileError';
29
+ }
30
+ }
31
+ export function parseApexOutput(stdout, stderr) {
32
+ let json;
33
+ try {
34
+ json = JSON.parse(stdout);
35
+ }
36
+ catch {
37
+ json = undefined;
38
+ }
39
+ // 1. Ran.
40
+ if (json?.status === 0 && json.result)
41
+ return { kind: 'ran', run: toRun(json.result) };
42
+ // 2. Ran, or reached Salesforce and did not compile. Before 3: an exception's
43
+ // text can hold "invalid_grant" (a failed OAuth callout in the Apex itself).
44
+ // Decided by the name alone: without `data`, it may have run, so it is unknown, never a login error.
45
+ const message = redactUrls(json?.message ?? lastErrorLine(stderr) ?? 'sf apex run failed');
46
+ if (json?.name === 'executeCompileFailure' || json?.name === 'executeRuntimeFailure') {
47
+ return json.data ? { kind: 'ran', run: toRun(json.data) } : { kind: 'failed', sfName: json.name, message };
48
+ }
49
+ // 3. Only now: a login error, before anything ran.
50
+ if (isAuthErrorMessage(message))
51
+ return { kind: 'authError', message };
52
+ // 4. The rest is of unknown outcome.
53
+ return { kind: 'failed', sfName: json?.name ?? 'unreadable', message };
54
+ }
55
+ /** sf writes true or 'True', 1 or '1', and -1 for no position. */
56
+ function toRun(r) {
57
+ const yes = (v) => v === true || v === 'True' || v === 'true';
58
+ const no = (v) => v === false || v === 'False' || v === 'false';
59
+ const text = (v) => (typeof v === 'string' && v ? v : undefined);
60
+ const position = (v) => {
61
+ const n = typeof v === 'number' ? v : typeof v === 'string' ? Number(v) : Number.NaN;
62
+ return Number.isInteger(n) && n > 0 ? n : undefined;
63
+ };
64
+ // Only a stated compile failure says it did not run: anything else may have run.
65
+ const result = no(r.compiled) ? 'compileError' : yes(r.success) ? 'success' : 'runtimeError';
66
+ const run = { result, logs: typeof r.logs === 'string' ? r.logs : '' };
67
+ if (result !== 'success') {
68
+ const line = position(r.line);
69
+ const column = position(r.column);
70
+ if (line !== undefined)
71
+ run.line = line;
72
+ if (column !== undefined)
73
+ run.column = column;
74
+ }
75
+ if (result === 'compileError')
76
+ run.compileProblem = text(r.compileProblem);
77
+ if (result === 'runtimeError') {
78
+ run.exceptionMessage = text(r.exceptionMessage);
79
+ run.exceptionStackTrace = text(r.exceptionStackTrace);
80
+ }
81
+ return run;
82
+ }
83
+ /**
84
+ * Runs anonymous Apex in the org. The code goes in a file so that it never
85
+ * appears on the command line. Throws OrgAuthError (did not run), SfTimeoutError
86
+ * (sf was stopped; Salesforce may still run it), ApexFileError (did not run)
87
+ * or ApexRunError (unknown). `apiVersion` is the only value from the screen on
88
+ * the command line, and only after it was found among the org's versions.
89
+ */
90
+ export async function runApex(username, code, tmpDir, timeoutMs, apiVersion) {
91
+ const fileName = `apex-${randomBytes(8).toString('hex')}.apex`;
92
+ const filePath = join(tmpDir, fileName);
93
+ try {
94
+ try {
95
+ writeFileSync(filePath, code, { mode: 0o600 });
96
+ }
97
+ catch (e) {
98
+ throw new ApexFileError(e.message);
99
+ }
100
+ // An SfTimeoutError from runSf goes to the caller as it is.
101
+ const { stdout, stderr } = await runSf(['apex', 'run', '-f', fileName, ...(apiVersion ? ['--api-version', apiVersion] : []), '--json'], { targetOrg: username, cwd: tmpDir, timeoutMs });
102
+ const output = parseApexOutput(stdout, stderr);
103
+ if (output.kind === 'ran')
104
+ return output.run;
105
+ if (output.kind === 'authError')
106
+ throw new OrgAuthError(username, output.message);
107
+ throw new ApexRunError(output.sfName, output.message);
108
+ }
109
+ finally {
110
+ rmSync(filePath, { force: true });
111
+ }
112
+ }