Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2a4040ae8 | ||
| 61910acac3 |
@@ -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
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "jekyll-publish",
|
||||
"name": "Jekyll Publish",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"minAppVersion": "1.5.0",
|
||||
"description": "Publish the active note as a Jekyll post (with images) via git.",
|
||||
"author": "Claude",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsidian-jekyll-publish",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"description": "Publish the active Obsidian note as a Jekyll post (with images) via git.",
|
||||
"main": "dist/main.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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<string> }) {}
|
||||
|
||||
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<void>((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 */ }
|
||||
}
|
||||
}
|
||||
72
src/main.ts
72
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<string> {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user