@syntax-syllogism/sfd 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,42 @@ 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.4.0] - 2026-10-22
18
+
19
+ ### Added
20
+
21
+ - `--limit` and `--csv` options for `sfd query`, with CSV output
22
+ - `sfd agents-snippet` command that generates a snippet for AGENTS.md and CLAUDE.md
23
+ - `sfd org list` command that lists locally authenticated orgs, with optional status checks
24
+ - `sfd list-metadata-types` and `sfd list-metadata` commands for browsing org metadata
25
+ - `sfd discover` command that reports missed opportunities to use `sfd` in place of `sf`
26
+
27
+ ## [0.3.1] - 2026-10-22
28
+
29
+ ### Changed
30
+
31
+ - Internal maintenance and tooling updates
32
+
33
+ ## [0.3.0] - 2026-10-22
34
+
35
+ ### Added
36
+
37
+ - Added `sfd retrieve` for pulling metadata from an org into the local project
38
+ - Added tracking commands for inspecting changes between local source and the org
39
+ - Added filtered debug log output
40
+ - Added automatic TraceFlag creation when debug logs are requested
41
+
42
+ ### Fixed
43
+
44
+ - Fixed a startup failure on Windows (`process.getuid is not a function`)
45
+
10
46
  ## [0.2.0] - 2026-10-22
11
47
 
12
48
  ### Added
package/README.md CHANGED
@@ -24,34 +24,91 @@ sfd query "SELECT Id, Name FROM Account LIMIT 5" -o my-org
24
24
 
25
25
  | Command | Does | `sf` equivalent |
26
26
  | --- | --- | --- |
27
- | `sfd query "<soql>"` | SOQL, all pages, `attributes` stripped. `--tooling`, `--all`, `--raw` for the unstripped result | `sf data query` |
27
+ | `sfd query "<soql>"` | SOQL, all pages by default, `attributes` stripped. `--limit <n>` caps rows; `--csv` prints CSV. `--tooling`, `--all`, `--raw` for the unstripped result | `sf data query` |
28
28
  | `sfd apex [file]` | Anonymous Apex from a file, `-f`, or stdin. Prints `USER_DEBUG` and fatal lines; `--log` for the full log | `sf apex run` |
29
29
  | `sfd rest <path>` | REST call. `-X <method>`, `--body <json\|@file>` | `sf api request rest` |
30
+ | `sfd list-metadata-types` | Supported metadata types, folder markers and child types; cached per org and API version | `sf org list metadata-types` |
31
+ | `sfd list-metadata -m <Type>[,<Type>…]` | Live member names; `--details` for audit fields, `--folder <name>` for folder contents | `sf org list metadata` |
30
32
  | `sfd describe <sobject>` | Compact describe; `--raw` for the full one. `--tooling` | `sf sobject describe` |
31
33
  | `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` |
34
+ | `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` |
35
+ | `sfd tracking [--local \| --remote] [--conflicts]` | List local, remote and conflicting source-tracked changes; `--conflicts` shows only conflicts | |
36
+ | `sfd test` | Run Apex tests. `-n <class>`, `-t <Class.method>`, `-s <suite>`, `-l <level>`, `-c` (coverage), `-w <minutes>`, `--logs` | `sf apex run test --wait` |
37
+ | `sfd logs [--count 10] [--user <username>]` | Recent debug logs, one line each; `--limit` aliases `--count` | `sf apex list log` |
38
+ | `sfd logs get <id\|latest>` | Debug, exception, validation, Flow error and high limit usage lines. `--full` prints raw text | `sf apex get log` |
39
+ | `sfd org list [--all] [--status]` | Local authenticated orgs; `--all` includes expired scratch orgs, `--status` adds live checks | `sf org list --skip-connection-status` |
33
40
  | `sfd org` | Resolved username, org id, instance URL and API version | `sf org display` |
