@syntax-syllogism/sfd 0.0.0-stage → 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/CHANGELOG.md ADDED
@@ -0,0 +1,23 @@
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.1.0] - 2026-10-22
11
+
12
+ ### Added
13
+
14
+ - Warm local daemon for fast Salesforce CLI operations, covering queries, anonymous Apex, REST calls, describes, source deploys, and Apex test runs
15
+
16
+ ### Fixed
17
+
18
+ - Correct UTF-8 handling of Salesforce output
19
+ - Fix paging of large result sets
20
+ - Fix daemon startup and shutdown lifecycle
21
+ - Enforce timeouts on Salesforce operations
22
+ - Fix command-line interface behavior
23
+
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,126 @@
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 start \| stop \| status \| reload` | Daemon lifecycle; `reload` drops cached orgs and auth state | |
35
+
36
+ `-d`, `-m`, `-n`, `-t` and `-s` repeat or take comma-separated lists. Every
37
+ command takes `-o <alias>` and `--json` (the raw daemon response). Without `-o`
38
+ the org resolves like `sf`: `SF_TARGET_ORG`, then the nearest
39
+ `.sf/config.json`, then the global config.
40
+
41
+ Exit codes: `0` success, `1` the operation failed (including a compile error,
42
+ a failed deploy, or a failing test), `2` usage error, `3` the daemon could
43
+ not be reached or started, or a request timed out. Unreadable input files are
44
+ usage errors and include the file path.
45
+
46
+ Use `sf` (with `--json` when scripting) for anything sfd does not cover (org creation, package
47
+ commands, retrieve, data import).
48
+
49
+ ## Daemon lifecycle
50
+
51
+ The first command starts the daemon detached; there is nothing to set up. It
52
+ listens on a Unix socket in `$XDG_RUNTIME_DIR/sfd-<uid>/` (or the system temp
53
+ directory), in a directory with mode `0700` and a socket with mode `0600`.
54
+ Access tokens stay in daemon memory and are never written to disk. The daemon
55
+ log is `sfd.log` in the same directory; `sfd status` prints its path.
56
+
57
+ The daemon exits on its own when:
58
+
59
+ - it has been idle for 60 minutes (`SFD_IDLE_MINUTES` overrides),
60
+ - a client with different sfd code connects (including lifecycle commands;
61
+ the protocol version includes the source mtime, so an upgrade or local edit
62
+ takes effect on the next call), or
63
+ - the `sf` install changes (an `sf update` rewrites its `package.json`).
64
+
65
+ In each case it stops accepting connections at once, finishes in-flight
66
+ requests (a running deploy or test run is not cut off), and the next call
67
+ starts a fresh daemon.
68
+
69
+ Auth changes made with `sf org login` or `sf alias set` are picked up
70
+ automatically. An expired session is rebuilt once and the call retried. Run
71
+ `sfd reload` if org state still looks stale. Lifecycle commands replace an
72
+ older daemon before reporting it as running or reloading it; `stop` shuts
73
+ it down even if its protocol differs.
74
+
75
+ ## Behavior notes
76
+
77
+ - Queries fetch every page without a record cap. Compact results preserve
78
+ `done` and any continuation URL supplied by Salesforce.
79
+ - Deploys reload `sfdx-project.json` on every call and run one at a time per
80
+ daemon. After a successful deploy to an org that tracks source, sfd updates
81
+ source tracking as `sf project deploy start` does.
82
+ - `deploy` without `-l` uses the Metadata API default. `-t` alone implies
83
+ `RunSpecifiedTests`.
84
+ - `test` without selectors runs `RunLocalTests`. A non-matching method is
85
+ reported as skipped, as with `sf`.
86
+ - Paths in `-d` and the project directory resolve from the client's working
87
+ directory, not the daemon's.
88
+
89
+ ## Environment
90
+
91
+ | Variable | Purpose |
92
+ | --- | --- |
93
+ | `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. |
94
+ | `SFD_IDLE_MINUTES` | Idle timeout, default 60. |
95
+ | `SFD_RUNTIME_DIR` | Socket, log and pid directory. |
96
+ | `SFD_SF_ROOT` | `@salesforce/cli` install to load libraries from, instead of the `sf` on `PATH`. |
97
+ | `SFD_HANDLERS_MODULE` | Test seam: stub handlers module used by the CLI tests. |
98
+
99
+ ## Testing
100
+
101
+ `npm test` covers project and org resolution, output formatting, the socket
102
+ protocol (dispatch, version restart, drain, idle, stale socket), and the CLI
103
+ end to end against stub handlers. Injected Salesforce modules cover handler
104
+ paging, auth refresh, deployment and test orchestration without an org.
105
+ A real-org check is opt-in:
106
+
107
+ ```bash
108
+ SFD_TEST_ORG=<alias> npm test
109
+ ```
110
+
111
+ For local development, `npm run build` syntax-checks every source file and
112
+ `npm test` runs the suite. sfd has no runtime or dev dependencies, so there is
113
+ nothing to install; the Salesforce libraries come from your `sf` install.
114
+
115
+ ## Contributing
116
+
117
+ See [CONTRIBUTING.md](CONTRIBUTING.md) and the
118
+ [Code of Conduct](CODE_OF_CONDUCT.md).
119
+
120
+ ## Security
121
+
122
+ See [SECURITY.md](SECURITY.md) for how to report vulnerabilities.
123
+
124
+ ## License
125
+
126
+ [MIT](LICENSE) © Jacob Richter
package/bin/sfd.mjs ADDED
@@ -0,0 +1,215 @@
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
+
10
+ const USAGE = `sfd ${VERSION}: fast Salesforce CLI operations through a warm local daemon
11
+
12
+ Usage: sfd <command> [options]
13
+
14
+ Commands:
15
+ query <soql> Run SOQL (all pages). --tooling, --all (include deleted)
16
+ apex [file] Run anonymous Apex from a file, -f <file>, or stdin. --log for the full debug log
17
+ rest <path> Call a REST path, e.g. /services/data/v67.0/limits. -X <method>, --body <json|@file>
18
+ describe <sobject> Compact describe. --tooling, --raw for the full describe
19
+ deploy Deploy source. -d <path> (repeatable), -m <Type[:Name]> (repeatable),
20
+ --check-only, -l <test level>, -t <test> (repeatable), -w <minutes>
21
+ test Run Apex tests. -n <class> (repeatable), -t <Class.method> (repeatable),
22
+ -s <suite> (repeatable), -l <test level>, -c (coverage), -w <minutes>
23
+ org Show the resolved org (username, id, instance, API version)
24
+ start | stop | status | reload
25
+ Manage the daemon. reload drops cached orgs and auth state
26
+
27
+ Common options:
28
+ -o, --target-org <alias> Org alias or username (default: sf target-org config)
29
+ --json Print the raw JSON response
30
+ -h, --help Show this help
31
+
32
+ Exit codes: 0 success, 1 operation failed, 2 usage error, 3 daemon error.`;
33
+
34
+ const options = {
35
+ "target-org": { type: "string", short: "o" },
36
+ json: { type: "boolean" },
37
+ help: { type: "boolean", short: "h" },
38
+ tooling: { type: "boolean" },
39
+ all: { type: "boolean" },
40
+ raw: { type: "boolean" },
41
+ log: { type: "boolean" },
42
+ file: { type: "string", short: "f" },
43
+ method: { type: "string", short: "X" },
44
+ body: { type: "string" },
45
+ "source-dir": { type: "string", short: "d", multiple: true },
46
+ metadata: { type: "string", short: "m", multiple: true },
47
+ "check-only": { type: "boolean" },
48
+ "test-level": { type: "string", short: "l" },
49
+ tests: { type: "string", short: "t", multiple: true },
50
+ "class-names": { type: "string", short: "n", multiple: true },
51
+ "suite-names": { type: "string", short: "s", multiple: true },
52
+ "code-coverage": { type: "boolean", short: "c" },
53
+ wait: { type: "string", short: "w" }
54
+ };
55
+
56
+ class UsageError extends Error {}
57
+
58
+ const split = list => list?.flatMap(v => v.split(",")).map(v => v.trim()).filter(Boolean);
59
+
60
+ function readStdin() {
61
+ if (process.stdin.isTTY) return "";
62
+ try { return readFileSync(0, "utf8"); } catch { return ""; }
63
+ }
64
+
65
+ function readInputFile(file) {
66
+ const path = resolve(file);
67
+ try { return readFileSync(path, "utf8"); }
68
+ catch (e) { throw new UsageError(`Cannot read input file ${path}: ${e.message}`); }
69
+ }
70
+
71
+ function readBody(spec) {
72
+ if (spec === undefined) return undefined;
73
+ const text = spec.startsWith("@") ? readInputFile(spec.slice(1)) : spec;
74
+ try { return JSON.parse(text); } catch { return text; }
75
+ }
76
+
77
+ function wait(values) {
78
+ if (values.wait === undefined) return undefined;
79
+ const minutes = Number(values.wait);
80
+ if (!Number.isFinite(minutes) || minutes <= 0) throw new UsageError("--wait must be a positive number of minutes.");
81
+ return minutes;
82
+ }
83
+
84
+ function print(value) {
85
+ process.stdout.write((typeof value === "string" ? value : JSON.stringify(value, null, 2)) + "\n");
86
+ }
87
+
88
+ async function run(argv) {
89
+ const { values, positionals } = parseArgs({ args: argv, options, allowPositionals: true });
90
+ const [command, ...rest] = positionals;
91
+ if (values.help || !command) { print(USAGE); return 0; }
92
+
93
+ const cwd = process.cwd();
94
+ const org = () => resolveTargetOrg(values["target-org"], cwd);
95
+ const projectDir = () => {
96
+ const dir = findProjectDir(cwd);
97
+ if (!dir) throw new UsageError("No sfdx-project.json found in this directory or its parents.");
98
+ return dir;
99
+ };
100
+
101
+ // Lifecycle commands.
102
+ switch (command) {
103
+ case "start":
104
+ if (await isRunning()) { print("sfd already running"); return 0; }
105
+ startDaemon();
106
+ await call("ping");
107
+ print(`sfd started (${paths().socket})`);
108
+ return 0;
109
+ case "stop":
110
+ if (!(await isRunning())) { print("sfd not running"); return 0; }
111
+ await call("stop", {}, { autoStart: false });
112
+ print("sfd stopping");
113
+ return 0;
114
+ case "status": {
115
+ if (!(await isRunning())) { print(values.json ? { running: false } : "sfd not running"); return 0; }
116
+ const res = await call("status", {}, { autoStart: false });
117
+ const s = { running: true, version: VERSION, socket: paths().socket, log: paths().log, ...res.result };
118
+ print(values.json ? s : `sfd ${VERSION} running (pid ${s.pid}, up ${s.uptimeSeconds}s), orgs: ${s.orgs.join(", ") || "none"}\nlog: ${s.log}`);
119
+ return 0;
120
+ }
121
+ case "reload": {
122
+ if (!(await isRunning())) { print("sfd not running"); return 0; }
123
+ await call("reload", {}, { autoStart: false });
124
+ print("sfd reloaded");
125
+ return 0;
126
+ }
127
+ }
128
+
129
+ let req;
130
+ let render = result => result;
131
+ switch (command) {
132
+ case "query": {
133
+ const q = rest.join(" ") || readStdin().trim();
134
+ if (!q) throw new UsageError("query needs a SOQL string.");
135
+ req = { org: org(), q, tooling: values.tooling, all: values.all, raw: values.raw };
136
+ break;
137
+ }
138
+ case "apex": {
139
+ const file = values.file ?? rest[0];
140
+ const body = file ? readInputFile(file) : readStdin();
141
+ if (!body.trim()) throw new UsageError("apex needs code from a file, -f <file>, or stdin.");
142
+ req = { org: org(), body };
143
+ render = result => formatApex(result, { fullLog: values.log });
144
+ break;
145
+ }
146
+ case "rest": {
147
+ const url = rest[0];
148
+ if (!url) throw new UsageError("rest needs a path, e.g. /services/data/v67.0/limits.");
149
+ req = { org: org(), method: (values.method ?? (values.body ? "POST" : "GET")).toUpperCase(), url, body: readBody(values.body) };
150
+ break;
151
+ }
152
+ case "describe": {
153
+ if (!rest[0]) throw new UsageError("describe needs an sObject name.");
154
+ req = { org: org(), sobject: rest[0], tooling: values.tooling, raw: values.raw };
155
+ break;
156
+ }
157
+ case "deploy": {
158
+ const dir = projectDir();
159
+ const sourcePaths = split(values["source-dir"])?.map(d => resolve(cwd, d));
160
+ for (const p of sourcePaths ?? []) if (!existsSync(p)) throw new UsageError(`No such path: ${p}`);
161
+ const metadata = split(values.metadata);
162
+ if (!sourcePaths?.length && !metadata?.length) throw new UsageError("deploy needs -d <path> or -m <Type[:Name]>.");
163
+ req = {
164
+ org: org(), projectDir: dir, sourcePaths, metadata, checkOnly: values["check-only"],
165
+ testLevel: values["test-level"], tests: split(values.tests), waitMinutes: wait(values)
166
+ };
167
+ render = result => formatDeploy(result, req.org);
168
+ break;
169
+ }
170
+ case "test":
171
+ req = {
172
+ org: org(), classNames: split(values["class-names"]), tests: split(values.tests), suiteNames: split(values["suite-names"]),
173
+ testLevel: values["test-level"], codeCoverage: values["code-coverage"], waitMinutes: wait(values)
174
+ };
175
+ render = result => formatTests(result, req.org);
176
+ break;
177
+ case "org":
178
+ req = { org: org() };
179
+ break;
180
+ default:
181
+ throw new UsageError(`Unknown command: ${command}. Run sfd --help.`);
182
+ }
183
+
184
+ const res = await call(command, req);
185
+ if (values.json) { print(res); return res.status === 0 ? okStatus(command, res.result) : 1; }
186
+ if (res.status !== 0) {
187
+ process.stderr.write(`${res.name}: ${res.message}\n`);
188
+ return 1;
189
+ }
190
+ print(render(res.result));
191
+ return okStatus(command, res.result);
192
+ }
193
+
194
+ // A request can succeed while the Salesforce operation fails (compile error, failed deploy or test).
195
+ function okStatus(command, result) {
196
+ if (command === "apex") return result.compiled && result.success ? 0 : 1;
197
+ if (command === "deploy") return result.success ? 0 : 1;
198
+ if (command === "test") return result.failing === 0 && !result.setupFailures?.length ? 0 : 1;
199
+ return 0;
200
+ }
201
+
202
+ try {
203
+ process.exitCode = await run(process.argv.slice(2));
204
+ } catch (e) {
205
+ if (e instanceof UsageError || e.code?.startsWith?.("ERR_PARSE_ARGS")) {
206
+ process.stderr.write(`${e.message}\n`);
207
+ process.exitCode = 2;
208
+ } else if (e instanceof DaemonError || ["ENOENT", "ECONNREFUSED", "ECONNRESET", "EPIPE"].includes(e.code)) {
209
+ process.stderr.write(`sfd daemon error: ${e.message}\nlog: ${paths().log}\n`);
210
+ process.exitCode = 3;
211
+ } else {
212
+ process.stderr.write(`${e.message}\n`);
213
+ process.exitCode = 1;
214
+ }
215
+ }
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.1.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})`);
package/src/format.mjs ADDED
@@ -0,0 +1,138 @@
1
+ // Compact, token-friendly renderings of Salesforce results.
2
+
3
+ const asArray = x => (x === undefined || x === null ? [] : Array.isArray(x) ? x : [x]);
4
+
5
+ export function stripAttributes(value) {
6
+ if (Array.isArray(value)) return value.map(stripAttributes);
7
+ if (value === null || typeof value !== "object") return value;
8
+ const out = {};
9
+ for (const [key, inner] of Object.entries(value)) {
10
+ if (key !== "attributes") out[key] = stripAttributes(inner);
11
+ }
12
+ return out;
13
+ }
14
+
15
+ export function compactQuery(result) {
16
+ return { totalSize: result.totalSize, done: result.done, ...(result.nextRecordsUrl ? { nextRecordsUrl: result.nextRecordsUrl } : {}), records: stripAttributes(result.records ?? []) };
17
+ }
18
+
19
+ export function compactDescribe(d) {
20
+ return {
21
+ name: d.name, label: d.label, keyPrefix: d.keyPrefix, custom: d.custom,
22
+ createable: d.createable, updateable: d.updateable, deletable: d.deletable,
23
+ fields: d.fields.map(f => {
24
+ const field = { name: f.name, type: f.type, label: f.label };
25
+ if (f.length) field.length = f.length;
26
+ if (f.referenceTo?.length) field.referenceTo = f.referenceTo;
27
+ if (f.picklistValues?.length) field.picklistValues = f.picklistValues.filter(p => p.active).map(p => p.value);
28
+ if (!f.nillable && f.createable && !f.defaultedOnCreate) field.required = true;
29
+ if (!f.updateable) field.readOnly = true;
30
+ if (f.calculated) field.formula = true;
31
+ return field;
32
+ }),
33
+ childRelationships: d.childRelationships.filter(c => c.relationshipName).map(c => `${c.relationshipName} (${c.childSObject}.${c.field})`)
34
+ };
35
+ }
36
+
37
+ const loc = d => (d.lineNumber ? ` [${d.lineNumber}:${d.columnNumber ?? ""}]` : "");
38
+
39
+ export function formatApex(result, { fullLog = false } = {}) {
40
+ const lines = [];
41
+ if (!result.compiled) {
42
+ lines.push("COMPILE ERROR");
43
+ for (const d of asArray(result.diagnostic)) lines.push(` ✖ ${d.compileProblem ?? d.exceptionMessage}${loc(d)}`);
44
+ return lines.join("\n");
45
+ }
46
+ lines.push(result.success ? "OK" : "EXCEPTION");
47
+ if (!result.success) {
48
+ for (const d of asArray(result.diagnostic)) {
49
+ lines.push(` ✖ ${d.exceptionMessage}`);
50
+ if (d.exceptionStackTrace) lines.push(` ${d.exceptionStackTrace}`);
51
+ }
52
+ }
53
+ const log = result.logs ?? "";
54
+ if (fullLog) {
55
+ lines.push(log);
56
+ } else {
57
+ // Keep only USER_DEBUG and fatal lines; the rest of a debug log is noise for agents.
58
+ for (const line of log.split("\n")) {
59
+ const m = line.match(/\|USER_DEBUG\|\[(\d+)\]\|\w+\|(.*)$/);
60
+ if (m) lines.push(`[${m[1]}] ${m[2]}`);
61
+ else if (/\|FATAL_ERROR\|/.test(line)) lines.push(line.replace(/^.*\|FATAL_ERROR\|/, "FATAL: "));
62
+ }
63
+ }
64
+ return lines.join("\n");
65
+ }
66
+
67
+ export function formatDeploy(summary, org) {
68
+ const action = summary.checkOnly ? "Validated" : "Deployed";
69
+ const tests = summary.numberTestsTotal > 0
70
+ ? `, tests ${summary.numberTestsCompleted}/${summary.numberTestsTotal}${summary.numberTestErrors ? ` (${summary.numberTestErrors} failed)` : ""}`
71
+ : "";
72
+ const head = summary.success
73
+ ? `${action} ${summary.numberComponentsDeployed}/${summary.numberComponentsTotal} components${tests} to ${org} (${summary.id})`
74
+ : `FAILED (${summary.status}) ${org} (${summary.id})`;
75
+ const lines = [head];
76
+ for (const c of summary.componentFailures) lines.push(` ✖ ${c.type} ${c.fullName}: ${c.problem}${loc(c)}`);
77
+ for (const t of summary.testFailures) lines.push(` ✖ test ${t.name}.${t.methodName}: ${t.message}`);
78
+ for (const w of summary.coverageWarnings ?? []) lines.push(` ✖ coverage${w.name ? ` ${w.name}` : ""}: ${w.message}`);
79
+ if (summary.errorMessage && !summary.componentFailures.length) lines.push(` ${summary.errorMessage}`);
80
+ return lines.join("\n");
81
+ }
82
+
83
+ // Reduce an SDR DeployResult response to the fields formatDeploy and --json callers need.
84
+ export function summarizeDeploy(response) {
85
+ const details = response.details ?? {};
86
+ return {
87
+ id: response.id,
88
+ status: response.status,
89
+ success: response.success,
90
+ checkOnly: response.checkOnly,
91
+ numberComponentsDeployed: response.numberComponentsDeployed,
92
+ numberComponentsTotal: response.numberComponentsTotal,
93
+ numberComponentErrors: response.numberComponentErrors,
94
+ numberTestsCompleted: response.numberTestsCompleted,
95
+ numberTestsTotal: response.numberTestsTotal,
96
+ numberTestErrors: response.numberTestErrors,
97
+ errorMessage: response.errorMessage,
98
+ componentFailures: asArray(details.componentFailures).map(c => ({
99
+ type: c.componentType, fullName: c.fullName, problem: c.problem, lineNumber: c.lineNumber, columnNumber: c.columnNumber
100
+ })),
101
+ testFailures: asArray(details.runTestResult?.failures).map(t => ({
102
+ name: t.name, methodName: t.methodName, message: t.message, stackTrace: t.stackTrace
103
+ })),
104
+ coverageWarnings: asArray(details.runTestResult?.codeCoverageWarnings).map(w => ({ name: w.name, message: w.message }))
105
+ };
106
+ }
107
+
108
+ export function summarizeTests(result) {
109
+ const s = result.summary;
110
+ return {
111
+ outcome: s.outcome, testRunId: s.testRunId, testsRan: s.testsRan, passing: s.passing, failing: s.failing,
112
+ skipped: s.skipped, testExecutionTimeInMs: s.testExecutionTimeInMs,
113
+ testRunCoverage: s.testRunCoverage, orgWideCoverage: s.orgWideCoverage,
114
+ failures: asArray(result.tests).filter(t => t.outcome !== "Pass" && t.outcome !== "Skip").map(t => ({
115
+ name: t.fullName ?? `${t.apexClass?.name}.${t.methodName}`, outcome: t.outcome, message: t.message, stackTrace: t.stackTrace
116
+ })),
117
+ setupFailures: asArray(result.setup).filter(s => s.message).map(s => ({ name: s.fullName, message: s.message, stackTrace: s.stackTrace })),
118
+ coverage: result.codecoverage
119
+ ? result.codecoverage.map(c => ({ name: c.name, percentage: c.percentage, uncoveredLines: c.uncoveredLines }))
120
+ : undefined
121
+ };
122
+ }
123
+
124
+ export function formatTests(summary, org) {
125
+ const parts = [`${summary.testsRan} ran`, `${summary.passing} passed`, `${summary.failing} failed`];
126
+ if (summary.skipped) parts.push(`${summary.skipped} skipped`);
127
+ const lines = [`Tests ${summary.outcome} on ${org}: ${parts.join(", ")} in ${summary.testExecutionTimeInMs} ms (${summary.testRunId})`];
128
+ if (summary.testRunCoverage) lines.push(`Coverage: run ${summary.testRunCoverage}, org-wide ${summary.orgWideCoverage}`);
129
+ for (const s of summary.setupFailures) lines.push(` ✖ setup ${s.name}: ${s.message}`);
130
+ for (const f of summary.failures) {
131
+ lines.push(` ✖ ${f.name}: ${f.message}`);
132
+ if (f.stackTrace) lines.push(` ${f.stackTrace.replace(/\n/g, "\n ")}`);
133
+ }
134
+ for (const c of summary.coverage ?? []) {
135
+ lines.push(` ${c.percentage.padStart(4)} ${c.name}${c.uncoveredLines.length ? ` uncovered: ${c.uncoveredLines.join(",")}` : ""}`);
136
+ }
137
+ return lines.join("\n");
138
+ }
@@ -0,0 +1,149 @@
1
+ import { statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { sfRequire } from "./sf-root.mjs";
5
+ import { compactDescribe, compactQuery, summarizeDeploy, summarizeTests } from "./format.mjs";
6
+
7
+ const SESSION_ERRORS = new Set(["INVALID_SESSION_ID", "INVALID_AUTH_HEADER"]);
8
+
9
+ function authStamp(home) {
10
+ // A login, logout or alias change touches ~/.sfdx or alias.json; reload org state when it does.
11
+ const stamp = path => { try { return statSync(path).mtimeMs; } catch { return 0; } };
12
+ return `${stamp(join(home, ".sfdx"))}:${stamp(join(home, ".sfdx", "alias.json"))}`;
13
+ }
14
+
15
+ // One queue for operations that touch the local project (deploy and source tracking):
16
+ // SDR and source-tracking keep process-wide state, so they must not interleave.
17
+ function serial() {
18
+ let tail = Promise.resolve();
19
+ return fn => {
20
+ const run = tail.then(fn, fn);
21
+ tail = run.catch(() => {});
22
+ return run;
23
+ };
24
+ }
25
+
26
+ export function createHandlers(sfRoot, { home = homedir(), load = sfRequire(sfRoot) } = {}) {
27
+ const req = load;
28
+ const core = req("@salesforce/core");
29
+ const { Duration } = req("@salesforce/kit");
30
+ const lazy = name => { let mod; return () => (mod ??= req(name)); };
31
+ const sdr = lazy("@salesforce/source-deploy-retrieve");
32
+ const apex = lazy("@salesforce/apex-node");
33
+ const tracking = lazy("@salesforce/source-tracking");
34
+ const projectQueue = serial();
35
+
36
+ const orgs = new Map();
37
+ let stamp = authStamp(home);
38
+
39
+ async function getOrg(alias) {
40
+ const now = authStamp(home);
41
+ if (now !== stamp) {
42
+ stamp = now;
43
+ orgs.clear();
44
+ await core.StateAggregator.clearInstanceAsync();
45
+ }
46
+ if (!orgs.has(alias)) {
47
+ const pending = core.Org.create({ aliasOrUsername: alias });
48
+ orgs.set(alias, pending);
49
+ pending.catch(() => orgs.delete(alias));
50
+ }
51
+ return orgs.get(alias);
52
+ }
53
+
54
+ // Run fn with a cached connection; on an expired or revoked session, rebuild it once.
55
+ async function withConn(alias, fn) {
56
+ try {
57
+ const org = await getOrg(alias);
58
+ return await fn(org.getConnection(), org);
59
+ } catch (e) {
60
+ if (!SESSION_ERRORS.has(e.errorCode ?? e.name)) throw e;
61
+ orgs.delete(alias);
62
+ const org = await getOrg(alias);
63
+ return fn(org.getConnection(), org);
64
+ }
65
+ }
66
+
67
+ return {
68
+ ping: async () => ({ pong: true }),
69
+
70
+ status: async () => ({
71
+ pid: process.pid,
72
+ uptimeSeconds: Math.round(process.uptime()),
73
+ orgs: [...orgs.keys()]
74
+ }),
75
+
76
+ reload: async () => {
77
+ orgs.clear();
78
+ await core.StateAggregator.clearInstanceAsync();
79
+ return { reloaded: true };
80
+ },
81
+
82
+ org: ({ org }) => withConn(org, (conn, o) => ({
83
+ alias: org, username: o.getUsername(), orgId: o.getOrgId(), instanceUrl: conn.instanceUrl, apiVersion: conn.getApiVersion()
84
+ })),
85
+
86
+ query: ({ org, q, tooling, all, raw }) => withConn(org, async conn => {
87
+ const api = tooling ? conn.tooling : conn;
88
+ const result = await api.query(q, { scanAll: Boolean(all) });
89
+ while (!result.done) {
90
+ if (!result.nextRecordsUrl) throw new Error("Incomplete query result: missing nextRecordsUrl.");
91
+ const page = await api.queryMore(result.nextRecordsUrl);
92
+ result.records.push(...page.records);
93
+ result.done = page.done;
94
+ result.nextRecordsUrl = page.nextRecordsUrl;
95
+ }
96
+ return raw ? result : compactQuery(result);
97
+ }),
98
+
99
+ apex: ({ org, body }) => withConn(org, conn => new (apex().ExecuteService)(conn).executeAnonymous({ apexCode: body })),
100
+
101
+ rest: ({ org, method, url, body, headers }) => withConn(org, conn => conn.request({
102
+ method, url, body: body === undefined || typeof body === "string" ? body : JSON.stringify(body),
103
+ headers: { "content-type": "application/json", ...headers }
104
+ })),
105
+
106
+ describe: ({ org, sobject, tooling, raw }) => withConn(org, async conn => {
107
+ const d = await (tooling ? conn.tooling : conn).describe(sobject);
108
+ return raw ? d : compactDescribe(d);
109
+ }),
110
+
111
+ deploy: ({ org, projectDir, sourcePaths, metadata, checkOnly, testLevel, tests, waitMinutes = 33 }) =>
112
+ projectQueue(() => withConn(org, async (conn, o) => {
113
+ const { ComponentSetBuilder } = sdr();
114
+ core.SfProject.clearInstances();
115
+ const project = await core.SfProject.resolve(projectDir);
116
+ const packageDirs = project.getUniquePackageDirectories().map(d => d.fullPath);
117
+ const cs = await ComponentSetBuilder.build({
118
+ projectDir,
119
+ sourceapiversion: project.getSfProjectJson().get("sourceApiVersion"),
120
+ sourcepath: sourcePaths?.length ? sourcePaths : undefined,
121
+ metadata: metadata?.length ? { metadataEntries: metadata, directoryPaths: packageDirs } : undefined
122
+ });
123
+ if (cs.size === 0) throw new Error("Nothing to deploy: no source components matched.");
124
+ // The Metadata API serializes undefined as the string "undefined", so only set what was asked.
125
+ const apiOptions = { checkOnly: Boolean(checkOnly), rollbackOnError: true };
126
+ if (tests?.length) Object.assign(apiOptions, { testLevel: "RunSpecifiedTests", runTests: tests });
127
+ if (testLevel) apiOptions.testLevel = testLevel;
128
+ const deploy = await cs.deploy({ usernameOrConnection: conn, apiOptions });
129
+ const result = await deploy.pollStatus({ frequency: Duration.seconds(2), timeout: Duration.minutes(waitMinutes) });
130
+ // Keep scratch-org source tracking in step, as `sf project deploy start` does.
131
+ if (result.response.success && !checkOnly && (await o.tracksSource())) {
132
+ const st = await tracking().SourceTracking.create({ org: o, project });
133
+ await st.updateTrackingFromDeploy(result);
134
+ }
135
+ return summarizeDeploy(result.response);
136
+ })),
137
+
138
+ test: ({ org, classNames, tests, suiteNames, testLevel, codeCoverage, waitMinutes = 60 }) => withConn(org, async conn => {
139
+ const { TestService } = apex();
140
+ const service = new TestService(conn);
141
+ const level = testLevel ?? (classNames?.length || tests?.length || suiteNames?.length ? "RunSpecifiedTests" : "RunLocalTests");
142
+ // apex-node takes comma-separated strings, as the sf flags do.
143
+ const csv = list => (list?.length ? list.join(",") : undefined);
144
+ const payload = await service.buildAsyncPayload(level, csv(tests), csv(classNames), csv(suiteNames));
145
+ const result = await service.runTestAsynchronous(payload, Boolean(codeCoverage), false, undefined, undefined, Duration.minutes(waitMinutes));
146
+ return summarizeTests(result);
147
+ })
148
+ };
149
+ }
package/src/paths.mjs ADDED
@@ -0,0 +1,16 @@
1
+ import { mkdirSync, chmodSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ // Unix socket paths are capped near 108 bytes, so keep the runtime dir short.
6
+ export function runtimeDir(env = process.env) {
7
+ const base = env.SFD_RUNTIME_DIR ?? join(env.XDG_RUNTIME_DIR ?? tmpdir(), `sfd-${process.getuid()}`);
8
+ mkdirSync(base, { recursive: true, mode: 0o700 });
9
+ chmodSync(base, 0o700);
10
+ return base;
11
+ }
12
+
13
+ export function paths(env = process.env) {
14
+ const dir = runtimeDir(env);
15
+ return { dir, socket: join(dir, "sfd.sock"), log: join(dir, "sfd.log"), pid: join(dir, "sfd.pid") };
16
+ }
@@ -0,0 +1,33 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+
5
+ function walkUp(start, test) {
6
+ for (let dir = resolve(start); ; dir = dirname(dir)) {
7
+ if (test(dir)) return dir;
8
+ if (dirname(dir) === dir) return undefined;
9
+ }
10
+ }
11
+
12
+ export function findProjectDir(cwd) {
13
+ return walkUp(cwd, dir => existsSync(join(dir, "sfdx-project.json")));
14
+ }
15
+
16
+ function readTargetOrg(file) {
17
+ try {
18
+ return JSON.parse(readFileSync(file, "utf8"))["target-org"];
19
+ } catch {
20
+ return undefined;
21
+ }
22
+ }
23
+
24
+ // Same precedence as sf: flag, SF_TARGET_ORG, nearest local .sf/config.json, then global config.
25
+ export function resolveTargetOrg(flag, cwd, env = process.env, home = homedir()) {
26
+ if (flag) return flag;
27
+ if (env.SF_TARGET_ORG) return env.SF_TARGET_ORG;
28
+ const local = walkUp(cwd, dir => dir !== home && readTargetOrg(join(dir, ".sf", "config.json")) !== undefined);
29
+ if (local) return readTargetOrg(join(local, ".sf", "config.json"));
30
+ const global = readTargetOrg(join(home, ".sf", "config.json"));
31
+ if (global) return global;
32
+ throw new Error("No target org. Pass -o <alias> or set target-org with `sf config set target-org`.");
33
+ }
package/src/server.mjs ADDED
@@ -0,0 +1,99 @@
1
+ import net from "node:net";
2
+ import { chmodSync, unlinkSync } from "node:fs";
3
+
4
+ // Newline-delimited JSON over a Unix socket: one request and one response per connection.
5
+ // Responses are {status: 0, result} | {status: 1, name, message} | {restart: true}.
6
+
7
+ export class AlreadyRunning extends Error {}
8
+
9
+ function probe(socket) {
10
+ return new Promise(resolve => {
11
+ const s = net.connect(socket, () => { s.destroy(); resolve(true); });
12
+ s.on("error", () => resolve(false));
13
+ });
14
+ }
15
+
16
+ function listenOnce(server, socket) {
17
+ return new Promise((resolve, reject) => {
18
+ server.once("error", reject);
19
+ server.listen(socket, () => { server.off("error", reject); resolve(); });
20
+ });
21
+ }
22
+
23
+ export async function startServer({ socket, version, handlers, idleMs = 60 * 60_000, isStale = () => false, onExit = () => {} }) {
24
+ let active = 0;
25
+ let idleTimer;
26
+ let draining;
27
+ let exited = false;
28
+
29
+ const maybeExit = () => {
30
+ if (draining && active === 0 && !exited) {
31
+ exited = true;
32
+ onExit(draining);
33
+ }
34
+ };
35
+
36
+ // Stop accepting connections and free the socket path right away, so a replacement
37
+ // daemon can bind it, but let in-flight requests (deploys, test runs) finish.
38
+ const drain = reason => {
39
+ if (draining) return;
40
+ draining = reason;
41
+ clearTimeout(idleTimer);
42
+ server.close();
43
+ try { unlinkSync(socket); } catch {}
44
+ maybeExit();
45
+ };
46
+ const armIdle = () => {
47
+ clearTimeout(idleTimer);
48
+ idleTimer = setTimeout(() => active === 0 && drain("idle"), idleMs);
49
+ idleTimer.unref?.();
50
+ };
51
+
52
+ async function respond(req) {
53
+ if (req.op === "stop") return { status: 0, result: { stopping: true } };
54
+ if (req.v !== version) return { restart: true };
55
+ const handler = handlers[req.op];
56
+ if (!handler) return { status: 1, name: "UnknownOperation", message: `Unknown operation: ${req.op}` };
57
+ try {
58
+ return { status: 0, result: await handler(req) };
59
+ } catch (e) {
60
+ return { status: 1, name: e.name ?? "Error", message: e.message ?? String(e), errorCode: e.errorCode };
61
+ }
62
+ }
63
+
64
+ const server = net.createServer(conn => {
65
+ conn.setEncoding("utf8");
66
+ let buf = "";
67
+ conn.on("error", () => {});
68
+ conn.on("data", async chunk => {
69
+ buf += chunk;
70
+ const nl = buf.indexOf("\n");
71
+ if (nl < 0) return;
72
+ conn.removeAllListeners("data");
73
+ active++;
74
+ clearTimeout(idleTimer);
75
+ let req;
76
+ try { req = JSON.parse(buf.slice(0, nl)); } catch { req = {}; }
77
+ const res = await respond(req);
78
+ conn.end(JSON.stringify(res) + "\n");
79
+ active--;
80
+ if (req.op === "stop") drain("stop");
81
+ else if (res.restart) drain("version");
82
+ else if (isStale()) drain("stale");
83
+ if (draining) maybeExit();
84
+ else if (active === 0) armIdle();
85
+ });
86
+ });
87
+
88
+ try {
89
+ await listenOnce(server, socket);
90
+ } catch (e) {
91
+ if (e.code !== "EADDRINUSE") throw e;
92
+ if (await probe(socket)) throw new AlreadyRunning("sfd is already running");
93
+ unlinkSync(socket);
94
+ await listenOnce(server, socket);
95
+ }
96
+ chmodSync(socket, 0o600);
97
+ armIdle();
98
+ return { server, drain };
99
+ }
@@ -0,0 +1,26 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { readFileSync, realpathSync, statSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { createRequire } from "node:module";
5
+
6
+ // sfd loads @salesforce/core, SDR, apex-node and source-tracking from the installed sf CLI,
7
+ // so auth files, API versions and metadata registries always match what `sf` itself uses.
8
+ export function findSfRoot(env = process.env) {
9
+ if (env.SFD_SF_ROOT) return env.SFD_SF_ROOT;
10
+ const bin = execFileSync("which", ["sf"], { encoding: "utf8" }).trim();
11
+ for (let dir = dirname(realpathSync(bin)); dirname(dir) !== dir; dir = dirname(dir)) {
12
+ try {
13
+ if (JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).name === "@salesforce/cli") return dir;
14
+ } catch {}
15
+ }
16
+ throw new Error(`Could not locate the @salesforce/cli install behind ${bin}; set SFD_SF_ROOT.`);
17
+ }
18
+
19
+ export function sfInfo(root) {
20
+ const file = join(root, "package.json");
21
+ return { root, version: JSON.parse(readFileSync(file, "utf8")).version, mtimeMs: statSync(file).mtimeMs };
22
+ }
23
+
24
+ export function sfRequire(root) {
25
+ return createRequire(join(root, "package.json"));
26
+ }
@@ -0,0 +1,10 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+
3
+ const pkg = new URL("../package.json", import.meta.url);
4
+ export const VERSION = JSON.parse(readFileSync(pkg, "utf8")).version;
5
+
6
+ // The client and daemon must run the same code, not just the same package version:
7
+ // fold the newest source mtime in, so an edited or reinstalled checkout replaces a running daemon.
8
+ const src = new URL("./", import.meta.url);
9
+ const newest = Math.max(...readdirSync(src).map(f => statSync(new URL(f, src)).mtimeMs));
10
+ export const PROTOCOL = `${VERSION}+${Math.floor(newest)}`;