@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 +29 -0
- package/LICENSE +21 -0
- package/README.md +144 -2
- package/bin/sfd.mjs +220 -0
- package/package.json +43 -4
- package/src/client.mjs +85 -0
- package/src/daemon.mjs +43 -0
- package/src/format.mjs +138 -0
- package/src/handlers.mjs +149 -0
- package/src/hooks.mjs +102 -0
- package/src/paths.mjs +16 -0
- package/src/project.mjs +33 -0
- package/src/rewrite.mjs +173 -0
- package/src/server.mjs +99 -0
- package/src/sf-root.mjs +26 -0
- package/src/usage-error.mjs +1 -0
- package/src/version.mjs +10 -0
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
|
-
#
|
|
1
|
+
# sfd
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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})`);
|