-
Notifications
You must be signed in to change notification settings - Fork 0
feat(cli): note CRUD/read commands with exit codes and i18n #18
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Lang, { usage: string }> = { | ||
| ja: { usage: '使い方: dennoh add "<本文>"\n' }, | ||
| en: { usage: 'Usage: dennoh add "<text>"\n' }, | ||
| }; | ||
|
|
||
| // `dennoh add "<text>"` — 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<number> { | ||
| 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); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <id>\n", | ||
| notFound: (id) => `メモが見つからないか、既に削除されています (id=${id})\n`, | ||
| success: (id) => `削除しました ${id}\n`, | ||
| }, | ||
| en: { | ||
| usage: "Usage: dennoh delete <id>\n", | ||
| notFound: (id) => `note not found or already deleted (id=${id})\n`, | ||
| success: (id) => `deleted ${id}\n`, | ||
| }, | ||
| }; | ||
|
|
||
| // `dennoh delete <id>` — 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<number> { | ||
| 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); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Lang, { usage: string; notFound: (id: string) => string }> = { | ||
| ja: { | ||
| usage: "使い方: dennoh get <id> [--json]\n", | ||
| notFound: (id) => `メモが見つかりません (id=${id})\n`, | ||
| }, | ||
| en: { | ||
| usage: "Usage: dennoh get <id> [--json]\n", | ||
| notFound: (id) => `note not found (id=${id})\n`, | ||
| }, | ||
| }; | ||
|
|
||
| // `dennoh get <id> [--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<number> { | ||
| 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); | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.