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:
doum1004
2026-04-11 01:10:57 -04:00
parent 0d621759cf
commit bf2ddb1ca5
26 changed files with 132 additions and 1283 deletions

View File

@@ -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
```

View File

@@ -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

View File

@@ -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;

View File

@@ -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).

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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);
});
}

View File

@@ -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);
});
}

View File

@@ -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);
});
}

View File

@@ -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}`,
);
}
}
}
}
}

View File

@@ -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.");
});
}

View File

@@ -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.");
});
}

View File

@@ -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;
}

View File

@@ -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.");
});
}

View File

@@ -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);
}

View File

@@ -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: "" };
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}

View File

@@ -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] };
}

View File

@@ -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);
});
});
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -13,6 +13,10 @@ export interface WikiConfig {
domain: string;
created: string;
backend?: BackendType;
git?: {
token: string;
repo: string;
};
supabase?: {
url: string;
key: string;

View File

@@ -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");
});
});

View File

@@ -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", () => {

View File

@@ -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");
});
});