34
41
  | `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) | |
42
+ | `sfd agents-snippet [--write [file]]` | Print agent instructions, or write them into `AGENTS.md` or `CLAUDE.md`. See [Using sfd with coding agents](#using-sfd-with-coding-agents) | |
43
+ | `sfd discover [--since 30d]` | Report missed sfd opportunities and adoption from local agent transcripts. See [docs/discover.md](docs/discover.md) | |
35
44
  | `sfd start \| stop \| status \| reload` | Daemon lifecycle; `reload` drops cached orgs and auth state | |
36
45
 
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`
46
+ For agents, `--csv --limit 200` is the cheapest way to read rows. The limit,
47
+ CSV layout, output by flag and truncation trailer are described in
48
+ [docs/query.md](docs/query.md).
49
+
50
+ `-d`, `-m`, `-n`, `-t` and `-s` repeat or take comma-separated lists. The org
51
+ commands take `-o <alias>` (except `org list`), and `--json` prints the raw daemon response. Without `-o`
39
52
  the org resolves like `sf`: `SF_TARGET_ORG`, then the nearest
40
53
  `.sf/config.json`, then the global config.
41
54
 
55
+ `sfd list-metadata-types` sorts supported types, marks folder-based types with
56
+ `(folder)` and shows up to six child names. Its daemon cache is scoped to the org
57
+ and connection API version, and cleared by `sfd reload`, auth changes or session
58
+ reconnection. `--json` includes `result.cached` and `result.apiVersion`.
59
+
60
+ `sfd list-metadata -m Flow,Layout` lists sorted names grouped by requested type.
61
+ Repeat `-m` to add types. `--details` adds the modification date, modifying user,
62
+ namespace and manageable state, omitting empty fields. Member lists are always
63
+ live; empty groups print `(none)`. Unknown types exit 2 with a suggestion when
64
+ available. Listings have the coverage and limits of the Metadata API
65
+ `listMetadata` operation; they do not enumerate special cases separately.
66
+
67
+ For folder-based types, omitting `--folder` lists folders with a hint. Choose a
68
+ folder to list its members; `--folder` accepts exactly one metadata type:
69
+
70
+ ```bash
71
+ sfd list-metadata -m Report
72
+ sfd list-metadata -m Report --folder Public_Reports
73
+ sfd list-metadata -m ApexClass --details
74
+ ```
75
+
76
+ `sfd org list` reads local auth without network calls. It shows aliases, usernames,
77
+ `*` for the default org, `H` for the default Dev Hub, org types and scratch expiry.
78
+ Expired scratch orgs are hidden with a count hint; use `--all` to include them.
79
+ `--status` checks non-scratch connections in parallel with a 10-second deadline
80
+ per org, and queries scratch status once per Dev Hub (also bounded to 10 seconds).
81
+ Unavailable Dev Hubs leave scratch status blank. New logins and logouts are picked
82
+ up automatically. This command rejects `-o`.
83
+
84
+ ```bash
85
+ sfd org list
86
+ sfd org list --all --status
87
+ sfd org list --json
88
+ ```
89
+
90
+ For `org list`, `--json` returns `{ "orgs": [...] }` directly, with alias,
91
+ username, orgId, instanceUrl, type, isDefault, isDefaultDevHub, and optional
92
+ expirationDate, expired and status. The command never includes access tokens,
93
+ refresh tokens or client secrets in any output, including `--json`. Primary-user
94
+ filtering, status checks and the JSON allowlist are described in
95
+ [docs/org-list.md](docs/org-list.md).
96
+
42
97
  Exit codes: `0` success, `1` the operation failed (including a compile error,
43
98
  a failed deploy, or a failing test), `2` usage error, `3` the daemon could
44
99
  not be reached or started, or a request timed out. Unreadable input files are
45
100
  usage errors and include the file path.
46
101
 
47
102
  Use `sf` (with `--json` when scripting) for anything sfd does not cover (org creation, package
48
- commands, retrieve, data import).
103
+ commands, data import).
49
104
 
50
105
  ## Daemon lifecycle
51
106
 
52
107
  The first command starts the daemon detached; there is nothing to set up. It
53
108
  listens on a Unix socket in `$XDG_RUNTIME_DIR/sfd-<uid>/` (or the system temp
54
109
  directory), in a directory with mode `0700` and a socket with mode `0600`.
