@syntax-syllogism/sfd 0.1.0 → 0.2.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 +6 -0
- package/README.md +20 -1
- package/bin/sfd.mjs +7 -2
- package/package.json +1 -1
- package/src/hooks.mjs +102 -0
- package/src/rewrite.mjs +173 -0
- package/src/usage-error.mjs +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2026-10-22
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Agent hook that rewrites supported `sf` commands to their faster `sfd` equivalents
|
|
15
|
+
|
|
10
16
|
## [0.1.0] - 2026-10-22
|
|
11
17
|
|
|
12
18
|
### Added
|
package/README.md
CHANGED
|
@@ -31,6 +31,7 @@ sfd query "SELECT Id, Name FROM Account LIMIT 5" -o my-org
|
|
|
31
31
|
| `sfd deploy -d <path> \| -m <Type[:Name]>` | Deploy source. `--check-only`, `-l <level>`, `-t <test>`, `-w <minutes>` | `sf project deploy start` |
|
|
32
32
|
| `sfd test` | Run Apex tests. `-n <class>`, `-t <Class.method>`, `-s <suite>`, `-l <level>`, `-c` (coverage), `-w <minutes>` | `sf apex run test --wait` |
|
|
33
33
|
| `sfd org` | Resolved username, org id, instance URL and API version | `sf org display` |
|
|
34
|
+
| `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) | |
|
|
34
35
|
| `sfd start \| stop \| status \| reload` | Daemon lifecycle; `reload` drops cached orgs and auth state | |
|
|
35
36
|
|
|
36
37
|
`-d`, `-m`, `-n`, `-t` and `-s` repeat or take comma-separated lists. Every
|
|
@@ -100,7 +101,8 @@ it down even if its protocol differs.
|
|
|
100
101
|
|
|
101
102
|
`npm test` covers project and org resolution, output formatting, the socket
|
|
102
103
|
protocol (dispatch, version restart, drain, idle, stale socket), and the CLI
|
|
103
|
-
end to end against stub handlers
|
|
104
|
+
end to end against stub handlers, and the agent hook adapters and `sf` rewrite
|
|
105
|
+
rules. Injected Salesforce modules cover handler
|
|
104
106
|
paging, auth refresh, deployment and test orchestration without an org.
|
|
105
107
|
A real-org check is opt-in:
|
|
106
108
|
|
|
@@ -124,3 +126,20 @@ See [SECURITY.md](SECURITY.md) for how to report vulnerabilities.
|
|
|
124
126
|
## License
|
|
125
127
|
|
|
126
128
|
[MIT](LICENSE) © Jacob Richter
|
|
129
|
+
|
|
130
|
+
## Agent integration
|
|
131
|
+
|
|
132
|
+
`sfd hook` rewrites supported `sf` shell commands before a coding agent runs
|
|
133
|
+
them, so they reach the warm daemon. Install an adapter for Claude Code, Codex
|
|
134
|
+
CLI or Gemini CLI (user scope by default):
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
sfd hook install --agent claude
|
|
138
|
+
sfd hook install --agent codex
|
|
139
|
+
sfd hook install --agent gemini
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Add `--project` to install into the current project instead. Preview a rewrite
|
|
143
|
+
with `sfd hook check '<sf command>'`. The supported commands, adapter details,
|
|
144
|
+
skip rules and the `SFD_HOOK_DISABLE=1` switch are in
|
|
145
|
+
[docs/agent-hooks.md](docs/agent-hooks.md).
|
package/bin/sfd.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { call, isRunning, startDaemon, DaemonError, VERSION } from "../src/clien
|
|
|
6
6
|
import { findProjectDir, resolveTargetOrg } from "../src/project.mjs";
|
|
7
7
|
import { formatApex, formatDeploy, formatTests } from "../src/format.mjs";
|
|
8
8
|
import { paths } from "../src/paths.mjs";
|
|
9
|
+
import { UsageError } from "../src/usage-error.mjs";
|
|
9
10
|
|
|
10
11
|
const USAGE = `sfd ${VERSION}: fast Salesforce CLI operations through a warm local daemon
|
|
11
12
|
|
|
@@ -21,6 +22,8 @@ Commands:
|
|
|
21
22
|
test Run Apex tests. -n <class> (repeatable), -t <Class.method> (repeatable),
|
|
22
23
|
-s <suite> (repeatable), -l <test level>, -c (coverage), -w <minutes>
|
|
23
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
|
|
24
27
|
start | stop | status | reload
|
|
25
28
|
Manage the daemon. reload drops cached orgs and auth state
|
|
26
29
|
|
|
@@ -53,8 +56,6 @@ const options = {
|
|
|
53
56
|
wait: { type: "string", short: "w" }
|
|
54
57
|
};
|
|
55
58
|
|
|
56
|
-
class UsageError extends Error {}
|
|
57
|
-
|
|
58
59
|
const split = list => list?.flatMap(v => v.split(",")).map(v => v.trim()).filter(Boolean);
|
|
59
60
|
|
|
60
61
|
function readStdin() {
|
|
@@ -86,6 +87,10 @@ function print(value) {
|
|
|
86
87
|
}
|
|
87
88
|
|
|
88
89
|
async function run(argv) {
|
|
90
|
+
if (argv[0] === "hook") {
|
|
91
|
+
const { runHook } = await import("../src/hooks.mjs");
|
|
92
|
+
return runHook(argv.slice(1));
|
|
93
|
+
}
|
|
89
94
|
const { values, positionals } = parseArgs({ args: argv, options, allowPositionals: true });
|
|
90
95
|
const [command, ...rest] = positionals;
|
|
91
96
|
if (values.help || !command) { print(USAGE); return 0; }
|
package/package.json
CHANGED
package/src/hooks.mjs
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, copyFileSync, renameSync, unlinkSync, statSync, constants } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import { rewrite } from "./rewrite.mjs";
|
|
6
|
+
import { UsageError } from "./usage-error.mjs";
|
|
7
|
+
|
|
8
|
+
const USAGE = "Usage: sfd hook claude|codex|gemini|check <command>|install|uninstall --agent <agent> [--project]";
|
|
9
|
+
|
|
10
|
+
const agents = {
|
|
11
|
+
claude: { directory: ".claude", file: "settings.json", event: "PreToolUse", tool: "Bash" },
|
|
12
|
+
codex: { directory: ".codex", file: "hooks.json", event: "PreToolUse", tool: "Bash" },
|
|
13
|
+
gemini: { directory: ".gemini", file: "settings.json", event: "BeforeTool", tool: "run_shell_command" }
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function handle(agent, inputJson, env = process.env) {
|
|
17
|
+
try {
|
|
18
|
+
if (env.SFD_HOOK_DISABLE === "1") return "";
|
|
19
|
+
const config = agents[agent];
|
|
20
|
+
if (!config) return "";
|
|
21
|
+
const input = JSON.parse(inputJson);
|
|
22
|
+
if (input?.tool_name !== config.tool || (input.hook_event_name && input.hook_event_name !== config.event)) return "";
|
|
23
|
+
if (!input.tool_input || typeof input.tool_input.command !== "string") return "";
|
|
24
|
+
const result = rewrite(input.tool_input.command);
|
|
25
|
+
if (result.skip) return "";
|
|
26
|
+
const updatedInput = { ...input.tool_input, command: result.command };
|
|
27
|
+
if (agent === "gemini") return JSON.stringify({ hookSpecificOutput: { tool_input: updatedInput } });
|
|
28
|
+
return JSON.stringify({ hookSpecificOutput: {
|
|
29
|
+
hookEventName: "PreToolUse",
|
|
30
|
+
...(agent === "codex" ? { permissionDecision: "allow" } : {}),
|
|
31
|
+
permissionDecisionReason: result.reason,
|
|
32
|
+
updatedInput
|
|
33
|
+
} });
|
|
34
|
+
} catch { return ""; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function object(value) { return value !== null && typeof value === "object" && !Array.isArray(value); }
|
|
38
|
+
|
|
39
|
+
export function editSettings(action, agent, { project = false, cwd = process.cwd(), home = homedir() } = {}) {
|
|
40
|
+
const config = agents[agent];
|
|
41
|
+
if (!config || !["install", "uninstall"].includes(action)) throw new UsageError("Expected install|uninstall --agent claude|codex|gemini [--project]");
|
|
42
|
+
const file = join(project ? cwd : home, config.directory, config.file);
|
|
43
|
+
let original, settings = {};
|
|
44
|
+
try { original = readFileSync(file, "utf8"); settings = JSON.parse(original); }
|
|
45
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
46
|
+
if (!object(settings) || (settings.hooks !== undefined && !object(settings.hooks))) throw Error(`Invalid hooks settings in ${file}`);
|
|
47
|
+
const entries = settings.hooks?.[config.event] ?? [];
|
|
48
|
+
if (!Array.isArray(entries) || entries.some(entry => !object(entry) || !Array.isArray(entry.hooks))) throw Error(`Invalid ${config.event} settings in ${file}`);
|
|
49
|
+
const isSfd = hook => hook?.type === "command" && typeof hook.command === "string" && new RegExp(`^sfd hook ${agent}(?:\\s|$)`).test(hook.command);
|
|
50
|
+
const exists = entries.some(entry => entry.hooks.some(isSfd));
|
|
51
|
+
if ((action === "install") === exists) return { file, changed: false };
|
|
52
|
+
let updated;
|
|
53
|
+
if (action === "install") {
|
|
54
|
+
updated = [...entries, { matcher: config.tool, hooks: [{ type: "command", command: `sfd hook ${agent}` }] }];
|
|
55
|
+
} else {
|
|
56
|
+
updated = entries.flatMap(entry => {
|
|
57
|
+
const hooks = entry.hooks.filter(hook => !isSfd(hook));
|
|
58
|
+
return hooks.length === entry.hooks.length ? [entry] : hooks.length ? [{ ...entry, hooks }] : [];
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
settings.hooks ??= {};
|
|
62
|
+
settings.hooks[config.event] = updated;
|
|
63
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
64
|
+
if (original !== undefined) {
|
|
65
|
+
try { copyFileSync(file, `${file}.bak`, constants.COPYFILE_EXCL); }
|
|
66
|
+
catch (error) { if (error.code !== "EEXIST") throw error; }
|
|
67
|
+
}
|
|
68
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
69
|
+
try {
|
|
70
|
+
writeFileSync(temporary, JSON.stringify(settings, null, 2) + "\n", { flag: "wx", mode: original === undefined ? 0o600 : statSync(file).mode & 0o777 });
|
|
71
|
+
renameSync(temporary, file);
|
|
72
|
+
} finally {
|
|
73
|
+
try { unlinkSync(temporary); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
74
|
+
}
|
|
75
|
+
return { file, changed: true };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function runHook(argv) {
|
|
79
|
+
const [action, ...args] = argv;
|
|
80
|
+
if (action === "--help" || action === "-h") {
|
|
81
|
+
process.stdout.write(`${USAGE}\n`);
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
if (Object.hasOwn(agents, action)) {
|
|
85
|
+
// Adapter errors must never change whether the original tool call can run.
|
|
86
|
+
try { process.stdout.write(handle(action, readFileSync(0, "utf8"))); } catch { /* no opinion */ }
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
if (action === "check") {
|
|
90
|
+
const result = process.env.SFD_HOOK_DISABLE === "1" ? { skip: "SFD_HOOK_DISABLE=1" } : rewrite(args.join(" "));
|
|
91
|
+
process.stdout.write(result.skip ? `skip: ${result.skip}\n` : `→ ${result.command}\n`);
|
|
92
|
+
return result.skip ? 1 : 0;
|
|
93
|
+
}
|
|
94
|
+
if (["install", "uninstall"].includes(action)) {
|
|
95
|
+
const { values } = parseArgs({ args, options: { agent: { type: "string" }, project: { type: "boolean" } } });
|
|
96
|
+
const result = editSettings(action, values.agent, { project: values.project });
|
|
97
|
+
process.stdout.write(`${result.changed ? "Updated" : "Unchanged"} ${result.file}\n`);
|
|
98
|
+
if (action === "install" && values.agent === "codex") process.stdout.write("Review and trust the hook with /hooks in Codex before it can run.\n");
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
throw new UsageError(USAGE);
|
|
102
|
+
}
|
package/src/rewrite.mjs
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// This is deliberately a literal-shell subset. Unknown shell syntax stays with sf.
|
|
2
|
+
const quote = value => `'${value.replaceAll("'", "'\\''")}'`;
|
|
3
|
+
const skip = reason => ({ skip: reason });
|
|
4
|
+
|
|
5
|
+
function lex(source) {
|
|
6
|
+
const tokens = [];
|
|
7
|
+
let i = 0;
|
|
8
|
+
while (i < source.length) {
|
|
9
|
+
if (/[ \t\r]/.test(source[i])) { i++; continue; }
|
|
10
|
+
const start = i;
|
|
11
|
+
const op = /^(?:2>&1|2>\/dev\/null|&&|\|\||>>|<<|[|;&<>\n])/.exec(source.slice(i));
|
|
12
|
+
if (op) {
|
|
13
|
+
tokens.push({ op: op[0], start, end: i += op[0].length });
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
let value = "", mode = "";
|
|
17
|
+
while (i < source.length) {
|
|
18
|
+
const c = source[i];
|
|
19
|
+
if (!mode && /[ \t\r\n|;&<>]/.test(c)) break;
|
|
20
|
+
if (c === "'" && mode !== '"') { mode = mode === "'" ? "" : "'"; i++; continue; }
|
|
21
|
+
if (c === '"' && mode !== "'") { mode = mode === '"' ? "" : '"'; i++; continue; }
|
|
22
|
+
if (c === "\\" && mode !== "'") {
|
|
23
|
+
if (++i === source.length) throw Error("incomplete escape");
|
|
24
|
+
if (mode === '"' && !/[\$`"\\]/.test(source[i])) value += "\\";
|
|
25
|
+
value += source[i++];
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (mode !== "'" && /[$`]/.test(c)) throw Error("shell expansion or command substitution");
|
|
29
|
+
if (!mode && /[(){}*?\[\]~#]/.test(c)) throw Error("unsupported shell syntax");
|
|
30
|
+
value += c; i++;
|
|
31
|
+
}
|
|
32
|
+
if (mode) throw Error("unclosed quote");
|
|
33
|
+
tokens.push({ value, start, end: i });
|
|
34
|
+
}
|
|
35
|
+
return tokens;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// [sf names, sfd flag (empty means drop), takes value, repeatable]
|
|
39
|
+
const org = ["o target-org", "-o", true];
|
|
40
|
+
const json = ["json", "", false];
|
|
41
|
+
const tooling = ["t use-tooling-api", "--tooling", false];
|
|
42
|
+
const format = ["r result-format", "", true];
|
|
43
|
+
const wait = ["w wait", "-w", true];
|
|
44
|
+
const level = ["l test-level", "-l", true];
|
|
45
|
+
const tests = ["t tests", "-t", true, true];
|
|
46
|
+
const mappings = [
|
|
47
|
+
{ sf: "data query", sfd: "query", flags: [org, json, tooling, ["q query", "", true], ["all-rows", "--all", false], format], required: "q" },
|
|
48
|
+
{ sf: "apex run test", sfd: "test", flags: [org, json, wait, level, tests, format, ["n class-names", "-n", true, true], ["s suite-names", "-s", true, true], ["c code-coverage", "-c", false], ["y synchronous", "", false]] },
|
|
49
|
+
{ sf: "apex run", sfd: "apex", flags: [org, json, ["f file", "-f", true]] },
|
|
50
|
+
{ sf: "project deploy start", sfd: "deploy", flags: [org, json, wait, level, tests, ["d source-dir", "-d", true, true], ["m metadata", "-m", true, true], ["dry-run", "--check-only", false]] },
|
|
51
|
+
{ sf: "sobject describe", sfd: "describe", flags: [org, json, tooling, ["s sobject", "", true]], required: "s" },
|
|
52
|
+
{ sf: "api request rest", sfd: "rest", flags: [org, ["X method", "-X", true], ["b body", "--body", true]], positional: true },
|
|
53
|
+
{ sf: "org display", sfd: "org", flags: [org, json] }
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
function mapArgs(argv) {
|
|
57
|
+
const mapping = mappings.find(m => m.sf.split(" ").every((v, i) => argv[i] === v));
|
|
58
|
+
if (!mapping) return skip("unsupported sf command");
|
|
59
|
+
const args = argv.slice(mapping.sf.split(" ").length);
|
|
60
|
+
const seen = new Map(), output = [], positionals = [];
|
|
61
|
+
for (let i = 0; i < args.length; i++) {
|
|
62
|
+
const arg = args[i];
|
|
63
|
+
if (!arg.startsWith("-")) { positionals.push(arg); continue; }
|
|
64
|
+
const match = /^(?:--([^=]+)|-([A-Za-z]))(?:=(.*))?$/.exec(arg);
|
|
65
|
+
const name = match?.[1] ?? match?.[2];
|
|
66
|
+
const spec = mapping.flags.find(f => f[0].split(" ").includes(name));
|
|
67
|
+
if (!spec) return skip(`unsupported flag ${arg.split("=")[0]}`);
|
|
68
|
+
const [names, target, takesValue, multiple] = spec;
|
|
69
|
+
const key = names.split(" ")[0];
|
|
70
|
+
if (seen.has(key) && !multiple) return skip(`duplicate flag ${arg.split("=")[0]}`);
|
|
71
|
+
let values = [];
|
|
72
|
+
if (takesValue) {
|
|
73
|
+
const value = match[3] ?? args[++i];
|
|
74
|
+
if (value === undefined || value === "") return skip(`missing value for ${arg}`);
|
|
75
|
+
if (value.startsWith("-")) return skip(`unsupported value for ${arg}`);
|
|
76
|
+
values.push(value);
|
|
77
|
+
if (multiple && match[3] === undefined) {
|
|
78
|
+
while (args[i + 1] !== undefined && !args[i + 1].startsWith("-")) values.push(args[++i]);
|
|
79
|
+
}
|
|
80
|
+
} else if (match[3] !== undefined) return skip(`unsupported boolean value ${arg}`);
|
|
81
|
+
seen.set(key, values);
|
|
82
|
+
if (key === "r" && !["human", "json"].includes(values[0])) return skip(`unsupported result format ${values[0]}`);
|
|
83
|
+
if (key === "w" && (!Number.isFinite(Number(values[0])) || Number(values[0]) <= 0)) return skip("wait must be positive");
|
|
84
|
+
if (target) {
|
|
85
|
+
if (takesValue) for (const v of values) output.push(target, quote(v));
|
|
86
|
+
else output.push(target);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (mapping.required && !seen.has(mapping.required)) return skip(`missing required ${mapping.required} flag`);
|
|
90
|
+
if (mapping.sfd === "deploy" && !seen.has("d") && !seen.has("m")) return skip("deploy needs source-dir or metadata");
|
|
91
|
+
if (mapping.sfd === "test" && !seen.has("w") && !seen.has("y")) return skip("asynchronous tests need --wait or --synchronous");
|
|
92
|
+
if (mapping.sfd === "rest") {
|
|
93
|
+
const method = seen.get("X")?.[0] ?? "GET";
|
|
94
|
+
if (!["GET", "POST", "PUT", "PATCH", "HEAD", "DELETE", "OPTIONS", "TRACE"].includes(method)) return skip("unsupported REST method");
|
|
95
|
+
// sf ignores a body with GET; sfd infers POST from a body instead.
|
|
96
|
+
if (seen.has("b") && method === "GET") return skip("REST body with implicit or explicit GET is unsupported");
|
|
97
|
+
}
|
|
98
|
+
if (mapping.positional ? positionals.length !== 1 : positionals.length !== 0) return skip("unsupported positional arguments");
|
|
99
|
+
const positional = mapping.required ? seen.get(mapping.required) : positionals;
|
|
100
|
+
// Protect sfd's own option parser from values that could be interpreted as options.
|
|
101
|
+
if (positional.some(v => v.startsWith("-"))) return skip("unsupported positional value");
|
|
102
|
+
return { command: ["sfd", mapping.sfd, ...positional.map(quote), ...output].join(" "), apex: mapping.sfd === "apex" };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function rewrite(command) {
|
|
106
|
+
if (typeof command !== "string" || !command.trim()) return skip("empty command");
|
|
107
|
+
try {
|
|
108
|
+
return rewriteShell(command);
|
|
109
|
+
} catch (error) { return skip(error.message); }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function rewriteShell(command) {
|
|
113
|
+
// Only remove continuations outside single quotes; the lexer does the rest.
|
|
114
|
+
let source = "", single = false, double = false;
|
|
115
|
+
for (let i = 0; i < command.length; i++) {
|
|
116
|
+
const c = command[i];
|
|
117
|
+
if (c === "\n" && !single && !double) { source += command.slice(i); break; }
|
|
118
|
+
if (c === "\\" && !single) {
|
|
119
|
+
if (command[i + 1] === "\n") { i++; continue; }
|
|
120
|
+
source += c + (command[++i] ?? ""); continue;
|
|
121
|
+
}
|
|
122
|
+
if (c === "'" && !double) single = !single;
|
|
123
|
+
if (c === '"' && !single) double = !double;
|
|
124
|
+
source += c;
|
|
125
|
+
}
|
|
126
|
+
source = source.trimStart();
|
|
127
|
+
// Heredoc contents are opaque: preserve them byte for byte and require one literal delimiter.
|
|
128
|
+
const newline = source.indexOf("\n");
|
|
129
|
+
let body = "";
|
|
130
|
+
if (newline !== -1) { body = source.slice(newline); source = source.slice(0, newline); }
|
|
131
|
+
const tokens = lex(source);
|
|
132
|
+
let stderr = "";
|
|
133
|
+
while (["2>&1", "2>/dev/null"].includes(tokens.at(-1)?.op)) {
|
|
134
|
+
stderr = ` ${tokens.pop().op}${stderr}`;
|
|
135
|
+
}
|
|
136
|
+
let heredoc = "";
|
|
137
|
+
const hd = tokens.findIndex(t => t.op === "<<");
|
|
138
|
+
if (hd !== -1) {
|
|
139
|
+
const delimiter = tokens[hd + 1];
|
|
140
|
+
if (!delimiter || delimiter.op || hd + 2 !== tokens.length || !/^[A-Za-z_][\w]*$/.test(delimiter.value)) return skip("unsupported heredoc");
|
|
141
|
+
const contents = body.slice(1);
|
|
142
|
+
const lines = (contents.endsWith("\n") ? contents.slice(0, -1) : contents).split("\n");
|
|
143
|
+
if (lines.at(-1) !== delimiter.value || lines.slice(0, -1).includes(delimiter.value)) return skip("unsupported heredoc terminator");
|
|
144
|
+
heredoc = source.slice(tokens[hd].start, delimiter.end) + body;
|
|
145
|
+
tokens.splice(hd);
|
|
146
|
+
} else if (body.trim()) return skip("unsupported multiline command");
|
|
147
|
+
while (["2>&1", "2>/dev/null"].includes(tokens.at(-1)?.op)) {
|
|
148
|
+
stderr = ` ${tokens.pop().op}${stderr}`;
|
|
149
|
+
}
|
|
150
|
+
if (tokens.some(t => t.op && !["&&", "|"].includes(t.op))) return skip("output redirection or unsupported shell chain");
|
|
151
|
+
const chains = tokens.flatMap((t, i) => t.op === "&&" ? [i] : []);
|
|
152
|
+
let start = 0;
|
|
153
|
+
if (chains.length) {
|
|
154
|
+
if (chains.length !== 1 || chains[0] !== 2 || tokens[0].value !== "cd" || !tokens[1].value || tokens[1].value.startsWith("-")) return skip("unsupported && chain");
|
|
155
|
+
start = 3;
|
|
156
|
+
}
|
|
157
|
+
const pipes = tokens.flatMap((t, i) => t.op === "|" ? [i] : []);
|
|
158
|
+
if (pipes.length) {
|
|
159
|
+
if (pipes.some(i => i <= start || i === tokens.length - 1 || tokens[i - 1].op)) return skip("unsupported pipeline");
|
|
160
|
+
// Never rewrite sf whose stdout is consumed, even if a later segment is also sf.
|
|
161
|
+
for (const i of [start, ...pipes.map(p => p + 1)].slice(0, -1)) {
|
|
162
|
+
if (tokens[i]?.value === "sf" || tokens[i]?.value?.startsWith("SF_TARGET_ORG=")) return skip("sf output is piped");
|
|
163
|
+
}
|
|
164
|
+
start = pipes.at(-1) + 1;
|
|
165
|
+
}
|
|
166
|
+
if (tokens[start]?.value?.startsWith("SF_TARGET_ORG=")) start++;
|
|
167
|
+
if (tokens[start]?.value !== "sf") return skip("not a plain sf invocation (paths, env prefixes and sfdx are unsupported)");
|
|
168
|
+
if (tokens.slice(start).some(t => t.op)) return skip("unsupported shell operator");
|
|
169
|
+
const mapped = mapArgs(tokens.slice(start + 1).map(t => t.value));
|
|
170
|
+
if (mapped.skip) return mapped;
|
|
171
|
+
if ((pipes.length || heredoc) && !mapped.apex) return skip("stdin is supported only for apex run");
|
|
172
|
+
return { command: source.slice(0, tokens[start].start) + mapped.command + stderr + (heredoc ? ` ${heredoc}` : ""), reason: "sfd: warm daemon, ~0.3 s" };
|
|
173
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export class UsageError extends Error {}
|