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,138 @@
1
+ // `sf api request rest`, the fallback transport. One of the documented
2
+ // exceptions to "depend only on sf's JSON output": with --include, sf prints a
3
+ // status line, headers and the body as plain text. All of that parsing lives
4
+ // here, tested against recorded output in __fixtures__.
5
+ import { randomBytes } from 'node:crypto';
6
+ import { rmSync, writeFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { OrgAuthError, SfCliError, isAuthErrorMessage } from "./errors.js";
9
+ import { lastErrorLine, runSf } from "./run.js";
10
+ /** Salesforce says the session (the access token) is invalid: a 401 with INVALID_SESSION_ID. */
11
+ export function isInvalidSession(res) {
12
+ if (res.status !== 401)
13
+ return false;
14
+ // XML when the request asked for it (Accept: application/xml).
15
+ if (/<errorCode>\s*INVALID_SESSION_ID\s*<\/errorCode>/.test(res.body))
16
+ return true;
17
+ try {
18
+ const body = JSON.parse(res.body);
19
+ return Array.isArray(body) && body.some((e) => e?.errorCode === 'INVALID_SESSION_ID');
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ /**
26
+ * A body that `sf api request rest` cannot send as it is: sf reads the request
27
+ * file as JSON and sends JSON.stringify of it, so only JSON goes through, and a
28
+ * number with more digits than a double holds would arrive rounded.
29
+ */
30
+ export class NotJsonBodyError extends Error {
31
+ constructor(reason) {
32
+ super(reason === 'notJson'
33
+ ? 'Only a JSON body can be sent while sfbench uses sf api request rest. Try again once Salesforce can be reached directly.'
34
+ : 'A number in the body would change (it has more digits than a double holds, or is out of its range) while sfbench uses sf api request rest, so the request was not sent. Write it as text, or try again once Salesforce can be reached directly.');
35
+ this.name = 'NotJsonBodyError';
36
+ }
37
+ }
38
+ /** A JSON number as its significant digits and exponent, so that 1.50 and 1.5e0 compare equal. */
39
+ function decimal(text) {
40
+ const m = /^(-?)(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/.exec(text);
41
+ if (!m)
42
+ return text;
43
+ const fraction = m[3] ?? '';
44
+ let digits = (m[2] + fraction).replace(/^0+/, '');
45
+ let exponent = Number(m[4] ?? 0) - fraction.length;
46
+ while (digits.endsWith('0')) {
47
+ digits = digits.slice(0, -1);
48
+ exponent++;
49
+ }
50
+ return digits ? `${m[1]}${digits}e${exponent}` : '0';
51
+ }
52
+ /** Whether sf would send the number as written: it parses it into a double and writes that back. */
53
+ function survivesSf(source, value) {
54
+ return Number.isFinite(value) && decimal(source) === decimal(JSON.stringify(value));
55
+ }
56
+ /** The body as sf will send it, or an error when sf would change it. */
57
+ function bodyForSf(body) {
58
+ let long = false;
59
+ let parsed;
60
+ try {
61
+ // The reviver's third argument (the source text) tells the number as written.
62
+ parsed = JSON.parse(body, function (_key, value, context) {
63
+ if (typeof value === 'number' && context?.source && !survivesSf(context.source, value))
64
+ long = true;
65
+ return value;
66
+ });
67
+ }
68
+ catch {
69
+ throw new NotJsonBodyError('notJson');
70
+ }
71
+ if (long)
72
+ throw new NotJsonBodyError('longNumber');
73
+ return parsed;
74
+ }
75
+ const HEADER_LINE = /^([!#$%&'*+.^_`|~0-9A-Za-z-]+):[ \t]?(.*)$/;
76
+ /**
77
+ * Parses the output of `sf api request rest --include`. sf prints no blank
78
+ * line between the headers and the body, so the body starts at the first line
79
+ * that is not a header. A plain-text body whose first line looks like a header
80
+ * would be misread; Salesforce REST responses are JSON, so this does not occur.
81
+ */
82
+ export function parseIncludedResponse(stdout) {
83
+ // Strip ANSI color codes in case sf colors its output despite NO_COLOR.
84
+ // oxlint-disable-next-line no-control-regex
85
+ const lines = stdout.replace(/\x1b\[[0-9;]*m/g, '').split(/\r?\n/);
86
+ const statusMatch = /^HTTP\/[\d.]+ (\d{3})\b/.exec(lines[0] ?? '');
87
+ if (!statusMatch)
88
+ return undefined;
89
+ const headers = {};
90
+ let i = 1;
91
+ for (; i < lines.length; i++) {
92
+ const m = HEADER_LINE.exec(lines[i]);
93
+ if (!m)
94
+ break;
95
+ headers[m[1].toLowerCase()] = m[2].trim();
96
+ }
97
+ // Skip a blank separator line in case a future sf version prints one.
98
+ if (lines[i] === '')
99
+ i++;
100
+ return { status: Number(statusMatch[1]), headers, body: lines.slice(i).join('\n').trimEnd() };
101
+ }
102
+ /** Sends a REST request through `sf api request rest`. Throws OrgAuthError when the org's auth is no longer valid. */
103
+ export async function apiRequest(username, request, tmpDir, timeoutMs) {
104
+ // The request goes in a file so that user input never appears on the command line.
105
+ const fileName = `req-${randomBytes(8).toString('hex')}.json`;
106
+ const filePath = join(tmpDir, fileName);
107
+ // sf sends JSON.stringify(raw), so the body goes in parsed: a string here
108
+ // would arrive quoted. Without a body, raw is left out and nothing is sent.
109
+ const raw = request.body !== undefined ? bodyForSf(request.body) : undefined;
110
+ const fileBody = {
111
+ url: request.path,
112
+ method: request.method,
113
+ header: Object.entries(request.headers).map(([key, value]) => ({ key, value })),
114
+ body: { mode: 'raw', raw },
115
+ };
116
+ writeFileSync(filePath, JSON.stringify(fileBody), { mode: 0o600 });
117
+ try {
118
+ const { stdout, stderr } = await runSf(['api', 'request', 'rest', '--file', fileName, '--include'], {
119
+ targetOrg: username,
120
+ cwd: tmpDir,
121
+ timeoutMs,
122
+ });
123
+ const response = parseIncludedResponse(stdout);
124
+ if (response) {
125
+ // sf refreshes the token itself, so an invalid session here needs a new login.
126
+ if (isInvalidSession(response))
127
+ throw new OrgAuthError(username, 'Salesforce rejected the session (401).');
128
+ return response;
129
+ }
130
+ const message = lastErrorLine(stderr) ?? 'sf api request rest returned no response';
131
+ if (isAuthErrorMessage(message))
132
+ throw new OrgAuthError(username, message);
133
+ throw new SfCliError(message);
134
+ }
135
+ finally {
136
+ rmSync(filePath, { force: true });
137
+ }
138
+ }
@@ -0,0 +1,143 @@
1
+ import { OrgAuthError, SfCliError, isAuthErrorMessage } from "./errors.js";
2
+ import { lastErrorLine, runSf, runSfJson } from "./run.js";
3
+ function kindOf(o) {
4
+ if (o.isScratch)
5
+ return 'scratch';
6
+ if (o.isSandbox)
7
+ return 'sandbox';
8
+ if (o.isDevHub)
9
+ return 'devhub';
10
+ return 'production';
11
+ }
12
+ /**
13
+ * Converts `sf org list --json` output to Org objects. Only the fields the UI
14
+ * needs are copied: sf's output also contains each org's access token, which
15
+ * must never reach the browser.
16
+ */
17
+ export function toOrgs(result) {
18
+ const seen = new Set();
19
+ const orgs = [];
20
+ for (const o of [...(result.nonScratchOrgs ?? []), ...(result.scratchOrgs ?? [])]) {
21
+ if (!o.username || !o.instanceUrl || seen.has(o.username))
22
+ continue;
23
+ seen.add(o.username);
24
+ orgs.push({
25
+ username: o.username,
26
+ alias: o.alias || undefined,
27
+ instanceUrl: o.instanceUrl,
28
+ kind: kindOf(o),
29
+ edition: o.orgEdition || o.edition || undefined,
30
+ expirationDate: o.isScratch ? o.expirationDate : undefined,
31
+ isExpired: Boolean(o.isExpired),
32
+ availability: scratchAvailability(o),
33
+ });
34
+ }
35
+ // Orgs with an alias first (by alias), then the rest (by username): orgs
36
+ // without an alias are usually leftovers such as test users.
37
+ return orgs.sort((a, b) => a.alias && b.alias ? a.alias.localeCompare(b.alias) : a.alias ? -1 : b.alias ? 1 : a.username.localeCompare(b.username));
38
+ }
39
+ /**
40
+ * Scratch orgs carry their state, recorded by the Dev Hub, even in the fast
41
+ * list. The sf CLI does not check their connection, so an active scratch org
42
+ * within its expiration date is treated as usable. Other orgs need the
43
+ * connection check.
44
+ */
45
+ function scratchAvailability(o) {
46
+ if (!o.isScratch)
47
+ return 'unknown';
48
+ if (o.isExpired || (o.status !== undefined && o.status !== 'Active'))
49
+ return 'unavailable';
50
+ return o.status === 'Active' ? 'ok' : 'unknown';
51
+ }
52
+ /** The command that logs in to an org again. */
53
+ export function reloginCommand(org) {
54
+ return `sf org login web --instance-url ${org.instanceUrl}${org.alias ? ` -a ${org.alias}` : ''}`;
55
+ }
56
+ // `sf org list` reports a deleted org or an expired trial as an HTML response with HTTP 420.
57
+ const GONE_PATTERNS = [/HTTP status code: 420/, /HTTP response contains html content/i];
58
+ /**
59
+ * Reads `connectedStatus`. Only clear signals hide an org: a network problem
60
+ * while checking (for example, being offline) must not hide every org.
61
+ */
62
+ export function availabilityFromStatus(connectedStatus) {
63
+ if (!connectedStatus)
64
+ return 'unknown';
65
+ if (connectedStatus === 'Connected')
66
+ return 'ok';
67
+ if (isAuthErrorMessage(connectedStatus))
68
+ return 'authError';
69
+ if (GONE_PATTERNS.some((p) => p.test(connectedStatus)))
70
+ return 'unavailable';
71
+ return 'unknown';
72
+ }
73
+ /** Maps username → availability from `sf org list --json` with connection status. */
74
+ export function toAvailability(result) {
75
+ const map = new Map();
76
+ for (const o of result.nonScratchOrgs ?? []) {
77
+ if (o.username)
78
+ map.set(o.username, availabilityFromStatus(o.connectedStatus));
79
+ }
80
+ return map;
81
+ }
82
+ /**
83
+ * Checks every org's connection. Slow (about 12 seconds with 50 orgs) and
84
+ * calls each org's API, so it runs once at startup and when the user asks.
85
+ */
86
+ export async function checkOrgConnections(cwd) {
87
+ const result = await runSfJson(['org', 'list'], { cwd, timeoutMs: 300_000 });
88
+ return toAvailability(result);
89
+ }
90
+ export async function listOrgs(cwd) {
91
+ // --skip-connection-status: checking every org's connection takes far too long.
92
+ const result = await runSfJson(['org', 'list', '--skip-connection-status'], {
93
+ cwd,
94
+ timeoutMs: 120_000,
95
+ });
96
+ return toOrgs(result);
97
+ }
98
+ /**
99
+ * The default org (alias or username) as configured for `cwd`. The CLI entry
100
+ * point runs this in the user's working directory, because projects can set
101
+ * their own default org.
102
+ */
103
+ export async function getDefaultOrg(cwd) {
104
+ return parseDefaultOrg(await runSfJson(['config', 'get', 'target-org'], { cwd }));
105
+ }
106
+ /** Reads the `result` of `sf config get target-org --json`. There is no `value` when no default org is set. */
107
+ export function parseDefaultOrg(result) {
108
+ const value = Array.isArray(result) ? result[0]?.value : undefined;
109
+ return typeof value === 'string' && value ? value : undefined;
110
+ }
111
+ /**
112
+ * The org's access token. sf refreshes an expired access token before returning
113
+ * it. When the refresh token itself is no longer valid, sf returns the stale
114
+ * access token without an error; the caller notices through a 401.
115
+ */
116
+ export async function getAccessToken(username, cwd) {
117
+ const result = await runSfJson(['org', 'auth', 'show-access-token', '--no-prompt'], {
118
+ targetOrg: username,
119
+ cwd,
120
+ });
121
+ return parseAccessToken(result);
122
+ }
123
+ /** Reads the `result` of `sf org auth show-access-token --json`. */
124
+ export function parseAccessToken(result) {
125
+ const token = result?.accessToken;
126
+ // A real token has the form "<org id>!<secret>"; anything else means sf masked it.
127
+ if (typeof token !== 'string' || !token.includes('!')) {
128
+ throw new SfCliError('sf did not return a usable access token');
129
+ }
130
+ return token;
131
+ }
132
+ /** Opens the org's home page in the default browser, logged in, with `sf org open`. */
133
+ export async function openOrg(username, cwd) {
134
+ // stdout contains the frontdoor URL, which carries a session: it is never read or logged.
135
+ const { exitCode, stderr } = await runSf(['org', 'open'], { targetOrg: username, cwd });
136
+ // stderr can contain the frontdoor URL, which carries a session; lastErrorLine removes URLs.
137
+ if (exitCode !== 0) {
138
+ const message = lastErrorLine(stderr) ?? 'unknown error';
139
+ if (isAuthErrorMessage(message))
140
+ throw new OrgAuthError(username, message);
141
+ throw new SfCliError(`sf org open failed: ${message}`);
142
+ }
143
+ }
@@ -0,0 +1,38 @@
1
+ /** The sf CLI is not installed or not on PATH. */
2
+ export class SfNotFoundError extends Error {
3
+ constructor() {
4
+ super('The Salesforce CLI (sf) was not found. Install it from https://developer.salesforce.com/tools/salesforcecli');
5
+ this.name = 'SfNotFoundError';
6
+ }
7
+ }
8
+ /** An sf command failed. `sfName` is the `name` from sf's JSON error, when there was one. */
9
+ export class SfCliError extends Error {
10
+ sfName;
11
+ constructor(message, sfName) {
12
+ super(message);
13
+ this.name = 'SfCliError';
14
+ this.sfName = sfName;
15
+ }
16
+ }
17
+ /** An sf command did not finish in time and was stopped. */
18
+ export class SfTimeoutError extends SfCliError {
19
+ constructor(message) {
20
+ super(message);
21
+ this.name = 'SfTimeoutError';
22
+ }
23
+ }
24
+ /** The org's auth is no longer valid (for example, the refresh token expired). */
25
+ export class OrgAuthError extends Error {
26
+ username;
27
+ constructor(username, message) {
28
+ super(message);
29
+ this.name = 'OrgAuthError';
30
+ this.username = username;
31
+ }
32
+ }
33
+ // sf reports an expired refresh token with a generic error name, so the
34
+ // message is the only reliable signal. See docs/design.md.
35
+ const AUTH_ERROR_PATTERNS = [/unable to refresh session/i, /expired access\/refresh token/i, /invalid_grant/i];
36
+ export function isAuthErrorMessage(message) {
37
+ return AUTH_ERROR_PATTERNS.some((p) => p.test(message));
38
+ }
@@ -0,0 +1,163 @@
1
+ // Runs the sf CLI as a child process. See docs/design.md ("sf CLI の呼び出し方").
2
+ //
3
+ // - sf is started by its absolute path, resolved from PATH without the current
4
+ // directory, so that an untrusted `sf.cmd` in the working directory is never run.
5
+ // - Arguments are fixed strings only. The org goes in SF_TARGET_ORG and user
6
+ // input goes in files, because on Windows `sf.cmd` needs a shell and the shell
7
+ // would interpret symbols in the arguments.
8
+ // - At most MAX_CONCURRENT sf processes run at once; the rest wait in a queue.
9
+ import { execFile } from 'node:child_process';
10
+ import { accessSync, constants } from 'node:fs';
11
+ import { delimiter, isAbsolute, join } from 'node:path';
12
+ import { SfCliError, SfNotFoundError, SfTimeoutError } from "./errors.js";
13
+ const isWindows = process.platform === 'win32';
14
+ const MAX_CONCURRENT = 4;
15
+ const DEFAULT_TIMEOUT_MS = 60_000;
16
+ const MAX_OUTPUT_BYTES = 512 * 1024 * 1024;
17
+ let sfPath;
18
+ /** Finds sf on PATH. Relative PATH entries (including ".") are ignored. */
19
+ export function resolveSfPath(env = process.env) {
20
+ if (sfPath)
21
+ return sfPath;
22
+ const names = isWindows ? ['sf.cmd', 'sf.exe', 'sf.bat'] : ['sf'];
23
+ const dirs = (env.PATH ?? env.Path ?? '').split(delimiter).filter((d) => d && isAbsolute(d));
24
+ for (const dir of dirs) {
25
+ for (const name of names) {
26
+ const candidate = join(dir, name);
27
+ try {
28
+ accessSync(candidate, isWindows ? constants.F_OK : constants.X_OK);
29
+ sfPath = candidate;
30
+ return candidate;
31
+ }
32
+ catch {
33
+ // Try the next one.
34
+ }
35
+ }
36
+ }
37
+ throw new SfNotFoundError();
38
+ }
39
+ let running = 0;
40
+ const waiting = [];
41
+ async function acquire() {
42
+ if (running < MAX_CONCURRENT) {
43
+ running++;
44
+ return;
45
+ }
46
+ await new Promise((resolve) => waiting.push(resolve));
47
+ }
48
+ function release() {
49
+ const next = waiting.shift();
50
+ if (next)
51
+ next();
52
+ else
53
+ running--;
54
+ }
55
+ /** Runs `sf <args>` and returns its output. Resolves even when sf exits with a non-zero code. */
56
+ export async function runSf(args, options) {
57
+ const file = resolveSfPath();
58
+ const env = {
59
+ ...process.env,
60
+ SF_JSON_TO_STDOUT: 'true',
61
+ SF_DISABLE_AUTOUPDATE: 'true',
62
+ SF_AUTOUPDATE_DISABLE: 'true',
63
+ NO_COLOR: '1',
64
+ FORCE_COLOR: '0',
65
+ };
66
+ delete env.SF_TARGET_ORG;
67
+ if (options.targetOrg)
68
+ env.SF_TARGET_ORG = options.targetOrg;
69
+ if (isWindows)
70
+ env.NoDefaultCurrentDirectoryInExePath = '1';
71
+ const label = `sf ${args.slice(0, 2).join(' ')}`;
72
+ await acquire();
73
+ try {
74
+ return await new Promise((resolve, reject) => {
75
+ let timedOut = false;
76
+ const child = execFile(
77
+ // With a shell, Node joins the command line without quoting, so quote the path.
78
+ isWindows ? `"${file}"` : file, args, {
79
+ cwd: options.cwd,
80
+ env,
81
+ shell: isWindows,
82
+ windowsHide: true,
83
+ maxBuffer: MAX_OUTPUT_BYTES,
84
+ encoding: 'utf8',
85
+ }, (error, stdout, stderr) => {
86
+ clearTimeout(timer);
87
+ const code = error?.code;
88
+ if (code === 'ENOENT')
89
+ reject(new SfNotFoundError());
90
+ else if (timedOut)
91
+ reject(new SfTimeoutError(`${label} timed out`));
92
+ else if (code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER')
93
+ reject(new SfCliError(`${label} printed too much output`));
94
+ else
95
+ resolve({ stdout, stderr, exitCode: typeof error?.code === 'number' ? error.code : error ? 1 : 0 });
96
+ });
97
+ const timer = setTimeout(() => {
98
+ timedOut = true;
99
+ killTree(child.pid, () => child.kill('SIGKILL'));
100
+ }, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
101
+ });
102
+ }
103
+ finally {
104
+ release();
105
+ }
106
+ }
107
+ /**
108
+ * On Windows, sf runs under cmd.exe, so killing the child only stops cmd.exe
109
+ * and leaves sf running. taskkill /T stops the whole process tree.
110
+ */
111
+ function killTree(pid, fallback) {
112
+ if (!isWindows || pid === undefined) {
113
+ fallback();
114
+ return;
115
+ }
116
+ const taskkill = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'taskkill.exe');
117
+ execFile(taskkill, ['/T', '/F', '/PID', String(pid)], { windowsHide: true }, (error) => {
118
+ if (error)
119
+ fallback();
120
+ });
121
+ }
122
+ /** Runs an sf command with --json and returns `result`. Throws SfCliError when sf reports an error. */
123
+ export async function runSfJson(args, options) {
124
+ const output = await runSf([...args, '--json'], options);
125
+ return parseSfJsonOutput(output, `sf ${args.slice(0, 2).join(' ')}`);
126
+ }
127
+ /** Reads sf's --json output: `result` on success, SfCliError with sf's `name` and `message` on failure. */
128
+ export function parseSfJsonOutput({ stdout, stderr, exitCode }, label) {
129
+ let json;
130
+ try {
131
+ json = JSON.parse(stdout);
132
+ }
133
+ catch {
134
+ json = undefined;
135
+ }
136
+ if (json && json.status === 0 && exitCode === 0 && json.result !== undefined)
137
+ return json.result;
138
+ if (json?.message)
139
+ throw new SfCliError(redactUrls(json.message), json.name);
140
+ throw new SfCliError(lastErrorLine(stderr) ?? `${label} failed with exit code ${exitCode}`);
141
+ }
142
+ /**
143
+ * Extracts the error message from sf's human-readable stderr ("Error (Name): message").
144
+ * URLs are removed: sf can print URLs that carry a session (such as frontdoor
145
+ * URLs), and these messages reach the log and the browser.
146
+ */
147
+ export function lastErrorLine(stderr) {
148
+ const lines = stderr.split(/\r?\n/).map((l) => l.trim());
149
+ const errorLine = lines.find((l) => /^Error \(.+?\): /.test(l));
150
+ if (errorLine)
151
+ return redactUrls(errorLine.replace(/^Error \(.+?\): /, ''));
152
+ const meaningful = lines.filter((l) => l && !l.startsWith('›'));
153
+ const last = meaningful.at(-1);
154
+ return last === undefined ? undefined : redactUrls(last);
155
+ }
156
+ /**
157
+ * Removes URLs and Salesforce paths from text that goes to the log or the
158
+ * browser: absolute URLs can carry a session, and /services/ paths can carry
159
+ * SOQL in the query string.
160
+ */
161
+ export function redactUrls(text) {
162
+ return text.replace(/https?:\/\/\S+/g, '<URL>').replace(/\/services\/\S+/g, '<PATH>');
163
+ }
@@ -0,0 +1,103 @@
1
+ // server.json and server.lock. Their format is part of the contract that must
2
+ // not change between versions: a newer CLI entry point has to find, use or
3
+ // stop an older server. See docs/design.md ("バージョンをまたいで変えない契約").
4
+ import { randomBytes } from 'node:crypto';
5
+ import { closeSync, openSync, readFileSync, renameSync, rmSync, statSync, writeSync } from 'node:fs';
6
+ import { readJson, writeFileAtomic } from "./fsutil.js";
7
+ export function readServerInfo(file) {
8
+ const v = readJson(file);
9
+ if (v &&
10
+ typeof v.port === 'number' &&
11
+ typeof v.adminToken === 'string' &&
12
+ typeof v.pid === 'number' &&
13
+ typeof v.version === 'string') {
14
+ return { port: v.port, adminToken: v.adminToken, pid: v.pid, version: v.version };
15
+ }
16
+ return undefined;
17
+ }
18
+ export function writeServerInfo(file, info) {
19
+ writeFileAtomic(file, JSON.stringify(info, null, 2));
20
+ }
21
+ /** Removes server.json if it still describes this process. */
22
+ export function removeServerInfo(file, pid) {
23
+ if (readServerInfo(file)?.pid === pid)
24
+ rmSync(file, { force: true });
25
+ }
26
+ /** Compares "x.y.z" versions. Pre-release suffixes are ignored. */
27
+ export function compareVersions(a, b) {
28
+ const parse = (v) => v.split('-')[0].split('.').map((n) => Number.parseInt(n, 10) || 0);
29
+ const pa = parse(a);
30
+ const pb = parse(b);
31
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
32
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
33
+ if (d !== 0)
34
+ return Math.sign(d);
35
+ }
36
+ return 0;
37
+ }
38
+ export function isProcessAlive(pid) {
39
+ try {
40
+ process.kill(pid, 0);
41
+ return true;
42
+ }
43
+ catch (e) {
44
+ // EPERM: the process exists but belongs to someone else.
45
+ return e.code === 'EPERM';
46
+ }
47
+ }
48
+ const LOCK_WAIT_MS = 20_000;
49
+ const LOCK_STALE_MS = 60_000;
50
+ const LOCK_POLL_MS = 200;
51
+ export class LockTimeoutError extends Error {
52
+ constructor() {
53
+ super('Another sfbench is starting. Try again in a moment.');
54
+ this.name = 'LockTimeoutError';
55
+ }
56
+ }
57
+ function isStale(file, now) {
58
+ let content;
59
+ try {
60
+ content = JSON.parse(readFileSync(file, 'utf8'));
61
+ }
62
+ catch {
63
+ // Unreadable: either being written right now or corrupt. Judge by age.
64
+ const mtime = statSync(file, { throwIfNoEntry: false })?.mtimeMs;
65
+ return mtime === undefined || now - mtime > LOCK_STALE_MS;
66
+ }
67
+ return now - content.createdAt > LOCK_STALE_MS || !isProcessAlive(content.pid);
68
+ }
69
+ /**
70
+ * Takes server.lock, waiting up to 20 seconds. Returns a function that releases it.
71
+ * A lock whose process is gone, or that is older than 60 seconds, is stale.
72
+ */
73
+ export async function acquireLock(file) {
74
+ const deadline = Date.now() + LOCK_WAIT_MS;
75
+ for (;;) {
76
+ try {
77
+ const fd = openSync(file, 'wx', 0o600);
78
+ writeSync(fd, JSON.stringify({ pid: process.pid, createdAt: Date.now() }));
79
+ closeSync(fd);
80
+ return () => rmSync(file, { force: true });
81
+ }
82
+ catch (e) {
83
+ if (e.code !== 'EEXIST')
84
+ throw e;
85
+ }
86
+ if (isStale(file, Date.now())) {
87
+ // Rename before deleting: if two processes find the same stale lock, only
88
+ // one rename succeeds, so neither deletes a lock the other just created.
89
+ const aside = `${file}.stale-${randomBytes(6).toString('hex')}`;
90
+ try {
91
+ renameSync(file, aside);
92
+ rmSync(aside, { force: true });
93
+ }
94
+ catch {
95
+ // Someone else handled it.
96
+ }
97
+ continue;
98
+ }
99
+ if (Date.now() > deadline)
100
+ throw new LockTimeoutError();
101
+ await new Promise((resolve) => setTimeout(resolve, LOCK_POLL_MS));
102
+ }
103
+ }