diff --git a/.tmp/tasks.md b/.tmp/tasks.md index f6b36c9..d8796cd 100644 --- a/.tmp/tasks.md +++ b/.tmp/tasks.md @@ -193,45 +193,50 @@ - 検証: ID 指定でノートが返る - [x] T10.10 `status()` ツール登録(インデックス状態・キュー残数・最新エラー) - 検証: 戻り値スキーマのテスト -- [ ] T10.11 Claude Desktop 実機接続テスト(設定例ドキュメント込み) +- [x] T10.11 Claude Desktop 実機接続テスト(設定例ドキュメント込み) - 検証: Claude Desktop の MCP 設定に登録して `save_memory` が呼べる --- ## T11. CLI (F-8) -- [ ] T11.1 CLI フレームワーク選定と導入(候補: Citty / Commander / 自前) - - 検証: `dennoh --help` が表示される -- [ ] T11.2 `dennoh init` 統合(T1.4 を CLI から呼ぶ) -- [ ] T11.3 `dennoh serve` 統合(T10.2) -- [ ] T11.4 `dennoh add ""` 実装(stdin パイプ対応) +- [x] T11.1 CLI フレームワーク選定と導入(自前ディスパッチャを `src/cli/main.ts` に実装) + - 検証: `dennoh --help` が表示される(バイリンガル対応) +- [x] T11.2 `dennoh init` 統合(T1.4 を CLI から呼ぶ) +- [x] T11.3 `dennoh serve` 統合(T10.2) +- [x] T11.4 `dennoh add ""` 実装(stdin パイプ対応) - 検証: `echo hello | dennoh add` でメモが保存される -- [ ] T11.5 `dennoh update ""` 実装(stdin パイプ対応) + - 補足: パイプ判定は `process.stdin.isTTY !== true`(実パイプでは `isTTY` が `undefined` で `=== false` では拾えないため) +- [x] T11.5 `dennoh update ""` 実装(stdin パイプ対応) - 検証: 既存ノートが更新 -- [ ] T11.6 `dennoh delete ` 実装 -- [ ] T11.7 `dennoh search "" [--project X] [--tag Y] [--limit N] [--json]` - - 検証: 表形式と JSON 両方で結果が出る -- [ ] T11.8 `dennoh get [--json]` -- [ ] T11.9 `dennoh recent [--limit N] [--json]` -- [ ] T11.10 `dennoh status` 実装 -- [ ] T11.11 `dennoh reindex` 実装(T4.5 を CLI から) -- [ ] T11.12 `dennoh history ` / `dennoh restore ` 統合 -- [ ] T11.13 `dennoh config get/set/list` 統合 -- [ ] T11.14 終了コード規約(成功 0、ユーザーエラー 1、内部エラー 2) - - 検証: 不正引数で 1、未捕捉例外で 2 -- [ ] T11.15 `--help` / コマンド別ヘルプの i18n 対応 +- [x] T11.6 `dennoh delete ` 実装 +- [x] T11.7 `dennoh search "" [--project X] [--tag Y] [--limit N] [--json]` + - 検証: 1行1件の一覧表示と JSON 両方で結果が出る +- [x] T11.8 `dennoh get [--json]` +- [x] T11.9 `dennoh recent [--limit N] [--json]` +- [x] T11.10 `dennoh status` 実装 +- [x] T11.11 `dennoh reindex` 実装(T4.5 を CLI から) +- [x] T11.12 `dennoh history ` / `dennoh restore ` 統合 +- [x] T11.13 `dennoh config get/set/list` 統合 +- [x] T11.14 終了コード規約(成功 0、ユーザーエラー 1、内部エラー 2) + - 検証: 不正引数・ID不存在・バリデーションで 1、DB接続失敗・予期しない例外で 2(`src/cli/types.ts` に `EXIT_SUCCESS`/`EXIT_USER_ERROR`/`EXIT_INTERNAL_ERROR` を定義し全コマンドに適用) +- [x] T11.15 `--help` / コマンド別ヘルプの i18n 対応 - 検証: `DENNOH_LANG=en dennoh --help` で英語表示 --- ## T12. i18n (4.7) +> **現状メモ**: CLI のメッセージは `status.ts` 由来の「各コマンドファイル内 `MESSAGES: Record`」方式で日英対応済み(add/update/delete/get/search/recent/reindex + main の usage/不明コマンド)。集中辞書(`src/i18n/ja.ts`/`en.ts`)方式は未導入のため T12.1/T12.3 は別途リファクタが必要。history/restore/serve/config は英語のまま(未 localize)。 + - [ ] T12.1 メッセージ辞書(`src/i18n/ja.ts`, `src/i18n/en.ts`、フラットキー構造) - 検証: ja/en 両方が同じキー集合を持つ単体テスト -- [ ] T12.2 言語解決ロジック(環境変数 > 設定ファイル > 既定 `ja`) - - 検証: 優先度のテスト + - 注: 現状は集中辞書ではなくコマンド単位の `MESSAGES` 定数で実装(要件の辞書方式は未着手) +- [x] T12.2 言語解決ロジック(環境変数 > 設定ファイル > 既定 `ja`) + - 検証: 優先度のテスト(`src/config` の `resolveLang()`: `DENNOH_LANG` > config.lang > 既定 `ja`) - [ ] T12.3 CLI 全コマンドが辞書経由でメッセージ出力 - 検証: ハードコード文字列が無いことを grep で検出するテスト + - 注: 新規 7 コマンド + main は日英対応済みだが、集中辞書経由ではなく、また history/restore/serve/config は未 localize - [ ] T12.4 MCP ツール description が辞書経由 - 検証: T10.11 と同じ diff --git a/src/cli/commands/add.ts b/src/cli/commands/add.ts new file mode 100644 index 0000000..baf1d49 --- /dev/null +++ b/src/cli/commands/add.ts @@ -0,0 +1,85 @@ +import type { Database } from "bun:sqlite"; + +import { readStdin } from "@/cli/stdin"; +import { + type CliIO, + EXIT_INTERNAL_ERROR, + EXIT_SUCCESS, + EXIT_USER_ERROR, + readError, +} from "@/cli/types"; +import { type Lang, readConfig, resolveLang } from "@/config"; +import { saveMemory } from "@/core/memory"; +import { ContentValidationError } from "@/core/validate"; +import { closeDatabase, openDatabase, runMigrations } from "@/db"; + +// Bilingual strings this command emits directly. Errors thrown by the core +// (config/validation/git) carry their own message and are rendered via +// readError; only the command-level help text is localized here, mirroring the +// status.ts pattern. +const MESSAGES: Record = { + ja: { usage: '使い方: dennoh add "<本文>"\n' }, + en: { usage: 'Usage: dennoh add ""\n' }, +}; + +// `dennoh add ""` — create a new note from its sole argument, or from +// stdin when no argument is given and the input is piped. This is a thin +// wrapper over `saveMemory`; all the file → DB → git work lives in core. +export async function addCommand(args: string[], io: CliIO): Promise { + const messages = MESSAGES[resolveLang()]; + + // Prefer the positional argument. Only fall back to stdin when content was + // not passed AND stdin is not an interactive terminal (i.e. piped or + // redirected). `isTTY` is `true` only for a real TTY and `undefined` for a + // pipe — never `false` — so the gate is `!== true`, not `=== false`. Reading + // stdin on a TTY would block forever waiting for an EOF the user has no + // reason to send, so that case is a usage error instead. + let content: string; + const arg = args[0]; + if (arg !== undefined) { + content = arg; + } else if (process.stdin.isTTY !== true) { + content = await readStdin(); + } else { + io.stderr(messages.usage); + return EXIT_USER_ERROR; + } + + let vaultPath: string; + try { + vaultPath = readConfig().vaultPath; + } catch (e) { + // Missing/invalid config ("run init first") is user-actionable, not an + // internal malfunction. + io.stderr(`${readError(e)}\n`); + return EXIT_USER_ERROR; + } + + // openDatabase creates the .dennoh dir and opens SQLite, closing itself on + // internal failure; handle that before the try/finally so closeDatabase + // never runs on an unopened handle. A failed open is an environmental + // problem, so it exits with the internal-error code. + let db: Database; + try { + db = openDatabase(vaultPath); + } catch (e) { + io.stderr(`${readError(e)}\n`); + return EXIT_INTERNAL_ERROR; + } + + try { + // runMigrations is idempotent — it covers the brand-new-vault case where + // `add` is the first command to touch the database. + runMigrations(db); + const id = await saveMemory(db, vaultPath, content); + io.stdout(`${id}\n`); + return EXIT_SUCCESS; + } catch (e) { + io.stderr(`${readError(e)}\n`); + // A rejected content (empty/oversize/binary) is the caller's mistake; + // anything else escaping here is unexpected and counts as internal. + return e instanceof ContentValidationError ? EXIT_USER_ERROR : EXIT_INTERNAL_ERROR; + } finally { + closeDatabase(db); + } +} diff --git a/src/cli/commands/delete.ts b/src/cli/commands/delete.ts new file mode 100644 index 0000000..711ca90 --- /dev/null +++ b/src/cli/commands/delete.ts @@ -0,0 +1,85 @@ +import type { Database } from "bun:sqlite"; + +import { + type CliIO, + EXIT_INTERNAL_ERROR, + EXIT_SUCCESS, + EXIT_USER_ERROR, + isNotFoundError, + readError, +} from "@/cli/types"; +import { type Lang, readConfig, resolveLang } from "@/config"; +import { deleteMemory } from "@/core/memory"; +import { closeDatabase, getNoteById, openDatabase, runMigrations } from "@/db"; + +const MESSAGES: Record< + Lang, + { usage: string; notFound: (id: string) => string; success: (id: string) => string } +> = { + ja: { + usage: "使い方: dennoh delete \n", + notFound: (id) => `メモが見つからないか、既に削除されています (id=${id})\n`, + success: (id) => `削除しました ${id}\n`, + }, + en: { + usage: "Usage: dennoh delete \n", + notFound: (id) => `note not found or already deleted (id=${id})\n`, + success: (id) => `deleted ${id}\n`, + }, +}; + +// `dennoh delete ` — soft-delete a note (remove the file, stamp deleted_at, +// record a git commit). A thin wrapper over `deleteMemory`. +export async function deleteCommand(args: string[], io: CliIO): Promise { + const messages = MESSAGES[resolveLang()]; + + const id = args[0]; + if (!id) { + io.stderr(messages.usage); + return EXIT_USER_ERROR; + } + + let vaultPath: string; + try { + vaultPath = readConfig().vaultPath; + } catch (e) { + io.stderr(`${readError(e)}\n`); + return EXIT_USER_ERROR; + } + + let db: Database; + try { + db = openDatabase(vaultPath); + } catch (e) { + io.stderr(`${readError(e)}\n`); + return EXIT_INTERNAL_ERROR; + } + + try { + runMigrations(db); + + // Pre-check existence so an unknown or already-deleted id is a user error, + // not a generic throw out of deleteMemory that would read as internal. + if (getNoteById(db, id) === null) { + io.stderr(messages.notFound(id)); + return EXIT_USER_ERROR; + } + + await deleteMemory(db, vaultPath, id); + io.stdout(messages.success(id)); + return EXIT_SUCCESS; + } catch (e) { + // The pre-check covers the common missing-id case; this guards the TOCTOU + // race where the note is deleted between that check and deleteMemory. Only + // a genuine not-found maps to a user error — a mid-delete failure (e.g. a + // git error after the soft delete) does not match and stays internal. + if (isNotFoundError(e)) { + io.stderr(messages.notFound(id)); + return EXIT_USER_ERROR; + } + io.stderr(`${readError(e)}\n`); + return EXIT_INTERNAL_ERROR; + } finally { + closeDatabase(db); + } +} diff --git a/src/cli/commands/get.ts b/src/cli/commands/get.ts new file mode 100644 index 0000000..ac7fa64 --- /dev/null +++ b/src/cli/commands/get.ts @@ -0,0 +1,95 @@ +import type { Database } from "bun:sqlite"; + +import { takeBooleanFlag } from "@/cli/flags"; +import { + type CliIO, + EXIT_INTERNAL_ERROR, + EXIT_SUCCESS, + EXIT_USER_ERROR, + readError, +} from "@/cli/types"; +import { type Lang, readConfig, resolveLang } from "@/config"; +import { getNote } from "@/core/memory"; +import { closeDatabase, openDatabase, runMigrations } from "@/db"; + +// Only the help and not-found prose is localized. The frontmatter field labels +// in the human-readable view (`id:`, `created:`, …) are language-neutral data +// keys — like a YAML dump — so they stay constant in both languages. +const MESSAGES: Record string }> = { + ja: { + usage: "使い方: dennoh get [--json]\n", + notFound: (id) => `メモが見つかりません (id=${id})\n`, + }, + en: { + usage: "Usage: dennoh get [--json]\n", + notFound: (id) => `note not found (id=${id})\n`, + }, +}; + +// `dennoh get [--json]` — print a single note. The default rendering is a +// human-readable header (id + frontmatter fields) followed by the body; with +// `--json` the whole NoteRead object is emitted verbatim for tooling. A thin +// wrapper over `getNote`, which resolves the on-disk path through the DB. +export async function getCommand(args: string[], io: CliIO): Promise { + const messages = MESSAGES[resolveLang()]; + + const { present: json, rest } = takeBooleanFlag(args, "--json"); + const id = rest[0]; + if (!id) { + io.stderr(messages.usage); + return EXIT_USER_ERROR; + } + + let vaultPath: string; + try { + vaultPath = readConfig().vaultPath; + } catch (e) { + io.stderr(`${readError(e)}\n`); + return EXIT_USER_ERROR; + } + + let db: Database; + try { + db = openDatabase(vaultPath); + } catch (e) { + io.stderr(`${readError(e)}\n`); + return EXIT_INTERNAL_ERROR; + } + + try { + runMigrations(db); + // getNote returns null for an unknown or soft-deleted id (the DB lookup + // filters `deleted_at IS NULL`); both collapse into one "not found". + const result = await getNote(db, vaultPath, id); + if (result === null) { + io.stderr(messages.notFound(id)); + return EXIT_USER_ERROR; + } + + if (json) { + io.stdout(`${JSON.stringify(result, null, 2)}\n`); + return EXIT_SUCCESS; + } + + // Human-readable form: id + frontmatter fields, a blank line, then the + // body exactly as stored. projects/tags are joined with ", " so an empty + // list renders as an empty value rather than "[]". + const { frontmatter, body } = result; + io.stdout(`id: ${result.id}\n`); + io.stdout(`created: ${frontmatter.createdAt}\n`); + io.stdout(`updated: ${frontmatter.updatedAt}\n`); + io.stdout(`source: ${frontmatter.source}\n`); + io.stdout(`projects: ${frontmatter.projects.join(", ")}\n`); + io.stdout(`tags: ${frontmatter.tags.join(", ")}\n`); + io.stdout(`\n${body}`); + if (!body.endsWith("\n")) { + io.stdout("\n"); + } + return EXIT_SUCCESS; + } catch (e) { + io.stderr(`${readError(e)}\n`); + return EXIT_INTERNAL_ERROR; + } finally { + closeDatabase(db); + } +} diff --git a/src/cli/commands/recent.ts b/src/cli/commands/recent.ts new file mode 100644 index 0000000..e2341ef --- /dev/null +++ b/src/cli/commands/recent.ts @@ -0,0 +1,119 @@ +import type { Database } from "bun:sqlite"; + +import { takeBooleanFlag, takeOption } from "@/cli/flags"; +import { + type CliIO, + EXIT_INTERNAL_ERROR, + EXIT_SUCCESS, + EXIT_USER_ERROR, + readError, +} from "@/cli/types"; +import { type Lang, readConfig, resolveLang } from "@/config"; +import { listRecent } from "@/core/memory"; +import { closeDatabase, openDatabase, runMigrations } from "@/db"; + +const DEFAULT_LIMIT = 10; + +const MESSAGES: Record< + Lang, + { + unexpectedArgs: (args: string) => string; + badLimit: (got: string) => string; + missingValue: (name: string) => string; + } +> = { + ja: { + unexpectedArgs: (a) => `'recent' に予期しない引数があります: ${a}\n`, + badLimit: (got) => `--limit は正の整数で指定してください (指定値: ${got})\n`, + missingValue: (name) => `${name} には値が必要です\n`, + }, + en: { + unexpectedArgs: (a) => `Unexpected arguments for 'recent': ${a}\n`, + badLimit: (got) => `--limit must be a positive integer (got ${got})\n`, + missingValue: (name) => `${name} requires a value\n`, + }, +}; + +// `dennoh recent [--limit N] [--json]` — list the most-recently-updated notes. +// Default output is one line per note (` `); +// `--json` emits the rows with their JSON columns deserialized. A thin wrapper +// over `listRecent`, which returns raw NoteRow metadata (no body reads). +export async function recentCommand(args: string[], io: CliIO): Promise { + const messages = MESSAGES[resolveLang()]; + + const { present: json, rest: r1 } = takeBooleanFlag(args, "--json"); + const { value: limitArg, present: limitGiven, rest } = takeOption(r1, "--limit"); + + // `--limit` with no value (e.g. `recent --limit`) is a usage error, not a + // silent fall-through to the default. + if (limitGiven && limitArg === undefined) { + io.stderr(messages.missingValue("--limit")); + return EXIT_USER_ERROR; + } + + if (rest.length > 0) { + io.stderr(messages.unexpectedArgs(rest.join(" "))); + return EXIT_USER_ERROR; + } + + let limit = DEFAULT_LIMIT; + if (limitArg !== undefined) { + const parsed = Number(limitArg); + if (!Number.isInteger(parsed) || parsed <= 0) { + io.stderr(messages.badLimit(limitArg)); + return EXIT_USER_ERROR; + } + limit = parsed; + } + + let vaultPath: string; + try { + vaultPath = readConfig().vaultPath; + } catch (e) { + io.stderr(`${readError(e)}\n`); + return EXIT_USER_ERROR; + } + + let db: Database; + try { + db = openDatabase(vaultPath); + } catch (e) { + io.stderr(`${readError(e)}\n`); + return EXIT_INTERNAL_ERROR; + } + + try { + runMigrations(db); + const rows = listRecent(db, limit); + + if (json) { + // projects_json / tags_json are stored as serialized arrays; deserialize + // them so JSON consumers get real arrays instead of escaped strings. The + // raw *_json columns are dropped in favor of the parsed `projects` / + // `tags` fields. + const out = rows.map((row) => { + const { projects_json, tags_json, ...fields } = row; + return { + ...fields, + projects: JSON.parse(projects_json) as string[], + tags: JSON.parse(tags_json) as string[], + }; + }); + io.stdout(`${JSON.stringify(out, null, 2)}\n`); + return EXIT_SUCCESS; + } + + // One line per note. Prefer the title; fall back to the path when a note + // has no title so the middle column is never blank. + for (const row of rows) { + const label = row.title ?? row.path; + io.stdout(`${row.id} ${label} ${row.updated_at}\n`); + } + return EXIT_SUCCESS; + } catch (e) { + io.stderr(`${readError(e)}\n`); + return EXIT_INTERNAL_ERROR; + } finally { + closeDatabase(db); + } +} diff --git a/src/cli/commands/reindex.ts b/src/cli/commands/reindex.ts new file mode 100644 index 0000000..a12cec9 --- /dev/null +++ b/src/cli/commands/reindex.ts @@ -0,0 +1,92 @@ +import type { Database } from "bun:sqlite"; + +import { + type CliIO, + EXIT_INTERNAL_ERROR, + EXIT_SUCCESS, + EXIT_USER_ERROR, + readError, +} from "@/cli/types"; +import { type Lang, readConfig, resolveLang } from "@/config"; +import { closeDatabase, openDatabase, reindexAll, runMigrations } from "@/db"; + +const MESSAGES: Record< + Lang, + { + unexpectedArgs: (args: string) => string; + summary: (processed: number, errors: number, translationErrors: number) => string; + error: (path: string, message: string) => string; + translationError: (path: string, message: string) => string; + } +> = { + ja: { + unexpectedArgs: (a) => `'reindex' に予期しない引数があります: ${a}\n`, + summary: (p, e, t) => + `${p} 件のメモを再インデックスしました。エラー ${e} 件、翻訳エラー ${t} 件\n`, + error: (path, message) => ` エラー: ${path}: ${message}\n`, + translationError: (path, message) => ` 翻訳エラー: ${path}: ${message}\n`, + }, + en: { + unexpectedArgs: (a) => `Unexpected arguments for 'reindex': ${a}\n`, + summary: (p, e, t) => `reindexed ${p} note(s); ${e} error(s), ${t} translation error(s)\n`, + error: (path, message) => ` error: ${path}: ${message}\n`, + translationError: (path, message) => ` translation error: ${path}: ${message}\n`, + }, +}; + +// `dennoh reindex` — rebuild the SQLite index from the on-disk notes. The core +// `reindexAll` clears the `notes` table and re-walks the vault, recording +// per-file failures rather than aborting. This wrapper prints a summary of the +// counts plus the details of any errors encountered. +export async function reindexCommand(args: string[], io: CliIO): Promise { + const messages = MESSAGES[resolveLang()]; + + if (args.length > 0) { + io.stderr(messages.unexpectedArgs(args.join(" "))); + return EXIT_USER_ERROR; + } + + let vaultPath: string; + try { + vaultPath = readConfig().vaultPath; + } catch (e) { + io.stderr(`${readError(e)}\n`); + return EXIT_USER_ERROR; + } + + let db: Database; + try { + db = openDatabase(vaultPath); + } catch (e) { + io.stderr(`${readError(e)}\n`); + return EXIT_INTERNAL_ERROR; + } + + try { + // runMigrations before reindexAll: the latter immediately `DELETE`s from + // `notes`, which requires the schema to exist. This makes `reindex` safe to + // run as the first command against a brand-new vault. + runMigrations(db); + const result = await reindexAll(db, vaultPath); + + io.stdout( + messages.summary(result.processed, result.errors.length, result.translationErrors.length) + ); + + // Surface the specifics so a failed file is actionable. Read errors and + // translation errors are reported in separate sections because they mean + // different things (could not index vs. indexed without a translation). + for (const { path, message } of result.errors) { + io.stdout(messages.error(path, message)); + } + for (const { path, message } of result.translationErrors) { + io.stdout(messages.translationError(path, message)); + } + return EXIT_SUCCESS; + } catch (e) { + io.stderr(`${readError(e)}\n`); + return EXIT_INTERNAL_ERROR; + } finally { + closeDatabase(db); + } +} diff --git a/src/cli/commands/search.ts b/src/cli/commands/search.ts new file mode 100644 index 0000000..05a094d --- /dev/null +++ b/src/cli/commands/search.ts @@ -0,0 +1,130 @@ +import type { Database } from "bun:sqlite"; + +import { takeBooleanFlag, takeOption } from "@/cli/flags"; +import { + type CliIO, + EXIT_INTERNAL_ERROR, + EXIT_SUCCESS, + EXIT_USER_ERROR, + readError, +} from "@/cli/types"; +import { type Lang, readConfig, resolveLang } from "@/config"; +import { searchMemory } from "@/core/memory"; +import type { SearchFilters } from "@/db"; +import { closeDatabase, openDatabase, runMigrations } from "@/db"; + +const DEFAULT_LIMIT = 20; + +const MESSAGES: Record< + Lang, + { usage: string; badLimit: (got: string) => string; missingValue: (name: string) => string } +> = { + ja: { + usage: '使い方: dennoh search "<検索語>" [--project X] [--tag Y] [--limit N] [--json]\n', + badLimit: (got) => `--limit は正の整数で指定してください (指定値: ${got})\n`, + missingValue: (name) => `${name} には値が必要です\n`, + }, + en: { + usage: 'Usage: dennoh search "" [--project X] [--tag Y] [--limit N] [--json]\n', + badLimit: (got) => `--limit must be a positive integer (got ${got})\n`, + missingValue: (name) => `${name} requires a value\n`, + }, +}; + +// `dennoh search "" [--project X] [--tag Y] [--limit N] [--json]` — +// full-text search over the index. Default output is one line per hit +// (` `); `--json` emits the raw result array. +// A thin wrapper over `searchMemory`. +export async function searchCommand(args: string[], io: CliIO): Promise { + const messages = MESSAGES[resolveLang()]; + + const { present: json, rest: r1 } = takeBooleanFlag(args, "--json"); + const { value: project, present: projectGiven, rest: r2 } = takeOption(r1, "--project"); + const { value: tag, present: tagGiven, rest: r3 } = takeOption(r2, "--tag"); + const { value: limitArg, present: limitGiven, rest } = takeOption(r3, "--limit"); + + // A flag that appeared without a value (e.g. `search foo --limit`) is a usage + // error, not a silent fall-through to defaults. `present && value undefined` + // is exactly that case. + for (const [name, given, value] of [ + ["--project", projectGiven, project], + ["--tag", tagGiven, tag], + ["--limit", limitGiven, limitArg], + ] as const) { + if (given && value === undefined) { + io.stderr(messages.missingValue(name)); + return EXIT_USER_ERROR; + } + } + + const query = rest[0]; + if (!query) { + io.stderr(messages.usage); + return EXIT_USER_ERROR; + } + + // Parse --limit up-front so a malformed value is a clean usage error rather + // than a confusing downstream slice. Absent --limit falls back to the + // product default of 20. + let limit = DEFAULT_LIMIT; + if (limitArg !== undefined) { + const parsed = Number(limitArg); + if (!Number.isInteger(parsed) || parsed <= 0) { + io.stderr(messages.badLimit(limitArg)); + return EXIT_USER_ERROR; + } + limit = parsed; + } + + // Only set filter keys that were actually provided so `searchMemory` joins + // the present ones with AND and ignores the rest. + const filters: SearchFilters = {}; + if (project !== undefined) { + filters.project = project; + } + if (tag !== undefined) { + filters.tag = tag; + } + + let vaultPath: string; + try { + vaultPath = readConfig().vaultPath; + } catch (e) { + io.stderr(`${readError(e)}\n`); + return EXIT_USER_ERROR; + } + + let db: Database; + try { + db = openDatabase(vaultPath); + } catch (e) { + io.stderr(`${readError(e)}\n`); + return EXIT_INTERNAL_ERROR; + } + + try { + runMigrations(db); + const results = searchMemory(db, query, filters, limit); + + if (json) { + io.stdout(`${JSON.stringify(results, null, 2)}\n`); + return EXIT_SUCCESS; + } + + // One line per hit. Prefer the title; fall back to the FTS snippet when a + // note has no title so the line is never blank in the middle column. The + // snippet can contain embedded newlines (it is a slice of the body), so + // collapse any whitespace run to a single space to preserve the + // one-line-per-hit contract. + for (const r of results) { + const label = (r.title ?? r.snippet).replace(/\s+/g, " ").trim(); + io.stdout(`${r.id} ${label} ${r.updatedAt}\n`); + } + return EXIT_SUCCESS; + } catch (e) { + io.stderr(`${readError(e)}\n`); + return EXIT_INTERNAL_ERROR; + } finally { + closeDatabase(db); + } +} diff --git a/src/cli/commands/update.ts b/src/cli/commands/update.ts new file mode 100644 index 0000000..b0a547d --- /dev/null +++ b/src/cli/commands/update.ts @@ -0,0 +1,105 @@ +import type { Database } from "bun:sqlite"; + +import { readStdin } from "@/cli/stdin"; +import { + type CliIO, + EXIT_INTERNAL_ERROR, + EXIT_SUCCESS, + EXIT_USER_ERROR, + isNotFoundError, + readError, +} from "@/cli/types"; +import { type Lang, readConfig, resolveLang } from "@/config"; +import { updateMemory } from "@/core/memory"; +import { ContentValidationError } from "@/core/validate"; +import { closeDatabase, getNoteById, openDatabase, runMigrations } from "@/db"; + +const MESSAGES: Record< + Lang, + { usage: string; notFound: (id: string) => string; success: (id: string) => string } +> = { + ja: { + usage: '使い方: dennoh update "<本文>"\n', + notFound: (id) => `メモが見つからないか、既に削除されています (id=${id})\n`, + success: (id) => `更新しました ${id}\n`, + }, + en: { + usage: 'Usage: dennoh update ""\n', + notFound: (id) => `note not found or already deleted (id=${id})\n`, + success: (id) => `updated ${id}\n`, + }, +}; + +// `dennoh update ""` — replace a note's content. Text may be passed +// as the second argument or piped on stdin. A thin wrapper over `updateMemory`, +// which performs the file → DB → git update. +export async function updateCommand(args: string[], io: CliIO): Promise { + const messages = MESSAGES[resolveLang()]; + + const id = args[0]; + if (!id) { + io.stderr(messages.usage); + return EXIT_USER_ERROR; + } + + // Second positional is the new text; fall back to stdin only when it is + // absent AND stdin is not an interactive terminal (piped or redirected), + // mirroring `add`. `isTTY` is `true` only for a real TTY and `undefined` for + // a pipe — never `false` — so the gate is `!== true`. On a TTY with no text + // argument, reading would block forever, so that is a usage error. + let content: string; + const textArg = args[1]; + if (textArg !== undefined) { + content = textArg; + } else if (process.stdin.isTTY !== true) { + content = await readStdin(); + } else { + io.stderr(messages.usage); + return EXIT_USER_ERROR; + } + + let vaultPath: string; + try { + vaultPath = readConfig().vaultPath; + } catch (e) { + io.stderr(`${readError(e)}\n`); + return EXIT_USER_ERROR; + } + + let db: Database; + try { + db = openDatabase(vaultPath); + } catch (e) { + io.stderr(`${readError(e)}\n`); + return EXIT_INTERNAL_ERROR; + } + + try { + runMigrations(db); + + // Pre-check existence here (as history/restore do) so an unknown or + // already-deleted id is reported as a user error rather than escaping the + // mutation as a generic throw that would read as internal. + if (getNoteById(db, id) === null) { + io.stderr(messages.notFound(id)); + return EXIT_USER_ERROR; + } + + await updateMemory(db, vaultPath, id, content); + io.stdout(messages.success(id)); + return EXIT_SUCCESS; + } catch (e) { + // The pre-check above covers the common missing-id case; this guards the + // TOCTOU race where the note is deleted between that check and the write. + // A not-found here is still a user error, reported with the same localized + // message rather than the raw core text. + if (isNotFoundError(e)) { + io.stderr(messages.notFound(id)); + return EXIT_USER_ERROR; + } + io.stderr(`${readError(e)}\n`); + return e instanceof ContentValidationError ? EXIT_USER_ERROR : EXIT_INTERNAL_ERROR; + } finally { + closeDatabase(db); + } +} diff --git a/src/cli/flags.ts b/src/cli/flags.ts new file mode 100644 index 0000000..e7426fc --- /dev/null +++ b/src/cli/flags.ts @@ -0,0 +1,71 @@ +// Minimal flag parsing shared by the read-side CLI commands. These commands +// take a mix of positional arguments and `--flag` / `--option value` tokens; +// rather than pull in a parser dependency, each command pulls the flags it +// knows about off the arg list and treats whatever remains as positionals. +// +// Both `--name value` and `--name=value` spellings are accepted so the +// commands behave the way users expect regardless of which form they type. + +// Remove every occurrence of a boolean flag (e.g. `--json`) from `args`. +// Returns whether it was present at least once and the remaining args. +export function takeBooleanFlag( + args: string[], + name: string +): { present: boolean; rest: string[] } { + const rest: string[] = []; + let present = false; + for (const arg of args) { + if (arg === name) { + present = true; + continue; + } + rest.push(arg); + } + return { present, rest }; +} + +// Remove a valued option (e.g. `--limit 20` or `--limit=20`) from `args`, +// returning the last value seen (undefined if absent), whether the option token +// appeared at all (`present`), and the remaining args. +// +// `present` lets callers distinguish "flag absent" from "flag given without a +// value" so the latter can be reported as a usage error instead of silently +// falling back to a default. +// +// In the space-separated form the next token is taken as the value ONLY when it +// is not itself a flag (does not start with `-`). A `-`-prefixed next token (or +// end of args) means the value was omitted: it is left in place so the +// following option / positional still sees it, and `value` stays undefined. A +// negative numeric value must therefore use the `--name=-3` form. +export function takeOption( + args: string[], + name: string +): { value: string | undefined; present: boolean; rest: string[] } { + const rest: string[] = []; + let value: string | undefined; + let present = false; + const prefix = `${name}=`; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === undefined) { + continue; + } + if (arg === name) { + present = true; + const next = args[i + 1]; + if (next !== undefined && !next.startsWith("-")) { + value = next; + i++; + } + continue; + } + if (arg.startsWith(prefix)) { + // `--name=value`: the value is the remainder after `=` (may be empty). + present = true; + value = arg.slice(prefix.length); + continue; + } + rest.push(arg); + } + return { value, present, rest }; +} diff --git a/src/cli/index.ts b/src/cli/index.ts index f2c2be0..d7b1765 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,7 +1,15 @@ export type { CliIO } from "./types"; +export { EXIT_INTERNAL_ERROR, EXIT_SUCCESS, EXIT_USER_ERROR } from "./types"; +export { addCommand } from "./commands/add"; export { configCommand, configGet, configList, configSet } from "./commands/config"; +export { deleteCommand } from "./commands/delete"; +export { getCommand } from "./commands/get"; export { historyCommand } from "./commands/history"; +export { recentCommand } from "./commands/recent"; +export { reindexCommand } from "./commands/reindex"; export { restoreCommand } from "./commands/restore"; +export { searchCommand } from "./commands/search"; +export { updateCommand } from "./commands/update"; export { serveCommand } from "./commands/serve"; export { statusCommand } from "./commands/status"; export { diff --git a/src/cli/main.ts b/src/cli/main.ts index 93fe94d..55082bc 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -1,23 +1,69 @@ #!/usr/bin/env bun +import { type Lang, resolveLang } from "@/config"; import pkg from "../../package.json" with { type: "json" }; import { type CliIO, + EXIT_SUCCESS, + EXIT_USER_ERROR, + addCommand, configCommand, defaultPromptVaultPath, + deleteCommand, + getCommand, historyCommand, initCommand, + recentCommand, + reindexCommand, restoreCommand, + searchCommand, serveCommand, statusCommand, + updateCommand, } from "./index"; -function usage(): string { - return [ +// Bilingual top-level help. Command tokens (left column) are language-neutral; +// only the prose descriptions and the section headers differ per language so +// the two columns stay aligned in both. Keep this list in sync with the +// dispatch table below. +const USAGE: Record = { + ja: [ + "使い方: dennoh [args]", + "", + "コマンド:", + " init Vault を初期化し dennoh の設定を書き込む", + ' add "" 新しいメモを作成(標準入力からも可)', + ' update "" メモの内容を置き換え(標準入力からも可)', + " delete メモを削除", + " get [--json] 1 件のメモを表示", + ' search "" [...] メモを検索(--project --tag --limit --json)', + " recent [--limit N] [--json] 最近更新されたメモを一覧表示", + " reindex ディスク上のメモからインデックスを再構築", + " config get 設定値を標準出力に表示", + " config set 設定値を更新", + " config list すべての設定値を一覧表示", + " history メモのコミット履歴を表示(新しい順)", + " restore メモを過去のコミットに復元", + " status Vault の状態を報告(例: クラウド同期の競合)", + " serve stdio MCP サーバーを起動", + "", + "フラグ:", + " --help, -h このヘルプを表示", + " --version, -v バージョンを表示", + "", + ].join("\n"), + en: [ "Usage: dennoh [args]", "", "Commands:", " init Initialize a vault and write dennoh config", + ' add "" Create a new note (or read content from stdin)', + ' update "" Replace a note\'s content (or read from stdin)', + " delete Delete a note", + " get [--json] Print a single note", + ' search "" [...] Search notes (--project --tag --limit --json)', + " recent [--limit N] [--json] List the most-recently-updated notes", + " reindex Rebuild the index from on-disk notes", " config get Print a config value to stdout", " config set Update a config value", " config list List all config values", @@ -30,27 +76,60 @@ function usage(): string { " --help, -h Show this help", " --version, -v Show version", "", - ].join("\n"); + ].join("\n"), +}; + +const UNKNOWN_COMMAND: Record string> = { + ja: (cmd) => `不明なコマンド: ${cmd}\n\n`, + en: (cmd) => `Unknown command: ${cmd}\n\n`, +}; + +function usage(lang: Lang): string { + return USAGE[lang]; } export async function main(argv: string[], io: CliIO): Promise { const args = argv.slice(2); + const lang = resolveLang(); if (args.length === 0 || args[0] === "--help" || args[0] === "-h") { - io.stdout(usage()); - return 0; + io.stdout(usage(lang)); + return EXIT_SUCCESS; } if (args[0] === "--version" || args[0] === "-v") { io.stdout(`${pkg.version}\n`); - return 0; + return EXIT_SUCCESS; } - const cmd = args[0]; + // args is non-empty here (the empty/--help case returned above), so args[0] + // is always present; the ?? keeps TypeScript's index-access narrowing happy. + const cmd = args[0] ?? ""; const rest = args.slice(1); if (cmd === "init") { return await initCommand({ io, promptVaultPath: defaultPromptVaultPath }); } + if (cmd === "add") { + return await addCommand(rest, io); + } + if (cmd === "update") { + return await updateCommand(rest, io); + } + if (cmd === "delete") { + return await deleteCommand(rest, io); + } + if (cmd === "get") { + return await getCommand(rest, io); + } + if (cmd === "search") { + return await searchCommand(rest, io); + } + if (cmd === "recent") { + return await recentCommand(rest, io); + } + if (cmd === "reindex") { + return await reindexCommand(rest, io); + } if (cmd === "config") { return configCommand(rest, io); } @@ -67,9 +146,9 @@ export async function main(argv: string[], io: CliIO): Promise { return await serveCommand(rest, io); } - io.stderr(`Unknown command: ${cmd}\n\n`); - io.stderr(usage()); - return 1; + io.stderr(UNKNOWN_COMMAND[lang](cmd)); + io.stderr(usage(lang)); + return EXIT_USER_ERROR; } if (import.meta.main) { diff --git a/src/cli/stdin.ts b/src/cli/stdin.ts new file mode 100644 index 0000000..24598de --- /dev/null +++ b/src/cli/stdin.ts @@ -0,0 +1,16 @@ +// Read the whole of stdin to a string for the data-entry commands +// (`add` / `update`) when content is piped instead of passed as an argument. +// +// Callers gate this behind `process.stdin.isTTY !== true`: `isTTY` is `true` +// only for an interactive terminal and `undefined` for a pipe (never `false`), +// so `!== true` is what detects piped/redirected input. When stdin is a +// terminal there is no piped payload, so reading would block forever waiting +// for the user to type EOF. The TTY check is the caller's responsibility, not +// this helper's, so the function stays a plain "drain stdin" primitive. +export async function readStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(chunk as Buffer); + } + return Buffer.concat(chunks).toString("utf8"); +} diff --git a/src/cli/types.ts b/src/cli/types.ts index 49f4e59..9a5b21e 100644 --- a/src/cli/types.ts +++ b/src/cli/types.ts @@ -3,9 +3,33 @@ export type CliIO = { stderr: (s: string) => void; }; +// CLI process exit codes. The three-way split lets scripts distinguish a +// caller mistake from an environmental/internal failure: +// 0 — success +// 1 — user error: bad/missing arguments, unknown id, content validation, +// unknown command. The user can fix the invocation and retry. +// 2 — internal error: the database could not be opened, or an unexpected +// exception escaped. Not the caller's fault; usually needs operator +// attention (permissions, disk, a bug). +export const EXIT_SUCCESS = 0; +export const EXIT_USER_ERROR = 1; +export const EXIT_INTERNAL_ERROR = 2; + // Normalize an unknown caught value into a printable message: an Error instance // surfaces its `.message`, anything else is coerced via String(). Shared by the // CLI commands so every `catch (e)` renders consistent, human-readable stderr. export function readError(e: unknown): string { return e instanceof Error ? e.message : String(e); } + +// The core update/delete helpers throw a plain Error whose message contains +// "not found or already deleted" when an id does not resolve to a live note. +// `update`/`delete` pre-check existence for the common case, but the note can +// be removed in the TOCTOU window between that check and the mutation; this +// predicate lets the catch path still classify that race as a user error +// (exit 1) rather than an internal one (exit 2). It deliberately matches only +// the not-found phrasing, so a mid-mutation failure (e.g. a git error after a +// soft delete) still falls through to the internal-error code. +export function isNotFoundError(e: unknown): boolean { + return e instanceof Error && /not found or already deleted/.test(e.message); +} diff --git a/tests/cli/add.test.ts b/tests/cli/add.test.ts new file mode 100644 index 0000000..46798d7 --- /dev/null +++ b/tests/cli/add.test.ts @@ -0,0 +1,146 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { Readable } from "node:stream"; + +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; +import git from "isomorphic-git"; + +import { type CliIO, EXIT_INTERNAL_ERROR, addCommand } from "@/cli"; +import { writeConfig } from "@/config"; +import { closeDatabase, getNoteById, openDatabase } from "@/db"; + +// Temporarily swap process.stdin for a fake while `fn` runs, then restore the +// original descriptor. bun:test's spyOn type signature doesn't accept the +// accessor (get) form, so we override the property directly instead. +async function withStdin(fake: unknown, fn: () => Promise): Promise { + const original = Object.getOwnPropertyDescriptor(process, "stdin"); + Object.defineProperty(process, "stdin", { configurable: true, get: () => fake }); + try { + await fn(); + } finally { + if (original) Object.defineProperty(process, "stdin", original); + } +} + +function makeIO(): { io: CliIO; stdout: () => string; stderr: () => string } { + const stdoutBuf: string[] = []; + const stderrBuf: string[] = []; + return { + io: { + stdout: (s) => { + stdoutBuf.push(s); + }, + stderr: (s) => { + stderrBuf.push(s); + }, + }, + stdout: () => stdoutBuf.join(""), + stderr: () => stderrBuf.join(""), + }; +} + +describe("cli add", () => { + let homeDir: string; + let vaultPath: string; + let homedirSpy: ReturnType>; + // Scope DENNOH_TRANSLATE_DISABLE per-test (disable JA→EN translation so + // saveMemory doesn't load model weights) and restore it afterwards so the + // env mutation does not leak into other suites in the same process. + let prevTranslateDisable: string | undefined; + + beforeEach(async () => { + prevTranslateDisable = process.env.DENNOH_TRANSLATE_DISABLE; + process.env.DENNOH_TRANSLATE_DISABLE = "1"; + + homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "dennoh-add-home-")); + homedirSpy = spyOn(os, "homedir").mockReturnValue(homeDir); + + vaultPath = path.join(homeDir, "vault"); + fs.mkdirSync(vaultPath, { recursive: true }); + await git.init({ fs, dir: vaultPath, defaultBranch: "main" }); + await git.setConfig({ fs, dir: vaultPath, path: "user.name", value: "Test" }); + await git.setConfig({ fs, dir: vaultPath, path: "user.email", value: "test@example.com" }); + + writeConfig({ vaultPath, lang: "ja" }); + }); + + afterEach(() => { + homedirSpy.mockRestore(); + fs.rmSync(homeDir, { recursive: true, force: true }); + prevTranslateDisable === undefined + ? Reflect.deleteProperty(process.env, "DENNOH_TRANSLATE_DISABLE") + : Reflect.set(process.env, "DENNOH_TRANSLATE_DISABLE", prevTranslateDisable); + }); + + it("saves the argument content and prints the new id", async () => { + const { io, stdout, stderr } = makeIO(); + const code = await addCommand(["hello world\n"], io); + expect(code).toBe(0); + expect(stderr()).toBe(""); + + const id = stdout().trim(); + expect(id.length).toBeGreaterThan(0); + + const db = openDatabase(vaultPath); + const row = getNoteById(db, id); + closeDatabase(db); + expect(row?.body).toBe("hello world\n"); + }); + + it("reads content from stdin when no argument is given and stdin is piped", async () => { + // A readable carrying the payload mirrors `echo ... | dennoh add`. A real + // pipe leaves `isTTY` as `undefined` (not `false`), so the fake omits it — + // this is exactly the case the `!== true` gate must catch. + const piped = Readable.from([Buffer.from("from stdin\n")]); + await withStdin(piped, async () => { + const { io, stdout } = makeIO(); + const code = await addCommand([], io); + expect(code).toBe(0); + + const id = stdout().trim(); + const db = openDatabase(vaultPath); + const row = getNoteById(db, id); + closeDatabase(db); + expect(row?.body).toBe("from stdin\n"); + }); + }); + + it("errors with usage when no argument and stdin is a TTY", async () => { + await withStdin({ isTTY: true }, async () => { + const { io, stderr } = makeIO(); + const code = await addCommand([], io); + expect(code).toBe(1); + expect(stderr()).toContain("使い方"); + }); + }); + + it("runs migrations so `add` works on a fresh vault", async () => { + // No openDatabase + runMigrations in beforeEach for this vault path; the + // command itself must initialize the schema. + const { io, stdout } = makeIO(); + const code = await addCommand(["fresh vault\n"], io); + expect(code).toBe(0); + + const id = stdout().trim(); + const db = openDatabase(vaultPath); + const row = getNoteById(db, id); + closeDatabase(db); + expect(row?.body).toBe("fresh vault\n"); + }); + + it("returns EXIT_INTERNAL_ERROR when the database cannot be opened", async () => { + // Point the vault at a path whose parent is a regular file, so + // openDatabase's recursive mkdir of `.dennoh` fails with ENOTDIR. config + // still reads cleanly, so this exercises the DB-open (internal) branch, + // which must exit with code 2 rather than the user-error code 1. + const filePath = path.join(homeDir, "not-a-dir"); + fs.writeFileSync(filePath, "x"); + writeConfig({ vaultPath: path.join(filePath, "vault"), lang: "ja" }); + + const { io, stderr } = makeIO(); + const code = await addCommand(["content"], io); + expect(code).toBe(EXIT_INTERNAL_ERROR); + expect(stderr()).not.toBe(""); + }); +}); diff --git a/tests/cli/delete.test.ts b/tests/cli/delete.test.ts new file mode 100644 index 0000000..5e61598 --- /dev/null +++ b/tests/cli/delete.test.ts @@ -0,0 +1,109 @@ +// Disable JA→EN translation so saveMemory doesn't load model weights; +// see restore.test.ts for the same guard. +process.env.DENNOH_TRANSLATE_DISABLE = "1"; + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; +import git from "isomorphic-git"; + +import { type CliIO, deleteCommand } from "@/cli"; +import { writeConfig } from "@/config"; +import { saveMemory } from "@/core/memory"; +import { closeDatabase, getNoteById, openDatabase, runMigrations } from "@/db"; + +function makeIO(): { io: CliIO; stdout: () => string; stderr: () => string } { + const stdoutBuf: string[] = []; + const stderrBuf: string[] = []; + return { + io: { + stdout: (s) => { + stdoutBuf.push(s); + }, + stderr: (s) => { + stderrBuf.push(s); + }, + }, + stdout: () => stdoutBuf.join(""), + stderr: () => stderrBuf.join(""), + }; +} + +describe("cli delete", () => { + let homeDir: string; + let vaultPath: string; + let homedirSpy: ReturnType>; + + beforeEach(async () => { + homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "dennoh-delete-home-")); + homedirSpy = spyOn(os, "homedir").mockReturnValue(homeDir); + + vaultPath = path.join(homeDir, "vault"); + fs.mkdirSync(vaultPath, { recursive: true }); + await git.init({ fs, dir: vaultPath, defaultBranch: "main" }); + await git.setConfig({ fs, dir: vaultPath, path: "user.name", value: "Test" }); + await git.setConfig({ fs, dir: vaultPath, path: "user.email", value: "test@example.com" }); + + writeConfig({ vaultPath, lang: "ja" }); + + const db = openDatabase(vaultPath); + runMigrations(db); + closeDatabase(db); + }); + + afterEach(() => { + homedirSpy.mockRestore(); + fs.rmSync(homeDir, { recursive: true, force: true }); + }); + + it("deletes the note, removes the file, and prints a confirmation", async () => { + const db = openDatabase(vaultPath); + const id = await saveMemory(db, vaultPath, "doomed\n"); + const filePath = getNoteById(db, id)?.path; + if (filePath === undefined) throw new Error("note path missing after save"); + closeDatabase(db); + + const { io, stdout, stderr } = makeIO(); + const code = await deleteCommand([id], io); + expect(code).toBe(0); + expect(stderr()).toBe(""); + expect(stdout()).toContain(id); + + // File is gone and the row no longer resolves through the live filter. + expect(fs.existsSync(filePath)).toBe(false); + const db2 = openDatabase(vaultPath); + const row = getNoteById(db2, id); + closeDatabase(db2); + expect(row).toBeNull(); + }); + + it("errors for an unknown id", async () => { + const { io, stderr } = makeIO(); + const code = await deleteCommand(["018f0c8e-7c4f-7d3a-8b2e-000000000000"], io); + expect(code).toBe(1); + expect(stderr()).toContain("見つか"); + }); + + it("errors with usage when the id is missing", async () => { + const { io, stderr } = makeIO(); + const code = await deleteCommand([], io); + expect(code).toBe(1); + expect(stderr()).toContain("使い方"); + }); + + it("errors when deleting an already-deleted id", async () => { + const db = openDatabase(vaultPath); + const id = await saveMemory(db, vaultPath, "doomed twice\n"); + closeDatabase(db); + + const { io: io1 } = makeIO(); + expect(await deleteCommand([id], io1)).toBe(0); + + const { io: io2, stderr } = makeIO(); + const code = await deleteCommand([id], io2); + expect(code).toBe(1); + expect(stderr()).toContain("見つか"); + }); +}); diff --git a/tests/cli/get.test.ts b/tests/cli/get.test.ts new file mode 100644 index 0000000..5f17e67 --- /dev/null +++ b/tests/cli/get.test.ts @@ -0,0 +1,124 @@ +// Disable JA→EN translation so saveMemory doesn't load model weights; +// see restore.test.ts for the same guard. +process.env.DENNOH_TRANSLATE_DISABLE = "1"; + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; +import git from "isomorphic-git"; + +import { type CliIO, getCommand } from "@/cli"; +import { writeConfig } from "@/config"; +import { saveMemory } from "@/core/memory"; +import { closeDatabase, openDatabase, runMigrations } from "@/db"; + +function makeIO(): { io: CliIO; stdout: () => string; stderr: () => string } { + const stdoutBuf: string[] = []; + const stderrBuf: string[] = []; + return { + io: { + stdout: (s) => { + stdoutBuf.push(s); + }, + stderr: (s) => { + stderrBuf.push(s); + }, + }, + stdout: () => stdoutBuf.join(""), + stderr: () => stderrBuf.join(""), + }; +} + +describe("cli get", () => { + let homeDir: string; + let vaultPath: string; + let homedirSpy: ReturnType>; + + beforeEach(async () => { + homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "dennoh-get-home-")); + homedirSpy = spyOn(os, "homedir").mockReturnValue(homeDir); + + vaultPath = path.join(homeDir, "vault"); + fs.mkdirSync(vaultPath, { recursive: true }); + await git.init({ fs, dir: vaultPath, defaultBranch: "main" }); + await git.setConfig({ fs, dir: vaultPath, path: "user.name", value: "Test" }); + await git.setConfig({ fs, dir: vaultPath, path: "user.email", value: "test@example.com" }); + + writeConfig({ vaultPath, lang: "ja" }); + + const db = openDatabase(vaultPath); + runMigrations(db); + closeDatabase(db); + }); + + afterEach(() => { + homedirSpy.mockRestore(); + fs.rmSync(homeDir, { recursive: true, force: true }); + }); + + it("prints frontmatter and body in human-readable form", async () => { + // `#` marks a project and `@` marks a tag (see extractMentions). + const db = openDatabase(vaultPath); + const id = await saveMemory(db, vaultPath, "the body #projx @tagy\n"); + closeDatabase(db); + + const { io, stdout, stderr } = makeIO(); + const code = await getCommand([id], io); + expect(code).toBe(0); + expect(stderr()).toBe(""); + + const out = stdout(); + expect(out).toContain(`id: ${id}`); + expect(out).toContain("projects: projx"); + expect(out).toContain("tags: tagy"); + expect(out).toContain("the body #projx @tagy"); + }); + + it("emits valid JSON with --json", async () => { + const db = openDatabase(vaultPath); + const id = await saveMemory(db, vaultPath, "json body\n"); + closeDatabase(db); + + const { io, stdout } = makeIO(); + const code = await getCommand([id, "--json"], io); + expect(code).toBe(0); + + const parsed = JSON.parse(stdout()); + expect(parsed.id).toBe(id); + expect(parsed.body).toBe("json body\n"); + expect(parsed.frontmatter.source).toBe("note"); + }); + + it("errors for an unknown id", async () => { + const { io, stderr } = makeIO(); + const code = await getCommand(["018f0c8e-7c4f-7d3a-8b2e-000000000000"], io); + expect(code).toBe(1); + expect(stderr()).toContain("見つか"); + }); + + it("errors with usage when the id is missing", async () => { + const { io, stderr } = makeIO(); + const code = await getCommand(["--json"], io); + expect(code).toBe(1); + expect(stderr()).toContain("使い方"); + }); + + it("localizes messages in English when DENNOH_LANG=en", async () => { + // The vault config is lang:ja; the env override takes precedence in + // resolveLang, so the same usage error must come back in English. + const prev = process.env.DENNOH_LANG; + process.env.DENNOH_LANG = "en"; + try { + const { io, stderr } = makeIO(); + const code = await getCommand([], io); + expect(code).toBe(1); + expect(stderr()).toContain("Usage"); + } finally { + prev === undefined + ? Reflect.deleteProperty(process.env, "DENNOH_LANG") + : Reflect.set(process.env, "DENNOH_LANG", prev); + } + }); +}); diff --git a/tests/cli/recent.test.ts b/tests/cli/recent.test.ts new file mode 100644 index 0000000..3fff5ba --- /dev/null +++ b/tests/cli/recent.test.ts @@ -0,0 +1,119 @@ +// Disable JA→EN translation so saveMemory doesn't load model weights; +// see restore.test.ts for the same guard. +process.env.DENNOH_TRANSLATE_DISABLE = "1"; + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; +import git from "isomorphic-git"; + +import { type CliIO, recentCommand } from "@/cli"; +import { writeConfig } from "@/config"; +import { saveMemory } from "@/core/memory"; +import { closeDatabase, openDatabase, runMigrations } from "@/db"; + +function makeIO(): { io: CliIO; stdout: () => string; stderr: () => string } { + const stdoutBuf: string[] = []; + const stderrBuf: string[] = []; + return { + io: { + stdout: (s) => { + stdoutBuf.push(s); + }, + stderr: (s) => { + stderrBuf.push(s); + }, + }, + stdout: () => stdoutBuf.join(""), + stderr: () => stderrBuf.join(""), + }; +} + +describe("cli recent", () => { + let homeDir: string; + let vaultPath: string; + let homedirSpy: ReturnType>; + + beforeEach(async () => { + homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "dennoh-recent-home-")); + homedirSpy = spyOn(os, "homedir").mockReturnValue(homeDir); + + vaultPath = path.join(homeDir, "vault"); + fs.mkdirSync(vaultPath, { recursive: true }); + await git.init({ fs, dir: vaultPath, defaultBranch: "main" }); + await git.setConfig({ fs, dir: vaultPath, path: "user.name", value: "Test" }); + await git.setConfig({ fs, dir: vaultPath, path: "user.email", value: "test@example.com" }); + + writeConfig({ vaultPath, lang: "ja" }); + + const db = openDatabase(vaultPath); + runMigrations(db); + closeDatabase(db); + }); + + afterEach(() => { + homedirSpy.mockRestore(); + fs.rmSync(homeDir, { recursive: true, force: true }); + }); + + it("lists notes one per line and respects --limit", async () => { + const db = openDatabase(vaultPath); + await saveMemory(db, vaultPath, "first\n"); + await saveMemory(db, vaultPath, "second\n"); + await saveMemory(db, vaultPath, "third\n"); + closeDatabase(db); + + const { io, stdout, stderr } = makeIO(); + const code = await recentCommand(["--limit", "2"], io); + expect(code).toBe(0); + expect(stderr()).toBe(""); + + const lines = stdout().trim().split("\n").filter(Boolean); + expect(lines).toHaveLength(2); + }); + + it("deserializes projects/tags in --json output", async () => { + // `#` marks a project and `@` marks a tag (see extractMentions). + const db = openDatabase(vaultPath); + await saveMemory(db, vaultPath, "tagged #proj @topic\n"); + closeDatabase(db); + + const { io, stdout } = makeIO(); + const code = await recentCommand(["--json"], io); + expect(code).toBe(0); + + const parsed = JSON.parse(stdout()); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed[0].projects).toEqual(["proj"]); + expect(parsed[0].tags).toEqual(["topic"]); + expect(parsed[0].id).toBeDefined(); + // Raw *_json columns are replaced by the parsed arrays. + expect(parsed[0].projects_json).toBeUndefined(); + expect(parsed[0].tags_json).toBeUndefined(); + }); + + it("rejects a non-positive --limit", async () => { + // A negative value must use the `--limit=-3` form: in the space-separated + // form a `-`-prefixed token is treated as a missing value, not a number. + const { io, stderr } = makeIO(); + const code = await recentCommand(["--limit=-3"], io); + expect(code).toBe(1); + expect(stderr()).toContain("正の整数"); + }); + + it("rejects --limit given without a value", async () => { + const { io, stderr } = makeIO(); + const code = await recentCommand(["--limit"], io); + expect(code).toBe(1); + expect(stderr()).toContain("値が必要"); + }); + + it("errors on unexpected positional arguments", async () => { + const { io, stderr } = makeIO(); + const code = await recentCommand(["stray"], io); + expect(code).toBe(1); + expect(stderr()).toContain("予期しない"); + }); +}); diff --git a/tests/cli/reindex.test.ts b/tests/cli/reindex.test.ts new file mode 100644 index 0000000..dbaa41c --- /dev/null +++ b/tests/cli/reindex.test.ts @@ -0,0 +1,91 @@ +// Disable JA→EN translation so saveMemory / reindexAll don't load model +// weights; see restore.test.ts for the same guard. +process.env.DENNOH_TRANSLATE_DISABLE = "1"; + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; +import git from "isomorphic-git"; + +import { type CliIO, reindexCommand } from "@/cli"; +import { writeConfig } from "@/config"; +import { saveMemory } from "@/core/memory"; +import { closeDatabase, getNoteById, openDatabase, runMigrations } from "@/db"; + +function makeIO(): { io: CliIO; stdout: () => string; stderr: () => string } { + const stdoutBuf: string[] = []; + const stderrBuf: string[] = []; + return { + io: { + stdout: (s) => { + stdoutBuf.push(s); + }, + stderr: (s) => { + stderrBuf.push(s); + }, + }, + stdout: () => stdoutBuf.join(""), + stderr: () => stderrBuf.join(""), + }; +} + +describe("cli reindex", () => { + let homeDir: string; + let vaultPath: string; + let homedirSpy: ReturnType>; + + beforeEach(async () => { + homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "dennoh-reindex-home-")); + homedirSpy = spyOn(os, "homedir").mockReturnValue(homeDir); + + vaultPath = path.join(homeDir, "vault"); + fs.mkdirSync(vaultPath, { recursive: true }); + await git.init({ fs, dir: vaultPath, defaultBranch: "main" }); + await git.setConfig({ fs, dir: vaultPath, path: "user.name", value: "Test" }); + await git.setConfig({ fs, dir: vaultPath, path: "user.email", value: "test@example.com" }); + + writeConfig({ vaultPath, lang: "ja" }); + + const db = openDatabase(vaultPath); + runMigrations(db); + closeDatabase(db); + }); + + afterEach(() => { + homedirSpy.mockRestore(); + fs.rmSync(homeDir, { recursive: true, force: true }); + }); + + it("rebuilds the index and reports the processed count", async () => { + const db = openDatabase(vaultPath); + const id = await saveMemory(db, vaultPath, "indexed note\n"); + // Wipe the row so reindex has something to restore from disk. + db.exec("DELETE FROM notes;"); + expect(getNoteById(db, id)).toBeNull(); + closeDatabase(db); + + const { io, stdout, stderr } = makeIO(); + const code = await reindexCommand([], io); + expect(code).toBe(0); + expect(stderr()).toBe(""); + // Japanese summary under the default lang:ja config: "1 件のメモを + // 再インデックスしました。エラー 0 件、翻訳エラー 0 件" + expect(stdout()).toContain("1 件のメモを再インデックスしました"); + expect(stdout()).toContain("エラー 0 件"); + expect(stdout()).toContain("翻訳エラー 0 件"); + + // The note is back in the index after the rebuild. + const db2 = openDatabase(vaultPath); + expect(getNoteById(db2, id)?.body).toBe("indexed note\n"); + closeDatabase(db2); + }); + + it("errors on unexpected arguments", async () => { + const { io, stderr } = makeIO(); + const code = await reindexCommand(["--json"], io); + expect(code).toBe(1); + expect(stderr()).toContain("予期しない"); + }); +}); diff --git a/tests/cli/search.test.ts b/tests/cli/search.test.ts new file mode 100644 index 0000000..bb50729 --- /dev/null +++ b/tests/cli/search.test.ts @@ -0,0 +1,138 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; +import git from "isomorphic-git"; + +import { type CliIO, searchCommand } from "@/cli"; +import { writeConfig } from "@/config"; +import { saveMemory } from "@/core/memory"; +import { closeDatabase, openDatabase, runMigrations } from "@/db"; + +function makeIO(): { io: CliIO; stdout: () => string; stderr: () => string } { + const stdoutBuf: string[] = []; + const stderrBuf: string[] = []; + return { + io: { + stdout: (s) => { + stdoutBuf.push(s); + }, + stderr: (s) => { + stderrBuf.push(s); + }, + }, + stdout: () => stdoutBuf.join(""), + stderr: () => stderrBuf.join(""), + }; +} + +describe("cli search", () => { + let homeDir: string; + let vaultPath: string; + let homedirSpy: ReturnType>; + // Scope DENNOH_TRANSLATE_DISABLE per-test and restore it afterwards so the + // env mutation does not leak into other suites in the same process. + let prevTranslateDisable: string | undefined; + + beforeEach(async () => { + prevTranslateDisable = process.env.DENNOH_TRANSLATE_DISABLE; + process.env.DENNOH_TRANSLATE_DISABLE = "1"; + + homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "dennoh-search-home-")); + homedirSpy = spyOn(os, "homedir").mockReturnValue(homeDir); + + vaultPath = path.join(homeDir, "vault"); + fs.mkdirSync(vaultPath, { recursive: true }); + await git.init({ fs, dir: vaultPath, defaultBranch: "main" }); + await git.setConfig({ fs, dir: vaultPath, path: "user.name", value: "Test" }); + await git.setConfig({ fs, dir: vaultPath, path: "user.email", value: "test@example.com" }); + + writeConfig({ vaultPath, lang: "ja" }); + + const db = openDatabase(vaultPath); + runMigrations(db); + closeDatabase(db); + }); + + afterEach(() => { + homedirSpy.mockRestore(); + fs.rmSync(homeDir, { recursive: true, force: true }); + prevTranslateDisable === undefined + ? Reflect.deleteProperty(process.env, "DENNOH_TRANSLATE_DISABLE") + : Reflect.set(process.env, "DENNOH_TRANSLATE_DISABLE", prevTranslateDisable); + }); + + it("prints one line per hit in default form", async () => { + const db = openDatabase(vaultPath); + const id = await saveMemory(db, vaultPath, "needle in the haystack\n"); + closeDatabase(db); + + const { io, stdout, stderr } = makeIO(); + const code = await searchCommand(["needle"], io); + expect(code).toBe(0); + expect(stderr()).toBe(""); + // Verify the behavior the test name claims: exactly one line, formatted + // `