@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 +23 -0
- package/LICENSE +21 -0
- package/README.md +125 -2
- package/bin/sfd.mjs +215 -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/paths.mjs +16 -0
- package/src/project.mjs +33 -0
- package/src/server.mjs +99 -0
- package/src/sf-root.mjs +26 -0
- package/src/version.mjs +10 -0
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
|
-
#
|
|
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 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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|
package/src/handlers.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/project.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/sf-root.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/version.mjs
ADDED
|
@@ -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)}`;
|