@syntax-syllogism/sfd 0.0.0-stage → 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 +29 -0
- package/LICENSE +21 -0
- package/README.md +144 -2
- package/bin/sfd.mjs +220 -0
- package/package.json +43 -4
- package/src/client.mjs +85 -0
- package/src/daemon.mjs +43 -0
- package/src/format.mjs +138 -0
- package/src/handlers.mjs +149 -0
- package/src/hooks.mjs +102 -0
- package/src/paths.mjs +16 -0
- package/src/project.mjs +33 -0
- package/src/rewrite.mjs +173 -0
- package/src/server.mjs +99 -0
- package/src/sf-root.mjs +26 -0
- package/src/usage-error.mjs +1 -0
- package/src/version.mjs +10 -0
package/src/format.mjs
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Compact, token-friendly renderings of Salesforce results.
|
|
2
|
+
|
|
3
|
+
const asArray = x => (x === undefined || x === null ? [] : Array.isArray(x) ? x : [x]);
|
|
4
|
+
|
|
5
|
+
export function stripAttributes(value) {
|
|
6
|
+
if (Array.isArray(value)) return value.map(stripAttributes);
|
|
7
|
+
if (value === null || typeof value !== "object") return value;
|
|
8
|
+
const out = {};
|
|
9
|
+
for (const [key, inner] of Object.entries(value)) {
|
|
10
|
+
if (key !== "attributes") out[key] = stripAttributes(inner);
|
|
11
|
+
}
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function compactQuery(result) {
|
|
16
|
+
return { totalSize: result.totalSize, done: result.done, ...(result.nextRecordsUrl ? { nextRecordsUrl: result.nextRecordsUrl } : {}), records: stripAttributes(result.records ?? []) };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function compactDescribe(d) {
|
|
20
|
+
return {
|
|
21
|
+
name: d.name, label: d.label, keyPrefix: d.keyPrefix, custom: d.custom,
|
|
22
|
+
createable: d.createable, updateable: d.updateable, deletable: d.deletable,
|
|
23
|
+
fields: d.fields.map(f => {
|
|
24
|
+
const field = { name: f.name, type: f.type, label: f.label };
|
|
25
|
+
if (f.length) field.length = f.length;
|
|
26
|
+
if (f.referenceTo?.length) field.referenceTo = f.referenceTo;
|
|
27
|
+
if (f.picklistValues?.length) field.picklistValues = f.picklistValues.filter(p => p.active).map(p => p.value);
|
|
28
|
+
if (!f.nillable && f.createable && !f.defaultedOnCreate) field.required = true;
|
|
29
|
+
if (!f.updateable) field.readOnly = true;
|
|
30
|
+
if (f.calculated) field.formula = true;
|
|
31
|
+
return field;
|
|
32
|
+
}),
|
|
33
|
+
childRelationships: d.childRelationships.filter(c => c.relationshipName).map(c => `${c.relationshipName} (${c.childSObject}.${c.field})`)
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const loc = d => (d.lineNumber ? ` [${d.lineNumber}:${d.columnNumber ?? ""}]` : "");
|
|
38
|
+
|
|
39
|
+
export function formatApex(result, { fullLog = false } = {}) {
|
|
40
|
+
const lines = [];
|
|
41
|
+
if (!result.compiled) {
|
|
42
|
+
lines.push("COMPILE ERROR");
|
|
43
|
+
for (const d of asArray(result.diagnostic)) lines.push(` ✖ ${d.compileProblem ?? d.exceptionMessage}${loc(d)}`);
|
|
44
|
+
return lines.join("\n");
|
|
45
|
+
}
|
|
46
|
+
lines.push(result.success ? "OK" : "EXCEPTION");
|
|
47
|
+
if (!result.success) {
|
|
48
|
+
for (const d of asArray(result.diagnostic)) {
|
|
49
|
+
lines.push(` ✖ ${d.exceptionMessage}`);
|
|
50
|
+
if (d.exceptionStackTrace) lines.push(` ${d.exceptionStackTrace}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const log = result.logs ?? "";
|
|
54
|
+
if (fullLog) {
|
|
55
|
+
lines.push(log);
|
|
56
|
+
} else {
|
|
57
|
+
// Keep only USER_DEBUG and fatal lines; the rest of a debug log is noise for agents.
|
|
58
|
+
for (const line of log.split("\n")) {
|
|
59
|
+
const m = line.match(/\|USER_DEBUG\|\[(\d+)\]\|\w+\|(.*)$/);
|
|
60
|
+
if (m) lines.push(`[${m[1]}] ${m[2]}`);
|
|
61
|
+
else if (/\|FATAL_ERROR\|/.test(line)) lines.push(line.replace(/^.*\|FATAL_ERROR\|/, "FATAL: "));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return lines.join("\n");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function formatDeploy(summary, org) {
|
|
68
|
+
const action = summary.checkOnly ? "Validated" : "Deployed";
|
|
69
|
+
const tests = summary.numberTestsTotal > 0
|
|
70
|
+
? `, tests ${summary.numberTestsCompleted}/${summary.numberTestsTotal}${summary.numberTestErrors ? ` (${summary.numberTestErrors} failed)` : ""}`
|
|
71
|
+
: "";
|
|
72
|
+
const head = summary.success
|
|
73
|
+
? `${action} ${summary.numberComponentsDeployed}/${summary.numberComponentsTotal} components${tests} to ${org} (${summary.id})`
|
|
74
|
+
: `FAILED (${summary.status}) ${org} (${summary.id})`;
|
|
75
|
+
const lines = [head];
|
|
76
|
+
for (const c of summary.componentFailures) lines.push(` ✖ ${c.type} ${c.fullName}: ${c.problem}${loc(c)}`);
|
|
77
|
+
for (const t of summary.testFailures) lines.push(` ✖ test ${t.name}.${t.methodName}: ${t.message}`);
|
|
78
|
+
for (const w of summary.coverageWarnings ?? []) lines.push(` ✖ coverage${w.name ? ` ${w.name}` : ""}: ${w.message}`);
|
|
79
|
+
if (summary.errorMessage && !summary.componentFailures.length) lines.push(` ${summary.errorMessage}`);
|
|
80
|
+
return lines.join("\n");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Reduce an SDR DeployResult response to the fields formatDeploy and --json callers need.
|
|
84
|
+
export function summarizeDeploy(response) {
|
|
85
|
+
const details = response.details ?? {};
|
|
86
|
+
return {
|
|
87
|
+
id: response.id,
|
|
88
|
+
status: response.status,
|
|
89
|
+
success: response.success,
|
|
90
|
+
checkOnly: response.checkOnly,
|
|
91
|
+
numberComponentsDeployed: response.numberComponentsDeployed,
|
|
92
|
+
numberComponentsTotal: response.numberComponentsTotal,
|
|
93
|
+
numberComponentErrors: response.numberComponentErrors,
|
|
94
|
+
numberTestsCompleted: response.numberTestsCompleted,
|
|
95
|
+
numberTestsTotal: response.numberTestsTotal,
|
|
96
|
+
numberTestErrors: response.numberTestErrors,
|
|
97
|
+
errorMessage: response.errorMessage,
|
|
98
|
+
componentFailures: asArray(details.componentFailures).map(c => ({
|
|
99
|
+
type: c.componentType, fullName: c.fullName, problem: c.problem, lineNumber: c.lineNumber, columnNumber: c.columnNumber
|
|
100
|
+
})),
|
|
101
|
+
testFailures: asArray(details.runTestResult?.failures).map(t => ({
|
|
102
|
+
name: t.name, methodName: t.methodName, message: t.message, stackTrace: t.stackTrace
|
|
103
|
+
})),
|
|
104
|
+
coverageWarnings: asArray(details.runTestResult?.codeCoverageWarnings).map(w => ({ name: w.name, message: w.message }))
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function summarizeTests(result) {
|
|
109
|
+
const s = result.summary;
|
|
110
|
+
return {
|
|
111
|
+
outcome: s.outcome, testRunId: s.testRunId, testsRan: s.testsRan, passing: s.passing, failing: s.failing,
|
|
112
|
+
skipped: s.skipped, testExecutionTimeInMs: s.testExecutionTimeInMs,
|
|
113
|
+
testRunCoverage: s.testRunCoverage, orgWideCoverage: s.orgWideCoverage,
|
|
114
|
+
failures: asArray(result.tests).filter(t => t.outcome !== "Pass" && t.outcome !== "Skip").map(t => ({
|
|
115
|
+
name: t.fullName ?? `${t.apexClass?.name}.${t.methodName}`, outcome: t.outcome, message: t.message, stackTrace: t.stackTrace
|
|
116
|
+
})),
|
|
117
|
+
setupFailures: asArray(result.setup).filter(s => s.message).map(s => ({ name: s.fullName, message: s.message, stackTrace: s.stackTrace })),
|
|
118
|
+
coverage: result.codecoverage
|
|
119
|
+
? result.codecoverage.map(c => ({ name: c.name, percentage: c.percentage, uncoveredLines: c.uncoveredLines }))
|
|
120
|
+
: undefined
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function formatTests(summary, org) {
|
|
125
|
+
const parts = [`${summary.testsRan} ran`, `${summary.passing} passed`, `${summary.failing} failed`];
|
|
126
|
+
if (summary.skipped) parts.push(`${summary.skipped} skipped`);
|
|
127
|
+
const lines = [`Tests ${summary.outcome} on ${org}: ${parts.join(", ")} in ${summary.testExecutionTimeInMs} ms (${summary.testRunId})`];
|
|
128
|
+
if (summary.testRunCoverage) lines.push(`Coverage: run ${summary.testRunCoverage}, org-wide ${summary.orgWideCoverage}`);
|
|
129
|
+
for (const s of summary.setupFailures) lines.push(` ✖ setup ${s.name}: ${s.message}`);
|
|
130
|
+
for (const f of summary.failures) {
|
|
131
|
+
lines.push(` ✖ ${f.name}: ${f.message}`);
|
|
132
|
+
if (f.stackTrace) lines.push(` ${f.stackTrace.replace(/\n/g, "\n ")}`);
|
|
133
|
+
}
|
|
134
|
+
for (const c of summary.coverage ?? []) {
|
|
135
|
+
lines.push(` ${c.percentage.padStart(4)} ${c.name}${c.uncoveredLines.length ? ` uncovered: ${c.uncoveredLines.join(",")}` : ""}`);
|
|
136
|
+
}
|
|
137
|
+
return lines.join("\n");
|
|
138
|
+
}
|
package/src/handlers.mjs
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { sfRequire } from "./sf-root.mjs";
|
|
5
|
+
import { compactDescribe, compactQuery, summarizeDeploy, summarizeTests } from "./format.mjs";
|
|
6
|
+
|
|
7
|
+
const SESSION_ERRORS = new Set(["INVALID_SESSION_ID", "INVALID_AUTH_HEADER"]);
|
|
8
|
+
|
|
9
|
+
function authStamp(home) {
|
|
10
|
+
// A login, logout or alias change touches ~/.sfdx or alias.json; reload org state when it does.
|
|
11
|
+
const stamp = path => { try { return statSync(path).mtimeMs; } catch { return 0; } };
|
|
12
|
+
return `${stamp(join(home, ".sfdx"))}:${stamp(join(home, ".sfdx", "alias.json"))}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// One queue for operations that touch the local project (deploy and source tracking):
|
|
16
|
+
// SDR and source-tracking keep process-wide state, so they must not interleave.
|
|
17
|
+
function serial() {
|
|
18
|
+
let tail = Promise.resolve();
|
|
19
|
+
return fn => {
|
|
20
|
+
const run = tail.then(fn, fn);
|
|
21
|
+
tail = run.catch(() => {});
|
|
22
|
+
return run;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createHandlers(sfRoot, { home = homedir(), load = sfRequire(sfRoot) } = {}) {
|
|
27
|
+
const req = load;
|
|
28
|
+
const core = req("@salesforce/core");
|
|
29
|
+
const { Duration } = req("@salesforce/kit");
|
|
30
|
+
const lazy = name => { let mod; return () => (mod ??= req(name)); };
|
|
31
|
+
const sdr = lazy("@salesforce/source-deploy-retrieve");
|
|
32
|
+
const apex = lazy("@salesforce/apex-node");
|
|
33
|
+
const tracking = lazy("@salesforce/source-tracking");
|
|
34
|
+
const projectQueue = serial();
|
|
35
|
+
|
|
36
|
+
const orgs = new Map();
|
|
37
|
+
let stamp = authStamp(home);
|
|
38
|
+
|
|
39
|
+
async function getOrg(alias) {
|
|
40
|
+
const now = authStamp(home);
|
|
41
|
+
if (now !== stamp) {
|
|
42
|
+
stamp = now;
|
|
43
|
+
orgs.clear();
|
|
44
|
+
await core.StateAggregator.clearInstanceAsync();
|
|
45
|
+
}
|
|
46
|
+
if (!orgs.has(alias)) {
|
|
47
|
+
const pending = core.Org.create({ aliasOrUsername: alias });
|
|
48
|
+
orgs.set(alias, pending);
|
|
49
|
+
pending.catch(() => orgs.delete(alias));
|
|
50
|
+
}
|
|
51
|
+
return orgs.get(alias);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Run fn with a cached connection; on an expired or revoked session, rebuild it once.
|
|
55
|
+
async function withConn(alias, fn) {
|
|
56
|
+
try {
|
|
57
|
+
const org = await getOrg(alias);
|
|
58
|
+
return await fn(org.getConnection(), org);
|
|
59
|
+
} catch (e) {
|
|
60
|
+
if (!SESSION_ERRORS.has(e.errorCode ?? e.name)) throw e;
|
|
61
|
+
orgs.delete(alias);
|
|
62
|
+
const org = await getOrg(alias);
|
|
63
|
+
return fn(org.getConnection(), org);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
ping: async () => ({ pong: true }),
|
|
69
|
+
|
|
70
|
+
status: async () => ({
|
|
71
|
+
pid: process.pid,
|
|
72
|
+
uptimeSeconds: Math.round(process.uptime()),
|
|
73
|
+
orgs: [...orgs.keys()]
|
|
74
|
+
}),
|
|
75
|
+
|
|
76
|
+
reload: async () => {
|
|
77
|
+
orgs.clear();
|
|
78
|
+
await core.StateAggregator.clearInstanceAsync();
|
|
79
|
+
return { reloaded: true };
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
org: ({ org }) => withConn(org, (conn, o) => ({
|
|
83
|
+
alias: org, username: o.getUsername(), orgId: o.getOrgId(), instanceUrl: conn.instanceUrl, apiVersion: conn.getApiVersion()
|
|
84
|
+
})),
|
|
85
|
+
|
|
86
|
+
query: ({ org, q, tooling, all, raw }) => withConn(org, async conn => {
|
|
87
|
+
const api = tooling ? conn.tooling : conn;
|
|
88
|
+
const result = await api.query(q, { scanAll: Boolean(all) });
|
|
89
|
+
while (!result.done) {
|
|
90
|
+
if (!result.nextRecordsUrl) throw new Error("Incomplete query result: missing nextRecordsUrl.");
|
|
91
|
+
const page = await api.queryMore(result.nextRecordsUrl);
|
|
92
|
+
result.records.push(...page.records);
|
|
93
|
+
result.done = page.done;
|
|
94
|
+
result.nextRecordsUrl = page.nextRecordsUrl;
|
|
95
|
+
}
|
|
96
|
+
return raw ? result : compactQuery(result);
|
|
97
|
+
}),
|
|
98
|
+
|
|
99
|
+
apex: ({ org, body }) => withConn(org, conn => new (apex().ExecuteService)(conn).executeAnonymous({ apexCode: body })),
|
|
100
|
+
|
|
101
|
+
rest: ({ org, method, url, body, headers }) => withConn(org, conn => conn.request({
|
|
102
|
+
method, url, body: body === undefined || typeof body === "string" ? body : JSON.stringify(body),
|
|
103
|
+
headers: { "content-type": "application/json", ...headers }
|
|
104
|
+
})),
|
|
105
|
+
|
|
106
|
+
describe: ({ org, sobject, tooling, raw }) => withConn(org, async conn => {
|
|
107
|
+
const d = await (tooling ? conn.tooling : conn).describe(sobject);
|
|
108
|
+
return raw ? d : compactDescribe(d);
|
|
109
|
+
}),
|
|
110
|
+
|
|
111
|
+
deploy: ({ org, projectDir, sourcePaths, metadata, checkOnly, testLevel, tests, waitMinutes = 33 }) =>
|
|
112
|
+
projectQueue(() => withConn(org, async (conn, o) => {
|
|
113
|
+
const { ComponentSetBuilder } = sdr();
|
|
114
|
+
core.SfProject.clearInstances();
|
|
115
|
+
const project = await core.SfProject.resolve(projectDir);
|
|
116
|
+
const packageDirs = project.getUniquePackageDirectories().map(d => d.fullPath);
|
|
117
|
+
const cs = await ComponentSetBuilder.build({
|
|
118
|
+
projectDir,
|
|
119
|
+
sourceapiversion: project.getSfProjectJson().get("sourceApiVersion"),
|
|
120
|
+
sourcepath: sourcePaths?.length ? sourcePaths : undefined,
|
|
121
|
+
metadata: metadata?.length ? { metadataEntries: metadata, directoryPaths: packageDirs } : undefined
|
|
122
|
+
});
|
|
123
|
+
if (cs.size === 0) throw new Error("Nothing to deploy: no source components matched.");
|
|
124
|
+
// The Metadata API serializes undefined as the string "undefined", so only set what was asked.
|
|
125
|
+
const apiOptions = { checkOnly: Boolean(checkOnly), rollbackOnError: true };
|
|
126
|
+
if (tests?.length) Object.assign(apiOptions, { testLevel: "RunSpecifiedTests", runTests: tests });
|
|
127
|
+
if (testLevel) apiOptions.testLevel = testLevel;
|
|
128
|
+
const deploy = await cs.deploy({ usernameOrConnection: conn, apiOptions });
|
|
129
|
+
const result = await deploy.pollStatus({ frequency: Duration.seconds(2), timeout: Duration.minutes(waitMinutes) });
|
|
130
|
+
// Keep scratch-org source tracking in step, as `sf project deploy start` does.
|
|
131
|
+
if (result.response.success && !checkOnly && (await o.tracksSource())) {
|
|
132
|
+
const st = await tracking().SourceTracking.create({ org: o, project });
|
|
133
|
+
await st.updateTrackingFromDeploy(result);
|
|
134
|
+
}
|
|
135
|
+
return summarizeDeploy(result.response);
|
|
136
|
+
})),
|
|
137
|
+
|
|
138
|
+
test: ({ org, classNames, tests, suiteNames, testLevel, codeCoverage, waitMinutes = 60 }) => withConn(org, async conn => {
|
|
139
|
+
const { TestService } = apex();
|
|
140
|
+
const service = new TestService(conn);
|
|
141
|
+
const level = testLevel ?? (classNames?.length || tests?.length || suiteNames?.length ? "RunSpecifiedTests" : "RunLocalTests");
|
|
142
|
+
// apex-node takes comma-separated strings, as the sf flags do.
|
|
143
|
+
const csv = list => (list?.length ? list.join(",") : undefined);
|
|
144
|
+
const payload = await service.buildAsyncPayload(level, csv(tests), csv(classNames), csv(suiteNames));
|
|
145
|
+
const result = await service.runTestAsynchronous(payload, Boolean(codeCoverage), false, undefined, undefined, Duration.minutes(waitMinutes));
|
|
146
|
+
return summarizeTests(result);
|
|
147
|
+
})
|
|
148
|
+
};
|
|
149
|
+
}
|
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/paths.mjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { mkdirSync, chmodSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
// Unix socket paths are capped near 108 bytes, so keep the runtime dir short.
|
|
6
|
+
export function runtimeDir(env = process.env) {
|
|
7
|
+
const base = env.SFD_RUNTIME_DIR ?? join(env.XDG_RUNTIME_DIR ?? tmpdir(), `sfd-${process.getuid()}`);
|
|
8
|
+
mkdirSync(base, { recursive: true, mode: 0o700 });
|
|
9
|
+
chmodSync(base, 0o700);
|
|
10
|
+
return base;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function paths(env = process.env) {
|
|
14
|
+
const dir = runtimeDir(env);
|
|
15
|
+
return { dir, socket: join(dir, "sfd.sock"), log: join(dir, "sfd.log"), pid: join(dir, "sfd.pid") };
|
|
16
|
+
}
|
package/src/project.mjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
function walkUp(start, test) {
|
|
6
|
+
for (let dir = resolve(start); ; dir = dirname(dir)) {
|
|
7
|
+
if (test(dir)) return dir;
|
|
8
|
+
if (dirname(dir) === dir) return undefined;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function findProjectDir(cwd) {
|
|
13
|
+
return walkUp(cwd, dir => existsSync(join(dir, "sfdx-project.json")));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function readTargetOrg(file) {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(readFileSync(file, "utf8"))["target-org"];
|
|
19
|
+
} catch {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Same precedence as sf: flag, SF_TARGET_ORG, nearest local .sf/config.json, then global config.
|
|
25
|
+
export function resolveTargetOrg(flag, cwd, env = process.env, home = homedir()) {
|
|
26
|
+
if (flag) return flag;
|
|
27
|
+
if (env.SF_TARGET_ORG) return env.SF_TARGET_ORG;
|
|
28
|
+
const local = walkUp(cwd, dir => dir !== home && readTargetOrg(join(dir, ".sf", "config.json")) !== undefined);
|
|
29
|
+
if (local) return readTargetOrg(join(local, ".sf", "config.json"));
|
|
30
|
+
const global = readTargetOrg(join(home, ".sf", "config.json"));
|
|
31
|
+
if (global) return global;
|
|
32
|
+
throw new Error("No target org. Pass -o <alias> or set target-org with `sf config set target-org`.");
|
|
33
|
+
}
|
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
|
+
}
|