diff --git a/README.md b/README.md index d4279b9..7179c9c 100644 --- a/README.md +++ b/README.md @@ -42,11 +42,12 @@ An Obsidian plugin that publishes the active note as a Jekyll blog post via git. ## Credentials model -The plugin uses your **system git** binary for all remote operations. No passwords or tokens are stored inside Obsidian or the plugin's data files. +The plugin uses your **system git** binary for all remote operations and stores **no** passwords or tokens inside Obsidian or the plugin's data files. Authentication is delegated entirely to git's own credential handling, which works across platforms: -When git needs a credential (e.g. HTTPS password or a personal access token), an **askpass bridge** intercepts the prompt and shows a native Obsidian modal so you can type the value. The value is passed directly to git through a temporary socket and is never persisted. +- **HTTPS remotes** — git uses your configured credential helper: **Git Credential Manager** (bundled with Git for Windows), **osxkeychain** (macOS), or libsecret (Linux). The first push prompts you through that helper's own dialog and caches the result in your OS keychain. If no helper is configured, the publish fails fast with a clear message (the plugin sets `GIT_TERMINAL_PROMPT=0` so git never hangs waiting on a non-existent terminal). +- **SSH remotes** — git uses your existing SSH agent / `~/.ssh` key configuration. Use an `ssh://` URL and make sure your key (or agent) is set up; no extra steps in the plugin. -For SSH remotes, the plugin relies on your existing SSH agent or `~/.ssh` key configuration — no extra steps needed. +Commits are authored with the **Author name/email** from the plugin settings if set; otherwise git uses your machine's git identity (`user.name` / `user.email`). If neither is configured, git will refuse to commit — set an author in settings or configure a global git identity. ## Development diff --git a/src/askpass.test.ts b/src/askpass.test.ts deleted file mode 100644 index 70d2fb3..0000000 --- a/src/askpass.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { afterEach, expect, test } from "vitest"; -import { execFile } from "node:child_process"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { promisify } from "node:util"; -import { AskpassBridge } from "./askpass"; - -const run = promisify(execFile); -let bridge: AskpassBridge | undefined; -afterEach(async () => { - bridge?.stop(); - bridge = undefined; -}); - -test("helper script round-trips a prompt to onPrompt and returns the answer", async () => { - const dir = mkdtempSync(join(tmpdir(), "jp-ask-")); - bridge = new AskpassBridge({ - dir, - onPrompt: async (p) => (p.includes("Password") ? "s3cret" : "alice"), - }); - const env = await bridge.start(); - const { stdout } = await run(env.GIT_ASKPASS, ["Password for 'https://x':"], { - env: { ...process.env, JEKYLL_ASKPASS_SOCK: env.JEKYLL_ASKPASS_SOCK }, - }); - expect(stdout.trim()).toBe("s3cret"); - rmSync(dir, { recursive: true, force: true }); -}); - -test("onPrompt rejection: helper process completes promptly and returns empty answer", async () => { - const dir = mkdtempSync(join(tmpdir(), "jp-ask-reject-")); - bridge = new AskpassBridge({ - dir, - onPrompt: async (_p) => { throw new Error("user cancelled"); }, - }); - const env = await bridge.start(); - - // The helper must exit within 3 seconds; if it hangs the test times out with a clear error. - const result = await Promise.race([ - run(env.GIT_ASKPASS, ["Password:"], { - env: { ...process.env, JEKYLL_ASKPASS_SOCK: env.JEKYLL_ASKPASS_SOCK }, - }).then(({ stdout }) => ({ timedOut: false, stdout })) - .catch(() => ({ timedOut: false, stdout: "" })), - new Promise<{ timedOut: true; stdout: string }>((resolve) => - setTimeout(() => resolve({ timedOut: true, stdout: "" }), 3000) - ), - ]); - - expect(result.timedOut, "helper process hung instead of exiting promptly").toBe(false); - // Empty answer is fine — git will fail fast on its own - expect(result.stdout.trim()).toBe(""); - rmSync(dir, { recursive: true, force: true }); -}); - -test("restart on same dir: stop() then start() again succeeds with no EADDRINUSE", async () => { - const dir = mkdtempSync(join(tmpdir(), "jp-ask-restart-")); - - // First session - bridge = new AskpassBridge({ - dir, - onPrompt: async (p) => (p.includes("user") ? "bob" : "pass1"), - }); - const env1 = await bridge.start(); - const { stdout: out1 } = await run(env1.GIT_ASKPASS, ["username:"], { - env: { ...process.env, JEKYLL_ASKPASS_SOCK: env1.JEKYLL_ASKPASS_SOCK }, - }); - expect(out1.trim()).toBe("bob"); - bridge.stop(); - bridge = undefined; - - // Second session — same dir; must not throw EADDRINUSE - bridge = new AskpassBridge({ - dir, - onPrompt: async (p) => (p.includes("Password") ? "newpass" : "carol"), - }); - const env2 = await bridge.start(); - const { stdout: out2 } = await run(env2.GIT_ASKPASS, ["Password for 'https://y':"], { - env: { ...process.env, JEKYLL_ASKPASS_SOCK: env2.JEKYLL_ASKPASS_SOCK }, - }); - expect(out2.trim()).toBe("newpass"); - - rmSync(dir, { recursive: true, force: true }); -}); diff --git a/src/askpass.ts b/src/askpass.ts deleted file mode 100644 index c498e20..0000000 --- a/src/askpass.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { createServer, Server } from "node:net"; -import { chmodSync, unlinkSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - -export class AskpassBridge { - private server?: Server; - private sock = ""; - private script = ""; - - constructor(private opts: { dir: string; onPrompt: (prompt: string) => Promise }) {} - - async start(): Promise<{ - GIT_ASKPASS: string; - SSH_ASKPASS: string; - SSH_ASKPASS_REQUIRE: string; - JEKYLL_ASKPASS_SOCK: string; - }> { - this.sock = join(this.opts.dir, "askpass.sock"); - this.script = join(this.opts.dir, "askpass.sh"); - - // Best-effort removal of a stale socket from a previous unclean shutdown. - try { unlinkSync(this.sock); } catch { /* ignore ENOENT */ } - - this.server = createServer({ allowHalfOpen: true }, (conn) => { - let buf = ""; - conn.on("data", (d) => (buf += d.toString())); - conn.on("end", () => { - // Wrap onPrompt in try/catch so a rejection never hangs the git/ssh process. - this.opts.onPrompt(buf.replace(/\n$/, "")).then( - (answer) => { - conn.end(answer.endsWith("\n") ? answer : answer + "\n"); - }, - (_err) => { - // User cancelled or onPrompt threw — send empty reply so git fails fast. - conn.end("\n"); - } - ); - }); - }); - - // Fix: reject the promise on listen error (e.g. EADDRINUSE) instead of hanging. - await new Promise((res, rej) => { - this.server!.once("error", rej); - this.server!.listen(this.sock, () => { - this.server!.removeListener("error", rej); - res(); - }); - }); - - // Helper: send argv[1] (the prompt) to the socket, print the reply. - // Uses a Node one-liner for reliable EOF signalling across platforms - // (nc -U without -N hangs on BSD-derived netcat; socat may also vary). - // Protocol: client writes prompt + "\n", server replies with answer + "\n" and closes. - // allowHalfOpen ensures the client can still receive after calling end(). - const sh = [ - "#!/bin/sh", - 'node -e "' + - "const n=require('net'),s=process.env.JEKYLL_ASKPASS_SOCK,p=process.argv[1];" + - "const c=n.createConnection({path:s,allowHalfOpen:true},()=>{c.end(p+'\\n')});" + - "c.on('data',d=>process.stdout.write(d));" + - '" -- "$1"', - "", - ].join("\n"); - writeFileSync(this.script, sh); - chmodSync(this.script, 0o755); - - return { - GIT_ASKPASS: this.script, - SSH_ASKPASS: this.script, - SSH_ASKPASS_REQUIRE: "force", - JEKYLL_ASKPASS_SOCK: this.sock, - }; - } - - stop(): void { - this.server?.close(); - this.server = undefined; - // Clean up socket and helper script to prevent stale-socket EADDRINUSE on restart. - try { unlinkSync(this.sock); } catch { /* ignore ENOENT */ } - try { unlinkSync(this.script); } catch { /* ignore ENOENT */ } - } -} diff --git a/src/main.ts b/src/main.ts index 791befd..c5907a7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,8 @@ -import { Modal, Notice, Plugin, Setting, TFile, normalizePath } from "obsidian"; +import { Notice, Plugin, TFile } from "obsidian"; import { DEFAULT_SETTINGS, JekyllPublishSettings } from "./settings"; import { JekyllPublishSettingTab } from "./SettingsTab"; import { ModalResult, PublishModal } from "./PublishModal"; import { ChildProcessGitClient } from "./git"; -import { AskpassBridge } from "./askpass"; import { publish } from "./publish"; export default class JekyllPublishPlugin extends Plugin { @@ -43,31 +42,26 @@ export default class JekyllPublishPlugin extends Plugin { new Notice("Jekyll Publish: set a remote URL in settings first."); return; } - const dir = normalizePath(this.app.vault.configDir + "/plugins/jekyll-publish"); - const adapter = this.app.vault.adapter as any; - if (typeof adapter.getFullPath !== "function") { - new Notice("Jekyll Publish requires desktop Obsidian (filesystem access unavailable)."); - return; - } - const bridge = new AskpassBridge({ - dir: adapter.getFullPath(dir), - onPrompt: (prompt) => this.promptCredential(prompt), - }); try { - const env = await bridge.start(); - const git = new ChildProcessGitClient({ env }); + // Credentials are handled entirely by the user's system git: its + // configured credential helper (Git Credential Manager on Windows, + // osxkeychain on macOS, libsecret on Linux) for https:// URLs, or the + // ssh-agent / key for ssh:// URLs. The plugin stores nothing and sets + // GIT_TERMINAL_PROMPT=0 (in ChildProcessGitClient) so git fails fast + // with a clear message instead of hanging when no helper is available. + const git = new ChildProcessGitClient(); const result = await publish( { noteText, ...r }, this.settings, git, (linktext) => this.resolveImage(linktext) ); - const warn = result.unresolved.length ? ` (${result.unresolved.length} image(s) unresolved)` : ""; + const warn = result.unresolved.length + ? ` (${result.unresolved.length} image(s) unresolved)` + : ""; new Notice(`Published ${result.postPath} with ${result.imageCount} image(s)${warn}`); } catch (e) { - new Notice(`Publish failed: ${(e as Error).message}`); - } finally { - bridge.stop(); + new Notice(`Publish failed: ${withCredentialHint((e as Error).message)}`, 12000); } } @@ -77,26 +71,24 @@ export default class JekyllPublishPlugin extends Plugin { const ab = await this.app.vault.readBinary(dest); return Buffer.from(ab); } - - private promptCredential(prompt: string): Promise { - return new Promise((resolve) => { - const modal = new Modal(this.app); - modal.titleEl.setText("Git credentials"); - modal.contentEl.createEl("p", { text: prompt }); - let value = ""; - const masked = /pass|secret|token/i.test(prompt); - new Setting(modal.contentEl).addText((t) => { - if (masked) t.inputEl.type = "password"; - t.onChange((v) => (value = v)); - t.inputEl.addEventListener("keydown", (e) => { - if (e.key === "Enter") { modal.close(); resolve(value); } - }); - }); - new Setting(modal.contentEl).addButton((b) => - b.setButtonText("OK").setCta().onClick(() => { modal.close(); resolve(value); }) - ); - modal.onClose = () => resolve(value); - modal.open(); - }); - } +} + +/** + * When git fails for an authentication reason, append guidance pointing the + * user at their system credential helper / ssh setup, since the plugin + * deliberately does not store or prompt for credentials itself. + */ +function withCredentialHint(message: string): string { + const authPattern = + /could not read (Username|Password)|terminal prompts disabled|Authentication failed|Permission denied|access denied|fatal: Authentication|no email was given|Author identity unknown/i; + if (authPattern.test(message)) { + return ( + `${message}\n\n` + + "Git could not authenticate or identify you. For an https:// URL, set up a " + + "git credential helper (e.g. Git Credential Manager). For an ssh:// URL, make " + + "sure your SSH key/agent is configured. You can also set an author name/email " + + "in the plugin settings." + ); + } + return message; }