Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: Release Obsidian plugin
on:
push:
tags:
- '*'
- '*.*.*'

permissions:
contents: read
Expand Down
31 changes: 29 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ This repository is currently a governance and hardening fork of the upstream `Fr
- syncs vault content against a GitHub repository through the GitHub REST API
- preserves folder structure and common file operations
- supports conflict handling and sync logs
- supports syncing either to repository root or to a dedicated remote subfolder (for example `vault/`)
- is intended for Obsidian desktop and mobile because `manifest.json` sets `isDesktopOnly` to `false`

## Security and privacy disclosures
Expand All @@ -25,11 +26,11 @@ Yes. You need a GitHub account, a repository, and a token with repository access

### Data leaves your device

Yes. Any files and metadata selected for sync are sent to the configured GitHub repository. That can include note contents, attachment bytes, file paths, and commit metadata.
Yes. Any files and metadata selected for sync are sent to the configured GitHub repository. That can include note contents, attachment bytes, file paths, and commit metadata. If repository scope is set to `Subfolder only`, synced content is constrained to that remote subfolder (for example `vault/`).

### Secrets

The current fork assumes GitHub credentials are stored locally in plugin data/settings. Do **not** sync `.obsidian/` or plugin settings into a public repository unless token handling is redesigned and documented.
By default, the token is session-only and is **not** persisted on disk unless you explicitly enable **Persist token on disk** in settings. Do **not** sync `.obsidian/` or plugin settings into a public repository if token persistence is enabled.

### Telemetry

Expand Down Expand Up @@ -60,6 +61,24 @@ Fallback for classic tokens:
- `docs/` — architecture, security, testing, release, and ADRs
- `.github/` — CI, security, templates, and maintenance workflows

## Repository scope modes

- **Full repository**: plugin paths map directly to repository root.
- **Subfolder only**: plugin paths map into a configured repository subfolder (default: `vault`).

This mode is useful for monorepo layouts such as:

```text
second-brain/
├─ docs/
├─ policies/
└─ vault/
├─ 00 Inbox/
└─ ...
```

In that setup, configure **Repository scope = Subfolder only** and **Repository subfolder = vault** so Obsidian-sync content stays inside `vault/`.

## Development

```bash
Expand All @@ -74,6 +93,14 @@ npm run release:preflight

Build artifacts land in `dist/`.

## Test without catalog submission

Yes — you can test this fork locally without submitting to the Obsidian community catalog:

1. run `npm ci` and `npm run build`
2. copy `dist/main.js`, `dist/manifest.json`, and optional `dist/styles.css` into a local Obsidian plugin folder
3. enable the plugin in Obsidian (Settings → Community Plugins)

## Release process

This fork uses a draft-release workflow on SemVer tags. Release readiness requires version sync, passing CI, release assets, and manual smoke checks. See `docs/release.md` for the full checklist.
Expand Down
1 change: 1 addition & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ flowchart LR
- stores plugin state and credentials locally
2. **Configured GitHub repository**
- receives synchronized note and attachment data
- can receive synced content at repository root or under a configured subfolder (for example `vault/`)
- becomes the remote source of sync truth for the configured branch
3. **Repository automation**
- builds, tests, and packages the plugin code
Expand Down
2 changes: 2 additions & 0 deletions docs/security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ The plugin runs inside Obsidian and can read the vault paths it is pointed at. L
### Remote side

The configured GitHub repository and branch act as the remote sync target. Data sent there is outside the local-only trust boundary.
When repository scope is set to a subfolder, only that configured remote subtree is used for plugin sync data.

### CI/CD side

Expand All @@ -39,6 +40,7 @@ Operational rules:

- one token per user or device where practical
- rotate on exposure or device loss
- default to session-only token handling (do not persist token unless explicitly enabled by the user)
- do not print tokens in logs
- do not embed tokens in fixtures, screenshots, or issue reports

