mirror of
https://github.com/doum1004/llmwiki-cli.git
synced 2026-04-28 23:16:09 +02:00
refactor: remove deprecated commands and authentication logic
- Deleted push, repo, sync, and auth commands as they are no longer needed. - Updated the use command to handle wiki selection without relying on the picker. - Refactored GitProvider to handle automatic commits and push logic. - Simplified GitHub API interactions by removing unnecessary functions and parameters. - Updated types to reflect changes in configuration and removed unused imports. - Cleaned up tests related to removed functionalities.
This commit is contained in:
41
CLAUDE.md
41
CLAUDE.md
@@ -26,26 +26,23 @@ dist/wiki.js # Built bundle (npm-published artifact)
|
||||
src/
|
||||
types.ts # Shared TypeScript interfaces
|
||||
lib/
|
||||
storage.ts # StorageProvider factory (createProvider) + requireGit guard
|
||||
storage.ts # StorageProvider factory (createProvider)
|
||||
wiki.ts # WikiManager: filesystem StorageProvider
|
||||
git-provider.ts # GitProvider: filesystem + auto-commit on write/append
|
||||
git-provider.ts # GitProvider: filesystem + auto-commit + auto-push
|
||||
supabase-provider.ts # SupabaseProvider: Supabase database StorageProvider
|
||||
config.ts # .llmwiki.yaml read/write
|
||||
registry.ts # Global registry (~/.config/llmwiki/registry.yaml)
|
||||
resolver.ts # Wiki resolution chain (--wiki → cwd → walk up → default)
|
||||
git.ts # Git operations via child_process.execFile
|
||||
github.ts # GitHub API: createRepo, getUsername
|
||||
templates.ts # Default file content (SCHEMA.md, index.md, log.md)
|
||||
picker.ts # Interactive wiki selection (prompt())
|
||||
prompt.ts # User input prompting (readline wrapper)
|
||||
search.ts # Full-text search with term-frequency ranking
|
||||
index-manager.ts # IndexManager: uses StorageProvider for index.md
|
||||
log-manager.ts # LogManager: uses StorageProvider for log.md
|
||||
frontmatter.ts # YAML frontmatter parse/detect/add
|
||||
link-parser.ts # Wikilink extraction and link graph building
|
||||
auth.ts # GitHub auth (PAT token) persistence
|
||||
github.ts # GitHub API: listRepos, getRepo, createRepo
|
||||
commands/
|
||||
init.ts # wiki init
|
||||
init.ts # wiki init (--backend, --git-token, --supabase-url, etc.)
|
||||
registry.ts # wiki registry
|
||||
use.ts # wiki use
|
||||
read.ts # wiki read
|
||||
@@ -55,31 +52,20 @@ src/
|
||||
search.ts # wiki search
|
||||
index-cmd.ts # wiki index (add/remove/show)
|
||||
log-cmd.ts # wiki log (append/show)
|
||||
commit.ts # wiki commit
|
||||
history.ts # wiki history
|
||||
diff.ts # wiki diff
|
||||
lint.ts # wiki lint
|
||||
links.ts # wiki links
|
||||
backlinks.ts # wiki backlinks
|
||||
orphans.ts # wiki orphans
|
||||
status.ts # wiki status
|
||||
auth.ts # wiki auth (login/status/logout)
|
||||
repo.ts # wiki repo (list/create/clone/connect)
|
||||
push.ts # wiki push
|
||||
pull.ts # wiki pull
|
||||
sync.ts # wiki sync
|
||||
skill.ts # wiki skill (print LLM agent guide)
|
||||
test/
|
||||
init.test.ts # Config, registry, resolver, templates, init integration
|
||||
git.test.ts # Git operations (commit, log, diff, remote, branch)
|
||||
read-write.test.ts # WikiManager page operations
|
||||
storage.test.ts # StorageProvider factory, filesystem + git provider contracts
|
||||
search.test.ts # Full-text search
|
||||
index-manager.test.ts # Index entry management
|
||||
log-manager.test.ts # Activity log management
|
||||
links.test.ts # Wikilink extraction and link graph
|
||||
lint.test.ts # Frontmatter and lint checks
|
||||
auth.test.ts # Auth persistence (save/load/clear/getToken)
|
||||
storage.test.ts # StorageProvider factory
|
||||
filesystem-provider.test.ts # Filesystem provider contract tests
|
||||
git-provider.test.ts # GitProvider auto-commit tests
|
||||
supabase-provider.test.ts # SupabaseProvider with mocked client
|
||||
github.test.ts # GitHub API with mocked fetch
|
||||
commands.test.ts # End-to-end CLI command integration tests
|
||||
docs/
|
||||
@@ -95,6 +81,7 @@ docs/
|
||||
### Wiki Management
|
||||
```
|
||||
wiki init [dir] --name --domain --backend <filesystem|git|supabase>
|
||||
wiki init [dir] --backend git --git-token <pat> [--git-repo owner/repo]
|
||||
wiki init [dir] --backend supabase --supabase-url <url> --supabase-key <key>
|
||||
wiki registry # List all wikis
|
||||
wiki use [wiki-id] # Set active wiki
|
||||
@@ -131,17 +118,17 @@ wiki status [--json] # Wiki overview stats
|
||||
|
||||
- **StorageProvider pattern**: All page I/O goes through the `StorageProvider` interface (5 methods: readPage, writePage, appendPage, pageExists, listPages). Three implementations:
|
||||
- `WikiManager` — filesystem (default)
|
||||
- `GitProvider` — wraps WikiManager, auto-commits on write/append
|
||||
- `GitProvider` — wraps WikiManager, auto-commits + auto-pushes on write/append
|
||||
- `SupabaseProvider` — pages in Supabase `wiki_pages` table (dynamic import, optional dependency)
|
||||
- **Provider factory**: `createProvider(config, root)` in `src/lib/storage.ts`. Async (for dynamic Supabase import). Called once in preAction hook, injected as `ctx.provider`.
|
||||
- **Commander pattern**: Each command is a factory function (`makeXxxCommand()`) returning a `Command` instance, registered via `program.addCommand()`.
|
||||
- **preAction hook**: Resolves which wiki to target, creates the StorageProvider, attaches both to `WikiContext`. Commands in `SKIP_RESOLUTION` set (init, registry, use, auth, skill) bypass this.
|
||||
- **preAction hook**: Resolves which wiki to target, creates the StorageProvider, attaches both to `WikiContext`. Commands in `SKIP_RESOLUTION` set (init, registry, use, skill) bypass this.
|
||||
- **Wiki resolution order**: `--wiki` flag → cwd `.llmwiki.yaml` → walk up directories → registry default.
|
||||
- **Backend gating**: Git commands (commit, push, pull, sync, history, diff) check `requireGit(ctx)` and exit with error for non-git backends.
|
||||
- **Credentials in config**: Git token/repo and Supabase URL/key stored in `.llmwiki.yaml`. No separate auth commands — everything via `wiki init` flags.
|
||||
- **IndexManager/LogManager**: Accept `StorageProvider` in constructor (not filesystem paths). Backend-agnostic.
|
||||
- **Registry**: Global at `~/.config/llmwiki/registry.yaml`, overridable via `LLMWIKI_CONFIG_DIR` env var (used in tests).
|
||||
- **Git**: All operations use `child_process.execFile`, return `{ ok: boolean, output: string }`.
|
||||
- **GitHub API**: Uses `fetch` with Bearer token auth. Pagination, error handling for 401/403/422.
|
||||
- **GitHub API**: Minimal `createRepo` + `getUsername` in `src/lib/github.ts`. Used by init for auto-creating repos.
|
||||
- **No Bun-specific APIs in src/**: Source code uses only Node.js APIs for npm compatibility. Bun APIs are only used in tests.
|
||||
|
||||
## Development
|
||||
@@ -149,7 +136,7 @@ wiki status [--json] # Wiki overview stats
|
||||
```bash
|
||||
bun install # Install deps
|
||||
bun run dev # Run CLI via source
|
||||
bun test # Run tests (210 tests across 12 files)
|
||||
bun test # Run tests (185 tests across 13 files)
|
||||
bun run build # Bundle to dist/wiki.js
|
||||
bun run typecheck # TypeScript check
|
||||
```
|
||||
|
||||
11
README.md
11
README.md
@@ -29,7 +29,7 @@ v
|
||||
wiki CLI (StorageProvider abstraction)
|
||||
|
|
||||
v
|
||||
filesystem | git (auto-commit) | supabase (database)
|
||||
filesystem | git (auto-commit + auto-push) | supabase (database)
|
||||
```
|
||||
|
||||
**Key principle**: The CLI never calls any LLM API. It is a pure storage tool with pluggable backends.
|
||||
@@ -47,7 +47,7 @@ This gives you two commands: `wiki` (primary, 4 chars) and `llmwiki` (fallback i
|
||||
| Backend | Description | Init |
|
||||
|---------|-------------|------|
|
||||
| `filesystem` (default) | Plain markdown files on disk | `wiki init my-wiki` |
|
||||
| `git` | Filesystem + auto-commit on every write + git commands | `wiki init my-wiki --backend git` |
|
||||
| `git` | Filesystem + auto-commit + auto-push to GitHub | `wiki init my-wiki --backend git --git-token <pat>` |
|
||||
| `supabase` | Pages in a Supabase database table | `wiki init my-wiki --backend supabase --supabase-url <url> --supabase-key <key>` |
|
||||
|
||||
## Quick Start
|
||||
@@ -56,8 +56,8 @@ This gives you two commands: `wiki` (primary, 4 chars) and `llmwiki` (fallback i
|
||||
# Create a new wiki (filesystem backend, default)
|
||||
wiki init my-wiki --name "My Notes" --domain "research"
|
||||
|
||||
# Or with git versioning
|
||||
wiki init my-wiki --name "My Notes" --domain "research" --backend git
|
||||
# Or with git + GitHub sync
|
||||
wiki init my-wiki --name "My Notes" --domain "research" --backend git --git-token ghp_xxx
|
||||
|
||||
# Write a page
|
||||
wiki write wiki/concepts/attention.md <<'EOF'
|
||||
@@ -105,7 +105,8 @@ For supabase backend, only `.llmwiki.yaml` is created locally. Pages are stored
|
||||
|
||||
### Wiki Management
|
||||
```bash
|
||||
wiki init [dir] --name <name> --domain <domain> --backend <type> # Create new wiki
|
||||
wiki init [dir] --name <name> --domain <domain> --backend <type>
|
||||
wiki init [dir] --backend git --git-token <pat> [--git-repo owner/repo]
|
||||
wiki init [dir] --backend supabase --supabase-url <url> --supabase-key <key>
|
||||
wiki registry # List all wikis
|
||||
wiki use [wiki-id] # Set active wiki
|
||||
|
||||
20
bin/wiki.ts
20
bin/wiki.ts
@@ -11,20 +11,12 @@ import { makeListCommand } from "../src/commands/list.ts";
|
||||
import { makeSearchCommand } from "../src/commands/search.ts";
|
||||
import { makeIndexCommand } from "../src/commands/index-cmd.ts";
|
||||
import { makeLogCommand } from "../src/commands/log-cmd.ts";
|
||||
import { makeCommitCommand } from "../src/commands/commit.ts";
|
||||
import { makeHistoryCommand } from "../src/commands/history.ts";
|
||||
import { makeDiffCommand } from "../src/commands/diff.ts";
|
||||
import { makeLintCommand } from "../src/commands/lint.ts";
|
||||
import { makeLinksCommand } from "../src/commands/links.ts";
|
||||
import { makeBacklinksCommand } from "../src/commands/backlinks.ts";
|
||||
import { makeOrphansCommand } from "../src/commands/orphans.ts";
|
||||
import { makeStatusCommand } from "../src/commands/status.ts";
|
||||
import { makeAuthCommand } from "../src/commands/auth.ts";
|
||||
import { makeSkillCommand } from "../src/commands/skill.ts";
|
||||
import { makeRepoCommand } from "../src/commands/repo.ts";
|
||||
import { makePushCommand } from "../src/commands/push.ts";
|
||||
import { makePullCommand } from "../src/commands/pull.ts";
|
||||
import { makeSyncCommand } from "../src/commands/sync.ts";
|
||||
import { resolveWiki } from "../src/lib/resolver.ts";
|
||||
import { createProvider } from "../src/lib/storage.ts";
|
||||
import type { GlobalOptions, WikiContext } from "../src/types.ts";
|
||||
@@ -34,14 +26,13 @@ const program = new Command();
|
||||
program
|
||||
.name("wiki")
|
||||
.description("CLI tool for LLM agents to build and maintain knowledge bases")
|
||||
.version("0.1.0")
|
||||
.version("0.1.5")
|
||||
.option("-w, --wiki <id>", "specify wiki by registry id");
|
||||
|
||||
// Commands that do NOT require wiki resolution
|
||||
program.addCommand(makeInitCommand());
|
||||
program.addCommand(makeRegistryCommand());
|
||||
program.addCommand(makeUseCommand());
|
||||
program.addCommand(makeAuthCommand());
|
||||
program.addCommand(makeSkillCommand());
|
||||
|
||||
// Commands that require wiki resolution
|
||||
@@ -52,21 +43,14 @@ program.addCommand(makeListCommand());
|
||||
program.addCommand(makeSearchCommand());
|
||||
program.addCommand(makeIndexCommand());
|
||||
program.addCommand(makeLogCommand());
|
||||
program.addCommand(makeCommitCommand());
|
||||
program.addCommand(makeHistoryCommand());
|
||||
program.addCommand(makeDiffCommand());
|
||||
program.addCommand(makeLintCommand());
|
||||
program.addCommand(makeLinksCommand());
|
||||
program.addCommand(makeBacklinksCommand());
|
||||
program.addCommand(makeOrphansCommand());
|
||||
program.addCommand(makeStatusCommand());
|
||||
program.addCommand(makeRepoCommand());
|
||||
program.addCommand(makePushCommand());
|
||||
program.addCommand(makePullCommand());
|
||||
program.addCommand(makeSyncCommand());
|
||||
|
||||
// Resolve wiki context for commands that need it
|
||||
const SKIP_RESOLUTION = new Set(["init", "registry", "use", "auth", "skill"]);
|
||||
const SKIP_RESOLUTION = new Set(["init", "registry", "use", "skill"]);
|
||||
|
||||
program.hook("preAction", async (thisCommand, actionCommand) => {
|
||||
if (SKIP_RESOLUTION.has(actionCommand.name())) return;
|
||||
|
||||
@@ -9,11 +9,11 @@ The CLI supports three storage backends:
|
||||
| Backend | Description | Init |
|
||||
|---------|-------------|------|
|
||||
| `filesystem` (default) | Plain markdown files on disk, no versioning | `wiki init my-wiki` |
|
||||
| `git` | Filesystem + auto-commit on every write/append + git commands | `wiki init my-wiki --backend git` |
|
||||
| `git` | Filesystem + auto-commit + auto-push to GitHub | `wiki init my-wiki --backend git --git-token <pat>` |
|
||||
| `supabase` | Pages stored in a Supabase database table | `wiki init my-wiki --backend supabase --supabase-url <url> --supabase-key <key>` |
|
||||
|
||||
- **filesystem**: Simplest. Pages are `.md` files. No versioning.
|
||||
- **git**: Every `wiki write` and `wiki append` auto-commits. Git commands (`commit`, `push`, `pull`, `sync`, `history`, `diff`) only work with this backend.
|
||||
- **git**: Every `wiki write` and `wiki append` auto-commits and auto-pushes. Provide `--git-token` at init to enable GitHub sync. Omit for local-only git.
|
||||
- **supabase**: Pages stored in `wiki_pages` table. No local files. Requires `@supabase/supabase-js` installed.
|
||||
|
||||
## Critical Patterns
|
||||
@@ -175,9 +175,10 @@ wiki search "neural networks" --all # search across all wikis
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `wiki init [dir] --name <n> --domain <d> --backend <type>` | Create new wiki (backends: filesystem, git, supabase) |
|
||||
| `wiki init [dir] --backend git --git-token <pat> [--git-repo owner/repo]` | Create git-backed wiki with GitHub sync |
|
||||
| `wiki init [dir] --backend supabase --supabase-url <url> --supabase-key <key>` | Create Supabase-backed wiki |
|
||||
| `wiki registry` | List all registered wikis |
|
||||
| `wiki use [wiki-id]` | Set active wiki (interactive picker if no id) |
|
||||
| `wiki use [wiki-id]` | List wikis or set active wiki |
|
||||
|
||||
### Reading & Writing
|
||||
|
||||
@@ -217,8 +218,8 @@ wiki search "neural networks" --all # search across all wikis
|
||||
|
||||
3. **append fails if page doesn't exist** — use `wiki write` to create new pages, `wiki append` only for existing ones.
|
||||
|
||||
5. **Wiki resolution** — if commands fail with "No wiki found", either `cd` into a wiki directory, run `wiki use <id>` to set a default, or pass `--wiki <id>`.
|
||||
4. **Wiki resolution** — if commands fail with "No wiki found", either `cd` into a wiki directory, run `wiki use <id>` to set a default, or pass `--wiki <id>`.
|
||||
|
||||
6. **search --all** searches across all registered wikis, not just the active one.
|
||||
5. **search --all** searches across all registered wikis, not just the active one.
|
||||
|
||||
7. **lint checks five things**: broken wikilinks, orphan pages, missing frontmatter, empty pages, and index consistency (pages not in index, index entries pointing to missing pages).
|
||||
6. **lint checks five things**: broken wikilinks, orphan pages, missing frontmatter, empty pages, and index consistency (pages not in index, index entries pointing to missing pages).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "llmwiki-cli",
|
||||
"version": "0.1.5",
|
||||
"version": "0.2.0",
|
||||
"description": "CLI tool for LLM agents to build and maintain personal knowledge bases",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { Command } from "commander";
|
||||
import { loadAuth, saveAuth, clearAuth, validateToken } from "../lib/auth.ts";
|
||||
import { promptUser } from "../lib/prompt.ts";
|
||||
|
||||
export function makeAuthCommand(): Command {
|
||||
const cmd = new Command("auth").description("Manage GitHub authentication");
|
||||
|
||||
cmd
|
||||
.command("login")
|
||||
.description("Authenticate with a GitHub personal access token")
|
||||
.action(async () => {
|
||||
console.log("Enter a GitHub Personal Access Token (PAT).");
|
||||
console.log("Create one at: https://github.com/settings/tokens");
|
||||
console.log('Required scope: "repo"\n');
|
||||
|
||||
const token = await promptUser("Token: ");
|
||||
if (!token) {
|
||||
console.error("No token provided.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("Validating token...");
|
||||
const { valid, username } = await validateToken(token);
|
||||
|
||||
if (!valid) {
|
||||
console.error("Invalid token. Check your token and try again.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await saveAuth({
|
||||
token,
|
||||
username,
|
||||
created: new Date().toISOString(),
|
||||
});
|
||||
|
||||
console.log(`Authenticated as ${username}.`);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("status")
|
||||
.description("Show current authentication status")
|
||||
.action(async () => {
|
||||
const auth = await loadAuth();
|
||||
if (!auth) {
|
||||
console.log('Not authenticated. Run "wiki auth login" to log in.');
|
||||
return;
|
||||
}
|
||||
console.log(`Authenticated as: ${auth.username}`);
|
||||
console.log(`Since: ${auth.created}`);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("logout")
|
||||
.description("Remove saved credentials")
|
||||
.action(async () => {
|
||||
await clearAuth();
|
||||
console.log("Logged out. Credentials removed.");
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { Command } from "commander";
|
||||
import * as git from "../lib/git.ts";
|
||||
import { LogManager } from "../lib/log-manager.ts";
|
||||
import { requireGit } from "../lib/storage.ts";
|
||||
import type { WikiContext } from "../types.ts";
|
||||
|
||||
export function makeCommitCommand(): Command {
|
||||
return new Command("commit")
|
||||
.description("Git add + commit all changes")
|
||||
.argument("[message]", "commit message")
|
||||
.action(async function (this: Command, message: string | undefined) {
|
||||
const ctx: WikiContext = this.optsWithGlobals().wikiContext;
|
||||
requireGit(ctx, "commit");
|
||||
|
||||
// Auto-generate message from last log entry if not provided
|
||||
if (!message) {
|
||||
const mgr = new LogManager(ctx.provider);
|
||||
const entries = await mgr.show({ last: 1 });
|
||||
if (entries.length > 0) {
|
||||
const match = entries[0]!.match(/## \[.*?\] (.+)/);
|
||||
message = match ? match[1]! : "Update wiki";
|
||||
} else {
|
||||
message = "Update wiki";
|
||||
}
|
||||
}
|
||||
|
||||
await git.addAll(ctx.root);
|
||||
const result = await git.commit(ctx.root, message);
|
||||
|
||||
if (!result.ok) {
|
||||
if (result.output.includes("nothing to commit")) {
|
||||
console.log("Nothing to commit.");
|
||||
} else {
|
||||
console.error(result.output);
|
||||
process.exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(result.output);
|
||||
});
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Command } from "commander";
|
||||
import * as git from "../lib/git.ts";
|
||||
import { requireGit } from "../lib/storage.ts";
|
||||
import type { WikiContext } from "../types.ts";
|
||||
|
||||
export function makeDiffCommand(): Command {
|
||||
return new Command("diff")
|
||||
.description("Show uncommitted changes or a specific commit")
|
||||
.argument("[ref]", "commit ref to show (default: uncommitted changes)")
|
||||
.action(async function (this: Command, ref: string | undefined) {
|
||||
const ctx: WikiContext = this.optsWithGlobals().wikiContext;
|
||||
requireGit(ctx, "diff");
|
||||
const result = await git.diff(ctx.root, ref);
|
||||
|
||||
if (!result.ok) {
|
||||
console.error(result.output);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!result.output) {
|
||||
console.log("No changes.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(result.output);
|
||||
});
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Command } from "commander";
|
||||
import * as git from "../lib/git.ts";
|
||||
import { requireGit } from "../lib/storage.ts";
|
||||
import type { WikiContext } from "../types.ts";
|
||||
|
||||
export function makeHistoryCommand(): Command {
|
||||
return new Command("history")
|
||||
.description("Show git commit history")
|
||||
.argument("[path]", "show history for a specific file")
|
||||
.option("-l, --last <n>", "number of commits to show", "20")
|
||||
.action(async function (
|
||||
this: Command,
|
||||
path: string | undefined,
|
||||
options: { last: string },
|
||||
) {
|
||||
const ctx: WikiContext = this.optsWithGlobals().wikiContext;
|
||||
requireGit(ctx, "history");
|
||||
const limit = parseInt(options.last, 10);
|
||||
|
||||
let result;
|
||||
if (path) {
|
||||
result = await git.logFile(ctx.root, path, limit);
|
||||
} else {
|
||||
result = await git.log(ctx.root, limit);
|
||||
}
|
||||
|
||||
if (!result.ok) {
|
||||
console.error(result.output);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!result.output) {
|
||||
console.log("No history found.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(result.output);
|
||||
});
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { saveConfig } from "../lib/config.ts";
|
||||
import { addToRegistry } from "../lib/registry.ts";
|
||||
import { createProvider } from "../lib/storage.ts";
|
||||
import * as git from "../lib/git.ts";
|
||||
import { createRepo, getUsername } from "../lib/github.ts";
|
||||
import {
|
||||
getDefaultConfig,
|
||||
getDefaultSchema,
|
||||
@@ -29,6 +30,8 @@ export function makeInitCommand(): Command {
|
||||
.option("-n, --name <name>", "wiki name")
|
||||
.option("-d, --domain <domain>", "knowledge domain", "general")
|
||||
.option("-b, --backend <type>", "storage backend (filesystem, git, supabase)", "filesystem")
|
||||
.option("--git-token <token>", "GitHub personal access token")
|
||||
.option("--git-repo <owner/repo>", "GitHub repo (auto-created if omitted with --git-token)")
|
||||
.option("--supabase-url <url>", "Supabase project URL")
|
||||
.option("--supabase-key <key>", "Supabase anon/service key")
|
||||
.action(
|
||||
@@ -38,6 +41,8 @@ export function makeInitCommand(): Command {
|
||||
name?: string;
|
||||
domain: string;
|
||||
backend: string;
|
||||
gitToken?: string;
|
||||
gitRepo?: string;
|
||||
supabaseUrl?: string;
|
||||
supabaseKey?: string;
|
||||
},
|
||||
@@ -57,6 +62,27 @@ export function makeInitCommand(): Command {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve git config
|
||||
let gitConfig: { token: string; repo: string } | undefined;
|
||||
if (backend === "git" && options.gitToken) {
|
||||
let repo = options.gitRepo;
|
||||
if (!repo) {
|
||||
// Auto-create repo on GitHub
|
||||
const repoName = `wiki-${name}`;
|
||||
console.log(`Creating GitHub repo: ${repoName}...`);
|
||||
try {
|
||||
const created = await createRepo(options.gitToken, repoName);
|
||||
const username = await getUsername(options.gitToken);
|
||||
repo = `${username}/${created.name}`;
|
||||
console.log(`Created: ${repo}`);
|
||||
} catch (err: any) {
|
||||
console.error(`Failed to create repo: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
gitConfig = { token: options.gitToken, repo };
|
||||
}
|
||||
|
||||
const supabaseConfig =
|
||||
backend === "supabase"
|
||||
? { url: options.supabaseUrl!, key: options.supabaseKey! }
|
||||
@@ -66,7 +92,10 @@ export function makeInitCommand(): Command {
|
||||
await mkdir(targetDir, { recursive: true });
|
||||
|
||||
// Write config
|
||||
const config = getDefaultConfig(name, domain, backend, supabaseConfig);
|
||||
const config = getDefaultConfig(name, domain, backend, {
|
||||
git: gitConfig,
|
||||
supabase: supabaseConfig,
|
||||
});
|
||||
await saveConfig(targetDir, config);
|
||||
|
||||
if (backend === "supabase") {
|
||||
@@ -111,7 +140,7 @@ export function makeInitCommand(): Command {
|
||||
),
|
||||
]);
|
||||
|
||||
// Git init + initial commit (git backend only)
|
||||
// Git init + initial commit + remote (git backend only)
|
||||
if (backend === "git") {
|
||||
const initResult = await git.init(targetDir);
|
||||
if (!initResult.ok) {
|
||||
@@ -129,6 +158,21 @@ export function makeInitCommand(): Command {
|
||||
`Warning: initial commit failed: ${commitResult.output}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Add remote and push if git config provided
|
||||
if (gitConfig) {
|
||||
const remoteUrl = `https://${gitConfig.token}@github.com/${gitConfig.repo}.git`;
|
||||
await git.addRemote(targetDir, "origin", remoteUrl);
|
||||
const branch = await git.currentBranch(targetDir);
|
||||
const pushResult = await git.push(targetDir, "origin", branch);
|
||||
if (pushResult.ok) {
|
||||
console.log(`Pushed to ${gitConfig.repo}`);
|
||||
} else {
|
||||
console.error(
|
||||
`Warning: initial push failed: ${pushResult.output}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Command } from "commander";
|
||||
import * as git from "../lib/git.ts";
|
||||
import { requireGit } from "../lib/storage.ts";
|
||||
import type { WikiContext } from "../types.ts";
|
||||
|
||||
export function makePullCommand(): Command {
|
||||
return new Command("pull")
|
||||
.description("Pull wiki changes from remote")
|
||||
.action(async function (this: Command) {
|
||||
const ctx: WikiContext = this.optsWithGlobals().wikiContext;
|
||||
requireGit(ctx, "pull");
|
||||
|
||||
if (!(await git.hasRemote(ctx.root))) {
|
||||
console.error('No remote configured. Use "wiki repo connect" to add one.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const branch = await git.currentBranch(ctx.root);
|
||||
const result = await git.pull(ctx.root, "origin", branch);
|
||||
if (!result.ok) {
|
||||
console.error(`Pull failed: ${result.output}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (await git.hasConflicts(ctx.root)) {
|
||||
console.error("Pull succeeded but there are merge conflicts to resolve.");
|
||||
console.log("Fix the conflicts, then: git add <files> && git rebase --continue");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(result.output || "Already up to date.");
|
||||
});
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Command } from "commander";
|
||||
import * as git from "../lib/git.ts";
|
||||
import { requireGit } from "../lib/storage.ts";
|
||||
import type { WikiContext } from "../types.ts";
|
||||
|
||||
export function makePushCommand(): Command {
|
||||
return new Command("push")
|
||||
.description("Push wiki changes to remote")
|
||||
.action(async function (this: Command) {
|
||||
const ctx: WikiContext = this.optsWithGlobals().wikiContext;
|
||||
requireGit(ctx, "push");
|
||||
|
||||
if (!(await git.hasRemote(ctx.root))) {
|
||||
console.error('No remote configured. Use "wiki repo connect" to add one.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const branch = await git.currentBranch(ctx.root);
|
||||
const result = await git.push(ctx.root, "origin", branch);
|
||||
if (!result.ok) {
|
||||
if (result.output.includes("fetch first") || result.output.includes("non-fast-forward")) {
|
||||
console.error("Push rejected: remote has new changes. Run \"wiki pull\" or \"wiki sync\" first.");
|
||||
} else {
|
||||
console.error(`Push failed: ${result.output}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(result.output || "Pushed successfully.");
|
||||
});
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
import { Command } from "commander";
|
||||
import { resolve, basename } from "path";
|
||||
import { homedir } from "os";
|
||||
import { listRepos, createRepo, getRepo } from "../lib/github.ts";
|
||||
import { loadAuth } from "../lib/auth.ts";
|
||||
import { promptUser } from "../lib/prompt.ts";
|
||||
import { loadConfig } from "../lib/config.ts";
|
||||
import { addToRegistry } from "../lib/registry.ts";
|
||||
import * as git from "../lib/git.ts";
|
||||
import { createProvider } from "../lib/storage.ts";
|
||||
import type { WikiContext, RegistryEntry } from "../types.ts";
|
||||
|
||||
export function makeRepoCommand(): Command {
|
||||
const cmd = new Command("repo").description("Manage GitHub wiki repositories");
|
||||
|
||||
cmd
|
||||
.command("list")
|
||||
.description("List your GitHub repositories")
|
||||
.option("-a, --all", "show all repos (default: 20 most recent)")
|
||||
.option("-f, --filter <text>", "filter repos by name")
|
||||
.action(async (options: { all?: boolean; filter?: string }) => {
|
||||
const auth = await loadAuth();
|
||||
if (!auth) {
|
||||
console.error('Not authenticated. Run "wiki auth login" first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const repos = await listRepos({
|
||||
all: options.all,
|
||||
filter: options.filter,
|
||||
});
|
||||
|
||||
if (repos.length === 0) {
|
||||
console.log("No repositories found.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const repo of repos) {
|
||||
const vis = repo.private ? "private" : "public";
|
||||
console.log(` ${repo.full_name} (${vis})`);
|
||||
if (repo.description) console.log(` ${repo.description}`);
|
||||
console.log(` ${repo.html_url}`);
|
||||
console.log();
|
||||
}
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("create")
|
||||
.description("Create a GitHub repo and initialize a wiki")
|
||||
.argument("<name>", "wiki name")
|
||||
.option("-d, --domain <domain>", "knowledge domain", "general")
|
||||
.option("--public", "create a public repo (default: private)")
|
||||
.option("--dir <path>", "local directory path")
|
||||
.action(
|
||||
async (
|
||||
name: string,
|
||||
options: { domain: string; public?: boolean; dir?: string },
|
||||
) => {
|
||||
const auth = await loadAuth();
|
||||
if (!auth) {
|
||||
console.error('Not authenticated. Run "wiki auth login" first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const repoName = `wiki-${name}`;
|
||||
console.log(`Creating GitHub repo: ${repoName}...`);
|
||||
|
||||
let repo;
|
||||
try {
|
||||
repo = await createRepo(repoName, {
|
||||
private: !options.public,
|
||||
description: `LLM Wiki: ${name} (${options.domain})`,
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("already exists")) {
|
||||
console.log(`Repo "${repoName}" already exists, connecting to it...`);
|
||||
repo = await getRepo(auth.username, repoName);
|
||||
if (!repo) {
|
||||
console.error(`Could not find repo "${repoName}" on your account.`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
console.error(err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Created: ${repo.html_url}`);
|
||||
|
||||
// Initialize local wiki
|
||||
const localDir = resolve(
|
||||
options.dir ?? `${homedir()}/wikis/${name}`,
|
||||
);
|
||||
|
||||
// Import and run init logic
|
||||
const { makeInitCommand } = await import("./init.ts");
|
||||
const initCmd = makeInitCommand();
|
||||
await initCmd.parseAsync([
|
||||
"node",
|
||||
"wiki",
|
||||
localDir,
|
||||
"--name",
|
||||
name,
|
||||
"--domain",
|
||||
options.domain,
|
||||
]);
|
||||
|
||||
// Add remote and push
|
||||
await git.addRemote(localDir, "origin", repo.ssh_url);
|
||||
const branch = await git.currentBranch(localDir);
|
||||
const pushResult = await git.push(localDir, "origin", branch);
|
||||
if (pushResult.ok) {
|
||||
console.log("Pushed to GitHub.");
|
||||
} else {
|
||||
console.error(`Warning: push failed: ${pushResult.output}`);
|
||||
console.log(`You can push manually: cd ${localDir} && git push -u origin ${branch}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
cmd
|
||||
.command("clone")
|
||||
.description("Clone a GitHub repo and register it")
|
||||
.argument("[repo-name]", "repository name (e.g. wiki-personal)")
|
||||
.option("--dir <path>", "local directory path")
|
||||
.action(async (repoName: string | undefined, options: { dir?: string }) => {
|
||||
const auth = await loadAuth();
|
||||
if (!auth) {
|
||||
console.error('Not authenticated. Run "wiki auth login" first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!repoName) {
|
||||
// List repos and let user pick
|
||||
const repos = await listRepos({ filter: "wiki-" });
|
||||
if (repos.length === 0) {
|
||||
console.log("No wiki repos found. Create one with: wiki repo create <name>");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("\nWiki repositories:\n");
|
||||
repos.forEach((r, i) => {
|
||||
console.log(` ${i + 1}) ${r.full_name}`);
|
||||
});
|
||||
console.log();
|
||||
|
||||
const answer = await promptUser(`Select repo (1-${repos.length}): `);
|
||||
if (!answer) return;
|
||||
const idx = parseInt(answer, 10) - 1;
|
||||
if (isNaN(idx) || idx < 0 || idx >= repos.length) {
|
||||
console.error("Invalid selection.");
|
||||
process.exit(1);
|
||||
}
|
||||
repoName = repos[idx]!.name;
|
||||
}
|
||||
|
||||
const localDir = resolve(
|
||||
options.dir ?? `${homedir()}/wikis/${repoName.replace(/^wiki-/, "")}`,
|
||||
);
|
||||
|
||||
console.log(`Cloning ${auth.username}/${repoName} to ${localDir}...`);
|
||||
const cloneResult = await git.clone(
|
||||
`git@github.com:${auth.username}/${repoName}.git`,
|
||||
localDir,
|
||||
);
|
||||
|
||||
if (!cloneResult.ok) {
|
||||
console.error(`Clone failed: ${cloneResult.output}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check if it's a wiki and register
|
||||
const config = await loadConfig(localDir);
|
||||
const wikiName = config?.name ?? repoName.replace(/^wiki-/, "");
|
||||
const domain = config?.domain ?? "general";
|
||||
|
||||
const entry: RegistryEntry = {
|
||||
path: localDir,
|
||||
name: wikiName,
|
||||
domain,
|
||||
created: config?.created ?? new Date().toISOString(),
|
||||
remote: `git@github.com:${auth.username}/${repoName}.git`,
|
||||
};
|
||||
await addToRegistry(wikiName, entry);
|
||||
|
||||
console.log(`Cloned and registered as "${wikiName}".`);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("connect")
|
||||
.description("Connect an existing wiki to a GitHub repo")
|
||||
.argument("[wiki-id]", "wiki to connect")
|
||||
.action(async function (this: Command, wikiId: string | undefined) {
|
||||
const auth = await loadAuth();
|
||||
if (!auth) {
|
||||
console.error('Not authenticated. Run "wiki auth login" first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let ctx: WikiContext;
|
||||
if (wikiId) {
|
||||
const { resolveWiki } = await import("../lib/resolver.ts");
|
||||
const resolved = await resolveWiki({ wiki: wikiId });
|
||||
if (!resolved) {
|
||||
console.error(`Wiki "${wikiId}" not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
ctx = { ...resolved, provider: await createProvider(resolved.config, resolved.root) };
|
||||
} else {
|
||||
ctx = this.optsWithGlobals().wikiContext;
|
||||
}
|
||||
|
||||
const hasExisting = await git.hasRemote(ctx.root);
|
||||
if (hasExisting) {
|
||||
console.error("This wiki already has a remote. Use git remote to manage it.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const repoName = `wiki-${ctx.config.name}`;
|
||||
console.log(`Creating GitHub repo: ${repoName}...`);
|
||||
|
||||
let repo;
|
||||
try {
|
||||
repo = await createRepo(repoName, {
|
||||
private: true,
|
||||
description: `LLM Wiki: ${ctx.config.name} (${ctx.config.domain})`,
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("already exists")) {
|
||||
console.log(`Repo "${repoName}" already exists, connecting to it...`);
|
||||
repo = await getRepo(auth.username, repoName);
|
||||
if (!repo) {
|
||||
console.error(`Could not find repo "${repoName}" on your account.`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
console.error(err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
await git.addRemote(ctx.root, "origin", repo.ssh_url);
|
||||
const branch = await git.currentBranch(ctx.root);
|
||||
|
||||
// Fetch and pull to merge any existing remote history
|
||||
await git.fetch(ctx.root, "origin");
|
||||
const pullResult = await git.pullRebaseAllowUnrelated(ctx.root, "origin", branch);
|
||||
if (!pullResult.ok || await git.hasConflicts(ctx.root)) {
|
||||
console.error("Remote added but there are merge conflicts.");
|
||||
console.log("Resolve conflicts in .llmwiki.yaml, then run:");
|
||||
console.log(" git add .llmwiki.yaml && git rebase --continue");
|
||||
console.log(" wiki push");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pushResult = await git.push(ctx.root, "origin", branch);
|
||||
if (pushResult.ok) {
|
||||
console.log(`Connected and pushed to ${repo.html_url}`);
|
||||
} else {
|
||||
console.error(`Remote added but push failed: ${pushResult.output}`);
|
||||
console.log(`Try: wiki sync`);
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { Command } from "commander";
|
||||
import * as git from "../lib/git.ts";
|
||||
import { requireGit } from "../lib/storage.ts";
|
||||
import type { WikiContext } from "../types.ts";
|
||||
|
||||
export function makeSyncCommand(): Command {
|
||||
return new Command("sync")
|
||||
.description("Pull then push (sync with remote)")
|
||||
.action(async function (this: Command) {
|
||||
const ctx: WikiContext = this.optsWithGlobals().wikiContext;
|
||||
requireGit(ctx, "sync");
|
||||
|
||||
if (!(await git.hasRemote(ctx.root))) {
|
||||
console.error('No remote configured. Use "wiki repo connect" to add one.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const branch = await git.currentBranch(ctx.root);
|
||||
|
||||
console.log("Pulling...");
|
||||
const pullResult = await git.pull(ctx.root, "origin", branch);
|
||||
if (!pullResult.ok) {
|
||||
console.error(`Pull failed: ${pullResult.output}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (await git.hasConflicts(ctx.root)) {
|
||||
console.error("Pull succeeded but there are merge conflicts to resolve.");
|
||||
console.log("Fix the conflicts, then: git add <files> && git rebase --continue");
|
||||
console.log("After resolving, run: wiki push");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(pullResult.output || "Already up to date.");
|
||||
|
||||
console.log("Pushing...");
|
||||
const pushResult = await git.push(ctx.root, "origin", branch);
|
||||
if (!pushResult.ok) {
|
||||
console.error(`Push failed: ${pushResult.output}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(pushResult.output || "Pushed successfully.");
|
||||
|
||||
console.log("Synced.");
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Command } from "commander";
|
||||
import { loadRegistry, setDefault } from "../lib/registry.ts";
|
||||
import { pickWiki } from "../lib/picker.ts";
|
||||
|
||||
export function makeUseCommand(): Command {
|
||||
return new Command("use")
|
||||
@@ -8,11 +7,19 @@ export function makeUseCommand(): Command {
|
||||
.argument("[wiki-id]", "wiki identifier to activate")
|
||||
.action(async (wikiId: string | undefined) => {
|
||||
if (!wikiId) {
|
||||
const selected = await pickWiki();
|
||||
if (!selected) {
|
||||
const registry = await loadRegistry();
|
||||
const ids = Object.keys(registry.wikis);
|
||||
if (ids.length === 0) {
|
||||
console.error('No wikis registered. Run "wiki init" first.');
|
||||
process.exit(1);
|
||||
}
|
||||
wikiId = selected.id;
|
||||
console.log("Available wikis:");
|
||||
for (const id of ids) {
|
||||
const marker = id === registry.default ? " (active)" : "";
|
||||
console.log(` ${id}${marker}`);
|
||||
}
|
||||
console.log('\nUsage: wiki use <wiki-id>');
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await setDefault(wikiId);
|
||||
@@ -22,8 +29,6 @@ export function makeUseCommand(): Command {
|
||||
console.error(`Wiki "${wikiId}" not found in registry.`);
|
||||
if (ids.length > 0) {
|
||||
console.error(`Available wikis: ${ids.join(", ")}`);
|
||||
} else {
|
||||
console.error('No wikis registered. Run "wiki init" first.');
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import * as yaml from "js-yaml";
|
||||
import { join } from "path";
|
||||
import { readFile, writeFile, mkdir, rm } from "fs/promises";
|
||||
import { homedir } from "os";
|
||||
|
||||
export interface AuthConfig {
|
||||
token: string;
|
||||
username: string;
|
||||
created: string;
|
||||
}
|
||||
|
||||
function getConfigDir(): string {
|
||||
return process.env.LLMWIKI_CONFIG_DIR ?? join(homedir(), ".config", "llmwiki");
|
||||
}
|
||||
|
||||
function getAuthPath(): string {
|
||||
return join(getConfigDir(), "auth.yaml");
|
||||
}
|
||||
|
||||
export async function loadAuth(): Promise<AuthConfig | null> {
|
||||
try {
|
||||
const content = await readFile(getAuthPath(), "utf-8");
|
||||
return yaml.load(content) as AuthConfig;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveAuth(auth: AuthConfig): Promise<void> {
|
||||
await mkdir(getConfigDir(), { recursive: true });
|
||||
const content = yaml.dump(auth, { lineWidth: 120, sortKeys: false });
|
||||
await writeFile(getAuthPath(), content, "utf-8");
|
||||
}
|
||||
|
||||
export async function clearAuth(): Promise<void> {
|
||||
try {
|
||||
await rm(getAuthPath());
|
||||
} catch {
|
||||
// ignore if doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
export async function getToken(): Promise<string> {
|
||||
const auth = await loadAuth();
|
||||
if (!auth) {
|
||||
throw new Error('Not authenticated. Run "wiki auth login" first.');
|
||||
}
|
||||
return auth.token;
|
||||
}
|
||||
|
||||
export async function isAuthenticated(): Promise<boolean> {
|
||||
const auth = await loadAuth();
|
||||
return auth !== null;
|
||||
}
|
||||
|
||||
export async function validateToken(token: string): Promise<{ valid: boolean; username: string }> {
|
||||
try {
|
||||
const res = await fetch("https://api.github.com/user", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/vnd.github+json",
|
||||
},
|
||||
});
|
||||
if (!res.ok) return { valid: false, username: "" };
|
||||
const data = (await res.json()) as { login: string };
|
||||
return { valid: true, username: data.login };
|
||||
} catch {
|
||||
return { valid: false, username: "" };
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,12 @@ import type { StorageProvider } from "../types.ts";
|
||||
export class GitProvider implements StorageProvider {
|
||||
private wiki: WikiManager;
|
||||
public readonly root: string;
|
||||
private gitConfig?: { token: string; repo: string };
|
||||
|
||||
constructor(root: string) {
|
||||
constructor(root: string, gitConfig?: { token: string; repo: string }) {
|
||||
this.root = root;
|
||||
this.wiki = new WikiManager(root);
|
||||
this.gitConfig = gitConfig;
|
||||
}
|
||||
|
||||
async readPage(relativePath: string): Promise<string | null> {
|
||||
@@ -39,5 +41,9 @@ export class GitProvider implements StorageProvider {
|
||||
private async autoCommit(message: string): Promise<void> {
|
||||
await git.addAll(this.root);
|
||||
await git.commit(this.root, message);
|
||||
if (this.gitConfig) {
|
||||
const branch = await git.currentBranch(this.root);
|
||||
await git.push(this.root, "origin", branch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { getToken } from "./auth.ts";
|
||||
|
||||
interface GitHubRepo {
|
||||
name: string;
|
||||
full_name: string;
|
||||
@@ -7,68 +5,23 @@ interface GitHubRepo {
|
||||
html_url: string;
|
||||
clone_url: string;
|
||||
ssh_url: string;
|
||||
pushed_at: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
async function githubFetch(path: string, options?: RequestInit): Promise<Response> {
|
||||
const token = await getToken();
|
||||
const res = await fetch(`https://api.github.com${path}`, {
|
||||
...options,
|
||||
export async function createRepo(
|
||||
token: string,
|
||||
name: string,
|
||||
): Promise<GitHubRepo> {
|
||||
const res = await fetch("https://api.github.com/user/repos", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/vnd.github+json",
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function listRepos(options?: {
|
||||
all?: boolean;
|
||||
filter?: string;
|
||||
}): Promise<GitHubRepo[]> {
|
||||
const perPage = options?.all ? 100 : 20;
|
||||
let repos: GitHubRepo[] = [];
|
||||
let page = 1;
|
||||
|
||||
while (true) {
|
||||
const res = await githubFetch(
|
||||
`/user/repos?per_page=${perPage}&sort=pushed&page=${page}`,
|
||||
);
|
||||
if (!res.ok) throw new Error(`GitHub API error: ${res.status} ${res.statusText}`);
|
||||
const data = (await res.json()) as GitHubRepo[];
|
||||
if (data.length === 0) break;
|
||||
repos = repos.concat(data);
|
||||
if (!options?.all || data.length < perPage) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
if (options?.filter) {
|
||||
const f = options.filter.toLowerCase();
|
||||
repos = repos.filter((r) => r.name.toLowerCase().includes(f));
|
||||
}
|
||||
|
||||
return repos;
|
||||
}
|
||||
|
||||
export async function getRepo(owner: string, name: string): Promise<GitHubRepo | null> {
|
||||
const res = await githubFetch(`/repos/${owner}/${name}`);
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as GitHubRepo;
|
||||
}
|
||||
|
||||
export async function createRepo(
|
||||
name: string,
|
||||
options?: { private?: boolean; description?: string },
|
||||
): Promise<GitHubRepo> {
|
||||
const res = await githubFetch("/user/repos", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
private: options?.private ?? true,
|
||||
description: options?.description ?? "LLM Wiki",
|
||||
private: true,
|
||||
description: "LLM Wiki",
|
||||
auto_init: false,
|
||||
}),
|
||||
});
|
||||
@@ -76,12 +29,10 @@ export async function createRepo(
|
||||
if (!res.ok) {
|
||||
const err = (await res.json()) as { message: string; errors?: { message: string }[] };
|
||||
if (res.status === 422 && err.message?.includes("name already exists")) {
|
||||
throw new Error(
|
||||
`Repository "${name}" already exists. Delete it first with:\n gh repo delete ${name} --yes\nOr use a different name.`,
|
||||
);
|
||||
throw new Error(`Repository "${name}" already exists.`);
|
||||
}
|
||||
if (res.status === 401) {
|
||||
throw new Error('Authentication failed. Run "wiki auth login" to re-authenticate.');
|
||||
throw new Error("Authentication failed. Check your --git-token.");
|
||||
}
|
||||
if (res.status === 403) {
|
||||
throw new Error("Permission denied. Your token may lack the 'repo' scope.");
|
||||
@@ -92,3 +43,15 @@ export async function createRepo(
|
||||
|
||||
return (await res.json()) as GitHubRepo;
|
||||
}
|
||||
|
||||
export async function getUsername(token: string): Promise<string> {
|
||||
const res = await fetch("https://api.github.com/user", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/vnd.github+json",
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error("Invalid git token. GitHub API returned " + res.status);
|
||||
const data = (await res.json()) as { login: string };
|
||||
return data.login;
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { loadRegistry } from "./registry.ts";
|
||||
import type { RegistryEntry } from "../types.ts";
|
||||
import { promptUser } from "./prompt.ts";
|
||||
|
||||
export async function pickWiki(): Promise<
|
||||
(RegistryEntry & { id: string }) | null
|
||||
> {
|
||||
const registry = await loadRegistry();
|
||||
const entries = Object.entries(registry.wikis);
|
||||
|
||||
if (entries.length === 0) {
|
||||
console.error('No wikis registered. Run "wiki init" to create one.');
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log("\nRegistered wikis:\n");
|
||||
entries.forEach(([id, w], i) => {
|
||||
const marker = id === registry.default ? " (default)" : "";
|
||||
console.log(` ${i + 1}) ${w.name} [${w.domain}]${marker}`);
|
||||
console.log(` ${w.path}`);
|
||||
});
|
||||
console.log();
|
||||
|
||||
const answer = await promptUser(`Select wiki (1-${entries.length}): `);
|
||||
if (!answer) return null;
|
||||
|
||||
const index = parseInt(answer, 10) - 1;
|
||||
if (isNaN(index) || index < 0 || index >= entries.length) {
|
||||
console.error("Invalid selection.");
|
||||
return null;
|
||||
}
|
||||
|
||||
const selected = entries[index];
|
||||
if (!selected) return null;
|
||||
return { id: selected[0], ...selected[1] };
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { createInterface } from "node:readline";
|
||||
|
||||
export function promptUser(message: string): Promise<string | null> {
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
rl.question(message, (answer) => {
|
||||
rl.close();
|
||||
resolve(answer || null);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { WikiManager } from "./wiki.ts";
|
||||
import { GitProvider } from "./git-provider.ts";
|
||||
import type { WikiConfig, StorageProvider, WikiContext } from "../types.ts";
|
||||
import type { WikiConfig, StorageProvider } from "../types.ts";
|
||||
|
||||
export async function createProvider(
|
||||
config: WikiConfig,
|
||||
@@ -11,7 +11,7 @@ export async function createProvider(
|
||||
case "filesystem":
|
||||
return new WikiManager(root);
|
||||
case "git":
|
||||
return new GitProvider(root);
|
||||
return new GitProvider(root, config.git);
|
||||
case "supabase": {
|
||||
if (!config.supabase?.url || !config.supabase?.key) {
|
||||
throw new Error(
|
||||
@@ -32,12 +32,3 @@ export async function createProvider(
|
||||
}
|
||||
}
|
||||
|
||||
export function requireGit(ctx: WikiContext, commandName: string): void {
|
||||
const backend = ctx.config.backend ?? "filesystem";
|
||||
if (backend !== "git") {
|
||||
console.error(
|
||||
`"wiki ${commandName}" requires git backend. This wiki uses "${backend}".`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ export function getDefaultConfig(
|
||||
name: string,
|
||||
domain: string,
|
||||
backend: BackendType = "filesystem",
|
||||
supabase?: { url: string; key: string },
|
||||
options?: {
|
||||
git?: { token: string; repo: string };
|
||||
supabase?: { url: string; key: string };
|
||||
},
|
||||
): WikiConfig {
|
||||
const config: WikiConfig = {
|
||||
name,
|
||||
@@ -17,8 +20,11 @@ export function getDefaultConfig(
|
||||
schema: "SCHEMA.md",
|
||||
},
|
||||
};
|
||||
if (supabase) {
|
||||
config.supabase = supabase;
|
||||
if (options?.git) {
|
||||
config.git = options.git;
|
||||
}
|
||||
if (options?.supabase) {
|
||||
config.supabase = options.supabase;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ export interface WikiConfig {
|
||||
domain: string;
|
||||
created: string;
|
||||
backend?: BackendType;
|
||||
git?: {
|
||||
token: string;
|
||||
repo: string;
|
||||
};
|
||||
supabase?: {
|
||||
url: string;
|
||||
key: string;
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { mkdtemp, rm } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import {
|
||||
loadAuth,
|
||||
saveAuth,
|
||||
clearAuth,
|
||||
isAuthenticated,
|
||||
} from "../src/lib/auth.ts";
|
||||
|
||||
let configDir: string;
|
||||
const origConfigDir = process.env.LLMWIKI_CONFIG_DIR;
|
||||
|
||||
beforeEach(async () => {
|
||||
configDir = await mkdtemp(join(tmpdir(), "llmwiki-auth-"));
|
||||
process.env.LLMWIKI_CONFIG_DIR = configDir;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(configDir, { recursive: true, force: true });
|
||||
if (origConfigDir) {
|
||||
process.env.LLMWIKI_CONFIG_DIR = origConfigDir;
|
||||
} else {
|
||||
delete process.env.LLMWIKI_CONFIG_DIR;
|
||||
}
|
||||
});
|
||||
|
||||
describe("auth", () => {
|
||||
it("loadAuth returns null when not authenticated", async () => {
|
||||
const auth = await loadAuth();
|
||||
expect(auth).toBeNull();
|
||||
});
|
||||
|
||||
it("isAuthenticated returns false when not authenticated", async () => {
|
||||
expect(await isAuthenticated()).toBe(false);
|
||||
});
|
||||
|
||||
it("saveAuth and loadAuth roundtrip", async () => {
|
||||
const authData = {
|
||||
token: "ghp_test123",
|
||||
username: "testuser",
|
||||
created: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
await saveAuth(authData);
|
||||
const loaded = await loadAuth();
|
||||
expect(loaded).toEqual(authData);
|
||||
});
|
||||
|
||||
it("isAuthenticated returns true after saveAuth", async () => {
|
||||
await saveAuth({
|
||||
token: "ghp_test",
|
||||
username: "user",
|
||||
created: new Date().toISOString(),
|
||||
});
|
||||
expect(await isAuthenticated()).toBe(true);
|
||||
});
|
||||
|
||||
it("clearAuth removes credentials", async () => {
|
||||
await saveAuth({
|
||||
token: "ghp_test",
|
||||
username: "user",
|
||||
created: new Date().toISOString(),
|
||||
});
|
||||
await clearAuth();
|
||||
expect(await isAuthenticated()).toBe(false);
|
||||
});
|
||||
|
||||
it("clearAuth on empty config does not throw", async () => {
|
||||
await clearAuth(); // should not throw
|
||||
expect(await isAuthenticated()).toBe(false);
|
||||
});
|
||||
|
||||
it("saveAuth overwrites existing credentials", async () => {
|
||||
await saveAuth({
|
||||
token: "ghp_first",
|
||||
username: "user1",
|
||||
created: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
await saveAuth({
|
||||
token: "ghp_second",
|
||||
username: "user2",
|
||||
created: "2026-02-01T00:00:00.000Z",
|
||||
});
|
||||
const loaded = await loadAuth();
|
||||
expect(loaded!.token).toBe("ghp_second");
|
||||
expect(loaded!.username).toBe("user2");
|
||||
});
|
||||
|
||||
it("getToken throws when not authenticated", async () => {
|
||||
const { getToken } = await import("../src/lib/auth.ts");
|
||||
try {
|
||||
await getToken();
|
||||
expect(true).toBe(false); // should not reach
|
||||
} catch (err: unknown) {
|
||||
expect((err as Error).message).toContain("Not authenticated");
|
||||
}
|
||||
});
|
||||
|
||||
it("getToken returns token when authenticated", async () => {
|
||||
const { getToken } = await import("../src/lib/auth.ts");
|
||||
await saveAuth({
|
||||
token: "ghp_mytoken",
|
||||
username: "user",
|
||||
created: new Date().toISOString(),
|
||||
});
|
||||
const token = await getToken();
|
||||
expect(token).toBe("ghp_mytoken");
|
||||
});
|
||||
|
||||
it("clearAuth then saveAuth works correctly", async () => {
|
||||
await saveAuth({
|
||||
token: "ghp_old",
|
||||
username: "old",
|
||||
created: new Date().toISOString(),
|
||||
});
|
||||
await clearAuth();
|
||||
await saveAuth({
|
||||
token: "ghp_new",
|
||||
username: "new",
|
||||
created: new Date().toISOString(),
|
||||
});
|
||||
const loaded = await loadAuth();
|
||||
expect(loaded!.token).toBe("ghp_new");
|
||||
});
|
||||
});
|
||||
@@ -221,38 +221,6 @@ describe("log command", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- commit ---
|
||||
|
||||
describe("commit command", () => {
|
||||
it("commits changes with provided message", async () => {
|
||||
await initWiki("testwiki", "git");
|
||||
// Modify a file directly (not via wiki write, which auto-commits)
|
||||
const indexPath = join(wikiDir, "wiki/index.md");
|
||||
const content = await readFile(indexPath, "utf-8");
|
||||
await writeFile(indexPath, content + "\n- [[new-entry]]\n", "utf-8");
|
||||
const result = await runWiki(["-w", "testwiki", "commit", "Add new entry"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("Add new entry");
|
||||
});
|
||||
|
||||
it("reports nothing to commit when clean", async () => {
|
||||
await initWiki("testwiki", "git");
|
||||
const result = await runWiki(["-w", "testwiki", "commit", "Empty commit"]);
|
||||
expect(result.stdout).toContain("Nothing to commit");
|
||||
});
|
||||
|
||||
it("auto-commits on wiki write with git backend", async () => {
|
||||
await initWiki("testwiki", "git");
|
||||
await runWiki(
|
||||
["-w", "testwiki", "write", "wiki/concepts/attention.md"],
|
||||
"Attention content",
|
||||
);
|
||||
// Page was auto-committed, so manual commit has nothing to do
|
||||
const result = await runWiki(["-w", "testwiki", "commit", "Manual commit"]);
|
||||
expect(result.stdout).toContain("Nothing to commit");
|
||||
});
|
||||
});
|
||||
|
||||
// --- lint ---
|
||||
|
||||
describe("lint command", () => {
|
||||
@@ -304,7 +272,7 @@ describe("status command", () => {
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("testwiki");
|
||||
expect(result.stdout).toContain("Pages:");
|
||||
expect(result.stdout).toContain("Git:");
|
||||
expect(result.stdout).toContain("Pages:");
|
||||
});
|
||||
|
||||
it("outputs json format", async () => {
|
||||
@@ -374,42 +342,6 @@ describe("orphans command", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- history + diff ---
|
||||
|
||||
describe("history command", () => {
|
||||
it("shows git history for a page", async () => {
|
||||
await initWiki("testwiki", "git");
|
||||
// wiki write auto-commits with git backend
|
||||
await runWiki(
|
||||
["-w", "testwiki", "write", "wiki/concepts/tracked.md"],
|
||||
"Version 1",
|
||||
);
|
||||
const result = await runWiki(["-w", "testwiki", "history", "wiki/concepts/tracked.md"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("update wiki/concepts/tracked.md");
|
||||
});
|
||||
});
|
||||
|
||||
describe("diff command", () => {
|
||||
it("shows no changes when working tree is clean", async () => {
|
||||
await initWiki("testwiki", "git");
|
||||
const result = await runWiki(["-w", "testwiki", "diff"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("No changes");
|
||||
});
|
||||
|
||||
it("shows changes to tracked files", async () => {
|
||||
await initWiki("testwiki", "git");
|
||||
// Modify a tracked file directly (not via wiki write, which auto-commits)
|
||||
const indexPath = join(wikiDir, "wiki/index.md");
|
||||
const content = await readFile(indexPath, "utf-8");
|
||||
await writeFile(indexPath, content + "\n## New Section\n", "utf-8");
|
||||
const result = await runWiki(["-w", "testwiki", "diff"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("New Section");
|
||||
});
|
||||
});
|
||||
|
||||
// --- error cases ---
|
||||
|
||||
describe("error handling", () => {
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test";
|
||||
import { mkdtemp, rm, writeFile, mkdir } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import * as yaml from "js-yaml";
|
||||
|
||||
let configDir: string;
|
||||
const origConfigDir = process.env.LLMWIKI_CONFIG_DIR;
|
||||
const origFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(async () => {
|
||||
configDir = await mkdtemp(join(tmpdir(), "llmwiki-gh-"));
|
||||
process.env.LLMWIKI_CONFIG_DIR = configDir;
|
||||
|
||||
// Save a fake auth token
|
||||
await mkdir(configDir, { recursive: true });
|
||||
const auth = { token: "ghp_fake123", username: "testuser", created: "2026-01-01T00:00:00.000Z" };
|
||||
await writeFile(join(configDir, "auth.yaml"), yaml.dump(auth), "utf-8");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
globalThis.fetch = origFetch;
|
||||
await rm(configDir, { recursive: true, force: true });
|
||||
if (origConfigDir) {
|
||||
process.env.LLMWIKI_CONFIG_DIR = origConfigDir;
|
||||
} else {
|
||||
delete process.env.LLMWIKI_CONFIG_DIR;
|
||||
}
|
||||
});
|
||||
|
||||
function mockFetch(handler: (url: string, opts?: RequestInit) => Response | Promise<Response>) {
|
||||
globalThis.fetch = handler as typeof fetch;
|
||||
}
|
||||
|
||||
function jsonResponse(data: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("listRepos", () => {
|
||||
it("returns repos from API", async () => {
|
||||
const { listRepos } = await import("../src/lib/github.ts");
|
||||
const fakeRepos = [
|
||||
{ name: "wiki-science", full_name: "user/wiki-science", private: true, html_url: "", clone_url: "", ssh_url: "", pushed_at: "", description: null },
|
||||
{ name: "wiki-code", full_name: "user/wiki-code", private: true, html_url: "", clone_url: "", ssh_url: "", pushed_at: "", description: null },
|
||||
];
|
||||
mockFetch(() => jsonResponse(fakeRepos));
|
||||
|
||||
const repos = await listRepos();
|
||||
expect(repos).toHaveLength(2);
|
||||
expect(repos[0]!.name).toBe("wiki-science");
|
||||
});
|
||||
|
||||
it("filters repos by name", async () => {
|
||||
const { listRepos } = await import("../src/lib/github.ts");
|
||||
const fakeRepos = [
|
||||
{ name: "wiki-science", full_name: "user/wiki-science", private: true, html_url: "", clone_url: "", ssh_url: "", pushed_at: "", description: null },
|
||||
{ name: "other-project", full_name: "user/other-project", private: false, html_url: "", clone_url: "", ssh_url: "", pushed_at: "", description: null },
|
||||
];
|
||||
mockFetch(() => jsonResponse(fakeRepos));
|
||||
|
||||
const repos = await listRepos({ filter: "wiki" });
|
||||
expect(repos).toHaveLength(1);
|
||||
expect(repos[0]!.name).toBe("wiki-science");
|
||||
});
|
||||
|
||||
it("filter is case insensitive", async () => {
|
||||
const { listRepos } = await import("../src/lib/github.ts");
|
||||
const fakeRepos = [
|
||||
{ name: "MyWiki", full_name: "user/MyWiki", private: true, html_url: "", clone_url: "", ssh_url: "", pushed_at: "", description: null },
|
||||
];
|
||||
mockFetch(() => jsonResponse(fakeRepos));
|
||||
|
||||
const repos = await listRepos({ filter: "mywiki" });
|
||||
expect(repos).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("throws on API error", async () => {
|
||||
const { listRepos } = await import("../src/lib/github.ts");
|
||||
mockFetch(() => new Response("", { status: 500, statusText: "Internal Server Error" }));
|
||||
|
||||
try {
|
||||
await listRepos();
|
||||
expect(true).toBe(false);
|
||||
} catch (err: unknown) {
|
||||
expect((err as Error).message).toContain("GitHub API error");
|
||||
}
|
||||
});
|
||||
|
||||
it("handles empty response", async () => {
|
||||
const { listRepos } = await import("../src/lib/github.ts");
|
||||
mockFetch(() => jsonResponse([]));
|
||||
|
||||
const repos = await listRepos();
|
||||
expect(repos).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("uses perPage=20 by default (not all)", async () => {
|
||||
const { listRepos } = await import("../src/lib/github.ts");
|
||||
let capturedUrl = "";
|
||||
mockFetch((url: string) => {
|
||||
capturedUrl = url;
|
||||
return jsonResponse([]);
|
||||
});
|
||||
|
||||
await listRepos();
|
||||
expect(capturedUrl).toContain("per_page=20");
|
||||
});
|
||||
|
||||
it("uses perPage=100 when all=true", async () => {
|
||||
const { listRepos } = await import("../src/lib/github.ts");
|
||||
let capturedUrl = "";
|
||||
mockFetch((url: string) => {
|
||||
capturedUrl = url;
|
||||
return jsonResponse([]);
|
||||
});
|
||||
|
||||
await listRepos({ all: true });
|
||||
expect(capturedUrl).toContain("per_page=100");
|
||||
});
|
||||
|
||||
it("stops pagination when page returns fewer than perPage", async () => {
|
||||
const { listRepos } = await import("../src/lib/github.ts");
|
||||
let callCount = 0;
|
||||
mockFetch(() => {
|
||||
callCount++;
|
||||
const repos = Array.from({ length: 5 }, (_, i) => ({
|
||||
name: `repo-${i}`, full_name: `user/repo-${i}`, private: true,
|
||||
html_url: "", clone_url: "", ssh_url: "", pushed_at: "", description: null,
|
||||
}));
|
||||
return jsonResponse(repos);
|
||||
});
|
||||
|
||||
const repos = await listRepos({ all: true });
|
||||
expect(repos).toHaveLength(5);
|
||||
expect(callCount).toBe(1); // Should not paginate since 5 < 100
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRepo", () => {
|
||||
it("returns repo data on success", async () => {
|
||||
const { getRepo } = await import("../src/lib/github.ts");
|
||||
const fakeRepo = {
|
||||
name: "wiki-test", full_name: "user/wiki-test", private: true,
|
||||
html_url: "https://github.com/user/wiki-test", clone_url: "", ssh_url: "",
|
||||
pushed_at: "2026-01-01", description: "test",
|
||||
};
|
||||
mockFetch(() => jsonResponse(fakeRepo));
|
||||
|
||||
const repo = await getRepo("user", "wiki-test");
|
||||
expect(repo).not.toBeNull();
|
||||
expect(repo!.name).toBe("wiki-test");
|
||||
});
|
||||
|
||||
it("returns null on 404", async () => {
|
||||
const { getRepo } = await import("../src/lib/github.ts");
|
||||
mockFetch(() => new Response("", { status: 404 }));
|
||||
|
||||
const repo = await getRepo("user", "nonexistent");
|
||||
expect(repo).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createRepo", () => {
|
||||
it("creates repo successfully", async () => {
|
||||
const { createRepo } = await import("../src/lib/github.ts");
|
||||
const fakeRepo = {
|
||||
name: "wiki-new", full_name: "user/wiki-new", private: true,
|
||||
html_url: "", clone_url: "", ssh_url: "", pushed_at: "", description: "LLM Wiki",
|
||||
};
|
||||
mockFetch(() => jsonResponse(fakeRepo, 201));
|
||||
|
||||
const repo = await createRepo("wiki-new");
|
||||
expect(repo.name).toBe("wiki-new");
|
||||
});
|
||||
|
||||
it("throws specific error for duplicate name (422)", async () => {
|
||||
const { createRepo } = await import("../src/lib/github.ts");
|
||||
mockFetch(() => jsonResponse(
|
||||
{ message: "Repository creation failed. name already exists on this account" },
|
||||
422,
|
||||
));
|
||||
|
||||
try {
|
||||
await createRepo("existing-repo");
|
||||
expect(true).toBe(false);
|
||||
} catch (err: unknown) {
|
||||
expect((err as Error).message).toContain("already exists");
|
||||
}
|
||||
});
|
||||
|
||||
it("throws auth error for 401", async () => {
|
||||
const { createRepo } = await import("../src/lib/github.ts");
|
||||
mockFetch(() => jsonResponse({ message: "Bad credentials" }, 401));
|
||||
|
||||
try {
|
||||
await createRepo("new-repo");
|
||||
expect(true).toBe(false);
|
||||
} catch (err: unknown) {
|
||||
expect((err as Error).message).toContain("Authentication failed");
|
||||
}
|
||||
});
|
||||
|
||||
it("throws permission error for 403", async () => {
|
||||
const { createRepo } = await import("../src/lib/github.ts");
|
||||
mockFetch(() => jsonResponse({ message: "Forbidden" }, 403));
|
||||
|
||||
try {
|
||||
await createRepo("new-repo");
|
||||
expect(true).toBe(false);
|
||||
} catch (err: unknown) {
|
||||
expect((err as Error).message).toContain("Permission denied");
|
||||
}
|
||||
});
|
||||
|
||||
it("uses default options (private, LLM Wiki description)", async () => {
|
||||
const { createRepo } = await import("../src/lib/github.ts");
|
||||
let capturedBody: string = "";
|
||||
mockFetch(async (_url, opts) => {
|
||||
capturedBody = opts?.body as string ?? "";
|
||||
return jsonResponse({
|
||||
name: "wiki-new", full_name: "user/wiki-new", private: true,
|
||||
html_url: "", clone_url: "", ssh_url: "", pushed_at: "", description: "LLM Wiki",
|
||||
}, 201);
|
||||
});
|
||||
|
||||
await createRepo("wiki-new");
|
||||
const parsed = JSON.parse(capturedBody);
|
||||
expect(parsed.private).toBe(true);
|
||||
expect(parsed.description).toBe("LLM Wiki");
|
||||
expect(parsed.auto_init).toBe(false);
|
||||
});
|
||||
|
||||
it("respects custom options", async () => {
|
||||
const { createRepo } = await import("../src/lib/github.ts");
|
||||
let capturedBody: string = "";
|
||||
mockFetch(async (_url, opts) => {
|
||||
capturedBody = opts?.body as string ?? "";
|
||||
return jsonResponse({
|
||||
name: "wiki-pub", full_name: "user/wiki-pub", private: false,
|
||||
html_url: "", clone_url: "", ssh_url: "", pushed_at: "", description: "My wiki",
|
||||
}, 201);
|
||||
});
|
||||
|
||||
await createRepo("wiki-pub", { private: false, description: "My wiki" });
|
||||
const parsed = JSON.parse(capturedBody);
|
||||
expect(parsed.private).toBe(false);
|
||||
expect(parsed.description).toBe("My wiki");
|
||||
});
|
||||
|
||||
it("includes error details in generic failures", async () => {
|
||||
const { createRepo } = await import("../src/lib/github.ts");
|
||||
mockFetch(() => jsonResponse(
|
||||
{ message: "Validation failed", errors: [{ message: "name is invalid" }] },
|
||||
422,
|
||||
));
|
||||
|
||||
try {
|
||||
await createRepo("bad--name");
|
||||
expect(true).toBe(false);
|
||||
} catch (err: unknown) {
|
||||
expect((err as Error).message).toContain("name is invalid");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("githubFetch auth headers", () => {
|
||||
it("includes Authorization header with token", async () => {
|
||||
const { listRepos } = await import("../src/lib/github.ts");
|
||||
let capturedHeaders: Record<string, string> = {};
|
||||
mockFetch((_url, opts) => {
|
||||
capturedHeaders = Object.fromEntries(
|
||||
Object.entries(opts?.headers ?? {}).map(([k, v]) => [k, String(v)])
|
||||
);
|
||||
return jsonResponse([]);
|
||||
});
|
||||
|
||||
await listRepos();
|
||||
expect(capturedHeaders["Authorization"]).toBe("Bearer ghp_fake123");
|
||||
expect(capturedHeaders["Accept"]).toContain("github");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user