110
+ On Windows it listens on a per-user named pipe (`\\.\pipe\sfd-<hash>`), and
111
+ the log and pid files go in `%LOCALAPPDATA%\sfd\`.
55
112
  Access tokens stay in daemon memory and are never written to disk. The daemon
56
113
  log is `sfd.log` in the same directory; `sfd status` prints its path.
57
114
 
@@ -75,11 +132,14 @@ it down even if its protocol differs.
75
132
 
76
133
  ## Behavior notes
77
134
 
78
- - Queries fetch every page without a record cap. Compact results preserve
79
- `done` and any continuation URL supplied by Salesforce.
80
- - Deploys reload `sfdx-project.json` on every call and run one at a time per
81
- daemon. After a successful deploy to an org that tracks source, sfd updates
82
- source tracking as `sf project deploy start` does.
135
+ - Queries fetch every page unless `--limit` is given. Compact results preserve
136
+ `done` and any continuation URL supplied by Salesforce; see
137
+ [docs/query.md](docs/query.md) for the limit rules.
138
+ - Deploys, retrieves and `tracking` share one queue per daemon, so they run
139
+ one at a time. Deploys reload `sfdx-project.json` on every call.
140
+ - Retrieve conflicts, changes-only retrieves, and how `tracking` and deploys
141
+ update source tracking are described in
142
+ [docs/source-tracking.md](docs/source-tracking.md).
83
143
  - `deploy` without `-l` uses the Metadata API default. `-t` alone implies
84
144
  `RunSpecifiedTests`.
85
145
  - `test` without selectors runs `RunLocalTests`. A non-matching method is
@@ -87,13 +147,23 @@ it down even if its protocol differs.
87
147
  - Paths in `-d` and the project directory resolve from the client's working
88
148
  directory, not the daemon's.
89
149
 
150
+ ### Debug logs
151
+
152
+ `logs`, `logs get` and `test --logs` create or extend the current user's
153
+ developer TraceFlag for 30 minutes when it is missing, expired or has less than
154
+ five minutes left, and print one notice on stderr when they do. `--no-trace`
155
+ skips that setup. `logs --user` only filters the list. Setup needs Tooling API
156
+ permission to manage TraceFlag and DebugLevel records. The TraceFlag rules,
157
+ filtered output format and `test --logs` behavior are in
158
+ [docs/debug-logs.md](docs/debug-logs.md).
159
+
90
160
  ## Environment
91
161
 
92
162
  | Variable | Purpose |
93
163
  | --- | --- |
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. |
164
+ | `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
165
  | `SFD_IDLE_MINUTES` | Idle timeout, default 60. |
96
- | `SFD_RUNTIME_DIR` | Socket, log and pid directory. |
166
+ | `SFD_RUNTIME_DIR` | Socket, log and pid directory (on Windows, log and pid only; the pipe name is derived from it). |
97
167
  | `SFD_SF_ROOT` | `@salesforce/cli` install to load libraries from, instead of the `sf` on `PATH`. |
98
168
  | `SFD_HANDLERS_MODULE` | Test seam: stub handlers module used by the CLI tests. |
99
169
 
@@ -103,7 +173,7 @@ it down even if its protocol differs.
103
173
  protocol (dispatch, version restart, drain, idle, stale socket), and the CLI
104
174
  end to end against stub handlers, and the agent hook adapters and `sf` rewrite
105
175
  rules. Injected Salesforce modules cover handler
106
- paging, auth refresh, deployment and test orchestration without an org.
176
+ paging, auth refresh, deployment, retrieve, source tracking and test orchestration without an org.
107
177
  A real-org check is opt-in:
108
178
 
109
179
  ```bash
@@ -140,6 +210,46 @@ sfd hook install --agent gemini
140
210
  ```
141
211
 
142
212
  Add `--project` to install into the current project instead. Preview a rewrite
143
- with `sfd hook check '<sf command>'`. The supported commands, adapter details,
144
- skip rules and the `SFD_HOOK_DISABLE=1` switch are in
213
+ with `sfd hook check '<sf command>'`. Query result format `-r csv` (or
214
+ `--result-format csv`) maps to `sfd query --csv`. The supported commands,
215
+ adapter details, skip rules and the `SFD_HOOK_DISABLE=1` switch are in
145
216
  [docs/agent-hooks.md](docs/agent-hooks.md).
