@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 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 test` | Run Apex tests. `-n <class>`, `-t <Class.method>`, `-s <suite>`, `-l <level>`, `-c` (coverage), `-w <minutes>` | `sf apex run test --wait` |
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. Every
38
- command takes `-o <alias>` and `--json` (the raw daemon response). Without `-o`
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, retrieve, data import).
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 reload `sfdx-project.json` on every call and run one at a time per
81
- daemon. After a successful deploy to an org that tracks source, sfd updates
82
- source tracking as `sf project deploy start` does.
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
- const [command, ...rest] = positionals;
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
- if (values.json) { print(res); return res.status === 0 ? okStatus(command, res.result) : 1; }
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.2.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": "for f in bin/sfd.mjs src/*.mjs; do node --check \"$f\" || exit 1; done",
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 ?? (op === "deploy" ? 33 : op === "test" ? 60 : 0);
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
- const { createHandlers } = await import(process.env.SFD_HANDLERS_MODULE ?? "./handlers.mjs");
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
- // Keep only USER_DEBUG and fatal lines; the rest of a debug log is noise for agents.
58
- for (const line of log.split("\n")) {
59
- const m = line.match(/\|USER_DEBUG\|\[(\d+)\]\|\w+\|(.*)$/);
60
- if (m) lines.push(`[${m[1]}] ${m[2]}`);
61
- else if (/\|FATAL_ERROR\|/.test(line)) lines.push(line.replace(/^.*\|FATAL_ERROR\|/, "FATAL: "));
62
- }
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
- test: ({ org, classNames, tests, suiteNames, testLevel, codeCoverage, waitMinutes = 60 }) => withConn(org, async conn => {
139
- const { TestService } = apex();
140
- const service = new TestService(conn);
141
- const level = testLevel ?? (classNames?.length || tests?.length || suiteNames?.length ? "RunSpecifiedTests" : "RunLocalTests");
142
- // apex-node takes comma-separated strings, as the sf flags do.
143
- const csv = list => (list?.length ? list.join(",") : undefined);
144
- const payload = await service.buildAsyncPayload(level, csv(tests), csv(classNames), csv(suiteNames));
145
- const result = await service.runTestAsynchronous(payload, Boolean(codeCoverage), false, undefined, undefined, Duration.minutes(waitMinutes));
146
- return summarizeTests(result);
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
- export function paths(env = process.env) {
14
- const dir = runtimeDir(env);
15
- return { dir, socket: join(dir, "sfd.sock"), log: join(dir, "sfd.log"), pid: join(dir, "sfd.pid") };
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
- const positional = mapping.required ? seen.get(mapping.required) : positionals;
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 = execFileSync("which", ["sf"], { encoding: "utf8" }).trim();
10
+ const bin = whichSf(platform);
11
11
  for (let dir = dirname(realpathSync(bin)); dirname(dir) !== dir; dir = dirname(dir)) {
12
- try {
13
- if (JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).name === "@salesforce/cli") return dir;
14
- } catch {}
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 };
@@ -1 +1,3 @@
1
- export class UsageError extends Error {}
1
+ export class UsageError extends Error {
2
+ name = "UsageError";
3
+ }