@syntax-syllogism/sfd 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/paths.mjs CHANGED
@@ -1,16 +1,34 @@
1
1
  import { mkdirSync, chmodSync } from "node:fs";
2
- import { tmpdir } from "node:os";
2
+ import { tmpdir, userInfo } from "node:os";
3
+ import { createHash } from "node:crypto";
3
4
  import { join } from "node:path";
4
5
 
5
6
  // Unix socket paths are capped near 108 bytes, so keep the runtime dir short.
6
- export function runtimeDir(env = process.env) {
7
+ export function runtimeDir(env = process.env, platform = process.platform) {
8
+ if (platform === "win32") {
9
+ const base = env.SFD_RUNTIME_DIR ?? join(env.LOCALAPPDATA ?? tmpdir(), "sfd");
10
+ mkdirSync(base, { recursive: true });
11
+ return base;
12
+ }
7
13
  const base = env.SFD_RUNTIME_DIR ?? join(env.XDG_RUNTIME_DIR ?? tmpdir(), `sfd-${process.getuid()}`);
8
14
  mkdirSync(base, { recursive: true, mode: 0o700 });
9
15
  chmodSync(base, 0o700);
10
16
  return base;
11
17
  }
12
18
 
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") };
19
+ // Windows has no Unix sockets in the filesystem; the daemon listens on a named pipe instead.
20
+ // Pipe names are machine-wide, so derive one per user and runtime dir.
21
+ export function socketPath(dir, platform = process.platform) {
22
+ if (platform !== "win32") return join(dir, "sfd.sock");
23
+ const id = createHash("sha256").update(`${userInfo().username}\0${dir.toLowerCase()}`).digest("hex").slice(0, 16);
24
+ return `\\\\.\\pipe\\sfd-${id}`;
25
+ }
26
+
27
+ export function isPipe(socket) {
28
+ return socket.startsWith("\\\\.\\pipe\\");
29
+ }
30
+
31
+ export function paths(env = process.env, platform = process.platform) {
32
+ const dir = runtimeDir(env, platform);
33
+ return { dir, socket: socketPath(dir, platform), log: join(dir, "sfd.log"), pid: join(dir, "sfd.pid") };
16
34
  }
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,12 +44,18 @@ 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]] },
49
+ { sf: "apex list log", sfd: "logs", flags: [org, json] },
50
+ { sf: "apex get log", sfd: "logs get", flags: [org, json, ["i log-id", "", true], ["n number", "", true]] },
47
51
  { sf: "data query", sfd: "query", flags: [org, json, tooling, ["q query", "", true], ["all-rows", "--all", false], format], required: "q" },
48
52
  { 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
53
  { sf: "apex run", sfd: "apex", flags: [org, json, ["f file", "-f", true]] },
54
+ { sf: "project retrieve start", sfd: "retrieve", flags: [org, json, wait, ["d source-dir", "-d", true, true], ["m metadata", "-m", true, true], ["x manifest", "-x", true], ["c ignore-conflicts", "--ignore-conflicts", false]] },
50
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]] },
51
56
  { sf: "sobject describe", sfd: "describe", flags: [org, json, tooling, ["s sobject", "", true]], required: "s" },
52
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]] },
53
59
  { sf: "org display", sfd: "org", flags: [org, json] }
54
60
  ];
55
61
 
@@ -79,15 +85,21 @@ function mapArgs(argv) {
79
85
  }
80
86
  } else if (match[3] !== undefined) return skip(`unsupported boolean value ${arg}`);
81
87
  seen.set(key, values);
82
- if (key === "r" && !["human", "json"].includes(values[0])) return skip(`unsupported result format ${values[0]}`);
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
+ }
83
92
  if (key === "w" && (!Number.isFinite(Number(values[0])) || Number(values[0]) <= 0)) return skip("wait must be positive");
84
93
  if (target) {
85
94
  if (takesValue) for (const v of values) output.push(target, quote(v));
86
95
  else output.push(target);
87
96
  }
88
97
  }
98
+ if (mapping.sfd === "org list" && !seen.has("skip-connection-status")) output.push("--status");
89
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");
90
101
  if (mapping.sfd === "deploy" && !seen.has("d") && !seen.has("m")) return skip("deploy needs source-dir or metadata");
102
+ if (mapping.sfd === "retrieve" && ["d", "m", "x"].filter(key => seen.has(key)).length > 1) return skip("retrieve selectors are mutually exclusive");
91
103
  if (mapping.sfd === "test" && !seen.has("w") && !seen.has("y")) return skip("asynchronous tests need --wait or --synchronous");
