@syntax-syllogism/sfd 0.3.1 → 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 +10 -0
- package/README.md +98 -6
- package/bin/sfd.mjs +89 -24
- package/package.json +1 -1
- package/src/commands.mjs +132 -0
- 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 +59 -1
- package/src/handlers.mjs +180 -7
- package/src/rewrite.mjs +10 -2
- package/src/snippet.mjs +42 -0
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
14
14
|
named pipe, finds `sf` with `where` (including npm `sf.cmd` shims), and
|
|
15
15
|
starts without a console window. CI runs on Windows as well as Linux.
|
|
16
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
|
+
|
|
17
27
|
## [0.3.1] - 2026-10-22
|
|
18
28
|
|
|
19
29
|
### Changed
|
package/README.md
CHANGED
|
@@ -24,9 +24,11 @@ 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
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` |
|
|
@@ -34,15 +36,64 @@ sfd query "SELECT Id, Name FROM Account LIMIT 5" -o my-org
|
|
|
34
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` |
|
|
35
37
|
| `sfd logs [--count 10] [--user <username>]` | Recent debug logs, one line each; `--limit` aliases `--count` | `sf apex list log` |
|
|
36
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` |
|
|
37
40
|
| `sfd org` | Resolved username, org id, instance URL and API version | `sf org display` |
|
|
38
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) | |
|
|
39
44
|
| `sfd start \| stop \| status \| reload` | Daemon lifecycle; `reload` drops cached orgs and auth state | |
|
|
40
45
|
|
|
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
|
+
|
|
41
50
|
`-d`, `-m`, `-n`, `-t` and `-s` repeat or take comma-separated lists. The org
|
|
42
|
-
commands take `-o <alias
|
|
51
|
+
commands take `-o <alias>` (except `org list`), and `--json` prints the raw daemon response. Without `-o`
|
|
43
52
|
the org resolves like `sf`: `SF_TARGET_ORG`, then the nearest
|
|
44
53
|
`.sf/config.json`, then the global config.
|
|
45
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
|
+
|
|
46
97
|
Exit codes: `0` success, `1` the operation failed (including a compile error,
|
|
47
98
|
a failed deploy, or a failing test), `2` usage error, `3` the daemon could
|
|
48
99
|
not be reached or started, or a request timed out. Unreadable input files are
|
|
@@ -81,8 +132,9 @@ it down even if its protocol differs.
|
|
|
81
132
|
|
|
82
133
|
## Behavior notes
|
|
83
134
|
|
|
84
|
-
- Queries fetch every page
|
|
85
|
-
`done` and any continuation URL supplied by Salesforce
|
|
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.
|
|
86
138
|
- Deploys, retrieves and `tracking` share one queue per daemon, so they run
|
|
87
139
|
one at a time. Deploys reload `sfdx-project.json` on every call.
|
|
88
140
|
- Retrieve conflicts, changes-only retrieves, and how `tracking` and deploys
|
|
@@ -158,6 +210,46 @@ sfd hook install --agent gemini
|
|
|
158
210
|
```
|
|
159
211
|
|
|
160
212
|
Add `--project` to install into the current project instead. Preview a rewrite
|
|
161
|
-
with `sfd hook check '<sf command>'`.
|
|
162
|
-
|
|
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
|
|
163
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, formatLog, formatLogList, formatDeploy, formatTests, formatRetrieve, formatTracking } 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,24 +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
|
-
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)
|
|
25
|
-
test Run Apex tests. -n <class> (repeatable), -t <Class.method> (repeatable),
|
|
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
|
|
29
|
-
org Show the resolved org (username, id, instance, API version)
|
|
30
|
-
hook <agent|check|install|uninstall>
|
|
31
|
-
Integrate with Claude Code, Codex CLI or Gemini CLI
|
|
32
|
-
start | stop | status | reload
|
|
33
|
-
Manage the daemon. reload drops cached orgs and auth state
|
|
18
|
+
${commandHelp()}
|
|
34
19
|
|
|
35
20
|
Common options:
|
|
36
21
|
-o, --target-org <alias> Org alias or username (default: sf target-org config)
|
|
@@ -41,10 +26,15 @@ Exit codes: 0 success, 1 operation failed, 2 usage error, 3 daemon error.`;
|
|
|
41
26
|
|
|
42
27
|
const options = {
|
|
43
28
|
"target-org": { type: "string", short: "o" },
|
|
29
|
+
write: { type: "boolean" },
|
|
44
30
|
json: { type: "boolean" },
|
|
45
31
|
help: { type: "boolean", short: "h" },
|
|
32
|
+
folder: { type: "string" },
|
|
33
|
+
details: { type: "boolean" },
|
|
46
34
|
tooling: { type: "boolean" },
|
|
35
|
+
status: { type: "boolean" },
|
|
47
36
|
all: { type: "boolean" },
|
|
37
|
+
csv: { type: "boolean" },
|
|
48
38
|
raw: { type: "boolean" },
|
|
49
39
|
log: { type: "boolean" },
|
|
50
40
|
logs: { type: "boolean" },
|
|
@@ -98,6 +88,13 @@ function wait(values) {
|
|
|
98
88
|
return minutes;
|
|
99
89
|
}
|
|
100
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
|
+
|
|
101
98
|
function print(value) {
|
|
102
99
|
process.stdout.write((typeof value === "string" ? value : JSON.stringify(value, null, 2)) + "\n");
|
|
103
100
|
}
|
|
@@ -107,6 +104,19 @@ async function run(argv) {
|
|
|
107
104
|
const { runHook } = await import("../src/hooks.mjs");
|
|
108
105
|
return runHook(argv.slice(1));
|
|
109
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
|
+
}
|
|
110
120
|
const { values, positionals } = parseArgs({ args: argv, options, allowPositionals: true });
|
|
111
121
|
let [command, ...rest] = positionals;
|
|
112
122
|
if (values.help || !command) { print(USAGE); return 0; }
|
|
@@ -119,6 +129,32 @@ async function run(argv) {
|
|
|
119
129
|
return dir;
|
|
120
130
|
};
|
|
121
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
|
+
|
|
122
158
|
// Lifecycle commands.
|
|
123
159
|
switch (command) {
|
|
124
160
|
case "start":
|
|
@@ -153,7 +189,9 @@ async function run(argv) {
|
|
|
153
189
|
case "query": {
|
|
154
190
|
const q = rest.join(" ") || readStdin().trim();
|
|
155
191
|
if (!q) throw new UsageError("query needs a SOQL string.");
|
|
156
|
-
|
|
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);
|
|
157
195
|
break;
|
|
158
196
|
}
|
|
159
197
|
case "apex": {
|
|
@@ -175,6 +213,20 @@ async function run(argv) {
|
|
|
175
213
|
req = { org: org(), sobject: rest[0], tooling: values.tooling, raw: values.raw };
|
|
176
214
|
break;
|
|
177
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
|
+
}
|
|
178
230
|
case "deploy": {
|
|
179
231
|
const dir = projectDir();
|
|
180
232
|
const sourcePaths = split(values["source-dir"])?.map(d => resolve(cwd, d));
|
|
@@ -232,7 +284,14 @@ async function run(argv) {
|
|
|
232
284
|
render = result => formatTests(result, req.org);
|
|
233
285
|
break;
|
|
234
286
|
case "org":
|
|
235
|
-
|
|
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
|
+
}
|
|
236
295
|
break;
|
|
237
296
|
default:
|
|
238
297
|
throw new UsageError(`Unknown command: ${command}. Run sfd --help.`);
|
|
@@ -243,12 +302,18 @@ async function run(argv) {
|
|
|
243
302
|
if (trace) {
|
|
244
303
|
process.stderr.write(`sfd: enabled debug logging for ${trace.username} until ${trace.expires} (TraceFlag ${trace.id})\n`);
|
|
245
304
|
}
|
|
246
|
-
if (values.json) { print(res); return res.status === 0 ? okStatus(command, res.result) : res.name === "UsageError" ? 2 : 1; }
|
|
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; }
|
|
247
306
|
if (res.status !== 0) {
|
|
248
307
|
process.stderr.write(`${res.name}: ${res.message}\n`);
|
|
249
308
|
return res.name === "UsageError" ? 2 : 1;
|
|
250
309
|
}
|
|
251
|
-
|
|
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);
|
|
316
|
+
}
|
|
252
317
|
return okStatus(command, res.result);
|
|
253
318
|
}
|
|
254
319
|
|
package/package.json
CHANGED
package/src/commands.mjs
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
export const COMMANDS = [
|
|
2
|
+
{
|
|
3
|
+
"name": "query",
|
|
4
|
+
"args": "<soql>",
|
|
5
|
+
"summary": "Run SOQL (all pages). --tooling, --all (include deleted), --limit <n>, --csv",
|
|
6
|
+
"agentTip": "\"<soql>\" --csv --limit 200: SOQL; --tooling, --all"
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"name": "apex",
|
|
10
|
+
"args": "[file]",
|
|
11
|
+
"summary": "Run anonymous Apex from a file, -f <file>, or stdin. --log for the full debug log",
|
|
12
|
+
"agentTip": "-f <file> (or stdin): anonymous Apex; debug and fatal lines"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "rest",
|
|
16
|
+
"args": "<path>",
|
|
17
|
+
"summary": "Call a REST path, e.g. /services/data/v67.0/limits. -X <method>, --body <json|@file>",
|
|
18
|
+
"agentTip": "<path>: REST; -X <method>, --body <json|@file>"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"name": "describe",
|
|
22
|
+
"args": "<sobject>",
|
|
23
|
+
"summary": "Compact describe. --tooling, --raw for the full describe",
|
|
24
|
+
"agentTip": "<sobject>: compact describe; --tooling, --raw",
|
|
25
|
+
"agentGroup": "describe"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"name": "list-metadata-types",
|
|
29
|
+
"args": "",
|
|
30
|
+
"summary": "List supported metadata types (cached per org and API version)",
|
|
31
|
+
"agentTip": ": supported metadata types (cached)",
|
|
32
|
+
"agentGroup": "describe"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"name": "list-metadata",
|
|
36
|
+
"args": "-m <Type>[,<Type>…]",
|
|
37
|
+
"summary": "List metadata names. -m repeatable, --folder <name>, --details for audit fields",
|
|
38
|
+
"agentTip": "-m <Type>: metadata names; --folder <name>, --details",
|
|
39
|
+
"agentGroup": "describe"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"name": "deploy",
|
|
43
|
+
"args": "",
|
|
44
|
+
"summary": "Deploy source. -d <path> (repeatable), -m <Type[:Name]> (repeatable),\n--check-only, -l <test level>, -t <test> (repeatable), -w <minutes>",
|
|
45
|
+
"agentTip": "-d <path> | -m <Type:Name>: deploy source",
|
|
46
|
+
"agentGroup": "source"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"name": "retrieve",
|
|
50
|
+
"args": "",
|
|
51
|
+
"summary": "Retrieve source. -d <path>, -m <Type[:Name]> or -x <package.xml>,\n-w <minutes>, --ignore-conflicts. No selector pulls tracked remote changes",
|
|
52
|
+
"agentTip": "[-d <path> | -m <Type:Name> | -x <manifest>]: retrieve source",
|
|
53
|
+
"agentGroup": "source"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"name": "tracking",
|
|
57
|
+
"args": "",
|
|
58
|
+
"summary": "Show source-tracked changes. --local | --remote, --conflicts (conflicts only)",
|
|
59
|
+
"agentTip": "[--local | --remote] [--conflicts]: source changes"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"name": "test",
|
|
63
|
+
"args": "",
|
|
64
|
+
"summary": "Run Apex tests. -n <class> (repeatable), -t <Class.method> (repeatable),\n-s <suite> (repeatable), -l <test level>, -c (coverage), -w <minutes>, --logs, --no-trace",
|
|
65
|
+
"agentTip": "-n <class> | -t <Class.method>: Apex tests; --logs, -c"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"name": "logs",
|
|
69
|
+
"args": "",
|
|
70
|
+
"summary": "List recent debug logs. --count/--limit <10>, --user <username>, --no-trace",
|
|
71
|
+
"agentTip": "[--count 10]: recent debug logs",
|
|
72
|
+
"agentGroup": "logs"
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"name": "logs get",
|
|
76
|
+
"args": "<id|latest>",
|
|
77
|
+
"summary": "Print a filtered log. --full for raw text, --no-trace",
|
|
78
|
+
"agentTip": "latest: filtered debug log; --full for raw text",
|
|
79
|
+
"agentGroup": "logs"
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
"name": "org",
|
|
83
|
+
"args": "",
|
|
84
|
+
"summary": "Show the resolved org (username, id, instance, API version)",
|
|
85
|
+
"agentTip": ": resolved org details",
|
|
86
|
+
"agentGroup": "orgs"
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"name": "org list",
|
|
90
|
+
"args": "[--all] [--status]",
|
|
91
|
+
"summary": "List authenticated orgs (local; --status pings)",
|
|
92
|
+
"agentTip": "[--all] [--status]: list local orgs; optional live checks",
|
|
93
|
+
"agentGroup": "orgs"
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"name": "hook",
|
|
97
|
+
"args": "<agent|check|install|uninstall>",
|
|
98
|
+
"summary": "Integrate with Claude Code, Codex CLI or Gemini CLI",
|
|
99
|
+
"agentTip": "install --agent <claude|codex|gemini>: install rewrite hook",
|
|
100
|
+
"agentGroup": "agents"
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"name": "agents-snippet",
|
|
104
|
+
"args": "[--write [file]]",
|
|
105
|
+
"summary": "Print agent instructions, or update AGENTS.md / CLAUDE.md",
|
|
106
|
+
"agentTip": "[--write [file]]: print or update agent instructions",
|
|
107
|
+
"agentGroup": "agents"
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"name": "discover",
|
|
111
|
+
"args": "[--since 30d]",
|
|
112
|
+
"summary": "Report missed sfd opportunities in local agent transcripts",
|
|
113
|
+
"agentTip": "[--since 30d]: local transcript adoption report",
|
|
114
|
+
"agentGroup": "agents"
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
"name": "start | stop | status | reload",
|
|
118
|
+
"args": "",
|
|
119
|
+
"summary": "Manage the daemon. reload drops cached orgs and auth state",
|
|
120
|
+
"agentTip": ": daemon lifecycle"
|
|
121
|
+
}
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
export function commandHelp() {
|
|
125
|
+
return COMMANDS.map(({ name, args, summary }) => {
|
|
126
|
+
const syntax = [name, args].filter(Boolean).join(" ");
|
|
127
|
+
const description = summary.replaceAll("\n", "\n ");
|
|
128
|
+
return syntax.length >= 22
|
|
129
|
+
? ` ${syntax}\n ${description}`
|
|
130
|
+
: ` ${syntax.padEnd(22)}${description}`;
|
|
131
|
+
}).join("\n");
|
|
132
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { files, lines, matches, complete, candidate, count } from "./shared.mjs";
|
|
3
|
+
|
|
4
|
+
export async function* read(options) {
|
|
5
|
+
const { home, since = new Date(0), stats } = options;
|
|
6
|
+
for await (const file of files(join(home, ".claude/projects"), ".jsonl", since, stats)) {
|
|
7
|
+
const pending = new Map();
|
|
8
|
+
for await (const entry of lines(file, stats)) {
|
|
9
|
+
const content = entry?.message?.content;
|
|
10
|
+
if (!Array.isArray(content)) { count(stats, "ignoredEntries"); continue; }
|
|
11
|
+
for (const item of content) {
|
|
12
|
+
if (item?.type === "tool_use" && item.name === "Bash") {
|
|
13
|
+
const command = item.input?.command;
|
|
14
|
+
if (!candidate(command)) continue;
|
|
15
|
+
const record = { agent: "claude", ts: new Date(entry.timestamp ?? NaN), command, cwd: entry.cwd, session: file };
|
|
16
|
+
if (matches(record, options)) pending.set(item.id, record);
|
|
17
|
+
} else if (item?.type === "tool_result" && pending.has(item.tool_use_id)) {
|
|
18
|
+
yield complete(pending.get(item.tool_use_id), entry.timestamp, item.content);
|
|
19
|
+
pending.delete(item.tool_use_id);
|
|
20
|
+
} else count(stats, "ignoredEntries");
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
yield* pending.values();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { files, lines, matches, complete, candidate, count, shellCommand } from "./shared.mjs";
|
|
3
|
+
|
|
4
|
+
export async function* read(options) {
|
|
5
|
+
const { home, since = new Date(0), stats } = options;
|
|
6
|
+
for await (const file of files(join(home, ".codex/sessions"), ".jsonl", since, stats)) {
|
|
7
|
+
const pending = new Map();
|
|
8
|
+
let cwd;
|
|
9
|
+
for await (const entry of lines(file, stats)) {
|
|
10
|
+
const payload = entry?.payload;
|
|
11
|
+
if (["session_meta", "turn_context"].includes(entry?.type)) { cwd = payload?.cwd ?? cwd; continue; }
|
|
12
|
+
if (entry?.type !== "response_item" || !payload) { count(stats, "ignoredEntries"); continue; }
|
|
13
|
+
if (payload.type === "function_call" && ["shell", "shell_command", "exec_command"].includes(payload.name)) {
|
|
14
|
+
let args;
|
|
15
|
+
try { args = JSON.parse(payload.arguments); }
|
|
16
|
+
catch { count(stats, "malformedEntries"); continue; }
|
|
17
|
+
const command = shellCommand(args);
|
|
18
|
+
if (!candidate(command)) continue;
|
|
19
|
+
const record = { agent: "codex", ts: new Date(entry.timestamp ?? NaN), command, cwd: args.workdir ?? cwd, session: file };
|
|
20
|
+
if (matches(record, options)) pending.set(payload.call_id, record);
|
|
21
|
+
} else if (payload.type === "function_call_output" && pending.has(payload.call_id)) {
|
|
22
|
+
yield complete(pending.get(payload.call_id), entry.timestamp, payload.output);
|
|
23
|
+
pending.delete(payload.call_id);
|
|
24
|
+
} else count(stats, "ignoredEntries");
|
|
25
|
+
}
|
|
26
|
+
yield* pending.values();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join, basename, dirname } from "node:path";
|
|
3
|
+
import { files, matches, candidate, count, outputChars } from "./shared.mjs";
|
|
4
|
+
|
|
5
|
+
export async function* read(options) {
|
|
6
|
+
const { home, since = new Date(0), stats } = options;
|
|
7
|
+
for await (const file of files(join(home, ".gemini/tmp"), ".json", since, stats)) {
|
|
8
|
+
if (basename(dirname(file)) !== "chats" && basename(file) !== "logs.json") continue;
|
|
9
|
+
let document;
|
|
10
|
+
// Gemini persists a JSON document, rather than a JSONL stream.
|
|
11
|
+
try { document = JSON.parse(await readFile(file, "utf8")); }
|
|
12
|
+
catch (error) { count(stats, error instanceof SyntaxError ? "malformedEntries" : "unreadableFiles"); continue; }
|
|
13
|
+
const messages = Array.isArray(document) ? document : document?.messages;
|
|
14
|
+
if (!Array.isArray(messages)) { count(stats, "ignoredEntries"); continue; }
|
|
15
|
+
for (const message of messages) {
|
|
16
|
+
if (!Array.isArray(message?.toolCalls)) { count(stats, "ignoredEntries"); continue; }
|
|
17
|
+
for (const tool of message.toolCalls) {
|
|
18
|
+
if (tool?.name !== "run_shell_command") { count(stats, "ignoredEntries"); continue; }
|
|
19
|
+
const command = tool.args?.command;
|
|
20
|
+
if (!candidate(command)) continue;
|
|
21
|
+
const record = {
|
|
22
|
+
agent: "gemini", ts: new Date(tool.timestamp ?? message.timestamp ?? NaN), command,
|
|
23
|
+
cwd: tool.args?.directory ?? message.cwd ?? document.cwd,
|
|
24
|
+
session: file
|
|
25
|
+
};
|
|
26
|
+
const chars = outputChars(tool.result);
|
|
27
|
+
if (chars !== undefined) record.outputChars = chars;
|
|
28
|
+
if (matches(record, options)) yield record;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { rewrite, lex } from "../rewrite.mjs";
|
|
4
|
+
import { read as claude } from "./claude.mjs";
|
|
5
|
+
import { read as codex } from "./codex.mjs";
|
|
6
|
+
import { read as gemini } from "./gemini.mjs";
|
|
7
|
+
import { UsageError } from "../usage-error.mjs";
|
|
8
|
+
import { Worker } from "node:worker_threads";
|
|
9
|
+
import { COMMANDS } from "../commands.mjs";
|
|
10
|
+
|
|
11
|
+
const readers = { claude, codex, gemini };
|
|
12
|
+
// Only known command paths may enter the report; trailing words can be private values.
|
|
13
|
+
const sfPaths = [
|
|
14
|
+
"org list metadata-types", "org list metadata", "org list", "org display",
|
|
15
|
+
"org create scratch", "org create sandbox", "org login web", "org login jwt",
|
|
16
|
+
"apex list log", "apex get log", "apex run test", "apex run", "data query",
|
|
17
|
+
"project retrieve start", "project deploy start", "sobject describe", "api request rest",
|
|
18
|
+
"plugins"
|
|
19
|
+
];
|
|
20
|
+
const commandPaths = {
|
|
21
|
+
sf: sfPaths,
|
|
22
|
+
sfdx: [...sfPaths, "force:org:list", "force:org:display", "force:data:soql:query",
|
|
23
|
+
"force:apex:execute", "force:apex:test:run", "force:source:deploy", "force:source:retrieve"],
|
|
24
|
+
sfd: COMMANDS.flatMap(({ name }) => name.split(" | "))
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function subcommandName([executable, ...args]) {
|
|
28
|
+
if (!args.length || args[0].startsWith("-")) return "(no subcommand)";
|
|
29
|
+
const matches = commandPaths[executable].filter(path =>
|
|
30
|
+
path.split(" ").every((word, index) => args[index] === word));
|
|
31
|
+
return matches.sort((a, b) => b.split(" ").length - a.split(" ").length)[0]
|
|
32
|
+
?? "(unrecognized subcommand)";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function discoverOptions(values, now = Date.now()) {
|
|
36
|
+
const window = values.since ?? "30d";
|
|
37
|
+
if (!/^\d+[dh]$/.test(window)) throw new UsageError("--since must be a duration such as 30d or 12h.");
|
|
38
|
+
const since = new Date(now - Number(window.slice(0, -1)) * (window.endsWith("d") ? 86400000 : 3600000));
|
|
39
|
+
if (!Number.isFinite(+since)) throw new UsageError("--since is too large.");
|
|
40
|
+
const agents = [...new Set(values.agent ?? Object.keys(readers))];
|
|
41
|
+
if (agents.some(agent => !Object.hasOwn(readers, agent))) throw new UsageError("--agent must be claude, codex or gemini.");
|
|
42
|
+
const top = Number(values.top ?? 10);
|
|
43
|
+
if (!Number.isSafeInteger(top) || top <= 0) throw new UsageError("--top must be a positive integer.");
|
|
44
|
+
return { since, window, agents, top, project: values.project };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function invocation(command) {
|
|
48
|
+
let tokens;
|
|
49
|
+
try { tokens = lex(command); }
|
|
50
|
+
catch {
|
|
51
|
+
// The rewriter will retain its exact syntax/expansion skip reason.
|
|
52
|
+
const match = /^(?:\s*cd\s+\S+\s*&&)?\s*((?:\w+=\S+\s+)*)(sf|sfd|sfdx)\b(.*)/s.exec(command);
|
|
53
|
+
return match ? [match[2], ...match[3].trim().split(/\s+/)] : undefined;
|
|
54
|
+
}
|
|
55
|
+
const found = [];
|
|
56
|
+
let start = true;
|
|
57
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
58
|
+
const token = tokens[i];
|
|
59
|
+
if (token.op) { start = ["&&", "||", ";", "|", "\n", "&"].includes(token.op); continue; }
|
|
60
|
+
if (!start) continue;
|
|
61
|
+
if (/^\w+=/.test(token.value) || ["env", "rtk", "proxy", "command"].includes(token.value)) continue;
|
|
62
|
+
start = false;
|
|
63
|
+
if (["sf", "sfd", "sfdx"].includes(basename(token.value))) {
|
|
64
|
+
const words = [basename(token.value)];
|
|
65
|
+
for (let j = i + 1; j < tokens.length && !tokens[j].op; j++) words.push(tokens[j].value);
|
|
66
|
+
found.push(words);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return found.find(words => words[0] !== "sfd") ?? found[0];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function reportReason(reason) {
|
|
73
|
+
if (reason.startsWith("unsupported result format ")) return "unsupported result format";
|
|
74
|
+
const flagReason = /^(unsupported flag|duplicate flag|missing value for|unsupported value for|unsupported boolean value) (.*)$/s.exec(reason);
|
|
75
|
+
if (!flagReason) return reason;
|
|
76
|
+
const flag = flagReason[2].split("=")[0];
|
|
77
|
+
return `${flagReason[1]}${/^(?:--[A-Za-z][A-Za-z0-9-]*|-[A-Za-z])$/.test(flag) ? ` ${flag}` : ""}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function classify(command) {
|
|
81
|
+
const words = invocation(command);
|
|
82
|
+
if (!words) return;
|
|
83
|
+
const subcommand = subcommandName(words);
|
|
84
|
+
if (words[0] === "sfd") return { classification: "adopted", subcommand };
|
|
85
|
+
if (words[0] === "sfdx" && words[1]?.startsWith("force:")) return { classification: "legacy", subcommand };
|
|
86
|
+
const result = rewrite(command);
|
|
87
|
+
if (result.command) return { classification: "rewritable", subcommand };
|
|
88
|
+
if (result.skip === "unsupported sf command") return { classification: "unsupported", subcommand };
|
|
89
|
+
return { classification: "skipped", subcommand, reason: reportReason(result.skip) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const median = values => {
|
|
93
|
+
if (!values.length) return null;
|
|
94
|
+
values.sort((a, b) => a - b);
|
|
95
|
+
const mid = Math.floor(values.length / 2);
|
|
96
|
+
return values.length % 2 ? values[mid] : (values[mid - 1] + values[mid]) / 2;
|
|
97
|
+
};
|
|
98
|
+
const ranked = map => [...map].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
|
|
99
|
+
|
|
100
|
+
function scanAgent(agent, options) {
|
|
101
|
+
// Independent JSON parsers keep large agent histories off one CPU core.
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
const worker = new Worker(new URL("./worker.mjs", import.meta.url), { workerData: { agent, ...options } });
|
|
104
|
+
let result;
|
|
105
|
+
worker.on("message", message => { result = message; });
|
|
106
|
+
worker.on("error", reject);
|
|
107
|
+
worker.on("exit", code => {
|
|
108
|
+
if (code !== 0 || !result) reject(new Error(`Discover ${agent} reader exited with code ${code}`));
|
|
109
|
+
else resolve(result);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function discover({ home = homedir(), agents = Object.keys(readers), since = new Date(0), window = "all history", project, top = 10 } = {}) {
|
|
115
|
+
const report = {
|
|
116
|
+
since: since.toISOString(), window, agents, project: project ?? null, top,
|
|
117
|
+
sfCalls: 0, sessions: 0,
|
|
118
|
+
counts: { rewritable: 0, skipped: 0, unsupported: 0, legacy: 0, adopted: 0 },
|
|
119
|
+
estimates: { timeSavedMs: 0, durationSamples: 0, outputChars: 0, outputSamples: 0, outputTokens: 0 },
|
|
120
|
+
diagnostics: { malformedEntries: 0, ignoredEntries: 0, unreadableFiles: 0 }
|
|
121
|
+
};
|
|
122
|
+
const sessions = new Set(), groups = new Map(), reasons = new Map(), unsupported = new Map(), rewritable = new Map();
|
|
123
|
+
const scans = await Promise.all(agents.map(agent => scanAgent(agent, { home, since, project })));
|
|
124
|
+
for (const { observations, stats } of scans) {
|
|
125
|
+
for (const [key, value] of Object.entries(stats)) report.diagnostics[key] += value;
|
|
126
|
+
for (const record of observations) {
|
|
127
|
+
const classification = record.classification;
|
|
128
|
+
const { classification: kind, subcommand, reason } = classification;
|
|
129
|
+
report.counts[kind]++;
|
|
130
|
+
if (kind !== "adopted") report.sfCalls++;
|
|
131
|
+
sessions.add(`${record.agent}:${record.session}`);
|
|
132
|
+
const key = JSON.stringify([kind, subcommand, reason]);
|
|
133
|
+
if (!groups.has(key)) groups.set(key, { ...classification, count: 0, durations: [], sizes: [] });
|
|
134
|
+
const group = groups.get(key);
|
|
135
|
+
group.count++;
|
|
136
|
+
if (record.durationMs !== undefined) group.durations.push(record.durationMs);
|
|
137
|
+
if (record.outputChars !== undefined) group.sizes.push(record.outputChars);
|
|
138
|
+
if (kind === "rewritable") {
|
|
139
|
+
rewritable.set(subcommand, (rewritable.get(subcommand) ?? 0) + 1);
|
|
140
|
+
if (record.durationMs !== undefined) {
|
|
141
|
+
report.estimates.durationSamples++;
|
|
142
|
+
report.estimates.timeSavedMs += Math.max(0, record.durationMs - 300);
|
|
143
|
+
}
|
|
144
|
+
if (record.outputChars !== undefined) {
|
|
145
|
+
report.estimates.outputSamples++;
|
|
146
|
+
report.estimates.outputChars += record.outputChars;
|
|
147
|
+
}
|
|
148
|
+
} else if (kind === "skipped") reasons.set(reason, (reasons.get(reason) ?? 0) + 1);
|
|
149
|
+
else if (["unsupported", "legacy"].includes(kind)) unsupported.set(subcommand, (unsupported.get(subcommand) ?? 0) + 1);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
report.sessions = sessions.size;
|
|
153
|
+
report.estimates.outputTokens = report.estimates.outputChars / 4;
|
|
154
|
+
report.commands = [...groups.values()].map(({ durations, sizes, ...group }) => ({
|
|
155
|
+
...group, medianDurationMs: median(durations), durationSamples: durations.length,
|
|
156
|
+
medianOutputChars: median(sizes), medianOutputTokens: sizes.length ? median(sizes) / 4 : null, outputSamples: sizes.length
|
|
157
|
+
})).sort((a, b) => b.count - a.count || a.subcommand.localeCompare(b.subcommand));
|
|
158
|
+
report.skipReasons = ranked(reasons);
|
|
159
|
+
report.unsupported = ranked(unsupported);
|
|
160
|
+
report.rewritable = ranked(rewritable);
|
|
161
|
+
return report;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function formatReport(report) {
|
|
165
|
+
const list = items => items.slice(0, report.top).map(item => `${item.name} ${item.count}`).join(" · ") || "(none)";
|
|
166
|
+
const { counts: c, estimates: e, diagnostics: d } = report;
|
|
167
|
+
const time = e.durationSamples ? `~${(e.timeSavedMs / 60000).toFixed(1)} min saved (${e.durationSamples} samples)` : "time unknown";
|
|
168
|
+
const output = e.outputSamples ? `~${Math.round(e.outputTokens)} output tokens (${e.outputSamples} samples)` : "output unknown";
|
|
169
|
+
return [
|
|
170
|
+
`sfd discover: ${report.window}, ${report.agents.length} agents, ${report.sfCalls} sf/sfdx calls in ${report.sessions} sessions`,
|
|
171
|
+
` rewritable now ${c.rewritable} (${time}; ${output})`,
|
|
172
|
+
` skipped ${c.skipped} ${list(report.skipReasons)}`,
|
|
173
|
+
` not in sfd ${c.unsupported + c.legacy} ${list(report.unsupported)} (legacy ${c.legacy})`,
|
|
174
|
+
` already sfd ${c.adopted}`,
|
|
175
|
+
`Top rewritable: ${list(report.rewritable)}`,
|
|
176
|
+
`Scan: ${d.malformedEntries} malformed, ${d.ignoredEntries} ignored entries, ${d.unreadableFiles} unreadable files.`,
|
|
177
|
+
"Durations include permission wait; savings assume 0.3 s/call. Tokens are chars/4.",
|
|
178
|
+
"Output tokens are potential savings before sfd output, which is not measured. Missing samples are not extrapolated.",
|
|
179
|
+
"Counts classify shell tool calls once; compound calls use the first sf/sfdx invocation."
|
|
180
|
+
].join("\n");
|
|
181
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { readdir, stat } from "node:fs/promises";
|
|
3
|
+
import { createInterface } from "node:readline";
|
|
4
|
+
import { join, relative, resolve, isAbsolute } from "node:path";
|
|
5
|
+
|
|
6
|
+
export function count(stats, key) {
|
|
7
|
+
if (stats) stats[key] = (stats[key] ?? 0) + 1;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function* files(root, extension, since, stats) {
|
|
11
|
+
let entries;
|
|
12
|
+
try { entries = await readdir(root, { withFileTypes: true }); }
|
|
13
|
+
catch (error) {
|
|
14
|
+
if (error.code !== "ENOENT") count(stats, "unreadableFiles");
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
for (const entry of entries) {
|
|
18
|
+
const path = join(root, entry.name);
|
|
19
|
+
if (entry.isDirectory()) yield* files(path, extension, since, stats);
|
|
20
|
+
else if (entry.isFile() && entry.name.endsWith(extension)) {
|
|
21
|
+
try {
|
|
22
|
+
if ((await stat(path)).mtimeMs >= Number(since)) yield path;
|
|
23
|
+
} catch { count(stats, "unreadableFiles"); }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function* lines(file, stats) {
|
|
29
|
+
const stream = createReadStream(file, { encoding: "utf8", highWaterMark: 1024 * 1024 });
|
|
30
|
+
const reader = createInterface({ input: stream, crlfDelay: Infinity });
|
|
31
|
+
// Forward stream errors into the iterator, including files removed mid-scan.
|
|
32
|
+
stream.on("error", error => reader.close());
|
|
33
|
+
try {
|
|
34
|
+
for await (const line of reader) {
|
|
35
|
+
if (!line.trim()) continue;
|
|
36
|
+
try { yield JSON.parse(line); }
|
|
37
|
+
catch { count(stats, "malformedEntries"); }
|
|
38
|
+
}
|
|
39
|
+
if (stream.errored) count(stats, "unreadableFiles");
|
|
40
|
+
} catch { count(stats, "unreadableFiles"); }
|
|
41
|
+
finally { reader.close(); stream.destroy(); }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function matches(record, { since, project, stats }) {
|
|
45
|
+
if (record.ts == null || !Number.isFinite(+record.ts)) {
|
|
46
|
+
count(stats, "malformedEntries");
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
if (+record.ts < +since) return false;
|
|
50
|
+
if (!project) return true;
|
|
51
|
+
if (typeof record.cwd !== "string") return false;
|
|
52
|
+
const path = relative(resolve(project), resolve(record.cwd));
|
|
53
|
+
return !isAbsolute(path) && path !== ".." && !path.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function outputChars(output) {
|
|
57
|
+
if (output === undefined || output === null) return undefined;
|
|
58
|
+
return (typeof output === "string" ? output : JSON.stringify(output)).length;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function complete(record, timestamp, output) {
|
|
62
|
+
const duration = +new Date(timestamp) - +record.ts;
|
|
63
|
+
return {
|
|
64
|
+
...record,
|
|
65
|
+
...(Number.isFinite(duration) && duration >= 0 ? { durationMs: duration } : {}),
|
|
66
|
+
...(outputChars(output) !== undefined ? { outputChars: outputChars(output) } : {})
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function shellCommand(args) {
|
|
71
|
+
const command = args?.command ?? args?.cmd;
|
|
72
|
+
if (typeof command === "string") return command;
|
|
73
|
+
if (!Array.isArray(command) || !command.every(v => typeof v === "string")) return;
|
|
74
|
+
if (["bash", "sh", "/bin/bash", "/bin/sh"].includes(command[0]) && ["-lc", "-c"].includes(command[1]) && command.length === 3) return command[2];
|
|
75
|
+
// Preserve argv boundaries for the literal-shell rewriter.
|
|
76
|
+
return command.map(v => `'${v.replaceAll("'", "'\\''")}'`).join(" ");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const candidate = command => typeof command === "string" && /\b(?:sf|sfd|sfdx)\b/.test(command);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { parentPort, workerData } from "node:worker_threads";
|
|
2
|
+
import { read as claude } from "./claude.mjs";
|
|
3
|
+
import { read as codex } from "./codex.mjs";
|
|
4
|
+
import { read as gemini } from "./gemini.mjs";
|
|
5
|
+
import { classify } from "./index.mjs";
|
|
6
|
+
|
|
7
|
+
const stats = {};
|
|
8
|
+
const observations = [];
|
|
9
|
+
const { agent, ...options } = workerData;
|
|
10
|
+
for await (const record of ({ claude, codex, gemini })[agent]({ ...options, stats })) {
|
|
11
|
+
const classification = classify(record.command);
|
|
12
|
+
if (classification) observations.push({
|
|
13
|
+
agent, session: record.session, durationMs: record.durationMs,
|
|
14
|
+
outputChars: record.outputChars, classification
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
parentPort.postMessage({ observations, stats });
|
package/src/format.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { relative, resolve } from "node:path";
|
|
|
2
2
|
|
|
3
3
|
// Compact, token-friendly renderings of Salesforce results.
|
|
4
4
|
|
|
5
|
-
const asArray = x => (x === undefined || x === null ? [] : Array.isArray(x) ? x : [x]);
|
|
5
|
+
export const asArray = x => (x === undefined || x === null ? [] : Array.isArray(x) ? x : [x]);
|
|
6
6
|
|
|
7
7
|
export function stripAttributes(value) {
|
|
8
8
|
if (Array.isArray(value)) return value.map(stripAttributes);
|
|
@@ -18,6 +18,31 @@ export function compactQuery(result) {
|
|
|
18
18
|
return { totalSize: result.totalSize, done: result.done, ...(result.nextRecordsUrl ? { nextRecordsUrl: result.nextRecordsUrl } : {}), records: stripAttributes(result.records ?? []) };
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
export function flattenRecord(record, prefix = "") {
|
|
22
|
+
const out = {};
|
|
23
|
+
for (const [key, value] of Object.entries(record)) {
|
|
24
|
+
if (key === "attributes") continue;
|
|
25
|
+
const column = prefix + key;
|
|
26
|
+
if (value === null || typeof value !== "object") out[column] = value;
|
|
27
|
+
else if (Array.isArray(value.records)) out[column] = JSON.stringify(stripAttributes(value.records));
|
|
28
|
+
else Object.assign(out, flattenRecord(value, column + "."));
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function toCsv(records) {
|
|
34
|
+
if (!records.length) return "";
|
|
35
|
+
const rows = records.map(record => flattenRecord(record));
|
|
36
|
+
const keys = [...new Set(rows.flatMap(row => Object.keys(row)))];
|
|
37
|
+
// A null parent supplies empty cells for its dotted columns, not an extra column.
|
|
38
|
+
const columns = keys.filter(key => !keys.some(other => other.startsWith(key + ".")) || rows.some(row => row[key] != null));
|
|
39
|
+
const escape = value => {
|
|
40
|
+
const text = value == null ? "" : String(value);
|
|
41
|
+
return /[",\r\n]/.test(text) ? '"' + text.replaceAll('"', '""') + '"' : text;
|
|
42
|
+
};
|
|
43
|
+
return [columns.map(escape).join(","), ...rows.map(row => columns.map(key => escape(row[key])).join(","))].join("\n");
|
|
44
|
+
}
|
|
45
|
+
|
|
21
46
|
export function compactDescribe(d) {
|
|
22
47
|
return {
|
|
23
48
|
name: d.name, label: d.label, keyPrefix: d.keyPrefix, custom: d.custom,
|
|
@@ -239,3 +264,36 @@ export function formatTracking(summary) {
|
|
|
239
264
|
}
|
|
240
265
|
return lines.join("\n") || "No changes.";
|
|
241
266
|
}
|
|
267
|
+
|
|
268
|
+
export function formatOrgList({ orgs, hiddenExpired = 0 }) {
|
|
269
|
+
const aliasWidth = Math.max(1, ...orgs.map(org => (org.alias ?? "-").length));
|
|
270
|
+
const userWidth = Math.max(1, ...orgs.map(org => org.username.length));
|
|
271
|
+
const lines = orgs.map(org => {
|
|
272
|
+
const marker = `${org.isDefault ? "*" : " "}${org.isDefaultDevHub ? "H" : " "}`;
|
|
273
|
+
const expiry = org.type === "Scratch" ? org.expired ? " EXPIRED" : org.expirationDate ? ` expires ${org.expirationDate.slice(0, 10)}` : "" : "";
|
|
274
|
+
return `${marker} ${(org.alias ?? "-").padEnd(aliasWidth)} ${org.username.padEnd(userWidth)} ${org.type}${expiry}${org.status ? ` ${org.status}` : ""}`;
|
|
275
|
+
});
|
|
276
|
+
if (hiddenExpired) lines.push(`(${hiddenExpired} expired scratch org(s) hidden; use --all)`);
|
|
277
|
+
if (!lines.length) lines.push("No authenticated orgs. Run: sf org login web -a <alias>");
|
|
278
|
+
return lines.join("\n");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function formatMetadataTypes({ types, apiVersion }) {
|
|
282
|
+
return [`${types.length} metadata types (API v${apiVersion})`, ...types.map(type => {
|
|
283
|
+
const children = type.children.slice(0, 6);
|
|
284
|
+
if (type.children.length > 6) children.push(`…+${type.children.length - 6}`);
|
|
285
|
+
return `${type.name}${type.inFolder ? " (folder)" : ""}${children.length ? ` [${children.join(", ")}]` : ""}`;
|
|
286
|
+
})].join("\n");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function formatMetadataList({ groups }, { details = false } = {}) {
|
|
290
|
+
return groups.map(group => {
|
|
291
|
+
const header = group.folders
|
|
292
|
+
? `${group.folderType} (${group.members.length}): use --folder <name> to list members`
|
|
293
|
+
: groups.length > 1 ? `${group.type} (${group.members.length}):` : "";
|
|
294
|
+
const lines = group.members.map(member => details ? [member.fullName, member.lastModifiedDate?.slice(0, 10),
|
|
295
|
+
member.lastModifiedByName, member.namespacePrefix ? `ns:${member.namespacePrefix}` : "", member.manageableState].filter(Boolean).join(" ") : member.fullName);
|
|
296
|
+
if (!lines.length) lines.push("(none)");
|
|
297
|
+
return header ? [header, ...lines.map(line => ` ${line}`)].join("\n") : lines.join("\n");
|
|
298
|
+
}).join("\n");
|
|
299
|
+
}
|
package/src/handlers.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { statSync } from "node:fs";
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { UsageError } from "./usage-error.mjs";
|
|
5
5
|
import { sfRequire } from "./sf-root.mjs";
|
|
6
|
-
import { compactDescribe, compactQuery, summarizeDeploy, summarizeTests, summarizeRetrieve, summarizeChanges } from "./format.mjs";
|
|
6
|
+
import { asArray, compactDescribe, compactQuery, summarizeDeploy, summarizeTests, summarizeRetrieve, summarizeChanges } from "./format.mjs";
|
|
7
7
|
|
|
8
8
|
const soqlString = value => `'${String(value).replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
|
|
9
9
|
const LOG_FIELDS = "Id, LogUser.Username, Operation, Request, Status, LogLength, StartTime, DurationMilliseconds";
|
|
@@ -67,6 +67,16 @@ export async function ensureTrace(conn, userId) {
|
|
|
67
67
|
return { action: flag ? "extended" : "created", id: flag?.Id ?? result.id, expires: dates.ExpirationDate };
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
async function withDeadline(fn) {
|
|
71
|
+
let timer;
|
|
72
|
+
try {
|
|
73
|
+
return await Promise.race([
|
|
74
|
+
Promise.resolve().then(fn),
|
|
75
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(Object.assign(new Error(), { name: "Timeout" })), 10000); })
|
|
76
|
+
]);
|
|
77
|
+
} finally { clearTimeout(timer); }
|
|
78
|
+
}
|
|
79
|
+
|
|
70
80
|
const SESSION_ERRORS = new Set(["INVALID_SESSION_ID", "INVALID_AUTH_HEADER"]);
|
|
71
81
|
|
|
72
82
|
function authStamp(home) {
|
|
@@ -86,6 +96,24 @@ function serial() {
|
|
|
86
96
|
};
|
|
87
97
|
}
|
|
88
98
|
|
|
99
|
+
function metadataSuggestion(name, names) {
|
|
100
|
+
const input = name.toLowerCase();
|
|
101
|
+
const distance = candidate => {
|
|
102
|
+
let row = Array.from({ length: input.length + 1 }, (_, i) => i);
|
|
103
|
+
for (const char of candidate.toLowerCase()) {
|
|
104
|
+
const next = [row[0] + 1];
|
|
105
|
+
for (let i = 1; i <= input.length; i++) {
|
|
106
|
+
next[i] = Math.min(next[i - 1] + 1, row[i] + 1, row[i - 1] + (input[i - 1] === char ? 0 : 1));
|
|
107
|
+
}
|
|
108
|
+
row = next;
|
|
109
|
+
}
|
|
110
|
+
return row[input.length];
|
|
111
|
+
};
|
|
112
|
+
return names.find(n => n.toLowerCase().startsWith(input)) ??
|
|
113
|
+
names.map(name => ({ name, distance: distance(name) })).filter(n => n.distance <= 2)
|
|
114
|
+
.sort((a, b) => a.distance - b.distance)[0]?.name;
|
|
115
|
+
}
|
|
116
|
+
|
|
89
117
|
export function createHandlers(sfRoot, { home = homedir(), load = sfRequire(sfRoot) } = {}) {
|
|
90
118
|
const req = load;
|
|
91
119
|
const core = req("@salesforce/core");
|
|
@@ -97,6 +125,7 @@ export function createHandlers(sfRoot, { home = homedir(), load = sfRequire(sfRo
|
|
|
97
125
|
const projectQueue = serial();
|
|
98
126
|
|
|
99
127
|
const orgs = new Map();
|
|
128
|
+
const typeCache = new Map();
|
|
100
129
|
const traces = new Map();
|
|
101
130
|
const traceQueue = serial();
|
|
102
131
|
const traceFor = (conn, alias, user, noTrace) => noTrace ? undefined : traceQueue(async () => {
|
|
@@ -108,14 +137,22 @@ export function createHandlers(sfRoot, { home = homedir(), load = sfRequire(sfRo
|
|
|
108
137
|
});
|
|
109
138
|
let stamp = authStamp(home);
|
|
110
139
|
|
|
111
|
-
|
|
140
|
+
let refreshing;
|
|
141
|
+
async function refreshIfAuthChanged() {
|
|
142
|
+
if (refreshing) await refreshing;
|
|
112
143
|
const now = authStamp(home);
|
|
113
144
|
if (now !== stamp) {
|
|
114
145
|
stamp = now;
|
|
115
146
|
orgs.clear();
|
|
116
147
|
traces.clear();
|
|
117
|
-
|
|
148
|
+
typeCache.clear();
|
|
149
|
+
refreshing = core.StateAggregator.clearInstanceAsync();
|
|
150
|
+
try { await refreshing; } finally { refreshing = undefined; }
|
|
118
151
|
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function getOrg(alias) {
|
|
155
|
+
await refreshIfAuthChanged();
|
|
119
156
|
if (!orgs.has(alias)) {
|
|
120
157
|
const pending = core.Org.create({ aliasOrUsername: alias });
|
|
121
158
|
orgs.set(alias, pending);
|
|
@@ -133,11 +170,27 @@ export function createHandlers(sfRoot, { home = homedir(), load = sfRequire(sfRo
|
|
|
133
170
|
if (!SESSION_ERRORS.has(e.errorCode ?? e.name)) throw e;
|
|
134
171
|
orgs.delete(alias);
|
|
135
172
|
traces.delete(alias);
|
|
173
|
+
typeCache.delete(alias);
|
|
136
174
|
const org = await getOrg(alias);
|
|
137
175
|
return fn(org.getConnection(), org);
|
|
138
176
|
}
|
|
139
177
|
}
|
|
140
178
|
|
|
179
|
+
async function metadataTypes(alias, conn) {
|
|
180
|
+
const apiVersion = conn.getApiVersion();
|
|
181
|
+
let versions = typeCache.get(alias);
|
|
182
|
+
if (!versions) typeCache.set(alias, versions = new Map());
|
|
183
|
+
const cached = versions.has(apiVersion);
|
|
184
|
+
if (!cached) {
|
|
185
|
+
const pending = conn.metadata.describe(apiVersion).then(result => asArray(result.metadataObjects)
|
|
186
|
+
.map(t => ({ name: t.xmlName, inFolder: Boolean(t.inFolder), children: asArray(t.childXmlNames) }))
|
|
187
|
+
.sort((a, b) => a.name.localeCompare(b.name)));
|
|
188
|
+
versions.set(apiVersion, pending);
|
|
189
|
+
pending.catch(() => { if (versions.get(apiVersion) === pending) versions.delete(apiVersion); });
|
|
190
|
+
}
|
|
191
|
+
return { types: await versions.get(apiVersion), apiVersion, cached };
|
|
192
|
+
}
|
|
193
|
+
|
|
141
194
|
return {
|
|
142
195
|
ping: async () => ({ pong: true }),
|
|
143
196
|
|
|
@@ -150,6 +203,7 @@ export function createHandlers(sfRoot, { home = homedir(), load = sfRequire(sfRo
|
|
|
150
203
|
reload: async () => {
|
|
151
204
|
orgs.clear();
|
|
152
205
|
traces.clear();
|
|
206
|
+
typeCache.clear();
|
|
153
207
|
await core.StateAggregator.clearInstanceAsync();
|
|
154
208
|
return { reloaded: true };
|
|
155
209
|
},
|
|
@@ -158,17 +212,105 @@ export function createHandlers(sfRoot, { home = homedir(), load = sfRequire(sfRo
|
|
|
158
212
|
alias: org, username: o.getUsername(), orgId: o.getOrgId(), instanceUrl: conn.instanceUrl, apiVersion: conn.getApiVersion()
|
|
159
213
|
})),
|
|
160
214
|
|
|
161
|
-
|
|
215
|
+
orgList: async ({ all = false, status = false } = {}) => {
|
|
216
|
+
await refreshIfAuthChanged();
|
|
217
|
+
const state = await core.StateAggregator.getInstance();
|
|
218
|
+
const config = await core.ConfigAggregator.create();
|
|
219
|
+
await config.reload();
|
|
220
|
+
const isDefault = (username, key) => {
|
|
221
|
+
const value = config.getPropertyValue(key);
|
|
222
|
+
return value === username || state.aliases.getAll(username).includes(value);
|
|
223
|
+
};
|
|
224
|
+
let authorizations;
|
|
225
|
+
try { authorizations = await core.AuthInfo.listAllAuthorizations() ?? []; }
|
|
226
|
+
catch (error) {
|
|
227
|
+
if (error.name !== "NoAuthInfoFound") throw error;
|
|
228
|
+
authorizations = [];
|
|
229
|
+
}
|
|
230
|
+
const entries = await Promise.all(authorizations.map(async ({ username }) => {
|
|
231
|
+
try {
|
|
232
|
+
const auth = await core.AuthInfo.create({ username });
|
|
233
|
+
let fields;
|
|
234
|
+
try { fields = auth.getFields(true); } catch { fields = auth.getFields(); }
|
|
235
|
+
const defaultOrg = isDefault(username, "target-org");
|
|
236
|
+
if (fields.orgId && !defaultOrg) {
|
|
237
|
+
let primary;
|
|
238
|
+
try { primary = JSON.parse(readFileSync(join(home, ".sfdx", `${fields.orgId}.json`), "utf8")).usernames?.[0]; }
|
|
239
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
240
|
+
if (primary && primary !== username) return undefined;
|
|
241
|
+
}
|
|
242
|
+
const scratch = Boolean(fields.expirationDate || fields.devHubUsername);
|
|
243
|
+
const org = {
|
|
244
|
+
alias: state.aliases.getAll(username)[0] ?? null, username,
|
|
245
|
+
orgId: fields.orgId, instanceUrl: fields.instanceUrl,
|
|
246
|
+
type: scratch ? "Scratch" : fields.isDevHub ? "DevHub" : fields.isSandbox ? "Sandbox" : "Org",
|
|
247
|
+
isDefault: defaultOrg, isDefaultDevHub: isDefault(username, "target-dev-hub")
|
|
248
|
+
};
|
|
249
|
+
if (scratch) {
|
|
250
|
+
if (fields.expirationDate) org.expirationDate = fields.expirationDate;
|
|
251
|
+
org.expired = Boolean(fields.expirationDate && fields.expirationDate.slice(0, 10) < new Date().toISOString().slice(0, 10));
|
|
252
|
+
}
|
|
253
|
+
return { org, devHubUsername: fields.devHubUsername };
|
|
254
|
+
} catch {
|
|
255
|
+
// Never log auth fields or vendor error messages, which can contain secrets.
|
|
256
|
+
console.warn("sfd org list: skipping unreadable local authorization");
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
}));
|
|
260
|
+
const readable = entries.filter(Boolean);
|
|
261
|
+
if (status) {
|
|
262
|
+
const hubs = new Map();
|
|
263
|
+
const checks = readable.filter(({ org }) => org.type !== "Scratch").map(async ({ org }) => {
|
|
264
|
+
try {
|
|
265
|
+
await withDeadline(async () => (await getOrg(org.username)).refreshAuth());
|
|
266
|
+
org.status = "Connected";
|
|
267
|
+
} catch (error) {
|
|
268
|
+
orgs.delete(org.username);
|
|
269
|
+
org.status = error.name;
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
for (const entry of readable) {
|
|
273
|
+
if (entry.org.type !== "Scratch" || !entry.devHubUsername || !entry.org.orgId) continue;
|
|
274
|
+
if (!hubs.has(entry.devHubUsername)) hubs.set(entry.devHubUsername, []);
|
|
275
|
+
hubs.get(entry.devHubUsername).push(entry.org);
|
|
276
|
+
}
|
|
277
|
+
await Promise.all([...checks, ...[...hubs].map(async ([hub, scratchOrgs]) => {
|
|
278
|
+
try {
|
|
279
|
+
const result = await withDeadline(async () => {
|
|
280
|
+
const org = await getOrg(hub);
|
|
281
|
+
return org.getConnection().query(`SELECT ScratchOrg, Status, ExpirationDate FROM ScratchOrgInfo WHERE ScratchOrg IN (${scratchOrgs.map(o => soqlString(o.orgId.slice(0, 15))).join(",")})`);
|
|
282
|
+
});
|
|
283
|
+
for (const org of scratchOrgs) {
|
|
284
|
+
const row = result.records.find(r => r.ScratchOrg?.slice(0, 15) === org.orgId.slice(0, 15));
|
|
285
|
+
if (!row) continue;
|
|
286
|
+
org.status = row.Status;
|
|
287
|
+
if (row.ExpirationDate) org.expirationDate = row.ExpirationDate;
|
|
288
|
+
org.expired = row.Status === "Expired" || row.Status === "Deleted";
|
|
289
|
+
}
|
|
290
|
+
} catch { /* Local scratch details remain useful when a Dev Hub is unavailable. */ }
|
|
291
|
+
})]);
|
|
292
|
+
}
|
|
293
|
+
const listed = readable.map(entry => entry.org);
|
|
294
|
+
const visible = all ? listed : listed.filter(org => !org.expired);
|
|
295
|
+
visible.sort((a, b) => (a.alias ?? "").localeCompare(b.alias ?? "") || a.username.localeCompare(b.username));
|
|
296
|
+
return { orgs: visible, hiddenExpired: listed.length - visible.length };
|
|
297
|
+
},
|
|
298
|
+
|
|
299
|
+
query: ({ org, q, tooling, all, raw, limit }) => withConn(org, async conn => {
|
|
162
300
|
const api = tooling ? conn.tooling : conn;
|
|
163
301
|
const result = await api.query(q, { scanAll: Boolean(all) });
|
|
164
|
-
while (!result.done) {
|
|
302
|
+
while (!result.done && (limit === undefined || result.records.length < limit)) {
|
|
165
303
|
if (!result.nextRecordsUrl) throw new Error("Incomplete query result: missing nextRecordsUrl.");
|
|
166
304
|
const page = await api.queryMore(result.nextRecordsUrl);
|
|
167
305
|
result.records.push(...page.records);
|
|
168
306
|
result.done = page.done;
|
|
169
307
|
result.nextRecordsUrl = page.nextRecordsUrl;
|
|
170
308
|
}
|
|
171
|
-
|
|
309
|
+
const truncated = limit !== undefined && (!result.done || result.records.length > limit);
|
|
310
|
+
if (truncated) result.records = result.records.slice(0, limit);
|
|
311
|
+
const output = raw ? result : compactQuery(result);
|
|
312
|
+
if (truncated) output.truncated = true;
|
|
313
|
+
return output;
|
|
172
314
|
}),
|
|
173
315
|
|
|
174
316
|
apex: ({ org, body }) => withConn(org, conn => new (apex().ExecuteService)(conn).executeAnonymous({ apexCode: body })),
|
|
@@ -183,6 +325,37 @@ export function createHandlers(sfRoot, { home = homedir(), load = sfRequire(sfRo
|
|
|
183
325
|
return raw ? d : compactDescribe(d);
|
|
184
326
|
}),
|
|
185
327
|
|
|
328
|
+
listMetadataTypes: ({ org }) => withConn(org, conn => metadataTypes(org, conn)),
|
|
329
|
+
|
|
330
|
+
listMetadata: ({ org, types = [], folder }) => withConn(org, async conn => {
|
|
331
|
+
types = [...new Set(types)];
|
|
332
|
+
if (!types.length) throw new UsageError("list-metadata needs -m <Type>.");
|
|
333
|
+
if (folder !== undefined && types.length > 1) throw new UsageError("--folder requires exactly one metadata type.");
|
|
334
|
+
const description = await metadataTypes(org, conn);
|
|
335
|
+
const known = new Set(description.types.flatMap(t => [t.name, ...t.children]));
|
|
336
|
+
const groups = types.map(type => {
|
|
337
|
+
if (!known.has(type)) {
|
|
338
|
+
const suggestion = metadataSuggestion(type, [...known].sort());
|
|
339
|
+
throw new UsageError(`Unknown metadata type: ${type}.${suggestion ? ` Did you mean ${suggestion}?` : ""}`);
|
|
340
|
+
}
|
|
341
|
+
const folders = folder === undefined && Boolean(description.types.find(t => t.name === type)?.inFolder);
|
|
342
|
+
const folderType = known.has(`${type}Folder`) ? `${type}Folder` : type === "EmailTemplate" ? "EmailFolder" : `${type}Folder`;
|
|
343
|
+
return { type, ...(folders ? { folders: true, folderType } : {}), members: [] };
|
|
344
|
+
});
|
|
345
|
+
const queries = groups.map(group => ({ type: group.folderType ?? group.type, ...(folder !== undefined ? { folder } : {}) }));
|
|
346
|
+
const chunks = [];
|
|
347
|
+
for (let i = 0; i < queries.length; i += 3) chunks.push(queries.slice(i, i + 3));
|
|
348
|
+
const results = await Promise.all(chunks.map(chunk => conn.metadata.list(chunk, description.apiVersion)));
|
|
349
|
+
const members = results.flatMap(asArray);
|
|
350
|
+
for (const group of groups) {
|
|
351
|
+
group.members = members.filter(member => member.type === (group.folderType ?? group.type)).map(member => ({
|
|
352
|
+
fullName: member.fullName, lastModifiedDate: member.lastModifiedDate, lastModifiedByName: member.lastModifiedByName,
|
|
353
|
+
namespacePrefix: member.namespacePrefix, manageableState: member.manageableState
|
|
354
|
+
})).sort((a, b) => a.fullName.localeCompare(b.fullName));
|
|
355
|
+
}
|
|
356
|
+
return { groups };
|
|
357
|
+
}),
|
|
358
|
+
|
|
186
359
|
deploy: ({ org, projectDir, sourcePaths, metadata, checkOnly, testLevel, tests, waitMinutes = 33 }) =>
|
|
187
360
|
projectQueue(() => withConn(org, async (conn, o) => {
|
|
188
361
|
const { ComponentSetBuilder } = sdr();
|
package/src/rewrite.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
const quote = value => `'${value.replaceAll("'", "'\\''")}'`;
|
|
3
3
|
const skip = reason => ({ skip: reason });
|
|
4
4
|
|
|
5
|
-
function lex(source) {
|
|
5
|
+
export function lex(source) {
|
|
6
6
|
const tokens = [];
|
|
7
7
|
let i = 0;
|
|
8
8
|
while (i < source.length) {
|
|
@@ -44,6 +44,8 @@ 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: "org list metadata-types", sfd: "list-metadata-types", flags: [org, json] },
|
|
48
|
+
{ sf: "org list metadata", sfd: "list-metadata", flags: [org, json, ["m metadata-type", "-m", true], ["folder", "--folder", true]] },
|
|
47
49
|
{ sf: "apex list log", sfd: "logs", flags: [org, json] },
|
|
48
50
|
{ sf: "apex get log", sfd: "logs get", flags: [org, json, ["i log-id", "", true], ["n number", "", true]] },
|
|
49
51
|
{ sf: "data query", sfd: "query", flags: [org, json, tooling, ["q query", "", true], ["all-rows", "--all", false], format], required: "q" },
|
|
@@ -53,6 +55,7 @@ const mappings = [
|
|
|
53
55
|
{ sf: "project deploy start", sfd: "deploy", flags: [org, json, wait, level, tests, ["d source-dir", "-d", true, true], ["m metadata", "-m", true, true], ["dry-run", "--check-only", false]] },
|
|
54
56
|
{ sf: "sobject describe", sfd: "describe", flags: [org, json, tooling, ["s sobject", "", true]], required: "s" },
|
|
55
57
|
{ sf: "api request rest", sfd: "rest", flags: [org, ["X method", "-X", true], ["b body", "--body", true]], positional: true },
|
|
58
|
+
{ sf: "org list", sfd: "org list", flags: [["all", "--all", false], ["skip-connection-status", "", false], ["json", "--json", false]] },
|
|
56
59
|
{ sf: "org display", sfd: "org", flags: [org, json] }
|
|
57
60
|
];
|
|
58
61
|
|
|
@@ -82,14 +85,19 @@ function mapArgs(argv) {
|
|
|
82
85
|
}
|
|
83
86
|
} else if (match[3] !== undefined) return skip(`unsupported boolean value ${arg}`);
|
|
84
87
|
seen.set(key, values);
|
|
85
|
-
if (key === "r"
|
|
88
|
+
if (key === "r") {
|
|
89
|
+
if (mapping.sfd === "query" && values[0] === "csv") output.push("--csv");
|
|
90
|
+
else if (!["human", "json"].includes(values[0])) return skip(`unsupported result format ${values[0]}`);
|
|
91
|
+
}
|
|
86
92
|
if (key === "w" && (!Number.isFinite(Number(values[0])) || Number(values[0]) <= 0)) return skip("wait must be positive");
|
|
87
93
|
if (target) {
|
|
88
94
|
if (takesValue) for (const v of values) output.push(target, quote(v));
|
|
89
95
|
else output.push(target);
|
|
90
96
|
}
|
|
91
97
|
}
|
|
98
|
+
if (mapping.sfd === "org list" && !seen.has("skip-connection-status")) output.push("--status");
|
|
92
99
|
if (mapping.required && !seen.has(mapping.required)) return skip(`missing required ${mapping.required} flag`);
|
|
100
|
+
if (mapping.sfd === "list-metadata" && !seen.has("m")) return skip("metadata listing needs metadata-type");
|
|
93
101
|
if (mapping.sfd === "deploy" && !seen.has("d") && !seen.has("m")) return skip("deploy needs source-dir or metadata");
|
|
94
102
|
if (mapping.sfd === "retrieve" && ["d", "m", "x"].filter(key => seen.has(key)).length > 1) return skip("retrieve selectors are mutually exclusive");
|
|
95
103
|
if (mapping.sfd === "test" && !seen.has("w") && !seen.has("y")) return skip("asynchronous tests need --wait or --synchronous");
|
package/src/snippet.mjs
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { COMMANDS } from "./commands.mjs";
|
|
2
|
+
|
|
3
|
+
const START = "<!-- sfd:start -->";
|
|
4
|
+
const END = "<!-- sfd:end -->";
|
|
5
|
+
|
|
6
|
+
export function snippet() {
|
|
7
|
+
const lines = [];
|
|
8
|
+
let previousGroup;
|
|
9
|
+
for (const { name, args, summary, agentTip, agentGroup } of COMMANDS) {
|
|
10
|
+
const tip = agentTip ?? `${args}: ${summary.replaceAll("\n", " ")}`;
|
|
11
|
+
const separator = tip.indexOf(": ");
|
|
12
|
+
if (separator === -1) throw new Error(`Invalid agentTip for ${name}: expected ": " separator`);
|
|
13
|
+
const invocation = ["sfd", name, tip.slice(0, separator).trim()].filter(Boolean).join(" ");
|
|
14
|
+
const entry = `\`${invocation}\`: ${tip.slice(separator + 2)}`;
|
|
15
|
+
if (agentGroup && agentGroup === previousGroup) lines[lines.length - 1] += `; ${entry}`;
|
|
16
|
+
else lines.push(`- ${entry}`);
|
|
17
|
+
previousGroup = agentGroup;
|
|
18
|
+
}
|
|
19
|
+
return [
|
|
20
|
+
START,
|
|
21
|
+
'Prefer `sfd` (warm Salesforce daemon, ~0.2 s/call, compact output) over `sf`:',
|
|
22
|
+
...lines,
|
|
23
|
+
'Add `-o <alias>` as with `sf`. Exit: 0 ok, 1 operation failed, 2 usage, 3 daemon. For anything else use `sf … --json`; pipe through `jq` for a few fields.',
|
|
24
|
+
END
|
|
25
|
+
].join("\n");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function upsertSnippet(text, block) {
|
|
29
|
+
const start = text.indexOf(START);
|
|
30
|
+
const end = text.indexOf(END);
|
|
31
|
+
if (start === -1 && end === -1) {
|
|
32
|
+
const prefix = text.replace(/\s+$/, "");
|
|
33
|
+
return `${prefix ? `${prefix}\n\n` : ""}${block}\n`;
|
|
34
|
+
}
|
|
35
|
+
if (end === -1) throw new Error("found start marker without end");
|
|
36
|
+
if (start === -1) throw new Error("found end marker without start");
|
|
37
|
+
if (end < start) throw new Error("end marker precedes start marker");
|
|
38
|
+
if (text.indexOf(START, start + START.length) !== -1 || text.indexOf(END, end + END.length) !== -1) {
|
|
39
|
+
throw new Error("found multiple sfd blocks or nested markers");
|
|
40
|
+
}
|
|
41
|
+
return text.slice(0, start) + block + text.slice(end + END.length);
|
|
42
|
+
}
|