217
+
218
+ ## Using sfd with coding agents
219
+
220
+ `sfd discover` scans local Claude Code, Codex CLI and Gemini CLI transcripts for
221
+ shell tool calls using `sf`, legacy `sfdx`, or `sfd`. It uses the hook's rewrite
222
+ engine to report rewritable calls, skip reasons, unsupported commands and adoption.
223
+ It reads local files only, without an org, daemon, network calls or writes.
224
+
225
+ ```bash
226
+ sfd discover
227
+ sfd discover --since 12h --agent claude --agent codex --project /path/to/project
228
+ sfd discover --top 5 --json
229
+ ```
230
+
231
+ `--since` takes days (`d`) or hours (`h`), default `30d`. `--agent` is repeatable,
232
+ and all three agents are scanned by default. Time and output-token savings are
233
+ estimates from observed timings and output sizes; they are never extrapolated.
234
+ The stores read, how each call is classified, the JSON shape and the limits are
235
+ described in [docs/discover.md](docs/discover.md).
236
+
237
+ The agent reference includes metadata type discovery and member listing alongside
238
+ query, source and log operations. Print a compact command reference with
239
+ `sfd agents-snippet`, or add it to your project's agent instructions:
240
+
241
+ ```bash
242
+ sfd agents-snippet --write
243
+ sfd agents-snippet --write docs/CLAUDE.md
244
+ ```
245
+
246
+ `--write` uses the first existing `AGENTS.md` or `CLAUDE.md` in the Salesforce
247
+ project root (or the current directory outside a project), creating `AGENTS.md`
248
+ if neither exists. Explicit paths resolve from the current directory. Only the
249
+ block between `<!-- sfd:start -->` and `<!-- sfd:end -->` is replaced on later
250
+ runs; other instructions are preserved. This command needs no org or daemon.
251
+ The block's contents and marker rules are described in
252
+ [docs/agents-snippet.md](docs/agents-snippet.md).
253
+
254
+ For automatic command rewriting, install a hook with
255
+ `sfd hook install --agent <agent>` (see [Agent integration](#agent-integration)).
package/bin/sfd.mjs CHANGED
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, existsSync } from "node:fs";
2
+ import { readFileSync, existsSync, writeFileSync } from "node:fs";
3
3
  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 { formatMetadataTypes, formatMetadataList, formatOrgList, formatApex, formatLog, formatLogList, formatDeploy, formatTests, formatRetrieve, formatTracking, toCsv } from "../src/format.mjs";
8
8
  import { paths } from "../src/paths.mjs";
9
+ import { commandHelp } from "../src/commands.mjs";
10
+ import { snippet, upsertSnippet } from "../src/snippet.mjs";
9
11
  import { UsageError } from "../src/usage-error.mjs";
10
12
 
11
13
  const USAGE = `sfd ${VERSION}: fast Salesforce CLI operations through a warm local daemon
@@ -13,19 +15,7 @@ const USAGE = `sfd ${VERSION}: fast Salesforce CLI operations through a warm loc
13
15
  Usage: sfd <command> [options]
14
16
 
15
17
  Commands:
16
- query <soql> Run SOQL (all pages). --tooling, --all (include deleted)
17
- apex [file] Run anonymous Apex from a file, -f <file>, or stdin. --log for the full debug log
18
- rest <path> Call a REST path, e.g. /services/data/v67.0/limits. -X <method>, --body <json|@file>
19
- describe <sobject> Compact describe. --tooling, --raw for the full describe
20
- deploy Deploy source. -d <path> (repeatable), -m <Type[:Name]> (repeatable),
21
- --check-only, -l <test level>, -t <test> (repeatable), -w <minutes>
22
- test Run Apex tests. -n <class> (repeatable), -t <Class.method> (repeatable),
23
- -s <suite> (repeatable), -l <test level>, -c (coverage), -w <minutes>
24
- org Show the resolved org (username, id, instance, API version)
25
- hook <agent|check|install|uninstall>
26
- Integrate with Claude Code, Codex CLI or Gemini CLI
27
- start | stop | status | reload
28
- Manage the daemon. reload drops cached orgs and auth state
18
+ ${commandHelp()}
29
19
 
30
20
  Common options:
31
21
  -o, --target-org <alias> Org alias or username (default: sf target-org config)
@@ -36,17 +26,33 @@ Exit codes: 0 success, 1 operation failed, 2 usage error, 3 daemon error.`;
36
26
 
37
27
  const options = {
38
28
  "target-org": { type: "string", short: "o" },
29
+ write: { type: "boolean" },
39
30
  json: { type: "boolean" },
40
31
  help: { type: "boolean", short: "h" },
32
+ folder: { type: "string" },
33
+ details: { type: "boolean" },
41
34
  tooling: { type: "boolean" },
35
+ status: { type: "boolean" },
42
36
  all: { type: "boolean" },
37
+ csv: { type: "boolean" },
43
38
  raw: { type: "boolean" },
44
39
  log: { type: "boolean" },
40
+ logs: { type: "boolean" },
41
+ full: { type: "boolean" },
42
+ "no-trace": { type: "boolean" },
43
+ count: { type: "string" },
44
+ limit: { type: "string" },
45
+ user: { type: "string" },
45
46
  file: { type: "string", short: "f" },
46
47
  method: { type: "string", short: "X" },
47
48
  body: { type: "string" },
48
49
  "source-dir": { type: "string", short: "d", multiple: true },
49
50
  metadata: { type: "string", short: "m", multiple: true },
51
+ manifest: { type: "string", short: "x" },
52
+ "ignore-conflicts": { type: "boolean" },
53
+ local: { type: "boolean" },
54
+ remote: { type: "boolean" },
55
+ conflicts: { type: "boolean" },
50
56
  "check-only": { type: "boolean" },
51
57
  "test-level": { type: "string", short: "l" },
52
58
  tests: { type: "string", short: "t", multiple: true },
@@ -82,6 +88,13 @@ function wait(values) {
82
88
  return minutes;
83
89
  }
84
90
 
91
+ function queryLimit(value) {
92
+ if (value === undefined) return undefined;
93
+ const limit = Number(value);
94
+ if (!Number.isSafeInteger(limit) || limit <= 0) throw new UsageError("--limit must be a positive integer.");
95
+ return limit;
96
+ }
97
+
85
98
  function print(value) {
86
99
  process.stdout.write((typeof value === "string" ? value : JSON.stringify(value, null, 2)) + "\n");
87
100
  }
@@ -91,8 +104,21 @@ async function run(argv) {
91
104
  const { runHook } = await import("../src/hooks.mjs");
92
105
  return runHook(argv.slice(1));
93
106
  }
107
+ if (argv[0] === "discover") {
108
+ const { values, positionals } = parseArgs({ args: argv.slice(1), options: {
109
+ since: { type: "string" }, agent: { type: "string", multiple: true },
110
+ project: { type: "string" }, top: { type: "string" }, json: { type: "boolean" },
111
+ help: { type: "boolean", short: "h" }
112
+ }, allowPositionals: true });
113
+ if (values.help) { print("sfd discover [--since 30d] [--agent claude|codex|gemini]… [--project <path>] [--top 10] [--json]"); return 0; }
114
+ if (positionals.length) throw new UsageError("discover does not accept positional arguments.");
115
+ const { discover, discoverOptions, formatReport } = await import("../src/discover/index.mjs");
116
+ const report = await discover(discoverOptions(values));
117
+ print(values.json ? report : formatReport(report));
118
+ return 0;
119
+ }
94
120
  const { values, positionals } = parseArgs({ args: argv, options, allowPositionals: true });
95
- const [command, ...rest] = positionals;
121
+ let [command, ...rest] = positionals;
96
122
  if (values.help || !command) { print(USAGE); return 0; }
97
123
 
98
124
  const cwd = process.cwd();
@@ -103,6 +129,32 @@ async function run(argv) {
103
129
  return dir;
104
130
  };
105
131
 
132
+ if (command === "agents-snippet") {
133
+ if (rest.length > 1 || (rest.length && !values.write)) {
134
+ throw new UsageError("agents-snippet accepts --write [file].");
135
+ }
136
+ const block = snippet();
137
+ if (!values.write) { print(block); return 0; }
138
+ const root = findProjectDir(cwd) ?? cwd;
139
+ const file = rest[0] ?? ["AGENTS.md", "CLAUDE.md"].find(name => existsSync(resolve(root, name))) ?? "AGENTS.md";
140
+ const target = resolve(rest[0] ? cwd : root, file);
141
+ let original;
142
+ try { original = readFileSync(target, "utf8"); }
143
+ catch (e) {
144
+ if (e.code !== "ENOENT") throw new UsageError(`Cannot read target file ${target}: ${e.message}`);
145
+ }
146
+ let updated;
147
+ try { updated = upsertSnippet(original ?? "", block); }
148
+ catch (e) { throw new UsageError(`Malformed sfd block in ${file}: ${e.message}`); }
149
+ if (updated === original) print(`${file} already up to date`);
150
+ else {
151
+ try { writeFileSync(target, updated); }
152
+ catch (e) { throw new UsageError(`Cannot write target file ${target}: ${e.message}`); }
153
+ print(`${original === undefined ? "Created" : "Updated"} ${file}`);
154
+ }
155
+ return 0;
156
+ }
157
+
106
158
  // Lifecycle commands.
107
159
  switch (command) {
108
160
  case "start":
@@ -137,7 +189,9 @@ async function run(argv) {
137
189
  case "query": {
138
190
  const q = rest.join(" ") || readStdin().trim();
139
191
  if (!q) throw new UsageError("query needs a SOQL string.");
140
- req = { org: org(), q, tooling: values.tooling, all: values.all, raw: values.raw };
192
+ const limit = queryLimit(values.limit);
193
+ req = { org: org(), q, tooling: values.tooling, all: values.all, raw: values.raw, limit };
194
+ if (values.csv) render = result => toCsv(result.records);
141
195
  break;
142
196
  }
143
197
  case "apex": {
@@ -159,6 +213,20 @@ async function run(argv) {
159
213
  req = { org: org(), sobject: rest[0], tooling: values.tooling, raw: values.raw };
160
214
  break;
161
215
  }
216
+ case "list-metadata-types":
217
+ command = "listMetadataTypes";
218
+ req = { org: org() };
219
+ render = formatMetadataTypes;
220
+ break;
221
+ case "list-metadata": {
222
+ const types = [...new Set(split(values.metadata) ?? [])];
223
+ if (!types.length) throw new UsageError("list-metadata needs -m <Type>.");
224
+ if (values.folder !== undefined && types.length > 1) throw new UsageError("--folder requires exactly one metadata type.");
225
+ command = "listMetadata";
226
+ req = { org: org(), types, folder: values.folder };
227
+ render = result => formatMetadataList(result, { details: values.details });
228
+ break;
229
+ }
162
230
  case "deploy": {
163
231
  const dir = projectDir();
164
232
  const sourcePaths = split(values["source-dir"])?.map(d => resolve(cwd, d));
@@ -172,34 +240,87 @@ async function run(argv) {
172
240
  render = result => formatDeploy(result, req.org);
173
241
  break;
174
242
  }
243
+ case "retrieve": {
244
+ const dir = projectDir();
245
+ const sourcePaths = split(values["source-dir"])?.map(d => resolve(cwd, d));
246
+ const manifest = values.manifest ? resolve(cwd, values.manifest) : undefined;
247
+ for (const p of [...(sourcePaths ?? []), ...(manifest ? [manifest] : [])]) {
248
+ if (!existsSync(p)) throw new UsageError(`No such path: ${p}`);
249
+ }
250
+ const metadata = split(values.metadata);
251
+ if ([Boolean(sourcePaths?.length), Boolean(metadata?.length), Boolean(manifest)].filter(Boolean).length > 1) {
252
+ throw new UsageError("retrieve accepts only one of -d, -m or -x.");
253
+ }
254
+ req = { org: org(), projectDir: dir, sourcePaths, metadata, manifest,
255
+ ignoreConflicts: values["ignore-conflicts"], waitMinutes: wait(values) };
256
+ render = result => formatRetrieve(result, req.org);
257
+ break;
258
+ }
259
+ case "tracking":
260
+ if (values.local && values.remote) throw new UsageError("tracking accepts only one of --local or --remote.");
261
+ req = { org: org(), projectDir: projectDir(), local: values.local, remote: values.remote, conflicts: values.conflicts };
262
+ render = formatTracking;
263
+ break;
264
+ case "logs": {
265
+ if (rest[0] === "get") {
266
+ if (rest.length !== 2) throw new UsageError("logs get needs an ID or latest.");
267
+ command = "logGet";
268
+ req = { org: org(), id: rest[1], noTrace: values["no-trace"] };
269
+ render = result => values.full ? result.body : formatLog(result);
270
+ } else {
271
+ if (rest.length) throw new UsageError("logs accepts only get <id|latest>.");
272
+ const count = Number(values.count ?? values.limit ?? 10);
273
+ if (!Number.isSafeInteger(count) || count < 1) throw new UsageError("Log count must be a positive integer.");
274
+ req = { org: org(), count, user: values.user, noTrace: values["no-trace"] };
275
+ render = result => formatLogList(result.records);
276
+ }
277
+ break;
278
+ }
175
279
  case "test":
176
280
  req = {
177
281
  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)
282
+ logs: values.logs, noTrace: values["no-trace"], testLevel: values["test-level"], codeCoverage: values["code-coverage"], waitMinutes: wait(values)
179
283
  };
180
284
  render = result => formatTests(result, req.org);
181
285
  break;
182
286
  case "org":
183
- req = { org: org() };
287
+ if (rest[0] === "list") {
288
+ if (rest.length !== 1 || values["target-org"] !== undefined) throw new UsageError("org list accepts no target org or additional arguments.");
289
+ command = "orgList";
290
+ req = { all: values.all, status: values.status };
291
+ render = formatOrgList;
292
+ } else {
293
+ req = { org: org() };
294
+ }
184
295
  break;
185
296
  default:
186
297
  throw new UsageError(`Unknown command: ${command}. Run sfd --help.`);
187
298
  }
188
299
 
189
300
  const res = await call(command, req);
190
- if (values.json) { print(res); return res.status === 0 ? okStatus(command, res.result) : 1; }
301
+ const trace = res.result?.trace ?? res.trace;
302
+ if (trace) {
303
+ process.stderr.write(`sfd: enabled debug logging for ${trace.username} until ${trace.expires} (TraceFlag ${trace.id})\n`);
304
+ }
305
+ if (values.json) { print(command === "orgList" && res.status === 0 ? { orgs: res.result.orgs } : res); return res.status === 0 ? okStatus(command, res.result) : res.name === "UsageError" ? 2 : 1; }
191
306
  if (res.status !== 0) {
192
307
  process.stderr.write(`${res.name}: ${res.message}\n`);
193
- return 1;
308
+ return res.name === "UsageError" ? 2 : 1;
309
+ }
310
+ const output = render(res.result);
311
+ if (command !== "query" || !values.csv || output) print(output);
312
+ if (command === "query" && res.result.truncated) {
313
+ const { totalSize, records } = res.result;
314
+ const trailer = `… ${(totalSize - records.length).toLocaleString("en-US")} more rows not shown (totalSize ${totalSize.toLocaleString("en-US")}); raise --limit or narrow the query\n`;
315
+ (values.csv ? process.stderr : process.stdout).write(trailer);
194
316
  }
195
- print(render(res.result));
196
317
  return okStatus(command, res.result);
197
318
  }
198
319
 
199
320
  // A request can succeed while the Salesforce operation fails (compile error, failed deploy or test).
200
321
  function okStatus(command, result) {
201
322
  if (command === "apex") return result.compiled && result.success ? 0 : 1;
202
- if (command === "deploy") return result.success ? 0 : 1;
323
+ if (command === "deploy" || command === "retrieve") return result.success ? 0 : 1;
203
324
  if (command === "test") return result.failing === 0 && !result.setupFailures?.length ? 0 : 1;
204
325
  return 0;
205
326
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syntax-syllogism/sfd",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
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);