From 3a91c3eba55e49db7f83f862bdf0f3f7993f5161 Mon Sep 17 00:00:00 2001 From: F88 <685250+F88@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:57:37 +0900 Subject: [PATCH 1/7] feat: add ppv-cli, ppex and pptop CLI tools Add CLI tools for inspecting ProtoPedia API data offline from local snapshots: - ppv-cli: one-shot, pipe-friendly CLI (JSON or tab-separated output) - ppex: interactive explorer built with Ink - pptop: top(1)-style monitor built with Ink Includes build/test tooling (TypeScript, esbuild, Vitest, ESLint, Prettier), CI workflow, and project documentation. Co-Authored-By: Claude Fable 5 --- .gitattributes | 4 + .github/workflows/ci.yml | 48 ++ .gitignore | 108 +++++ .markdownlint.yaml | 13 + .prettierignore | 15 + CHANGELOG.md | 10 + DEVELOPMENT.md | 95 ++++ LICENSE | 21 + PRD.md | 121 +++++ README.md | 104 +++- eslint.config.mjs | 66 +++ package.json | 76 +++ prettier.config.mjs | 23 + scripts/build-ink.mjs | 70 +++ scripts/stub-react-devtools-core.mjs | 13 + src/core/clipboard.ts | 32 ++ src/core/config-entries.test.ts | 118 +++++ src/core/config-entries.ts | 91 ++++ src/core/config-file.test.ts | 122 +++++ src/core/config-file.ts | 153 ++++++ src/core/config-validation.test.ts | 129 +++++ src/core/config-validation.ts | 113 +++++ src/core/constants.test.ts | 100 ++++ src/core/constants.ts | 58 +++ src/core/file-io-errors.ts | 27 ++ src/core/format.test.ts | 49 ++ src/core/format.ts | 72 +++ src/core/list-window.test.ts | 63 +++ src/core/list-window.ts | 43 ++ src/core/logger.test.ts | 96 ++++ src/core/logger.ts | 83 ++++ src/core/protopedia-utils.ts | 4 + src/core/repository-factory.ts | 69 +++ src/core/sanitize-display-text.test.ts | 86 ++++ src/core/sanitize-display-text.ts | 53 +++ src/core/search-model.ts | 127 +++++ src/core/session.test.ts | 109 +++++ src/core/session.ts | 182 +++++++ src/core/snapshot-catalog.test.ts | 72 +++ src/core/snapshot-catalog.ts | 134 ++++++ src/core/snapshot-delete.test.ts | 49 ++ src/core/snapshot-delete.ts | 48 ++ src/core/token.ts | 39 ++ src/core/user-dirs.ts | 32 ++ src/core/version.ts | 36 ++ src/ppc/commands/config-init.test.ts | 90 ++++ src/ppc/commands/config-init.ts | 81 ++++ src/ppc/commands/config-set-token.test.ts | 147 ++++++ src/ppc/commands/config-set-token.ts | 133 ++++++ src/ppc/commands/config-show.test.ts | 174 +++++++ src/ppc/commands/config-show.ts | 120 +++++ src/ppc/commands/data-analyze.ts | 52 ++ src/ppc/commands/data-stats.ts | 58 +++ src/ppc/commands/prototype-list.ts | 88 ++++ src/ppc/commands/prototype-search.ts | 67 +++ src/ppc/commands/prototype-show.ts | 54 +++ src/ppc/commands/snapshot-create.test.ts | 91 ++++ src/ppc/commands/snapshot-create.ts | 183 ++++++++ src/ppc/commands/snapshot-list.ts | 46 ++ src/ppc/explorer.test.ts | 153 ++++++ src/ppc/explorer.ts | 107 +++++ src/ppc/format-prototype-row.test.ts | 42 ++ src/ppc/format-prototype-row.ts | 19 + src/ppc/output.ts | 20 + src/ppc/snapshot-loader.ts | 119 +++++ src/ppex.tsx | 90 ++++ src/ppex/ppex-app.test.tsx | 208 ++++++++ src/ppex/ppex-app.tsx | 239 ++++++++++ src/ppex/search-screen.test.tsx | 290 ++++++++++++ src/ppex/search-screen.tsx | 250 ++++++++++ src/pptop.tsx | 90 ++++ src/pptop/top-app.test.tsx | 437 +++++++++++++++++ src/pptop/top-app.tsx | 548 ++++++++++++++++++++++ src/pptop/top-header.tsx | 109 +++++ src/pptop/top-stats.ts | 128 +++++ src/ppv-cli.ts | 403 ++++++++++++++++ src/ui/creating-progress.test.ts | 61 +++ src/ui/creating-progress.tsx | 61 +++ src/ui/header.tsx | 45 ++ src/ui/help-panel.tsx | 68 +++ src/ui/menu.tsx | 84 ++++ src/ui/multi-select-list.tsx | 118 +++++ src/ui/preview-pane.tsx | 41 ++ src/ui/prototype-detail.test.tsx | 149 ++++++ src/ui/prototype-detail.tsx | 221 +++++++++ src/ui/prototype-raw.tsx | 17 + src/ui/result-list.tsx | 53 +++ src/ui/result-table.test.tsx | 87 ++++ src/ui/result-table.tsx | 96 ++++ src/ui/search-form.tsx | 46 ++ src/ui/snapshot-manager.test.tsx | 235 ++++++++++ src/ui/snapshot-manager.tsx | 464 ++++++++++++++++++ src/ui/snapshot-picker.tsx | 147 ++++++ src/ui/table-columns.test.ts | 154 ++++++ src/ui/table-columns.ts | 181 +++++++ src/ui/token-missing-notice.test.tsx | 31 ++ src/ui/token-missing-notice.tsx | 41 ++ src/ui/use-terminal-size.ts | 28 ++ tsconfig.build.json | 12 + tsconfig.json | 17 + vitest.config.ts | 15 + 101 files changed, 10283 insertions(+), 1 deletion(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .markdownlint.yaml create mode 100644 .prettierignore create mode 100644 CHANGELOG.md create mode 100644 DEVELOPMENT.md create mode 100644 LICENSE create mode 100644 PRD.md create mode 100644 eslint.config.mjs create mode 100644 package.json create mode 100644 prettier.config.mjs create mode 100644 scripts/build-ink.mjs create mode 100644 scripts/stub-react-devtools-core.mjs create mode 100644 src/core/clipboard.ts create mode 100644 src/core/config-entries.test.ts create mode 100644 src/core/config-entries.ts create mode 100644 src/core/config-file.test.ts create mode 100644 src/core/config-file.ts create mode 100644 src/core/config-validation.test.ts create mode 100644 src/core/config-validation.ts create mode 100644 src/core/constants.test.ts create mode 100644 src/core/constants.ts create mode 100644 src/core/file-io-errors.ts create mode 100644 src/core/format.test.ts create mode 100644 src/core/format.ts create mode 100644 src/core/list-window.test.ts create mode 100644 src/core/list-window.ts create mode 100644 src/core/logger.test.ts create mode 100644 src/core/logger.ts create mode 100644 src/core/protopedia-utils.ts create mode 100644 src/core/repository-factory.ts create mode 100644 src/core/sanitize-display-text.test.ts create mode 100644 src/core/sanitize-display-text.ts create mode 100644 src/core/search-model.ts create mode 100644 src/core/session.test.ts create mode 100644 src/core/session.ts create mode 100644 src/core/snapshot-catalog.test.ts create mode 100644 src/core/snapshot-catalog.ts create mode 100644 src/core/snapshot-delete.test.ts create mode 100644 src/core/snapshot-delete.ts create mode 100644 src/core/token.ts create mode 100644 src/core/user-dirs.ts create mode 100644 src/core/version.ts create mode 100644 src/ppc/commands/config-init.test.ts create mode 100644 src/ppc/commands/config-init.ts create mode 100644 src/ppc/commands/config-set-token.test.ts create mode 100644 src/ppc/commands/config-set-token.ts create mode 100644 src/ppc/commands/config-show.test.ts create mode 100644 src/ppc/commands/config-show.ts create mode 100644 src/ppc/commands/data-analyze.ts create mode 100644 src/ppc/commands/data-stats.ts create mode 100644 src/ppc/commands/prototype-list.ts create mode 100644 src/ppc/commands/prototype-search.ts create mode 100644 src/ppc/commands/prototype-show.ts create mode 100644 src/ppc/commands/snapshot-create.test.ts create mode 100644 src/ppc/commands/snapshot-create.ts create mode 100644 src/ppc/commands/snapshot-list.ts create mode 100644 src/ppc/explorer.test.ts create mode 100644 src/ppc/explorer.ts create mode 100644 src/ppc/format-prototype-row.test.ts create mode 100644 src/ppc/format-prototype-row.ts create mode 100644 src/ppc/output.ts create mode 100644 src/ppc/snapshot-loader.ts create mode 100644 src/ppex.tsx create mode 100644 src/ppex/ppex-app.test.tsx create mode 100644 src/ppex/ppex-app.tsx create mode 100644 src/ppex/search-screen.test.tsx create mode 100644 src/ppex/search-screen.tsx create mode 100644 src/pptop.tsx create mode 100644 src/pptop/top-app.test.tsx create mode 100644 src/pptop/top-app.tsx create mode 100644 src/pptop/top-header.tsx create mode 100644 src/pptop/top-stats.ts create mode 100644 src/ppv-cli.ts create mode 100644 src/ui/creating-progress.test.ts create mode 100644 src/ui/creating-progress.tsx create mode 100644 src/ui/header.tsx create mode 100644 src/ui/help-panel.tsx create mode 100644 src/ui/menu.tsx create mode 100644 src/ui/multi-select-list.tsx create mode 100644 src/ui/preview-pane.tsx create mode 100644 src/ui/prototype-detail.test.tsx create mode 100644 src/ui/prototype-detail.tsx create mode 100644 src/ui/prototype-raw.tsx create mode 100644 src/ui/result-list.tsx create mode 100644 src/ui/result-table.test.tsx create mode 100644 src/ui/result-table.tsx create mode 100644 src/ui/search-form.tsx create mode 100644 src/ui/snapshot-manager.test.tsx create mode 100644 src/ui/snapshot-manager.tsx create mode 100644 src/ui/snapshot-picker.tsx create mode 100644 src/ui/table-columns.test.ts create mode 100644 src/ui/table-columns.ts create mode 100644 src/ui/token-missing-notice.test.tsx create mode 100644 src/ui/token-missing-notice.tsx create mode 100644 src/ui/use-terminal-size.ts create mode 100644 tsconfig.build.json create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8449f14 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Keep LF line endings in the working tree on every platform +# (Windows runners default to core.autocrlf=true, which would break +# prettier's LF expectation). +* text=auto eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..dedbc57 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + workflow_dispatch: + push: + branches: [main] + pull_request: + +jobs: + check: + name: Node ${{ matrix.node-version }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + node-version: [22, 24] + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Check formatting + run: npm run format:check + + - name: Lint + run: npm run lint + + - name: Typecheck + run: npm run typecheck + + - name: Test + run: npm test + + - name: Build + run: npm run build + + - name: Smoke test built CLIs + run: | + node dist/ppv-cli.js --help + node dist/ppex.js --help + node dist/pptop.js --help diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ff401a --- /dev/null +++ b/.gitignore @@ -0,0 +1,108 @@ +# Created by https://www.toptal.com/developers/gitignore/api/windows,macos,linux +# Edit at https://www.toptal.com/developers/gitignore?templates=windows,macos,linux + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/windows,macos,linux + +# Node dependencies +node_modules/ + +# Build outputs +dist/ +build/ + +# TypeScript +*.tsbuildinfo +*.d.ts +*.d.ts.map +*.js +*.js.map + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# Test +coverage/ + +# Local environment (contains the API token) +.env +.env.* + +# npm pack artifacts +*.tgz diff --git a/.markdownlint.yaml b/.markdownlint.yaml new file mode 100644 index 0000000..9187aa9 --- /dev/null +++ b/.markdownlint.yaml @@ -0,0 +1,13 @@ +MD007: + indent: 4 + +MD013: + line_length: 500 + +MD025: + front_matter_title: '' + level: 1 + +MD033: + allowed_elements: + - br diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..a69b9ec --- /dev/null +++ b/.prettierignore @@ -0,0 +1,15 @@ +# Build outputs +build/ +dist/ +coverage/ + +# Dependencies +node_modules/ + +# Snapshot data (large generated JSON) +snapshots/ + +# Misc +package-lock.json +renovate.json + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8d14f9e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ + + +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..3991adf --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,95 @@ +# Development + +Notes for developing ppv26-cli. See [README.md](README.md) for setup and usage, and [PRD.md](PRD.md) for requirements and design. + +## Commands + +```console +npm run typecheck # tsc --noEmit +npm test # vitest +npm run lint # eslint (lint:fix to autofix) +npm run format # prettier --write (format:check to check only) +npm run build # build:tsc then build:bundle -> dist/ +``` + +`npm install` also builds `dist/` automatically (via the `prepare` script), so a fresh clone is runnable right away. + +CI runs all of the above (formatting as a check) plus a `--help` smoke test of the built CLI on ubuntu / windows with Node 22 / 24. + +## Running the binaries + +There are two ways to run the binaries; for pptop / ppex the two ways use different React builds. This matters: + +```console +# From source (fast iteration). Uses React's DEVELOPMENT build. +npx tsx src/pptop.tsx +npx tsx src/ppex.tsx +npm run ppv-cli -- + +# From dist/ (production-equivalent). Uses React's PRODUCTION build. +npm run build && node dist/pptop +node dist/ppex +node dist/ppv-cli +``` + +Why the split: Ink picks React's dev vs production build at runtime from `process.env.NODE_ENV`, and neither `tsx` nor `tsc` replaces that value, so running from source (or a plain `tsc` output) gets the **dev** build. +React 19's dev build carries the Component Performance Track, which serializes changed props into `performance.measure` entries that Node's performance timeline retains indefinitely. When a large array prop changes reference every render (pptop sort / search recomputing `filtered`), that grows without bound and the process eventually OOMs (issue #18). + +`npm run build` runs two stages (`package.json` scripts): + +- `build:tsc` - `tsc` emits `ppv-cli` and the shared React-free `dist/core/` (no Ink/ui output). +- `build:bundle` - `scripts/build-ink.mjs` bundles pptop / ppex with esbuild, baking `NODE_ENV=production` (via `define`) so React's production build is selected at build time and the dev-only instrumentation is dead-code-eliminated. This is what makes the shipped binaries safe from the OOM above. `ppv-cli` (no React) stays on `tsc`. Use `npm run build:bundle` alone to rebuild just the bundles. + +Practical guidance: + +- UI work / quick checks -> `npx tsx src/pptop.tsx` is fine. Just avoid hammering sort / search over a large snapshot; the dev build leaks. +- Memory or production-fidelity checks -> run the built binary with `npm run build && node dist/pptop`, or prefix the source run with `NODE_ENV=production npx tsx src/pptop.tsx` (which also disables the instrumentation). + +## Source layout + +One binary = one entry file at the src/ root (named after the bin) plus one directory of the same name: + +```text +src/ + ppv-cli.ts ppex.tsx pptop.tsx # bin entries (thin launchers) + ppc/ # ppv-cli-only (commands/, explorer.ts) + ppex/ # ppex-only (app container, screens) + pptop/ # pptop-only (app container, header) + ui/ # shared Ink components + core/ # shared React-free logic +``` + +Rules: + +- Shared code has exactly two homes: `ui/` for anything that depends on Ink (React), `core/` for everything else. +- Dependencies point one way: `pp*/ -> ui/ -> core/`. One-shot binaries (ppv-cli) must not import from `ui/`, so starting the CLI never loads Ink/React. +- Tests are co-located: `foo.ts` is tested by `foo.test.ts` right next to it (excluded from the build by tsconfig.build.json), so tests move together with their subjects when directories are reorganized. + +To add a new binary `ppxxx`: + +1. Create `src/ppxxx.tsx` (arg parsing, TTY guard, render) and `src/ppxxx/` (the app). The existing three are the templates. +2. Add `"ppxxx": "./dist/ppxxx.js"` to `bin` in package.json. +3. Document it in README.md and PRD.md. + +## Dependencies management + +Check for updates with: + +```console +npx npm-check-updates --format group +``` + +Policy: + +- **Patch / minor updates**: apply freely. Run the full check suite (format:check, lint, typecheck, test, build, and a smoke run of `dist/ppv-cli.js`) before committing. +- **Major updates**: review the changelog first, apply one package at a time, and verify with the full check suite. + +### Constraints + +Deliberate exceptions to "just update". Do not bump these without revisiting the reasoning: + +| Package | Constraint | Reason | +| ---------------------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `typescript` | Stay on 5.x | TypeScript 6/7 is the native-compiler (tsgo) generation, not a routine upgrade. Wait until the toolchain (typescript-eslint, tsx) and the PROMIDAS ecosystem (all repos on 5.9) have migrated, then follow. (Decided 2026-07-15) | +| `@types/node` | Stay on 24.x | Type definitions should not exceed the supported/tested Node versions (engines: >=22, CI matrix: 22 / 24). Newer majors would let APIs unavailable on Node 22 pass the typecheck. Raise only together with the engines / CI matrix. | +| `promidas` / `promidas-utils` / `protopedia-api-v2-client` | Update promptly, together | PROMIDAS ecosystem packages. Keep `promidas` and `protopedia-api-v2-client` on versions that satisfy the peerDependency (`^3`). This CLI dogfoods the ecosystem, so new releases should be adopted early. | diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1ff84d8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 F88 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PRD.md b/PRD.md new file mode 100644 index 0000000..7baeb8d --- /dev/null +++ b/PRD.md @@ -0,0 +1,121 @@ +# PRD: ppv-cli - ProtoPedia データ調査 CLI ツール + +本書は実装済みの現行仕様の要約である。使い方と設定の詳細は [README.md](README.md)、開発情報は [DEVELOPMENT.md](DEVELOPMENT.md) を参照。設計判断の経緯と棄却した代替案は本書のメンテナンス対象から外した。必要なら git 履歴 (簡素化前の PRD) と Issue / PR の記録を参照する。 + +## 1. 概要 + +ProtoPedia のデータをローカル環境で調査するための CLI ツール群。 [promidas](https://www.npmjs.com/package/promidas) / [promidas-utils](https://www.npmjs.com/package/promidas-utils) / [protopedia-api-v2-client](https://www.npmjs.com/package/protopedia-api-v2-client) を組み合わせて構築する。 + +ProtoPedia API から取得したデータは snapshot としてローカルファイルに保存し、以降の起動では既存 snapshot をロードして利用する。これにより API への不要なリクエストを避け、オフラインでもデータ調査ができ、取得時点の異なる snapshot を比較・参照できる。 + +### 1.1 スコープ + +- 配布は npm パッケージ (`npm install`) として行う。 +- 単一バイナリ化などのバイナリファイル提供は行わない。 + +## 2. 動作環境 + +| 項目 | 要件 | +| -------------- | ------------------------------------- | +| OS | macOS / Windows (両対応必須) | +| ランタイム | Node.js >= 22 (promidas の要件に準拠) | +| モジュール形式 | ESM | +| 言語 | TypeScript | + +Windows 対応のため、パス操作は `node:path` を使用し、パス区切り文字やホームディレクトリ解決を OS 依存にしない。 + +## 3. 用語 + +| 用語 | 意味 | +| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| snapshot | ある時点で ProtoPedia API から取得した全 prototype データの集合。promidas の `SerializableSnapshot` を JSON ファイルとして保存したもの | +| repository | promidas の `ProtopediaInMemoryRepository`。in-memory store と fetcher を統合した高レベル API | +| ワンショット実行 | `ppv-cli ` 形式で 1 回の実行で 1 操作を行う利用形態 | +| 対話モード | 画面を占有する対話型の利用形態 (ppex / pptop) | + +## 4. 提供機能 + +3 つのバイナリが同じ snapshot ファイルを共有する。 + +- `ppv-cli`: UNIX 風ワンショット CLI (パイプ前提) +- `ppex`: 対話エクスプローラ (Ink、日本語 UI) +- `pptop`: top(1) 風モニター (Ink、英語 UI) + +### 4.1 ppv-cli (ワンショット CLI) + +コマンド体系は「名詞 (操作対象) → 動詞」の 2 階層。操作対象は `config` (CLI 自体の設定) / `data` (ロード済みデータ全体) / `prototype` (個々の作品) / `snapshot` (ディスク上のファイル) の 4 種類。 + +```console +ppv-cli config init # ~/.ppv-cli/config をテンプレートで生成 +ppv-cli config set-token # トークンの対話設定 +ppv-cli config show # 設定ファイルの中身・状態の表示 +ppv-cli data analyze # ロード済みデータの分析 (ID min/max) +ppv-cli data stats # ロード済みデータの状態表示 (getStats) +ppv-cli prototype list --last 20 # 最新 20 件を一覧 (ID 昇順) +ppv-cli prototype search LED cube # キーワード検索 (複数指定は AND) +ppv-cli prototype search --tag M5Stack # ファセットで絞り込み +ppv-cli prototype show 1234 # ID 指定で詳細 (JSON) 表示 +ppv-cli snapshot create # API から全件取得して snapshot 保存 +ppv-cli snapshot list # snapshot 一覧 (新しい順) +``` + +- 検索構文: キーワードは `prototypeNm` / `summary` への部分一致で複数指定は AND。ファセット (`--id` / `--tag` / `--user` / `--event` / `--material` / `--status`) は同一ファセット内 OR、ファセット間 AND。`--id` / `--status` は完全一致、他は部分一致。条件なしの実行はエラー。結果は ID 昇順のタブ区切り (ID・作品名・ユーザー)。 +- `prototype list` は範囲指定必須 (`--all` / `--first ` / `--last ` のいずれか 1 つ)。出力形式は search と同一。 +- データ系コマンドは明示指定がなければ最新 snapshot を無言でロードする。`--snapshot ` で特定ファイルを指定できる。 +- `snapshot create` だけが API を呼ぶ (トークン必須)。取得は利用者の明示操作のみで、自動 fetch はしない。既存 snapshot は上書きせず新ファイルとして追加する。 + +### 4.2 ppex (対話エクスプローラ) + +- 起動すると snapshot picker を提示する (最新がデフォルト選択、`[作成して選択]` あり、m キーで snapshot manager へ)。 +- 検索機能は「作品探索」に統合: 6 フィールド (ID / 作品名 / 概要 / タグ / 素材 / ユーザー) のライブフィルタ + 選択追従のプレビューカード + 詳細表示 (JSON / カードのタブ切替、`r` で Raw 全文、`c` でクリップボードコピー)。 +- 検索フォームの意味論: フィールド内スペース区切りは AND、フィールド間も AND。ID フィールドのみ前方一致かつスペース区切り OR。 +- 端末幅 110 桁以上ではプレビューを右カラムに配置するレスポンシブレイアウト。非 TTY での起動は明示エラー。 + +### 4.3 pptop (top(1) 風モニター) + +- 起動すると最新 snapshot を自動ロードし、即メイン画面に入る (なければ空テーブル)。 +- top(1) 風の集計ヘッダ (時刻・経過時間・stale 表示、Newborns、ステータス内訳と %、engagement 合計、distinct 数) をテーブルの上に常時表示する。 +- キーはコマンドとして扱う: `s` (snapshot 管理) / `/` (検索入力モード、ppex と共有の 6 フィールド) / ↑↓ PgUp PgDn (行選択) / Enter (詳細) / `R` (ソート反転) / `<` `>` (ソート列移動、PID が第 2 キー) / `?` `h` (ヘルプ) / `q` (終了)。 +- テーブルはレスポンシブで、狭い端末では優先度の低い列から非表示になる。レイアウトは手計算せず `measureElement` の実測から導出する。 + +### 4.4 snapshot picker / manager (共有 UI) + +- picker: snapshot 一覧の選択 UI。最新がデフォルトで Enter だけでロードできる。`[作成して選択]` と m キー (manager) を含む。 +- manager: 既存の snapshots を UI から削除できる唯一の機能 (ppex / pptop 共通の 1 実装、`lang` prop で言語切替)。表示は検証済みの実測値のみで、ファイル名からの推定は出さない。一覧を開くと裏で 1 件ずつ自動検証する。Space でマーク → d で確認付き完全削除 (Trash は使わない)。c で作成 (ファイルを作るだけでロードしない)。 + +## 5. 設定 + +- 設定ファイルは `~/.ppv-cli/config` ただ 1 つ (KEY=VALUE の dotenv 互換形式)。トークンを含む全設定値の単一情報源 (SSOT) で、環境変数や `.env` は読まない。process.env には何も書き込まない。 +- 有効なキーは 4 つ: `PROTOPEDIA_API_V2_TOKEN` / `PPV_CLI_SNAPSHOT_DIR` / `PPV_CLI_LOG_LEVEL` / `PPV_CLI_SNAPSHOT_STALE_HOURS`。各キーの意味とデフォルトは [README.md](README.md) を参照。認識されないキーは黙って無視する。 +- 一回性の上書きは CLI オプションのみ (`--snapshot-dir` / `--snapshot` / `--verbose` / `--quiet`)。 +- 起動時に 1 回だけ読み込み、無検証の生値 + 読み取り状態 (read / missing / unreadable) として保持する。unreadable のとき、設定を消費するコマンドはエラー停止する (config コマンドは修復手段なので除く)。 +- 値の検証にフォールバックはない: 規定値が値に内在する 2 キー (`PPV_CLI_LOG_LEVEL` / `PPV_CLI_SNAPSHOT_STALE_HOURS`) は起動時に検査し、不正ならエラー停止する。デフォルトはキーが無い場合のみ適用する。 +- config サブコマンドの規則: 結果は stdout、診断は stderr 直書き (ログ設定から独立)。トークンの完全値は決して出力しない (`****` + 末尾 4 文字)。ファイルを作成できるのは init だけで、読めないファイルをテンプレートで作り直すことはしない。 +- 普通の利用者は `config set-token` のみで完結する。設定変更はファイル直接編集で、テンプレートのコメントが編集方法を説明する。 + +## 6. snapshot 仕様 + +- 形式: promidas の `SerializableSnapshot` (version / serializedAt / prototypes) をそのまま JSON 化。独自ラッパーは追加しない。 +- ファイル命名: `snapshot--<件数>.json` (例: `snapshot-20260715T100000Z-1234.json`)。ファイル名だけで一覧表示を構成でき、正式なメタ情報はファイル内の `serializedAt` を正とする。 +- 保存先の解決順序: `--snapshot-dir` オプション → 設定の `PPV_CLI_SNAPSHOT_DIR` → デフォルト `~/.ppv-cli/snapshots/`。 +- 「最新」の判定はファイル名タイムスタンプの降順。規則外のファイル名は一覧の末尾に回す。 +- 鮮度: ロードした snapshot がしきい値 (デフォルト 1 時間、`PPV_CLI_SNAPSHOT_STALE_HOURS` で変更) より古い場合、更新を促す警告を出す。自動 fetch はしない。鮮度はファイル名のタイムスタンプで判定し、同じ値を store の TTL にも使う。 +- 世代管理 (自動削除・prune) は行わない。 + +## 7. 設計原則 + +- レイヤ構成: `ppxxx/` (各バイナリ) → `ui/` (共有 Ink コンポーネント) → `core/` (共有の非 React ロジック) → promidas / promidas-utils → protopedia-api-v2-client。依存方向は一方通行で、ワンショット系 (ppv-cli) は `ui/` を import しない (Ink の閉じ込めをこの規則で保証する)。 +- バイナリ 1 つ = src 直下のエントリ 1 ファイル + 同名ディレクトリ 1 つ。bin 追加は package.json に 1 行足すだけ。 +- 出力規約: コマンドの結果 (JSON 等) は stdout、ログ・診断は stderr。終了コードは正常 0 / エラー 1 (スクリプト利用を考慮)。 +- ロガー: 解決順序は `--quiet` (error のみ) > `--verbose` (debug) > `PPV_CLI_LOG_LEVEL` > デフォルト info。ライブラリ層は通常 warn 以上に抑制する。UI 表示中は silent。 +- エラーハンドリング: promidas-utils/file-io の Result 型 (`FileIoError.kind`) を判定してユーザー向けメッセージに変換する。`SETUP_FAILED` は `toLocalizedMessage()` の日本語メッセージを表示する。エラー表現の網羅はエコシステムの責務とし、アプリ側で再実装しない。 +- repository 生成は `PromidasRepositoryBuilder` (factory 関数は使わない)。進捗表示は promidas のイベント + `FetchProgressEvent` を消費し、4xx/5xx のエラー本文の転送を成功と表示しない (`status` を確認する)。 +- 主要ライブラリ: promidas (repository / 型) / promidas-utils (token / file-io / エラー変換) / dotenv (parse のみ) / commander (ppv-cli) / ink (ppex / pptop)。 + +## 8. 非機能要件 + +- 既存 snapshot 利用時はネットワーク接続なしで全機能が動作すること (fetch を除く)。 +- snapshot のロードと検索は、数千件規模のデータで体感上ストレスのない速度で動作すること。 +- 文字化けなく日本語データを扱えること (UTF-8)。 +- エラー時は原因がわかるメッセージを日本語で表示すること (pptop の UI 文言のみ英語)。 +- JSON 出力はパイプ処理可能な形式で stdout に出すこと。 diff --git a/README.md b/README.md index 1a6a4b6..6c20e58 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,104 @@ # ppv26-cli -CLI tools for inspecting ProtoPedia API data offline, from local snapshots + +ProtoPedia Viewer CLI tools - inspect ProtoPedia API data offline, from local snapshots. + +## Overview + +Tools for developers who build applications with the [ProtoPedia API Ver 2.0](https://protopediav2.docs.apiary.io/): fetch all prototypes once into a local snapshot file, then search, filter, and inspect the exact data - field values, raw JSON, counts - offline, without hitting the API again. + +Built on [🧰 PROMIDAS](https://protopedia.net/prototype/7917), which normalizes parts of the API response to be easier to work with - for example, date-time values gain a timezone offset, and pipe-joined strings such as users and tags become arrays. What the tools display is this normalized data, not the verbatim response. + +Three tools share the same snapshot files, each trying a different UI style: + +- `ppv-cli` - one-shot, pipe-friendly UNIX-like CLI +- `ppex` - interactive explorer (Ink; ProtoPedia EXplorer) +- `pptop` - top(1)-style snapshot monitor (Ink) + +## About ProtoPedia + +[ProtoPedia](https://protopedia.net/) (プロトペディア) とは、ITものづくりに関する作品を記録・公開できるWEBサービス。ソフト、ハード、XR、アート、ロボット作品など、様々なジャンルの作品の登録があり、アイデアの宝庫です。作品を登録していくと、沢山の人に見られ、フィードバックがもらえます。時々コンテストも開催しており、個人開発者のポートフォリオとしても活用されています。 + +ProtoPediaでは、作品一覧の取得、開発資料の管理、作品とイベントの紐付けなど、プログラムによる創作活動の支援を行う [ProtoPedia API Ver 2.0](https://protopediav2.docs.apiary.io/) も提供しています。 + +[ProtoPedia](https://protopedia.net/) (プロトペディア) is a web service for recording and publishing IT maker works - software, hardware, XR, art, robots, and more. It is a treasure trove of ideas: registered works reach a wide audience and gather feedback, contests are held from time to time, and many individual developers use it as their portfolio. The site's motto: つくる、たのしむ、ひろがる. + +ProtoPedia also offers the [ProtoPedia API Ver 2.0](https://protopediav2.docs.apiary.io/), which supports creative activity programmatically - retrieving lists of works, managing development materials, and associating works with events. + +## Requirements + +- Node.js >= 22 +- ProtoPedia API Ver 2.0 access token (only needed for `snapshot create`; see ) + +## Quick start + +Install the commands, set your token, and create the first snapshot. After that, everything works offline. + +```console +# 1. Install ppv-cli / ppex / pptop onto your PATH. +npm install -g ppv26-cli + +# 2. Set the ProtoPedia API token (interactive; saved to ~/.ppv-cli/config). +ppv-cli config set-token + +# 3. Fetch all prototypes once and save them as a local snapshot. +ppv-cli snapshot create +``` + +Now the data is ready - explore it any way you like: + +```console +ppv-cli prototype list # one-shot, pipe-friendly +ppex # interactive explorer +pptop # top(1)-style monitor +``` + +> A token is only required for `snapshot create`. Starting `pptop` / `ppex` before a token and a snapshot exist just shows a setup hint, so run steps 2-3 first. + +Uninstall with `npm uninstall -g ppv26-cli`. + +## The tools + +### ppv-cli - one-shot CLI + +A pipe-friendly, UNIX-like CLI: one command per invocation, results on stdout (JSON or tab-separated), logs on stderr. Run `ppv-cli --help` for the command list. + +```console +ppv-cli prototype search --tag M5Stack # facet search +ppv-cli prototype show 1234 # one work as JSON +``` + +### ppex - interactive explorer (Japanese UI) + +An Ink-based explorer: pick a snapshot, then filter works live via 作品探索 and inspect each one down to its raw JSON. Key hints are shown on screen. Requires a TTY. + +```console +ppex # installed via npm +node dist/ppex.js # from a source clone +``` + +### pptop - snapshot monitor (English UI) + +A top(1)-style monitor: auto-loads the latest snapshot and shows an aggregate header above a sortable, filterable table. Press `?` for the built-in help. Requires a TTY. + +```console +pptop # installed via npm +node dist/pptop.js # from a source clone +``` + +## Configuration + +The settings live in a single file under your home directory: `~/.ppv-cli/config` on macOS / Linux, `%USERPROFILE%\.ppv-cli\config` on Windows. + +This file (KEY=VALUE format) is the single source of truth for every setting, the API token included - no .env files and no environment-variable overrides. One-shot overrides are the CLI options (`--snapshot`, `--snapshot-dir`, `--verbose` / `--quiet`). Keys: + +| Key | Description | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------ | +| `PROTOPEDIA_API_V2_TOKEN` | ProtoPedia API Ver 2.0 token (required for `snapshot create`) | +| `PPV_CLI_LOG_LEVEL` | Default log level: debug / info / warn / error / silent. CLI flags (`--verbose` / `--quiet`) take precedence | +| `PPV_CLI_SNAPSHOT_DIR` | Snapshot directory (default: `~/.ppv-cli/snapshots`). The `--snapshot-dir` option takes precedence | +| `PPV_CLI_SNAPSHOT_STALE_HOURS` | Hours after which a snapshot is considered stale (default: 1). Also used as the in-memory store TTL | + +## Documents + +- [PRD.md](PRD.md) - requirements and design summary (Japanese) +- [DEVELOPMENT.md](DEVELOPMENT.md) - building and running from source, development notes, and dependencies management policy diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..27a0f8e --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,66 @@ +import js from '@eslint/js'; +import eslintConfigPrettier from 'eslint-config-prettier'; +import importX from 'eslint-plugin-import-x'; +import unusedImports from 'eslint-plugin-unused-imports'; +import { defineConfig, globalIgnores } from 'eslint/config'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; + +export default defineConfig([ + globalIgnores(['dist', 'coverage', 'snapshots']), + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ['**/*.ts', '**/*.tsx'], + plugins: { + 'import-x': importX, + 'unused-imports': unusedImports, + }, + languageOptions: { + globals: globals.node, + }, + rules: { + '@typescript-eslint/no-unused-vars': 'off', + 'unused-imports/no-unused-imports': 'error', + 'unused-imports/no-unused-vars': [ + 'warn', + { + vars: 'all', + varsIgnorePattern: '^_', + args: 'after-used', + argsIgnorePattern: '^_', + ignoreRestSiblings: true, + }, + ], + 'import-x/order': [ + 'error', + { + groups: [ + 'builtin', + 'external', + 'internal', + ['parent', 'sibling'], + 'index', + 'object', + 'type', + ], + pathGroupsExcludedImportTypes: ['builtin'], + 'newlines-between': 'always', + alphabetize: { order: 'asc', caseInsensitive: true }, + }, + ], + 'import-x/newline-after-import': 'error', + 'import-x/no-duplicates': 'error', + 'import-x/first': 'error', + }, + }, + { + // Node build/tooling scripts (ESM): give them the Node globals so + // URL, process, and friends are recognized. + files: ['**/*.mjs'], + languageOptions: { + globals: globals.node, + }, + }, + eslintConfigPrettier, +]); diff --git a/package.json b/package.json new file mode 100644 index 0000000..2803bca --- /dev/null +++ b/package.json @@ -0,0 +1,76 @@ +{ + "name": "ppv26-cli", + "version": "0.1.0", + "description": "CLI tools for inspecting ProtoPedia API data offline, from local snapshots", + "engines": { + "node": ">=22" + }, + "type": "module", + "private": false, + "keywords": [ + "CLI", + "ProtoPedia", + "PROMIDAS" + ], + "author": "F88 <685250+F88@users.noreply.github.com>", + "license": "MIT", + "homepage": "https://github.com/F88/ppv26-cli#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/F88/ppv26-cli.git" + }, + "bugs": { + "url": "https://github.com/F88/ppv26-cli/issues" + }, + "bin": { + "ppv-cli": "./dist/ppv-cli.js", + "ppex": "./dist/ppex.js", + "pptop": "./dist/pptop.js" + }, + "files": [ + "dist" + ], + "scripts": { + "ppv-cli": "tsx src/ppv-cli.ts", + "build": "npm run build:tsc && npm run build:bundle", + "build:tsc": "tsc --project tsconfig.build.json", + "build:bundle": "node scripts/build-ink.mjs", + "format": "prettier --write .", + "format:check": "prettier --check .", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"", + "prepare": "npm run build", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "prepublishOnly": "node -e \"console.error('This package is private and must not be published.'); process.exit(1)\"" + }, + "dependencies": { + "commander": "^15.0.0", + "dotenv": "^17.4.2", + "ink": "^7.1.1", + "promidas": "^3.1.0", + "promidas-utils": "^3.2.1", + "protopedia-api-v2-client": "^3.0.0", + "react": "^19.2.8", + "wrap-ansi": "^10.0.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^24.0.0", + "@types/react": "^19.2.17", + "esbuild": "^0.28.1", + "eslint": "^10.7.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-import-x": "^4.17.1", + "eslint-plugin-unused-imports": "^4.4.1", + "globals": "^17.7.0", + "ink-testing-library": "^4.0.0", + "prettier": "^3.9.6", + "tsx": "^4.23.1", + "typescript": "^5.9.0", + "typescript-eslint": "^8.65.0", + "vitest": "^4.1.10" + } +} diff --git a/prettier.config.mjs b/prettier.config.mjs new file mode 100644 index 0000000..a1f237e --- /dev/null +++ b/prettier.config.mjs @@ -0,0 +1,23 @@ +/** + * @see https://prettier.io/docs/en/configuration.html + * @type {import('prettier').Config} + */ +const config = { + printWidth: 80, + tabWidth: 2, + useTabs: false, + semi: true, + singleQuote: true, + trailingComma: 'all', + overrides: [ + { + files: '*.md', + options: { + tabWidth: 4, + useTabs: false, + }, + }, + ], +}; + +export default config; diff --git a/scripts/build-ink.mjs b/scripts/build-ink.mjs new file mode 100644 index 0000000..b96bba1 --- /dev/null +++ b/scripts/build-ink.mjs @@ -0,0 +1,70 @@ +/** + * Bundle the Ink binaries (pptop, ppex) with NODE_ENV baked to + * 'production' so React's production build is selected at build time. + * + * This dead-code-eliminates the dev build's Component Performance Track + * (which serializes changed props into performance.measure entries that + * Node's timeline retains indefinitely) - the cause of the sort/search + * OOM (issue #18). It also yields a self-contained, fast-starting file. + * ppv-cli (no React) is left on tsc. + */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +import * as esbuild from 'esbuild'; + +// Single source of truth for the version; baked in so the bundle does +// not read package.json at runtime (import.meta.url points at the +// bundle, not src/core/, once everything is collapsed into one file). +const { version } = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), +); + +await esbuild.build({ + // Anchor every relative path (entryPoints, outdir) to the repo root + // derived from this script's own location, not process.cwd(), so the + // build is identical no matter which directory it is invoked from and + // stays consistent with the script-relative package.json read above. + absWorkingDir: fileURLToPath(new URL('..', import.meta.url)), + entryPoints: ['src/pptop.tsx', 'src/ppex.tsx'], + outdir: 'dist', + bundle: true, + platform: 'node', + format: 'esm', + target: 'node22', + // No minify: this is a locally-run Node CLI, so bundle size is + // irrelevant, and unminified output keeps stack traces in crash + // reports readable (identifiers and structure survive) without + // shipping a source map. The dev-React elimination that fixes the + // OOM comes from the NODE_ENV define + DCE below, not from minify. + // Ink imports react-devtools-core only on the DEV devtools path + // (never in production); alias the uninstalled dep to a no-op stub so + // the import resolves without an unresolved bare specifier at runtime. + alias: { + // fileURLToPath (not URL.pathname): decodes percent-encoding and + // yields a platform-correct path, so a repo path with spaces / + // non-ASCII, or a Windows drive path, still resolves. + 'react-devtools-core': fileURLToPath( + new URL('./stub-react-devtools-core.mjs', import.meta.url), + ), + }, + // The whole point: pick React's production build at build time. + // Also bake the tool version (see src/core/version.ts). + define: { + 'process.env.NODE_ENV': '"production"', + __PPCLI_VERSION__: JSON.stringify(version), + }, + // Bundled CJS deps may reference a CommonJS require / __dirname at + // runtime; provide them in the ESM output. + banner: { + js: [ + "import { createRequire as __createRequire } from 'node:module';", + "import { fileURLToPath as __fileURLToPath } from 'node:url';", + "import { dirname as __pathDirname } from 'node:path';", + 'const require = __createRequire(import.meta.url);', + 'const __filename = __fileURLToPath(import.meta.url);', + 'const __dirname = __pathDirname(__filename);', + ].join('\n'), + }, + logLevel: 'info', +}); diff --git a/scripts/stub-react-devtools-core.mjs b/scripts/stub-react-devtools-core.mjs new file mode 100644 index 0000000..399d114 --- /dev/null +++ b/scripts/stub-react-devtools-core.mjs @@ -0,0 +1,13 @@ +// Stub for react-devtools-core: Ink only imports it on the DEV devtools +// path (process.env.DEV === 'true'), which is additionally gated by +// import.meta.resolve('react-devtools-core') succeeding - so the bundle +// never actually reaches this in practice. Aliased here so the import +// resolves without pulling in the (uninstalled, heavy) real package. +// +// Mirror the methods Ink calls (devtools.initialize() then +// connectToDevTools(), in that order) as no-ops, so that even if the +// DEV path were ever reached the stub stays inert instead of throwing. +export default { + initialize() {}, + connectToDevTools() {}, +}; diff --git a/src/core/clipboard.ts b/src/core/clipboard.ts new file mode 100644 index 0000000..a8222dc --- /dev/null +++ b/src/core/clipboard.ts @@ -0,0 +1,32 @@ +/** + * System clipboard access through the platform utility, so copied + * text is byte-exact regardless of terminal wrapping. + */ +import { spawn } from 'node:child_process'; + +/** + * Copies text to the system clipboard (pbcopy on macOS, clip on + * Windows). Resolves false when no utility is available for the + * platform or the utility exits with a failure. + */ +export function copyToClipboard(text: string): Promise { + const command = + process.platform === 'darwin' + ? 'pbcopy' + : process.platform === 'win32' + ? 'clip' + : null; + if (command === null) { + return Promise.resolve(false); + } + return new Promise((resolve) => { + const child = spawn(command, { stdio: ['pipe', 'ignore', 'ignore'] }); + child.on('error', () => resolve(false)); + child.on('close', (code) => resolve(code === 0)); + child.stdin.on('error', () => { + // Spawn failures also surface here; 'error'/'close' resolve. + }); + child.stdin.write(text); + child.stdin.end(); + }); +} diff --git a/src/core/config-entries.test.ts b/src/core/config-entries.test.ts new file mode 100644 index 0000000..a227df0 --- /dev/null +++ b/src/core/config-entries.test.ts @@ -0,0 +1,118 @@ +import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + configEntries, + initConfigEntries, + resetConfigEntriesForTests, + SNAPSHOT_DIR_KEY, +} from './config-entries.js'; +import { resolveSnapshotDir } from './snapshot-catalog.js'; +import { resolveToken } from './token.js'; + +const TOKEN_KEY = 'PROTOPEDIA_API_V2_TOKEN'; + +let home: string; +let savedEnv: Record; + +beforeEach(async () => { + home = await mkdtemp(path.join(tmpdir(), 'ppv-cli-home-')); + savedEnv = { + HOME: process.env.HOME, + USERPROFILE: process.env.USERPROFILE, + [TOKEN_KEY]: process.env[TOKEN_KEY], + [SNAPSHOT_DIR_KEY]: process.env[SNAPSHOT_DIR_KEY], + }; + process.env.HOME = home; // POSIX + process.env.USERPROFILE = home; // Windows + delete process.env[TOKEN_KEY]; + delete process.env[SNAPSHOT_DIR_KEY]; + resetConfigEntriesForTests(); +}); + +afterEach(async () => { + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + resetConfigEntriesForTests(); + await rm(home, { recursive: true, force: true }); +}); + +async function writeGlobalConfig(content: string): Promise { + await mkdir(path.join(home, '.ppv-cli'), { recursive: true }); + await writeFile(path.join(home, '.ppv-cli', 'config'), content, 'utf8'); +} + +describe('initConfigEntries (single source: ~/.ppv-cli/config)', () => { + it('reads values from the config file', async () => { + await writeGlobalConfig(`${TOKEN_KEY}=tok\n${SNAPSHOT_DIR_KEY}=/data\n`); + initConfigEntries(); + expect(configEntries().token).toBe('tok'); + expect(configEntries().snapshotDir).toBe('/data'); + expect(configEntries().fileStatus).toBe('read'); + }); + + it('runs on defaults when the file is missing', () => { + initConfigEntries(); + expect(configEntries().token).toBeNull(); + expect(configEntries().fileStatus).toBe('missing'); + }); + + it.skipIf(process.platform === 'win32')( + 'reports an existing but unreadable file as unreadable', + async () => { + await writeGlobalConfig(`${TOKEN_KEY}=tok\n`); + const file = path.join(home, '.ppv-cli', 'config'); + await chmod(file, 0o000); + initConfigEntries(); + await chmod(file, 0o600); + expect(configEntries().fileStatus).toBe('unreadable'); + // Unknown, not unset - but the raw values are null either way. + expect(configEntries().token).toBeNull(); + }, + ); + + it('never writes anything into process.env', async () => { + await writeGlobalConfig( + `DATABASE_URL=postgres://secret\n${TOKEN_KEY}=tok\n`, + ); + initConfigEntries(); + expect(process.env.DATABASE_URL).toBeUndefined(); + expect(process.env[TOKEN_KEY]).toBeUndefined(); + }); + + it('normalizes the template placeholder token to null', async () => { + await writeGlobalConfig(`${TOKEN_KEY}=your-token-here\n`); + initConfigEntries(); + expect(configEntries().token).toBeNull(); + }); +}); + +describe('the config file is the single source of truth', () => { + it('resolveSnapshotDir ignores the environment variable', () => { + process.env[SNAPSHOT_DIR_KEY] = '/from-env'; + initConfigEntries(); + expect(resolveSnapshotDir()).toBe( + path.resolve(path.join(home, '.ppv-cli', 'snapshots')), + ); + }); + + it('resolveToken ignores the environment variable', async () => { + await writeGlobalConfig(`${TOKEN_KEY}=from-file\n`); + process.env[TOKEN_KEY] = 'from-env'; + initConfigEntries(); + expect(resolveToken()).toBe('from-file'); + }); + + it('resolveToken returns null when nothing is configured', () => { + initConfigEntries(); + expect(resolveToken()).toBeNull(); + }); +}); diff --git a/src/core/config-entries.ts b/src/core/config-entries.ts new file mode 100644 index 0000000..d4fb4b5 --- /dev/null +++ b/src/core/config-entries.ts @@ -0,0 +1,91 @@ +/** + * Application configuration (issue #7). + * + * The global config file (path: user-dirs.ts) is the single source + * of truth - for every value, + * the token included. No .env files, no --config option, no + * environment-variable overrides (all considered and rejected on + * 2026-07-16: one well-known file keeps the behavior predictable + * and closes every injection route). One-shot overrides are the + * existing CLI options (--snapshot-dir, --verbose / --quiet). + * + * dotenv is used as a pure parser and NOTHING is written into + * process.env. + */ +import { readFileSync } from 'node:fs'; + +import { parse as parseDotenv } from 'dotenv'; +import { TOKEN_KEYS } from 'promidas-utils/token'; + +import { TOKEN_PLACEHOLDER } from './config-file.js'; +import { globalConfigPath } from './user-dirs.js'; + +export const SNAPSHOT_DIR_KEY = 'PPV_CLI_SNAPSHOT_DIR'; +export const LOG_LEVEL_KEY = 'PPV_CLI_LOG_LEVEL'; +export const STALE_HOURS_KEY = 'PPV_CLI_SNAPSHOT_STALE_HOURS'; + +/** + * How reading the config file went. 'missing' is the normal + * first-run state; 'unreadable' (the file exists but could not be + * read, e.g. a permission problem) is an environment error - the + * entries are unknown then, not unset. + */ +export type ConfigFileStatus = 'read' | 'missing' | 'unreadable'; + +export type ConfigEntries = { + readonly token: string | null; + readonly snapshotDir: string | null; + readonly logLevel: string | null; + readonly staleHours: string | null; + /** How reading the file went. */ + readonly fileStatus: ConfigFileStatus; +}; + +let current: ConfigEntries | null = null; + +/** + * Loads the global config file and installs the result as the + * process-wide + * config. A missing file is the normal first-run state. + */ +export function initConfigEntries(): ConfigEntries { + let parsed: Record = {}; + let fileStatus: ConfigFileStatus = 'read'; + try { + parsed = parseDotenv(readFileSync(globalConfigPath(), 'utf8')); + } catch (error) { + // Missing file (the normal first-run state) runs on built-in + // defaults. Anything else means the file exists but could not + // be read - never treat that as "unset" (the entries are + // unknown); commands that consume settings stop on it. + fileStatus = + (error as NodeJS.ErrnoException).code === 'ENOENT' + ? 'missing' + : 'unreadable'; + } + + // The template's placeholder means "not set yet" - normalize it + // here so no other layer has to know the magic value. + const rawToken = parsed[TOKEN_KEYS.PROTOPEDIA_API_V2_TOKEN] ?? null; + current = { + token: rawToken === TOKEN_PLACEHOLDER ? null : rawToken, + snapshotDir: parsed[SNAPSHOT_DIR_KEY] ?? null, + logLevel: parsed[LOG_LEVEL_KEY] ?? null, + staleHours: parsed[STALE_HOURS_KEY] ?? null, + fileStatus, + }; + return current; +} + +/** + * Returns the process-wide config, loading it lazily when + * initConfigEntries() has not been called. + */ +export function configEntries(): ConfigEntries { + return current ?? initConfigEntries(); +} + +/** Clears the process-wide config (test isolation only). */ +export function resetConfigEntriesForTests(): void { + current = null; +} diff --git a/src/core/config-file.test.ts b/src/core/config-file.test.ts new file mode 100644 index 0000000..97a6493 --- /dev/null +++ b/src/core/config-file.test.ts @@ -0,0 +1,122 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { parse as parseDotenv } from 'dotenv'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + createGlobalConfig, + globalConfigExists, + updateGlobalConfig, + upsertConfigEntry, +} from './config-file.js'; + +const KEY = 'PROTOPEDIA_API_V2_TOKEN'; + +describe('upsertConfigEntry', () => { + it('appends to empty content', () => { + expect(upsertConfigEntry('', KEY, 'abc')).toBe(`${KEY}=abc\n`); + }); + + it('appends after existing lines, keeping one trailing newline', () => { + expect(upsertConfigEntry('PPV_CLI_LOG_LEVEL=debug\n', KEY, 'abc')).toBe( + `PPV_CLI_LOG_LEVEL=debug\n${KEY}=abc\n`, + ); + expect(upsertConfigEntry('PPV_CLI_LOG_LEVEL=debug', KEY, 'abc')).toBe( + `PPV_CLI_LOG_LEVEL=debug\n${KEY}=abc\n`, + ); + }); + + it('replaces in place, preserving comments and other keys', () => { + const before = `# comment\n${KEY}=old\nPPV_CLI_SNAPSHOT_DIR=/data\n`; + const after = upsertConfigEntry(before, KEY, 'new'); + expect(after).toBe(`# comment\n${KEY}=new\nPPV_CLI_SNAPSHOT_DIR=/data\n`); + }); + + it('does not touch commented-out template lines of the key', () => { + const before = `# ${KEY}=example\nPPV_CLI_LOG_LEVEL=info\n`; + const after = upsertConfigEntry(before, KEY, 'real'); + expect(after).toContain(`# ${KEY}=example`); + expect(after).toContain(`${KEY}=real`); + }); + + it('collapses duplicate key lines into one (dotenv: last wins)', () => { + // Pin the dotenv semantics this guarantee protects against: with + // duplicates, parse() returns the LAST occurrence, so replacing + // only the first line would leave the old value effective. + expect(parseDotenv(`${KEY}=a\n${KEY}=b\n`)[KEY]).toBe('b'); + + const after = upsertConfigEntry( + `${KEY}=a\n${KEY}=b\nOTHER=x\n`, + KEY, + 'new', + ); + expect(after).toBe(`${KEY}=new\nOTHER=x\n`); + expect(parseDotenv(after)[KEY]).toBe('new'); + }); + + it('normalizes CRLF input without corrupting lines', () => { + const before = `# note\r\n${KEY}=old\r\nOTHER=x\r\n`; + const after = upsertConfigEntry(before, KEY, 'new'); + expect(after).toBe(`# note\n${KEY}=new\nOTHER=x\n`); + expect(parseDotenv(after)[KEY]).toBe('new'); + }); + + it('always ends the file with exactly one newline', () => { + expect(upsertConfigEntry(`${KEY}=old`, KEY, 'new')).toBe(`${KEY}=new\n`); + expect(upsertConfigEntry(`${KEY}=old\n`, KEY, 'new')).toBe(`${KEY}=new\n`); + }); +}); + +describe('createGlobalConfig / updateGlobalConfig', () => { + let home: string; + let savedEnv: Record; + + beforeEach(async () => { + home = await mkdtemp(path.join(tmpdir(), 'ppv-cli-configfile-')); + savedEnv = { + HOME: process.env.HOME, + USERPROFILE: process.env.USERPROFILE, + }; + process.env.HOME = home; + process.env.USERPROFILE = home; + }); + + afterEach(async () => { + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + await rm(home, { recursive: true, force: true }); + }); + + const configPath = () => path.join(home, '.ppv-cli', 'config'); + + it('createGlobalConfig writes the template', async () => { + expect(await globalConfigExists()).toBe(false); + const written = await createGlobalConfig(); + expect(written).toBe(configPath()); + expect(await globalConfigExists()).toBe(true); + expect(await readFile(configPath(), 'utf8')).toContain( + `${KEY}=your-token-here`, + ); + }); + + it('updateGlobalConfig rewrites an existing file', async () => { + await mkdir(path.dirname(configPath()), { recursive: true }); + await writeFile(configPath(), `${KEY}=old\n`, 'utf8'); + await updateGlobalConfig((content) => + upsertConfigEntry(content, KEY, 'new'), + ); + expect(await readFile(configPath(), 'utf8')).toBe(`${KEY}=new\n`); + }); + + it('updateGlobalConfig never creates a missing file', async () => { + await expect(updateGlobalConfig((content) => content)).rejects.toThrow(); + expect(await globalConfigExists()).toBe(false); + }); +}); diff --git a/src/core/config-file.ts b/src/core/config-file.ts new file mode 100644 index 0000000..37595ef --- /dev/null +++ b/src/core/config-file.ts @@ -0,0 +1,153 @@ +/** + * Creation and read-modify-write helpers for the global user config + * (dotenv format; the path is defined in user-dirs.ts), shared by + * the config + * subcommands (issue #7). + */ +import { access, chmod, mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +import { PPV_CLI_TOOL_NAME } from './constants.js'; +import { globalConfigPath } from './user-dirs.js'; + +/** + * Placeholder token value shipped in the template below. It means + * "not set yet": initConfigEntries() normalizes it to null, so the rest + * of the app (config show, API calls) sees an unset token instead of + * mistaking the placeholder for a real one. + */ +export const TOKEN_PLACEHOLDER = 'your-token-here'; + +/** + * Initial content of the config file: every key documented as a + * comment, so "edit the file directly" means reading the explanation + * and uncommenting a line - approachable without prior knowledge. + * Textually similar to config init's .env.example on purpose, but + * kept separate: the hints differ (e.g. ./snapshots only makes sense + * relative to a repository, not in a global file). + */ +export const GLOBAL_CONFIG_TEMPLATE = `# ppv-cli global configuration +# Edit values directly (KEY=VALUE); lines starting with # are comments. + +# ProtoPedia API v2 access token (Bearer Token) +# See: https://protopediav2.docs.apiary.io/ +# Usually set via: ${PPV_CLI_TOOL_NAME} config set-token +PROTOPEDIA_API_V2_TOKEN=${TOKEN_PLACEHOLDER} + +# Optional: default log level (debug | info | warn | error | silent). +# CLI flags (--verbose / --quiet) take precedence. +# PPV_CLI_LOG_LEVEL=info + +# Optional: hours after which a snapshot is considered stale +# (default: 1). Positive number; decimals allowed. +# PPV_CLI_SNAPSHOT_STALE_HOURS=1 + +# Optional: snapshot directory. +# Default: the "snapshots" folder next to this file. +# To change it, write a FULL absolute path. The value is used +# as-is: "~", "$HOME" and "%USERPROFILE%" are NOT expanded and +# would be taken literally. +# Example (macOS): PPV_CLI_SNAPSHOT_DIR=/Users/you/pp-snapshots +# Example (Windows): PPV_CLI_SNAPSHOT_DIR=C:\\Users\\you\\pp-snapshots +# The --snapshot-dir CLI option takes precedence. +# PPV_CLI_SNAPSHOT_DIR= +`; + +/** True when the global config file exists. */ +export async function globalConfigExists(): Promise { + try { + await access(globalConfigPath()); + return true; + } catch { + return false; + } +} + +/** + * Creates (or overwrites) the global config file from the documented + * template, creating the directory (700) as needed, and returns the + * config path. Creation is config init's job; config set-token runs + * it only via init's creation step when the file does not exist yet. + * The 600 permission is a no-op on Windows (NTFS ACLs; the user + * profile is owner-only by default). + */ +export async function createGlobalConfig(): Promise { + const configPath = globalConfigPath(); + await mkdir(path.dirname(configPath), { recursive: true, mode: 0o700 }); + await writeFile(configPath, GLOBAL_CONFIG_TEMPLATE, { + encoding: 'utf8', + mode: 0o600, + }); + await chmod(configPath, 0o600); + return configPath; +} + +/** + * Applies a content transformation to the EXISTING config file + * and returns the config path. Never creates the file: when it is + * missing or unreadable this throws, so a permission problem can not + * silently degrade into "replace the user's config with the + * template". writeFile's mode only applies on creation, hence the + * explicit chmod to repair a previously loose permission. + */ +export async function updateGlobalConfig( + mutate: (content: string) => string, +): Promise { + const configPath = globalConfigPath(); + const content = await readFile(configPath, 'utf8'); + await writeFile(configPath, mutate(content), { + encoding: 'utf8', + mode: 0o600, + }); + await chmod(configPath, 0o600); + return configPath; +} + +/** + * Sets or replaces KEY=value in dotenv-format content. + * + * Guarantees: + * - Exactly ONE line for the key remains. Duplicate lines are + * collapsed: dotenv's parse() lets the LAST occurrence win, so + * rewriting only the first (as a naive regex replace would) leaves + * the old value silently effective. + * - Every other line - comments (# ...), other keys, blanks - is + * preserved verbatim; the new value takes the position of the + * first existing occurrence, or is appended. + * - CRLF input (a Windows editor may save it) is normalized to LF, + * and the result always ends with a newline. + */ +export function upsertConfigEntry( + content: string, + key: string, + value: string, +): string { + const line = `${key}=${value}`; + const keyLine = new RegExp(`^\\s*${key}\\s*=`); + const lines = content.split(/\r?\n/); + const out: string[] = []; + let done = false; + for (const current of lines) { + if (keyLine.test(current)) { + if (!done) { + out.push(line); + done = true; + } + continue; // Drop duplicates of the key. + } + out.push(current); + } + if (!done) { + // Append. When the content ended with \n, split() leaves a final + // '' element - insert before it. + if (out.length > 0 && out[out.length - 1] === '') { + out[out.length - 1] = line; + } else { + out.push(line); + } + } + if (out[out.length - 1] !== '') { + out.push(''); // Ensure the file ends with a newline. + } + return out.join('\n'); +} diff --git a/src/core/config-validation.test.ts b/src/core/config-validation.test.ts new file mode 100644 index 0000000..a67ac95 --- /dev/null +++ b/src/core/config-validation.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; + +import { LOG_LEVEL_KEY, STALE_HOURS_KEY } from './config-entries.js'; +import { + configGateErrors, + isValidLogLevel, + isValidStaleHours, + validateConfigEntries, +} from './config-validation.js'; +import { globalConfigPath } from './user-dirs.js'; + +import type { ConfigEntries } from './config-entries.js'; + +/** Builds ConfigEntries with everything unset except the overrides. */ +function entries(overrides: Partial): ConfigEntries { + return { + token: null, + snapshotDir: null, + logLevel: null, + staleHours: null, + fileStatus: 'read', + ...overrides, + }; +} + +describe('isValidLogLevel', () => { + it('accepts exactly the five levels', () => { + for (const level of ['debug', 'info', 'warn', 'error', 'silent']) { + expect(isValidLogLevel(level)).toBe(true); + } + }); + + it('rejects everything else, the empty string included', () => { + for (const invalid of ['loud', 'INFO', 'info ', '']) { + expect(isValidLogLevel(invalid)).toBe(false); + } + }); +}); + +describe('isValidStaleHours', () => { + it('accepts positive numbers, decimals included', () => { + for (const valid of ['6', '0.5', '12', '1.25']) { + expect(isValidStaleHours(valid)).toBe(true); + } + }); + + it('rejects everything else, the empty string included', () => { + for (const invalid of ['abc', '0', '-3', '1O', '1h', '']) { + expect(isValidStaleHours(invalid)).toBe(false); + } + }); +}); + +describe('validateConfigEntries', () => { + it('reports nothing when the checked keys are unset', () => { + expect(validateConfigEntries(entries({}))).toEqual([]); + }); + + it('reports nothing for valid values', () => { + expect( + validateConfigEntries(entries({ logLevel: 'warn', staleHours: '0.5' })), + ).toEqual([]); + }); + + it('reports an invalid log level', () => { + const errors = validateConfigEntries(entries({ logLevel: 'hoge' })); + expect(errors).toHaveLength(1); + expect(errors[0]?.key).toBe(LOG_LEVEL_KEY); + expect(errors[0]?.value).toBe('hoge'); + }); + + it('reports an invalid stale-hours value', () => { + const errors = validateConfigEntries(entries({ staleHours: '6aasfas' })); + expect(errors).toHaveLength(1); + expect(errors[0]?.key).toBe(STALE_HOURS_KEY); + expect(errors[0]?.value).toBe('6aasfas'); + }); + + it('reports both keys when both are invalid', () => { + const errors = validateConfigEntries( + entries({ logLevel: '', staleHours: '0' }), + ); + expect(errors.map((error) => error.key)).toEqual([ + LOG_LEVEL_KEY, + STALE_HOURS_KEY, + ]); + }); + + it('does not touch the unchecked keys (dir and token)', () => { + expect( + validateConfigEntries( + entries({ token: '', snapshotDir: '' }), // empty, but not gated + ), + ).toEqual([]); + }); +}); + +describe('configGateErrors', () => { + it('returns null when the entries pass', () => { + expect(configGateErrors(entries({ logLevel: 'warn' }), 'ja')).toBeNull(); + expect(configGateErrors(entries({}), 'en')).toBeNull(); + }); + + it('reports an unreadable file in both languages', () => { + const broken = entries({ fileStatus: 'unreadable' }); + expect(configGateErrors(broken, 'ja')).toEqual([ + `設定ファイルを読み込めませんでした: ${globalConfigPath()}`, + 'ファイルの権限を確認してください。', + ]); + expect(configGateErrors(broken, 'en')).toEqual([ + `Could not read the config file: ${globalConfigPath()}`, + 'Check the file permissions.', + ]); + }); + + it('reports invalid values in both languages, fix line last', () => { + const broken = entries({ logLevel: 'hoge', staleHours: 'abc' }); + expect(configGateErrors(broken, 'ja')).toEqual([ + `設定ファイルの ${LOG_LEVEL_KEY} の値が不正です: "hoge" (debug | info | warn | error | silent)`, + `設定ファイルの ${STALE_HOURS_KEY} の値が不正です: "abc" (正の数値、小数可)`, + `設定ファイルを修正してください: ${globalConfigPath()}`, + ]); + expect(configGateErrors(broken, 'en')).toEqual([ + `Invalid ${LOG_LEVEL_KEY} in the config file: "hoge" (debug | info | warn | error | silent)`, + `Invalid ${STALE_HOURS_KEY} in the config file: "abc" (positive number)`, + `Fix the config file: ${globalConfigPath()}`, + ]); + }); +}); diff --git a/src/core/config-validation.ts b/src/core/config-validation.ts new file mode 100644 index 0000000..1089366 --- /dev/null +++ b/src/core/config-validation.ts @@ -0,0 +1,113 @@ +/** + * Startup validation of the config entries (invalid -> + * error, never fall back). + * + * Only the two keys whose validity is INTRINSIC to the value are + * checked here: PPV_CLI_LOG_LEVEL (closed enum) and + * PPV_CLI_SNAPSHOT_STALE_HOURS (positive number). Their truth does + * not depend on when or whether the value is used, so a broken + * entry stops every settings-consuming command until fixed. + * + * PPV_CLI_SNAPSHOT_DIR and the token are NOT checked: their validity + * lives outside the value (the filesystem at use time / the + * ProtoPedia API), so errors surface where they can actually be + * judged - at use. + */ +import { LOG_LEVEL_KEY, STALE_HOURS_KEY } from './config-entries.js'; +import { globalConfigPath } from './user-dirs.js'; + +import type { ConfigEntries } from './config-entries.js'; +import type { LogLevel } from 'promidas/logger'; + +export const VALID_LOG_LEVELS: readonly string[] = [ + 'debug', + 'info', + 'warn', + 'error', + 'silent', +]; + +/** True when the value is one of the five log levels. */ +export function isValidLogLevel(value: string): value is LogLevel { + return VALID_LOG_LEVELS.includes(value); +} + +/** True when the value is a positive number (decimals allowed). */ +export function isValidStaleHours(value: string): boolean { + return /^\d+(\.\d+)?$/.test(value) && Number(value) > 0; +} + +/** One invalid entry, with per-language hints for the error line. */ +export type ConfigEntryError = { + readonly key: string; + readonly value: string; + readonly hintJa: string; + readonly hintEn: string; +}; + +/** + * Returns the invalid entries among the intrinsically checkable + * keys. Unset keys (null) are not errors - absence is normal. + */ +export function validateConfigEntries( + entries: ConfigEntries, +): ConfigEntryError[] { + const errors: ConfigEntryError[] = []; + if (entries.logLevel !== null && !isValidLogLevel(entries.logLevel)) { + errors.push({ + key: LOG_LEVEL_KEY, + value: entries.logLevel, + hintJa: 'debug | info | warn | error | silent', + hintEn: 'debug | info | warn | error | silent', + }); + } + if (entries.staleHours !== null && !isValidStaleHours(entries.staleHours)) { + errors.push({ + key: STALE_HOURS_KEY, + value: entries.staleHours, + hintJa: '正の数値、小数可', + hintEn: 'positive number', + }); + } + return errors; +} + +/** + * Builds the startup-gate error lines for a broken config file - + * unreadable, or a value outside its prescribed set - + * or returns null when the entries pass. The wording of BOTH + * languages lives here and nowhere else; the binaries only choose + * the language, the output channel and how to exit. + */ +export function configGateErrors( + entries: ConfigEntries, + lang: 'ja' | 'en', +): string[] | null { + const configPath = globalConfigPath(); + if (entries.fileStatus === 'unreadable') { + return lang === 'ja' + ? [ + `設定ファイルを読み込めませんでした: ${configPath}`, + 'ファイルの権限を確認してください。', + ] + : [ + `Could not read the config file: ${configPath}`, + 'Check the file permissions.', + ]; + } + const errors = validateConfigEntries(entries); + if (errors.length === 0) { + return null; + } + const lines = errors.map(({ key, value, hintJa, hintEn }) => + lang === 'ja' + ? `設定ファイルの ${key} の値が不正です: "${value}" (${hintJa})` + : `Invalid ${key} in the config file: "${value}" (${hintEn})`, + ); + lines.push( + lang === 'ja' + ? `設定ファイルを修正してください: ${configPath}` + : `Fix the config file: ${configPath}`, + ); + return lines; +} diff --git a/src/core/constants.test.ts b/src/core/constants.test.ts new file mode 100644 index 0000000..8d69ce2 --- /dev/null +++ b/src/core/constants.test.ts @@ -0,0 +1,100 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + initConfigEntries, + resetConfigEntriesForTests, + STALE_HOURS_KEY, +} from './config-entries.js'; +import { + DEFAULT_SNAPSHOT_STALE_AFTER_MS, + resolveSnapshotStaleAfterMs, +} from './constants.js'; + +const HOUR_MS = 3_600_000; + +describe('resolveSnapshotStaleAfterMs', () => { + let dir: string; + + async function configureStaleHours(value: string | null): Promise { + const file = path.join(dir, '.ppv-cli', 'config'); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile( + file, + value === null ? '' : `${STALE_HOURS_KEY}=${value}\n`, + 'utf8', + ); + initConfigEntries(); + } + + let savedHome: Record; + + beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), 'ppv-cli-stale-')); + savedHome = { + HOME: process.env.HOME, + USERPROFILE: process.env.USERPROFILE, + }; + process.env.HOME = dir; + process.env.USERPROFILE = dir; + // Pin an empty config so the developer's real files never leak in. + await configureStaleHours(null); + }); + + afterEach(async () => { + resetConfigEntriesForTests(); + for (const [key, value] of Object.entries(savedHome)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + await rm(dir, { recursive: true, force: true }); + }); + + it('defaults to 1 hour when unset', () => { + expect(resolveSnapshotStaleAfterMs()).toBe(DEFAULT_SNAPSHOT_STALE_AFTER_MS); + expect(DEFAULT_SNAPSHOT_STALE_AFTER_MS).toBe(1 * HOUR_MS); + }); + + it('reads hours from PPV_CLI_SNAPSHOT_STALE_HOURS in config files', async () => { + await configureStaleHours('12'); + expect(resolveSnapshotStaleAfterMs()).toBe(12 * HOUR_MS); + }); + + it('accepts decimal hours', async () => { + await configureStaleHours('0.5'); + expect(resolveSnapshotStaleAfterMs()).toBe(0.5 * HOUR_MS); + }); + + it('throws for invalid values (the startup gate stops commands first)', async () => { + // invalid -> error, never fall back. The empty string + // is just one more value outside the prescribed set. + for (const invalid of ['abc', '0', '-3', '1O', '1h', '']) { + await configureStaleHours(invalid); + expect(() => resolveSnapshotStaleAfterMs()).toThrow( + 'PPV_CLI_SNAPSHOT_STALE_HOURS', + ); + } + }); + + it('ignores the environment variable', () => { + const saved = process.env[STALE_HOURS_KEY]; + process.env[STALE_HOURS_KEY] = '99'; + try { + expect(resolveSnapshotStaleAfterMs()).toBe( + DEFAULT_SNAPSHOT_STALE_AFTER_MS, + ); + } finally { + if (saved === undefined) { + delete process.env[STALE_HOURS_KEY]; + } else { + process.env[STALE_HOURS_KEY] = saved; + } + } + }); +}); diff --git a/src/core/constants.ts b/src/core/constants.ts new file mode 100644 index 0000000..31188a6 --- /dev/null +++ b/src/core/constants.ts @@ -0,0 +1,58 @@ +/** + * Shared constants for the CLI. + */ + +/** + * Default age after which a snapshot file is considered stale. + * Override with PPV_CLI_SNAPSHOT_STALE_HOURS in the config files. + */ +import { configEntries, STALE_HOURS_KEY } from './config-entries.js'; +import { isValidStaleHours } from './config-validation.js'; + +/** + * The user-facing CLI command name (the `bin` in package.json). Kept as a + * single constant because the name appears in several user-visible strings + * (help output, the token-setup hint, the generated config comment), so a + * future rename touches code in one place. The package.json bin key, the + * entry file name, and the docs are literals that must still be updated by + * hand - this only centralizes the in-code occurrences. + */ +export const PPV_CLI_TOOL_NAME = 'ppv-cli'; + +export const DEFAULT_SNAPSHOT_STALE_AFTER_MS = 1 * 60 * 60 * 1000; + +/** + * Resolves the age after which a snapshot file is considered stale. + * + * The CLI never fetches automatically; when the loaded snapshot is older + * than this threshold, it only warns the user to run + * `ppv-cli snapshot create`. Also used as the in-memory store TTL so that + * `data stats` output (isExpired) conveys the same freshness signal. + * + * The default applies to an ABSENT key only - there is no fallback + * for invalid values (invalid -> error). The startup gate + * stops settings-consuming commands before this point, so an invalid + * value here is a wiring bug and throws. + */ +export function resolveSnapshotStaleAfterMs(): number { + const raw = configEntries().staleHours; + if (raw === null) { + return DEFAULT_SNAPSHOT_STALE_AFTER_MS; + } + if (!isValidStaleHours(raw)) { + throw new Error(`invalid ${STALE_HOURS_KEY}: "${raw}"`); + } + return Number(raw) * 3_600_000; +} + +/** + * Maximum data size accepted by the in-memory store. + * Matches PROMIDAS' internal LIMIT_DATA_SIZE_BYTES (30 MiB). + */ +export const STORE_MAX_DATA_SIZE_BYTES = 30 * 1024 * 1024; + +/** + * Fetch limit used to retrieve all prototypes in a single request. + * Same value as the maximum used by F88/promidas-demo. + */ +export const FETCH_ALL_LIMIT = 10_000; diff --git a/src/core/file-io-errors.ts b/src/core/file-io-errors.ts new file mode 100644 index 0000000..713f6ce --- /dev/null +++ b/src/core/file-io-errors.ts @@ -0,0 +1,27 @@ +/** + * Converts promidas-utils file I/O errors into user-facing messages. + */ +import { toLocalizedMessage } from 'promidas-utils/repository'; + +import type { FileIoError } from 'promidas-utils/file-io'; + +/** + * Returns a Japanese description of a snapshot file I/O failure. + */ +export function describeFileIoError(error: FileIoError): string { + const code = error.code ? ` (${error.code})` : ''; + switch (error.kind) { + case 'READ_FAILED': + return `ファイルを読み込めません${code}: ${error.message}`; + case 'PARSE_FAILED': + return `JSON として解釈できません: ${error.message}`; + case 'SETUP_FAILED': + return `snapshot データが不正です: ${toLocalizedMessage(error.snapshotFailure ?? null)}`; + case 'SERIALIZE_FAILED': + return `snapshot のシリアライズに失敗しました: ${error.message}`; + case 'WRITE_FAILED': + return `ファイルへの書き込みに失敗しました${code}: ${error.message}`; + default: + return error.message; + } +} diff --git a/src/core/format.test.ts b/src/core/format.test.ts new file mode 100644 index 0000000..848a783 --- /dev/null +++ b/src/core/format.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { formatSizeMB, formatSnapshotRow } from './format.js'; + +import type { SnapshotFileInfo } from './snapshot-catalog.js'; + +describe('formatSizeMB', () => { + it('formats sizes in SI units with one decimal', () => { + expect(formatSizeMB(18_400_000)).toBe('18.4 MB'); + expect(formatSizeMB(1_000_000)).toBe('1.0 MB'); + expect(formatSizeMB(512_300)).toBe('512.3 KB'); + expect(formatSizeMB(2_500_000_000)).toBe('2.5 GB'); + }); + + it('returns ? for unknown sizes', () => { + expect(formatSizeMB(null)).toBe('?'); + expect(formatSizeMB(undefined)).toBe('?'); + }); +}); + +describe('formatSnapshotRow', () => { + it('shows date, count, size and file name', () => { + const info: SnapshotFileInfo = { + fileName: 'snapshot-20260715T100000Z-6479.json', + filePath: '/tmp/snapshot-20260715T100000Z-6479.json', + takenAt: new Date('2026-07-15T10:00:00Z'), + count: 6479, + sizeBytes: 18_400_000, + }; + const row = formatSnapshotRow(info); + expect(row).toContain('6479件'); + expect(row).toContain('18.4 MB'); + expect(row).toContain('snapshot-20260715T100000Z-6479.json'); + }); + + it('marks non-conforming names and unknown sizes', () => { + const info: SnapshotFileInfo = { + fileName: 'hoge.json', + filePath: '/tmp/hoge.json', + takenAt: null, + count: null, + sizeBytes: null, + }; + const row = formatSnapshotRow(info); + expect(row).toContain('(命名規則外)'); + expect(row).toContain('?'); + expect(row).toContain('hoge.json'); + }); +}); diff --git a/src/core/format.ts b/src/core/format.ts new file mode 100644 index 0000000..8f5e874 --- /dev/null +++ b/src/core/format.ts @@ -0,0 +1,72 @@ +/** + * Formatting helpers shared across commands. + */ +import type { SnapshotFileInfo } from './snapshot-catalog.js'; +import type { PrototypeInMemoryStats } from 'promidas'; + +/** + * Formats a byte count as a human-readable string (binary units). + */ +export function formatBytes(bytes: number | null | undefined): string { + if (bytes == null || !Number.isFinite(bytes)) return '?'; + if (bytes < 1024) return `${bytes} B`; + let value = bytes; + let unit = 'B'; + for (const nextUnit of ['KiB', 'MiB', 'GiB']) { + if (value < 1024) break; + value /= 1024; + unit = nextUnit; + } + return `${value.toFixed(1)} ${unit}`; +} + +/** + * Formats a file size as "18.4 MB" (SI units, one decimal; values + * under 1 MB as "512.3 KB"). Snapshot sizes on disk - distinct from + * formatBytes (binary units) used for in-memory stats. + */ +export function formatSizeMB(bytes: number | null | undefined): string { + if (bytes == null || !Number.isFinite(bytes)) return '?'; + if (bytes < 1_000_000) return `${(bytes / 1_000).toFixed(1)} KB`; + if (bytes < 1_000_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`; + return `${(bytes / 1_000_000_000).toFixed(1)} GB`; +} + +/** + * One-line representation of a snapshot file: + * date, count, size, file name. Shared by ppv-cli snapshot list, the + * snapshot picker and the snapshot manager. + */ +export function formatSnapshotRow(info: SnapshotFileInfo): string { + const when = info.takenAt + ? formatLocalDateTime(info.takenAt) + : '(命名規則外) '; + const count = info.count !== null ? `${info.count}件` : '-'; + return `${when} ${count} ${formatSizeMB(info.sizeBytes)} ${info.fileName}`; +} + +/** + * Formats a Date in the local timezone as "YYYY-MM-DD HH:mm:ss". + */ +export function formatLocalDateTime(date: Date): string { + const pad = (n: number): string => String(n).padStart(2, '0'); + const ymd = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; + const hms = `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; + return `${ymd} ${hms}`; +} + +/** + * Converts PrototypeInMemoryStats into a JSON-friendly plain object. + */ +export function statsToJson( + stats: PrototypeInMemoryStats, +): Record { + return { + size: stats.size, + cachedAt: stats.cachedAt?.toISOString() ?? null, + isExpired: stats.isExpired, + remainingTtlMs: stats.remainingTtlMs, + dataSizeBytes: stats.dataSizeBytes, + refreshInFlight: stats.refreshInFlight, + }; +} diff --git a/src/core/list-window.test.ts b/src/core/list-window.test.ts new file mode 100644 index 0000000..787cf80 --- /dev/null +++ b/src/core/list-window.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; + +import { computeListWindow } from './list-window.js'; + +describe('computeListWindow', () => { + it('shows everything when maxVisible is omitted', () => { + expect(computeListWindow(3, 10, undefined)).toEqual({ + start: 0, + end: 10, + hiddenAbove: 0, + hiddenBelow: 0, + }); + }); + + it('keeps the cursor inside the window', () => { + // Cursor at the top: window starts at 0. + expect(computeListWindow(0, 10, 3)).toEqual({ + start: 0, + end: 3, + hiddenAbove: 0, + hiddenBelow: 7, + }); + // Cursor beyond the window: it slides down. + expect(computeListWindow(5, 10, 3)).toEqual({ + start: 3, + end: 6, + hiddenAbove: 3, + hiddenBelow: 4, + }); + // Cursor at the bottom: window ends at the list end. + expect(computeListWindow(9, 10, 3)).toEqual({ + start: 7, + end: 10, + hiddenAbove: 7, + hiddenBelow: 0, + }); + }); + + it('clamps when the list shrinks below the cursor', () => { + // e.g. rows were deleted while the cursor sat near the end. + expect(computeListWindow(9, 4, 3)).toEqual({ + start: 1, + end: 4, + hiddenAbove: 1, + hiddenBelow: 0, + }); + }); + + it('handles lists smaller than the window and empty lists', () => { + expect(computeListWindow(0, 2, 5)).toEqual({ + start: 0, + end: 2, + hiddenAbove: 0, + hiddenBelow: 0, + }); + expect(computeListWindow(0, 0, 5)).toEqual({ + start: 0, + end: 0, + hiddenAbove: 0, + hiddenBelow: 0, + }); + }); +}); diff --git a/src/core/list-window.ts b/src/core/list-window.ts new file mode 100644 index 0000000..17a5007 --- /dev/null +++ b/src/core/list-window.ts @@ -0,0 +1,43 @@ +/** + * Cursor-following sliding window for vertical lists. Ink cannot + * scroll frames taller than the terminal, so every list component + * shows a slice and reports how many rows are hidden. This is the + * single home of that calculation (Menu, MultiSelectList). + */ + +export type ListWindow = { + /** First visible index (inclusive). */ + readonly start: number; + /** Last visible index (exclusive). */ + readonly end: number; + readonly hiddenAbove: number; + readonly hiddenBelow: number; +}; + +/** + * Computes the visible slice so that the cursor stays inside, + * clamped to the list bounds (also when the list shrinks, e.g. + * after a deletion). + */ +export function computeListWindow( + cursor: number, + itemCount: number, + maxVisible: number | undefined, +): ListWindow { + const visible = + maxVisible !== undefined + ? Math.min(Math.max(1, maxVisible), itemCount) + : itemCount; + const boundedCursor = Math.min(cursor, Math.max(0, itemCount - 1)); + const start = Math.min( + Math.max(0, boundedCursor - visible + 1), + Math.max(0, itemCount - visible), + ); + const end = start + visible; + return { + start, + end, + hiddenAbove: start, + hiddenBelow: itemCount - end, + }; +} diff --git a/src/core/logger.test.ts b/src/core/logger.test.ts new file mode 100644 index 0000000..3afca0d --- /dev/null +++ b/src/core/logger.test.ts @@ -0,0 +1,96 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + initConfigEntries, + LOG_LEVEL_KEY, + resetConfigEntriesForTests, +} from './config-entries.js'; +import { resolveLogLevel } from './logger.js'; + +describe('resolveLogLevel', () => { + let dir: string; + + async function configureLogLevel(value: string | null): Promise { + const file = path.join(dir, '.ppv-cli', 'config'); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile( + file, + value === null ? '' : `${LOG_LEVEL_KEY}=${value}\n`, + 'utf8', + ); + initConfigEntries(); + } + + let savedHome: Record; + + beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), 'ppv-cli-loglevel-')); + savedHome = { + HOME: process.env.HOME, + USERPROFILE: process.env.USERPROFILE, + }; + process.env.HOME = dir; + process.env.USERPROFILE = dir; + // Pin an empty config so the developer's real files never leak in. + await configureLogLevel(null); + }); + + afterEach(async () => { + resetConfigEntriesForTests(); + for (const [key, value] of Object.entries(savedHome)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + await rm(dir, { recursive: true, force: true }); + }); + + it('defaults to info', () => { + expect(resolveLogLevel({})).toBe('info'); + }); + + it('maps --verbose to debug and --quiet to error', () => { + expect(resolveLogLevel({ verbose: true })).toBe('debug'); + expect(resolveLogLevel({ quiet: true })).toBe('error'); + expect(resolveLogLevel({ verbose: true, quiet: true })).toBe('error'); + }); + + it('reads the default from PPV_CLI_LOG_LEVEL in config files', async () => { + await configureLogLevel('warn'); + expect(resolveLogLevel({})).toBe('warn'); + }); + + it('lets CLI flags win over the configured level', async () => { + await configureLogLevel('silent'); + expect(resolveLogLevel({ verbose: true })).toBe('debug'); + }); + + it('throws for invalid values (the startup gate stops commands first)', async () => { + // invalid -> error, never fall back. The empty string + // is just one more value outside the prescribed set. + for (const invalid of ['loud', '']) { + await configureLogLevel(invalid); + expect(() => resolveLogLevel({})).toThrow('PPV_CLI_LOG_LEVEL'); + } + }); + + it('ignores the PPV_CLI_LOG_LEVEL environment variable', () => { + const saved = process.env[LOG_LEVEL_KEY]; + process.env[LOG_LEVEL_KEY] = 'silent'; + try { + expect(resolveLogLevel({})).toBe('info'); + } finally { + if (saved === undefined) { + delete process.env[LOG_LEVEL_KEY]; + } else { + process.env[LOG_LEVEL_KEY] = saved; + } + } + }); +}); diff --git a/src/core/logger.ts b/src/core/logger.ts new file mode 100644 index 0000000..5bb6abc --- /dev/null +++ b/src/core/logger.ts @@ -0,0 +1,83 @@ +/** + * Stderr logger for the CLI. + * + * All diagnostics (logs, progress, warnings) go to stderr so that stdout + * stays reserved for command results (JSON etc.) and remains pipe-friendly. + */ +import { inspect } from 'node:util'; + +import { configEntries, LOG_LEVEL_KEY } from './config-entries.js'; +import { isValidLogLevel } from './config-validation.js'; + +import type { Logger, LogLevel } from 'promidas/logger'; + +const LEVEL_PRIORITY: Record, number> = { + debug: 10, + info: 20, + warn: 30, + error: 40, +}; + +export type LogLevelFlags = { + readonly verbose?: boolean; + readonly quiet?: boolean; +}; + +/** + * Resolves the log level with the following precedence: + * --quiet > --verbose > PPV_CLI_LOG_LEVEL (config file) > 'info'. + * + * 'info' is the default for an ABSENT key only - there is no + * fallback for invalid values (invalid -> error). The + * startup gate stops settings-consuming commands before this point, + * so an invalid value here is a wiring bug and throws. + */ +export function resolveLogLevel(flags: LogLevelFlags): LogLevel { + if (flags.quiet) return 'error'; + if (flags.verbose) return 'debug'; + + const configured = configEntries().logLevel; + if (configured === null) return 'info'; + if (!isValidLogLevel(configured)) { + throw new Error(`invalid ${LOG_LEVEL_KEY}: "${configured}"`); + } + return configured; +} + +/** + * Log level for the PROMIDAS library layers (store/fetcher/repository). + * + * Libraries log construction and internal details at info level, which is + * noisy for normal CLI usage; keep them at warn unless --verbose is given. + */ +export function resolveLibraryLogLevel(cliLevel: LogLevel): LogLevel { + return cliLevel === 'debug' ? 'debug' : 'warn'; +} + +/** + * Creates a PROMIDAS-compatible logger that writes to stderr. + */ +export function createStderrLogger(level: LogLevel): Logger { + const threshold = + level === 'silent' ? Number.POSITIVE_INFINITY : LEVEL_PRIORITY[level]; + + const write = ( + messageLevel: Exclude, + message: string, + meta?: unknown, + ): void => { + if (LEVEL_PRIORITY[messageLevel] < threshold) return; + const suffix = + meta === undefined + ? '' + : ` ${inspect(meta, { depth: 4, breakLength: Number.POSITIVE_INFINITY })}`; + process.stderr.write(`[${messageLevel}] ${message}${suffix}\n`); + }; + + return { + error: (message, meta) => write('error', message, meta), + warn: (message, meta) => write('warn', message, meta), + info: (message, meta) => write('info', message, meta), + debug: (message, meta) => write('debug', message, meta), + }; +} diff --git a/src/core/protopedia-utils.ts b/src/core/protopedia-utils.ts new file mode 100644 index 0000000..0ddde2e --- /dev/null +++ b/src/core/protopedia-utils.ts @@ -0,0 +1,4 @@ +const PROTOPEDIA_PROTOTYPE_BASE_URL = 'https://protopedia.net/prototype'; +export const buildPrototypeLink = (prototypeId: number): string => { + return `${PROTOPEDIA_PROTOTYPE_BASE_URL}/${prototypeId}`; +}; diff --git a/src/core/repository-factory.ts b/src/core/repository-factory.ts new file mode 100644 index 0000000..a30ebed --- /dev/null +++ b/src/core/repository-factory.ts @@ -0,0 +1,69 @@ +/** + * Repository construction via PromidasRepositoryBuilder. + * + * Configuration follows the reference implementation in F88/promidas-demo + * (src/lib/repository/), adapted for a CLI: all layers share a stderr + * logger, and download progress is reported through a callback. + * + * Note: the factory functions (createPromidasForLocal etc.) are + * intentionally not used, per the library author's recommendation. + */ +import { PromidasRepositoryBuilder } from 'promidas'; + +import { + resolveSnapshotStaleAfterMs, + STORE_MAX_DATA_SIZE_BYTES, +} from './constants.js'; + +import type { ProtopediaInMemoryRepository } from 'promidas'; +import type { FetchProgressEvent } from 'promidas/fetcher'; +import type { Logger } from 'promidas/logger'; + +/** + * Placeholder token for offline usage (loading snapshots from files). + * + * The builder always constructs the API client, and the client's + * constructor rejects a missing token even when the API is never called. + * Offline commands therefore pass this placeholder; any accidental API + * call with it fails with an authentication error. + */ +const OFFLINE_TOKEN_PLACEHOLDER = 'offline-no-token'; + +export type CreateRepositoryOptions = { + /** API token. Omit for offline usage (loading snapshots from files). */ + readonly token?: string; + readonly logger: Logger; + /** Download progress callback used during fetch. */ + readonly onProgress?: (event: FetchProgressEvent) => void; +}; + +/** + * Builds a ProtopediaInMemoryRepository configured for this CLI. + */ +export function createRepository( + options: CreateRepositoryOptions, +): ProtopediaInMemoryRepository { + const { token, logger, onProgress } = options; + + return new PromidasRepositoryBuilder() + .setStoreConfig({ + ttlMs: resolveSnapshotStaleAfterMs(), + maxDataSizeBytes: STORE_MAX_DATA_SIZE_BYTES, + logger, + }) + .setApiClientConfig({ + protoPediaApiClientOptions: { + token: token ?? OFFLINE_TOKEN_PLACEHOLDER, + logger, + }, + logger, + // Progress is rendered by the CLI's own callback instead. + progressLog: false, + ...(onProgress ? { progressCallback: onProgress } : {}), + }) + .setRepositoryConfig({ + logger, + enableEvents: true, + }) + .build(); +} diff --git a/src/core/sanitize-display-text.test.ts b/src/core/sanitize-display-text.test.ts new file mode 100644 index 0000000..47cd103 --- /dev/null +++ b/src/core/sanitize-display-text.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; + +import { sanitizeDisplayText } from './sanitize-display-text.js'; + +// Build control / invisible characters from their code points so the +// source stays plain ASCII and each case is unambiguous. +const TAB = String.fromCodePoint(0x09); +const BACKSPACE = String.fromCodePoint(0x08); +const FILE_SEPARATOR = String.fromCodePoint(0x1c); +const DEL = String.fromCodePoint(0x7f); +const CSI = String.fromCodePoint(0x9b); // C1 +const LINE_SEP = String.fromCodePoint(0x2028); +const ZWJ = String.fromCodePoint(0x200d); // Cf - must be preserved +const ZWSP = String.fromCodePoint(0x200b); // Cf - must be preserved +const FULL_WIDTH_SPACE = String.fromCodePoint(0x3000); + +describe('sanitizeDisplayText', () => { + it('leaves clean text unchanged', () => { + expect(sanitizeDisplayText('Responsive Widget')).toBe('Responsive Widget'); + }); + + it('replaces a leading TAB with a space, without trimming (prototype 3064)', () => { + // TAB + existing space -> two leading spaces; edges are not trimmed. + expect(sanitizeDisplayText(`${TAB} 超低電力ソーラーモータ`)).toBe( + ' 超低電力ソーラーモータ', + ); + }); + + it('replaces a leading BACKSPACE with a space, without trimming (prototype 1054)', () => { + expect(sanitizeDisplayText(`${BACKSPACE}魚(ぎょ)パニック`)).toBe( + ' 魚(ぎょ)パニック', + ); + }); + + it('turns an internal control char into a space (prototype 577)', () => { + expect(sanitizeDisplayText(`ai-kai(八${FILE_SEPARATOR}菜戒)`)).toBe( + 'ai-kai(八 菜戒)', + ); + }); + + it('replaces each CR/LF with a space and does not collapse them', () => { + // Four control chars become four spaces: internal runs are never + // collapsed, so intentional spacing in the data is preserved. + expect(sanitizeDisplayText('a\r\n\r\nb')).toBe('a b'); + }); + + it('handles the Unicode line separator U+2028', () => { + expect(sanitizeDisplayText(`前${LINE_SEP}後`)).toBe('前 後'); + }); + + it('sanitizes a C1 control (CSI U+009B)', () => { + expect(sanitizeDisplayText(`a${CSI}b`)).toBe('a b'); + }); + + it('sanitizes DEL (U+007F)', () => { + expect(sanitizeDisplayText(`a${DEL}b`)).toBe('a b'); + }); + + it('preserves ZERO WIDTH JOINER in emoji sequences', () => { + // Woman gesturing OK: person-gesturing + ZWJ + female sign + VS16. + const emoji = + String.fromCodePoint(0x1f646) + + ZWJ + + String.fromCodePoint(0x2640) + + String.fromCodePoint(0xfe0f); + expect(sanitizeDisplayText(`OK ${emoji}`)).toBe(`OK ${emoji}`); + }); + + it('preserves ZERO WIDTH SPACE (Cf is not touched)', () => { + expect(sanitizeDisplayText(`a${ZWSP}b`)).toBe(`a${ZWSP}b`); + }); + + it('preserves the full-width space U+3000', () => { + expect(sanitizeDisplayText(`モータ${FULL_WIDTH_SPACE}消費電力`)).toBe( + `モータ${FULL_WIDTH_SPACE}消費電力`, + ); + }); + + it('preserves intentional consecutive spaces', () => { + expect(sanitizeDisplayText('a b')).toBe('a b'); + }); + + it('turns all-control input into the same number of spaces', () => { + expect(sanitizeDisplayText('\t\n\r')).toBe(' '); + }); +}); diff --git a/src/core/sanitize-display-text.ts b/src/core/sanitize-display-text.ts new file mode 100644 index 0000000..ff0dc9d --- /dev/null +++ b/src/core/sanitize-display-text.ts @@ -0,0 +1,53 @@ +/** + * Clean an untrusted string for display to a human. + * + * ProtoPedia data (names, summaries, ...) sometimes carries stray + * control codes. Printed raw to a terminal they wreck the layout - Ink + * measures e.g. TAB as zero width, yet the terminal still expands it, so + * a one-line table row overflows and wraps - or, worse, an ESC / C1 byte + * injects a terminal escape sequence. This turns those bytes into plain + * spaces so the text is safe and stays on a single line. + * + * Examples (only control chars change; spacing is never collapsed or + * trimmed, so a control char at an edge leaves a space there): + * "a\tb" -> "a b" + * "\t 超低電力モータ " -> " 超低電力モータ " + * + * Use it for anything human-facing (table cells, the preview / detail + * card, ppv-cli's plain-text output). Do NOT use it for machine-readable + * output: the JSON views (pptop detail / raw JSON, `ppv-cli prototype show`) + * and clipboard copies must stay byte-for-byte faithful. + * + * What it REPLACES (each becomes one ASCII space): + * - Every control character, i.e. Unicode category Cc: + * - C0 U+0000-U+001F (TAB, LF, CR, BACKSPACE, ESC, ...) + * - DEL U+007F + * - C1 U+0080-U+009F (8-bit escape introducers such as CSI/OSC) + * - The Unicode line / paragraph separators U+2028 / U+2029 - they act + * as newlines but are not part of Cc. + * (This is the whole job: the single CONTROL_OR_LINE_SEPARATOR replace + * below and nothing else - no whitespace collapsing, no trimming.) + * + * What it deliberately KEEPS (does not touch): + * - Unicode category Cf (format characters): zero-width joiner U+200D, + * zero-width space U+200B, bidi marks U+200E-U+202E, ... These have + * real uses - ZWJ builds composed emoji, bidi marks make + * right-to-left scripts display correctly - so removing them would + * corrupt valid text. `\p{Cc}` never matches Cf, so they pass + * straight through. + * - All whitespace other than the targets above: runs of spaces, + * leading / trailing spaces, and non-ASCII spaces such as the + * full-width space U+3000 are left exactly as they appear in the + * data. Single responsibility - neutralize control codes, normalize + * nothing. (A control char at an edge therefore leaves one space + * there; harmless, and it marks that something was removed.) + */ +const CONTROL_OR_LINE_SEPARATOR = /[\p{Cc}\u2028\u2029]/gu; + +export function sanitizeDisplayText(text: string): string { + // Single responsibility: turn each control / line-separator char into + // an ASCII space and change nothing else - no collapsing, no trimming. + // All other characters and every existing space (internal or at the + // edges) are preserved exactly as they appear in the data. + return text.replace(CONTROL_OR_LINE_SEPARATOR, ' '); +} diff --git a/src/core/search-model.ts b/src/core/search-model.ts new file mode 100644 index 0000000..d1db3c1 --- /dev/null +++ b/src/core/search-model.ts @@ -0,0 +1,127 @@ +/** + * Shared search model: field definitions and the match predicate, + * used by both ppex (SearchScreen) and pptop (TopApp). + * + * Semantics (decided 2026-07-15): terms within a field are ANDed, and + * fields are ANDed with each other. Note this intentionally differs + * from the CLI facets (repeated options are ORed there): live + * narrowing feels natural as AND. The ID field is the exception: + * prefix match with space-separated terms ORed, matching the CLI's + * repeated --id semantics. + */ +import type { NormalizedPrototype } from 'promidas/types'; + +export const SEARCH_FIELDS = [ + { key: 'id', label: { ja: 'ID', en: 'ID' } }, + { key: 'name', label: { ja: '作品名', en: 'Name' } }, + { key: 'summary', label: { ja: '概要', en: 'Summary' } }, + { key: 'tag', label: { ja: 'タグ', en: 'Tag' } }, + { key: 'material', label: { ja: '素材', en: 'Material' } }, + { key: 'user', label: { ja: 'ユーザー', en: 'User' } }, +] as const; +export type SearchFieldKey = (typeof SEARCH_FIELDS)[number]['key']; +export type FieldValues = Record; + +export const EMPTY_VALUES: FieldValues = { + id: '', + name: '', + summary: '', + tag: '', + material: '', + user: '', +}; + +function terms(value: string): readonly string[] { + return value.toLowerCase().split(/\s+/).filter(Boolean); +} + +/** + * Returns true when the prototype satisfies every field (fields are + * ANDed; terms within a field are ANDed as well). + */ +export function matchesFields( + prototype: NormalizedPrototype, + values: FieldValues, +): boolean { + // ID: prefix match; space-separated terms are ORed ("10 30" shows + // both), matching the CLI's repeated --id semantics. + const idTerms = terms(values.id); + if (idTerms.length > 0) { + const idString = String(prototype.id); + if (!idTerms.some((term) => idString.startsWith(term))) return false; + } + + const name = prototype.prototypeNm.toLowerCase(); + if (!terms(values.name).every((term) => name.includes(term))) return false; + + const summary = prototype.summary.toLowerCase(); + if (!terms(values.summary).every((term) => summary.includes(term))) { + return false; + } + + const tags = prototype.tags.map((tag) => tag.toLowerCase()); + if ( + !terms(values.tag).every((term) => tags.some((tag) => tag.includes(term))) + ) { + return false; + } + + const materials = prototype.materials.map((material) => + material.toLowerCase(), + ); + if ( + !terms(values.material).every((term) => + materials.some((material) => material.includes(term)), + ) + ) { + return false; + } + + const users = prototype.users.map((user) => user.toLowerCase()); + if ( + !terms(values.user).every((term) => + users.some((user) => user.includes(term)), + ) + ) { + return false; + } + return true; +} + +/** + * Appends typed text to a field value, applying per-field input rules + * (the ID field accepts digits and spaces only). + */ +function appendFieldInput( + key: SearchFieldKey, + current: string, + input: string, +): string { + const text = key === 'id' ? input.replace(/[^0-9 ]/g, '') : input; + return (current + text).slice(0, 50); +} + +/** + * FieldValues update for typed text. Preserves object identity when + * nothing effectively changes (e.g. non-digits typed into the ID + * field), so effects keyed on the values object (cursor reset) do + * not fire spuriously. + */ +export function withFieldInput( + values: FieldValues, + key: SearchFieldKey, + input: string, +): FieldValues { + const next = appendFieldInput(key, values[key], input); + return next === values[key] ? values : { ...values, [key]: next }; +} + +/** FieldValues update for backspace; identity-preserving like above. */ +export function withFieldBackspace( + values: FieldValues, + key: SearchFieldKey, +): FieldValues { + return values[key] === '' + ? values + : { ...values, [key]: values[key].slice(0, -1) }; +} diff --git a/src/core/session.test.ts b/src/core/session.test.ts new file mode 100644 index 0000000..f77f1a7 --- /dev/null +++ b/src/core/session.test.ts @@ -0,0 +1,109 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { loadSession } from './session.js'; + +function prototypeFixture(id: number): Record { + return { + id, + createDate: '2026-01-01T00:00:00.000Z', + releaseFlg: 1, + status: 1, + prototypeNm: `Prototype ${id}`, + summary: '', + freeComment: '', + systemDescription: '', + users: ['tester'], + teamNm: '', + tags: [], + materials: [], + events: [], + awards: [], + mainUrl: `https://protopedia.net/prototype/${id}`, + viewCount: 0, + goodCount: 0, + commentCount: 0, + }; +} + +describe('loadSession', () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), 'ppc-session-test-')); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('loads a snapshot and returns metadata from the file content', async () => { + const filePath = path.join(dir, 'snapshot-20260715T100000Z-3.json'); + await writeFile( + filePath, + JSON.stringify({ + version: '1.0.0', + serializedAt: '2026-07-15T10:00:00.123Z', + prototypes: [ + prototypeFixture(5), + prototypeFixture(42), + prototypeFixture(17), + ], + }), + 'utf8', + ); + + const result = await loadSession(filePath); + expect(result.ok).toBe(true); + if (!result.ok) return; + try { + expect(result.session.fileName).toBe('snapshot-20260715T100000Z-3.json'); + expect(result.session.version).toBe('1.0.0'); + // The real generation time, millisecond precision, from the file. + expect(result.session.serializedAt.toISOString()).toBe( + '2026-07-15T10:00:00.123Z', + ); + expect(result.session.size).toBe(3); + expect(result.session.minId).toBe(5); + expect(result.session.maxId).toBe(42); + } finally { + result.repository.dispose(); + } + }); + + it('fails on a missing file', async () => { + const result = await loadSession(path.join(dir, 'missing.json')); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.message).toContain('読み込めません'); + }); + + it('fails on broken JSON', async () => { + const filePath = path.join(dir, 'broken.json'); + await writeFile(filePath, '{ broken', 'utf8'); + const result = await loadSession(filePath); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.message).toContain('JSON'); + }); + + it('fails on an invalid serializedAt', async () => { + const filePath = path.join(dir, 'bad-date.json'); + await writeFile( + filePath, + JSON.stringify({ + version: '1.0.0', + serializedAt: 'not-a-date', + prototypes: [], + }), + 'utf8', + ); + const result = await loadSession(filePath); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.message).toContain('serializedAt'); + }); +}); diff --git a/src/core/session.ts b/src/core/session.ts new file mode 100644 index 0000000..cc02647 --- /dev/null +++ b/src/core/session.ts @@ -0,0 +1,182 @@ +/** + * Snapshot session management for the interactive UIs (ppex, pptop). + * + * A "session" is a loaded snapshot plus the metadata shown in the + * always-visible header: file identity, the real generation time + * (serializedAt from the file content, not the file name), counts, + * data size, and the prototype ID range used for input validation. + * + * Loading parses the file itself and calls + * setupSnapshotFromSerializedData() (the PROMIDAS-native path) instead + * of promidas-utils' importSnapshotFromFile(), because the latter does + * not hand back the parsed metadata. + * + * Non-React module so that it stays unit-testable. + */ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; + +import { exportSnapshotToFile } from 'promidas-utils/file-io'; +import { toLocalizedMessage } from 'promidas-utils/repository'; + +import { FETCH_ALL_LIMIT } from './constants.js'; +import { describeFileIoError } from './file-io-errors.js'; +import { createStderrLogger } from './logger.js'; +import { createRepository } from './repository-factory.js'; +import { buildSnapshotFileName } from './snapshot-catalog.js'; + +import type { ProtopediaInMemoryRepository } from 'promidas'; +import type { FetchProgressEvent } from 'promidas/fetcher'; + +type SerializedSnapshotData = Parameters< + ProtopediaInMemoryRepository['setupSnapshotFromSerializedData'] +>[0]; + +export type SnapshotSession = { + readonly filePath: string; + readonly fileName: string; + /** Snapshot format version from the file content. */ + readonly version: string; + /** Real generation time from the file content (serializedAt). */ + readonly serializedAt: Date; + readonly size: number; + readonly dataSizeBytes: number; + /** null when the snapshot is empty. */ + readonly minId: number | null; + readonly maxId: number | null; +}; + +export type SessionResult = + | { + readonly ok: true; + readonly session: SnapshotSession; + readonly repository: ProtopediaInMemoryRepository; + } + | { readonly ok: false; readonly message: string }; + +/** + * Builds the session metadata from a repository that already holds the + * snapshot data. + */ +async function buildSession( + repository: ProtopediaInMemoryRepository, + filePath: string, + version: string, + serializedAt: Date, +): Promise { + const stats = repository.getStats(); + const { min, max } = await repository.analyzePrototypes(); + return { + filePath, + fileName: path.basename(filePath), + version, + serializedAt, + size: stats.size, + dataSizeBytes: stats.dataSizeBytes, + minId: min, + maxId: max, + }; +} + +/** + * Loads a snapshot file into a fresh repository and returns the session. + * The caller owns the returned repository (call dispose() when done). + */ +export async function loadSession(filePath: string): Promise { + let raw: string; + try { + raw = await readFile(filePath, 'utf8'); + } catch (error) { + return { + ok: false, + message: `ファイルを読み込めません: ${error instanceof Error ? error.message : String(error)}`, + }; + } + + let data: SerializedSnapshotData; + try { + data = JSON.parse(raw) as SerializedSnapshotData; + } catch { + return { ok: false, message: 'JSON として解釈できません。' }; + } + + const serializedAt = new Date(data.serializedAt); + if (Number.isNaN(serializedAt.getTime())) { + return { ok: false, message: 'serializedAt が不正です。' }; + } + + const repository = createRepository({ + logger: createStderrLogger('silent'), + }); + const result = repository.setupSnapshotFromSerializedData(data); + if (!result.ok) { + repository.dispose(); + return { ok: false, message: toLocalizedMessage(result) }; + } + + return { + ok: true, + session: await buildSession( + repository, + filePath, + data.version, + serializedAt, + ), + repository, + }; +} + +export type CreateSessionOptions = { + readonly token: string; + readonly snapshotDir: string; + readonly onProgress?: (event: FetchProgressEvent) => void; +}; + +/** + * Fetches all prototypes from the API, saves a new snapshot file, and + * returns the session for the freshly fetched data. + * The caller owns the returned repository (call dispose() when done). + */ +export async function createSession( + options: CreateSessionOptions, +): Promise { + const repository = createRepository({ + token: options.token, + logger: createStderrLogger('silent'), + ...(options.onProgress ? { onProgress: options.onProgress } : {}), + }); + + const result = await repository.setupSnapshot({ + limit: FETCH_ALL_LIMIT, + offset: 0, + }); + if (!result.ok) { + repository.dispose(); + return { + ok: false, + message: `データ取得に失敗しました: ${toLocalizedMessage(result)}`, + }; + } + + const stats = result.stats; + const takenAt = stats.cachedAt ?? new Date(); + const filePath = path.join( + options.snapshotDir, + buildSnapshotFileName(takenAt, stats.size), + ); + const exportResult = await exportSnapshotToFile(repository, filePath); + if (!exportResult.ok) { + repository.dispose(); + return { + ok: false, + message: `snapshot の保存に失敗しました: ${describeFileIoError(exportResult.error)}`, + }; + } + + const version = repository.getSerializableSnapshot().version; + return { + ok: true, + session: await buildSession(repository, filePath, version, takenAt), + repository, + }; +} diff --git a/src/core/snapshot-catalog.test.ts b/src/core/snapshot-catalog.test.ts new file mode 100644 index 0000000..bd0e0be --- /dev/null +++ b/src/core/snapshot-catalog.test.ts @@ -0,0 +1,72 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + buildSnapshotFileName, + findLatestSnapshot, + listSnapshots, + parseSnapshotFileName, +} from './snapshot-catalog.js'; + +describe('buildSnapshotFileName', () => { + it('builds a compact UTC file name', () => { + const takenAt = new Date('2026-07-15T10:00:00.123Z'); + expect(buildSnapshotFileName(takenAt, 1234)).toBe( + 'snapshot-20260715T100000Z-1234.json', + ); + }); +}); + +describe('parseSnapshotFileName', () => { + it('round-trips with buildSnapshotFileName', () => { + const takenAt = new Date('2026-07-15T10:00:00.000Z'); + const fileName = buildSnapshotFileName(takenAt, 42); + const parsed = parseSnapshotFileName(fileName); + expect(parsed).not.toBeNull(); + expect(parsed?.takenAt.toISOString()).toBe(takenAt.toISOString()); + expect(parsed?.count).toBe(42); + }); + + it('returns null for non-conforming names', () => { + expect(parseSnapshotFileName('snapshot.json')).toBeNull(); + expect(parseSnapshotFileName('snapshot-20260715-1234.json')).toBeNull(); + expect(parseSnapshotFileName('other.txt')).toBeNull(); + }); +}); + +describe('listSnapshots / findLatestSnapshot', () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), 'ppc-test-')); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('returns an empty list for a missing directory', async () => { + expect(await listSnapshots(path.join(dir, 'missing'))).toEqual([]); + }); + + it('sorts conforming files newest first and others last', async () => { + const older = 'snapshot-20260710T090000Z-1200.json'; + const newer = 'snapshot-20260715T100000Z-1234.json'; + const unknown = 'aaa-manual-copy.json'; + const notJson = 'notes.txt'; + for (const name of [older, newer, unknown, notJson]) { + await writeFile(path.join(dir, name), '{}', 'utf8'); + } + + const infos = await listSnapshots(dir); + expect(infos.map((info) => info.fileName)).toEqual([newer, older, unknown]); + expect(infos[0]?.count).toBe(1234); + expect(infos[2]?.takenAt).toBeNull(); + + const latest = await findLatestSnapshot(dir); + expect(latest?.fileName).toBe(newer); + }); +}); diff --git a/src/core/snapshot-catalog.ts b/src/core/snapshot-catalog.ts new file mode 100644 index 0000000..b881142 --- /dev/null +++ b/src/core/snapshot-catalog.ts @@ -0,0 +1,134 @@ +/** + * Snapshot file catalog: naming convention, listing, and latest resolution. + * + * File name convention: + * snapshot--.json + * e.g. snapshot-20260715T100000Z-1234.json + * + * The timestamp and count are embedded in the file name so that listing + * does not require parsing each JSON file. The authoritative metadata is + * still the `serializedAt` field inside the file. + */ +import { readdir, stat } from 'node:fs/promises'; +import path from 'node:path'; + +import { configEntries } from './config-entries.js'; +import { defaultSnapshotDir } from './user-dirs.js'; + +const SNAPSHOT_FILE_PATTERN = + /^snapshot-(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z-(\d+)\.json$/; + +export type SnapshotFileInfo = { + readonly fileName: string; + readonly filePath: string; + /** null when the file name does not follow the naming convention. */ + readonly takenAt: Date | null; + /** null when the file name does not follow the naming convention. */ + readonly count: number | null; + /** File size on disk; null when stat failed (e.g. just removed). */ + readonly sizeBytes: number | null; +}; + +/** + * Resolves the snapshot directory (issue #7): + * CLI option > PPV_CLI_SNAPSHOT_DIR in the config file > defaultSnapshotDir() + * + * Settings are read from the global config file only, never from + * environment variables or .env files. Repo-clone development uses + * ./snapshots by passing --snapshot-dir ./snapshots. + */ +export function resolveSnapshotDir(cliDir?: string): string { + const dir = cliDir ?? configEntries().snapshotDir ?? defaultSnapshotDir(); + return path.resolve(dir); +} + +/** + * Builds a snapshot file name from its taken-at time and prototype count. + */ +export function buildSnapshotFileName(takenAt: Date, count: number): string { + const compact = takenAt + .toISOString() + .replace(/\.\d{3}Z$/, 'Z') + .replaceAll('-', '') + .replaceAll(':', ''); + return `snapshot-${compact}-${count}.json`; +} + +/** + * Parses a snapshot file name. Returns null for non-conforming names. + */ +export function parseSnapshotFileName( + fileName: string, +): { takenAt: Date; count: number } | null { + const match = SNAPSHOT_FILE_PATTERN.exec(fileName); + if (!match) return null; + const [, year, month, day, hour, minute, second, count] = match; + const takenAt = new Date( + Date.UTC( + Number(year), + Number(month) - 1, + Number(day), + Number(hour), + Number(minute), + Number(second), + ), + ); + if (Number.isNaN(takenAt.getTime())) return null; + return { takenAt, count: Number(count) }; +} + +/** + * Lists JSON files in the snapshot directory, newest first. + * Files that do not follow the naming convention are placed last + * (sorted by name). A missing directory yields an empty list. + * The size comes from stat only - the JSON is never opened. + */ +export async function listSnapshots(dir: string): Promise { + let names: string[]; + try { + names = await readdir(dir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } + + const infos = await Promise.all( + names + .filter((name) => name.endsWith('.json')) + .map(async (fileName): Promise => { + const filePath = path.join(dir, fileName); + const parsed = parseSnapshotFileName(fileName); + let sizeBytes: number | null = null; + try { + sizeBytes = (await stat(filePath)).size; + } catch { + // The file vanished between readdir and stat: keep null. + } + return { + fileName, + filePath, + takenAt: parsed?.takenAt ?? null, + count: parsed?.count ?? null, + sizeBytes, + }; + }), + ); + + return infos.sort((a, b) => { + if (a.takenAt && b.takenAt) + return b.takenAt.getTime() - a.takenAt.getTime(); + if (a.takenAt) return -1; + if (b.takenAt) return 1; + return a.fileName.localeCompare(b.fileName); + }); +} + +/** + * Returns the newest conforming snapshot, or null when none exists. + */ +export async function findLatestSnapshot( + dir: string, +): Promise { + const infos = await listSnapshots(dir); + return infos.find((info) => info.takenAt !== null) ?? null; +} diff --git a/src/core/snapshot-delete.test.ts b/src/core/snapshot-delete.test.ts new file mode 100644 index 0000000..d53ae03 --- /dev/null +++ b/src/core/snapshot-delete.test.ts @@ -0,0 +1,49 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { deleteSnapshotFiles } from './snapshot-delete.js'; + +describe('deleteSnapshotFiles', () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), 'ppv-cli-delete-')); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('deletes the given files and reports each success', async () => { + const a = path.join(dir, 'a.json'); + const b = path.join(dir, 'b.json'); + await writeFile(a, '{}', 'utf8'); + await writeFile(b, '{}', 'utf8'); + + const results = await deleteSnapshotFiles([a, b]); + expect(results).toEqual([ + { filePath: a, fileName: 'a.json', ok: true }, + { filePath: b, fileName: 'b.json', ok: true }, + ]); + }); + + it('reports a per-file failure and continues with the rest', async () => { + const missing = path.join(dir, 'missing.json'); + const real = path.join(dir, 'real.json'); + await writeFile(real, '{}', 'utf8'); + + const results = await deleteSnapshotFiles([missing, real]); + expect(results).toHaveLength(2); + expect(results[0]?.ok).toBe(false); + expect(results[0]?.message).toContain('ENOENT'); + // The failure did not stop the second deletion. + expect(results[1]).toEqual({ + filePath: real, + fileName: 'real.json', + ok: true, + }); + }); +}); diff --git a/src/core/snapshot-delete.ts b/src/core/snapshot-delete.ts new file mode 100644 index 0000000..43f5998 --- /dev/null +++ b/src/core/snapshot-delete.ts @@ -0,0 +1,48 @@ +/** + * Snapshot file deletion (issue #10). + * + * Permanent deletion with a confirmation step in the UI - Trash + * support was considered and permanently rejected: + * trashing does not free disk space (the motivation for deleting), + * has no Node standard API, and behaves unpredictably across + * platforms. + * + * No pre-checks: filesystem state can change at any moment, so each + * unlink is attempted and its own failure is reported (per file); + * one failure does not stop the rest. + */ +import { unlink } from 'node:fs/promises'; +import path from 'node:path'; + +export type DeleteResult = { + readonly filePath: string; + readonly fileName: string; + readonly ok: boolean; + /** Failure reason (errno message) when ok is false. */ + readonly message?: string; +}; + +/** + * Deletes the given snapshot files, one by one. Always returns one + * result per requested path, in the requested order. + */ +export async function deleteSnapshotFiles( + filePaths: readonly string[], +): Promise { + const results: DeleteResult[] = []; + for (const filePath of filePaths) { + const fileName = path.basename(filePath); + try { + await unlink(filePath); + results.push({ filePath, fileName, ok: true }); + } catch (error) { + results.push({ + filePath, + fileName, + ok: false, + message: error instanceof Error ? error.message : String(error), + }); + } + } + return results; +} diff --git a/src/core/token.ts b/src/core/token.ts new file mode 100644 index 0000000..8472a78 --- /dev/null +++ b/src/core/token.ts @@ -0,0 +1,39 @@ +/** + * Token resolution for the ProtoPedia API. + * + * The global config file is the single source of truth (issue #7, decided + * 2026-07-16): the token is read from the config file only. No + * environment variables. Set it with the config set-token command + * (see SET_TOKEN_COMMAND). + */ +import { configEntries } from './config-entries.js'; +import { PPV_CLI_TOOL_NAME } from './constants.js'; + +/** + * Returns the ProtoPedia API token, or null when not configured. + */ +export function resolveToken(): string | null { + return configEntries().token; +} + +/** The command that configures the token, shared by message + highlight. */ +export const SET_TOKEN_COMMAND = `${PPV_CLI_TOOL_NAME} config set-token`; + +/** + * User-facing message shown when the API token is not configured, split + * into the error (what went wrong) and the guide (how to fix it, which + * embeds SET_TOKEN_COMMAND). The shared UI renders error in red, guide + * in the default colour, and the command in cyan. Shared by the snapshot + * picker and the snapshot manager so both stay identical (ppex / pptop + * differ only by language). The ppv-cli CLI keeps its own stderr wording. + */ +export const TOKEN_MISSING_MESSAGE = { + ja: { + error: 'PROTOPEDIA_API_V2_TOKEN が未設定です。', + guide: `${SET_TOKEN_COMMAND} で設定してください。`, + }, + en: { + error: 'PROTOPEDIA_API_V2_TOKEN is not set.', + guide: `Run: ${SET_TOKEN_COMMAND}`, + }, +} as const; diff --git a/src/core/user-dirs.ts b/src/core/user-dirs.ts new file mode 100644 index 0000000..4f1de8a --- /dev/null +++ b/src/core/user-dirs.ts @@ -0,0 +1,32 @@ +/** + * User-level directories of the ppv-cli tools (issue #7). + * + * Everything lives under a single, discoverable dot directory in the + * user's home (`~/.ppv-cli`), the same shape on every OS (Windows has + * no name-based hiding, so it is plainly visible in Explorer): + * + * ~/.ppv-cli/ + * config KEY=VALUE (dotenv format): token and PPV_CLI_* + * snapshots/ default snapshot directory + * + * The classic dot-dir pattern (cargo, npm, aws) was chosen over + * XDG / APPDATA deliberately: it is easier to find and to document, + * and needs zero platform branching. + */ +import os from 'node:os'; +import path from 'node:path'; + +/** Root of all ppv-cli user files: `~/.ppv-cli`. */ +export function ppCliHome(): string { + return path.join(os.homedir(), '.ppv-cli'); +} + +/** Global config file (dotenv format): `~/.ppv-cli/config`. */ +export function globalConfigPath(): string { + return path.join(ppCliHome(), 'config'); +} + +/** Built-in default snapshot directory: `~/.ppv-cli/snapshots`. */ +export function defaultSnapshotDir(): string { + return path.join(ppCliHome(), 'snapshots'); +} diff --git a/src/core/version.ts b/src/core/version.ts new file mode 100644 index 0000000..72b2f5d --- /dev/null +++ b/src/core/version.ts @@ -0,0 +1,36 @@ +/** + * Tool version, read from package.json so there is a single source + * of truth for chore(release) bumps. + * + * The runtime read below works from both the compiled layout + * (dist/core/) and the tsx source layout (src/core/): two levels up is + * the package root either way. But when the Ink binaries are bundled by + * esbuild every module collapses into a single dist/*.js file, so + * `import.meta.url` no longer points two levels below the root and the + * relative read would miss. For that build the version is baked in via + * an esbuild `define` for `__PPCLI_VERSION__`; the read is then dead + * code and is eliminated. Under tsc/tsx the constant is undefined and + * the read runs as before. `typeof` on the undeclared global is safe. + */ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +declare const __PPCLI_VERSION__: string | undefined; + +function readPackageVersion(): string { + const packageJsonPath = path.join( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..', + 'package.json', + ); + return ( + JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { version: string } + ).version; +} + +export const VERSION: string = + typeof __PPCLI_VERSION__ === 'string' + ? __PPCLI_VERSION__ + : readPackageVersion(); diff --git a/src/ppc/commands/config-init.test.ts b/src/ppc/commands/config-init.test.ts new file mode 100644 index 0000000..4e654bb --- /dev/null +++ b/src/ppc/commands/config-init.test.ts @@ -0,0 +1,90 @@ +import { mkdtemp, readFile, rm, writeFile, mkdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { runConfigInit } from './config-init.js'; + +describe('runConfigInit', () => { + let home: string; + let savedEnv: Record; + let out: string; + + beforeEach(async () => { + home = await mkdtemp(path.join(tmpdir(), 'ppv-cli-init-')); + savedEnv = { + HOME: process.env.HOME, + USERPROFILE: process.env.USERPROFILE, + }; + process.env.HOME = home; + process.env.USERPROFILE = home; + out = ''; + vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + out += String(chunk); + return true; + }); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + await rm(home, { recursive: true, force: true }); + }); + + const configPath = () => path.join(home, '.ppv-cli', 'config'); + + it('creates ~/.ppv-cli/config from the documented template', async () => { + const code = await runConfigInit(); + expect(code).toBe(0); + const content = await readFile(configPath(), 'utf8'); + expect(content).toContain('PROTOPEDIA_API_V2_TOKEN=your-token-here'); + expect(content).toContain('# PPV_CLI_SNAPSHOT_DIR='); + expect(content).toContain('# PPV_CLI_LOG_LEVEL=info'); + expect(out).toContain(`設定ファイルを作成しました: ${configPath()}`); + }); + + it('keeps an existing file when the user answers No', async () => { + await mkdir(path.dirname(configPath()), { recursive: true }); + await writeFile(configPath(), 'PPV_CLI_LOG_LEVEL=debug\n', 'utf8'); + const code = await runConfigInit(async () => false); + expect(code).toBe(0); + expect(await readFile(configPath(), 'utf8')).toBe( + 'PPV_CLI_LOG_LEVEL=debug\n', + ); + expect(out).toContain('上書きしませんでした。'); + }); + + it('overwrites with the template when the user answers Yes', async () => { + await mkdir(path.dirname(configPath()), { recursive: true }); + await writeFile( + configPath(), + 'PROTOPEDIA_API_V2_TOKEN=old\nPPV_CLI_LOG_LEVEL=debug\n', + 'utf8', + ); + const code = await runConfigInit(async () => true); + expect(code).toBe(0); + const content = await readFile(configPath(), 'utf8'); + expect(content).toContain('PROTOPEDIA_API_V2_TOKEN=your-token-here'); + expect(content).not.toContain('PROTOPEDIA_API_V2_TOKEN=old'); + expect(content).not.toContain('PPV_CLI_LOG_LEVEL=debug'); + expect(out).toContain(`設定を初期化しました: ${configPath()}`); + }); + + it('never overwrites without a TTY (default confirm path)', async () => { + await mkdir(path.dirname(configPath()), { recursive: true }); + await writeFile(configPath(), 'PPV_CLI_LOG_LEVEL=debug\n', 'utf8'); + const code = await runConfigInit(); + expect(code).toBe(0); + expect(await readFile(configPath(), 'utf8')).toBe( + 'PPV_CLI_LOG_LEVEL=debug\n', + ); + expect(out).toContain('非対話環境のため上書き確認は行いません。'); + }); +}); diff --git a/src/ppc/commands/config-init.ts b/src/ppc/commands/config-init.ts new file mode 100644 index 0000000..3b47878 --- /dev/null +++ b/src/ppc/commands/config-init.ts @@ -0,0 +1,81 @@ +/** + * `ppc config init` - initializes the global config file + * from the documented template (issue #7). + * + * When the file already exists, asks interactively whether to + * overwrite it with the template (default: No). Overwriting replaces + * the whole content, the token included. In a non-interactive + * environment the question cannot be asked, so the file is never + * overwritten. + */ +import { createInterface } from 'node:readline'; + +import { + createGlobalConfig, + globalConfigExists, +} from '../../core/config-file.js'; +import { PPV_CLI_TOOL_NAME } from '../../core/constants.js'; +import { SET_TOKEN_COMMAND } from '../../core/token.js'; +import { globalConfigPath } from '../../core/user-dirs.js'; +import { print } from '../output.js'; + +/** Asks a yes/no question on the TTY. Exported type for test injection. */ +export type ConfirmReader = (promptText: string) => Promise; + +const confirmFromTty: ConfirmReader = (promptText) => + new Promise((resolve) => { + const rl = createInterface({ + input: process.stdin, + output: process.stderr, + }); + rl.question(promptText, (answer) => { + rl.close(); + resolve(answer.trim().toLowerCase() === 'y'); + }); + }); + +/** + * Creates the config file from the template and reports the path. + * This is init's creation step; config set-token also runs it when + * the file does not exist yet. + */ +export async function createConfigWithNotice(): Promise { + const configPath = await createGlobalConfig(); + print(`設定ファイルを作成しました: ${configPath}`); +} + +/** + * Entry point for `ppc config init`. Returns the process exit code. + */ +export async function runConfigInit( + confirm: ConfirmReader = confirmFromTty, +): Promise { + const configPath = globalConfigPath(); + + if (await globalConfigExists()) { + if (confirm === confirmFromTty && !process.stdin.isTTY) { + print(`設定ファイルは既に存在します: ${configPath}`); + print('非対話環境のため上書き確認は行いません。'); + return 0; + } + const overwrite = await confirm( + `設定ファイルは既に存在します: ${configPath}\n` + + 'テンプレートで上書きしますか? 現在の内容(トークンを含む)は失われます (y/N): ', + ); + if (!overwrite) { + print( + `上書きしませんでした。内容の確認は ${PPV_CLI_TOOL_NAME} config show で行えます。`, + ); + return 0; + } + await createGlobalConfig(); + print(`設定を初期化しました: ${configPath}`); + print(`トークンは ${SET_TOKEN_COMMAND} で設定してください。`); + return 0; + } + + await createConfigWithNotice(); + print(`トークンは ${SET_TOKEN_COMMAND} で設定してください。`); + print('その他の設定を変更する場合はファイルを直接編集して下さい。'); + return 0; +} diff --git a/src/ppc/commands/config-set-token.test.ts b/src/ppc/commands/config-set-token.test.ts new file mode 100644 index 0000000..4cb9426 --- /dev/null +++ b/src/ppc/commands/config-set-token.test.ts @@ -0,0 +1,147 @@ +import { + chmod, + mkdir, + mkdtemp, + readFile, + rm, + stat, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { runConfigSetToken } from './config-set-token.js'; +import { resetConfigEntriesForTests } from '../../core/config-entries.js'; + +const KEY = 'PROTOPEDIA_API_V2_TOKEN'; + +describe('runConfigSetToken', () => { + let home: string; + let savedEnv: Record; + let out: string; + let err: string; + + beforeEach(async () => { + home = await mkdtemp(path.join(tmpdir(), 'ppv-cli-settoken-')); + savedEnv = { + HOME: process.env.HOME, + USERPROFILE: process.env.USERPROFILE, + }; + process.env.HOME = home; + process.env.USERPROFILE = home; + // set-token decides create-vs-update from the startup snapshot. + resetConfigEntriesForTests(); + out = ''; + err = ''; + vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + out += String(chunk); + return true; + }); + vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + err += String(chunk); + return true; + }); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + resetConfigEntriesForTests(); + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + await rm(home, { recursive: true, force: true }); + }); + + const configPath = () => path.join(home, '.ppv-cli', 'config'); + + it('creates ~/.ppv-cli/config with the token and says so', async () => { + const code = await runConfigSetToken(async () => 'tok-123'); + expect(code).toBe(0); + const content = await readFile(configPath(), 'utf8'); + expect(content).toContain(`${KEY}=tok-123`); + // The rest of the template survives the token upsert. + expect(content).toContain('# PPV_CLI_LOG_LEVEL=info'); + // Init's creation step reports the new file, then the save. + expect(out).toContain(`設定ファイルを作成しました: ${configPath()}`); + expect(out).toContain('保存しました'); + if (process.platform !== 'win32') { + const mode = (await stat(configPath())).mode & 0o777; + expect(mode).toBe(0o600); + } + }); + + it('replaces the token but preserves other settings', async () => { + await mkdir(path.dirname(configPath()), { recursive: true }); + await writeFile( + configPath(), + `PPV_CLI_SNAPSHOT_DIR=/data\n${KEY}=old-token\n`, + 'utf8', + ); + const code = await runConfigSetToken(async () => 'new-token'); + expect(code).toBe(0); + const content = await readFile(configPath(), 'utf8'); + expect(content).toContain(`${KEY}=new-token`); + expect(content).not.toContain('old-token'); + expect(content).toContain('PPV_CLI_SNAPSHOT_DIR=/data'); + // An existing file gets its path reported, not a creation notice. + expect(out).toContain(`設定ファイル: ${configPath()}`); + expect(out).not.toContain('作成しました'); + expect(out).toContain('保存しました'); + }); + + it('replaces the template placeholder in place', async () => { + // First run init's creation step implicitly, then set the token: + // the placeholder line is replaced where it stands, not appended. + await runConfigSetToken(async () => 'real-token'); + const content = await readFile(configPath(), 'utf8'); + expect(content).toContain(`${KEY}=real-token`); + expect(content).not.toContain('your-token-here'); + }); + + it('rejects empty input without writing the token', async () => { + const code = await runConfigSetToken(async () => ' '); + expect(code).toBe(1); + expect(err).toContain('[error] トークンが入力されませんでした。'); + // The file was created by init's creation step (before the + // prompt), but no token was written into it. + const content = await readFile(configPath(), 'utf8'); + expect(content).toContain(`${KEY}=your-token-here`); + }); + + it('rejects input outside printable ASCII without writing', async () => { + // An arrow key in raw mode arrives as an invisible escape + // sequence; a paste can carry whitespace or non-ASCII. + const withEscape = 'abc\u001B[Adef'; + for (const invalid of [withEscape, 'abc def', 'トークン123']) { + err = ''; + const code = await runConfigSetToken(async () => invalid); + expect(code).toBe(1); + expect(err).toContain('入力値に使用できない文字が含まれています'); + expect(err).toContain('トークン設定処理を中止します'); + } + // The template file was created, but no token was written. + const content = await readFile(configPath(), 'utf8'); + expect(content).toContain(`${KEY}=your-token-here`); + }); + + it.skipIf(process.platform === 'win32')( + 'aborts when the file exists but is unreadable', + async () => { + await mkdir(path.dirname(configPath()), { recursive: true }); + await writeFile(configPath(), `${KEY}=old-token\n`, 'utf8'); + await chmod(configPath(), 0o000); + const code = await runConfigSetToken(async () => 'new-token'); + expect(code).toBe(1); + expect(err).toContain('[error] 設定ファイルを更新できませんでした'); + // The existing file was NOT replaced with the template. + await chmod(configPath(), 0o600); + expect(await readFile(configPath(), 'utf8')).toBe(`${KEY}=old-token\n`); + }, + ); +}); diff --git a/src/ppc/commands/config-set-token.ts b/src/ppc/commands/config-set-token.ts new file mode 100644 index 0000000..7fbc2b9 --- /dev/null +++ b/src/ppc/commands/config-set-token.ts @@ -0,0 +1,133 @@ +/** + * `ppc config set-token` - interactively stores the ProtoPedia API + * token in the global config file, so users never + * have to edit a dotfile by hand (issue #7). + * + * The token is read from an interactive masked prompt on purpose: + * a --token argument would leak the secret into the shell history, + * so none is provided. + */ +import { TOKEN_KEYS } from 'promidas-utils/token'; + +import { createConfigWithNotice } from './config-init.js'; +import { configEntries } from '../../core/config-entries.js'; +import { + updateGlobalConfig, + upsertConfigEntry, +} from '../../core/config-file.js'; +import { globalConfigPath } from '../../core/user-dirs.js'; +import { print, printError } from '../output.js'; + +const TOKEN_KEY = TOKEN_KEYS.PROTOPEDIA_API_V2_TOKEN; + +/** + * Reads one line from a TTY stdin without echoing it (each keystroke + * is masked). Exported type for test injection. + */ +export type SecretReader = (promptText: string) => Promise; + +const readSecretFromTty: SecretReader = (promptText) => + new Promise((resolve) => { + const { stdin, stderr } = process; + stderr.write(promptText); + stdin.setRawMode(true); + stdin.resume(); + stdin.setEncoding('utf8'); + let value = ''; + const onData = (chunk: string): void => { + for (const char of chunk) { + if (char === '\r' || char === '\n') { + stdin.off('data', onData); + stdin.setRawMode(false); + stdin.pause(); + stderr.write('\n'); + resolve(value); + return; + } + if (char === '\u0003') { + // Ctrl+C + stdin.setRawMode(false); + stderr.write('\n'); + process.exit(1); + } + if (char === '\u007F' || char === '\b') { + if (value.length > 0) { + value = value.slice(0, -1); + stderr.write('\b \b'); + } + continue; + } + value += char; + stderr.write('*'); + } + }; + stdin.on('data', onData); + }); + +/** + * Entry point for `ppc config set-token`. Returns the process exit code. + */ +export async function runConfigSetToken( + readSecret: SecretReader = readSecretFromTty, +): Promise { + if (readSecret === readSecretFromTty && !process.stdin.isTTY) { + printError( + 'set-token は対話的なターミナル (TTY) でのみ実行できます。' + + `非対話環境では ${globalConfigPath()} に直接記述してください。`, + ); + return 1; + } + + // Run init's creation step when the file does not exist yet, so + // the user is told a file was created and where. + if (configEntries().fileStatus === 'missing') { + await createConfigWithNotice(); + } else { + print(`設定ファイル: ${globalConfigPath()}`); + print(''); + } + + print('ProtoPedia API v2 のトークンを設定します'); + print('参考: https://protopediav2.docs.apiary.io/'); + print(''); + + print('トークンを入力して下さい (入力内容は画面には表示されません)'); + const token = (await readSecret('> ')).trim(); + if (token === '') { + printError('トークンが入力されませんでした。中止します。'); + return 1; + } + // Minimal check on the ENTERED STRING, nothing more. The token + // format itself is unknown here (realistically alphanumeric, but + // symbols are possible) - only the ProtoPedia API can judge a + // token. This rejects input that cannot be stored and read back + // faithfully in unquoted KEY=VALUE: control characters (raw-mode + // input silently picks up invisible key-escape sequences), + // whitespace and non-ASCII. + if (!/^[\u0021-\u007E]+$/.test(token)) { + printError( + '入力値に使用できない文字が含まれています ' + + '(空白・制御文字・非 ASCII 文字は不可)', + ); + printError('トークン設定処理を中止します'); + return 1; + } + + try { + await updateGlobalConfig((content) => + upsertConfigEntry(content, TOKEN_KEY, token), + ); + } catch (error) { + // The file exists but could not be read or written back (e.g. a + // permission problem). Never fall back to recreating it - that + // would silently destroy the user's settings. + printError( + `設定ファイルを更新できませんでした: ${globalConfigPath()} (${String(error)})`, + ); + return 1; + } + + print('保存しました'); + + return 0; +} diff --git a/src/ppc/commands/config-show.test.ts b/src/ppc/commands/config-show.test.ts new file mode 100644 index 0000000..46a6005 --- /dev/null +++ b/src/ppc/commands/config-show.test.ts @@ -0,0 +1,174 @@ +import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { maskToken, runConfigShow } from './config-show.js'; +import { + initConfigEntries, + resetConfigEntriesForTests, +} from '../../core/config-entries.js'; +import { PPV_CLI_TOOL_NAME } from '../../core/constants.js'; + +const KEY = 'PROTOPEDIA_API_V2_TOKEN'; + +describe('maskToken', () => { + it('keeps only the last 4 characters', () => { + expect(maskToken('abcdefgh')).toBe('****efgh'); + expect(maskToken('secret-token-abcd')).toBe('****abcd'); + }); + + it('hides values shorter than 8 characters entirely', () => { + // Showing 4 of 5 characters would expose most of the secret. + expect(maskToken('abc')).toBe('****'); + expect(maskToken('abcde')).toBe('****'); + expect(maskToken('abcdefg')).toBe('****'); + }); +}); + +describe('runConfigShow', () => { + let home: string; + let savedEnv: Record; + let out: string; + let err: string; + + beforeEach(async () => { + home = await mkdtemp(path.join(tmpdir(), 'ppv-cli-show-')); + savedEnv = { + HOME: process.env.HOME, + USERPROFILE: process.env.USERPROFILE, + }; + process.env.HOME = home; + process.env.USERPROFILE = home; + resetConfigEntriesForTests(); + out = ''; + err = ''; + vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + out += String(chunk); + return true; + }); + vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + err += String(chunk); + return true; + }); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + resetConfigEntriesForTests(); + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + await rm(home, { recursive: true, force: true }); + }); + + const configPath = () => path.join(home, '.ppv-cli', 'config'); + + async function writeConfig(content: string): Promise { + await mkdir(path.dirname(configPath()), { recursive: true }); + await writeFile(configPath(), content, 'utf8'); + initConfigEntries(); + } + + it('shows the file path and the entries the file sets', async () => { + await writeConfig(`${KEY}=secret-token-abcd\nPPV_CLI_LOG_LEVEL=debug\n`); + const code = await runConfigShow(); + expect(code).toBe(0); + expect(out).toContain(`設定ファイル: ${configPath()} (あり)`); + expect(out).toContain(`${KEY}: 設定済み (****abcd)`); + expect(out).toContain('PPV_CLI_LOG_LEVEL: debug'); + expect(err).toBe(''); + }); + + it('hides optional keys the file does not set', async () => { + await writeConfig(`${KEY}=secret-token-abcd\n`); + await runConfigShow(); + expect(out).not.toContain('PPV_CLI_SNAPSHOT_DIR'); + expect(out).not.toContain('PPV_CLI_LOG_LEVEL'); + expect(out).not.toContain('PPV_CLI_SNAPSHOT_STALE_HOURS'); + }); + + it('never prints the full token', async () => { + await writeConfig(`${KEY}=secret-token-abcd\n`); + await runConfigShow(); + expect(out).not.toContain('secret-token-abcd'); + expect(err).not.toContain('secret-token-abcd'); + }); + + it('always reports the token state, set or not', async () => { + await writeConfig('PPV_CLI_LOG_LEVEL=debug\n'); + await runConfigShow(); + expect(out).toContain(`${KEY}: 未設定`); + }); + + it('treats the template placeholder as an unset token', async () => { + await writeConfig(`${KEY}=your-token-here\n`); + await runConfigShow(); + expect(out).toContain(`${KEY}: 未設定`); + expect(out).not.toContain('設定済み'); + }); + + it('reports a missing file and points to config init', async () => { + initConfigEntries(); + const code = await runConfigShow(); + expect(code).toBe(0); + expect(out).toContain(`設定ファイル: ${configPath()} (なし)`); + expect(out).toContain(`作成用コマンド: ${PPV_CLI_TOOL_NAME} config init`); + // No entries are listed - there is no file to show. + expect(out).not.toContain(KEY); + expect(err).toBe(''); + }); + + it('shows invalid values as written, without interpretation', async () => { + await writeConfig('PPV_CLI_LOG_LEVEL=loud\n'); + await runConfigShow(); + expect(out).toContain('PPV_CLI_LOG_LEVEL: loud'); + }); + + it('ignores unrecognized keys silently', async () => { + await writeConfig(`PPV_CLI_LOGLEVEL=debug\n${KEY}=secret-token-abcd\n`); + const code = await runConfigShow(); + expect(code).toBe(0); + expect(out).not.toContain('PPV_CLI_LOGLEVEL'); + expect(err).toBe(''); + }); + + it.skipIf(process.platform === 'win32')( + 'fails when the file exists but cannot be read', + async () => { + await writeConfig(`${KEY}=secret-token-abcd\n`); + await chmod(configPath(), 0o000); + resetConfigEntriesForTests(); + initConfigEntries(); + const code = await runConfigShow(); + await chmod(configPath(), 0o600); + expect(code).toBe(1); + expect(out).toContain(`設定ファイル: ${configPath()} (あり)`); + expect(err).toContain('[error] 設定ファイルを読み込めませんでした。'); + // Unknown entries are not shown as "unset". + expect(out).not.toContain('未設定'); + }, + ); + + it('ignores environment variables (the file is the SSOT)', async () => { + const saved = process.env[KEY]; + process.env[KEY] = 'env-token-zzzz'; + try { + await writeConfig('PPV_CLI_LOG_LEVEL=debug\n'); + await runConfigShow(); + expect(out).toContain(`${KEY}: 未設定`); + expect(out).not.toContain('zzzz'); + } finally { + if (saved === undefined) { + delete process.env[KEY]; + } else { + process.env[KEY] = saved; + } + } + }); +}); diff --git a/src/ppc/commands/config-show.ts b/src/ppc/commands/config-show.ts new file mode 100644 index 0000000..3366c99 --- /dev/null +++ b/src/ppc/commands/config-show.ts @@ -0,0 +1,120 @@ +/** + * `ppc config show` - shows the contents and state of the global + * config file for users who do not open it + * themselves (issue #7): whether the file exists, whether the token + * is set, and the entries the file actually sets (as written, no + * interpretation). The only transformation is masking the token, + * whose full value is never printed. Commented-out lines and + * unrecognized keys are not settings and are not reported. + * + * show never writes: a missing file is reported as a normal state + * (exit 0, pointing to config init), while a file that exists but + * could not be read is an error (exit 1) - the entries are unknown + * then, and pretending they are "unset" would be a lie. + * + * The listing is the command's RESULT, so it goes to stdout, outside + * of the log-level control - otherwise PPV_CLI_LOG_LEVEL=error (or + * --quiet) would silence the very command that displays it. The + * warning and error lines go directly to stderr for the same reason: + * config commands are upstream of the logging configuration (they + * exist to create and repair it), so nothing they say may be + * silenceable by it. + */ +import { TOKEN_KEYS } from 'promidas-utils/token'; + +import { + configEntries, + LOG_LEVEL_KEY, + SNAPSHOT_DIR_KEY, + STALE_HOURS_KEY, +} from '../../core/config-entries.js'; +import { PPV_CLI_TOOL_NAME } from '../../core/constants.js'; +import { globalConfigPath } from '../../core/user-dirs.js'; +import { print, printError } from '../output.js'; + +import type { ConfigEntries } from '../../core/config-entries.js'; + +const TOKEN_KEY = TOKEN_KEYS.PROTOPEDIA_API_V2_TOKEN; + +/** + * Masks a secret, keeping only the last 4 characters. Values shorter + * than 8 characters are masked entirely: showing 4 of, say, 5 + * characters would expose most of the secret. + */ +export function maskToken(token: string): string { + const tail = token.length >= 8 ? token.slice(-4) : ''; + return `****${tail}`; +} + +/** + * Reports where the config file is and whether it exists, pointing + * to config init when it does not. Returns whether the file exists, + * so the caller knows if there is anything more to show. Reads the + * startup snapshot's fileStatus - the same observation every other + * output of this command is based on. + */ +function showFileStatus(config: ConfigEntries): boolean { + const exists = config.fileStatus !== 'missing'; + print(`設定ファイル: ${globalConfigPath()} (${exists ? 'あり' : 'なし'})`); + print(''); + if (!exists) { + // A missing file is the normal first-run state: everything is + // trivially unset, so listing the keys adds nothing - point to + // the way forward instead. + print(`作成用コマンド: ${PPV_CLI_TOOL_NAME} config init`); + return false; + } + + return true; +} + +/** + * Shows the entries of the config file. The token state is always + * reported (set or not); the optional keys appear only when the file + * actually sets them, values as written. + */ +function showEntries(config: ConfigEntries): void { + // The token is the one thing everyone needs to check, so its state + // is always reported, set or not. + const tokenState = + config.token !== null ? `設定済み (${maskToken(config.token)})` : '未設定'; + print(`${TOKEN_KEY}: ${tokenState}`); + + // Optional keys are part of the file's contents only when the file + // actually sets them - absent keys are not shown. + if (config.snapshotDir !== null) { + print(`${SNAPSHOT_DIR_KEY}: ${config.snapshotDir}`); + } + if (config.logLevel !== null) { + print(`${LOG_LEVEL_KEY}: ${config.logLevel}`); + } + if (config.staleHours !== null) { + print(`${STALE_HOURS_KEY}: ${config.staleHours}`); + } +} + +/** + * Entry point for `ppc config show`. Returns the process exit code: + * 0 when the state was shown (a missing file is a normal state), + * 1 when the file exists but could not be read. + */ +export async function runConfigShow(): Promise { + const config = configEntries(); + + if (!showFileStatus(config)) { + return 0; + } + if (config.fileStatus === 'unreadable') { + // The file exists but could not be read (e.g. a permission + // problem): the key states are unknown, not "unset", so showing + // the list would be a lie. show cannot fulfill its purpose here. + printError( + '設定ファイルを読み込めませんでした。ファイルの権限を確認してください。', + ); + return 1; + } + + showEntries(config); + + return 0; +} diff --git a/src/ppc/commands/data-analyze.ts b/src/ppc/commands/data-analyze.ts new file mode 100644 index 0000000..ba5ad92 --- /dev/null +++ b/src/ppc/commands/data-analyze.ts @@ -0,0 +1,52 @@ +/** + * `ppc data analyze` - loads a snapshot (latest by default) and prints the + * result of PROMIDAS' analyzePrototypes() as JSON to stdout. + * + * Works fully offline; no API token is required. + */ +import { + createStderrLogger, + resolveLibraryLogLevel, +} from '../../core/logger.js'; +import { createRepository } from '../../core/repository-factory.js'; +import { loadSnapshot, selectSnapshot } from '../snapshot-loader.js'; + +import type { LogLevel } from 'promidas/logger'; + +export type DataAnalyzeCommandOptions = { + readonly snapshot?: string; + readonly snapshotDir?: string; + readonly logLevel: LogLevel; +}; + +/** + * Entry point for `ppc data analyze`. Returns the process exit code. + */ +export async function runDataAnalyze( + options: DataAnalyzeCommandOptions, +): Promise { + const logger = createStderrLogger(options.logLevel); + + const selection = await selectSnapshot(options, logger); + if (!selection) return 1; + + const libraryLogger = createStderrLogger( + resolveLibraryLogLevel(options.logLevel), + ); + const repository = createRepository({ logger: libraryLogger }); + try { + if (!(await loadSnapshot(repository, selection, logger))) return 1; + + const analysis = await repository.analyzePrototypes(); + process.stdout.write( + `${JSON.stringify( + { snapshotFile: selection.filePath, ...analysis }, + null, + 2, + )}\n`, + ); + return 0; + } finally { + repository.dispose(); + } +} diff --git a/src/ppc/commands/data-stats.ts b/src/ppc/commands/data-stats.ts new file mode 100644 index 0000000..4b55c54 --- /dev/null +++ b/src/ppc/commands/data-stats.ts @@ -0,0 +1,58 @@ +/** + * `ppc data stats` - loads a snapshot file (latest by default) into the + * repository and prints the store stats as JSON to stdout. + * + * Works fully offline; no API token is required. + */ +import { statsToJson } from '../../core/format.js'; +import { + createStderrLogger, + resolveLibraryLogLevel, +} from '../../core/logger.js'; +import { createRepository } from '../../core/repository-factory.js'; +import { loadSnapshot, selectSnapshot } from '../snapshot-loader.js'; + +import type { LogLevel } from 'promidas/logger'; + +export type DataStatsCommandOptions = { + /** Explicit snapshot file path. Defaults to the latest in the directory. */ + readonly snapshot?: string; + readonly snapshotDir?: string; + readonly logLevel: LogLevel; +}; + +/** + * Entry point for `ppc data stats`. Returns the process exit code. + */ +export async function runDataStats( + options: DataStatsCommandOptions, +): Promise { + const logger = createStderrLogger(options.logLevel); + + const selection = await selectSnapshot(options, logger); + if (!selection) return 1; + + const libraryLogger = createStderrLogger( + resolveLibraryLogLevel(options.logLevel), + ); + const repository = createRepository({ logger: libraryLogger }); + try { + if (!(await loadSnapshot(repository, selection, logger))) return 1; + + const stats = repository.getStats(); + process.stdout.write( + `${JSON.stringify( + { + snapshotFile: selection.filePath, + snapshotTakenAt: selection.takenAt?.toISOString() ?? null, + ...statsToJson(stats), + }, + null, + 2, + )}\n`, + ); + return 0; + } finally { + repository.dispose(); + } +} diff --git a/src/ppc/commands/prototype-list.ts b/src/ppc/commands/prototype-list.ts new file mode 100644 index 0000000..4ef2089 --- /dev/null +++ b/src/ppc/commands/prototype-list.ts @@ -0,0 +1,88 @@ +/** + * `ppc prototype list` - loads a snapshot (latest by default) and lists + * prototypes as tab-separated lines (id, name, users), ID ascending. + * + * Unlike `prototype search` (which requires criteria), `list` enumerates + * without conditions. Exactly one of --all / --first / --last is + * required (the CLI layer shows help when none is given); --first / + * --last narrow the output to the n smallest / largest IDs while + * keeping the ascending display order. + * + * Works fully offline; no API token is required. + */ +import { + createStderrLogger, + resolveLibraryLogLevel, +} from '../../core/logger.js'; +import { createRepository } from '../../core/repository-factory.js'; +import { formatPrototypeRow } from '../format-prototype-row.js'; +import { loadSnapshot, selectSnapshot } from '../snapshot-loader.js'; + +import type { LogLevel } from 'promidas/logger'; + +export type PrototypeListCommandOptions = { + /** Show all prototypes. Mutually exclusive with `first` / `last`. */ + readonly all?: boolean; + /** Show only the n smallest IDs. Mutually exclusive with the others. */ + readonly first?: number; + /** Show only the n largest IDs. Mutually exclusive with the others. */ + readonly last?: number; + readonly snapshot?: string; + readonly snapshotDir?: string; + readonly logLevel: LogLevel; +}; + +/** + * Entry point for `ppc prototype list`. Returns the process exit code. + */ +export async function runPrototypeList( + options: PrototypeListCommandOptions, +): Promise { + const logger = createStderrLogger(options.logLevel); + + const rangeOptionCount = [ + options.all === true, + options.first !== undefined, + options.last !== undefined, + ].filter(Boolean).length; + if (rangeOptionCount !== 1) { + logger.error( + '--all / --first / --last のいずれか 1 つを指定してください。', + ); + return 1; + } + + const selection = await selectSnapshot(options, logger); + if (!selection) return 1; + + const libraryLogger = createStderrLogger( + resolveLibraryLogLevel(options.logLevel), + ); + const repository = createRepository({ logger: libraryLogger }); + try { + if (!(await loadSnapshot(repository, selection, logger))) return 1; + + const all = [...(await repository.getAllFromSnapshot())].sort( + (a, b) => a.id - b.id, + ); + + let listed: typeof all; + if (options.first !== undefined) { + listed = all.slice(0, options.first); + logger.info(`先頭${listed.length}件を表示します (全${all.length}件)`); + } else if (options.last !== undefined) { + listed = all.slice(Math.max(0, all.length - options.last)); + logger.info(`末尾${listed.length}件を表示します (全${all.length}件)`); + } else { + listed = all; + logger.info(`全${all.length}件を表示します`); + } + + for (const prototype of listed) { + process.stdout.write(`${formatPrototypeRow(prototype)}\n`); + } + return 0; + } finally { + repository.dispose(); + } +} diff --git a/src/ppc/commands/prototype-search.ts b/src/ppc/commands/prototype-search.ts new file mode 100644 index 0000000..b4e16b8 --- /dev/null +++ b/src/ppc/commands/prototype-search.ts @@ -0,0 +1,67 @@ +/** + * `ppc prototype search [keywords...]` - loads a snapshot (latest by + * default) and filters prototypes by the given criteria. + * + * Criteria (including multiple keywords, Google-search style) are + * combined with AND. Results are printed to stdout as tab-separated + * lines: id, name, users. Works fully offline. + */ +import { + createStderrLogger, + resolveLibraryLogLevel, +} from '../../core/logger.js'; +import { createRepository } from '../../core/repository-factory.js'; +import { + filterPrototypes, + hasAnyCriteria, + type SearchCriteria, +} from '../explorer.js'; +import { formatPrototypeRow } from '../format-prototype-row.js'; +import { loadSnapshot, selectSnapshot } from '../snapshot-loader.js'; + +import type { LogLevel } from 'promidas/logger'; + +export type PrototypeSearchCommandOptions = { + readonly criteria: SearchCriteria; + readonly snapshot?: string; + readonly snapshotDir?: string; + readonly logLevel: LogLevel; +}; + +/** + * Entry point for `ppc prototype search`. Returns the process exit code. + */ +export async function runPrototypeSearch( + options: PrototypeSearchCommandOptions, +): Promise { + const logger = createStderrLogger(options.logLevel); + + if (!hasAnyCriteria(options.criteria)) { + logger.error( + '検索条件を指定してください (キーワード (複数可、AND)、--tag、--user、--event、--material、--status)。', + ); + return 1; + } + + const selection = await selectSnapshot(options, logger); + if (!selection) return 1; + + const libraryLogger = createStderrLogger( + resolveLibraryLogLevel(options.logLevel), + ); + const repository = createRepository({ logger: libraryLogger }); + try { + if (!(await loadSnapshot(repository, selection, logger))) return 1; + + const all = await repository.getAllFromSnapshot(); + const hits = filterPrototypes(all, options.criteria); + + logger.info(`${hits.length}件ヒットしました (全${all.length}件)`); + for (const prototype of hits) { + process.stdout.write(`${formatPrototypeRow(prototype)}\n`); + } + return 0; + } finally { + repository.dispose(); + } +} diff --git a/src/ppc/commands/prototype-show.ts b/src/ppc/commands/prototype-show.ts new file mode 100644 index 0000000..0abefea --- /dev/null +++ b/src/ppc/commands/prototype-show.ts @@ -0,0 +1,54 @@ +/** + * `ppc prototype show ` - loads a snapshot (latest by default) and prints the + * prototype with the given ID as JSON to stdout. + * + * Works fully offline; no API token is required. + */ +import { + createStderrLogger, + resolveLibraryLogLevel, +} from '../../core/logger.js'; +import { createRepository } from '../../core/repository-factory.js'; +import { loadSnapshot, selectSnapshot } from '../snapshot-loader.js'; + +import type { LogLevel } from 'promidas/logger'; + +export type PrototypeShowCommandOptions = { + readonly prototypeId: number; + readonly snapshot?: string; + readonly snapshotDir?: string; + readonly logLevel: LogLevel; +}; + +/** + * Entry point for `ppc prototype show`. Returns the process exit code. + */ +export async function runPrototypeShow( + options: PrototypeShowCommandOptions, +): Promise { + const logger = createStderrLogger(options.logLevel); + + const selection = await selectSnapshot(options, logger); + if (!selection) return 1; + + const libraryLogger = createStderrLogger( + resolveLibraryLogLevel(options.logLevel), + ); + const repository = createRepository({ logger: libraryLogger }); + try { + if (!(await loadSnapshot(repository, selection, logger))) return 1; + + const prototype = await repository.getPrototypeFromSnapshotByPrototypeId( + options.prototypeId, + ); + if (prototype === null) { + logger.error(`ID ${options.prototypeId} の作品はありません。`); + return 1; + } + + process.stdout.write(`${JSON.stringify(prototype, null, 2)}\n`); + return 0; + } finally { + repository.dispose(); + } +} diff --git a/src/ppc/commands/snapshot-create.test.ts b/src/ppc/commands/snapshot-create.test.ts new file mode 100644 index 0000000..a102593 --- /dev/null +++ b/src/ppc/commands/snapshot-create.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createProgressRenderer } from './snapshot-create.js'; + +import type { FetchProgressEvent } from 'promidas/fetcher'; + +/** Runs the events through a fresh renderer and returns the stderr text. */ +function renderToStderr(events: readonly FetchProgressEvent[]): string { + let out = ''; + vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + out += String(chunk); + return true; + }); + const emit = createProgressRenderer(); + for (const event of events) emit(event); + return out; +} + +describe('createProgressRenderer', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('shows size, percentage, and completion for a successful (2xx) download', () => { + const out = renderToStderr([ + { + type: 'response-received', + status: 200, + prepareTimeMs: 100, + estimatedTotal: 2048, + limit: 10_000, + }, + { + type: 'download-progress', + status: 200, + received: 1024, + total: 2048, + percentage: 50, + }, + { + type: 'complete', + status: 200, + received: 2048, + estimatedTotal: 2048, + downloadTimeMs: 10, + totalTimeMs: 120, + }, + ]); + expect(out).toContain('推定サイズ'); + expect(out).toContain('ダウンロード中'); + expect(out).toContain('ダウンロード完了'); + expect(out).not.toContain('HTTP'); + }); + + it('does not present a 4xx error body as a successful download (promidas #126)', () => { + // A 401 without Content-Length: the small error JSON streams to the + // end, so `complete` fires carrying status 401. + const out = renderToStderr([ + { + type: 'response-received', + status: 401, + prepareTimeMs: 100, + estimatedTotal: 26_738_688, + limit: 10_000, + }, + { + type: 'download-progress', + status: 401, + received: 97, + total: 0, + percentage: 0, + }, + { + type: 'complete', + status: 401, + received: 97, + estimatedTotal: 26_738_688, + downloadTimeMs: 5, + totalTimeMs: 119, + }, + ]); + // The status replaces the meaningless size estimate... + expect(out).toContain('レスポンス受信 (HTTP 401)'); + expect(out).not.toContain('推定サイズ'); + // ...the meaningless progress line is suppressed... + expect(out).not.toContain('ダウンロード中'); + // ...and completion does not read as a success. + expect(out).toContain('HTTP 401'); + expect(out).not.toContain('ダウンロード完了'); + }); +}); diff --git a/src/ppc/commands/snapshot-create.ts b/src/ppc/commands/snapshot-create.ts new file mode 100644 index 0000000..108548b --- /dev/null +++ b/src/ppc/commands/snapshot-create.ts @@ -0,0 +1,183 @@ +/** + * `ppc snapshot create` - fetches all prototypes from the ProtoPedia API + * and saves them as a new snapshot file. + * + * This is the only command that talks to the API (and therefore the only + * one that needs the token). Fetching is always an explicit user action; + * the CLI never fetches automatically. + */ +import path from 'node:path'; + +import { exportSnapshotToFile } from 'promidas-utils/file-io'; +import { toLocalizedMessage } from 'promidas-utils/repository'; + +import { describeFileIoError } from '../../core/file-io-errors.js'; +import { formatBytes, statsToJson } from '../../core/format.js'; +import { + createStderrLogger, + resolveLibraryLogLevel, +} from '../../core/logger.js'; +import { createRepository } from '../../core/repository-factory.js'; +import { + buildSnapshotFileName, + resolveSnapshotDir, +} from '../../core/snapshot-catalog.js'; +import { resolveToken, SET_TOKEN_COMMAND } from '../../core/token.js'; + +import type { FetchProgressEvent } from 'promidas/fetcher'; +import type { LogLevel } from 'promidas/logger'; + +export type SnapshotCreateCommandOptions = { + readonly limit: number; + readonly snapshotDir?: string; + readonly logLevel: LogLevel; +}; + +/** + * Renders download progress to stderr. + * Uses carriage returns for in-place percentage updates. + * + * Exported for unit testing. `complete` fires for 4xx/5xx too (the error + * body still transfers to the end), so the rendering consults each + * event's HTTP `status` to avoid presenting an error response as a + * successful download (promidas #126). + */ +export function createProgressRenderer(): (event: FetchProgressEvent) => void { + let inProgressLine = false; + const endProgressLine = (): void => { + if (inProgressLine) { + process.stderr.write('\n'); + inProgressLine = false; + } + }; + + return (event) => { + switch (event.type) { + case 'request-start': + process.stderr.write('API リクエストを開始しました...\n'); + break; + case 'response-received': + // For a 4xx/5xx the estimatedTotal (derived from the URL limit) + // is meaningless against the small error body, so show the status + // instead of a misleading size estimate. + process.stderr.write( + event.status >= 400 + ? `レスポンス受信 (HTTP ${event.status})\n` + : `レスポンス受信 (推定サイズ: ${formatBytes(event.estimatedTotal)})\n`, + ); + break; + case 'download-progress': { + // The body being streamed for a 4xx/5xx is the error payload + // (e.g. a 401 JSON): its percentage and size say nothing about a + // fetch, so do not render a progress line for it. + if (event.status >= 400) break; + const percentage = + typeof event.percentage === 'number' + ? `${event.percentage.toFixed(1)}%` + : '?%'; + process.stderr.write( + `\rダウンロード中 ${percentage} (${formatBytes(event.received)})`, + ); + inProgressLine = true; + break; + } + case 'complete': + endProgressLine(); + // `complete` also fires for 4xx/5xx once the error body finishes + // transferring (WHATWG fetch semantics), so check the status to + // avoid presenting it as a successful download (promidas #126). + process.stderr.write( + event.status >= 400 + ? `レスポンス受信完了 (HTTP ${event.status}, ${formatBytes(event.received)})\n` + : `ダウンロード完了 (${formatBytes(event.received)}, ${event.totalTimeMs}ms)\n`, + ); + break; + case 'error': + endProgressLine(); + process.stderr.write(`ダウンロードエラー: ${event.error}\n`); + break; + } + }; +} + +/** + * Entry point for `ppc snapshot create`. Returns the process exit code. + */ +export async function runSnapshotCreate( + options: SnapshotCreateCommandOptions, +): Promise { + const logger = createStderrLogger(options.logLevel); + + const token = resolveToken(); + if (token === null) { + logger.error('PROTOPEDIA_API_V2_TOKEN が設定されていません。'); + logger.error(`${SET_TOKEN_COMMAND} を実行してトークンを設定してください。`); + return 1; + } + + const libraryLogger = createStderrLogger( + resolveLibraryLogLevel(options.logLevel), + ); + const repository = createRepository({ + token, + logger: libraryLogger, + onProgress: createProgressRenderer(), + }); + + try { + repository.events?.on('snapshotStarted', (params) => { + logger.debug('snapshotStarted', params); + }); + repository.events?.on('snapshotCompleted', (stats) => { + logger.debug('snapshotCompleted', { size: stats.size }); + }); + repository.events?.on('snapshotFailed', (failure) => { + logger.debug('snapshotFailed', { + origin: failure.origin, + message: failure.message, + }); + }); + + logger.info(`ProtoPedia API から取得します (limit: ${options.limit})`); + const result = await repository.setupSnapshot({ + limit: options.limit, + offset: 0, + }); + if (!result.ok) { + logger.error(`データ取得に失敗しました: ${toLocalizedMessage(result)}`); + return 1; + } + + const stats = result.stats; + const snapshotDir = resolveSnapshotDir(options.snapshotDir); + const fileName = buildSnapshotFileName( + stats.cachedAt ?? new Date(), + stats.size, + ); + const exportResult = await exportSnapshotToFile( + repository, + path.join(snapshotDir, fileName), + ); + if (!exportResult.ok) { + logger.error( + `snapshot の保存に失敗しました: ${describeFileIoError(exportResult.error)}`, + ); + return 1; + } + + logger.info( + `snapshot を保存しました: ${exportResult.filePath} ` + + `(${exportResult.prototypesExported}件, ${formatBytes(exportResult.bytesWritten)})`, + ); + process.stdout.write( + `${JSON.stringify( + { snapshotFile: exportResult.filePath, ...statsToJson(stats) }, + null, + 2, + )}\n`, + ); + return 0; + } finally { + repository.dispose(); + } +} diff --git a/src/ppc/commands/snapshot-list.ts b/src/ppc/commands/snapshot-list.ts new file mode 100644 index 0000000..d4f8fc5 --- /dev/null +++ b/src/ppc/commands/snapshot-list.ts @@ -0,0 +1,46 @@ +/** + * `ppc snapshot list` - lists saved snapshot files, newest first. + */ +import { PPV_CLI_TOOL_NAME } from '../../core/constants.js'; +import { formatLocalDateTime, formatSizeMB } from '../../core/format.js'; +import { createStderrLogger } from '../../core/logger.js'; +import { + listSnapshots, + resolveSnapshotDir, +} from '../../core/snapshot-catalog.js'; + +import type { LogLevel } from 'promidas/logger'; + +export type SnapshotListCommandOptions = { + readonly snapshotDir?: string; + readonly logLevel: LogLevel; +}; + +/** + * Entry point for `ppc snapshot list`. Returns the process exit code. + */ +export async function runSnapshotList( + options: SnapshotListCommandOptions, +): Promise { + const logger = createStderrLogger(options.logLevel); + const snapshotDir = resolveSnapshotDir(options.snapshotDir); + const infos = await listSnapshots(snapshotDir); + + if (infos.length === 0) { + logger.info( + `snapshot はまだありません (${snapshotDir})。\`${PPV_CLI_TOOL_NAME} snapshot create\` で作成できます。`, + ); + return 0; + } + + logger.info(`snapshot ディレクトリ: ${snapshotDir}`); + for (const info of infos) { + const takenAt = info.takenAt + ? formatLocalDateTime(info.takenAt) + : '(命名規則外) '; + const count = info.count !== null ? `${info.count}件` : '-'; + const size = formatSizeMB(info.sizeBytes); + process.stdout.write(`${takenAt}\t${count}\t${size}\t${info.fileName}\n`); + } + return 0; +} diff --git a/src/ppc/explorer.test.ts b/src/ppc/explorer.test.ts new file mode 100644 index 0000000..71190a7 --- /dev/null +++ b/src/ppc/explorer.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest'; + +import { filterPrototypes, hasAnyCriteria } from './explorer.js'; + +import type { NormalizedPrototype } from 'promidas/types'; + +function prototype( + overrides: Partial & { id: number }, +): NormalizedPrototype { + return { + createDate: '2026-01-01T00:00:00.000Z', + releaseFlg: 1, + status: 1, + prototypeNm: `Prototype ${overrides.id}`, + summary: '', + freeComment: '', + systemDescription: '', + users: [], + teamNm: '', + tags: [], + materials: [], + events: [], + awards: [], + mainUrl: `https://protopedia.net/prototype/${overrides.id}`, + viewCount: 0, + goodCount: 0, + commentCount: 0, + ...overrides, + } as NormalizedPrototype; +} + +// Deliberately unordered by ID to verify result sorting. +const FIXTURES: readonly NormalizedPrototype[] = [ + prototype({ + id: 3, + prototypeNm: 'Weather Station', + summary: 'Rainfall logger', + tags: ['IoT'], + users: ['carol'], + events: ['ヒーローズ・リーグ'], + materials: ['M5StickC'], + status: 3, + }), + prototype({ + id: 1, + prototypeNm: 'LED Cube', + summary: 'A shiny cube', + tags: ['LED', 'Arduino'], + users: ['alice'], + events: ['Maker Faire Tokyo'], + materials: ['M5Stack'], + status: 3, + }), + prototype({ + id: 2, + prototypeNm: 'Robot Arm', + summary: 'Arm with led indicator', + tags: ['robot'], + users: ['bob', 'carol'], + events: [], + materials: ['Raspberry Pi'], + status: 1, + }), +]; + +describe('hasAnyCriteria', () => { + it('is false for an empty criteria object', () => { + expect(hasAnyCriteria({})).toBe(false); + }); + + it('is false for empty arrays', () => { + expect(hasAnyCriteria({ keywords: [], tags: [], statuses: [] })).toBe( + false, + ); + }); + + it('is true when any criterion is set', () => { + expect(hasAnyCriteria({ keywords: ['x'] })).toBe(true); + expect(hasAnyCriteria({ tags: ['LED'] })).toBe(true); + expect(hasAnyCriteria({ statuses: [1] })).toBe(true); + }); +}); + +describe('filterPrototypes', () => { + it('returns hits sorted by ID ascending', () => { + expect(filterPrototypes(FIXTURES, {}).map((p) => p.id)).toEqual([1, 2, 3]); + }); + + it('matches a keyword against name and summary, case-insensitively', () => { + const hits = filterPrototypes(FIXTURES, { keywords: ['led'] }); + expect(hits.map((p) => p.id)).toEqual([1, 2]); + }); + + it('combines multiple keywords with AND (Google-search style)', () => { + expect( + filterPrototypes(FIXTURES, { keywords: ['led', 'cube'] }).map( + (p) => p.id, + ), + ).toEqual([1]); + expect(filterPrototypes(FIXTURES, { keywords: ['led', 'rain'] })).toEqual( + [], + ); + }); + + it('matches a tag partially and case-insensitively', () => { + expect( + filterPrototypes(FIXTURES, { tags: ['arduino'] }).map((p) => p.id), + ).toEqual([1]); + }); + + it('matches IDs exactly, ORed, and returns them sorted', () => { + expect( + filterPrototypes(FIXTURES, { ids: [3, 1] }).map((p) => p.id), + ).toEqual([1, 3]); + expect(filterPrototypes(FIXTURES, { ids: [999] })).toEqual([]); + }); + + it('combines values within one facet with OR', () => { + expect( + filterPrototypes(FIXTURES, { tags: ['LED', 'robot'] }).map((p) => p.id), + ).toEqual([1, 2]); + expect( + filterPrototypes(FIXTURES, { statuses: [1, 3] }).map((p) => p.id), + ).toEqual([1, 2, 3]); + }); + + it('matches users, events, and materials', () => { + expect( + filterPrototypes(FIXTURES, { users: ['carol'] }).map((p) => p.id), + ).toEqual([2, 3]); + expect( + filterPrototypes(FIXTURES, { events: ['リーグ'] }).map((p) => p.id), + ).toEqual([3]); + expect( + filterPrototypes(FIXTURES, { materials: ['m5'] }).map((p) => p.id), + ).toEqual([1, 3]); + }); + + it('matches status exactly', () => { + expect( + filterPrototypes(FIXTURES, { statuses: [3] }).map((p) => p.id), + ).toEqual([1, 3]); + }); + + it('combines different facets with AND', () => { + const hits = filterPrototypes(FIXTURES, { + materials: ['m5'], + statuses: [3], + users: ['alice'], + }); + expect(hits.map((p) => p.id)).toEqual([1]); + }); +}); diff --git a/src/ppc/explorer.ts b/src/ppc/explorer.ts new file mode 100644 index 0000000..2298cc9 --- /dev/null +++ b/src/ppc/explorer.ts @@ -0,0 +1,107 @@ +/** + * Search and filter logic over normalized prototypes. + * + * Pure functions, independent of the repository, so that the logic is + * unit-testable and reusable from both one-shot commands and the future + * interactive mode. + */ +import type { NormalizedPrototype } from 'promidas/types'; + +export type SearchCriteria = { + /** + * Keywords combined with AND, Google-search style: every keyword must + * partially match prototypeNm or summary (case-insensitive). + */ + readonly keywords?: readonly string[]; + /** Values are ORed; each is an exact prototype ID match. */ + readonly ids?: readonly number[]; + /** Values are ORed; each partially matches a tag (case-insensitive). */ + readonly tags?: readonly string[]; + /** Values are ORed; each partially matches a user name (case-insensitive). */ + readonly users?: readonly string[]; + /** Values are ORed; each partially matches an event name (case-insensitive). */ + readonly events?: readonly string[]; + /** Values are ORed; each partially matches a material name (case-insensitive). */ + readonly materials?: readonly string[]; + /** Values are ORed; each is an exact status code match. */ + readonly statuses?: readonly number[]; +}; + +function isSet(values: readonly T[] | undefined): values is readonly T[] { + return values !== undefined && values.length > 0; +} + +/** + * Returns true when at least one criterion is set. + */ +export function hasAnyCriteria(criteria: SearchCriteria): boolean { + return ( + isSet(criteria.keywords) || + isSet(criteria.ids) || + isSet(criteria.tags) || + isSet(criteria.users) || + isSet(criteria.events) || + isSet(criteria.materials) || + isSet(criteria.statuses) + ); +} + +function includesIgnoreCase(haystack: string, needle: string): boolean { + return haystack.toLowerCase().includes(needle.toLowerCase()); +} + +function someIncludesIgnoreCase( + values: readonly string[], + needle: string, +): boolean { + return values.some((value) => includesIgnoreCase(value, needle)); +} + +/** + * Filters prototypes by the given criteria and returns the hits sorted + * by prototype ID (ascending). + * + * Different criteria are combined with AND; values within one facet + * (tags, users, events, materials, statuses) are combined with OR. + * An empty criteria object matches everything. + */ +export function filterPrototypes( + prototypes: readonly NormalizedPrototype[], + criteria: SearchCriteria, +): NormalizedPrototype[] { + const matchesFacet = ( + values: readonly string[] | undefined, + targets: readonly string[], + ): boolean => + !isSet(values) || + values.some((value) => someIncludesIgnoreCase(targets, value)); + + return prototypes + .filter((prototype) => { + if ( + isSet(criteria.keywords) && + !criteria.keywords.every( + (keyword) => + includesIgnoreCase(prototype.prototypeNm, keyword) || + includesIgnoreCase(prototype.summary, keyword), + ) + ) { + return false; + } + if (isSet(criteria.ids) && !criteria.ids.includes(prototype.id)) { + return false; + } + if (!matchesFacet(criteria.tags, prototype.tags)) return false; + if (!matchesFacet(criteria.users, prototype.users)) return false; + if (!matchesFacet(criteria.events, prototype.events)) return false; + if (!matchesFacet(criteria.materials, prototype.materials)) return false; + if ( + isSet(criteria.statuses) && + !criteria.statuses.includes(prototype.status) + ) { + return false; + } + return true; + }) + .sort((a, b) => a.id - b.id); +} diff --git a/src/ppc/format-prototype-row.test.ts b/src/ppc/format-prototype-row.test.ts new file mode 100644 index 0000000..2933eb8 --- /dev/null +++ b/src/ppc/format-prototype-row.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; + +import { formatPrototypeRow } from './format-prototype-row.js'; + +import type { NormalizedPrototype } from 'promidas/types'; + +function proto(fields: Partial>): NormalizedPrototype { + return { + id: 0, + prototypeNm: '', + users: [], + ...fields, + } as unknown as NormalizedPrototype; +} + +describe('formatPrototypeRow', () => { + const TAB = String.fromCodePoint(0x09); + + it('formats id, name and users as a tab-separated row', () => { + const row = formatPrototypeRow( + proto({ id: 42, prototypeNm: 'LED Cube', users: ['alice', 'bob'] }), + ); + expect(row).toBe('42\tLED Cube\talice,bob'); + }); + + it('sanitizes a control char in the name without adding a column', () => { + const row = formatPrototypeRow( + proto({ id: 1, prototypeNm: `A${TAB}B`, users: ['u'] }), + ); + expect(row).toBe('1\tA B\tu'); + // Still exactly three tab-separated columns; the data control char + // was neutralized to a space and did not corrupt the structure. + expect(row.split('\t')).toHaveLength(3); + }); + + it('sanitizes a control char inside a user name', () => { + const row = formatPrototypeRow( + proto({ id: 2, prototypeNm: 'N', users: [`x${TAB}y`] }), + ); + expect(row).toBe('2\tN\tx y'); + }); +}); diff --git a/src/ppc/format-prototype-row.ts b/src/ppc/format-prototype-row.ts new file mode 100644 index 0000000..e9dcfd7 --- /dev/null +++ b/src/ppc/format-prototype-row.ts @@ -0,0 +1,19 @@ +/** + * Shared row format for `ppc prototype list` / `ppc prototype search`: + * a tab-separated `id \t name \t users` line (no trailing newline). + * + * The untrusted name / user fields are run through sanitizeDisplayText, + * so a control character in the data cannot corrupt the tab-delimited + * columns or inject a terminal escape sequence when printed. Single + * source of truth so the two commands stay in sync and the + * sanitization can never be applied to one but forgotten on the other. + */ +import { sanitizeDisplayText } from '../core/sanitize-display-text.js'; + +import type { NormalizedPrototype } from 'promidas/types'; + +export function formatPrototypeRow(prototype: NormalizedPrototype): string { + const name = sanitizeDisplayText(prototype.prototypeNm); + const users = sanitizeDisplayText(prototype.users.join(',')); + return `${prototype.id}\t${name}\t${users}`; +} diff --git a/src/ppc/output.ts b/src/ppc/output.ts new file mode 100644 index 0000000..d99aaa6 --- /dev/null +++ b/src/ppc/output.ts @@ -0,0 +1,20 @@ +/** + * Output helpers shared by the ppc config commands and the startup + * gate. Single home for these - the same implementation must not be + * repeated per file. + * + * Results go to stdout and errors directly to stderr, outside of the + * log-level control: config commands (and the gate that guards the + * config) are upstream of the logging configuration, so nothing they + * say may be silenced by PPV_CLI_LOG_LEVEL or --quiet. + */ + +/** Writes one line of a command's result to stdout. */ +export function print(line: string): void { + process.stdout.write(`${line}\n`); +} + +/** Writes an always-visible error line to stderr. */ +export function printError(line: string): void { + process.stderr.write(`[error] ${line}\n`); +} diff --git a/src/ppc/snapshot-loader.ts b/src/ppc/snapshot-loader.ts new file mode 100644 index 0000000..0f70973 --- /dev/null +++ b/src/ppc/snapshot-loader.ts @@ -0,0 +1,119 @@ +/** + * Shared snapshot selection and loading for offline commands + * (stats / show / search). + * + * Selection logic: + * - --snapshot when given, otherwise the latest conforming file + * in the snapshot directory. + * - Warns (never auto-fetches) when the selected snapshot is older than + * the staleness threshold, judged by the file name timestamp. + */ +import path from 'node:path'; + +import { importSnapshotFromFile } from 'promidas-utils/file-io'; + +import { + PPV_CLI_TOOL_NAME, + resolveSnapshotStaleAfterMs, +} from '../core/constants.js'; +import { describeFileIoError } from '../core/file-io-errors.js'; +import { formatLocalDateTime } from '../core/format.js'; +import { + findLatestSnapshot, + parseSnapshotFileName, + resolveSnapshotDir, +} from '../core/snapshot-catalog.js'; + +import type { ProtopediaInMemoryRepository } from 'promidas'; +import type { Logger } from 'promidas/logger'; + +export type SnapshotSelection = { + readonly filePath: string; + /** Taken-at time from the file name; null for non-conforming names. */ + readonly takenAt: Date | null; +}; + +export type SnapshotSelectOptions = { + /** Explicit snapshot file path. Defaults to the latest in the directory. */ + readonly snapshot?: string; + readonly snapshotDir?: string; +}; + +/** + * Resolves which snapshot file to load. Logs an error and returns null + * when no snapshot is available. + */ +export async function selectSnapshot( + options: SnapshotSelectOptions, + logger: Logger, +): Promise { + if (options.snapshot) { + const filePath = path.resolve(options.snapshot); + return { + filePath, + takenAt: parseSnapshotFileName(path.basename(filePath))?.takenAt ?? null, + }; + } + + const snapshotDir = resolveSnapshotDir(options.snapshotDir); + const latest = await findLatestSnapshot(snapshotDir); + if (!latest) { + logger.error( + `snapshot が見つかりません (${snapshotDir})。まず \`${PPV_CLI_TOOL_NAME} snapshot create\` を実行してください。`, + ); + return null; + } + return { filePath: latest.filePath, takenAt: latest.takenAt }; +} + +/** + * Loads the selected snapshot file into the repository. + * Logs the outcome (including a staleness warning) and returns whether + * the load succeeded. + */ +export async function loadSnapshot( + repository: ProtopediaInMemoryRepository, + selection: SnapshotSelection, + logger: Logger, +): Promise { + const result = await importSnapshotFromFile(repository, selection.filePath); + if (!result.ok) { + logger.error( + `snapshot の読み込みに失敗しました: ${describeFileIoError(result.error)}`, + ); + return false; + } + + logger.info(`snapshot をロードしました: ${selection.filePath}`); + logLoadSummary(result.prototypesLoaded, selection.takenAt, logger); + return true; +} + +/** + * Logs a one-line summary of the loaded snapshot (count, taken-at time, + * age) and warns when it is older than the staleness threshold. + * + * The age is judged from the file name timestamp: loading a file resets + * the store's cachedAt to "now", so store stats cannot tell the file age. + */ +function logLoadSummary( + count: number, + takenAt: Date | null, + logger: Logger, +): void { + if (!takenAt) { + logger.info(`${count}件, 取得時刻: 不明 (ファイル名が命名規則外)`); + return; + } + + const ageMs = Date.now() - takenAt.getTime(); + const hours = (ageMs / 3_600_000).toFixed(1); + logger.info( + `${count}件, 取得時刻: ${formatLocalDateTime(takenAt)} (約${hours}時間前)`, + ); + if (ageMs >= resolveSnapshotStaleAfterMs()) { + logger.warn( + `この snapshot は約 ${hours} 時間前のデータです。\`${PPV_CLI_TOOL_NAME} snapshot create\` で更新できます。`, + ); + } +} diff --git a/src/ppex.tsx b/src/ppex.tsx new file mode 100644 index 0000000..18832e1 --- /dev/null +++ b/src/ppex.tsx @@ -0,0 +1,90 @@ +#!/usr/bin/env node +/** + * ppex - interactive snapshot explorer for ProtoPedia data + * (ProtoPedia EXplorer; renamed from ppc-ui on 2026-07-16). + * + * Separate binary from the one-shot `ppv-cli` CLI so that the CLI never + * loads Ink/React and the two surfaces stay independent. + */ +import { render } from 'ink'; + +import { initConfigEntries } from './core/config-entries.js'; +import { configGateErrors } from './core/config-validation.js'; +import { resolveSnapshotDir } from './core/snapshot-catalog.js'; +import { VERSION } from './core/version.js'; +import { PpexApp } from './ppex/ppex-app.js'; + +const USAGE = `Usage: ppex [options] + +Interactive snapshot explorer for ProtoPedia data. + +Options: + --snapshot-dir directory for snapshot files + -V, --version output the version number + -h, --help display this help +`; + +function parseArgs(argv: readonly string[]): { + snapshotDir?: string; + help?: boolean; + version?: boolean; + error?: string; +} { + const result: { + snapshotDir?: string; + help?: boolean; + version?: boolean; + error?: string; + } = {}; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '-h' || arg === '--help') { + result.help = true; + } else if (arg === '-V' || arg === '--version') { + result.version = true; + } else if (arg === '--snapshot-dir') { + const value = argv[index + 1]; + if (value === undefined) { + result.error = '--snapshot-dir にはディレクトリを指定してください。'; + return result; + } + result.snapshotDir = value; + index += 1; + } else { + result.error = `不明な引数です: ${arg}`; + return result; + } + } + return result; +} + +const args = parseArgs(process.argv.slice(2)); +if (args.error !== undefined) { + process.stderr.write(`[error] ${args.error}\n`); + process.exitCode = 1; +} else if (args.help === true) { + process.stdout.write(USAGE); +} else if (args.version === true) { + process.stdout.write(`${VERSION}\n`); +} else if (!process.stdin.isTTY || !process.stdout.isTTY) { + process.stderr.write( + '[error] ppex は対話的なターミナル (TTY) でのみ実行できます。\n', + ); + process.exitCode = 1; +} else { + // A broken config (unreadable, or a value outside its prescribed + // set) stops the app; running as if nothing were + // configured would be misleading. config init (ppv-cli) is the repair + // path. + const gateErrors = configGateErrors(initConfigEntries(), 'ja'); + if (gateErrors !== null) { + for (const line of gateErrors) { + process.stderr.write(`[error] ${line}\n`); + } + process.exitCode = 1; + } else { + const snapshotDir = resolveSnapshotDir(args.snapshotDir); + const { waitUntilExit } = render(); + await waitUntilExit(); + } +} diff --git a/src/ppex/ppex-app.test.tsx b/src/ppex/ppex-app.test.tsx new file mode 100644 index 0000000..c145760 --- /dev/null +++ b/src/ppex/ppex-app.test.tsx @@ -0,0 +1,208 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { render } from 'ink-testing-library'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { PpexApp, CREATE_LABEL, MAIN_MENU } from './ppex-app.js'; + +const ARROW_DOWN = '\u001B[B'; +const ENTER = '\r'; +const ESCAPE = '\u001B'; + +function prototypeFixture(id: number, name: string): Record { + return { + id, + createDate: '2026-01-01T00:00:00.000Z', + releaseFlg: 1, + status: 1, + prototypeNm: name, + summary: '', + freeComment: '', + systemDescription: '', + users: ['tester'], + teamNm: '', + tags: [], + materials: [], + events: [], + awards: [], + mainUrl: `https://protopedia.net/prototype/${id}`, + viewCount: 0, + goodCount: 0, + commentCount: 0, + }; +} + +async function writeSnapshotFixture(dir: string): Promise { + const filePath = path.join(dir, 'snapshot-20260715T100000Z-2.json'); + await writeFile( + filePath, + JSON.stringify({ + version: '1.0.0', + serializedAt: '2026-07-15T10:00:00.000Z', + prototypes: [ + prototypeFixture(10, 'Alpha Machine'), + prototypeFixture(30, 'Beta Device'), + ], + }), + 'utf8', + ); + return filePath; +} + +// retry: multi-screen key sequences flake on slow Windows CI +// runners (never reproduced locally, 0/15 stress runs). The +// ink-testing-library mock stdin is a bare EventEmitter with a +// single-slot buffer, so a keypress written in the gap while Ink +// re-attaches its readable listener between screens can be lost or +// mis-delivered. A retry re-runs the whole scenario cleanly. +describe('PpexApp', { retry: 2 }, () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), 'ppc-app-test-')); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('offers only the create option when no snapshot exists', async () => { + const { lastFrame, unmount } = render(); + await vi.waitFor(() => { + expect(lastFrame()).toContain('snapshot がありません'); + }); + expect(lastFrame()).toContain(CREATE_LABEL); + expect(lastFrame()).not.toContain('[最新]'); + unmount(); + }); + + it('loads a snapshot and shows the metadata header with the menu', async () => { + await writeSnapshotFixture(dir); + const { lastFrame, stdin, unmount } = render(); + await vi.waitFor(() => { + expect(lastFrame()).toContain('[最新]'); + }); + + stdin.write(ENTER); + // Wait for the menu cursor: "スナップショット" alone also matches + // the picker hint (m: スナップショット管理). + await vi.waitFor(() => { + expect(lastFrame()).toContain(`> ${MAIN_MENU.search}`); + }); + const frame = lastFrame() ?? ''; + // Always-visible metadata header (real serializedAt, count, range). + expect(frame).toContain('snapshot-20260715T100000Z-2.json'); + expect(frame).toContain('出力日時: 2026-07-15'); + expect(frame).toContain('時間前'); + expect(frame).toContain('2 件'); + expect(frame).toContain('ID: 10 - 30'); + expect(frame).toContain(MAIN_MENU.search); + unmount(); + }); + + it('opens the snapshot manager with m and protects the loaded snapshot', async () => { + await writeSnapshotFixture(dir); + const { lastFrame, stdin, unmount } = render(); + await vi.waitFor(() => { + expect(lastFrame()).toContain('[最新]'); + }); + stdin.write(ENTER); // load + await vi.waitFor(() => { + expect(lastFrame()).toContain(`> ${MAIN_MENU.search}`); + }); + stdin.write(ARROW_DOWN); // 作品探索 -> スナップショット + await vi.waitFor(() => { + expect(lastFrame()).toContain(`> ${MAIN_MENU.snapshot}`); + }); + stdin.write(ENTER); // -> picker (direct; no intermediate menu) + await vi.waitFor(() => { + expect(lastFrame()).toContain('m: スナップショット管理'); + }); + stdin.write('m'); // -> snapshot manager + await vi.waitFor(() => { + expect(lastFrame()).toContain('Snapshot管理'); + }); + // The loaded snapshot cannot be selected, and sizes are shown. + expect(lastFrame()).toContain('[-]'); + expect(lastFrame()).toContain('(ロード中)'); + expect(lastFrame()).toContain('KB'); + unmount(); + }); + + it('marks the currently loaded snapshot on the re-select picker', async () => { + const filePath = await writeSnapshotFixture(dir); + const fileName = path.basename(filePath); + const { lastFrame, stdin, unmount } = render(); + await vi.waitFor(() => { + expect(lastFrame()).toContain('[最新]'); + }); + // At startup nothing is loaded yet: no current marker. + expect(lastFrame()).not.toContain(`* ${fileName}`); + expect(lastFrame()).not.toContain('* 2026-07-15'); + + stdin.write(ENTER); // load + // Wait for the menu cursor, not just the label: the cursor + // proves the main-menu Menu is mounted and owns the keys + // (looser waits lost the next keypress on slow CI runners). + await vi.waitFor(() => { + expect(lastFrame()).toContain(`> ${MAIN_MENU.search}`); + }); + stdin.write(ARROW_DOWN); // 作品探索 -> スナップショット + await vi.waitFor(() => { + expect(lastFrame()).toContain(`> ${MAIN_MENU.snapshot}`); + }); + stdin.write(ENTER); // -> picker (direct) + await vi.waitFor(() => { + expect(lastFrame()).toContain('snapshot を選択'); + }); + // The loaded snapshot is marked, and Esc-back is offered. + expect(lastFrame()).toContain('* 2026-07-15'); + expect(lastFrame()).toContain('* = ロード中'); + expect(lastFrame()).toContain('Esc: 戻る'); + unmount(); + }); + + it('opens 作品探索 from the menu with every prototype listed', async () => { + await writeSnapshotFixture(dir); + const { lastFrame, stdin, unmount } = render(); + await vi.waitFor(() => { + expect(lastFrame()).toContain('[最新]'); + }); + stdin.write(ENTER); // load + await vi.waitFor(() => { + expect(lastFrame()).toContain(`> ${MAIN_MENU.search}`); + }); + stdin.write(ENTER); // 作品探索 is the first item + await vi.waitFor(() => { + expect(lastFrame()).toContain('ヒット: 2件 (全2件)'); + }); + // Rows are ID ascending; the header stays visible. + expect(lastFrame()).toContain('> 10 Alpha Machine'); + expect(lastFrame()).toContain('30 Beta Device'); + expect(lastFrame()).toContain('出力日時: 2026-07-15'); + + stdin.write(ESCAPE); // back to the main menu + await vi.waitFor(() => { + expect(lastFrame()).toContain(`> ${MAIN_MENU.search}`); + }); + unmount(); + }); + + it('shows an error notice on the picker when loading fails', async () => { + const broken = path.join(dir, 'snapshot-20260715T100000Z-1.json'); + await writeFile(broken, '{ broken', 'utf8'); + const { lastFrame, stdin, unmount } = render(); + await vi.waitFor(() => { + expect(lastFrame()).toContain('snapshot を選択'); + }); + stdin.write(ENTER); + await vi.waitFor(() => { + expect(lastFrame()).toContain('ロードに失敗しました'); + }); + // Still on the picker; the create option remains available. + expect(lastFrame()).toContain(CREATE_LABEL); + unmount(); + }); +}); diff --git a/src/ppex/ppex-app.tsx b/src/ppex/ppex-app.tsx new file mode 100644 index 0000000..cbba5b9 --- /dev/null +++ b/src/ppex/ppex-app.tsx @@ -0,0 +1,239 @@ +/** + * ppex: interactive snapshot explorer (issue #1; renamed from + * ppc-ui on 2026-07-16). + * + * Screen flow: + * + * boot -> picker (snapshot list + [作成して選択]; create-only when + * | empty; marks the loaded snapshot on re-select; + * | m -> snapshot manager: multi-select delete / create) + * -> main (always-visible metadata header + menu) + * ├── 作品探索 -> live-filter form + result list + + * │ fixed-height preview; Enter opens the + * │ detail (JSON / card tabs) + * └── スナップショット -> picker (再選択; m -> manager) + * + * This module is the screen-flow container (state machine + wiring); + * the screens themselves live under components/ and search-screen.tsx. + * Everything Ink-related stays inside src/interactive/; all data + * operations go through core/* and session.ts. + */ +import { Box, Text, useApp } from 'ink'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { SearchScreen } from './search-screen.js'; +import { createSession, loadSession } from '../core/session.js'; +import { listSnapshots } from '../core/snapshot-catalog.js'; +import { resolveToken } from '../core/token.js'; +import { + CreatingProgress, + progressToCreatingProgress, +} from '../ui/creating-progress.js'; +import { Header } from '../ui/header.js'; +import { Menu } from '../ui/menu.js'; +import { SnapshotManager } from '../ui/snapshot-manager.js'; +import { SnapshotPicker } from '../ui/snapshot-picker.js'; + +import type { SessionResult, SnapshotSession } from '../core/session.js'; +import type { SnapshotFileInfo } from '../core/snapshot-catalog.js'; +import type { ProtopediaInMemoryRepository } from 'promidas'; +import type { NormalizedPrototype } from 'promidas/types'; + +// All user-facing menu labels in one place. Exported so that tests +// reference the same values and wording changes never break them. +export const CREATE_LABEL = '[作成して選択]'; +export const MAIN_MENU = { + search: '作品探索', + snapshot: 'スナップショット', +} as const; + +type Screen = + | { kind: 'boot' } + | { kind: 'picker'; snapshots: readonly SnapshotFileInfo[]; notice?: string } + | { kind: 'manager' } + | { kind: 'creating'; progress: string } + | { kind: 'main'; notice?: string } + | { kind: 'search'; prototypes: readonly NormalizedPrototype[] }; + +export function PpexApp({ snapshotDir }: { readonly snapshotDir: string }) { + const { exit } = useApp(); + const [screen, setScreen] = useState({ kind: 'boot' }); + const [session, setSession] = useState(null); + const [now, setNow] = useState(() => Date.now()); + const repositoryRef = useRef(null); + + // Keep the header age display current while the app stays open. + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 30_000); + return () => clearInterval(timer); + }, []); + + // Dispose the repository when the app unmounts (q / Ctrl+C). + useEffect( + () => () => { + repositoryRef.current?.dispose(); + repositoryRef.current = null; + }, + [], + ); + + const openPicker = useCallback( + async (notice?: string) => { + const snapshots = await listSnapshots(snapshotDir); + setScreen({ + kind: 'picker', + snapshots, + ...(notice !== undefined ? { notice } : {}), + }); + }, + [snapshotDir], + ); + + useEffect(() => { + void openPicker(); + }, []); + + const adoptSessionResult = useCallback( + async (result: SessionResult, failureNotice: string) => { + if (!result.ok) { + await openPicker(`${failureNotice}: ${result.message}`); + return; + } + repositoryRef.current?.dispose(); + repositoryRef.current = result.repository; + setSession(result.session); + setNow(Date.now()); + setScreen({ kind: 'main' }); + }, + [openPicker], + ); + + const startCreate = useCallback(async () => { + const token = resolveToken(); + // The picker guards the token before calling onCreate; bail here + // defensively so token is a string for createSession. + if (token === null) return; + setScreen({ kind: 'creating', progress: 'API リクエストを準備中...' }); + const result = await createSession({ + token, + snapshotDir, + onProgress: (event) => { + const progress = progressToCreatingProgress(event, { + downloading: (pct, received) => `ダウンロード中 ${pct} (${received})`, + saving: '保存中...', + }); + if (progress !== null) { + setScreen({ kind: 'creating', progress }); + } + }, + }); + await adoptSessionResult(result, '取得に失敗しました'); + }, [snapshotDir, openPicker, adoptSessionResult]); + + const loadSnapshotFile = useCallback( + async (info: SnapshotFileInfo) => { + const result = await loadSession(info.filePath); + await adoptSessionResult( + result, + `ロードに失敗しました (${info.fileName})`, + ); + }, + [adoptSessionResult], + ); + + const openSearch = useCallback(() => { + void repositoryRef.current?.getAllFromSnapshot().then((all) => + setScreen({ + kind: 'search', + prototypes: [...all].sort((a, b) => a.id - b.id), + }), + ); + }, []); + + switch (screen.kind) { + case 'boot': + return snapshot を確認中...; + + case 'picker': + return ( + setScreen({ kind: 'main' }), + } + : {})} + createLabel={CREATE_LABEL} + lang="ja" + onSelectSnapshot={(info) => void loadSnapshotFile(info)} + onCreate={() => void startCreate()} + onOpenManager={() => setScreen({ kind: 'manager' })} + onQuit={exit} + /> + ); + + case 'manager': + return ( + void openPicker()} + /> + ); + + case 'creating': + return ; + + default: + break; + } + + // Screens below require a loaded session. The session implies a + // live repository (both are only set together in + // adoptSessionResult), and reading the ref here would violate the + // no-refs-during-render rule. + if (session === null) { + return 内部エラー: セッションがありません。; + } + + return ( + +
+ {screen.kind === 'main' && ( + + {screen.notice && {screen.notice}} + { + switch (index) { + case 0: + openSearch(); + break; + case 1: + void openPicker(); + break; + default: + break; + } + }} + onQuit={exit} + /> + ↑↓: 移動 / Enter: 決定 / q: 終了 + + )} + {screen.kind === 'search' && ( + setScreen({ kind: 'main' })} + /> + )} + + ); +} diff --git a/src/ppex/search-screen.test.tsx b/src/ppex/search-screen.test.tsx new file mode 100644 index 0000000..051e067 --- /dev/null +++ b/src/ppex/search-screen.test.tsx @@ -0,0 +1,290 @@ +import { render } from 'ink-testing-library'; +import { describe, expect, it, vi } from 'vitest'; + +import { SEARCH_FIELDS, SearchScreen } from './search-screen.js'; + +import type { NormalizedPrototype } from 'promidas/types'; + +const TAB = '\t'; +const ARROW_DOWN = '\u001B[B'; +const ENTER = '\r'; +const ESCAPE = '\u001B'; + +function prototype( + id: number, + overrides: Partial, +): NormalizedPrototype { + return { + id, + createDate: '2026-01-01T00:00:00.000Z', + releaseFlg: 1, + status: 1, + prototypeNm: `Prototype ${id}`, + summary: '', + freeComment: '', + systemDescription: '', + users: ['tester'], + teamNm: '', + tags: [], + materials: [], + events: [], + awards: [], + mainUrl: `https://protopedia.net/prototype/${id}`, + viewCount: 0, + goodCount: 0, + commentCount: 0, + ...overrides, + } as NormalizedPrototype; +} + +const ITEMS: readonly NormalizedPrototype[] = [ + prototype(1, { + prototypeNm: 'LED Cube', + summary: 'A shiny cube', + tags: ['LED', 'Arduino'], + materials: ['M5Stack'], + users: ['alice'], + }), + prototype(2, { + prototypeNm: 'Robot Arm', + summary: 'Arm with led indicator', + tags: ['robot'], + materials: ['Raspberry Pi'], + users: ['bob'], + }), + prototype(3, { + prototypeNm: 'LED Robot', + summary: 'Walking led robot', + tags: ['LED', 'robot'], + materials: ['M5StickC'], + users: ['carol'], + }), +]; + +describe('SearchScreen', () => { + it('shows all fields and every item before any input', () => { + const { lastFrame, unmount } = render( + {}} visibleRows={10} />, + ); + const frame = lastFrame() ?? ''; + for (const field of SEARCH_FIELDS) { + expect(frame).toContain(`${field.label.ja}: [`); + } + expect(frame).toContain('ヒット: 3件 (全3件)'); + expect(frame).toContain('> 1 LED Cube'); + unmount(); + }); + + it('filters live as the name field is typed (terms are ANDed)', async () => { + const { lastFrame, stdin, unmount } = render( + {}} visibleRows={10} />, + ); + + stdin.write(TAB); // ID -> 作品名 + await vi.waitFor(() => { + expect(lastFrame()).toContain('> 作品名: ['); + }); + stdin.write('led'); + await vi.waitFor(() => { + expect(lastFrame()).toContain('ヒット: 2件 (全3件)'); + }); + expect(lastFrame()).toContain('LED Cube'); + expect(lastFrame()).toContain('LED Robot'); + + stdin.write(' robot'); // "led robot" both must match the name + await vi.waitFor(() => { + expect(lastFrame()).toContain('ヒット: 1件 (全3件)'); + }); + expect(lastFrame()).toContain('LED Robot'); + unmount(); + }); + + it('filters by ID prefix; space-separated terms are ORed', async () => { + const { lastFrame, stdin, unmount } = render( + {}} visibleRows={10} />, + ); + + // The ID field is the first field, already active. + await vi.waitFor(() => { + expect(lastFrame()).toContain('> ID: ['); + }); + stdin.write('x'); // letters are ignored on the ID field + stdin.write('1'); + await vi.waitFor(() => { + expect(lastFrame()).toContain('ヒット: 1件 (全3件)'); + }); + expect(lastFrame()).toContain('LED Cube'); + expect(lastFrame()).not.toContain('ID: [x'); + + stdin.write(' 3'); // "1 3" -> IDs 1 and 3 (OR) + await vi.waitFor(() => { + expect(lastFrame()).toContain('ヒット: 2件 (全3件)'); + }); + expect(lastFrame()).toContain('LED Robot'); + unmount(); + }); + + it('combines fields with AND (name + material)', async () => { + const { lastFrame, stdin, unmount } = render( + {}} visibleRows={10} />, + ); + + stdin.write(TAB); // ID -> 作品名 + await vi.waitFor(() => { + expect(lastFrame()).toContain('> 作品名: ['); + }); + stdin.write('led'); + await vi.waitFor(() => { + expect(lastFrame()).toContain('ヒット: 2件 (全3件)'); + }); + + // Move to 素材: 作品名 -> 概要 -> タグ -> 素材. + stdin.write(TAB); + stdin.write(TAB); + stdin.write(TAB); + await vi.waitFor(() => { + expect(lastFrame()).toContain('> 素材: ['); + }); + stdin.write('m5stack'); + await vi.waitFor(() => { + expect(lastFrame()).toContain('ヒット: 1件 (全3件)'); + }); + expect(lastFrame()).toContain('LED Cube'); + unmount(); + }); + + it('selects a result with arrows and opens the detail directly', async () => { + const { lastFrame, stdin, unmount } = render( + {}} visibleRows={10} />, + ); + + stdin.write(TAB); // ID -> 作品名 + await vi.waitFor(() => { + expect(lastFrame()).toContain('> 作品名: ['); + }); + stdin.write('led'); + await vi.waitFor(() => { + expect(lastFrame()).toContain('ヒット: 2件 (全3件)'); + }); + expect(lastFrame()).toContain('> 1 LED Cube'); + + // The card of the selected result is previewed below the list. + expect(lastFrame()).toContain('Name: LED Cube'); + + stdin.write(ARROW_DOWN); // arrows move the result selection directly + await vi.waitFor(() => { + expect(lastFrame()).toContain('> 3 LED Robot'); + }); + // The preview follows the selection. + expect(lastFrame()).toContain('Name: LED Robot'); + + stdin.write(ENTER); // open the detail (JSON tab by default) + await vi.waitFor(() => { + expect(lastFrame()).toContain('"prototypeNm": "LED Robot"'); + }); + stdin.write(ESCAPE); // back to the search; query and selection kept + await vi.waitFor(() => { + expect(lastFrame()).toContain('作品名: ['); + }); + expect(lastFrame()).toContain('led'); + expect(lastFrame()).toContain('> 3 LED Robot'); + unmount(); + }); + + it('keeps the frame height stable while the preview follows the cursor', async () => { + // ITEMS[0] has tags/materials populated; ITEMS[1] has fewer fields. + const { lastFrame, stdin, unmount } = render( + {}} visibleRows={10} />, + ); + await vi.waitFor(() => { + expect(lastFrame()).toContain('> 1 LED Cube'); + }); + const linesBefore = (lastFrame() ?? '').split('\n').length; + + stdin.write(ARROW_DOWN); // move to a prototype with fewer fields + await vi.waitFor(() => { + expect(lastFrame()).toContain('> 2 Robot Arm'); + }); + const linesAfter = (lastFrame() ?? '').split('\n').length; + expect(linesAfter).toBe(linesBefore); + unmount(); + }); + + it('places the preview beside the form in the wide layout', async () => { + const { lastFrame, unmount } = render( + {}} visibleRows={10} wide />, + ); + await vi.waitFor(() => { + expect(lastFrame()).toContain('> 1 LED Cube'); + }); + // In the two-column layout the field lines share terminal rows + // with the preview card. The form draws its own border (2 '│'), + // so the card border beside it brings the count above 2. + const fieldLine = (lastFrame() ?? '') + .split('\n') + .find((line) => line.includes('タグ: [')); + expect(fieldLine).toBeDefined(); + expect((fieldLine?.match(/│/g) ?? []).length).toBeGreaterThan(2); + unmount(); + }); + + it('keeps the preview below the form in the narrow layout', async () => { + const { lastFrame, unmount } = render( + {}} + visibleRows={10} + wide={false} + />, + ); + await vi.waitFor(() => { + expect(lastFrame()).toContain('> 1 LED Cube'); + }); + // Only the form's own border is on the field line (2 '│'); the + // preview card sits below, not beside. + const fieldLine = (lastFrame() ?? '') + .split('\n') + .find((line) => line.includes('タグ: [')); + expect(fieldLine).toBeDefined(); + expect((fieldLine?.match(/│/g) ?? []).length).toBe(2); + unmount(); + }); + + it('calls onBack with Esc on the form', async () => { + const onBack = vi.fn(); + const { lastFrame, stdin, unmount } = render( + , + ); + await vi.waitFor(() => { + expect(lastFrame()).toContain('検索'); + }); + stdin.write(ESCAPE); + await vi.waitFor(() => { + expect(onBack).toHaveBeenCalledOnce(); + }); + unmount(); + }); + + it('sanitizes control chars in the list and preview, keeping ZWJ emoji', () => { + const emoji = + String.fromCodePoint(0x1f646) + + String.fromCodePoint(0x200d) + + String.fromCodePoint(0x2640) + + String.fromCodePoint(0xfe0f); + const item = prototype(9, { + prototypeNm: `Ctrl${TAB}${emoji}`, + users: [`u${TAB}v`], + }); + const { lastFrame, unmount } = render( + {}} visibleRows={10} />, + ); + const frame = lastFrame() ?? ''; + // List row: TAB becomes a space, the ZWJ emoji survives, users clean. + expect(frame).toContain(`9 Ctrl ${emoji}`); + expect(frame).toContain('u v'); + // The preview card shows the sanitized name too. + expect(frame).toContain(`Name: Ctrl ${emoji}`); + expect(frame).not.toContain(TAB); // no raw control char on screen + unmount(); + }); +}); diff --git a/src/ppex/search-screen.tsx b/src/ppex/search-screen.tsx new file mode 100644 index 0000000..cabbd06 --- /dev/null +++ b/src/ppex/search-screen.tsx @@ -0,0 +1,250 @@ +/** + * 作品検索: a form of per-field filters with live (incremental) + * filtering — results update on every keystroke. + * + * Semantics (decided 2026-07-15): terms within a field are ANDed, and + * fields are ANDed with each other. Note this intentionally differs + * from the CLI facets (repeated options are ORed there): live + * narrowing feels natural as AND. + * + * Key model (fzf-style, no focus switching): typing always edits the + * active field, Tab / Shift+Tab switch fields, ↑↓ always move the + * result selection, and Enter opens the detail (card / JSON tabs) of + * the selected result. + */ +import { Box, Text, useInput } from 'ink'; +import { useEffect, useMemo, useState } from 'react'; + +import { + EMPTY_VALUES, + matchesFields, + SEARCH_FIELDS, + withFieldBackspace, + withFieldInput, +} from '../core/search-model.js'; +import { PreviewPane } from '../ui/preview-pane.js'; +import { PrototypeDetail } from '../ui/prototype-detail.js'; +import { PrototypeRaw } from '../ui/prototype-raw.js'; +import { ResultList } from '../ui/result-list.js'; +import { SearchForm } from '../ui/search-form.js'; +import { useTerminalSize } from '../ui/use-terminal-size.js'; + +import type { FieldValues } from '../core/search-model.js'; +import type { NormalizedPrototype } from 'promidas/types'; + +// Re-exported for existing tests; the model lives in search-model.ts. +export { matchesFields, SEARCH_FIELDS } from '../core/search-model.js'; + +// Fixed height reserved for the preview card so that the layout does +// not jump as the selection moves across prototypes with more or fewer +// populated fields. Longer content is clipped. +const PREVIEW_HEIGHT = 15; + +// Two-column layout kicks in at this terminal width: the preview moves +// to the right of the form + result list (media-query equivalent). +const WIDE_THRESHOLD_COLUMNS = 110; +// Maximum width of the preview column in the wide layout; the actual +// pane is clamped to half the terminal so the form and result list +// always keep at least the other half. +const PREVIEW_MAX_WIDTH = 100; + +export function SearchScreen({ + items, + onBack, + visibleRows, + wide, +}: { + /** All prototypes, sorted by ID ascending. */ + readonly items: readonly NormalizedPrototype[]; + readonly onBack: () => void; + /** Row-count override for tests. */ + readonly visibleRows?: number; + /** Layout override for tests; defaults to the terminal width. */ + readonly wide?: boolean; +}) { + const size = useTerminalSize(); + const [values, setValues] = useState(EMPTY_VALUES); + const [active, setActive] = useState(0); + const [cursor, setCursor] = useState(0); + const [selected, setSelected] = useState(null); + // Full raw-JSON view of the selected prototype (r from the detail). + const [rawView, setRawView] = useState(false); + + const filtered = useMemo( + () => items.filter((item) => matchesFields(item, values)), + [items, values], + ); + + // Any query change resets the result selection to the top. + useEffect(() => { + setCursor(0); + }, [values]); + + const isWide = wide ?? size.columns >= WIDE_THRESHOLD_COLUMNS; + + // Budget: fields + summary + hints + the form border (2), plus the + // fixed-height preview when it sits below the list (narrow layout). + // In the wide layout the preview is beside the list, so the rows + // are not contested. + const rows = + visibleRows ?? + Math.max( + 3, + size.rows - 15 - SEARCH_FIELDS.length - (isWide ? 0 : PREVIEW_HEIGHT), + ); + + useInput((input, key) => { + if (selected) { + if (rawView) { + // Raw JSON: Esc (or Enter) returns to the detail view. + if (key.escape || key.return) setRawView(false); + return; + } + if (input === 'r') { + setRawView(true); + return; + } + if (key.escape || key.return) setSelected(null); + return; + } + if (key.escape) { + onBack(); + return; + } + if (key.return) { + const item = filtered[cursor]; + if (item) setSelected(item); + return; + } + if (key.tab) { + setActive( + (current) => + (current + (key.shift ? SEARCH_FIELDS.length - 1 : 1)) % + SEARCH_FIELDS.length, + ); + return; + } + if (key.upArrow) { + setCursor((current) => + current === 0 ? Math.max(0, filtered.length - 1) : current - 1, + ); + return; + } + if (key.downArrow) { + setCursor((current) => + current >= filtered.length - 1 ? 0 : current + 1, + ); + return; + } + if (key.pageUp) { + setCursor((current) => Math.max(0, current - rows)); + return; + } + if (key.pageDown) { + // Clamp at 0: with an empty result list, length - 1 is -1. + setCursor((current) => + Math.max(0, Math.min(filtered.length - 1, current + rows)), + ); + return; + } + const field = SEARCH_FIELDS[active]; + if (!field) return; + if (key.backspace || key.delete) { + setValues((current) => withFieldBackspace(current, field.key)); + return; + } + if (input !== '' && !key.ctrl && !key.meta) { + setValues((current) => withFieldInput(current, field.key, input)); + } + }); + + if (selected) { + if (rawView) { + return ( + + + Esc: 詳細に戻る + + ); + } + // The card is already visible as the preview, so the detail opens + // on the JSON tab (switchable as usual). + return ( + // minHeight so the detail's flex-grown JSON pane gets the + // leftover terminal height (see PrototypeDetail). + + + Esc: 戻る / r: Raw JSON + + ); + } + + // Presentation is delegated to the components/ modules; this + // container only wires state into their props. + const form = ( + + ); + + const summary = ( + + ヒット: {filtered.length}件 (全{items.length}件) + {filtered.length > 0 ? ` (${cursor + 1}/${filtered.length})` : ''} + + ); + + // Narrow layout: the preview competes with the list for rows, so + // its height stays fixed. Wide layout: the right column is + // otherwise empty below the card, so the preview may use the full + // terminal height (minus the hint line) and wrapped long fields + // (e.g. summary) stay fully visible. + const preview = (height: number) => ( + + ); + + const list = ; + + const hint = ( + + Tab: フィールド移動 / ↑↓: 結果選択 / Enter: 詳細 (JSON) / Esc: 戻る + + ); + + if (isWide) { + // Wide layout: form + list on the left, the preview on the right. + return ( + + + + {form} + {summary} + {list} + + + {preview(size.rows - 1)} + + + {hint} + + ); + } + + // Narrow layout: everything stacked, the preview above the list. + return ( + + {form} + {summary} + {preview(PREVIEW_HEIGHT)} + {list} + {hint} + + ); +} diff --git a/src/pptop.tsx b/src/pptop.tsx new file mode 100644 index 0000000..d89e549 --- /dev/null +++ b/src/pptop.tsx @@ -0,0 +1,90 @@ +#!/usr/bin/env node +/** + * pptop - top(1)-style monitor for ProtoPedia snapshot data. + * + * Third binary alongside `ppv-cli` (pure UNIX-like command) and `ppex` + * (modern interactive CLI): command-key driven, dense aggregate + * header, table-centric display. + */ +import { render } from 'ink'; + +import { initConfigEntries } from './core/config-entries.js'; +import { configGateErrors } from './core/config-validation.js'; +import { resolveSnapshotDir } from './core/snapshot-catalog.js'; +import { VERSION } from './core/version.js'; +import { TopApp } from './pptop/top-app.js'; + +const USAGE = `Usage: pptop [options] + +top-style monitor for ProtoPedia snapshot data. + +Options: + --snapshot-dir directory for snapshot files + -V, --version output the version number + -h, --help display this help +`; + +function parseArgs(argv: readonly string[]): { + snapshotDir?: string; + help?: boolean; + version?: boolean; + error?: string; +} { + const result: { + snapshotDir?: string; + help?: boolean; + version?: boolean; + error?: string; + } = {}; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '-h' || arg === '--help') { + result.help = true; + } else if (arg === '-V' || arg === '--version') { + result.version = true; + } else if (arg === '--snapshot-dir') { + const value = argv[index + 1]; + if (value === undefined) { + result.error = '--snapshot-dir requires a directory.'; + return result; + } + result.snapshotDir = value; + index += 1; + } else { + result.error = `Unknown argument: ${arg}`; + return result; + } + } + return result; +} + +const args = parseArgs(process.argv.slice(2)); +if (args.error !== undefined) { + process.stderr.write(`[error] ${args.error}\n`); + process.exitCode = 1; +} else if (args.help === true) { + process.stdout.write(USAGE); +} else if (args.version === true) { + process.stdout.write(`${VERSION}\n`); +} else if (!process.stdin.isTTY || !process.stdout.isTTY) { + process.stderr.write( + '[error] pptop requires an interactive terminal (TTY).\n', + ); + process.exitCode = 1; +} else { + // A broken config (unreadable, or a value outside its prescribed + // set) stops the app; running as if nothing were + // configured would be misleading. config init (ppv-cli) is the repair + // path. pptop's UI language is English. + const gateErrors = configGateErrors(initConfigEntries(), 'en'); + if (gateErrors !== null) { + for (const line of gateErrors) { + process.stderr.write(`[error] ${line}\n`); + } + process.exitCode = 1; + } else { + const snapshotDir = resolveSnapshotDir(args.snapshotDir); + const { waitUntilExit } = render(); + await waitUntilExit(); + } +} diff --git a/src/pptop/top-app.test.tsx b/src/pptop/top-app.test.tsx new file mode 100644 index 0000000..32043f2 --- /dev/null +++ b/src/pptop/top-app.test.tsx @@ -0,0 +1,437 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { render } from 'ink-testing-library'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { TopApp } from './top-app.js'; +import { computeSnapshotStats, countNewborns } from './top-stats.js'; +import { formatLocalDateTime } from '../core/format.js'; + +import type { NormalizedPrototype } from 'promidas/types'; + +const TAB = '\t'; +const PAGE_DOWN = '\u001B[6~'; +const ARROW_DOWN = '\u001B[B'; +const ENTER = '\r'; +const ESCAPE = '\u001B'; + +function prototypeFixture( + id: number, + name: string, + extra: Record = {}, +): Record { + return { + id, + createDate: '2026-01-01T00:00:00.000Z', + releaseFlg: 1, + status: 1, + prototypeNm: name, + summary: '', + freeComment: '', + systemDescription: '', + users: ['tester'], + teamNm: '', + tags: [], + materials: [], + events: [], + awards: [], + mainUrl: `https://protopedia.net/prototype/${id}`, + viewCount: 0, + goodCount: 0, + commentCount: 0, + ...extra, + }; +} + +async function writeSnapshotFixture(dir: string): Promise { + const filePath = path.join(dir, 'snapshot-20260715T100000Z-2.json'); + await writeFile( + filePath, + JSON.stringify({ + version: '1.0.0', + serializedAt: '2026-07-15T10:00:00.000Z', + prototypes: [ + prototypeFixture(10, 'Alpha Machine', { + status: 3, + viewCount: 100, + goodCount: 5, + tags: ['LED'], + }), + prototypeFixture(30, 'Beta Device', { viewCount: 20 }), + ], + }), + 'utf8', + ); + return filePath; +} + +describe('computeSnapshotStats', () => { + it('aggregates counts, sums, and distinct values', () => { + const items = [ + prototypeFixture(1, 'A', { + status: 3, + viewCount: 100, + goodCount: 2, + commentCount: 1, + users: ['alice'], + tags: ['LED', 'IoT'], + materials: ['M5Stack'], + awards: ['Gugen'], + events: ['MFT'], + teamNm: 'team-x', + }), + prototypeFixture(2, 'B', { + status: 1, + viewCount: 50, + users: ['alice', 'bob'], + tags: ['LED'], + events: ['MFT'], + }), + ] as unknown as readonly NormalizedPrototype[]; + + const stats = computeSnapshotStats(items); + expect(stats.total).toBe(2); + expect(stats.statusCounts.get(3)).toBe(1); + expect(stats.statusCounts.get(1)).toBe(1); + expect(stats.viewsTotal).toBe(150); + expect(stats.viewsAvg).toBe(75); + expect(stats.viewsMax).toBe(100); + expect(stats.goodsTotal).toBe(2); + expect(stats.commentsTotal).toBe(1); + expect(stats.distinctUsers).toBe(2); + expect(stats.distinctTeams).toBe(1); + expect(stats.distinctTags).toBe(2); + expect(stats.distinctMaterials).toBe(1); + expect(stats.distinctAwards).toBe(1); + expect(stats.distinctEvents).toBe(1); + }); + + it('handles the empty dataset', () => { + const stats = computeSnapshotStats([]); + expect(stats.total).toBe(0); + expect(stats.viewsAvg).toBe(0); + }); +}); + +describe('countNewborns', () => { + const HOUR = 3_600_000; + const NOW = 1_000_000 * HOUR; // arbitrary fixed "now" (TZ-independent) + + it('counts releases within 24h / 7d / 30d, cumulatively', () => { + const times = [ + NOW - 40 * 24 * HOUR, // 40d ago: outside every window + NOW - 20 * 24 * HOUR, // 20d ago: 30d only + NOW - 5 * 24 * HOUR, // 5d ago: 7d + 30d + NOW - 10 * HOUR, // 10h ago: all windows + ].sort((a, b) => a - b); + expect(countNewborns(times, NOW)).toEqual({ + last24h: 1, + last7d: 2, + last30d: 3, + }); + }); + + it('ignores releases dated after now (clock skew)', () => { + const times = [NOW - HOUR, NOW + HOUR].sort((a, b) => a - b); + expect(countNewborns(times, NOW)).toEqual({ + last24h: 1, + last7d: 1, + last30d: 1, + }); + }); + + it('returns zeros when there are no releases', () => { + expect(countNewborns([], NOW)).toEqual({ + last24h: 0, + last7d: 0, + last30d: 0, + }); + }); +}); + +describe('TopApp', () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), 'pptop-test-')); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('auto-loads the latest snapshot and shows the top-style header', async () => { + await writeSnapshotFixture(dir); + const { lastFrame, unmount } = render(); + await vi.waitFor(() => { + expect(lastFrame()).toContain('Prototypes: 2 total'); + }); + const frame = lastFrame() ?? ''; + expect(frame).toContain('1 idea'); + expect(frame).toContain('1 done'); + // top(1)-style header. The context line shows the snapshot time in + // the local timezone; derive the expected value with the same + // formatter so this assertion stays TZ-independent. + const taken = formatLocalDateTime( + new Date('2026-07-15T10:00:00.000Z'), + ).slice(0, 16); + expect(frame).toContain(`pptop - ${taken}`); + expect(frame).toContain('%Status: 50.0 idea, 0.0 dev, 50.0 done, 0.0 eol'); + // releaseDate is unset in the fixture, so newborns are 0 regardless + // of the wall clock (also TZ-independent). + expect(frame).toContain('Newborns: 0, 0, 0'); + expect(frame).toContain( + 'Engagement: 120 views, 100 max, 5 goods, 0 comments', + ); + expect(frame).toContain('Catalog: 1 usr, 0 tm, 1 tag, 0 mat, 0 awd, 0 ev'); + // Table with the fixed header labels; command-mode hint. + expect(frame).toContain('PID'); + expect(frame).toContain('NAME'); + // Rows render without a marker column. The cyan highlight is not + // assertable here (colors are disabled in the test environment). + expect(frame).toMatch(/\s10\s.*Alpha Machine/); + // The preview shows the ProtoPedia page URL of the selected row. + expect(frame).toContain('url: https://protopedia.net/prototype/10'); + expect(frame).toContain('s: Snapshot | ?: Help | q: Quit'); + unmount(); + }); + + it('starts with an empty table when no snapshot exists', async () => { + const { lastFrame, unmount } = render(); + await vi.waitFor(() => { + expect(lastFrame()).toContain('Prototypes: 0 total'); + }); + expect(lastFrame()).toContain('pptop - (no snapshot)'); + expect(lastFrame()).toContain('Prototypes: 0 / 0'); + unmount(); + }); + + it('filters via the / input mode and returns to command mode', async () => { + await writeSnapshotFixture(dir); + const { lastFrame, stdin, unmount } = render(); + await vi.waitFor(() => { + expect(lastFrame()).toContain('Prototypes: 2 total'); + }); + + stdin.write('/'); // enter input mode (the ID field is active) + await vi.waitFor(() => { + expect(lastFrame()).toContain('Search (input mode'); + }); + stdin.write(TAB); // ID -> Name + await vi.waitFor(() => { + expect(lastFrame()).toContain('> Name: ['); + }); + stdin.write('alpha'); // live filter on the name field + await vi.waitFor(() => { + expect(lastFrame()).toContain('Prototypes: 1 / 2 (1/1)'); + }); + // The active condition appears above the table. + expect(lastFrame()).toContain('Name: alpha'); + stdin.write(ESCAPE); // back to command mode; the filter persists + await vi.waitFor(() => { + expect(lastFrame()).toContain('s: Snapshot | ?: Help | q: Quit'); + }); + expect(lastFrame()).toContain('Prototypes: 1 / 2 (1/1)'); + + stdin.write(ENTER); // detail of the selected row (JSON default) + await vi.waitFor(() => { + expect(lastFrame()).toContain('"prototypeNm": "Alpha Machine"'); + }); + stdin.write(ESCAPE); + await vi.waitFor(() => { + expect(lastFrame()).toContain('s: Snapshot | ?: Help | q: Quit'); + }); + unmount(); + }); + + it('opens snapshot management with s and returns with Esc', async () => { + await writeSnapshotFixture(dir); + const { lastFrame, stdin, unmount } = render(); + await vi.waitFor(() => { + expect(lastFrame()).toContain('Prototypes: 2 total'); + }); + + stdin.write('s'); + await vi.waitFor(() => { + expect(lastFrame()).toContain('Select a snapshot'); + }); + // The loaded snapshot is marked; the create option is present. + expect(lastFrame()).toContain('* 2026-07-15'); + expect(lastFrame()).toContain('[Create and select]'); + + stdin.write(ESCAPE); // back to the table + await vi.waitFor(() => { + expect(lastFrame()).toContain('s: Snapshot | ?: Help | q: Quit'); + }); + unmount(); + }); + + it('moves the selection with arrows in command mode', async () => { + await writeSnapshotFixture(dir); + const { lastFrame, stdin, unmount } = render(); + await vi.waitFor(() => { + expect(lastFrame()).toContain('Prototypes: 2 total'); + }); + stdin.write(ARROW_DOWN); + await vi.waitFor(() => { + // The preview follows the selection to the second row (the cyan + // highlight is not assertable: colors are off in tests). + expect(lastFrame()).toContain('Name: Beta Device'); + }); + unmount(); + }); + + it('keeps the cursor when the ID field rejects typed input', async () => { + await writeSnapshotFixture(dir); + const { lastFrame, stdin, unmount } = render(); + await vi.waitFor(() => { + expect(lastFrame()).toContain('Prototypes: 2 total'); + }); + stdin.write(ARROW_DOWN); // select the second row + await vi.waitFor(() => { + expect(lastFrame()).toContain('Prototypes: 2 / 2 (2/2)'); + }); + + stdin.write('/'); // input mode; the ID field is active + await vi.waitFor(() => { + expect(lastFrame()).toContain('Search (input mode'); + }); + stdin.write('x'); // rejected by the ID field; values are unchanged + stdin.write(TAB); // observable follow-up event: ID -> Name + await vi.waitFor(() => { + expect(lastFrame()).toContain('> Name: ['); + }); + // The rejected input must not reset the selection to the top. + expect(lastFrame()).toContain('Prototypes: 2 / 2 (2/2)'); + expect(lastFrame()).toContain('Name: Beta Device'); + unmount(); + }); + + it('scrolls the detail JSON with PgDn', async () => { + await writeSnapshotFixture(dir); + const { lastFrame, stdin, unmount } = render(); + await vi.waitFor(() => { + expect(lastFrame()).toContain('Prototypes: 2 total'); + }); + + stdin.write(ENTER); // detail of the first row (JSON default) + await vi.waitFor(() => { + expect(lastFrame()).toContain('"prototypeNm": "Alpha Machine"'); + }); + // Longer than the pane: the position indicator starts at line 1 + // and the tail keys are not visible yet. + expect(lastFrame()).toContain('(1-'); + expect(lastFrame()).not.toContain('"commentCount"'); + + stdin.write(PAGE_DOWN); + await vi.waitFor(() => { + expect(lastFrame()).toContain('"commentCount": 0'); + }); + expect(lastFrame()).not.toContain('(1-'); + unmount(); + }); + + it('shows the full raw JSON with r and returns with Esc', async () => { + await writeSnapshotFixture(dir); + const { lastFrame, stdin, unmount } = render(); + await vi.waitFor(() => { + expect(lastFrame()).toContain('Prototypes: 2 total'); + }); + + stdin.write(ENTER); // detail (scrollable JSON) + await vi.waitFor(() => { + expect(lastFrame()).toContain('r: Raw JSON'); + }); + + stdin.write('r'); // full raw output, no border, no window + await vi.waitFor(() => { + expect(lastFrame()).toContain('Esc: back to detail'); + }); + const frame = lastFrame() ?? ''; + expect(frame).toContain('"id": 10'); + expect(frame).toContain('"commentCount": 0'); // tail is present too + expect(frame).not.toContain('╭'); // no border to pollute copies + + stdin.write(ESCAPE); // back to the detail view + await vi.waitFor(() => { + expect(lastFrame()).toContain('r: Raw JSON'); + }); + unmount(); + }); + + it('reverses the sort order with R and resets the cursor to the top', async () => { + await writeSnapshotFixture(dir); + const { lastFrame, stdin, unmount } = render(); + await vi.waitFor(() => { + expect(lastFrame()).toContain('Prototypes: 2 total'); + }); + // Default: PID ascending, so id 10 (Alpha) is the top row. + expect(lastFrame()).toContain('Sort: PID asc'); + expect(lastFrame()).toContain('Name: Alpha Machine'); + + stdin.write(ARROW_DOWN); // move the cursor off the top row + await vi.waitFor(() => { + expect(lastFrame()).toContain('Prototypes: 2 / 2 (2/2)'); + }); + + stdin.write('R'); // reverse -> PID descending + await vi.waitFor(() => { + expect(lastFrame()).toContain('Sort: PID desc'); + }); + // The cursor is reset to the top, which is now id 30 (Beta). + expect(lastFrame()).toContain('Prototypes: 2 / 2 (1/2)'); + expect(lastFrame()).toContain('Name: Beta Device'); + unmount(); + }); + + it('moves the sort column right with > (keeps direction, resets cursor)', async () => { + await writeSnapshotFixture(dir); + const { lastFrame, stdin, unmount } = render(); + await vi.waitFor(() => { + expect(lastFrame()).toContain('Prototypes: 2 total'); + }); + expect(lastFrame()).toContain('Sort: PID asc'); + + stdin.write(ARROW_DOWN); // move the cursor off the top row + await vi.waitFor(() => { + expect(lastFrame()).toContain('Prototypes: 2 / 2 (2/2)'); + }); + + // GROUP (minColumns 120) is hidden at the 100-column test width, so + // the next visible column to the right of PID is USER. + stdin.write('>'); + await vi.waitFor(() => { + expect(lastFrame()).toContain('Sort: USER asc'); + }); + // Direction is preserved (asc) and the cursor is reset to the top. + expect(lastFrame()).toContain('Prototypes: 2 / 2 (1/2)'); + unmount(); + }); + + it('clamps < at the leftmost column: no column change, no cursor reset', async () => { + await writeSnapshotFixture(dir); + const { lastFrame, stdin, unmount } = render(); + await vi.waitFor(() => { + expect(lastFrame()).toContain('Prototypes: 2 total'); + }); + expect(lastFrame()).toContain('Sort: PID asc'); // PID is the leftmost column + + stdin.write(ARROW_DOWN); // move the cursor to id 30 (Beta) + await vi.waitFor(() => { + expect(lastFrame()).toContain('Prototypes: 2 / 2 (2/2)'); + }); + + stdin.write('<'); // clamped: PID has no visible column to its left + // The sort column stays PID and the cursor is not reset. + expect(lastFrame()).toContain('Sort: PID asc'); + stdin.write(ENTER); // observable follow-up: open the current row's detail + await vi.waitFor(() => { + // Still on the second row (Beta), proving < neither moved the sort + // column nor reset the cursor to the top (which would be Alpha). + expect(lastFrame()).toContain('"prototypeNm": "Beta Device"'); + }); + unmount(); + }); +}); diff --git a/src/pptop/top-app.tsx b/src/pptop/top-app.tsx new file mode 100644 index 0000000..ba196f1 --- /dev/null +++ b/src/pptop/top-app.tsx @@ -0,0 +1,548 @@ +/** + * pptop: top(1)-style snapshot monitor (command-key driven). + * + * Startup loads the latest snapshot automatically (empty table when + * none exists). Keys are commands, like top: + * + * s snapshot management (the table area is replaced by the + * snapshot list; Esc returns) + * / search input mode (typing edits the fields; Esc / Enter + * return to command mode; filters apply live) + * ↑↓ PgUp/PgDn row selection (wrap-around) + * Enter detail of the selected prototype (JSON / card tabs) + * q quit + */ +import { + Box, + measureElement, + Text, + useApp, + useInput, + type DOMElement, +} from 'ink'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { TopHeader } from './top-header.js'; +import { + computeSnapshotStats, + countNewborns, + releaseTimestamps, +} from './top-stats.js'; +import { + EMPTY_VALUES, + matchesFields, + SEARCH_FIELDS, + withFieldBackspace, + withFieldInput, +} from '../core/search-model.js'; +import { createSession, loadSession } from '../core/session.js'; +import { + findLatestSnapshot, + listSnapshots, + type SnapshotFileInfo, +} from '../core/snapshot-catalog.js'; +import { resolveToken } from '../core/token.js'; +import { + CreatingProgress, + progressToCreatingProgress, +} from '../ui/creating-progress.js'; +import { HelpPanel } from '../ui/help-panel.js'; +import { PreviewPane } from '../ui/preview-pane.js'; +import { PrototypeDetail } from '../ui/prototype-detail.js'; +import { PrototypeRaw } from '../ui/prototype-raw.js'; +import { ResultTable } from '../ui/result-table.js'; +import { SearchForm } from '../ui/search-form.js'; +import { SnapshotManager } from '../ui/snapshot-manager.js'; +import { SnapshotPicker } from '../ui/snapshot-picker.js'; +import { + COLUMNS, + columnLabel, + compareByColumn, + visibleColumnsForWidth, +} from '../ui/table-columns.js'; +import { useTerminalSize } from '../ui/use-terminal-size.js'; + +import type { FieldValues } from '../core/search-model.js'; +import type { SessionResult, SnapshotSession } from '../core/session.js'; +import type { Column } from '../ui/table-columns.js'; +import type { ProtopediaInMemoryRepository } from 'promidas'; +import type { NormalizedPrototype } from 'promidas/types'; + +export const CREATE_LABEL = '[Create and select]'; + +// Reserved height of the preview area, matching PrototypeCard's fixed +// height (14 fields + 2 border lines) with wrap='truncate'. Reserving it +// even when no prototype is shown keeps the layout from jumping: without +// it the table's measured row count inflates while the preview is empty +// (0 rows) and then cannot shrink when data loads and the card appears +// (measureElement grows the window but never shrinks it once the frame +// overflows), pushing the preview / form off the bottom by the card's +// height. See PreviewPane height + the measureElement windowing effect. +const PREVIEW_HEIGHT = 16; + +type Mode = + | { kind: 'boot' } + | { kind: 'command' } + | { kind: 'input' } + | { kind: 'manage'; snapshots: readonly SnapshotFileInfo[]; notice?: string } + | { kind: 'manager' } + | { kind: 'help' } + | { kind: 'creating'; progress: string }; + +export function TopApp({ snapshotDir }: { readonly snapshotDir: string }) { + const { exit } = useApp(); + const { columns: terminalColumns, rows: terminalRows } = useTerminalSize(); + const [mode, setMode] = useState({ kind: 'boot' }); + const [session, setSession] = useState(null); + const [items, setItems] = useState([]); + const [values, setValues] = useState(EMPTY_VALUES); + const [active, setActive] = useState(0); + const [cursor, setCursor] = useState(0); + // Table sort state: the column (< / > move it among visible columns) + // and the direction (R toggles asc/desc). See top-app sort spec + // (2026-07-21). PID is the secondary key (see compareByColumn). + const [sortColumn, setSortColumn] = useState('id'); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); + const [selected, setSelected] = useState(null); + // Full raw-JSON view of the selected prototype (r from the detail). + const [rawView, setRawView] = useState(false); + const [notice, setNotice] = useState(null); + // Windowed data-row count of the table, measured from the layout. + const [rows, setRows] = useState(3); + const repositoryRef = useRef(null); + const tableAreaRef = useRef(null); + // Reject a snapshot selection while a load is already in flight. The + // picker stays mounted during the await, so without this a fast + // double-select could start two loads and let the earlier one finish + // last, overwriting the newer selection (issue #20). + const loadingRef = useRef(false); + + useEffect( + () => () => { + repositoryRef.current?.dispose(); + repositoryRef.current = null; + }, + [], + ); + + const adoptSessionResult = useCallback( + async (result: SessionResult, failureNotice: string): Promise => { + if (!result.ok) { + setNotice(`${failureNotice}: ${result.message}`); + return false; + } + repositoryRef.current?.dispose(); + repositoryRef.current = result.repository; + const all = await result.repository.getAllFromSnapshot(); + setItems([...all]); + setSession(result.session); + setCursor(0); + setNotice(null); + return true; + }, + [], + ); + + // Startup: load the latest snapshot automatically; an empty table + // when none exists (decided 2026-07-16). + useEffect(() => { + void (async () => { + const latest = await findLatestSnapshot(snapshotDir); + if (latest) { + const result = await loadSession(latest.filePath); + await adoptSessionResult(result, `Failed to load ${latest.fileName}`); + } + setMode({ kind: 'command' }); + })(); + }, []); + + const openManage = useCallback( + async (manageNotice?: string) => { + const snapshots = await listSnapshots(snapshotDir); + setMode({ + kind: 'manage', + snapshots, + ...(manageNotice !== undefined ? { notice: manageNotice } : {}), + }); + }, + [snapshotDir], + ); + + const loadSnapshotFile = useCallback( + async (info: SnapshotFileInfo) => { + if (loadingRef.current) return; + loadingRef.current = true; + try { + const result = await loadSession(info.filePath); + const ok = await adoptSessionResult( + result, + `Failed to load ${info.fileName}`, + ); + if (ok) { + setMode({ kind: 'command' }); + } else { + await openManage(); + } + } finally { + loadingRef.current = false; + } + }, + [adoptSessionResult, openManage], + ); + + const startCreate = useCallback(async () => { + const token = resolveToken(); + // The picker guards the token before calling onCreate; bail here + // defensively so token is a string for createSession. + if (token === null) return; + setMode({ kind: 'creating', progress: 'Preparing the API request...' }); + const result = await createSession({ + token, + snapshotDir, + onProgress: (event) => { + const progress = progressToCreatingProgress(event, { + downloading: (pct, received) => `Downloading ${pct} (${received})`, + saving: 'Saving...', + }); + if (progress !== null) { + setMode({ kind: 'creating', progress }); + } + }, + }); + await adoptSessionResult(result, 'Fetch failed'); + setMode({ kind: 'command' }); + }, [snapshotDir, adoptSessionResult]); + + const visibleColumns = useMemo( + () => visibleColumnsForWidth(terminalColumns), + [terminalColumns], + ); + + const filtered = useMemo(() => { + // filter() returns a fresh array, so the in-place sort is safe. + const direction = sortDirection === 'asc' ? 1 : -1; + const compare = compareByColumn(sortColumn); + return items + .filter((item) => matchesFields(item, values)) + .sort((a, b) => compare(a, b) * direction); + }, [items, values, sortColumn, sortDirection]); + const stats = useMemo(() => computeSnapshotStats(items), [items]); + // Release timestamps depend only on the data; the cheap window count + // re-runs each render against the current time (see TopHeader now). + const releaseTimes = useMemo(() => releaseTimestamps(items), [items]); + // Read once per render (no periodic timer): the header's age and + // newborn counts update on interaction and never redraw on their own. + const now = Date.now(); + + // Any query change resets the selection to the top. + useEffect(() => { + setCursor(0); + }, [values]); + + // If a narrower terminal hid the sort column, fall back to the + // nearest visible column to its left (PID is always visible, so a + // fallback always exists). A wider terminal does not restore it. + useEffect(() => { + if (visibleColumns.some((column) => column.key === sortColumn)) return; + const index = COLUMNS.findIndex((column) => column.key === sortColumn); + for (let i = index - 1; i >= 0; i--) { + const candidate = COLUMNS[i]; + if (candidate && visibleColumns.some((c) => c.key === candidate.key)) { + setSortColumn(candidate.key); + // Reset the cursor to the top, like the explicit sort changes + // (R, < / >): the re-sort reorders the rows, so keeping the old + // index would silently select a different prototype. + setCursor(0); + return; + } + } + }, [visibleColumns, sortColumn]); + + // Non-empty conditions, in SEARCH_FIELDS order, for the filter line. + const activeConditions = SEARCH_FIELDS.map((field) => ({ + key: field.key, + label: field.label.en, + value: values[field.key].trim(), + })).filter((condition) => condition.value !== ''); + + // The table area flex-grows into whatever height the fixed parts + // leave over; measure it after every commit to know how many data + // rows to window. This replaces a manual line-count budget, which + // broke whenever the surrounding layout changed. + useEffect(() => { + if (tableAreaRef.current === null) return; + const { height } = measureElement(tableAreaRef.current); + const next = Math.max(3, height - 1); // minus the table header line + if (next !== rows) setRows(next); + }); + + useInput((input, key) => { + if (mode.kind === 'boot' || mode.kind === 'creating') return; + // The manage screen (SnapshotPicker) owns the keys via its Menu, + // and the snapshot manager owns its own keys likewise. + if (mode.kind === 'manage' || mode.kind === 'manager') return; + if (mode.kind === 'help') { + // Any key dismisses the help overlay (top's behavior); the key + // is consumed here so it does not also fire a command. + setMode({ kind: 'command' }); + return; + } + if (selected) { + if (rawView) { + // Raw JSON: Esc (or Enter / b) returns to the detail view. + if (key.escape || key.return || input === 'b') setRawView(false); + return; + } + if (input === 'r') { + setRawView(true); + return; + } + if (key.escape || key.return || input === 'b') setSelected(null); + return; + } + if (mode.kind === 'input') { + if (key.escape || key.return) { + setMode({ kind: 'command' }); + return; + } + if (key.tab || key.downArrow) { + setActive( + (current) => + (current + (key.shift ? SEARCH_FIELDS.length - 1 : 1)) % + SEARCH_FIELDS.length, + ); + return; + } + if (key.upArrow) { + setActive( + (current) => + (current - 1 + SEARCH_FIELDS.length) % SEARCH_FIELDS.length, + ); + return; + } + const field = SEARCH_FIELDS[active]; + if (!field) return; + if (key.backspace || key.delete) { + setValues((current) => withFieldBackspace(current, field.key)); + return; + } + if (input !== '' && !key.ctrl && !key.meta) { + setValues((current) => withFieldInput(current, field.key, input)); + } + return; + } + // Command mode. + if (input === 'q') { + exit(); + return; + } + if (input === 's') { + void openManage(); + return; + } + if (input === '?' || input === 'h') { + setMode({ kind: 'help' }); + return; + } + if (input === '/') { + setMode({ kind: 'input' }); + return; + } + if (input === 'R') { + // Reverse the sort order (top's R). Reset the cursor to the top, + // matching how a query change resets it. + setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc')); + setCursor(0); + return; + } + if (input === '<' || input === '>' || key.leftArrow || key.rightArrow) { + // Move the sort column among the currently visible columns, + // clamped at the ends; the direction is kept. < or Left moves + // left, > or Right moves right. + const forward = input === '>' || key.rightArrow; + const index = visibleColumns.findIndex( + (column) => column.key === sortColumn, + ); + // The sort column can be transiently absent from visibleColumns + // during a resize, before the fallback effect re-homes it. Guard + // the -1: otherwise '>' would compute index -1 + 1 === 0 and jump + // to the first column. + if (index === -1) return; + const next = visibleColumns[forward ? index + 1 : index - 1]; + if (next) { + setSortColumn(next.key); + setCursor(0); + } + return; + } + if (key.upArrow) { + setCursor((current) => + current === 0 ? Math.max(0, filtered.length - 1) : current - 1, + ); + } else if (key.downArrow) { + setCursor((current) => + current >= filtered.length - 1 ? 0 : current + 1, + ); + } else if (key.pageUp) { + setCursor((current) => Math.max(0, current - rows)); + } else if (key.pageDown) { + // Clamp at 0: with an empty result list, length - 1 is -1. + setCursor((current) => + Math.max(0, Math.min(filtered.length - 1, current + rows)), + ); + } else if (key.return) { + const item = filtered[cursor]; + if (item) setSelected(item); + } + }); + + if (mode.kind === 'boot') { + return Loading snapshot...; + } + + if (selected) { + if (rawView) { + return ( + + + Esc: back to detail + + ); + } + return ( + // minHeight so the detail's flex-grown JSON pane gets the + // leftover terminal height (see PrototypeDetail). + + + Esc: back | r: Raw JSON + + ); + } + + const usageForSearch = 'Tab/arrows: move fields / Enter, Esc: command mode'; + const usageForList = + '↑↓: Select | Enter: Show detail | /: Search | s: Snapshot | ?: Help | q: Quit'; + + return ( + // minHeight (not height): fill the terminal so the flex-grown + // table area absorbs the leftover rows, but let the frame grow + // naturally instead of clipping when the terminal is too small + // for the fixed parts (header, preview, form). + + {/* now is read at render time (no periodic timer): the age line + updates on interaction and stays put while idle, so it never + redraws on its own and never drops a terminal selection. */} + + + {notice && {notice}} + + {mode.kind === 'manage' && ( + void loadSnapshotFile(info)} + onCreate={() => void startCreate()} + onOpenManager={() => setMode({ kind: 'manager' })} + onBack={() => setMode({ kind: 'command' })} + onQuit={exit} + /> + )} + + {mode.kind === 'manager' && ( + void openManage()} + /> + )} + + {mode.kind === 'creating' && ( + + )} + + {mode.kind === 'help' && } + + {(mode.kind === 'command' || mode.kind === 'input') && ( + + {activeConditions.length > 0 && ( + + {activeConditions.map((condition, index) => ( + + {index > 0 ? ', ' : ''} + {condition.label}: {condition.value} + + ))} + + )} + + + Prototypes: {filtered.length} / {items.length} + {filtered.length > 0 ? ` (${cursor + 1}/${filtered.length})` : ''} + + + Sort: {columnLabel(sortColumn)} {sortDirection} + + + + + + + + + + + + {mode.kind === 'input' ? ( + <> + + Search + + {usageForSearch} + + ) : ( + <> + + List + + {usageForList} + + )} + + + )} + + ); +} diff --git a/src/pptop/top-header.tsx b/src/pptop/top-header.tsx new file mode 100644 index 0000000..5df449f --- /dev/null +++ b/src/pptop/top-header.tsx @@ -0,0 +1,109 @@ +/** + * Presentational: pptop's own header - dense aggregate summary lines in + * the style of top(1), instead of the boxed metadata header used by ppex. + */ +import { Box, Text } from 'ink'; + +import { resolveSnapshotStaleAfterMs } from '../core/constants.js'; +import { formatLocalDateTime } from '../core/format.js'; + +import type { NewbornCounts, SnapshotStats } from './top-stats.js'; +import type { SnapshotSession } from '../core/session.js'; + +// Status code labels for the breakdown line (ProtoPedia statuses). +// Short labels for the ProtoPedia statuses (JP: アイデア / 開発中 / 完成 +// / 供養). "eol" (end of life) stands in for 供養 - the work is still +// published but retired: development stopped, site taken down, or +// discarded. +const STATUS_LABELS: ReadonlyMap = new Map([ + [1, 'idea'], + [2, 'dev'], + [3, 'done'], + [4, 'eol'], +]); + +function n(value: number): string { + return value.toLocaleString('en-US'); +} + +export type TopHeaderProps = { + readonly stats: SnapshotStats; + /** null when no snapshot is loaded (empty table startup). */ + readonly session: SnapshotSession | null; + readonly now: number; + readonly newborns: NewbornCounts; +}; + +export function TopHeader({ stats, session, now, newborns }: TopHeaderProps) { + const statuses = [...STATUS_LABELS.entries()]; + const breakdown = statuses + .map(([code, label]) => `${n(stats.statusCounts.get(code) ?? 0)} ${label}`) + .join(', '); + const percentages = statuses + .map(([code, label]) => { + const pct = + stats.total === 0 + ? '0.0' + : (((stats.statusCounts.get(code) ?? 0) / stats.total) * 100).toFixed( + 1, + ); + return `${pct} ${label}`; + }) + .join(', '); + + // top(1)'s "top -