@syntax-syllogism/sfd 0.0.0-stage → 0.2.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/CHANGELOG.md ADDED
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0/).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.2.0] - 2026-10-22
11
+
12
+ ### Added
13
+
14
+ - Agent hook that rewrites supported `sf` commands to their faster `sfd` equivalents
15
+
16
+ ## [0.1.0] - 2026-10-22
17
+
18
+ ### Added
19
+
20
+ - Warm local daemon for fast Salesforce CLI operations, covering queries, anonymous Apex, REST calls, describes, source deploys, and Apex test runs
21
+
22
+ ### Fixed
23
+
24
+ - Correct UTF-8 handling of Salesforce output
25
+ - Fix paging of large result sets
26
+ - Fix daemon startup and shutdown lifecycle
27
+ - Enforce timeouts on Salesforce operations
28
+ - Fix command-line interface behavior
29
+
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jacob Richter
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,145 @@
1
- # Temporary Holding Version
1
+ # sfd
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Fast Salesforce CLI operations through a warm local daemon.
4
+
5
+ sfd runs common Salesforce CLI operations through a
6
+ long-lived local daemon, so each call skips the 5–20 seconds `sf` spends
7
+ loading oclif and its plugins. A warm call answers in about 0.2 seconds. It is
8
+ meant for agents and scripts that make many small org calls in a row.
9
+
10
+ Requires Node 22 or newer and an installed, authenticated Salesforce CLI. sfd
11
+ has no Salesforce dependencies of its own: the daemon loads
12
+ `@salesforce/core`, source-deploy-retrieve, apex-node and source-tracking from
13
+ the `sf` install on `PATH`. Auth, aliases, API versions and the metadata
14
+ registry therefore always match what `sf` uses.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ npm install --global @syntax-syllogism/sfd
20
+ sfd query "SELECT Id, Name FROM Account LIMIT 5" -o my-org
21
+ ```
22
+
23
+ ## Commands
24
+
25
+ | Command | Does | `sf` equivalent |
26
+ | --- | --- | --- |
27
+ | `sfd query "<soql>"` | SOQL, all pages, `attributes` stripped. `--tooling`, `--all`, `--raw` for the unstripped result | `sf data query` |
28
+ | `sfd apex [file]` | Anonymous Apex from a file, `-f`, or stdin. Prints `USER_DEBUG` and fatal lines; `--log` for the full log | `sf apex run` |
29
+ | `sfd rest <path>` | REST call. `-X <method>`, `--body <json\|@file>` | `sf api request rest` |
30
+ | `sfd describe <sobject>` | Compact describe; `--raw` for the full one. `--tooling` | `sf sobject describe` |
31
+ | `sfd deploy -d <path> \| -m <Type[:Name]>` | Deploy source. `--check-only`, `-l <level>`, `-t <test>`, `-w <minutes>` | `sf project deploy start` |
32
+ | `sfd test` | Run Apex tests. `-n <class>`, `-t <Class.method>`, `-s <suite>`, `-l <level>`, `-c` (coverage), `-w <minutes>` | `sf apex run test --wait` |
33
+ | `sfd org` | Resolved username, org id, instance URL and API version | `sf org display` |
34
+ | `sfd hook <agent>`, `check`, `install`, `uninstall` | Agent integration: rewrite `sf` commands before an agent runs them. See [docs/agent-hooks.md](docs/agent-hooks.md) | |
35
+ | `sfd start \| stop \| status \| reload` | Daemon lifecycle; `reload` drops cached orgs and auth state | |
36
+
37
+ `-d`, `-m`, `-n`, `-t` and `-s` repeat or take comma-separated lists. Every
38
+ command takes `-o <alias>` and `--json` (the raw daemon response). Without `-o`
39
+ the org resolves like `sf`: `SF_TARGET_ORG`, then the nearest
40
+ `.sf/config.json`, then the global config.
41
+
42
+ Exit codes: `0` success, `1` the operation failed (including a compile error,
43
+ a failed deploy, or a failing test), `2` usage error, `3` the daemon could
44
+ not be reached or started, or a request timed out. Unreadable input files are
45
+ usage errors and include the file path.
46
+
47
+ Use `sf` (with `--json` when scripting) for anything sfd does not cover (org creation, package
48
+ commands, retrieve, data import).
49
+
50
+ ## Daemon lifecycle
51
+
52
+ The first command starts the daemon detached; there is nothing to set up. It
53
+ listens on a Unix socket in `$XDG_RUNTIME_DIR/sfd-<uid>/` (or the system temp
54
+ directory), in a directory with mode `0700` and a socket with mode `0600`.
55
+ Access tokens stay in daemon memory and are never written to disk. The daemon
56
+ log is `sfd.log` in the same directory; `sfd status` prints its path.
57
+
58
+ The daemon exits on its own when:
59
+
60
+ - it has been idle for 60 minutes (`SFD_IDLE_MINUTES` overrides),
61
+ - a client with different sfd code connects (including lifecycle commands;
62
+ the protocol version includes the source mtime, so an upgrade or local edit
63
+ takes effect on the next call), or
64
+ - the `sf` install changes (an `sf update` rewrites its `package.json`).
65
+
66
+ In each case it stops accepting connections at once, finishes in-flight
67
+ requests (a running deploy or test run is not cut off), and the next call
68
+ starts a fresh daemon.
69
+
70
+ Auth changes made with `sf org login` or `sf alias set` are picked up
71
+ automatically. An expired session is rebuilt once and the call retried. Run
72
+ `sfd reload` if org state still looks stale. Lifecycle commands replace an
73
+ older daemon before reporting it as running or reloading it; `stop` shuts
74
+ it down even if its protocol differs.
75
+
76
+ ## Behavior notes
77
+
78
+ - Queries fetch every page without a record cap. Compact results preserve
79
+ `done` and any continuation URL supplied by Salesforce.
80
+ - Deploys reload `sfdx-project.json` on every call and run one at a time per
81
+ daemon. After a successful deploy to an org that tracks source, sfd updates
82
+ source tracking as `sf project deploy start` does.
83
+ - `deploy` without `-l` uses the Metadata API default. `-t` alone implies
84
+ `RunSpecifiedTests`.
85
+ - `test` without selectors runs `RunLocalTests`. A non-matching method is
86
+ reported as skipped, as with `sf`.
87
+ - Paths in `-d` and the project directory resolve from the client's working
88
+ directory, not the daemon's.
89
+
90
+ ## Environment
91
+
92
+ | Variable | Purpose |
93
+ | --- | --- |
94
+ | `SFD_TIMEOUT_SECONDS` | Positive request deadline in seconds, including connection time. Overrides defaults: 3 seconds for health/lifecycle requests; 120 seconds for other operations, extended to at least the deploy/test `-w` wait plus 60 seconds (default waits: 33/60 minutes). Increase this for long queries or queued deploys. Timeout exits 3. |
95
+ | `SFD_IDLE_MINUTES` | Idle timeout, default 60. |
96
+ | `SFD_RUNTIME_DIR` | Socket, log and pid directory. |
97
+ | `SFD_SF_ROOT` | `@salesforce/cli` install to load libraries from, instead of the `sf` on `PATH`. |
98
+ | `SFD_HANDLERS_MODULE` | Test seam: stub handlers module used by the CLI tests. |
99
+
100
+ ## Testing
101
+
102
+ `npm test` covers project and org resolution, output formatting, the socket
103
+ protocol (dispatch, version restart, drain, idle, stale socket), and the CLI
104
+ end to end against stub handlers, and the agent hook adapters and `sf` rewrite
105
+ rules. Injected Salesforce modules cover handler
106
+ paging, auth refresh, deployment and test orchestration without an org.
107
+ A real-org check is opt-in:
108
+
109
+ ```bash
110
+ SFD_TEST_ORG=<alias> npm test
111
+ ```
112
+
113
+ For local development, `npm run build` syntax-checks every source file and
114
+ `npm test` runs the suite. sfd has no runtime or dev dependencies, so there is
115
+ nothing to install; the Salesforce libraries come from your `sf` install.
116
+
117
+ ## Contributing
118
+
119
+ See [CONTRIBUTING.md](CONTRIBUTING.md) and the
120
+ [Code of Conduct](CODE_OF_CONDUCT.md).
121
+
122
+ ## Security
123
+
124
+ See [SECURITY.md](SECURITY.md) for how to report vulnerabilities.
125
+
126
+ ## License
127
+
128
+ [MIT](LICENSE) © Jacob Richter
129
+
130
+ ## Agent integration
131
+
132
+ `sfd hook` rewrites supported `sf` shell commands before a coding agent runs
133
+ them, so they reach the warm daemon. Install an adapter for Claude Code, Codex
134
+ CLI or Gemini CLI (user scope by default):
135
+
136
+ ```bash
137
+ sfd hook install --agent claude
138
+ sfd hook install --agent codex
139
+ sfd hook install --agent gemini
140
+ ```
141
+
142
+ Add `--project` to install into the current project instead. Preview a rewrite
143
+ with `sfd hook check '<sf command>'`. The supported commands, adapter details,
144
+ skip rules and the `SFD_HOOK_DISABLE=1` switch are in
145
+ [docs/agent-hooks.md](docs/agent-hooks.md).
package/bin/sfd.mjs ADDED
@@ -0,0 +1,220 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, existsSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { parseArgs } from "node:util";
5
+ import { call, isRunning, startDaemon, DaemonError, VERSION } from "../src/client.mjs";
6
+ import { findProjectDir, resolveTargetOrg } from "../src/project.mjs";
7
+ import { formatApex, formatDeploy, formatTests } from "../src/format.mjs";
8
+ import { paths } from "../src/paths.mjs";
9
+ import { UsageError } from "../src/usage-error.mjs";
10
+
11
+ const USAGE = `sfd ${VERSION}: fast Salesforce CLI operations through a warm local daemon
12
+
13
+ Usage: sfd <command> [options]
14
+
15
+ Commands:
16
+ query <soql> Run SOQL (all pages). --tooling, --all (include deleted)
17
+ apex [file] Run anonymous Apex from a file, -f <file>, or stdin. --log for the full debug log
18
+ rest <path> Call a REST path, e.g. /services/data/v67.0/limits. -X <method>, --body <json|@file>
19
+ describe <sobject> Compact describe. --tooling, --raw for the full describe
20
+ deploy Deploy source. -d <path> (repeatable), -m <Type[:Name]> (repeatable),
21
+ --check-only, -l <test level>, -t <test> (repeatable), -w <minutes>
22
+ test Run Apex tests. -n <class> (repeatable), -t <Class.method> (repeatable),
23
+ -s <suite> (repeatable), -l <test level>, -c (coverage), -w <minutes>
24
+ org Show the resolved org (username, id, instance, API version)
25
+ hook <agent|check|install|uninstall>
26
+ Integrate with Claude Code, Codex CLI or Gemini CLI
27
+ start | stop | status | reload
28
+ Manage the daemon. reload drops cached orgs and auth state
29
+
30
+ Common options:
31
+ -o, --target-org <alias> Org alias or username (default: sf target-org config)
32
+ --json Print the raw JSON response
33
+ -h, --help Show this help
34
+
35
+ Exit codes: 0 success, 1 operation failed, 2 usage error, 3 daemon error.`;
36
+
37
+ const options = {
38
+ "target-org": { type: "string", short: "o" },
39
+ json: { type: "boolean" },
40
+ help: { type: "boolean", short: "h" },
41
+ tooling: { type: "boolean" },
42
+ all: { type: "boolean" },
43
+ raw: { type: "boolean" },
44
+ log: { type: "boolean" },
45
+ file: { type: "string", short: "f" },
46
+ method: { type: "string", short: "X" },
47
+ body: { type: "string" },
48
+ "source-dir": { type: "string", short: "d", multiple: true },
49
+ metadata: { type: "string", short: "m", multiple: true },
50
+ "check-only": { type: "boolean" },
51
+ "test-level": { type: "string", short: "l" },
52
+ tests: { type: "string", short: "t", multiple: true },
53
+ "class-names": { type: "string", short: "n", multiple: true },
54
+ "suite-names": { type: "string", short: "s", multiple: true },
55
+ "code-coverage": { type: "boolean", short: "c" },
56
+ wait: { type: "string", short: "w" }
57
+ };
58
+
59
+ const split = list => list?.flatMap(v => v.split(",")).map(v => v.trim()).filter(Boolean);
60
+
61
+ function readStdin() {
62
+ if (process.stdin.isTTY) return "";
63
+ try { return readFileSync(0, "utf8"); } catch { return ""; }
64
+ }
65
+
66
+ function readInputFile(file) {
67
+ const path = resolve(file);
68
+ try { return readFileSync(path, "utf8"); }
69
+ catch (e) { throw new UsageError(`Cannot read input file ${path}: ${e.message}`); }
70
+ }
71
+
72
+ function readBody(spec) {
73
+ if (spec === undefined) return undefined;
74
+ const text = spec.startsWith("@") ? readInputFile(spec.slice(1)) : spec;
75
+ try { return JSON.parse(text); } catch { return text; }
76
+ }
77
+
78
+ function wait(values) {
79
+ if (values.wait === undefined) return undefined;
80
+ const minutes = Number(values.wait);
81
+ if (!Number.isFinite(minutes) || minutes <= 0) throw new UsageError("--wait must be a positive number of minutes.");
82
+ return minutes;
83
+ }
84
+
85
+ function print(value) {
86
+ process.stdout.write((typeof value === "string" ? value : JSON.stringify(value, null, 2)) + "\n");
87
+ }
88
+
89
+ async function run(argv) {
90
+ if (argv[0] === "hook") {
91
+ const { runHook } = await import("../src/hooks.mjs");
92
+ return runHook(argv.slice(1));
93
+ }
94
+ const { values, positionals } = parseArgs({ args: argv, options, allowPositionals: true });
95
+ const [command, ...rest] = positionals;
96
+ if (values.help || !command) { print(USAGE); return 0; }
97
+
98
+ const cwd = process.cwd();
99
+ const org = () => resolveTargetOrg(values["target-org"], cwd);
100
+ const projectDir = () => {
101
+ const dir = findProjectDir(cwd);
102
+ if (!dir) throw new UsageError("No sfdx-project.json found in this directory or its parents.");
103
+ return dir;
104
+ };
105
+
106
+ // Lifecycle commands.
107
+ switch (command) {
108
+ case "start":
109
+ if (await isRunning()) { print("sfd already running"); return 0; }
110
+ startDaemon();
111
+ await call("ping");
112
+ print(`sfd started (${paths().socket})`);
113
+ return 0;
114
+ case "stop":
115
+ if (!(await isRunning())) { print("sfd not running"); return 0; }
116
+ await call("stop", {}, { autoStart: false });
117
+ print("sfd stopping");
118
+ return 0;
119
+ case "status": {
120
+ if (!(await isRunning())) { print(values.json ? { running: false } : "sfd not running"); return 0; }
121
+ const res = await call("status", {}, { autoStart: false });
122
+ const s = { running: true, version: VERSION, socket: paths().socket, log: paths().log, ...res.result };
123
+ print(values.json ? s : `sfd ${VERSION} running (pid ${s.pid}, up ${s.uptimeSeconds}s), orgs: ${s.orgs.join(", ") || "none"}\nlog: ${s.log}`);
124
+ return 0;
125
+ }
126
+ case "reload": {
127
+ if (!(await isRunning())) { print("sfd not running"); return 0; }
128
+ await call("reload", {}, { autoStart: false });
129
+ print("sfd reloaded");
130
+ return 0;
131
+ }
132
+ }
133
+
134
+ let req;
135
+ let render = result => result;
136
+ switch (command) {
137
+ case "query": {
138
+ const q = rest.join(" ") || readStdin().trim();
139
+ if (!q) throw new UsageError("query needs a SOQL string.");
140
+ req = { org: org(), q, tooling: values.tooling, all: values.all, raw: values.raw };
141
+ break;
142
+ }
143
+ case "apex": {
144
+ const file = values.file ?? rest[0];
145
+ const body = file ? readInputFile(file) : readStdin();
146
+ if (!body.trim()) throw new UsageError("apex needs code from a file, -f <file>, or stdin.");
147
+ req = { org: org(), body };
148
+ render = result => formatApex(result, { fullLog: values.log });
149
+ break;
150
+ }
151
+ case "rest": {
152
+ const url = rest[0];
153
+ if (!url) throw new UsageError("rest needs a path, e.g. /services/data/v67.0/limits.");
154
+ req = { org: org(), method: (values.method ?? (values.body ? "POST" : "GET")).toUpperCase(), url, body: readBody(values.body) };
155
+ break;
156
+ }
157
+ case "describe": {
158
+ if (!rest[0]) throw new UsageError("describe needs an sObject name.");
159
+ req = { org: org(), sobject: rest[0], tooling: values.tooling, raw: values.raw };
160
+ break;
161
+ }
162
+ case "deploy": {
163
+ const dir = projectDir();
164
+ const sourcePaths = split(values["source-dir"])?.map(d => resolve(cwd, d));
165
+ for (const p of sourcePaths ?? []) if (!existsSync(p)) throw new UsageError(`No such path: ${p}`);
166
+ const metadata = split(values.metadata);
167
+ if (!sourcePaths?.length && !metadata?.length) throw new UsageError("deploy needs -d <path> or -m <Type[:Name]>.");
168
+ req = {
169
+ org: org(), projectDir: dir, sourcePaths, metadata, checkOnly: values["check-only"],
170
+ testLevel: values["test-level"], tests: split(values.tests), waitMinutes: wait(values)
171
+ };
172
+ render = result => formatDeploy(result, req.org);
173
+ break;
174
+ }
175
+ case "test":
176
+ req = {
177
+ org: org(), classNames: split(values["class-names"]), tests: split(values.tests), suiteNames: split(values["suite-names"]),
178
+ testLevel: values["test-level"], codeCoverage: values["code-coverage"], waitMinutes: wait(values)
179
+ };
180
+ render = result => formatTests(result, req.org);
181
+ break;
182
+ case "org":
183
+ req = { org: org() };
184
+ break;
185
+ default:
186
+ throw new UsageError(`Unknown command: ${command}. Run sfd --help.`);
187
+ }
188
+
189
+ const res = await call(command, req);
190
+ if (values.json) { print(res); return res.status === 0 ? okStatus(command, res.result) : 1; }
191
+ if (res.status !== 0) {
192
+ process.stderr.write(`${res.name}: ${res.message}\n`);
193
+ return 1;
194
+ }
195
+ print(render(res.result));
196
+ return okStatus(command, res.result);
197
+ }
198
+
199
+ // A request can succeed while the Salesforce operation fails (compile error, failed deploy or test).
200
+ function okStatus(command, result) {
201
+ if (command === "apex") return result.compiled && result.success ? 0 : 1;
202
+ if (command === "deploy") return result.success ? 0 : 1;
203
+ if (command === "test") return result.failing === 0 && !result.setupFailures?.length ? 0 : 1;
204
+ return 0;
205
+ }
206
+
207
+ try {
208
+ process.exitCode = await run(process.argv.slice(2));
209
+ } catch (e) {
210
+ if (e instanceof UsageError || e.code?.startsWith?.("ERR_PARSE_ARGS")) {
211
+ process.stderr.write(`${e.message}\n`);
212
+ process.exitCode = 2;
213
+ } else if (e instanceof DaemonError || ["ENOENT", "ECONNREFUSED", "ECONNRESET", "EPIPE"].includes(e.code)) {
214
+ process.stderr.write(`sfd daemon error: ${e.message}\nlog: ${paths().log}\n`);
215
+ process.exitCode = 3;
216
+ } else {
217
+ process.stderr.write(`${e.message}\n`);
218
+ process.exitCode = 1;
219
+ }
220
+ }
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "@syntax-syllogism/sfd",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Warm Salesforce CLI daemon: SOQL, anonymous Apex, REST, describe, deploy, and Apex tests without sf startup cost.",
5
+ "type": "module",
6
+ "bin": {
7
+ "sfd": "bin/sfd.mjs"
8
+ },
9
+ "engines": {
10
+ "node": ">=22"
11
+ },
12
+ "files": [
13
+ "bin/sfd.mjs",
14
+ "src",
15
+ "CHANGELOG.md",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "keywords": [
20
+ "salesforce",
21
+ "sf",
22
+ "sfdx",
23
+ "cli",
24
+ "daemon",
25
+ "apex",
26
+ "soql",
27
+ "agents"
28
+ ],
29
+ "homepage": "https://github.com/Syntax-Syllogism/sfd#readme",
30
+ "bugs": {
31
+ "url": "https://github.com/Syntax-Syllogism/sfd/issues"
32
+ },
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/Syntax-Syllogism/sfd.git"
36
+ },
37
+ "license": "MIT",
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "scripts": {
42
+ "build": "for f in bin/sfd.mjs src/*.mjs; do node --check \"$f\" || exit 1; done",
43
+ "test": "node --test test/*.test.mjs"
44
+ }
45
+ }
package/src/client.mjs ADDED
@@ -0,0 +1,85 @@
1
+ import net from "node:net";
2
+ import { spawn } from "node:child_process";
3
+ import { openSync } from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ import { paths } from "./paths.mjs";
6
+ import { PROTOCOL } from "./version.mjs";
7
+
8
+ export { VERSION } from "./version.mjs";
9
+
10
+ export class DaemonError extends Error {}
11
+
12
+ function send(socket, req, timeoutMs) {
13
+ return new Promise((resolve, reject) => {
14
+ const conn = net.connect(socket);
15
+ conn.setEncoding("utf8");
16
+ const timer = setTimeout(() => conn.destroy(new DaemonError(`sfd request timed out after ${timeoutMs / 1000} seconds.`)), timeoutMs);
17
+ conn.on("close", () => clearTimeout(timer));
18
+ let buf = "";
19
+ conn.on("connect", () => conn.write(JSON.stringify(req) + "\n"));
20
+ conn.on("data", chunk => (buf += chunk));
21
+ conn.on("end", () => {
22
+ try { resolve(JSON.parse(buf)); } catch { reject(new DaemonError(`Bad response from sfd: ${buf.slice(0, 200)}`)); }
23
+ });
24
+ conn.on("error", reject);
25
+ });
26
+ }
27
+
28
+ export function startDaemon(env = process.env) {
29
+ const p = paths(env);
30
+ const out = openSync(p.log, "a");
31
+ const child = spawn(process.execPath, [fileURLToPath(new URL("./daemon.mjs", import.meta.url))], {
32
+ detached: true, stdio: ["ignore", out, out], env
33
+ });
34
+ child.unref();
35
+ }
36
+
37
+ async function waitForSocket(socket, timeoutMs) {
38
+ const deadline = Date.now() + timeoutMs;
39
+ for (;;) {
40
+ try {
41
+ return await send(socket, { op: "ping", v: PROTOCOL }, Math.max(1, Math.min(3000, deadline - Date.now())));
42
+ } catch (e) {
43
+ if (Date.now() > deadline) throw new DaemonError(`sfd did not start within ${timeoutMs} ms (${e.code ?? e.message}); see the daemon log.`);
44
+ await new Promise(r => setTimeout(r, 50));
45
+ }
46
+ }
47
+ }
48
+
49
+ // Send one request, starting the daemon if needed and replacing it once on a version mismatch.
50
+ export async function call(op, args = {}, { env = process.env, autoStart = true, startTimeoutMs = 30_000 } = {}) {
51
+ const p = paths(env);
52
+ const req = { ...args, op, v: PROTOCOL };
53
+ for (let attempt = 0; attempt < 3; attempt++) {
54
+ let res;
55
+ try {
56
+ const configured = Number(env.SFD_TIMEOUT_SECONDS);
57
+ const health = ["ping", "status", "stop", "reload"].includes(op);
58
+ const waitMinutes = args.waitMinutes ?? (op === "deploy" ? 33 : op === "test" ? 60 : 0);
59
+ const timeoutMs = Number.isFinite(configured) && configured > 0
60
+ ? configured * 1000
61
+ : health ? 3000 : Math.max(120_000, waitMinutes * 60_000 + 60_000);
62
+ res = await send(p.socket, req, timeoutMs);
63
+ } catch (e) {
64
+ if (!autoStart || !["ENOENT", "ECONNREFUSED"].includes(e.code)) throw e;
65
+ startDaemon(env);
66
+ await waitForSocket(p.socket, startTimeoutMs);
67
+ continue;
68
+ }
69
+ if (!res.restart) return res;
70
+ // The old daemon is draining and has freed the socket path; start ours.
71
+ startDaemon(env);
72
+ await waitForSocket(p.socket, startTimeoutMs);
73
+ }
74
+ throw new DaemonError("sfd kept asking for a restart; run `sfd stop` and retry.");
75
+ }
76
+
77
+ export async function isRunning(env = process.env) {
78
+ try {
79
+ await call("ping", {}, { env, autoStart: false });
80
+ return true;
81
+ } catch (e) {
82
+ if (["ENOENT", "ECONNREFUSED"].includes(e.code)) return false;
83
+ throw e;
84
+ }
85
+ }
package/src/daemon.mjs ADDED
@@ -0,0 +1,43 @@
1
+ // Daemon entry point. The client spawns this detached; it is not meant to be run by hand.
2
+ import { readFileSync, statSync, writeFileSync, unlinkSync } from "node:fs";
3
+ import { PROTOCOL, VERSION } from "./version.mjs";
4
+ import { AlreadyRunning, startServer } from "./server.mjs";
5
+ import { paths } from "./paths.mjs";
6
+ import { findSfRoot, sfInfo } from "./sf-root.mjs";
7
+
8
+ const log = (...args) => console.error(new Date().toISOString(), ...args);
9
+ const p = paths();
10
+ const sf = sfInfo(findSfRoot());
11
+
12
+ // SFD_HANDLERS_MODULE swaps in stub handlers for tests that must not touch an org.
13
+ const { createHandlers } = await import(process.env.SFD_HANDLERS_MODULE ?? "./handlers.mjs");
14
+ const handlers = createHandlers(sf.root);
15
+
16
+ // An sf upgrade rewrites its package.json; finish in-flight work and let the next call start fresh.
17
+ const isStale = () => {
18
+ try { return statSync(`${sf.root}/package.json`).mtimeMs !== sf.mtimeMs; } catch { return true; }
19
+ };
20
+
21
+ try {
22
+ await startServer({
23
+ socket: p.socket,
24
+ version: PROTOCOL,
25
+ handlers,
26
+ idleMs: Number(process.env.SFD_IDLE_MINUTES ?? 60) * 60_000,
27
+ isStale,
28
+ onExit: reason => {
29
+ log(`exiting (${reason})`);
30
+ try { if (readFileSync(p.pid, "utf8").trim() === String(process.pid)) unlinkSync(p.pid); } catch {}
31
+ setTimeout(() => process.exit(0), 100);
32
+ }
33
+ });
34
+ } catch (e) {
35
+ if (e instanceof AlreadyRunning) {
36
+ log("another sfd daemon is already running; exiting");
37
+ process.exit(0);
38
+ }
39
+ throw e;
40
+ }
41
+
42
+ writeFileSync(p.pid, String(process.pid));
43
+ log(`sfd ${VERSION} listening on ${p.socket} (pid ${process.pid}, sf ${sf.version})`);