@syntax-syllogism/sfd 0.2.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 +26 -0
- package/README.md +28 -10
- package/bin/sfd.mjs +63 -7
- 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/paths.mjs +23 -5
- package/src/rewrite.mjs +12 -1
- package/src/server.mjs +6 -3
- package/src/sf-root.mjs +15 -5
- package/src/usage-error.mjs +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,32 @@ 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
|
+
|
|
10
36
|
## [0.2.0] - 2026-10-22
|
|
11
37
|
|
|
12
38
|
### Added
|
package/README.md
CHANGED
|
@@ -29,13 +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` |
|
|
34
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) | |
|
|
35
39
|
| `sfd start \| stop \| status \| reload` | Daemon lifecycle; `reload` drops cached orgs and auth state | |
|
|
36
40
|
|
|
37
|
-
`-d`, `-m`, `-n`, `-t` and `-s` repeat or take comma-separated lists.
|
|
38
|
-
|
|
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`
|
|
39
43
|
the org resolves like `sf`: `SF_TARGET_ORG`, then the nearest
|
|
40
44
|
`.sf/config.json`, then the global config.
|
|
41
45
|
|
|
@@ -45,13 +49,15 @@ not be reached or started, or a request timed out. Unreadable input files are
|
|
|
45
49
|
usage errors and include the file path.
|
|
46
50
|
|
|
47
51
|
Use `sf` (with `--json` when scripting) for anything sfd does not cover (org creation, package
|
|
48
|
-
commands,
|
|
52
|
+
commands, data import).
|
|
49
53
|
|
|
50
54
|
## Daemon lifecycle
|
|
51
55
|
|
|
52
56
|
The first command starts the daemon detached; there is nothing to set up. It
|
|
53
57
|
listens on a Unix socket in `$XDG_RUNTIME_DIR/sfd-<uid>/` (or the system temp
|
|
54
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\`.
|
|
55
61
|
Access tokens stay in daemon memory and are never written to disk. The daemon
|
|
56
62
|
log is `sfd.log` in the same directory; `sfd status` prints its path.
|
|
57
63
|
|
|
@@ -77,9 +83,11 @@ it down even if its protocol differs.
|
|
|
77
83
|
|
|
78
84
|
- Queries fetch every page without a record cap. Compact results preserve
|
|
79
85
|
`done` and any continuation URL supplied by Salesforce.
|
|
80
|
-
- Deploys
|
|
81
|
-
|
|
82
|
-
|
|
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).
|
|
83
91
|
- `deploy` without `-l` uses the Metadata API default. `-t` alone implies
|
|
84
92
|
`RunSpecifiedTests`.
|
|
85
93
|
- `test` without selectors runs `RunLocalTests`. A non-matching method is
|
|
@@ -87,13 +95,23 @@ it down even if its protocol differs.
|
|
|
87
95
|
- Paths in `-d` and the project directory resolve from the client's working
|
|
88
96
|
directory, not the daemon's.
|
|
89
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
|
+
|
|
90
108
|
## Environment
|
|
91
109
|
|
|
92
110
|
| Variable | Purpose |
|
|
93
111
|
| --- | --- |
|
|
94
|
-
| `SFD_TIMEOUT_SECONDS` | Positive request deadline in seconds, including connection time. Overrides defaults: 3 seconds for health/lifecycle requests; 120 seconds for other operations, extended to at least the deploy/test `-w` wait plus 60 seconds (default waits: 33/60 minutes). Increase this for long queries or queued deploys. Timeout exits 3. |
|
|
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. |
|
|
95
113
|
| `SFD_IDLE_MINUTES` | Idle timeout, default 60. |
|
|
96
|
-
| `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). |
|
|
97
115
|
| `SFD_SF_ROOT` | `@salesforce/cli` install to load libraries from, instead of the `sf` on `PATH`. |
|
|
98
116
|
| `SFD_HANDLERS_MODULE` | Test seam: stub handlers module used by the CLI tests. |
|
|
99
117
|
|
|
@@ -103,7 +121,7 @@ it down even if its protocol differs.
|
|
|
103
121
|
protocol (dispatch, version restart, drain, idle, stale socket), and the CLI
|
|
104
122
|
end to end against stub handlers, and the agent hook adapters and `sf` rewrite
|
|
105
123
|
rules. Injected Salesforce modules cover handler
|
|
106
|
-
paging, auth refresh, deployment and test orchestration without an org.
|
|
124
|
+
paging, auth refresh, deployment, retrieve, source tracking and test orchestration without an org.
|
|
107
125
|
A real-org check is opt-in:
|
|
108
126
|
|
|
109
127
|
```bash
|
package/bin/sfd.mjs
CHANGED
|
@@ -4,7 +4,7 @@ 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
9
|
import { UsageError } from "../src/usage-error.mjs";
|
|
10
10
|
|
|
@@ -19,8 +19,13 @@ Commands:
|
|
|
19
19
|
describe <sobject> Compact describe. --tooling, --raw for the full describe
|
|
20
20
|
deploy Deploy source. -d <path> (repeatable), -m <Type[:Name]> (repeatable),
|
|
21
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)
|
|
22
25
|
test Run Apex tests. -n <class> (repeatable), -t <Class.method> (repeatable),
|
|
23
|
-
-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
|
|
24
29
|
org Show the resolved org (username, id, instance, API version)
|
|
25
30
|
hook <agent|check|install|uninstall>
|
|
26
31
|
Integrate with Claude Code, Codex CLI or Gemini CLI
|
|
@@ -42,11 +47,22 @@ const options = {
|
|
|
42
47
|
all: { type: "boolean" },
|
|
43
48
|
raw: { type: "boolean" },
|
|
44
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" },
|
|
45
56
|
file: { type: "string", short: "f" },
|
|
46
57
|
method: { type: "string", short: "X" },
|
|
47
58
|
body: { type: "string" },
|
|
48
59
|
"source-dir": { type: "string", short: "d", multiple: true },
|
|
49
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" },
|
|
50
66
|
"check-only": { type: "boolean" },
|
|
51
67
|
"test-level": { type: "string", short: "l" },
|
|
52
68
|
tests: { type: "string", short: "t", multiple: true },
|
|
@@ -92,7 +108,7 @@ async function run(argv) {
|
|
|
92
108
|
return runHook(argv.slice(1));
|
|
93
109
|
}
|
|
94
110
|
const { values, positionals } = parseArgs({ args: argv, options, allowPositionals: true });
|
|
95
|
-
|
|
111
|
+
let [command, ...rest] = positionals;
|
|
96
112
|
if (values.help || !command) { print(USAGE); return 0; }
|
|
97
113
|
|
|
98
114
|
const cwd = process.cwd();
|
|
@@ -172,10 +188,46 @@ async function run(argv) {
|
|
|
172
188
|
render = result => formatDeploy(result, req.org);
|
|
173
189
|
break;
|
|
174
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
|
+
}
|
|
175
227
|
case "test":
|
|
176
228
|
req = {
|
|
177
229
|
org: org(), classNames: split(values["class-names"]), tests: split(values.tests), suiteNames: split(values["suite-names"]),
|
|
178
|
-
testLevel: values["test-level"], codeCoverage: values["code-coverage"], waitMinutes: wait(values)
|
|
230
|
+
logs: values.logs, noTrace: values["no-trace"], testLevel: values["test-level"], codeCoverage: values["code-coverage"], waitMinutes: wait(values)
|
|
179
231
|
};
|
|
180
232
|
render = result => formatTests(result, req.org);
|
|
181
233
|
break;
|
|
@@ -187,10 +239,14 @@ async function run(argv) {
|
|
|
187
239
|
}
|
|
188
240
|
|
|
189
241
|
const res = await call(command, req);
|
|
190
|
-
|
|
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; }
|
|
191
247
|
if (res.status !== 0) {
|
|
192
248
|
process.stderr.write(`${res.name}: ${res.message}\n`);
|
|
193
|
-
return 1;
|
|
249
|
+
return res.name === "UsageError" ? 2 : 1;
|
|
194
250
|
}
|
|
195
251
|
print(render(res.result));
|
|
196
252
|
return okStatus(command, res.result);
|
|
@@ -199,7 +255,7 @@ async function run(argv) {
|
|
|
199
255
|
// A request can succeed while the Salesforce operation fails (compile error, failed deploy or test).
|
|
200
256
|
function okStatus(command, result) {
|
|
201
257
|
if (command === "apex") return result.compiled && result.success ? 0 : 1;
|
|
202
|
-
if (command === "deploy") return result.success ? 0 : 1;
|
|
258
|
+
if (command === "deploy" || command === "retrieve") return result.success ? 0 : 1;
|
|
203
259
|
if (command === "test") return result.failing === 0 && !result.setupFailures?.length ? 0 : 1;
|
|
204
260
|
return 0;
|
|
205
261
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syntax-syllogism/sfd",
|
|
3
|
-
"version": "0.
|
|
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/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
CHANGED
|
@@ -44,9 +44,12 @@ const wait = ["w wait", "-w", true];
|
|
|
44
44
|
const level = ["l test-level", "-l", true];
|
|
45
45
|
const tests = ["t tests", "-t", true, true];
|
|
46
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]] },
|
|
47
49
|
{ sf: "data query", sfd: "query", flags: [org, json, tooling, ["q query", "", true], ["all-rows", "--all", false], format], required: "q" },
|
|
48
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]] },
|
|
49
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]] },
|
|
50
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]] },
|
|
51
54
|
{ sf: "sobject describe", sfd: "describe", flags: [org, json, tooling, ["s sobject", "", true]], required: "s" },
|
|
52
55
|
{ sf: "api request rest", sfd: "rest", flags: [org, ["X method", "-X", true], ["b body", "--body", true]], positional: true },
|
|
@@ -88,6 +91,7 @@ function mapArgs(argv) {
|
|
|
88
91
|
}
|
|
89
92
|
if (mapping.required && !seen.has(mapping.required)) return skip(`missing required ${mapping.required} flag`);
|
|
90
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");
|
|
91
95
|
if (mapping.sfd === "test" && !seen.has("w") && !seen.has("y")) return skip("asynchronous tests need --wait or --synchronous");
|
|
92
96
|
if (mapping.sfd === "rest") {
|
|
93
97
|
const method = seen.get("X")?.[0] ?? "GET";
|
|
@@ -96,7 +100,14 @@ function mapArgs(argv) {
|
|
|
96
100
|
if (seen.has("b") && method === "GET") return skip("REST body with implicit or explicit GET is unsupported");
|
|
97
101
|
}
|
|
98
102
|
if (mapping.positional ? positionals.length !== 1 : positionals.length !== 0) return skip("unsupported positional arguments");
|
|
99
|
-
|
|
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;
|
|
100
111
|
// Protect sfd's own option parser from values that could be interpreted as options.
|
|
101
112
|
if (positional.some(v => v.startsWith("-"))) return skip("unsupported positional value");
|
|
102
113
|
return { command: ["sfd", mapping.sfd, ...positional.map(quote), ...output].join(" "), apex: mapping.sfd === "apex" };
|
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 };
|
package/src/usage-error.mjs
CHANGED