@syntax-syllogism/sfd 0.1.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +48 -11
- package/bin/sfd.mjs +70 -9
- package/package.json +2 -2
- package/src/client.mjs +2 -2
- package/src/daemon.mjs +4 -1
- package/src/format.mjs +109 -6
- package/src/handlers.mjs +175 -10
- package/src/hooks.mjs +102 -0
- package/src/paths.mjs +23 -5
- package/src/rewrite.mjs +184 -0
- package/src/server.mjs +6 -3
- package/src/sf-root.mjs +15 -5
- package/src/usage-error.mjs +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- Windows: `sfd start` and every other command failed with
|
|
13
|
+
"process.getuid is not a function". The daemon now listens on a per-user
|
|
14
|
+
named pipe, finds `sf` with `where` (including npm `sf.cmd` shims), and
|
|
15
|
+
starts without a console window. CI runs on Windows as well as Linux.
|
|
16
|
+
|
|
17
|
+
## [0.3.1] - 2026-10-22
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- Internal maintenance and tooling updates
|
|
22
|
+
|
|
23
|
+
## [0.3.0] - 2026-10-22
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- Added `sfd retrieve` for pulling metadata from an org into the local project
|
|
28
|
+
- Added tracking commands for inspecting changes between local source and the org
|
|
29
|
+
- Added filtered debug log output
|
|
30
|
+
- Added automatic TraceFlag creation when debug logs are requested
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- Fixed a startup failure on Windows (`process.getuid is not a function`)
|
|
35
|
+
|
|
36
|
+
## [0.2.0] - 2026-10-22
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- Agent hook that rewrites supported `sf` commands to their faster `sfd` equivalents
|
|
41
|
+
|
|
10
42
|
## [0.1.0] - 2026-10-22
|
|
11
43
|
|
|
12
44
|
### Added
|
package/README.md
CHANGED
|
@@ -29,12 +29,17 @@ sfd query "SELECT Id, Name FROM Account LIMIT 5" -o my-org
|
|
|
29
29
|
| `sfd rest <path>` | REST call. `-X <method>`, `--body <json\|@file>` | `sf api request rest` |
|
|
30
30
|
| `sfd describe <sobject>` | Compact describe; `--raw` for the full one. `--tooling` | `sf sobject describe` |
|
|
31
31
|
| `sfd deploy -d <path> \| -m <Type[:Name]>` | Deploy source. `--check-only`, `-l <level>`, `-t <test>`, `-w <minutes>` | `sf project deploy start` |
|
|
32
|
-
| `sfd
|
|
32
|
+
| `sfd retrieve [-d <path> \| -m <Type[:Name]> \| -x <package.xml>]` | Retrieve source; no selector pulls tracked remote changes. `--ignore-conflicts`, `-w <minutes>` | `sf project retrieve start` |
|
|
33
|
+
| `sfd tracking [--local \| --remote] [--conflicts]` | List local, remote and conflicting source-tracked changes; `--conflicts` shows only conflicts | |
|
|
34
|
+
| `sfd test` | Run Apex tests. `-n <class>`, `-t <Class.method>`, `-s <suite>`, `-l <level>`, `-c` (coverage), `-w <minutes>`, `--logs` | `sf apex run test --wait` |
|
|
35
|
+
| `sfd logs [--count 10] [--user <username>]` | Recent debug logs, one line each; `--limit` aliases `--count` | `sf apex list log` |
|
|
36
|
+
| `sfd logs get <id\|latest>` | Debug, exception, validation, Flow error and high limit usage lines. `--full` prints raw text | `sf apex get log` |
|
|
33
37
|
| `sfd org` | Resolved username, org id, instance URL and API version | `sf org display` |
|
|
38
|
+
| `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) | |
|
|
34
39
|
| `sfd start \| stop \| status \| reload` | Daemon lifecycle; `reload` drops cached orgs and auth state | |
|
|
35
40
|
|
|
36
|
-
`-d`, `-m`, `-n`, `-t` and `-s` repeat or take comma-separated lists.
|
|
37
|
-
|
|
41
|
+
`-d`, `-m`, `-n`, `-t` and `-s` repeat or take comma-separated lists. The org
|
|
42
|
+
commands take `-o <alias>`, and `--json` prints the raw daemon response. Without `-o`
|
|
38
43
|
the org resolves like `sf`: `SF_TARGET_ORG`, then the nearest
|
|
39
44
|
`.sf/config.json`, then the global config.
|
|
40
45
|
|
|
@@ -44,13 +49,15 @@ not be reached or started, or a request timed out. Unreadable input files are
|
|
|
44
49
|
usage errors and include the file path.
|
|
45
50
|
|
|
46
51
|
Use `sf` (with `--json` when scripting) for anything sfd does not cover (org creation, package
|
|
47
|
-
commands,
|
|
52
|
+
commands, data import).
|
|
48
53
|
|
|
49
54
|
## Daemon lifecycle
|
|
50
55
|
|
|
51
56
|
The first command starts the daemon detached; there is nothing to set up. It
|
|
52
57
|
listens on a Unix socket in `$XDG_RUNTIME_DIR/sfd-<uid>/` (or the system temp
|
|
53
58
|
directory), in a directory with mode `0700` and a socket with mode `0600`.
|
|
59
|
+
On Windows it listens on a per-user named pipe (`\\.\pipe\sfd-<hash>`), and
|
|
60
|
+
the log and pid files go in `%LOCALAPPDATA%\sfd\`.
|
|
54
61
|
Access tokens stay in daemon memory and are never written to disk. The daemon
|
|
55
62
|
log is `sfd.log` in the same directory; `sfd status` prints its path.
|
|
56
63
|
|
|
@@ -76,9 +83,11 @@ it down even if its protocol differs.
|
|
|
76
83
|
|
|
77
84
|
- Queries fetch every page without a record cap. Compact results preserve
|
|
78
85
|
`done` and any continuation URL supplied by Salesforce.
|
|
79
|
-
- Deploys
|
|
80
|
-
|
|
81
|
-
|
|
86
|
+
- Deploys, retrieves and `tracking` share one queue per daemon, so they run
|
|
87
|
+
one at a time. Deploys reload `sfdx-project.json` on every call.
|
|
88
|
+
- Retrieve conflicts, changes-only retrieves, and how `tracking` and deploys
|
|
89
|
+
update source tracking are described in
|
|
90
|
+
[docs/source-tracking.md](docs/source-tracking.md).
|
|
82
91
|
- `deploy` without `-l` uses the Metadata API default. `-t` alone implies
|
|
83
92
|
`RunSpecifiedTests`.
|
|
84
93
|
- `test` without selectors runs `RunLocalTests`. A non-matching method is
|
|
@@ -86,13 +95,23 @@ it down even if its protocol differs.
|
|
|
86
95
|
- Paths in `-d` and the project directory resolve from the client's working
|
|
87
96
|
directory, not the daemon's.
|
|
88
97
|
|
|
98
|
+
### Debug logs
|
|
99
|
+
|
|
100
|
+
`logs`, `logs get` and `test --logs` create or extend the current user's
|
|
101
|
+
developer TraceFlag for 30 minutes when it is missing, expired or has less than
|
|
102
|
+
five minutes left, and print one notice on stderr when they do. `--no-trace`
|
|
103
|
+
skips that setup. `logs --user` only filters the list. Setup needs Tooling API
|
|
104
|
+
permission to manage TraceFlag and DebugLevel records. The TraceFlag rules,
|
|
105
|
+
filtered output format and `test --logs` behavior are in
|
|
106
|
+
[docs/debug-logs.md](docs/debug-logs.md).
|
|
107
|
+
|
|
89
108
|
## Environment
|
|
90
109
|
|
|
91
110
|
| Variable | Purpose |
|
|
92
111
|
| --- | --- |
|
|
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. |
|
|
112
|
+
| `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/retrieve/test `-w` wait plus 60 seconds (default waits: 33/33/60 minutes). Increase this for long queries or queued deploys. Timeout exits 3. |
|
|
94
113
|
| `SFD_IDLE_MINUTES` | Idle timeout, default 60. |
|
|
95
|
-
| `SFD_RUNTIME_DIR` | Socket, log and pid directory. |
|
|
114
|
+
| `SFD_RUNTIME_DIR` | Socket, log and pid directory (on Windows, log and pid only; the pipe name is derived from it). |
|
|
96
115
|
| `SFD_SF_ROOT` | `@salesforce/cli` install to load libraries from, instead of the `sf` on `PATH`. |
|
|
97
116
|
| `SFD_HANDLERS_MODULE` | Test seam: stub handlers module used by the CLI tests. |
|
|
98
117
|
|
|
@@ -100,8 +119,9 @@ it down even if its protocol differs.
|
|
|
100
119
|
|
|
101
120
|
`npm test` covers project and org resolution, output formatting, the socket
|
|
102
121
|
protocol (dispatch, version restart, drain, idle, stale socket), and the CLI
|
|
103
|
-
end to end against stub handlers
|
|
104
|
-
|
|
122
|
+
end to end against stub handlers, and the agent hook adapters and `sf` rewrite
|
|
123
|
+
rules. Injected Salesforce modules cover handler
|
|
124
|
+
paging, auth refresh, deployment, retrieve, source tracking and test orchestration without an org.
|
|
105
125
|
A real-org check is opt-in:
|
|
106
126
|
|
|
107
127
|
```bash
|
|
@@ -124,3 +144,20 @@ See [SECURITY.md](SECURITY.md) for how to report vulnerabilities.
|
|
|
124
144
|
## License
|
|
125
145
|
|
|
126
146
|
[MIT](LICENSE) © Jacob Richter
|
|
147
|
+
|
|
148
|
+
## Agent integration
|
|
149
|
+
|
|
150
|
+
`sfd hook` rewrites supported `sf` shell commands before a coding agent runs
|
|
151
|
+
them, so they reach the warm daemon. Install an adapter for Claude Code, Codex
|
|
152
|
+
CLI or Gemini CLI (user scope by default):
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
sfd hook install --agent claude
|
|
156
|
+
sfd hook install --agent codex
|
|
157
|
+
sfd hook install --agent gemini
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Add `--project` to install into the current project instead. Preview a rewrite
|
|
161
|
+
with `sfd hook check '<sf command>'`. The supported commands, adapter details,
|
|
162
|
+
skip rules and the `SFD_HOOK_DISABLE=1` switch are in
|
|
163
|
+
[docs/agent-hooks.md](docs/agent-hooks.md).
|
package/bin/sfd.mjs
CHANGED
|
@@ -4,8 +4,9 @@ import { resolve } from "node:path";
|
|
|
4
4
|
import { parseArgs } from "node:util";
|
|
5
5
|
import { call, isRunning, startDaemon, DaemonError, VERSION } from "../src/client.mjs";
|
|
6
6
|
import { findProjectDir, resolveTargetOrg } from "../src/project.mjs";
|
|
7
|
-
import { formatApex, formatDeploy, formatTests } from "../src/format.mjs";
|
|
7
|
+
import { formatApex, formatLog, formatLogList, formatDeploy, formatTests, formatRetrieve, formatTracking } from "../src/format.mjs";
|
|
8
8
|
import { paths } from "../src/paths.mjs";
|
|
9
|
+
import { UsageError } from "../src/usage-error.mjs";
|
|
9
10
|
|
|
10
11
|
const USAGE = `sfd ${VERSION}: fast Salesforce CLI operations through a warm local daemon
|
|
11
12
|
|
|
@@ -18,9 +19,16 @@ Commands:
|
|
|
18
19
|
describe <sobject> Compact describe. --tooling, --raw for the full describe
|
|
19
20
|
deploy Deploy source. -d <path> (repeatable), -m <Type[:Name]> (repeatable),
|
|
20
21
|
--check-only, -l <test level>, -t <test> (repeatable), -w <minutes>
|
|
22
|
+
retrieve Retrieve source. -d <path>, -m <Type[:Name]> or -x <package.xml>,
|
|
23
|
+
-w <minutes>, --ignore-conflicts. No selector pulls tracked remote changes
|
|
24
|
+
tracking Show source-tracked changes. --local | --remote, --conflicts (conflicts only)
|
|
21
25
|
test Run Apex tests. -n <class> (repeatable), -t <Class.method> (repeatable),
|
|
22
|
-
-s <suite> (repeatable), -l <test level>, -c (coverage), -w <minutes
|
|
26
|
+
-s <suite> (repeatable), -l <test level>, -c (coverage), -w <minutes>, --logs, --no-trace
|
|
27
|
+
logs List recent debug logs. --count/--limit <10>, --user <username>, --no-trace
|
|
28
|
+
logs get <id|latest> Print a filtered log. --full for raw text, --no-trace
|
|
23
29
|
org Show the resolved org (username, id, instance, API version)
|
|
30
|
+
hook <agent|check|install|uninstall>
|
|
31
|
+
Integrate with Claude Code, Codex CLI or Gemini CLI
|
|
24
32
|
start | stop | status | reload
|
|
25
33
|
Manage the daemon. reload drops cached orgs and auth state
|
|
26
34
|
|
|
@@ -39,11 +47,22 @@ const options = {
|
|
|
39
47
|
all: { type: "boolean" },
|
|
40
48
|
raw: { type: "boolean" },
|
|
41
49
|
log: { type: "boolean" },
|
|
50
|
+
logs: { type: "boolean" },
|
|
51
|
+
full: { type: "boolean" },
|
|
52
|
+
"no-trace": { type: "boolean" },
|
|
53
|
+
count: { type: "string" },
|
|
54
|
+
limit: { type: "string" },
|
|
55
|
+
user: { type: "string" },
|
|
42
56
|
file: { type: "string", short: "f" },
|
|
43
57
|
method: { type: "string", short: "X" },
|
|
44
58
|
body: { type: "string" },
|
|
45
59
|
"source-dir": { type: "string", short: "d", multiple: true },
|
|
46
60
|
metadata: { type: "string", short: "m", multiple: true },
|
|
61
|
+
manifest: { type: "string", short: "x" },
|
|
62
|
+
"ignore-conflicts": { type: "boolean" },
|
|
63
|
+
local: { type: "boolean" },
|
|
64
|
+
remote: { type: "boolean" },
|
|
65
|
+
conflicts: { type: "boolean" },
|
|
47
66
|
"check-only": { type: "boolean" },
|
|
48
67
|
"test-level": { type: "string", short: "l" },
|
|
49
68
|
tests: { type: "string", short: "t", multiple: true },
|
|
@@ -53,8 +72,6 @@ const options = {
|
|
|
53
72
|
wait: { type: "string", short: "w" }
|
|
54
73
|
};
|
|
55
74
|
|
|
56
|
-
class UsageError extends Error {}
|
|
57
|
-
|
|
58
75
|
const split = list => list?.flatMap(v => v.split(",")).map(v => v.trim()).filter(Boolean);
|
|
59
76
|
|
|
60
77
|
function readStdin() {
|
|
@@ -86,8 +103,12 @@ function print(value) {
|
|
|
86
103
|
}
|
|
87
104
|
|
|
88
105
|
async function run(argv) {
|
|
106
|
+
if (argv[0] === "hook") {
|
|
107
|
+
const { runHook } = await import("../src/hooks.mjs");
|
|
108
|
+
return runHook(argv.slice(1));
|
|
109
|
+
}
|
|
89
110
|
const { values, positionals } = parseArgs({ args: argv, options, allowPositionals: true });
|
|
90
|
-
|
|
111
|
+
let [command, ...rest] = positionals;
|
|
91
112
|
if (values.help || !command) { print(USAGE); return 0; }
|
|
92
113
|
|
|
93
114
|
const cwd = process.cwd();
|
|
@@ -167,10 +188,46 @@ async function run(argv) {
|
|
|
167
188
|
render = result => formatDeploy(result, req.org);
|
|
168
189
|
break;
|
|
169
190
|
}
|
|
191
|
+
case "retrieve": {
|
|
192
|
+
const dir = projectDir();
|
|
193
|
+
const sourcePaths = split(values["source-dir"])?.map(d => resolve(cwd, d));
|
|
194
|
+
const manifest = values.manifest ? resolve(cwd, values.manifest) : undefined;
|
|
195
|
+
for (const p of [...(sourcePaths ?? []), ...(manifest ? [manifest] : [])]) {
|
|
196
|
+
if (!existsSync(p)) throw new UsageError(`No such path: ${p}`);
|
|
197
|
+
}
|
|
198
|
+
const metadata = split(values.metadata);
|
|
199
|
+
if ([Boolean(sourcePaths?.length), Boolean(metadata?.length), Boolean(manifest)].filter(Boolean).length > 1) {
|
|
200
|
+
throw new UsageError("retrieve accepts only one of -d, -m or -x.");
|
|
201
|
+
}
|
|
202
|
+
req = { org: org(), projectDir: dir, sourcePaths, metadata, manifest,
|
|
203
|
+
ignoreConflicts: values["ignore-conflicts"], waitMinutes: wait(values) };
|
|
204
|
+
render = result => formatRetrieve(result, req.org);
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
case "tracking":
|
|
208
|
+
if (values.local && values.remote) throw new UsageError("tracking accepts only one of --local or --remote.");
|
|
209
|
+
req = { org: org(), projectDir: projectDir(), local: values.local, remote: values.remote, conflicts: values.conflicts };
|
|
210
|
+
render = formatTracking;
|
|
211
|
+
break;
|
|
212
|
+
case "logs": {
|
|
213
|
+
if (rest[0] === "get") {
|
|
214
|
+
if (rest.length !== 2) throw new UsageError("logs get needs an ID or latest.");
|
|
215
|
+
command = "logGet";
|
|
216
|
+
req = { org: org(), id: rest[1], noTrace: values["no-trace"] };
|
|
217
|
+
render = result => values.full ? result.body : formatLog(result);
|
|
218
|
+
} else {
|
|
219
|
+
if (rest.length) throw new UsageError("logs accepts only get <id|latest>.");
|
|
220
|
+
const count = Number(values.count ?? values.limit ?? 10);
|
|
221
|
+
if (!Number.isSafeInteger(count) || count < 1) throw new UsageError("Log count must be a positive integer.");
|
|
222
|
+
req = { org: org(), count, user: values.user, noTrace: values["no-trace"] };
|
|
223
|
+
render = result => formatLogList(result.records);
|
|
224
|
+
}
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
170
227
|
case "test":
|
|
171
228
|
req = {
|
|
172
229
|
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)
|
|
230
|
+
logs: values.logs, noTrace: values["no-trace"], testLevel: values["test-level"], codeCoverage: values["code-coverage"], waitMinutes: wait(values)
|
|
174
231
|
};
|
|
175
232
|
render = result => formatTests(result, req.org);
|
|
176
233
|
break;
|
|
@@ -182,10 +239,14 @@ async function run(argv) {
|
|
|
182
239
|
}
|
|
183
240
|
|
|
184
241
|
const res = await call(command, req);
|
|
185
|
-
|
|
242
|
+
const trace = res.result?.trace ?? res.trace;
|
|
243
|
+
if (trace) {
|
|
244
|
+
process.stderr.write(`sfd: enabled debug logging for ${trace.username} until ${trace.expires} (TraceFlag ${trace.id})\n`);
|
|
245
|
+
}
|
|
246
|
+
if (values.json) { print(res); return res.status === 0 ? okStatus(command, res.result) : res.name === "UsageError" ? 2 : 1; }
|
|
186
247
|
if (res.status !== 0) {
|
|
187
248
|
process.stderr.write(`${res.name}: ${res.message}\n`);
|
|
188
|
-
return 1;
|
|
249
|
+
return res.name === "UsageError" ? 2 : 1;
|
|
189
250
|
}
|
|
190
251
|
print(render(res.result));
|
|
191
252
|
return okStatus(command, res.result);
|
|
@@ -194,7 +255,7 @@ async function run(argv) {
|
|
|
194
255
|
// A request can succeed while the Salesforce operation fails (compile error, failed deploy or test).
|
|
195
256
|
function okStatus(command, result) {
|
|
196
257
|
if (command === "apex") return result.compiled && result.success ? 0 : 1;
|
|
197
|
-
if (command === "deploy") return result.success ? 0 : 1;
|
|
258
|
+
if (command === "deploy" || command === "retrieve") return result.success ? 0 : 1;
|
|
198
259
|
if (command === "test") return result.failing === 0 && !result.setupFailures?.length ? 0 : 1;
|
|
199
260
|
return 0;
|
|
200
261
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syntax-syllogism/sfd",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Warm Salesforce CLI daemon: SOQL, anonymous Apex, REST, describe, deploy, and Apex tests without sf startup cost.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"access": "public"
|
|
40
40
|
},
|
|
41
41
|
"scripts": {
|
|
42
|
-
"build": "
|
|
42
|
+
"build": "node scripts/check-syntax.mjs",
|
|
43
43
|
"test": "node --test test/*.test.mjs"
|
|
44
44
|
}
|
|
45
45
|
}
|
package/src/client.mjs
CHANGED
|
@@ -29,7 +29,7 @@ export function startDaemon(env = process.env) {
|
|
|
29
29
|
const p = paths(env);
|
|
30
30
|
const out = openSync(p.log, "a");
|
|
31
31
|
const child = spawn(process.execPath, [fileURLToPath(new URL("./daemon.mjs", import.meta.url))], {
|
|
32
|
-
detached: true, stdio: ["ignore", out, out], env
|
|
32
|
+
detached: true, windowsHide: true, stdio: ["ignore", out, out], env
|
|
33
33
|
});
|
|
34
34
|
child.unref();
|
|
35
35
|
}
|
|
@@ -55,7 +55,7 @@ export async function call(op, args = {}, { env = process.env, autoStart = true,
|
|
|
55
55
|
try {
|
|
56
56
|
const configured = Number(env.SFD_TIMEOUT_SECONDS);
|
|
57
57
|
const health = ["ping", "status", "stop", "reload"].includes(op);
|
|
58
|
-
const waitMinutes = args.waitMinutes ?? (
|
|
58
|
+
const waitMinutes = args.waitMinutes ?? (["deploy", "retrieve"].includes(op) ? 33 : op === "test" ? 60 : 0);
|
|
59
59
|
const timeoutMs = Number.isFinite(configured) && configured > 0
|
|
60
60
|
? configured * 1000
|
|
61
61
|
: health ? 3000 : Math.max(120_000, waitMinutes * 60_000 + 60_000);
|
package/src/daemon.mjs
CHANGED
|
@@ -4,13 +4,16 @@ import { PROTOCOL, VERSION } from "./version.mjs";
|
|
|
4
4
|
import { AlreadyRunning, startServer } from "./server.mjs";
|
|
5
5
|
import { paths } from "./paths.mjs";
|
|
6
6
|
import { findSfRoot, sfInfo } from "./sf-root.mjs";
|
|
7
|
+
import { pathToFileURL } from "node:url";
|
|
7
8
|
|
|
8
9
|
const log = (...args) => console.error(new Date().toISOString(), ...args);
|
|
9
10
|
const p = paths();
|
|
10
11
|
const sf = sfInfo(findSfRoot());
|
|
11
12
|
|
|
12
13
|
// SFD_HANDLERS_MODULE swaps in stub handlers for tests that must not touch an org.
|
|
13
|
-
|
|
14
|
+
// import() needs a file URL for absolute Windows paths such as C:\\...
|
|
15
|
+
const handlersModule = process.env.SFD_HANDLERS_MODULE ? pathToFileURL(process.env.SFD_HANDLERS_MODULE).href : "./handlers.mjs";
|
|
16
|
+
const { createHandlers } = await import(handlersModule);
|
|
14
17
|
const handlers = createHandlers(sf.root);
|
|
15
18
|
|
|
16
19
|
// An sf upgrade rewrites its package.json; finish in-flight work and let the next call start fresh.
|
package/src/format.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { relative, resolve } from "node:path";
|
|
2
|
+
|
|
1
3
|
// Compact, token-friendly renderings of Salesforce results.
|
|
2
4
|
|
|
3
5
|
const asArray = x => (x === undefined || x === null ? [] : Array.isArray(x) ? x : [x]);
|
|
@@ -54,16 +56,56 @@ export function formatApex(result, { fullLog = false } = {}) {
|
|
|
54
56
|
if (fullLog) {
|
|
55
57
|
lines.push(log);
|
|
56
58
|
} else {
|
|
57
|
-
|
|
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
|
-
}
|
|
59
|
+
lines.push(...filterLog(log, { apexOnly: true }));
|
|
63
60
|
}
|
|
64
61
|
return lines.join("\n");
|
|
65
62
|
}
|
|
66
63
|
|
|
64
|
+
// Anonymous Apex retains its original two-event rendering.
|
|
65
|
+
export function filterLog(text, { apexOnly = false } = {}) {
|
|
66
|
+
const lines = [];
|
|
67
|
+
let inLimits = false;
|
|
68
|
+
for (const line of text.split(/\r?\n/)) {
|
|
69
|
+
const debug = line.match(/\|USER_DEBUG\|\[(\d+|EXTERNAL)\]\|\w+\|(.*)$/);
|
|
70
|
+
if (debug && (!apexOnly || debug[1] !== "EXTERNAL")) { lines.push(`[${debug[1]}] ${debug[2]}`); continue; }
|
|
71
|
+
if (/\|FATAL_ERROR\|/.test(line)) { lines.push(line.replace(/^.*\|FATAL_ERROR\|/, "FATAL: ")); continue; }
|
|
72
|
+
if (apexOnly) continue;
|
|
73
|
+
const event = line.match(/\|(EXCEPTION_THROWN|VALIDATION_FAIL|VALIDATION_ERROR|FLOW_ELEMENT_ERROR)(?:\|(.*))?$/);
|
|
74
|
+
if (event) {
|
|
75
|
+
const label = event[1] === "EXCEPTION_THROWN" ? "EXCEPTION" : event[1].startsWith("FLOW") ? "FLOW" : "VALIDATION";
|
|
76
|
+
lines.push(`${label} ${(event[2] ?? "").replaceAll("|", " ")}`.trimEnd());
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (line.includes("|LIMIT_USAGE_FOR_NS|")) { inLimits = true; continue; }
|
|
80
|
+
if (inLimits && /^\s*Number of |^\s*(Maximum CPU time|Maximum heap size)/.test(line)) {
|
|
81
|
+
const usage = line.match(/^\s*(.+?):\s*(\d+) out of (\d+)/);
|
|
82
|
+
if (usage && Number(usage[3]) > 0 && Number(usage[2]) >= Number(usage[3]) / 2) {
|
|
83
|
+
const name = usage[1].replace(/^Number of /, "").replace(/ queries$/, "").replace(/^Maximum /, "");
|
|
84
|
+
lines.push(`LIMIT ${name} ${usage[2]}/${usage[3]}`);
|
|
85
|
+
}
|
|
86
|
+
} else if (line.includes("|")) inLimits = false;
|
|
87
|
+
}
|
|
88
|
+
if (apexOnly) return lines;
|
|
89
|
+
const deduped = [];
|
|
90
|
+
for (let i = 0; i < lines.length;) {
|
|
91
|
+
let end = i + 1;
|
|
92
|
+
while (lines[end] === lines[i]) end++;
|
|
93
|
+
deduped.push(lines[i] + (end - i > 1 ? ` ×${end - i}` : ""));
|
|
94
|
+
i = end;
|
|
95
|
+
}
|
|
96
|
+
return deduped;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const logSize = bytes => bytes >= 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${bytes ?? 0} B`;
|
|
100
|
+
|
|
101
|
+
export function formatLog(log) {
|
|
102
|
+
return [`${log.operation} ${log.status} ${log.duration ?? 0}ms ${logSize(log.length)}`, ...filterLog(log.body)].join("\n");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function formatLogList(rows) {
|
|
106
|
+
return rows.length ? rows.map(row => `${row.Id} ${row.StartTime.replace("T", " ").replace(/\.\d+Z$/, "")} ${row.Operation} ${row.Status} ${logSize(row.LogLength)} ${row.DurationMilliseconds}ms`).join("\n") : "No debug logs found.";
|
|
107
|
+
}
|
|
108
|
+
|
|
67
109
|
export function formatDeploy(summary, org) {
|
|
68
110
|
const action = summary.checkOnly ? "Validated" : "Deployed";
|
|
69
111
|
const tests = summary.numberTestsTotal > 0
|
|
@@ -134,5 +176,66 @@ export function formatTests(summary, org) {
|
|
|
134
176
|
for (const c of summary.coverage ?? []) {
|
|
135
177
|
lines.push(` ${c.percentage.padStart(4)} ${c.name}${c.uncoveredLines.length ? ` uncovered: ${c.uncoveredLines.join(",")}` : ""}`);
|
|
136
178
|
}
|
|
179
|
+
if (summary.logs?.length) {
|
|
180
|
+
lines.push("Logs:");
|
|
181
|
+
for (const log of summary.logs) {
|
|
182
|
+
lines.push(` ${log.id}`);
|
|
183
|
+
if (log.body !== undefined) lines.push(...formatLog(log).split("\n").map(line => ` ${line}`));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
137
186
|
return lines.join("\n");
|
|
138
187
|
}
|
|
188
|
+
|
|
189
|
+
// Normalize both SDR file responses and STL change/status rows at the boundary.
|
|
190
|
+
export function summarizeChanges(changes, projectDir, state) {
|
|
191
|
+
const files = changes.flatMap(c => (c.filenames?.length ? c.filenames : [c.filePath]).map(path => ({
|
|
192
|
+
state: state ?? c.state ?? (c.deleted ? "Deleted" : c.modified ? "Changed" : "Add"),
|
|
193
|
+
type: c.type, fullName: c.fullName ?? c.name,
|
|
194
|
+
path: path ? relative(projectDir, resolve(projectDir, path)) : undefined,
|
|
195
|
+
...(c.problem ? { problem: c.problem } : {})
|
|
196
|
+
})));
|
|
197
|
+
return [...new Map(files.map(f => [JSON.stringify([f.state, f.type, f.fullName, f.path, f.problem]), f])).values()];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function summarizeRetrieve(result, projectDir) {
|
|
201
|
+
const response = result.response;
|
|
202
|
+
const files = summarizeChanges(result.getFileResponses(), projectDir);
|
|
203
|
+
return {
|
|
204
|
+
success: Boolean(response.success) && !files.some(f => f.state === "Failed" || f.problem),
|
|
205
|
+
status: response.status, id: response.id, files,
|
|
206
|
+
warnings: asArray(response.messages), conflicts: [], errorMessage: response.errorMessage
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function componentLines(files, conflict = false) {
|
|
211
|
+
// Collapse only a source file's companion XML; retain bundle files and all failures.
|
|
212
|
+
const paths = new Set(files.filter(f => !f.problem).map(f => `${f.type}:${f.fullName}:${f.path}`));
|
|
213
|
+
return files.filter(f => f.problem || !f.path?.endsWith("-meta.xml") ||
|
|
214
|
+
!paths.has(`${f.type}:${f.fullName}:${f.path.slice(0, -9)}`)).map(f => {
|
|
215
|
+
const state = String(f.state).toLowerCase();
|
|
216
|
+
const mark = conflict ? "! conflict" : f.problem || state === "failed" ? "✖" :
|
|
217
|
+
["add", "added", "created"].includes(state) ? "+" : ["delete", "deleted"].includes(state) ? "-" : "~";
|
|
218
|
+
return ` ${mark} ${f.type} ${f.fullName}${f.path ? ` ${f.path}` : ""}${f.problem ? `: ${f.problem}` : ""}`;
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function formatRetrieve(summary, org) {
|
|
223
|
+
const count = new Set(summary.conflicts.map(c => `${c.type}:${c.fullName}`)).size;
|
|
224
|
+
const head = count
|
|
225
|
+
? `CONFLICTS: ${count} component${count === 1 ? "" : "s"} changed both locally and in ${org}; rerun with --ignore-conflicts to overwrite`
|
|
226
|
+
: summary.message ?? (summary.success
|
|
227
|
+
? `Retrieved ${summary.files.length} files from ${org}${summary.id ? ` (${summary.id})` : ""}`
|
|
228
|
+
: `FAILED (${summary.status}) ${org}${summary.id ? ` (${summary.id})` : ""}`);
|
|
229
|
+
const lines = [head, ...componentLines(summary.files), ...componentLines(summary.conflicts, true)];
|
|
230
|
+
for (const warning of summary.warnings) lines.push(` ! ${typeof warning === "string" ? warning : warning.problem ?? JSON.stringify(warning)}`);
|
|
231
|
+
if (summary.errorMessage) lines.push(` ✖ ${summary.errorMessage}`);
|
|
232
|
+
return lines.join("\n");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function formatTracking(summary) {
|
|
236
|
+
const lines = [];
|
|
237
|
+
for (const section of ["local", "remote", "conflicts"]) {
|
|
238
|
+
if (summary[section].length) lines.push(section, ...componentLines(summary[section], section === "conflicts"));
|
|
239
|
+
}
|
|
240
|
+
return lines.join("\n") || "No changes.";
|
|
241
|
+
}
|
package/src/handlers.mjs
CHANGED
|
@@ -1,8 +1,71 @@
|
|
|
1
1
|
import { statSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { UsageError } from "./usage-error.mjs";
|
|
4
5
|
import { sfRequire } from "./sf-root.mjs";
|
|
5
|
-
import { compactDescribe, compactQuery, summarizeDeploy, summarizeTests } from "./format.mjs";
|
|
6
|
+
import { compactDescribe, compactQuery, summarizeDeploy, summarizeTests, summarizeRetrieve, summarizeChanges } from "./format.mjs";
|
|
7
|
+
|
|
8
|
+
const soqlString = value => `'${String(value).replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
|
|
9
|
+
const LOG_FIELDS = "Id, LogUser.Username, Operation, Request, Status, LogLength, StartTime, DurationMilliseconds";
|
|
10
|
+
const LOG_CLOCK_SKEW_MS = 5 * 60 * 1000;
|
|
11
|
+
|
|
12
|
+
async function currentUser(conn) {
|
|
13
|
+
const fields = conn.getAuthInfoFields?.();
|
|
14
|
+
if (fields?.userId) return { id: fields.userId, username: fields.username ?? fields.userId };
|
|
15
|
+
const identity = await conn.identity();
|
|
16
|
+
return { id: identity.user_id, username: identity.username };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function logCount(count) {
|
|
20
|
+
if (!Number.isSafeInteger(count) || count < 1) throw new UsageError("Log count must be a positive integer.");
|
|
21
|
+
return count;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function logRows(conn, where = "", count = 10) {
|
|
25
|
+
return (await conn.query(`SELECT ${LOG_FIELDS} FROM ApexLog ${where ? `WHERE ${where}` : ""} ORDER BY StartTime DESC LIMIT ${logCount(count)}`)).records;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function logBody(conn, row) {
|
|
29
|
+
const body = await conn.request(`/services/data/v${conn.getApiVersion()}/sobjects/ApexLog/${row.Id}/Body`, { responseType: "text" });
|
|
30
|
+
return { id: row.Id, operation: row.Operation, status: row.Status, startTime: row.StartTime,
|
|
31
|
+
length: row.LogLength, duration: row.DurationMilliseconds, body };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Preserve the org-write notice even when the subsequent operation fails.
|
|
35
|
+
async function afterTrace(trace, fn) {
|
|
36
|
+
try { return { ...await fn(), ...(trace ? { trace } : {}) }; }
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (trace) error.trace = trace;
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function ensureTrace(conn, userId) {
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
const flags = await conn.tooling.query(`SELECT Id, ExpirationDate, StartDate FROM TraceFlag WHERE TracedEntityId = ${soqlString(userId)} AND LogType = 'DEVELOPER_LOG'`);
|
|
46
|
+
const flag = flags.records.find(f => Date.parse(f.StartDate) <= now && Date.parse(f.ExpirationDate) > now + 300000) ?? flags.records[0];
|
|
47
|
+
if (flag && Date.parse(flag.StartDate) <= now && Date.parse(flag.ExpirationDate) > now + 300000) {
|
|
48
|
+
return { action: "existing", id: flag.Id, expires: flag.ExpirationDate };
|
|
49
|
+
}
|
|
50
|
+
const dates = { StartDate: new Date(now).toISOString(), ExpirationDate: new Date(now + 1800000).toISOString() };
|
|
51
|
+
let result;
|
|
52
|
+
if (flag) {
|
|
53
|
+
result = await conn.tooling.sobject("TraceFlag").update({ Id: flag.Id, ...dates });
|
|
54
|
+
} else {
|
|
55
|
+
const levels = await conn.tooling.query("SELECT Id FROM DebugLevel WHERE DeveloperName = 'SFD' LIMIT 1");
|
|
56
|
+
let levelId = levels.records[0]?.Id;
|
|
57
|
+
if (!levelId) {
|
|
58
|
+
const level = await conn.tooling.sobject("DebugLevel").create({ DeveloperName: "SFD", MasterLabel: "SFD",
|
|
59
|
+
ApexCode: "FINE", ApexProfiling: "INFO", Callout: "INFO", Database: "INFO", System: "DEBUG",
|
|
60
|
+
Validation: "INFO", Visualforce: "NONE", Workflow: "INFO", Wave: "NONE", Nba: "NONE" });
|
|
61
|
+
if (!level.success) throw new Error(`Cannot create debug level: ${JSON.stringify(level.errors)}`);
|
|
62
|
+
levelId = level.id;
|
|
63
|
+
}
|
|
64
|
+
result = await conn.tooling.sobject("TraceFlag").create({ TracedEntityId: userId, LogType: "DEVELOPER_LOG", DebugLevelId: levelId, ...dates });
|
|
65
|
+
}
|
|
66
|
+
if (!result.success) throw new Error(`Cannot enable debug logging: ${JSON.stringify(result.errors)}`);
|
|
67
|
+
return { action: flag ? "extended" : "created", id: flag?.Id ?? result.id, expires: dates.ExpirationDate };
|
|
68
|
+
}
|
|
6
69
|
|
|
7
70
|
const SESSION_ERRORS = new Set(["INVALID_SESSION_ID", "INVALID_AUTH_HEADER"]);
|
|
8
71
|
|
|
@@ -34,6 +97,15 @@ export function createHandlers(sfRoot, { home = homedir(), load = sfRequire(sfRo
|
|
|
34
97
|
const projectQueue = serial();
|
|
35
98
|
|
|
36
99
|
const orgs = new Map();
|
|
100
|
+
const traces = new Map();
|
|
101
|
+
const traceQueue = serial();
|
|
102
|
+
const traceFor = (conn, alias, user, noTrace) => noTrace ? undefined : traceQueue(async () => {
|
|
103
|
+
const cached = traces.get(alias);
|
|
104
|
+
if (cached?.userId === user.id && Date.parse(cached.expires) > Date.now() + 300000) return undefined;
|
|
105
|
+
const trace = await ensureTrace(conn, user.id);
|
|
106
|
+
traces.set(alias, { ...trace, userId: user.id });
|
|
107
|
+
return trace.action === "existing" ? undefined : { ...trace, username: user.username };
|
|
108
|
+
});
|
|
37
109
|
let stamp = authStamp(home);
|
|
38
110
|
|
|
39
111
|
async function getOrg(alias) {
|
|
@@ -41,6 +113,7 @@ export function createHandlers(sfRoot, { home = homedir(), load = sfRequire(sfRo
|
|
|
41
113
|
if (now !== stamp) {
|
|
42
114
|
stamp = now;
|
|
43
115
|
orgs.clear();
|
|
116
|
+
traces.clear();
|
|
44
117
|
await core.StateAggregator.clearInstanceAsync();
|
|
45
118
|
}
|
|
46
119
|
if (!orgs.has(alias)) {
|
|
@@ -59,6 +132,7 @@ export function createHandlers(sfRoot, { home = homedir(), load = sfRequire(sfRo
|
|
|
59
132
|
} catch (e) {
|
|
60
133
|
if (!SESSION_ERRORS.has(e.errorCode ?? e.name)) throw e;
|
|
61
134
|
orgs.delete(alias);
|
|
135
|
+
traces.delete(alias);
|
|
62
136
|
const org = await getOrg(alias);
|
|
63
137
|
return fn(org.getConnection(), org);
|
|
64
138
|
}
|
|
@@ -75,6 +149,7 @@ export function createHandlers(sfRoot, { home = homedir(), load = sfRequire(sfRo
|
|
|
75
149
|
|
|
76
150
|
reload: async () => {
|
|
77
151
|
orgs.clear();
|
|
152
|
+
traces.clear();
|
|
78
153
|
await core.StateAggregator.clearInstanceAsync();
|
|
79
154
|
return { reloaded: true };
|
|
80
155
|
},
|
|
@@ -135,15 +210,105 @@ export function createHandlers(sfRoot, { home = homedir(), load = sfRequire(sfRo
|
|
|
135
210
|
return summarizeDeploy(result.response);
|
|
136
211
|
})),
|
|
137
212
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
213
|
+
retrieve: ({ org, projectDir, sourcePaths, metadata, manifest, ignoreConflicts = false, waitMinutes = 33 }) =>
|
|
214
|
+
projectQueue(() => withConn(org, async (conn, o) => {
|
|
215
|
+
core.SfProject.clearInstances();
|
|
216
|
+
const project = await core.SfProject.resolve(projectDir);
|
|
217
|
+
const tracked = await o.tracksSource();
|
|
218
|
+
const changesOnly = !sourcePaths?.length && !metadata?.length && !manifest;
|
|
219
|
+
if (changesOnly && !tracked) throw new UsageError("retrieve needs -d, -m or -x on an org without source tracking.");
|
|
220
|
+
// Handle conflicts and tracking explicitly: daemon requests must not leave lifecycle listeners behind.
|
|
221
|
+
const st = tracked ? await tracking().SourceTracking.create({ org: o, project, ignoreConflicts, ignoreLocalCache: true }) : undefined;
|
|
222
|
+
const conflicts = st && !ignoreConflicts ? await st.getConflicts() : [];
|
|
223
|
+
let cs;
|
|
224
|
+
let deletedFiles = [];
|
|
225
|
+
const refusal = entries => ({ success: false, status: "Conflicts", files: [], warnings: [], conflicts: summarizeChanges(entries, projectDir, "Conflict") });
|
|
226
|
+
if (changesOnly) {
|
|
227
|
+
// Remote deletes write to disk, so refuse before asking STL to apply any of them.
|
|
228
|
+
if (conflicts.length) return refusal(conflicts);
|
|
229
|
+
const targets = await st.maybeApplyRemoteDeletesToLocal(true);
|
|
230
|
+
cs = targets.componentSetFromNonDeletes;
|
|
231
|
+
deletedFiles = targets.fileResponsesFromDelete;
|
|
232
|
+
} else {
|
|
233
|
+
const packageDirs = project.getUniquePackageDirectories().map(d => d.fullPath);
|
|
234
|
+
cs = await sdr().ComponentSetBuilder.build({
|
|
235
|
+
projectDir,
|
|
236
|
+
sourceapiversion: project.getSfProjectJson().get("sourceApiVersion"),
|
|
237
|
+
sourcepath: sourcePaths?.length ? sourcePaths : undefined,
|
|
238
|
+
metadata: metadata?.length ? { metadataEntries: metadata, directoryPaths: packageDirs } : undefined,
|
|
239
|
+
...(manifest ? { manifest: { manifestPath: manifest, directoryPaths: packageDirs } } : {})
|
|
240
|
+
});
|
|
241
|
+
const selectedConflicts = conflicts.filter(c => cs.has({ type: c.type, fullName: c.name }));
|
|
242
|
+
if (selectedConflicts.length) return refusal(selectedConflicts);
|
|
243
|
+
}
|
|
244
|
+
if (cs.size === 0) {
|
|
245
|
+
if (!changesOnly) throw new Error("Nothing to retrieve: no components matched.");
|
|
246
|
+
return { success: true, status: "Succeeded", files: summarizeChanges(deletedFiles, projectDir), warnings: [], conflicts: [],
|
|
247
|
+
...(!deletedFiles.length ? { message: "No remote changes to retrieve." } : {}) };
|
|
248
|
+
}
|
|
249
|
+
const retrieve = await cs.retrieve({ usernameOrConnection: conn, output: project.getDefaultPackage().fullPath, merge: true, format: "source" });
|
|
250
|
+
const result = await retrieve.pollStatus({ frequency: Duration.seconds(2), timeout: Duration.minutes(waitMinutes) });
|
|
251
|
+
if (st && result.response.success) await st.updateTrackingFromRetrieve(result);
|
|
252
|
+
const summary = summarizeRetrieve(result, projectDir);
|
|
253
|
+
summary.files.unshift(...summarizeChanges(deletedFiles, projectDir));
|
|
254
|
+
return summary;
|
|
255
|
+
})),
|
|
256
|
+
|
|
257
|
+
tracking: ({ org, projectDir, local = false, remote = false, conflicts: conflictsOnly = false }) =>
|
|
258
|
+
projectQueue(() => withConn(org, async (conn, o) => {
|
|
259
|
+
if (!(await o.tracksSource())) throw new UsageError("tracking requires an org with source tracking.");
|
|
260
|
+
core.SfProject.clearInstances();
|
|
261
|
+
const project = await core.SfProject.resolve(projectDir);
|
|
262
|
+
const st = await tracking().SourceTracking.create({ org: o, project, ignoreLocalCache: true });
|
|
263
|
+
const rows = conflictsOnly ? [] : await st.getStatus({ local: !remote, remote: !local });
|
|
264
|
+
return {
|
|
265
|
+
local: summarizeChanges(rows.filter(r => r.origin === "local" && !r.ignored), projectDir),
|
|
266
|
+
remote: summarizeChanges(rows.filter(r => r.origin === "remote" && !r.ignored), projectDir),
|
|
267
|
+
conflicts: summarizeChanges(await st.getConflicts(), projectDir, "Conflict")
|
|
268
|
+
};
|
|
269
|
+
})),
|
|
270
|
+
|
|
271
|
+
logs: ({ org, count = 10, user, noTrace }) => withConn(org, async conn => {
|
|
272
|
+
logCount(count);
|
|
273
|
+
const trace = await traceFor(conn, org, await currentUser(conn), noTrace);
|
|
274
|
+
return afterTrace(trace, async () => ({
|
|
275
|
+
records: await logRows(conn, user ? `LogUser.Username = ${soqlString(user)}` : "", count)
|
|
276
|
+
}));
|
|
277
|
+
}),
|
|
278
|
+
|
|
279
|
+
logGet: ({ org, id, noTrace }) => withConn(org, async conn => {
|
|
280
|
+
if (id !== "latest" && !/^07L[a-zA-Z0-9]{12}(?:[a-zA-Z0-9]{3})?$/.test(id)) throw new UsageError("logs get needs an ApexLog ID or latest.");
|
|
281
|
+
const user = await currentUser(conn);
|
|
282
|
+
const trace = await traceFor(conn, org, user, noTrace);
|
|
283
|
+
return afterTrace(trace, async () => {
|
|
284
|
+
const rows = await logRows(conn, id === "latest" ? `LogUserId = ${soqlString(user.id)}` : `Id = ${soqlString(id)}`, 1);
|
|
285
|
+
if (!rows.length) throw new Error("No debug logs found.");
|
|
286
|
+
return logBody(conn, rows[0]);
|
|
287
|
+
});
|
|
288
|
+
}),
|
|
289
|
+
|
|
290
|
+
test: ({ org, classNames, tests, suiteNames, testLevel, codeCoverage, logs, noTrace, waitMinutes = 60 }) => withConn(org, async conn => {
|
|
291
|
+
const user = logs ? await currentUser(conn) : undefined;
|
|
292
|
+
const trace = logs ? await traceFor(conn, org, user, noTrace) : undefined;
|
|
293
|
+
return afterTrace(trace, async () => {
|
|
294
|
+
const { TestService } = apex();
|
|
295
|
+
const service = new TestService(conn);
|
|
296
|
+
const level = testLevel ?? (classNames?.length || tests?.length || suiteNames?.length ? "RunSpecifiedTests" : "RunLocalTests");
|
|
297
|
+
// apex-node takes comma-separated strings, as the sf flags do.
|
|
298
|
+
const csv = list => (list?.length ? list.join(",") : undefined);
|
|
299
|
+
const payload = await service.buildAsyncPayload(level, csv(tests), csv(classNames), csv(suiteNames));
|
|
300
|
+
const startedAt = new Date(Math.floor((Date.now() - LOG_CLOCK_SKEW_MS) / 1000) * 1000).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
301
|
+
const result = await service.runTestAsynchronous(payload, Boolean(codeCoverage), false, undefined, undefined, Duration.minutes(waitMinutes));
|
|
302
|
+
const summary = summarizeTests(result);
|
|
303
|
+
if (logs) {
|
|
304
|
+
// Pad both bounds for clock skew, rounding outward to whole-second SOQL literals.
|
|
305
|
+
const finishedAt = new Date(Math.ceil((Date.now() + LOG_CLOCK_SKEW_MS) / 1000) * 1000).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
306
|
+
const rows = await logRows(conn, `LogUserId = ${soqlString(user.id)} AND StartTime >= ${startedAt} AND StartTime <= ${finishedAt}`, 10);
|
|
307
|
+
summary.logs = summary.failing > 0 || summary.failures.length || summary.setupFailures.length
|
|
308
|
+
? await Promise.all(rows.map(row => logBody(conn, row))) : rows.map(row => ({ id: row.Id }));
|
|
309
|
+
}
|
|
310
|
+
return summary;
|
|
311
|
+
});
|
|
147
312
|
})
|
|
148
313
|
};
|
|
149
314
|
}
|
package/src/hooks.mjs
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, copyFileSync, renameSync, unlinkSync, statSync, constants } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import { rewrite } from "./rewrite.mjs";
|
|
6
|
+
import { UsageError } from "./usage-error.mjs";
|
|
7
|
+
|
|
8
|
+
const USAGE = "Usage: sfd hook claude|codex|gemini|check <command>|install|uninstall --agent <agent> [--project]";
|
|
9
|
+
|
|
10
|
+
const agents = {
|
|
11
|
+
claude: { directory: ".claude", file: "settings.json", event: "PreToolUse", tool: "Bash" },
|
|
12
|
+
codex: { directory: ".codex", file: "hooks.json", event: "PreToolUse", tool: "Bash" },
|
|
13
|
+
gemini: { directory: ".gemini", file: "settings.json", event: "BeforeTool", tool: "run_shell_command" }
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function handle(agent, inputJson, env = process.env) {
|
|
17
|
+
try {
|
|
18
|
+
if (env.SFD_HOOK_DISABLE === "1") return "";
|
|
19
|
+
const config = agents[agent];
|
|
20
|
+
if (!config) return "";
|
|
21
|
+
const input = JSON.parse(inputJson);
|
|
22
|
+
if (input?.tool_name !== config.tool || (input.hook_event_name && input.hook_event_name !== config.event)) return "";
|
|
23
|
+
if (!input.tool_input || typeof input.tool_input.command !== "string") return "";
|
|
24
|
+
const result = rewrite(input.tool_input.command);
|
|
25
|
+
if (result.skip) return "";
|
|
26
|
+
const updatedInput = { ...input.tool_input, command: result.command };
|
|
27
|
+
if (agent === "gemini") return JSON.stringify({ hookSpecificOutput: { tool_input: updatedInput } });
|
|
28
|
+
return JSON.stringify({ hookSpecificOutput: {
|
|
29
|
+
hookEventName: "PreToolUse",
|
|
30
|
+
...(agent === "codex" ? { permissionDecision: "allow" } : {}),
|
|
31
|
+
permissionDecisionReason: result.reason,
|
|
32
|
+
updatedInput
|
|
33
|
+
} });
|
|
34
|
+
} catch { return ""; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function object(value) { return value !== null && typeof value === "object" && !Array.isArray(value); }
|
|
38
|
+
|
|
39
|
+
export function editSettings(action, agent, { project = false, cwd = process.cwd(), home = homedir() } = {}) {
|
|
40
|
+
const config = agents[agent];
|
|
41
|
+
if (!config || !["install", "uninstall"].includes(action)) throw new UsageError("Expected install|uninstall --agent claude|codex|gemini [--project]");
|
|
42
|
+
const file = join(project ? cwd : home, config.directory, config.file);
|
|
43
|
+
let original, settings = {};
|
|
44
|
+
try { original = readFileSync(file, "utf8"); settings = JSON.parse(original); }
|
|
45
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
46
|
+
if (!object(settings) || (settings.hooks !== undefined && !object(settings.hooks))) throw Error(`Invalid hooks settings in ${file}`);
|
|
47
|
+
const entries = settings.hooks?.[config.event] ?? [];
|
|
48
|
+
if (!Array.isArray(entries) || entries.some(entry => !object(entry) || !Array.isArray(entry.hooks))) throw Error(`Invalid ${config.event} settings in ${file}`);
|
|
49
|
+
const isSfd = hook => hook?.type === "command" && typeof hook.command === "string" && new RegExp(`^sfd hook ${agent}(?:\\s|$)`).test(hook.command);
|
|
50
|
+
const exists = entries.some(entry => entry.hooks.some(isSfd));
|
|
51
|
+
if ((action === "install") === exists) return { file, changed: false };
|
|
52
|
+
let updated;
|
|
53
|
+
if (action === "install") {
|
|
54
|
+
updated = [...entries, { matcher: config.tool, hooks: [{ type: "command", command: `sfd hook ${agent}` }] }];
|
|
55
|
+
} else {
|
|
56
|
+
updated = entries.flatMap(entry => {
|
|
57
|
+
const hooks = entry.hooks.filter(hook => !isSfd(hook));
|
|
58
|
+
return hooks.length === entry.hooks.length ? [entry] : hooks.length ? [{ ...entry, hooks }] : [];
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
settings.hooks ??= {};
|
|
62
|
+
settings.hooks[config.event] = updated;
|
|
63
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
64
|
+
if (original !== undefined) {
|
|
65
|
+
try { copyFileSync(file, `${file}.bak`, constants.COPYFILE_EXCL); }
|
|
66
|
+
catch (error) { if (error.code !== "EEXIST") throw error; }
|
|
67
|
+
}
|
|
68
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
69
|
+
try {
|
|
70
|
+
writeFileSync(temporary, JSON.stringify(settings, null, 2) + "\n", { flag: "wx", mode: original === undefined ? 0o600 : statSync(file).mode & 0o777 });
|
|
71
|
+
renameSync(temporary, file);
|
|
72
|
+
} finally {
|
|
73
|
+
try { unlinkSync(temporary); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
74
|
+
}
|
|
75
|
+
return { file, changed: true };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function runHook(argv) {
|
|
79
|
+
const [action, ...args] = argv;
|
|
80
|
+
if (action === "--help" || action === "-h") {
|
|
81
|
+
process.stdout.write(`${USAGE}\n`);
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
if (Object.hasOwn(agents, action)) {
|
|
85
|
+
// Adapter errors must never change whether the original tool call can run.
|
|
86
|
+
try { process.stdout.write(handle(action, readFileSync(0, "utf8"))); } catch { /* no opinion */ }
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
if (action === "check") {
|
|
90
|
+
const result = process.env.SFD_HOOK_DISABLE === "1" ? { skip: "SFD_HOOK_DISABLE=1" } : rewrite(args.join(" "));
|
|
91
|
+
process.stdout.write(result.skip ? `skip: ${result.skip}\n` : `→ ${result.command}\n`);
|
|
92
|
+
return result.skip ? 1 : 0;
|
|
93
|
+
}
|
|
94
|
+
if (["install", "uninstall"].includes(action)) {
|
|
95
|
+
const { values } = parseArgs({ args, options: { agent: { type: "string" }, project: { type: "boolean" } } });
|
|
96
|
+
const result = editSettings(action, values.agent, { project: values.project });
|
|
97
|
+
process.stdout.write(`${result.changed ? "Updated" : "Unchanged"} ${result.file}\n`);
|
|
98
|
+
if (action === "install" && values.agent === "codex") process.stdout.write("Review and trust the hook with /hooks in Codex before it can run.\n");
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
throw new UsageError(USAGE);
|
|
102
|
+
}
|
package/src/paths.mjs
CHANGED
|
@@ -1,16 +1,34 @@
|
|
|
1
1
|
import { mkdirSync, chmodSync } from "node:fs";
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
2
|
+
import { tmpdir, userInfo } from "node:os";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
|
|
5
6
|
// Unix socket paths are capped near 108 bytes, so keep the runtime dir short.
|
|
6
|
-
export function runtimeDir(env = process.env) {
|
|
7
|
+
export function runtimeDir(env = process.env, platform = process.platform) {
|
|
8
|
+
if (platform === "win32") {
|
|
9
|
+
const base = env.SFD_RUNTIME_DIR ?? join(env.LOCALAPPDATA ?? tmpdir(), "sfd");
|
|
10
|
+
mkdirSync(base, { recursive: true });
|
|
11
|
+
return base;
|
|
12
|
+
}
|
|
7
13
|
const base = env.SFD_RUNTIME_DIR ?? join(env.XDG_RUNTIME_DIR ?? tmpdir(), `sfd-${process.getuid()}`);
|
|
8
14
|
mkdirSync(base, { recursive: true, mode: 0o700 });
|
|
9
15
|
chmodSync(base, 0o700);
|
|
10
16
|
return base;
|
|
11
17
|
}
|
|
12
18
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
19
|
+
// Windows has no Unix sockets in the filesystem; the daemon listens on a named pipe instead.
|
|
20
|
+
// Pipe names are machine-wide, so derive one per user and runtime dir.
|
|
21
|
+
export function socketPath(dir, platform = process.platform) {
|
|
22
|
+
if (platform !== "win32") return join(dir, "sfd.sock");
|
|
23
|
+
const id = createHash("sha256").update(`${userInfo().username}\0${dir.toLowerCase()}`).digest("hex").slice(0, 16);
|
|
24
|
+
return `\\\\.\\pipe\\sfd-${id}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isPipe(socket) {
|
|
28
|
+
return socket.startsWith("\\\\.\\pipe\\");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function paths(env = process.env, platform = process.platform) {
|
|
32
|
+
const dir = runtimeDir(env, platform);
|
|
33
|
+
return { dir, socket: socketPath(dir, platform), log: join(dir, "sfd.log"), pid: join(dir, "sfd.pid") };
|
|
16
34
|
}
|
package/src/rewrite.mjs
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// This is deliberately a literal-shell subset. Unknown shell syntax stays with sf.
|
|
2
|
+
const quote = value => `'${value.replaceAll("'", "'\\''")}'`;
|
|
3
|
+
const skip = reason => ({ skip: reason });
|
|
4
|
+
|
|
5
|
+
function lex(source) {
|
|
6
|
+
const tokens = [];
|
|
7
|
+
let i = 0;
|
|
8
|
+
while (i < source.length) {
|
|
9
|
+
if (/[ \t\r]/.test(source[i])) { i++; continue; }
|
|
10
|
+
const start = i;
|
|
11
|
+
const op = /^(?:2>&1|2>\/dev\/null|&&|\|\||>>|<<|[|;&<>\n])/.exec(source.slice(i));
|
|
12
|
+
if (op) {
|
|
13
|
+
tokens.push({ op: op[0], start, end: i += op[0].length });
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
let value = "", mode = "";
|
|
17
|
+
while (i < source.length) {
|
|
18
|
+
const c = source[i];
|
|
19
|
+
if (!mode && /[ \t\r\n|;&<>]/.test(c)) break;
|
|
20
|
+
if (c === "'" && mode !== '"') { mode = mode === "'" ? "" : "'"; i++; continue; }
|
|
21
|
+
if (c === '"' && mode !== "'") { mode = mode === '"' ? "" : '"'; i++; continue; }
|
|
22
|
+
if (c === "\\" && mode !== "'") {
|
|
23
|
+
if (++i === source.length) throw Error("incomplete escape");
|
|
24
|
+
if (mode === '"' && !/[\$`"\\]/.test(source[i])) value += "\\";
|
|
25
|
+
value += source[i++];
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (mode !== "'" && /[$`]/.test(c)) throw Error("shell expansion or command substitution");
|
|
29
|
+
if (!mode && /[(){}*?\[\]~#]/.test(c)) throw Error("unsupported shell syntax");
|
|
30
|
+
value += c; i++;
|
|
31
|
+
}
|
|
32
|
+
if (mode) throw Error("unclosed quote");
|
|
33
|
+
tokens.push({ value, start, end: i });
|
|
34
|
+
}
|
|
35
|
+
return tokens;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// [sf names, sfd flag (empty means drop), takes value, repeatable]
|
|
39
|
+
const org = ["o target-org", "-o", true];
|
|
40
|
+
const json = ["json", "", false];
|
|
41
|
+
const tooling = ["t use-tooling-api", "--tooling", false];
|
|
42
|
+
const format = ["r result-format", "", true];
|
|
43
|
+
const wait = ["w wait", "-w", true];
|
|
44
|
+
const level = ["l test-level", "-l", true];
|
|
45
|
+
const tests = ["t tests", "-t", true, true];
|
|
46
|
+
const mappings = [
|
|
47
|
+
{ sf: "apex list log", sfd: "logs", flags: [org, json] },
|
|
48
|
+
{ sf: "apex get log", sfd: "logs get", flags: [org, json, ["i log-id", "", true], ["n number", "", true]] },
|
|
49
|
+
{ sf: "data query", sfd: "query", flags: [org, json, tooling, ["q query", "", true], ["all-rows", "--all", false], format], required: "q" },
|
|
50
|
+
{ sf: "apex run test", sfd: "test", flags: [org, json, wait, level, tests, format, ["n class-names", "-n", true, true], ["s suite-names", "-s", true, true], ["c code-coverage", "-c", false], ["y synchronous", "", false]] },
|
|
51
|
+
{ sf: "apex run", sfd: "apex", flags: [org, json, ["f file", "-f", true]] },
|
|
52
|
+
{ sf: "project retrieve start", sfd: "retrieve", flags: [org, json, wait, ["d source-dir", "-d", true, true], ["m metadata", "-m", true, true], ["x manifest", "-x", true], ["c ignore-conflicts", "--ignore-conflicts", false]] },
|
|
53
|
+
{ sf: "project deploy start", sfd: "deploy", flags: [org, json, wait, level, tests, ["d source-dir", "-d", true, true], ["m metadata", "-m", true, true], ["dry-run", "--check-only", false]] },
|
|
54
|
+
{ sf: "sobject describe", sfd: "describe", flags: [org, json, tooling, ["s sobject", "", true]], required: "s" },
|
|
55
|
+
{ sf: "api request rest", sfd: "rest", flags: [org, ["X method", "-X", true], ["b body", "--body", true]], positional: true },
|
|
56
|
+
{ sf: "org display", sfd: "org", flags: [org, json] }
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
function mapArgs(argv) {
|
|
60
|
+
const mapping = mappings.find(m => m.sf.split(" ").every((v, i) => argv[i] === v));
|
|
61
|
+
if (!mapping) return skip("unsupported sf command");
|
|
62
|
+
const args = argv.slice(mapping.sf.split(" ").length);
|
|
63
|
+
const seen = new Map(), output = [], positionals = [];
|
|
64
|
+
for (let i = 0; i < args.length; i++) {
|
|
65
|
+
const arg = args[i];
|
|
66
|
+
if (!arg.startsWith("-")) { positionals.push(arg); continue; }
|
|
67
|
+
const match = /^(?:--([^=]+)|-([A-Za-z]))(?:=(.*))?$/.exec(arg);
|
|
68
|
+
const name = match?.[1] ?? match?.[2];
|
|
69
|
+
const spec = mapping.flags.find(f => f[0].split(" ").includes(name));
|
|
70
|
+
if (!spec) return skip(`unsupported flag ${arg.split("=")[0]}`);
|
|
71
|
+
const [names, target, takesValue, multiple] = spec;
|
|
72
|
+
const key = names.split(" ")[0];
|
|
73
|
+
if (seen.has(key) && !multiple) return skip(`duplicate flag ${arg.split("=")[0]}`);
|
|
74
|
+
let values = [];
|
|
75
|
+
if (takesValue) {
|
|
76
|
+
const value = match[3] ?? args[++i];
|
|
77
|
+
if (value === undefined || value === "") return skip(`missing value for ${arg}`);
|
|
78
|
+
if (value.startsWith("-")) return skip(`unsupported value for ${arg}`);
|
|
79
|
+
values.push(value);
|
|
80
|
+
if (multiple && match[3] === undefined) {
|
|
81
|
+
while (args[i + 1] !== undefined && !args[i + 1].startsWith("-")) values.push(args[++i]);
|
|
82
|
+
}
|
|
83
|
+
} else if (match[3] !== undefined) return skip(`unsupported boolean value ${arg}`);
|
|
84
|
+
seen.set(key, values);
|
|
85
|
+
if (key === "r" && !["human", "json"].includes(values[0])) return skip(`unsupported result format ${values[0]}`);
|
|
86
|
+
if (key === "w" && (!Number.isFinite(Number(values[0])) || Number(values[0]) <= 0)) return skip("wait must be positive");
|
|
87
|
+
if (target) {
|
|
88
|
+
if (takesValue) for (const v of values) output.push(target, quote(v));
|
|
89
|
+
else output.push(target);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (mapping.required && !seen.has(mapping.required)) return skip(`missing required ${mapping.required} flag`);
|
|
93
|
+
if (mapping.sfd === "deploy" && !seen.has("d") && !seen.has("m")) return skip("deploy needs source-dir or metadata");
|
|
94
|
+
if (mapping.sfd === "retrieve" && ["d", "m", "x"].filter(key => seen.has(key)).length > 1) return skip("retrieve selectors are mutually exclusive");
|
|
95
|
+
if (mapping.sfd === "test" && !seen.has("w") && !seen.has("y")) return skip("asynchronous tests need --wait or --synchronous");
|
|
96
|
+
if (mapping.sfd === "rest") {
|
|
97
|
+
const method = seen.get("X")?.[0] ?? "GET";
|
|
98
|
+
if (!["GET", "POST", "PUT", "PATCH", "HEAD", "DELETE", "OPTIONS", "TRACE"].includes(method)) return skip("unsupported REST method");
|
|
99
|
+
// sf ignores a body with GET; sfd infers POST from a body instead.
|
|
100
|
+
if (seen.has("b") && method === "GET") return skip("REST body with implicit or explicit GET is unsupported");
|
|
101
|
+
}
|
|
102
|
+
if (mapping.positional ? positionals.length !== 1 : positionals.length !== 0) return skip("unsupported positional arguments");
|
|
103
|
+
let logTarget;
|
|
104
|
+
if (mapping.sfd === "logs get") {
|
|
105
|
+
if (seen.has("i") && seen.has("n")) return skip("choose log-id or number");
|
|
106
|
+
if (seen.has("i")) logTarget = seen.get("i")[0];
|
|
107
|
+
else if (seen.get("n")?.[0] === "1") logTarget = "latest";
|
|
108
|
+
else return skip("log get needs log-id or --number 1");
|
|
109
|
+
}
|
|
110
|
+
const positional = logTarget ? [logTarget] : mapping.required ? seen.get(mapping.required) : positionals;
|
|
111
|
+
// Protect sfd's own option parser from values that could be interpreted as options.
|
|
112
|
+
if (positional.some(v => v.startsWith("-"))) return skip("unsupported positional value");
|
|
113
|
+
return { command: ["sfd", mapping.sfd, ...positional.map(quote), ...output].join(" "), apex: mapping.sfd === "apex" };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function rewrite(command) {
|
|
117
|
+
if (typeof command !== "string" || !command.trim()) return skip("empty command");
|
|
118
|
+
try {
|
|
119
|
+
return rewriteShell(command);
|
|
120
|
+
} catch (error) { return skip(error.message); }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function rewriteShell(command) {
|
|
124
|
+
// Only remove continuations outside single quotes; the lexer does the rest.
|
|
125
|
+
let source = "", single = false, double = false;
|
|
126
|
+
for (let i = 0; i < command.length; i++) {
|
|
127
|
+
const c = command[i];
|
|
128
|
+
if (c === "\n" && !single && !double) { source += command.slice(i); break; }
|
|
129
|
+
if (c === "\\" && !single) {
|
|
130
|
+
if (command[i + 1] === "\n") { i++; continue; }
|
|
131
|
+
source += c + (command[++i] ?? ""); continue;
|
|
132
|
+
}
|
|
133
|
+
if (c === "'" && !double) single = !single;
|
|
134
|
+
if (c === '"' && !single) double = !double;
|
|
135
|
+
source += c;
|
|
136
|
+
}
|
|
137
|
+
source = source.trimStart();
|
|
138
|
+
// Heredoc contents are opaque: preserve them byte for byte and require one literal delimiter.
|
|
139
|
+
const newline = source.indexOf("\n");
|
|
140
|
+
let body = "";
|
|
141
|
+
if (newline !== -1) { body = source.slice(newline); source = source.slice(0, newline); }
|
|
142
|
+
const tokens = lex(source);
|
|
143
|
+
let stderr = "";
|
|
144
|
+
while (["2>&1", "2>/dev/null"].includes(tokens.at(-1)?.op)) {
|
|
145
|
+
stderr = ` ${tokens.pop().op}${stderr}`;
|
|
146
|
+
}
|
|
147
|
+
let heredoc = "";
|
|
148
|
+
const hd = tokens.findIndex(t => t.op === "<<");
|
|
149
|
+
if (hd !== -1) {
|
|
150
|
+
const delimiter = tokens[hd + 1];
|
|
151
|
+
if (!delimiter || delimiter.op || hd + 2 !== tokens.length || !/^[A-Za-z_][\w]*$/.test(delimiter.value)) return skip("unsupported heredoc");
|
|
152
|
+
const contents = body.slice(1);
|
|
153
|
+
const lines = (contents.endsWith("\n") ? contents.slice(0, -1) : contents).split("\n");
|
|
154
|
+
if (lines.at(-1) !== delimiter.value || lines.slice(0, -1).includes(delimiter.value)) return skip("unsupported heredoc terminator");
|
|
155
|
+
heredoc = source.slice(tokens[hd].start, delimiter.end) + body;
|
|
156
|
+
tokens.splice(hd);
|
|
157
|
+
} else if (body.trim()) return skip("unsupported multiline command");
|
|
158
|
+
while (["2>&1", "2>/dev/null"].includes(tokens.at(-1)?.op)) {
|
|
159
|
+
stderr = ` ${tokens.pop().op}${stderr}`;
|
|
160
|
+
}
|
|
161
|
+
if (tokens.some(t => t.op && !["&&", "|"].includes(t.op))) return skip("output redirection or unsupported shell chain");
|
|
162
|
+
const chains = tokens.flatMap((t, i) => t.op === "&&" ? [i] : []);
|
|
163
|
+
let start = 0;
|
|
164
|
+
if (chains.length) {
|
|
165
|
+
if (chains.length !== 1 || chains[0] !== 2 || tokens[0].value !== "cd" || !tokens[1].value || tokens[1].value.startsWith("-")) return skip("unsupported && chain");
|
|
166
|
+
start = 3;
|
|
167
|
+
}
|
|
168
|
+
const pipes = tokens.flatMap((t, i) => t.op === "|" ? [i] : []);
|
|
169
|
+
if (pipes.length) {
|
|
170
|
+
if (pipes.some(i => i <= start || i === tokens.length - 1 || tokens[i - 1].op)) return skip("unsupported pipeline");
|
|
171
|
+
// Never rewrite sf whose stdout is consumed, even if a later segment is also sf.
|
|
172
|
+
for (const i of [start, ...pipes.map(p => p + 1)].slice(0, -1)) {
|
|
173
|
+
if (tokens[i]?.value === "sf" || tokens[i]?.value?.startsWith("SF_TARGET_ORG=")) return skip("sf output is piped");
|
|
174
|
+
}
|
|
175
|
+
start = pipes.at(-1) + 1;
|
|
176
|
+
}
|
|
177
|
+
if (tokens[start]?.value?.startsWith("SF_TARGET_ORG=")) start++;
|
|
178
|
+
if (tokens[start]?.value !== "sf") return skip("not a plain sf invocation (paths, env prefixes and sfdx are unsupported)");
|
|
179
|
+
if (tokens.slice(start).some(t => t.op)) return skip("unsupported shell operator");
|
|
180
|
+
const mapped = mapArgs(tokens.slice(start + 1).map(t => t.value));
|
|
181
|
+
if (mapped.skip) return mapped;
|
|
182
|
+
if ((pipes.length || heredoc) && !mapped.apex) return skip("stdin is supported only for apex run");
|
|
183
|
+
return { command: source.slice(0, tokens[start].start) + mapped.command + stderr + (heredoc ? ` ${heredoc}` : ""), reason: "sfd: warm daemon, ~0.3 s" };
|
|
184
|
+
}
|
package/src/server.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import net from "node:net";
|
|
2
2
|
import { chmodSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { isPipe } from "./paths.mjs";
|
|
3
4
|
|
|
4
5
|
// Newline-delimited JSON over a Unix socket: one request and one response per connection.
|
|
5
6
|
// Responses are {status: 0, result} | {status: 1, name, message} | {restart: true}.
|
|
@@ -40,7 +41,7 @@ export async function startServer({ socket, version, handlers, idleMs = 60 * 60_
|
|
|
40
41
|
draining = reason;
|
|
41
42
|
clearTimeout(idleTimer);
|
|
42
43
|
server.close();
|
|
43
|
-
try { unlinkSync(socket); } catch {}
|
|
44
|
+
if (!isPipe(socket)) try { unlinkSync(socket); } catch {}
|
|
44
45
|
maybeExit();
|
|
45
46
|
};
|
|
46
47
|
const armIdle = () => {
|
|
@@ -57,7 +58,7 @@ export async function startServer({ socket, version, handlers, idleMs = 60 * 60_
|
|
|
57
58
|
try {
|
|
58
59
|
return { status: 0, result: await handler(req) };
|
|
59
60
|
} catch (e) {
|
|
60
|
-
return { status: 1, name: e.name ?? "Error", message: e.message ?? String(e), errorCode: e.errorCode };
|
|
61
|
+
return { status: 1, name: e.name ?? "Error", message: e.message ?? String(e), errorCode: e.errorCode, ...(e.trace ? { trace: e.trace } : {}) };
|
|
61
62
|
}
|
|
62
63
|
}
|
|
63
64
|
|
|
@@ -90,10 +91,12 @@ export async function startServer({ socket, version, handlers, idleMs = 60 * 60_
|
|
|
90
91
|
} catch (e) {
|
|
91
92
|
if (e.code !== "EADDRINUSE") throw e;
|
|
92
93
|
if (await probe(socket)) throw new AlreadyRunning("sfd is already running");
|
|
94
|
+
// A named pipe disappears with its owner, so only a Unix socket file can be stale.
|
|
95
|
+
if (isPipe(socket)) throw e;
|
|
93
96
|
unlinkSync(socket);
|
|
94
97
|
await listenOnce(server, socket);
|
|
95
98
|
}
|
|
96
|
-
chmodSync(socket, 0o600);
|
|
99
|
+
if (!isPipe(socket)) chmodSync(socket, 0o600);
|
|
97
100
|
armIdle();
|
|
98
101
|
return { server, drain };
|
|
99
102
|
}
|
package/src/sf-root.mjs
CHANGED
|
@@ -5,17 +5,27 @@ import { createRequire } from "node:module";
|
|
|
5
5
|
|
|
6
6
|
// sfd loads @salesforce/core, SDR, apex-node and source-tracking from the installed sf CLI,
|
|
7
7
|
// so auth files, API versions and metadata registries always match what `sf` itself uses.
|
|
8
|
-
export function findSfRoot(env = process.env) {
|
|
8
|
+
export function findSfRoot(env = process.env, platform = process.platform) {
|
|
9
9
|
if (env.SFD_SF_ROOT) return env.SFD_SF_ROOT;
|
|
10
|
-
const bin =
|
|
10
|
+
const bin = whichSf(platform);
|
|
11
11
|
for (let dir = dirname(realpathSync(bin)); dirname(dir) !== dir; dir = dirname(dir)) {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
// npm installs on Windows put an sf.cmd shim next to node_modules instead of a symlink.
|
|
13
|
+
for (const candidate of [dir, join(dir, "node_modules", "@salesforce", "cli")]) {
|
|
14
|
+
try {
|
|
15
|
+
if (JSON.parse(readFileSync(join(candidate, "package.json"), "utf8")).name === "@salesforce/cli") return candidate;
|
|
16
|
+
} catch {}
|
|
17
|
+
}
|
|
15
18
|
}
|
|
16
19
|
throw new Error(`Could not locate the @salesforce/cli install behind ${bin}; set SFD_SF_ROOT.`);
|
|
17
20
|
}
|
|
18
21
|
|
|
22
|
+
function whichSf(platform) {
|
|
23
|
+
if (platform !== "win32") return execFileSync("which", ["sf"], { encoding: "utf8" }).trim();
|
|
24
|
+
// `where` lists every match in PATH order; prefer the cmd shim over the extensionless sh script.
|
|
25
|
+
const found = execFileSync("where", ["sf"], { encoding: "utf8" }).split(/\r?\n/).filter(Boolean);
|
|
26
|
+
return found.find(f => /\.(cmd|exe)$/i.test(f)) ?? found[0];
|
|
27
|
+
}
|
|
28
|
+
|
|
19
29
|
export function sfInfo(root) {
|
|
20
30
|
const file = join(root, "package.json");
|
|
21
31
|
return { root, version: JSON.parse(readFileSync(file, "utf8")).version, mtimeMs: statSync(file).mtimeMs };
|