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.
- package/LICENSE +21 -0
- package/README.ja.md +165 -0
- package/README.md +164 -0
- package/dist/server/ai/agent.js +132 -0
- package/dist/server/ai/agentTasks.js +296 -0
- package/dist/server/ai/anthropic.js +36 -0
- package/dist/server/ai/conversation.js +3 -0
- package/dist/server/ai/errors.js +75 -0
- package/dist/server/ai/openai.js +53 -0
- package/dist/server/ai/tools.js +249 -0
- package/dist/server/browser.js +26 -0
- package/dist/server/cli.js +151 -0
- package/dist/server/fsutil.js +17 -0
- package/dist/server/http/security.js +37 -0
- package/dist/server/http/server.js +778 -0
- package/dist/server/launcher.js +170 -0
- package/dist/server/log.js +33 -0
- package/dist/server/orgs.js +143 -0
- package/dist/server/paths.js +35 -0
- package/dist/server/records.js +118 -0
- package/dist/server/rest.js +120 -0
- package/dist/server/salesforce.js +348 -0
- package/dist/server/serve.js +121 -0
- package/dist/server/sf/apexRun.js +112 -0
- package/dist/server/sf/apiRequest.js +138 -0
- package/dist/server/sf/commands.js +143 -0
- package/dist/server/sf/errors.js +38 -0
- package/dist/server/sf/run.js +163 -0
- package/dist/server/state.js +103 -0
- package/dist/server/store.js +201 -0
- package/dist/server/version.js +8 -0
- package/dist/shared/api.js +31 -0
- package/dist/shared/namespace.js +28 -0
- package/dist/web/assets/index-CW8D9pEg.js +210 -0
- package/dist/web/assets/index-LqgSbazj.css +1 -0
- package/dist/web/favicon.svg +1 -0
- package/dist/web/index.html +14 -0
- package/dist/web/licenses.txt +1434 -0
- package/package.json +78 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// The tools the AI calls to read the org's metadata (docs/ai-agent.md "ツール",
|
|
2
|
+
// "SOQL のときのツール"): searching objects and listing an object's fields.
|
|
3
|
+
// Only metadata goes back, never record values. Names are full API names, with
|
|
4
|
+
// their namespace, except where SOQL may leave the org's own namespace out.
|
|
5
|
+
import { findByName, stripNamespace } from "../../shared/namespace.js";
|
|
6
|
+
/** The most objects one search returns. */
|
|
7
|
+
const MAX_FOUND = 50;
|
|
8
|
+
const MAX_PICKLIST_VALUES = 50;
|
|
9
|
+
/** Objects that are rarely queried and would crowd a SOQL search (sharing, history, feeds, change events): listed last. */
|
|
10
|
+
const NOISE = /(Share|History|Feed|ChangeEvent)$/;
|
|
11
|
+
const encoder = new TextEncoder();
|
|
12
|
+
export const METADATA_TOOLS = [
|
|
13
|
+
{
|
|
14
|
+
name: 'findObjects',
|
|
15
|
+
description: `Finds the org's objects whose API name or label contains any of the keywords (case-insensitive). Returns "ApiName Label" lines, at most ${MAX_FOUND}; custom objects (made in this org or by a package) end with "(custom)".`,
|
|
16
|
+
schema: {
|
|
17
|
+
type: 'object',
|
|
18
|
+
properties: { keywords: { type: 'array', items: { type: 'string' }, description: 'Words to look for, in English or the language of the labels.' } },
|
|
19
|
+
required: ['keywords'],
|
|
20
|
+
additionalProperties: false,
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: 'describeObject',
|
|
25
|
+
description: 'Returns one object: its child relationships ("Name -> ChildObject"), then its fields one per line: "Name type(referenceTo) relationship Name [readonly] Label [picklist values]". A large object comes with less detail, or with names only; then use findFields for the fields you need.',
|
|
26
|
+
schema: {
|
|
27
|
+
type: 'object',
|
|
28
|
+
properties: { name: { type: 'string', description: 'The API name of the object.' } },
|
|
29
|
+
required: ['name'],
|
|
30
|
+
additionalProperties: false,
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'findFields',
|
|
35
|
+
description: `Finds the fields and child relationships of one object whose name, relationship name or label contains any of the keywords (case-insensitive), with labels and picklist values (at most ${MAX_PICKLIST_VALUES} values a field); exact names first, at most ${MAX_FOUND} lines.`,
|
|
36
|
+
schema: {
|
|
37
|
+
type: 'object',
|
|
38
|
+
properties: {
|
|
39
|
+
object: { type: 'string', description: 'The API name of the object.' },
|
|
40
|
+
keywords: { type: 'array', items: { type: 'string' }, description: 'Field or relationship names, or words of their labels.' },
|
|
41
|
+
},
|
|
42
|
+
required: ['object', 'keywords'],
|
|
43
|
+
additionalProperties: false,
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
/** A name as the AI is shown it. */
|
|
48
|
+
export function shown(source, name) {
|
|
49
|
+
return source.omitNamespace ? stripNamespace(name, source.omitNamespace) : name;
|
|
50
|
+
}
|
|
51
|
+
/** The object in the list that a name means, matched without regard to case or the org's namespace. */
|
|
52
|
+
export async function findObject(source, name) {
|
|
53
|
+
return findByName(await source.objects(), name.trim(), source.omitNamespace);
|
|
54
|
+
}
|
|
55
|
+
export function findObjectsText(objects, keywords, source = {}) {
|
|
56
|
+
const words = keywords.map((k) => k.trim().toLowerCase()).filter(Boolean);
|
|
57
|
+
if (words.length === 0)
|
|
58
|
+
return 'Give at least one keyword.';
|
|
59
|
+
const searched = source.soql ? objects.filter((o) => o.queryable !== false) : objects;
|
|
60
|
+
const matched = searched.filter((o) => words.some((w) => o.name.toLowerCase().includes(w) || o.label.toLowerCase().includes(w)));
|
|
61
|
+
// Still found when asked for (LoginHistory), but after the objects usually meant.
|
|
62
|
+
const found = source.soql ? [...matched.filter((o) => !NOISE.test(o.name)), ...matched.filter((o) => NOISE.test(o.name))] : matched;
|
|
63
|
+
if (found.length === 0)
|
|
64
|
+
return 'No object matches.';
|
|
65
|
+
const lines = found.slice(0, MAX_FOUND).map((o) => `${shown(source, o.name)} ${o.label}${o.custom ? ' (custom)' : ''}`);
|
|
66
|
+
if (found.length > MAX_FOUND)
|
|
67
|
+
lines.push(`(${found.length - MAX_FOUND} more: use more specific keywords)`);
|
|
68
|
+
return lines.join('\n');
|
|
69
|
+
}
|
|
70
|
+
/** The most one tool result returns, so that one large object leaves room for the objects read after it. */
|
|
71
|
+
export const MAX_READ_BYTES = 40_000;
|
|
72
|
+
const bytes = (text) => encoder.encode(text).length;
|
|
73
|
+
const NEAR_LIMIT = 'Not returned: the limit of what the tools return is near. Answer with what you have.';
|
|
74
|
+
/** Below this much budget, findFields could not return even a few lines: the tools are near their limit. */
|
|
75
|
+
const MIN_USEFUL_BYTES = 4_000;
|
|
76
|
+
function fieldLine(f, name, full) {
|
|
77
|
+
const parts = [name(f.name), f.type === 'reference' && f.referenceTo?.length ? `reference(${f.referenceTo.map(name).join(',')})` : f.type];
|
|
78
|
+
if (f.relationshipName)
|
|
79
|
+
parts.push(`relationship ${name(f.relationshipName)}`);
|
|
80
|
+
if (f.updateable === false && f.createable === false)
|
|
81
|
+
parts.push('readonly');
|
|
82
|
+
if (!full)
|
|
83
|
+
return parts.join(' ');
|
|
84
|
+
parts.push(f.label);
|
|
85
|
+
const active = (f.picklistValues ?? []).filter((v) => v.active);
|
|
86
|
+
if (active.length === 0)
|
|
87
|
+
return parts.join(' ');
|
|
88
|
+
const more = active.length > MAX_PICKLIST_VALUES ? ` (+${active.length - MAX_PICKLIST_VALUES} more)` : '';
|
|
89
|
+
return `${parts.join(' ')} [${active
|
|
90
|
+
.slice(0, MAX_PICKLIST_VALUES)
|
|
91
|
+
.map((v) => v.value)
|
|
92
|
+
.join(', ')}]${more}`;
|
|
93
|
+
}
|
|
94
|
+
const childLine = (r, name) => `${name(r.relationshipName)} -> ${name(r.childSObject)}`;
|
|
95
|
+
const childrenOf = (d) => (d.childRelationships ?? []).filter((r) => Boolean(r.relationshipName));
|
|
96
|
+
/**
|
|
97
|
+
* The forms of describeObject, from the most detail to the least: each names
|
|
98
|
+
* every child relationship and field (docs/ai-agent.md "大きなオブジェクト").
|
|
99
|
+
*/
|
|
100
|
+
const FORMS = [
|
|
101
|
+
(d, name) => describeLines(d, name, true).join('\n'),
|
|
102
|
+
(d, name) => [`Object ${name(d.name)} (${d.label})`, '(Labels and picklist values are left out: use findFields to read the fields you need in full.)', ...describeLines(d, name, false).slice(1)].join('\n'),
|
|
103
|
+
(d, name) => [
|
|
104
|
+
`Object ${name(d.name)} (${d.label})`,
|
|
105
|
+
'(Types, labels and picklist values are left out: use findFields to read the fields and child relationships you need.)',
|
|
106
|
+
`Child relationships: ${childrenOf(d)
|
|
107
|
+
.map((r) => name(r.relationshipName))
|
|
108
|
+
.join(', ')}`,
|
|
109
|
+
`Fields: ${d.fields.map((f) => name(f.name)).join(', ')}`,
|
|
110
|
+
].join('\n'),
|
|
111
|
+
];
|
|
112
|
+
function describeLines(d, name, full) {
|
|
113
|
+
const lines = [`Object ${name(d.name)} (${d.label})`];
|
|
114
|
+
const children = childrenOf(d);
|
|
115
|
+
if (children.length)
|
|
116
|
+
lines.push('Child relationships:', ...children.map((r) => childLine(r, name)));
|
|
117
|
+
lines.push('Fields:', ...d.fields.map((f) => fieldLine(f, name, full)));
|
|
118
|
+
return lines;
|
|
119
|
+
}
|
|
120
|
+
/** The object in full, as the first form of describeObject. */
|
|
121
|
+
export function describeText(d, source = {}) {
|
|
122
|
+
return FORMS[0](d, (n) => shown(source, n));
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* A text cut to the budget, on a line, saying so: the last guard for a tool
|
|
126
|
+
* result (the tools keep within the budget themselves).
|
|
127
|
+
*/
|
|
128
|
+
export function cutToBudget(text, budget) {
|
|
129
|
+
if (bytes(text) <= budget)
|
|
130
|
+
return text;
|
|
131
|
+
const note = '\n(Cut here: the limit of what the tools return is reached. Answer with what you have.)';
|
|
132
|
+
const room = Math.max(0, budget - bytes(note));
|
|
133
|
+
const kept = [];
|
|
134
|
+
let used = 0;
|
|
135
|
+
for (const line of text.split('\n')) {
|
|
136
|
+
const size = bytes(line) + 1;
|
|
137
|
+
if (used + size > room)
|
|
138
|
+
break;
|
|
139
|
+
kept.push(line);
|
|
140
|
+
used += size;
|
|
141
|
+
}
|
|
142
|
+
return kept.join('\n') + note;
|
|
143
|
+
}
|
|
144
|
+
export const newReadState = () => ({ listed: new Set() });
|
|
145
|
+
/** An object's describe, matched in the list; undefined when the org has no such object. */
|
|
146
|
+
async function described(source, name) {
|
|
147
|
+
// Read with the list's spelling, so that one describe is not read again under another case.
|
|
148
|
+
const object = await findObject(source, name);
|
|
149
|
+
const d = object ? await source.describe(object.name) : undefined;
|
|
150
|
+
// Anything but a describe (a cache shared with other answers) is no object.
|
|
151
|
+
return object && d && Array.isArray(d.fields) ? { object, d } : undefined;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* describeObject: the form with the most detail that fits `budget` and one
|
|
155
|
+
* result. Undefined when the org has no such object.
|
|
156
|
+
*/
|
|
157
|
+
export async function readObject(source, name, budget, state) {
|
|
158
|
+
const found = await described(source, name);
|
|
159
|
+
if (!found)
|
|
160
|
+
return undefined;
|
|
161
|
+
const shownName = shown(source, found.object.name);
|
|
162
|
+
if (state.listed.has(found.object.name))
|
|
163
|
+
return `${shownName} is already above in this conversation: use it, and findFields for what was left out.`;
|
|
164
|
+
const show = (n) => shown(source, n);
|
|
165
|
+
// The detailed forms within one result; the names, the least the AI needs, within all the budget left.
|
|
166
|
+
const rooms = [Math.min(budget, MAX_READ_BYTES), Math.min(budget, MAX_READ_BYTES), budget];
|
|
167
|
+
for (const [i, form] of FORMS.entries()) {
|
|
168
|
+
const text = form(found.d, show);
|
|
169
|
+
if (bytes(text) > rooms[i])
|
|
170
|
+
continue;
|
|
171
|
+
state.listed.add(found.object.name);
|
|
172
|
+
return text;
|
|
173
|
+
}
|
|
174
|
+
// Too many names for the budget, with enough left to find fields: the AI finds what it needs instead.
|
|
175
|
+
if (budget < MIN_USEFUL_BYTES)
|
|
176
|
+
return NEAR_LIMIT;
|
|
177
|
+
state.listed.add(found.object.name);
|
|
178
|
+
return `Object ${shownName} (${found.d.label}) has ${found.d.fields.length} fields and ${childrenOf(found.d).length} child relationships, too many to list: use findFields to find the ones you need.`;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* findFields: the fields and child relationships whose names or labels hold a
|
|
182
|
+
* keyword, in full; exact names first, within `budget` and one result.
|
|
183
|
+
*/
|
|
184
|
+
export async function findFieldsText(source, objectName, keywords, budget) {
|
|
185
|
+
const found = await described(source, objectName);
|
|
186
|
+
if (!found)
|
|
187
|
+
return undefined;
|
|
188
|
+
const words = keywords.map((k) => k.trim().toLowerCase()).filter(Boolean);
|
|
189
|
+
if (words.length === 0)
|
|
190
|
+
return 'Give at least one keyword.';
|
|
191
|
+
const name = (n) => shown(source, n);
|
|
192
|
+
// Both the name as shown and the full name, so that either spelling finds it.
|
|
193
|
+
const names = (n) => [n.toLowerCase(), name(n).toLowerCase()];
|
|
194
|
+
const exact = (n) => (n ? names(n).some((x) => words.includes(x)) : false);
|
|
195
|
+
const exactField = (f) => exact(f.name) || exact(f.relationshipName);
|
|
196
|
+
const contains = (n, label = '') => [...names(n), label.toLowerCase()].some((x) => words.some((w) => x.includes(w)));
|
|
197
|
+
// A reference is also found by its relationship name (Account__r), as SOQL follows it by that name.
|
|
198
|
+
const fields = found.d.fields.filter((f) => contains(f.name, f.label) || (f.relationshipName ? contains(f.relationshipName) : false));
|
|
199
|
+
const children = childrenOf(found.d).filter((r) => contains(r.relationshipName) || contains(r.childSObject));
|
|
200
|
+
const lines = [
|
|
201
|
+
...fields.filter(exactField).map((f) => fieldLine(f, name, true)),
|
|
202
|
+
...children.filter((r) => exact(r.relationshipName)).map((r) => `child relationship ${childLine(r, name)}`),
|
|
203
|
+
...fields.filter((f) => !exactField(f)).map((f) => fieldLine(f, name, true)),
|
|
204
|
+
...children.filter((r) => !exact(r.relationshipName)).map((r) => `child relationship ${childLine(r, name)}`),
|
|
205
|
+
];
|
|
206
|
+
if (lines.length === 0)
|
|
207
|
+
return `No field or child relationship of ${name(found.object.name)} matches.`;
|
|
208
|
+
const room = Math.min(budget, MAX_READ_BYTES);
|
|
209
|
+
const head = `Object ${name(found.object.name)}:`;
|
|
210
|
+
const more = (rest) => `(${rest} more: use more specific keywords.)`;
|
|
211
|
+
const kept = [];
|
|
212
|
+
// Room for the longest closing note it can need.
|
|
213
|
+
let used = bytes(head) + bytes(more(lines.length)) + 1;
|
|
214
|
+
for (const line of lines.slice(0, MAX_FOUND)) {
|
|
215
|
+
if (used + bytes(line) + 1 > room)
|
|
216
|
+
break;
|
|
217
|
+
kept.push(line);
|
|
218
|
+
used += bytes(line) + 1;
|
|
219
|
+
}
|
|
220
|
+
if (kept.length === 0)
|
|
221
|
+
return NEAR_LIMIT;
|
|
222
|
+
const rest = lines.length - kept.length;
|
|
223
|
+
return [head, ...kept, ...(rest ? [more(rest)] : [])].join('\n');
|
|
224
|
+
}
|
|
225
|
+
const noObject = (name) => `The org has no object named ${name}. Use findObjects to look for it.`;
|
|
226
|
+
const isName = (v) => typeof v === 'string' && /^[A-Za-z][A-Za-z0-9_]*$/.test(v);
|
|
227
|
+
/** Runs one metadata tool. A call of the wrong shape is answered with what is wrong, so that the AI can try again. */
|
|
228
|
+
export async function runTool(source, name, input, budget, state) {
|
|
229
|
+
const args = (input ?? {});
|
|
230
|
+
const isWords = (v) => Array.isArray(v) && v.every((k) => typeof k === 'string');
|
|
231
|
+
if (name === 'findObjects') {
|
|
232
|
+
if (!isWords(args.keywords))
|
|
233
|
+
return 'keywords must be a list of strings.';
|
|
234
|
+
return cutToBudget(findObjectsText(await source.objects(), args.keywords, source), Math.min(budget, MAX_READ_BYTES));
|
|
235
|
+
}
|
|
236
|
+
if (name === 'describeObject') {
|
|
237
|
+
if (!isName(args.name))
|
|
238
|
+
return 'name must be an object API name.';
|
|
239
|
+
return (await readObject(source, args.name, budget, state)) ?? noObject(args.name);
|
|
240
|
+
}
|
|
241
|
+
if (name === 'findFields') {
|
|
242
|
+
if (!isName(args.object))
|
|
243
|
+
return 'object must be an object API name.';
|
|
244
|
+
if (!isWords(args.keywords))
|
|
245
|
+
return 'keywords must be a list of strings.';
|
|
246
|
+
return (await findFieldsText(source, args.object, args.keywords, budget)) ?? noObject(args.object);
|
|
247
|
+
}
|
|
248
|
+
return `There is no tool named ${name}.`;
|
|
249
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Opens a URL in the default browser without a shell. See docs/design.md ("コマンドライン引数").
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
function command(url) {
|
|
5
|
+
switch (process.platform) {
|
|
6
|
+
case 'darwin':
|
|
7
|
+
return ['/usr/bin/open', [url]];
|
|
8
|
+
case 'win32':
|
|
9
|
+
// Absolute path, so that an executable in the working directory is never run.
|
|
10
|
+
return [join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'rundll32.exe'), ['url.dll,FileProtocolHandler', url]];
|
|
11
|
+
default:
|
|
12
|
+
return ['xdg-open', [url]];
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** Resolves true when the browser command started, false when it could not be run. */
|
|
16
|
+
export function openBrowser(url) {
|
|
17
|
+
const [file, args] = command(url);
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
const child = spawn(file, args, { stdio: 'ignore', detached: true, windowsHide: true });
|
|
20
|
+
child.once('error', () => resolve(false));
|
|
21
|
+
child.once('spawn', () => {
|
|
22
|
+
child.unref();
|
|
23
|
+
resolve(true);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Entry point of `npx sfbench`. See docs/design.md ("コマンドライン引数").
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { openSfbench, stopSfbench, UserError } from "./launcher.js";
|
|
6
|
+
import { restLaunch } from "./rest.js";
|
|
7
|
+
import { MAX_APEX_CODE } from "../shared/api.js";
|
|
8
|
+
import { serve } from "./serve.js";
|
|
9
|
+
import { packageVersion } from "./version.js";
|
|
10
|
+
const HELP = `Usage: sfbench [options]
|
|
11
|
+
sfbench stop
|
|
12
|
+
|
|
13
|
+
Opens sfbench, a local workbench for Salesforce, in your browser.
|
|
14
|
+
It uses the orgs you logged in to with the Salesforce CLI (sf).
|
|
15
|
+
|
|
16
|
+
Options:
|
|
17
|
+
-o, --target-org <alias> Org to open. Defaults to the sf CLI's default org (target-org).
|
|
18
|
+
--query "<SOQL>" Open with this SOQL in the editor.
|
|
19
|
+
--run Also run the --query SOQL.
|
|
20
|
+
--rest <path> Open the REST API screen with this request. It is never sent:
|
|
21
|
+
check it and press Send.
|
|
22
|
+
--method <method> The --rest request's method: GET (default), POST, PATCH, PUT or DELETE.
|
|
23
|
+
--body <json> The --rest request's JSON body (POST, PATCH and PUT).
|
|
24
|
+
--body-file <file> Read the --rest request's JSON body from a file.
|
|
25
|
+
--apex "<code>" Open the Apex screen with this anonymous Apex. It is never run:
|
|
26
|
+
check it and press Run.
|
|
27
|
+
--apex-file <file> Read the --apex code from a file.
|
|
28
|
+
--json Print the result as JSON.
|
|
29
|
+
--no-open Do not open a browser; print the URL instead.
|
|
30
|
+
-h, --help Show this help.
|
|
31
|
+
-v, --version Show the version.
|
|
32
|
+
|
|
33
|
+
Commands:
|
|
34
|
+
stop Stop the background sfbench server.
|
|
35
|
+
|
|
36
|
+
Examples for AI agents:
|
|
37
|
+
npx -y sfbench -o my-org --query "SELECT Id, Name FROM Account LIMIT 10" --run
|
|
38
|
+
npx -y sfbench -o my-org --query "SELECT Id FROM Opportunity WHERE IsWon = true" --run --json
|
|
39
|
+
npx -y sfbench -o my-org --rest /services/data/v62.0/sobjects/Account --method POST --body-file account.json
|
|
40
|
+
npx -y sfbench -o my-org --apex-file fix-data.apex
|
|
41
|
+
`;
|
|
42
|
+
async function main() {
|
|
43
|
+
const { values, positionals } = parseArgs({
|
|
44
|
+
allowPositionals: true,
|
|
45
|
+
allowNegative: true,
|
|
46
|
+
options: {
|
|
47
|
+
'target-org': { type: 'string', short: 'o' },
|
|
48
|
+
query: { type: 'string' },
|
|
49
|
+
run: { type: 'boolean', default: false },
|
|
50
|
+
rest: { type: 'string' },
|
|
51
|
+
method: { type: 'string' },
|
|
52
|
+
body: { type: 'string' },
|
|
53
|
+
'body-file': { type: 'string' },
|
|
54
|
+
apex: { type: 'string' },
|
|
55
|
+
'apex-file': { type: 'string' },
|
|
56
|
+
json: { type: 'boolean', default: false },
|
|
57
|
+
open: { type: 'boolean', default: true },
|
|
58
|
+
dev: { type: 'boolean', default: false },
|
|
59
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
60
|
+
version: { type: 'boolean', short: 'v', default: false },
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
const command = positionals[0];
|
|
64
|
+
if (values.help) {
|
|
65
|
+
process.stdout.write(HELP);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (values.version) {
|
|
69
|
+
process.stdout.write(`${packageVersion()}\n`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
// `serve` is internal: the background server process. --dev runs it in the foreground for development.
|
|
73
|
+
if (command === 'serve' || values.dev) {
|
|
74
|
+
await serve(values.dev);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (command === 'stop') {
|
|
78
|
+
await stopSfbench();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (command !== undefined)
|
|
82
|
+
throw new UserError(`Unknown command: ${command}. See \`sfbench --help\`.`);
|
|
83
|
+
await openSfbench({
|
|
84
|
+
targetOrg: values['target-org'],
|
|
85
|
+
query: values.query,
|
|
86
|
+
run: values.run,
|
|
87
|
+
rest: restOption(values),
|
|
88
|
+
apex: apexOption(values),
|
|
89
|
+
json: values.json,
|
|
90
|
+
open: values.open,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/** The --rest request, checked. --method and --body without --rest are mistakes, not ignored. */
|
|
94
|
+
function restOption(values) {
|
|
95
|
+
if (values.rest === undefined) {
|
|
96
|
+
if (values.method !== undefined || values.body !== undefined || values['body-file'] !== undefined) {
|
|
97
|
+
throw new UserError('--method, --body and --body-file go with --rest.');
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
if (values.body !== undefined && values['body-file'] !== undefined)
|
|
102
|
+
throw new UserError('Use either --body or --body-file.');
|
|
103
|
+
let body = values.body;
|
|
104
|
+
if (values['body-file'] !== undefined) {
|
|
105
|
+
try {
|
|
106
|
+
// Read here, in the user's working directory; the server gets the text.
|
|
107
|
+
// Without a byte order mark, which Windows editors may put first.
|
|
108
|
+
body = readFileSync(values['body-file'], 'utf8').replace(/^\uFEFF/, '');
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
throw new UserError(`Could not read ${values['body-file']}: ${e.message}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
return restLaunch({ path: values.rest, method: values.method, body });
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
throw new UserError(e.message);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** The --apex code, checked. */
|
|
122
|
+
function apexOption(values) {
|
|
123
|
+
if (values.apex !== undefined && values['apex-file'] !== undefined)
|
|
124
|
+
throw new UserError('Use either --apex or --apex-file.');
|
|
125
|
+
let code = values.apex;
|
|
126
|
+
if (values['apex-file'] !== undefined) {
|
|
127
|
+
try {
|
|
128
|
+
// Without a byte order mark, which Windows editors may put first.
|
|
129
|
+
code = readFileSync(values['apex-file'], 'utf8').replace(/^\uFEFF/, '');
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
throw new UserError(`Could not read ${values['apex-file']}: ${e.message}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (code === undefined)
|
|
136
|
+
return undefined;
|
|
137
|
+
if (!code.trim())
|
|
138
|
+
throw new UserError('--apex needs some code.');
|
|
139
|
+
if (code.length > MAX_APEX_CODE)
|
|
140
|
+
throw new UserError(`Anonymous Apex can be at most ${MAX_APEX_CODE} characters.`);
|
|
141
|
+
return { code };
|
|
142
|
+
}
|
|
143
|
+
main().catch((e) => {
|
|
144
|
+
if (e instanceof UserError || (e instanceof TypeError && e.code?.startsWith('ERR_PARSE_ARGS'))) {
|
|
145
|
+
process.stderr.write(`sfbench: ${e.message}\n`);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
process.stderr.write(`sfbench: ${e.stack ?? String(e)}\n`);
|
|
149
|
+
}
|
|
150
|
+
process.exitCode = 1;
|
|
151
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
3
|
+
/** Writes a file so that readers never see a partial file: write to a temporary file, then rename. */
|
|
4
|
+
export function writeFileAtomic(path, data) {
|
|
5
|
+
const tmp = `${path}.${randomBytes(6).toString('hex')}.tmp`;
|
|
6
|
+
writeFileSync(tmp, data, { mode: 0o600 });
|
|
7
|
+
renameSync(tmp, path);
|
|
8
|
+
}
|
|
9
|
+
/** Reads and parses a JSON file. Returns undefined if it does not exist or is not valid JSON. */
|
|
10
|
+
export function readJson(path) {
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export const DEV_WEB_HOST = 'localhost:5173';
|
|
2
|
+
/** Hosts the server answers to. Anything else is rejected (DNS rebinding). */
|
|
3
|
+
export function allowedHosts(port, dev) {
|
|
4
|
+
const hosts = [`127.0.0.1:${port}`, `localhost:${port}`];
|
|
5
|
+
if (dev)
|
|
6
|
+
hosts.push(DEV_WEB_HOST);
|
|
7
|
+
return hosts;
|
|
8
|
+
}
|
|
9
|
+
export function isAllowedHost(host, port, dev) {
|
|
10
|
+
return host !== undefined && allowedHosts(port, dev).includes(host.toLowerCase());
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Admin endpoints are called by the CLI entry point with Node's fetch, which
|
|
14
|
+
* sends no Origin. Browsers always send Origin on these POSTs, so any Origin
|
|
15
|
+
* means the request came from a web page and is rejected.
|
|
16
|
+
*/
|
|
17
|
+
export function isAdminRequestOrigin(headers) {
|
|
18
|
+
return headers.origin === undefined;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Same-origin check for endpoints the web UI calls. Browsers omit Origin on
|
|
22
|
+
* same-origin GET requests, so a missing Origin is not a rejection by itself:
|
|
23
|
+
* the session token in a custom header already forces a CORS preflight for
|
|
24
|
+
* cross-origin requests, and the server never approves one.
|
|
25
|
+
*/
|
|
26
|
+
export function isSameOriginRequest(headers) {
|
|
27
|
+
const host = headers.host?.toLowerCase();
|
|
28
|
+
if (!host)
|
|
29
|
+
return false;
|
|
30
|
+
const origin = headers.origin;
|
|
31
|
+
if (origin !== undefined && origin.toLowerCase() !== `http://${host}`)
|
|
32
|
+
return false;
|
|
33
|
+
const site = headers['sec-fetch-site'];
|
|
34
|
+
if (site !== undefined && site !== 'same-origin')
|
|
35
|
+
return false;
|
|
36
|
+
return true;
|
|
37
|
+
}
|