sfbench 0.1.0 → 0.1.1

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/README.ja.md CHANGED
@@ -51,7 +51,7 @@ lwc-soql-builder は、Salesforce が OAuth のユーザーエージェントフ
51
51
 
52
52
  ## 必要なもの
53
53
 
54
- - Node.js 22.21.0 以上の 22 系、または 24.5.0 以上
54
+ - Node.js 22.21.0 以上の 22 系、または 24.5.0 以上(プロジェクトで古い Node.js を指定していても、`npx -y -p node@24 -p sfbench -- sfbench` で動かせます)
55
55
  - [Salesforce CLI](https://developer.salesforce.com/tools/salesforcecli)(`sf`)と、使う組織へのログイン
56
56
 
57
57
  macOS、Linux、Windows で動きます。
@@ -108,6 +108,14 @@ Claude Code の中では `/plugin install sfbench --marketplace atskimura/sfbenc
108
108
  日本語版は [plugins/sfbench/ja/sfbench/SKILL.md](plugins/sfbench/ja/sfbench/SKILL.md)、英語版は [plugins/sfbench/skills/sfbench/SKILL.md](plugins/sfbench/skills/sfbench/SKILL.md) です。
109
109
  `sfbench` フォルダごと、エージェントがスキルを読む場所(Claude Code なら `~/.claude/skills/`、Codex なら `~/.agents/skills/`)に置きます(Codex では試していません)。
110
110
 
111
+ Claude Code のシェルのサンドボックスを有効にしている場合(`/sandbox`)、sfbench はサンドボックスの中では起動できません。
112
+ `~/.npm/` と `~/.sfbench/` に書き込み、ローカルのサーバーを動かし続けるからです。
113
+ `~/.claude/settings.json` に次を足し、sfbench だけをサンドボックスの外で動かしてください。
114
+
115
+ ```json
116
+ "sandbox": { "excludedCommands": ["npx -y sfbench *", "npx -y sfbench", "npx -y -p node@24 -p sfbench -- sfbench *", "npx -y -p node@24 -p sfbench -- sfbench"] }
117
+ ```
118
+
111
119
  スキルを入れた Claude Code に「sfbench で成約済みの商談を一覧して」と頼むと、エージェントは SOQL を組み立てて次のように起動します。
112
120
 
113
121
  ```
package/README.md CHANGED
@@ -51,7 +51,7 @@ lwc-soql-builder can no longer log in, because Salesforce has blocked the OAuth
51
51
 
52
52
  ## Requirements
53
53
 
54
- - Node.js 22.21.0 or later in the 22 line, or 24.5.0 or later
54
+ - Node.js 22.21.0 or later in the 22 line, or 24.5.0 or later (if a project pins an older Node.js, `npx -y -p node@24 -p sfbench -- sfbench` still runs it)
55
55
  - The [Salesforce CLI](https://developer.salesforce.com/tools/salesforcecli) (`sf`), logged in to the orgs you use
56
56
 
57
57
  sfbench runs on macOS, Linux and Windows.
@@ -107,6 +107,13 @@ Your instructions file (`CLAUDE.md`) stays as it is.
107
107
  You can also put the skill's file in place yourself: [plugins/sfbench/skills/sfbench/SKILL.md](plugins/sfbench/skills/sfbench/SKILL.md) in English, or [plugins/sfbench/ja/sfbench/SKILL.md](plugins/sfbench/ja/sfbench/SKILL.md) in Japanese.
108
108
  Copy the `sfbench` folder to where the agent reads skills (`~/.claude/skills/` for Claude Code, `~/.agents/skills/` for Codex; untested with Codex).
109
109
 
110
+ If you turned on Claude Code's command sandbox (`/sandbox`), sfbench cannot start inside it: it writes `~/.npm/` and `~/.sfbench/` and keeps a local server running.
111
+ Let sfbench alone run outside the sandbox by adding this to `~/.claude/settings.json`:
112
+
113
+ ```json
114
+ "sandbox": { "excludedCommands": ["npx -y sfbench *", "npx -y sfbench", "npx -y -p node@24 -p sfbench -- sfbench *", "npx -y -p node@24 -p sfbench -- sfbench"] }
115
+ ```
116
+
110
117
  With the skill, ask Claude Code to "list the won opportunities in sfbench", and it writes the SOQL and starts sfbench like this:
111
118
 
112
119
  ```
@@ -5,6 +5,7 @@ import { readFileSync } from 'node:fs';
5
5
  import { openSfbench, stopSfbench, UserError } from "./launcher.js";
6
6
  import { restLaunch } from "./rest.js";
7
7
  import { MAX_APEX_CODE } from "../shared/api.js";
8
+ import { unsupportedNode } from "./nodeVersion.js";
8
9
  import { serve } from "./serve.js";
9
10
  import { packageVersion } from "./version.js";
10
11
  const HELP = `Usage: sfbench [options]
@@ -40,6 +41,13 @@ Examples for AI agents:
40
41
  npx -y sfbench -o my-org --apex-file fix-data.apex
41
42
  `;
42
43
  async function main() {
44
+ // First, before the arguments: an older Node.js may not parse them (--no-open).
45
+ // Help, the version and stop start no server, so they work on any.
46
+ const args = process.argv.slice(2);
47
+ const startsNoServer = args[0] === 'stop' || args.some((a) => ['-h', '--help', '-v', '--version'].includes(a));
48
+ const tooOld = startsNoServer ? undefined : unsupportedNode();
49
+ if (tooOld)
50
+ throw new UserError(tooOld);
43
51
  const { values, positionals } = parseArgs({
44
52
  allowPositionals: true,
45
53
  allowNegative: true,
@@ -1,6 +1,8 @@
1
1
  // The CLI entry point's work: find or start the background server, then open
2
2
  // a new browser tab. See docs/design.md ("起動").
3
3
  import { spawn } from 'node:child_process';
4
+ import { closeSync, existsSync, openSync, readFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
4
6
  import { fileURLToPath } from 'node:url';
5
7
  import { openBrowser } from "./browser.js";
6
8
  import { ensureDirs, resolvePaths } from "./paths.js";
@@ -57,19 +59,61 @@ async function stopServer(info) {
57
59
  }
58
60
  return false;
59
61
  }
62
+ /**
63
+ * Opens a file for the server's stderr, one per start, so that a server still
64
+ * running from before keeps its own. The server removes it once it listens
65
+ * (serve.ts): it is kept only to tell why a start failed. Undefined when it
66
+ * cannot be opened; the server then starts with its stderr ignored.
67
+ */
68
+ function openStderrFile(paths) {
69
+ const file = join(paths.tmpDir, `server-stderr-${Date.now()}-${process.pid}.log`);
70
+ try {
71
+ return { file, fd: openSync(file, 'w', 0o600) };
72
+ }
73
+ catch {
74
+ return undefined;
75
+ }
76
+ }
60
77
  function startServer(paths) {
61
78
  // cli.js next to this file when installed; cli.ts when run from the sources.
62
79
  const cli = fileURLToPath(new URL(import.meta.url.endsWith('.ts') ? './cli.ts' : './cli.js', import.meta.url));
63
- const child = spawn(process.execPath, ['--use-env-proxy', cli, 'serve'], {
64
- cwd: paths.tmpDir,
65
- detached: true,
66
- stdio: 'ignore',
67
- windowsHide: true,
68
- });
69
- child.unref();
70
- if (child.pid === undefined)
71
- throw new UserError('Could not start the sfbench server.');
72
- return child.pid;
80
+ // Node.js refusing to start the server (an unknown option, a missing module)
81
+ // says so on stderr, before the server can log anything.
82
+ const stderr = openStderrFile(paths);
83
+ let ended = false;
84
+ try {
85
+ const child = spawn(process.execPath, ['--use-env-proxy', cli, 'serve'], {
86
+ cwd: paths.tmpDir,
87
+ detached: true,
88
+ stdio: ['ignore', 'ignore', stderr ? stderr.fd : 'ignore'],
89
+ env: { ...process.env, ...(stderr ? { SFBENCH_STDERR_FILE: stderr.file } : {}) },
90
+ windowsHide: true,
91
+ });
92
+ child.on('exit', () => (ended = true));
93
+ child.on('error', () => (ended = true));
94
+ child.unref();
95
+ if (child.pid === undefined)
96
+ throw new UserError('Could not start the sfbench server.');
97
+ return {
98
+ pid: child.pid,
99
+ ended: () => ended,
100
+ stderr: () => {
101
+ if (!stderr)
102
+ return '';
103
+ try {
104
+ return readFileSync(stderr.file, 'utf8').trim().slice(0, 2000);
105
+ }
106
+ catch {
107
+ return '';
108
+ }
109
+ },
110
+ stderrFile: stderr?.file,
111
+ };
112
+ }
113
+ finally {
114
+ if (stderr)
115
+ closeSync(stderr.fd);
116
+ }
73
117
  }
74
118
  /** Returns a running server of this version or newer, starting one if needed. Call with server.lock held. */
75
119
  async function ensureServer(paths) {
@@ -87,15 +131,18 @@ async function ensureServer(paths) {
87
131
  }
88
132
  }
89
133
  }
90
- const pid = startServer(paths);
134
+ const server = startServer(paths);
91
135
  const deadline = Date.now() + START_TIMEOUT_MS;
92
- while (Date.now() < deadline) {
136
+ while (Date.now() < deadline && !server.ended()) {
93
137
  await sleep(200);
94
138
  const info = readServerInfo(paths.serverJson);
95
- if (info?.pid === pid && (await health(info)) !== undefined)
139
+ if (info?.pid === server.pid && (await health(info)) !== undefined)
96
140
  return info;
97
141
  }
98
- throw new UserError(`The sfbench server did not start. See ${paths.serverLog}.`);
142
+ const said = server.stderr();
143
+ // The server removes its stderr file once it listens: named only while it is there.
144
+ const files = [paths.serverLog, ...(server.stderrFile && existsSync(server.stderrFile) ? [server.stderrFile] : [])].join(' and ');
145
+ throw new UserError(`The sfbench server did not start${said ? `:\n${said}` : '.'}\nSee ${files}.`);
99
146
  }
100
147
  async function resolveOrg(options) {
101
148
  if (options.targetOrg)
@@ -0,0 +1,27 @@
1
+ // Whether this Node.js can run the sfbench server, which is started with
2
+ // --use-env-proxy (docs/design.md "プロキシ"). Node.js has it from 22.21.0 and
3
+ // 24.5.0 (package.json "engines"); an older one refuses the option and the
4
+ // server dies at once. The option itself is checked, not the version.
5
+ import { readFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { packageRoot } from "./paths.js";
8
+ /** The versions package.json "engines" asks for, as the message names them. */
9
+ function engines() {
10
+ try {
11
+ return JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')).engines?.node ?? 'a newer one';
12
+ }
13
+ catch {
14
+ return 'a newer one';
15
+ }
16
+ }
17
+ /** Why this Node.js cannot run sfbench, with what to do; undefined when it can. */
18
+ export function unsupportedNode(hasEnvProxy = process.allowedNodeEnvironmentFlags.has('--use-env-proxy'), version = process.versions.node, execPath = process.execPath, wanted = engines()) {
19
+ if (hasEnvProxy)
20
+ return undefined;
21
+ return [
22
+ `sfbench needs Node.js ${wanted}, but this is Node.js ${version} (${execPath}).`,
23
+ 'A version manager (asdf, nvm, mise and the like) may pick an older Node.js in this folder.',
24
+ 'To run sfbench with Node.js 24 from npm anyway:',
25
+ ' npx -y -p node@24 -p sfbench -- sfbench <options>',
26
+ ].join('\n');
27
+ }
@@ -2,7 +2,7 @@
2
2
  // the background; `npm run dev:server` runs it in the foreground.
3
3
  import { randomBytes } from 'node:crypto';
4
4
  import { readdirSync, rmSync, statSync } from 'node:fs';
5
- import { join } from 'node:path';
5
+ import { basename, dirname, join } from 'node:path';
6
6
  import { readJson, writeFileAtomic } from "./fsutil.js";
7
7
  import { App } from "./http/server.js";
8
8
  import { initLog, log } from "./log.js";
@@ -52,9 +52,35 @@ function cleanTmp(dir) {
52
52
  const now = Date.now();
53
53
  for (const name of readdirSync(dir)) {
54
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 });
55
+ try {
56
+ const mtime = statSync(file, { throwIfNoEntry: false })?.mtimeMs;
57
+ if (mtime !== undefined && now - mtime > TMP_MAX_AGE_MS)
58
+ rmSync(file, { recursive: true, force: true });
59
+ }
60
+ catch {
61
+ // In use (Windows refuses to remove a file a running server keeps open): left for the next time.
62
+ }
63
+ }
64
+ }
65
+ /** Whether a path is one of the files the CLI keeps a server's stderr in: only such a file is removed. */
66
+ export function isStderrFile(file, tmpDir) {
67
+ return dirname(file) === tmpDir && /^server-stderr-[\w-]+\.log$/.test(basename(file));
68
+ }
69
+ /**
70
+ * Removes the file the CLI kept this server's stderr in, once it listens: it
71
+ * was only to tell why a start failed, and must not keep growing while the
72
+ * server runs (launcher.ts). Only a file of that name in tmp/ is removed.
73
+ * Windows may refuse to remove an open file; it is then cleared with tmp/.
74
+ */
75
+ function removeStderrFile(paths) {
76
+ const file = process.env.SFBENCH_STDERR_FILE;
77
+ if (!file || !isStderrFile(file, paths.tmpDir))
78
+ return;
79
+ try {
80
+ rmSync(file, { force: true });
81
+ }
82
+ catch {
83
+ // Kept until tmp/ is cleaned.
58
84
  }
59
85
  }
60
86
  export async function serve(dev) {
@@ -100,6 +126,7 @@ export async function serve(dev) {
100
126
  // never sees a server.json for a server that is not ready.
101
127
  writeServerInfo(paths.serverJson, { port, adminToken, pid: process.pid, version });
102
128
  log.info(`sfbench ${version} listening on 127.0.0.1:${port}${dev ? ' (dev)' : ''}`);
129
+ removeStderrFile(paths);
103
130
  break;
104
131
  }
105
132
  }