From bf2ddb1ca5538be3395ed6176c6b3199faf71ddb Mon Sep 17 00:00:00 2001 From: doum1004 Date: Sat, 11 Apr 2026 01:10:57 -0400 Subject: [PATCH] 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. --- CLAUDE.md | 41 ++---- README.md | 11 +- bin/wiki.ts | 20 +-- docs/SKILL.md | 13 +- package.json | 2 +- src/commands/auth.ts | 61 --------- src/commands/commit.ts | 42 ------ src/commands/diff.ts | 27 ---- src/commands/history.ts | 39 ------ src/commands/init.ts | 48 ++++++- src/commands/pull.ts | 33 ----- src/commands/push.ts | 31 ----- src/commands/repo.ts | 266 ------------------------------------- src/commands/sync.ts | 45 ------- src/commands/use.ts | 17 ++- src/lib/auth.ts | 70 ---------- src/lib/git-provider.ts | 8 +- src/lib/github.ts | 81 ++++-------- src/lib/picker.ts | 36 ----- src/lib/prompt.ts | 15 --- src/lib/storage.ts | 13 +- src/lib/templates.ts | 12 +- src/types.ts | 4 + test/auth.test.ts | 126 ------------------ test/commands.test.ts | 70 +--------- test/github.test.ts | 284 ---------------------------------------- 26 files changed, 132 insertions(+), 1283 deletions(-) delete mode 100644 src/commands/auth.ts delete mode 100644 src/commands/commit.ts delete mode 100644 src/commands/diff.ts delete mode 100644 src/commands/history.ts delete mode 100644 src/commands/pull.ts delete mode 100644 src/commands/push.ts delete mode 100644 src/commands/repo.ts delete mode 100644 src/commands/sync.ts delete mode 100644 src/lib/auth.ts delete mode 100644 src/lib/picker.ts delete mode 100644 src/lib/prompt.ts delete mode 100644 test/auth.test.ts delete mode 100644 test/github.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 3fce690..c995adc 100644 --- a/CLAUDE.md +++ b/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 +wiki init [dir] --backend git --git-token [--git-repo owner/repo] wiki init [dir] --backend supabase --supabase-url --supabase-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 ``` diff --git a/README.md b/README.md index 160034f..a5b1466 100644 --- a/README.md +++ b/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 ` | | `supabase` | Pages in a Supabase database table | `wiki init my-wiki --backend supabase --supabase-url --supabase-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 --domain --backend # Create new wiki +wiki init [dir] --name --domain --backend +wiki init [dir] --backend git --git-token [--git-repo owner/repo] wiki init [dir] --backend supabase --supabase-url --supabase-key wiki registry # List all wikis wiki use [wiki-id] # Set active wiki diff --git a/bin/wiki.ts b/bin/wiki.ts index 5e8a49c..9e477e9 100644 --- a/bin/wiki.ts +++ b/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 ", "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; diff --git a/docs/SKILL.md b/docs/SKILL.md index 5a490c8..8a24d53 100644 --- a/docs/SKILL.md +++ b/docs/SKILL.md @@ -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 ` | | `supabase` | Pages stored in a Supabase database table | `wiki init my-wiki --backend supabase --supabase-url --supabase-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 --domain --backend ` | Create new wiki (backends: filesystem, git, supabase) | +| `wiki init [dir] --backend git --git-token [--git-repo owner/repo]` | Create git-backed wiki with GitHub sync | | `wiki init [dir] --backend supabase --supabase-url --supabase-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 ` to set a default, or pass `--wiki `. +4. **Wiki resolution** — if commands fail with "No wiki found", either `cd` into a wiki directory, run `wiki use ` to set a default, or pass `--wiki `. -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). diff --git a/package.json b/package.json index d131f70..7725fb3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/commands/auth.ts b/src/commands/auth.ts deleted file mode 100644 index ca913e7..0000000 --- a/src/commands/auth.ts +++ /dev/null @@ -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; -} diff --git a/src/commands/commit.ts b/src/commands/commit.ts deleted file mode 100644 index 71747ec..0000000 --- a/src/commands/commit.ts +++ /dev/null @@ -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); - }); -} diff --git a/src/commands/diff.ts b/src/commands/diff.ts deleted file mode 100644 index e883c1c..0000000 --- a/src/commands/diff.ts +++ /dev/null @@ -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); - }); -} diff --git a/src/commands/history.ts b/src/commands/history.ts deleted file mode 100644 index 6cc4832..0000000 --- a/src/commands/history.ts +++ /dev/null @@ -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 ", "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); - }); -} diff --git a/src/commands/init.ts b/src/commands/init.ts index d821161..7865637 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -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 ", "wiki name") .option("-d, --domain ", "knowledge domain", "general") .option("-b, --backend ", "storage backend (filesystem, git, supabase)", "filesystem") + .option("--git-token ", "GitHub personal access token") + .option("--git-repo ", "GitHub repo (auto-created if omitted with --git-token)") .option("--supabase-url ", "Supabase project URL") .option("--supabase-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}`, + ); + } + } } } } diff --git a/src/commands/pull.ts b/src/commands/pull.ts deleted file mode 100644 index d9d0c6b..0000000 --- a/src/commands/pull.ts +++ /dev/null @@ -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 && git rebase --continue"); - process.exit(1); - } - - console.log(result.output || "Already up to date."); - }); -} diff --git a/src/commands/push.ts b/src/commands/push.ts deleted file mode 100644 index 169ff97..0000000 --- a/src/commands/push.ts +++ /dev/null @@ -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."); - }); -} diff --git a/src/commands/repo.ts b/src/commands/repo.ts deleted file mode 100644 index 2834964..0000000 --- a/src/commands/repo.ts +++ /dev/null @@ -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 ", "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("", "wiki name") - .option("-d, --domain ", "knowledge domain", "general") - .option("--public", "create a public repo (default: private)") - .option("--dir ", "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 ", "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 "); - 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; -} diff --git a/src/commands/sync.ts b/src/commands/sync.ts deleted file mode 100644 index 3218181..0000000 --- a/src/commands/sync.ts +++ /dev/null @@ -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 && 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."); - }); -} diff --git a/src/commands/use.ts b/src/commands/use.ts index 3687e3d..ee836b7 100644 --- a/src/commands/use.ts +++ b/src/commands/use.ts @@ -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 '); + 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); } diff --git a/src/lib/auth.ts b/src/lib/auth.ts deleted file mode 100644 index 05684e0..0000000 --- a/src/lib/auth.ts +++ /dev/null @@ -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 { - try { - const content = await readFile(getAuthPath(), "utf-8"); - return yaml.load(content) as AuthConfig; - } catch { - return null; - } -} - -export async function saveAuth(auth: AuthConfig): Promise { - 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 { - try { - await rm(getAuthPath()); - } catch { - // ignore if doesn't exist - } -} - -export async function getToken(): Promise { - const auth = await loadAuth(); - if (!auth) { - throw new Error('Not authenticated. Run "wiki auth login" first.'); - } - return auth.token; -} - -export async function isAuthenticated(): Promise { - 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: "" }; - } -} diff --git a/src/lib/git-provider.ts b/src/lib/git-provider.ts index 4eab8f0..2b9cc5d 100644 --- a/src/lib/git-provider.ts +++ b/src/lib/git-provider.ts @@ -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 { @@ -39,5 +41,9 @@ export class GitProvider implements StorageProvider { private async autoCommit(message: string): Promise { 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); + } } } diff --git a/src/lib/github.ts b/src/lib/github.ts index 2b4e6f4..2ca479c 100644 --- a/src/lib/github.ts +++ b/src/lib/github.ts @@ -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 { - const token = await getToken(); - const res = await fetch(`https://api.github.com${path}`, { - ...options, +export async function createRepo( + token: string, + name: string, +): Promise { + 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 { - 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 { - 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 { - 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 { + 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; +} diff --git a/src/lib/picker.ts b/src/lib/picker.ts deleted file mode 100644 index b7dbebe..0000000 --- a/src/lib/picker.ts +++ /dev/null @@ -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] }; -} diff --git a/src/lib/prompt.ts b/src/lib/prompt.ts deleted file mode 100644 index d98a87f..0000000 --- a/src/lib/prompt.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { createInterface } from "node:readline"; - -export function promptUser(message: string): Promise { - const rl = createInterface({ - input: process.stdin, - output: process.stdout, - }); - - return new Promise((resolve) => { - rl.question(message, (answer) => { - rl.close(); - resolve(answer || null); - }); - }); -} diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 200fd4f..3f8ce6d 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -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); - } -} diff --git a/src/lib/templates.ts b/src/lib/templates.ts index 82de634..ee56387 100644 --- a/src/lib/templates.ts +++ b/src/lib/templates.ts @@ -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; } diff --git a/src/types.ts b/src/types.ts index def2f45..29138a5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,10 @@ export interface WikiConfig { domain: string; created: string; backend?: BackendType; + git?: { + token: string; + repo: string; + }; supabase?: { url: string; key: string; diff --git a/test/auth.test.ts b/test/auth.test.ts deleted file mode 100644 index f5b2ea8..0000000 --- a/test/auth.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/test/commands.test.ts b/test/commands.test.ts index 36c0177..2a9f010 100644 --- a/test/commands.test.ts +++ b/test/commands.test.ts @@ -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", () => { diff --git a/test/github.test.ts b/test/github.test.ts deleted file mode 100644 index dbee8cc..0000000 --- a/test/github.test.ts +++ /dev/null @@ -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) { - 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 = {}; - 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"); - }); -});