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,201 @@
|
|
|
1
|
+
// Settings and query history, stored under ~/.sfbench. See docs/design.md ("データの保存").
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { AI_PROVIDERS, isValidTimeout, squashSoql, REST_METHODS } from "../shared/api.js";
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { readJson, writeFileAtomic } from "./fsutil.js";
|
|
6
|
+
export const DEFAULT_SETTINGS = { omitNamespace: true, requestTimeoutSeconds: 120 };
|
|
7
|
+
function isProvider(value) {
|
|
8
|
+
return AI_PROVIDERS.includes(value);
|
|
9
|
+
}
|
|
10
|
+
function loadAi(value) {
|
|
11
|
+
if (!value || typeof value !== 'object')
|
|
12
|
+
return undefined;
|
|
13
|
+
const v = value;
|
|
14
|
+
if (!isProvider(v.provider))
|
|
15
|
+
return undefined;
|
|
16
|
+
const ai = { provider: v.provider, model: typeof v.model === 'string' ? v.model : '' };
|
|
17
|
+
// A key is kept only with the provider it was entered for (Off keeps it for when that provider is chosen again).
|
|
18
|
+
const keyProvider = isProvider(v.keyProvider) && v.keyProvider !== 'off' ? v.keyProvider : undefined;
|
|
19
|
+
if (typeof v.apiKey === 'string' && v.apiKey && keyProvider && (v.provider === 'off' || v.provider === keyProvider)) {
|
|
20
|
+
ai.apiKey = v.apiKey;
|
|
21
|
+
ai.keyProvider = keyProvider;
|
|
22
|
+
}
|
|
23
|
+
return ai;
|
|
24
|
+
}
|
|
25
|
+
export function loadSettings(file) {
|
|
26
|
+
const stored = readJson(file);
|
|
27
|
+
const s = stored && typeof stored === 'object' ? stored : {};
|
|
28
|
+
const settings = {
|
|
29
|
+
omitNamespace: typeof s.omitNamespace === 'boolean' ? s.omitNamespace : DEFAULT_SETTINGS.omitNamespace,
|
|
30
|
+
requestTimeoutSeconds: isValidTimeout(s.requestTimeoutSeconds)
|
|
31
|
+
? s.requestTimeoutSeconds
|
|
32
|
+
: DEFAULT_SETTINGS.requestTimeoutSeconds,
|
|
33
|
+
};
|
|
34
|
+
const ai = loadAi(s.ai);
|
|
35
|
+
if (ai)
|
|
36
|
+
settings.ai = ai;
|
|
37
|
+
return settings;
|
|
38
|
+
}
|
|
39
|
+
/** Written with mode 0600 (writeFileAtomic): the file holds the AI's API key. */
|
|
40
|
+
export function saveSettings(file, settings) {
|
|
41
|
+
writeFileAtomic(file, JSON.stringify(settings, null, 2));
|
|
42
|
+
}
|
|
43
|
+
/** What the web UI gets: the AI settings without the key. */
|
|
44
|
+
export function settingsView(settings) {
|
|
45
|
+
const { ai, ...rest } = settings;
|
|
46
|
+
if (!ai)
|
|
47
|
+
return rest;
|
|
48
|
+
const view = { provider: ai.provider, model: ai.model, hasApiKey: Boolean(ai.apiKey) };
|
|
49
|
+
if (ai.apiKey && ai.keyProvider)
|
|
50
|
+
view.keyProvider = ai.keyProvider;
|
|
51
|
+
return { ...rest, ai: view };
|
|
52
|
+
}
|
|
53
|
+
export class AiSettingsError extends Error {
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Merges the AI settings sent by the web UI into the saved ones
|
|
57
|
+
* (docs/ai.md "API"). Leaving out apiKey keeps the saved key, null removes
|
|
58
|
+
* it, and choosing another provider (other than Off) drops it, so that a key
|
|
59
|
+
* never goes to a provider it was not entered for.
|
|
60
|
+
*/
|
|
61
|
+
export function mergeAiSettings(saved, input) {
|
|
62
|
+
if (!input || typeof input !== 'object')
|
|
63
|
+
throw new AiSettingsError('ai must be an object.');
|
|
64
|
+
const v = input;
|
|
65
|
+
if (!isProvider(v.provider))
|
|
66
|
+
throw new AiSettingsError('ai.provider must be off, anthropic or openai.');
|
|
67
|
+
if (v.model !== undefined && typeof v.model !== 'string')
|
|
68
|
+
throw new AiSettingsError('ai.model must be a string.');
|
|
69
|
+
if (v.apiKey !== undefined && v.apiKey !== null && typeof v.apiKey !== 'string') {
|
|
70
|
+
throw new AiSettingsError('ai.apiKey must be a string or null.');
|
|
71
|
+
}
|
|
72
|
+
const provider = v.provider;
|
|
73
|
+
const model = typeof v.model === 'string' ? v.model.trim() : (saved?.model ?? '');
|
|
74
|
+
if (provider !== 'off' && !model)
|
|
75
|
+
throw new AiSettingsError('Enter the model ID.');
|
|
76
|
+
let apiKey = saved?.apiKey;
|
|
77
|
+
let keyProvider = saved?.keyProvider;
|
|
78
|
+
if (provider !== 'off' && keyProvider !== provider) {
|
|
79
|
+
apiKey = undefined;
|
|
80
|
+
keyProvider = undefined;
|
|
81
|
+
}
|
|
82
|
+
if (v.apiKey === null) {
|
|
83
|
+
apiKey = undefined;
|
|
84
|
+
keyProvider = undefined;
|
|
85
|
+
}
|
|
86
|
+
else if (typeof v.apiKey === 'string' && v.apiKey.trim() && provider !== 'off') {
|
|
87
|
+
apiKey = v.apiKey.trim();
|
|
88
|
+
keyProvider = provider;
|
|
89
|
+
}
|
|
90
|
+
const merged = { provider, model };
|
|
91
|
+
if (apiKey && keyProvider) {
|
|
92
|
+
merged.apiKey = apiKey;
|
|
93
|
+
merged.keyProvider = keyProvider;
|
|
94
|
+
}
|
|
95
|
+
return merged;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Settings kept in memory: they are read on every Salesforce request, so the
|
|
99
|
+
* file is read once at startup and written on each change.
|
|
100
|
+
*/
|
|
101
|
+
export class SettingsStore {
|
|
102
|
+
file;
|
|
103
|
+
current;
|
|
104
|
+
constructor(file) {
|
|
105
|
+
this.file = file;
|
|
106
|
+
this.current = loadSettings(file);
|
|
107
|
+
}
|
|
108
|
+
get() {
|
|
109
|
+
return this.current;
|
|
110
|
+
}
|
|
111
|
+
set(settings) {
|
|
112
|
+
saveSettings(this.file, settings);
|
|
113
|
+
this.current = settings;
|
|
114
|
+
return settings;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
export const MAX_HISTORY = 100;
|
|
118
|
+
/** File name for an org's history: characters other than letters, digits, @ . - _ become _. */
|
|
119
|
+
export function historyFile(dir, username) {
|
|
120
|
+
return join(dir, `${username.replace(/[^A-Za-z0-9@._-]/g, '_')}.json`);
|
|
121
|
+
}
|
|
122
|
+
export function loadHistory(dir, username) {
|
|
123
|
+
const stored = readJson(historyFile(dir, username));
|
|
124
|
+
return Array.isArray(stored) ? stored : [];
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Adds a successful query. The same query moves to the top instead of being
|
|
128
|
+
* added twice, keeping the request to the AI that wrote it if this run has none.
|
|
129
|
+
*/
|
|
130
|
+
export function addHistory(dir, username, entry) {
|
|
131
|
+
const sameQuery = (e) => squashSoql(e.soql) === squashSoql(entry.soql) && e.tooling === entry.tooling && e.queryAll === entry.queryAll;
|
|
132
|
+
const previous = loadHistory(dir, username);
|
|
133
|
+
const prompt = entry.prompt ?? previous.find(sameQuery)?.prompt;
|
|
134
|
+
const added = prompt ? { ...entry, prompt } : entry;
|
|
135
|
+
const entries = [added, ...previous.filter((e) => !sameQuery(e))].slice(0, MAX_HISTORY);
|
|
136
|
+
writeFileAtomic(historyFile(dir, username), JSON.stringify(entries, null, 2));
|
|
137
|
+
return entries;
|
|
138
|
+
}
|
|
139
|
+
/** At most this much of a request body is kept in the REST history (docs/rest.md "残すもの"). */
|
|
140
|
+
export const MAX_REST_HISTORY_BODY = 32 * 1024;
|
|
141
|
+
function isExecRecord(e) {
|
|
142
|
+
if (!e || typeof e !== 'object')
|
|
143
|
+
return false;
|
|
144
|
+
const r = e;
|
|
145
|
+
const outcome = r.outcome;
|
|
146
|
+
return typeof r.id === 'string' && typeof r.sentAt === 'string' && !!outcome && ['pending', 'done', 'unknown'].includes(outcome.state);
|
|
147
|
+
}
|
|
148
|
+
function loadExec(file, valid) {
|
|
149
|
+
const stored = readJson(file);
|
|
150
|
+
// A record of another shape (a damaged or hand-edited file) is dropped.
|
|
151
|
+
return Array.isArray(stored) ? stored.filter((e) => isExecRecord(e) && valid(e)) : [];
|
|
152
|
+
}
|
|
153
|
+
/** Newest first. A request still pending from a server that has stopped (another bootId) shows as unknown, not as waiting for ever. */
|
|
154
|
+
function loadExecHistory(file, bootId, valid) {
|
|
155
|
+
return loadExec(file, valid).map(({ bootId: entryBoot, ...entry }) => entry.outcome.state === 'pending' && entryBoot !== bootId ? { ...entry, outcome: { state: 'unknown', reason: 'stopped' } } : entry);
|
|
156
|
+
}
|
|
157
|
+
/** Records a request as pending; the same request twice is two records. Returns its id. */
|
|
158
|
+
function addExecPending(file, fields, bootId, valid) {
|
|
159
|
+
const id = randomUUID();
|
|
160
|
+
const entry = { id, ...fields, sentAt: new Date().toISOString(), outcome: { state: 'pending' }, bootId };
|
|
161
|
+
const entries = [entry, ...loadExec(file, valid)].slice(0, MAX_HISTORY);
|
|
162
|
+
// Not indented: the file is rewritten twice for each request.
|
|
163
|
+
writeFileAtomic(file, JSON.stringify(entries));
|
|
164
|
+
return id;
|
|
165
|
+
}
|
|
166
|
+
/** Writes how a request ended; `undefined` removes the record (never processed). A missing record is not added again. */
|
|
167
|
+
function finishExec(file, id, outcome, valid) {
|
|
168
|
+
const entries = loadExec(file, valid);
|
|
169
|
+
if (!entries.some((e) => e.id === id))
|
|
170
|
+
return;
|
|
171
|
+
const next = outcome ? entries.map((e) => (e.id === id ? { ...e, outcome } : e)) : entries.filter((e) => e.id !== id);
|
|
172
|
+
writeFileAtomic(file, JSON.stringify(next));
|
|
173
|
+
}
|
|
174
|
+
const isRestRecord = (r) => typeof r.method === 'string' && REST_METHODS.includes(r.method) && typeof r.path === 'string';
|
|
175
|
+
export function loadRestHistory(dir, username, bootId) {
|
|
176
|
+
return loadExecHistory(historyFile(dir, username), bootId, isRestRecord);
|
|
177
|
+
}
|
|
178
|
+
/** Records a REST request just before it is sent, as pending. Returns its id. */
|
|
179
|
+
export function addRestPending(dir, username, request, bootId) {
|
|
180
|
+
const keepBody = request.body !== undefined && Buffer.byteLength(request.body) <= MAX_REST_HISTORY_BODY;
|
|
181
|
+
return addExecPending(historyFile(dir, username), {
|
|
182
|
+
method: request.method,
|
|
183
|
+
path: request.path,
|
|
184
|
+
...(Object.keys(request.headers).length ? { headers: request.headers } : {}),
|
|
185
|
+
...(keepBody ? { body: request.body } : request.body !== undefined ? { bodyOmitted: true } : {}),
|
|
186
|
+
}, bootId, isRestRecord);
|
|
187
|
+
}
|
|
188
|
+
export function finishRest(dir, username, id, outcome) {
|
|
189
|
+
finishExec(historyFile(dir, username), id, outcome, isRestRecord);
|
|
190
|
+
}
|
|
191
|
+
const isApexRecord = (r) => typeof r.code === 'string';
|
|
192
|
+
export function loadApexHistory(dir, username, bootId) {
|
|
193
|
+
return loadExecHistory(historyFile(dir, username), bootId, isApexRecord);
|
|
194
|
+
}
|
|
195
|
+
/** Records Apex just before it runs, as pending, with the whole code (at most MAX_APEX_CODE). Returns its id. */
|
|
196
|
+
export function addApexPending(dir, username, code, bootId) {
|
|
197
|
+
return addExecPending(historyFile(dir, username), { code }, bootId, isApexRecord);
|
|
198
|
+
}
|
|
199
|
+
export function finishApex(dir, username, id, outcome) {
|
|
200
|
+
finishExec(historyFile(dir, username), id, outcome, isApexRecord);
|
|
201
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { packageRoot } from "./paths.js";
|
|
4
|
+
let cached;
|
|
5
|
+
export function packageVersion() {
|
|
6
|
+
cached ??= JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')).version;
|
|
7
|
+
return cached;
|
|
8
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Types of the local server's HTTP API, shared by the server and the web UI.
|
|
2
|
+
// See docs/design.md ("API").
|
|
3
|
+
export const MIN_TIMEOUT_SECONDS = 10;
|
|
4
|
+
export const MAX_TIMEOUT_SECONDS = 600;
|
|
5
|
+
export function isValidTimeout(value) {
|
|
6
|
+
return Number.isInteger(value) && value >= MIN_TIMEOUT_SECONDS && value <= MAX_TIMEOUT_SECONDS;
|
|
7
|
+
}
|
|
8
|
+
/** The AI providers sfbench can call: Anthropic or OpenAI, or none (docs/ai.md). */
|
|
9
|
+
export const AI_PROVIDERS = ['off', 'anthropic', 'openai'];
|
|
10
|
+
/** The longest anonymous Apex sfbench takes: about Salesforce's limit (docs/apex.md "前提にする事実"). */
|
|
11
|
+
export const MAX_APEX_CODE = 32_000;
|
|
12
|
+
/** At most this many records go in one save or delete: one sObject Collections call (docs/edit.md "サーバー"). */
|
|
13
|
+
export const MAX_RECORDS_PER_CALL = 200;
|
|
14
|
+
/** The methods the REST API screen sends (docs/rest.md "要求"). */
|
|
15
|
+
export const REST_METHODS = ['GET', 'POST', 'PATCH', 'PUT', 'DELETE'];
|
|
16
|
+
/** Whether a request with this method carries a body: the server checks it, the screen disables the body for the others. */
|
|
17
|
+
export function methodTakesBody(method) {
|
|
18
|
+
return method === 'POST' || method === 'PATCH' || method === 'PUT';
|
|
19
|
+
}
|
|
20
|
+
/** Whether a path is under /services/, the only paths sfbench sends to Salesforce (the origin is checked by the server). */
|
|
21
|
+
export function isServicesPath(path) {
|
|
22
|
+
return path.startsWith('/services/');
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* A query with its spaces and line breaks squashed, so that Format does not
|
|
26
|
+
* make another query: the history (addHistory) and the request to the AI
|
|
27
|
+
* behind a query (src/web/ai/promptMark.ts) compare queries this way.
|
|
28
|
+
*/
|
|
29
|
+
export function squashSoql(soql) {
|
|
30
|
+
return soql.trim().replace(/\s+/g, ' ');
|
|
31
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// The org's namespace prefix: omitted when composing SOQL, ignored when matching
|
|
2
|
+
// names (docs/web.md "SOQL と項目の選択の同期").
|
|
3
|
+
function stripOne(name, ns) {
|
|
4
|
+
const prefix = `${ns}__`;
|
|
5
|
+
return name.toLowerCase().startsWith(prefix.toLowerCase()) ? name.slice(prefix.length) : name;
|
|
6
|
+
}
|
|
7
|
+
/** Removes the namespace from each dot-separated part. */
|
|
8
|
+
export function stripNamespace(name, ns) {
|
|
9
|
+
if (!ns)
|
|
10
|
+
return name;
|
|
11
|
+
return name
|
|
12
|
+
.split('.')
|
|
13
|
+
.map((part) => stripOne(part, ns))
|
|
14
|
+
.join('.');
|
|
15
|
+
}
|
|
16
|
+
/** A key for comparing names: namespace removed, case ignored. */
|
|
17
|
+
export function nameKey(name, ns) {
|
|
18
|
+
return stripNamespace(name, ns).toLowerCase();
|
|
19
|
+
}
|
|
20
|
+
/** The name to write into SOQL, following the "omit the namespace" setting. */
|
|
21
|
+
export function soqlName(name, ns, omitNamespace) {
|
|
22
|
+
return omitNamespace ? stripNamespace(name, ns) : name;
|
|
23
|
+
}
|
|
24
|
+
/** The item named in SOQL (matching case and namespace loosely), such as an object in the describeGlobal list. */
|
|
25
|
+
export function findByName(items, name, ns) {
|
|
26
|
+
const key = nameKey(name, ns);
|
|
27
|
+
return items.find((item) => nameKey(item.name, ns) === key);
|
|
28
|
+
}
|