Expand Down
3 changes: 2 additions & 1 deletion docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ Release smoke should verify at minimum:
| Change type | Expected validation |
| --- | --- |
| planner / engine logic | targeted tests plus integration coverage |
| auth / token / request handling | unit tests plus docs update |
| auth / token / request handling | unit tests plus docs update (including token persistence behavior) |
| repository root vs subfolder sync scope | targeted tests for path mapping and conflict handling |
| UI-only change | at least a focused regression check and manual smoke |
| workflow / release / scripts | local script execution or CI evidence |
| trust-boundary change | tests, docs, and ADR |
Expand Down
33 changes: 9 additions & 24 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 1 addition & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
"@eslint/js": "^9.39.2",
"@eslint/json": "^0.14.0",
"@types/node": "^20.12.12",
"@types/picomatch": "^4.0.2",
"@typescript-eslint/parser": "^8.52.0",
"esbuild": "^0.21.5",
"eslint": "^9.39.2",
Expand All @@ -29,7 +28,5 @@
"typescript-eslint": "^8.52.0",
"vitest": "^1.6.0"
},
"dependencies": {
"picomatch": "^4.0.3"
}
"dependencies": {}
}
48 changes: 42 additions & 6 deletions src/clients/github-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ export class GitHubApiClient implements GitHubClient {
};
}

const shouldRetry = this.shouldRetry(response.status, attempt);
const shouldRetry = this.shouldRetry(response.status, response.headers, attempt);
if (response.status === 409) {
throw new Error(`GitHub API conflict (409): ${response.text}`);
}
Expand All @@ -262,7 +262,7 @@ export class GitHubApiClient implements GitHubClient {
throw new Error(`GitHub API error ${response.status}: ${response.text}`);
}

const delayMs = this.getRetryDelayMs(response.status, attempt);
const delayMs = this.getRetryDelayMs(response.status, response.headers, attempt);
await this.sleep(delayMs);
attempt += 1;
}
Expand All @@ -287,7 +287,7 @@ export class GitHubApiClient implements GitHubClient {
.join("/");
}

private shouldRetry(status: number, attempt: number): boolean {
private shouldRetry(status: number, headers: Record<string, string>, attempt: number): boolean {
if (attempt >= this.maxRetries) {
return false;
}
Expand All @@ -301,14 +301,40 @@ export class GitHubApiClient implements GitHubClient {
}

if (status === 403) {
// Rate limit - will be handled by retry-after
return true;
const remaining = this.getHeader(headers, "x-ratelimit-remaining");
const retryAfter = this.getHeader(headers, "retry-after");
return remaining === "0" || Boolean(retryAfter);
}

return false;
}

private getRetryDelayMs(status: number, attempt: number): number {
private getRetryDelayMs(
status: number,
headers: Record<string, string>,
attempt: number
): number {
const retryAfter = this.getHeader(headers, "retry-after");
if (retryAfter) {
const retryAfterSeconds = Number(retryAfter);
if (Number.isFinite(retryAfterSeconds) && retryAfterSeconds >= 0) {
return Math.min(retryAfterSeconds * 1000, 30_000);
}
}

if (status === 403) {
const resetRaw = this.getHeader(headers, "x-ratelimit-reset");
if (resetRaw) {
const resetSeconds = Number(resetRaw);
if (Number.isFinite(resetSeconds)) {
const untilResetMs = Math.max(0, resetSeconds * 1000 - Date.now());
if (untilResetMs > 0) {
return Math.min(untilResetMs, 30_000);
}
}
}
}

const base = 500 * Math.pow(2, attempt);
return Math.min(base, 5000);
}
Expand All @@ -321,4 +347,14 @@ export class GitHubApiClient implements GitHubClient {
const message = error instanceof Error ? error.message : String(error);
return message.includes("Git Repository is empty");
}

private getHeader(headers: Record<string, string>, key: string): string | undefined {
const lowerKey = key.toLowerCase();
for (const [headerKey, value] of Object.entries(headers)) {
if (headerKey.toLowerCase() === lowerKey) {
return value;
}
}
return undefined;
}
}
Loading
Loading