92
104
  if (mapping.sfd === "rest") {
93
105
  const method = seen.get("X")?.[0] ?? "GET";
@@ -96,7 +108,14 @@ function mapArgs(argv) {
96
108
  if (seen.has("b") && method === "GET") return skip("REST body with implicit or explicit GET is unsupported");
97
109
  }
98
110
  if (mapping.positional ? positionals.length !== 1 : positionals.length !== 0) return skip("unsupported positional arguments");
99
- const positional = mapping.required ? seen.get(mapping.required) : positionals;
111
+ let logTarget;
112
+ if (mapping.sfd === "logs get") {
113
+ if (seen.has("i") && seen.has("n")) return skip("choose log-id or number");
114
+ if (seen.has("i")) logTarget = seen.get("i")[0];
115
+ else if (seen.get("n")?.[0] === "1") logTarget = "latest";
116
+ else return skip("log get needs log-id or --number 1");
117
+ }
118
+ const positional = logTarget ? [logTarget] : mapping.required ? seen.get(mapping.required) : positionals;
100
119
  // Protect sfd's own option parser from values that could be interpreted as options.
101
120
  if (positional.some(v => v.startsWith("-"))) return skip("unsupported positional value");
102
121
  return { command: ["sfd", mapping.sfd, ...positional.map(quote), ...output].join(" "), apex: mapping.sfd === "apex" };
package/src/server.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import net from "node:net";
2
2
  import { chmodSync, unlinkSync } from "node:fs";
3
+ import { isPipe } from "./paths.mjs";
3
4
 
4
5
  // Newline-delimited JSON over a Unix socket: one request and one response per connection.
5
6
  // Responses are {status: 0, result} | {status: 1, name, message} | {restart: true}.
@@ -40,7 +41,7 @@ export async function startServer({ socket, version, handlers, idleMs = 60 * 60_
40
41
  draining = reason;
41
42
  clearTimeout(idleTimer);
42
43
  server.close();
43
- try { unlinkSync(socket); } catch {}
44
+ if (!isPipe(socket)) try { unlinkSync(socket); } catch {}
44
45
  maybeExit();
45
46
  };
46
47
  const armIdle = () => {
@@ -57,7 +58,7 @@ export async function startServer({ socket, version, handlers, idleMs = 60 * 60_
57
58
  try {
58
59
  return { status: 0, result: await handler(req) };
59
60
  } catch (e) {
60
- return { status: 1, name: e.name ?? "Error", message: e.message ?? String(e), errorCode: e.errorCode };
61
+ return { status: 1, name: e.name ?? "Error", message: e.message ?? String(e), errorCode: e.errorCode, ...(e.trace ? { trace: e.trace } : {}) };
61
62
  }
62
63
  }
63
64
 
@@ -90,10 +91,12 @@ export async function startServer({ socket, version, handlers, idleMs = 60 * 60_
90
91
  } catch (e) {
91
92
  if (e.code !== "EADDRINUSE") throw e;
92
93
  if (await probe(socket)) throw new AlreadyRunning("sfd is already running");
94
+ // A named pipe disappears with its owner, so only a Unix socket file can be stale.
95
+ if (isPipe(socket)) throw e;
93
96
  unlinkSync(socket);
94
97
  await listenOnce(server, socket);
95
98
  }
96
- chmodSync(socket, 0o600);
99
+ if (!isPipe(socket)) chmodSync(socket, 0o600);
97
100
  armIdle();
98
101
  return { server, drain };
99
102
  }
package/src/sf-root.mjs CHANGED
@@ -5,17 +5,27 @@ import { createRequire } from "node:module";
5
5
 
6
6
  // sfd loads @salesforce/core, SDR, apex-node and source-tracking from the installed sf CLI,
7
7
  // so auth files, API versions and metadata registries always match what `sf` itself uses.
8
- export function findSfRoot(env = process.env) {
8
+ export function findSfRoot(env = process.env, platform = process.platform) {
9
9
  if (env.SFD_SF_ROOT) return env.SFD_SF_ROOT;
10
- const bin = execFileSync("which", ["sf"], { encoding: "utf8" }).trim();
10
+ const bin = whichSf(platform);
11
11
  for (let dir = dirname(realpathSync(bin)); dirname(dir) !== dir; dir = dirname(dir)) {
12
- try {
13
- if (JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).name === "@salesforce/cli") return dir;
14
- } catch {}
12
+ // npm installs on Windows put an sf.cmd shim next to node_modules instead of a symlink.
13
+ for (const candidate of [dir, join(dir, "node_modules", "@salesforce", "cli")]) {
14
+ try {
15
+ if (JSON.parse(readFileSync(join(candidate, "package.json"), "utf8")).name === "@salesforce/cli") return candidate;
16
+ } catch {}
17
+ }
15
18
  }
16
19
  throw new Error(`Could not locate the @salesforce/cli install behind ${bin}; set SFD_SF_ROOT.`);
17
20
  }
18
21
 
22
+ function whichSf(platform) {
23
+ if (platform !== "win32") return execFileSync("which", ["sf"], { encoding: "utf8" }).trim();
24
+ // `where` lists every match in PATH order; prefer the cmd shim over the extensionless sh script.
25
+ const found = execFileSync("where", ["sf"], { encoding: "utf8" }).split(/\r?\n/).filter(Boolean);
26
+ return found.find(f => /\.(cmd|exe)$/i.test(f)) ?? found[0];
27
+ }
28
+
19
29
  export function sfInfo(root) {
20
30
  const file = join(root, "package.json");
21
31
  return { root, version: JSON.parse(readFileSync(file, "utf8")).version, mtimeMs: statSync(file).mtimeMs };
@@ -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
+ }
@@ -1 +1,3 @@
1
- export class UsageError extends Error {}
1
+ export class UsageError extends Error {
2
+ name = "UsageError";
3
+ }