@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 +36 -0
- package/README.md +125 -15
- package/bin/sfd.mjs +144 -23
- package/package.json +2 -2
- package/src/client.mjs +2 -2
- package/src/commands.mjs +132 -0
- package/src/daemon.mjs +4 -1
- package/src/discover/claude.mjs +25 -0
- package/src/discover/codex.mjs +28 -0
- package/src/discover/gemini.mjs +32 -0
- package/src/discover/index.mjs +181 -0
- package/src/discover/shared.mjs +79 -0
- package/src/discover/worker.mjs +17 -0
- package/src/format.mjs +168 -7
- package/src/handlers.mjs +354 -16
- package/src/paths.mjs +23 -5
- package/src/rewrite.mjs +22 -3
- package/src/server.mjs +6 -3
- package/src/sf-root.mjs +15 -5
- package/src/snippet.mjs +42 -0
- package/src/usage-error.mjs +3 -1
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
|
|
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
|
-
|
|
38
|
-
|
|
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,
|
|
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
|
|
79
|
-
`done` and any continuation URL supplied by Salesforce
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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>'`.
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
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);
|