diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a4519c1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,30 @@ +.git +.github +node_modules +server/node_modules +client/node_modules +site/node_modules +site +promo +docs +electron +dist +server/dist +client/dist +electron/dist +release +data +data-demo +*.db +*.db-journal +*.log +.env +.env.local +.astro +.claude +.trae +.codebuddy +.kiro +.agents +.uploads +skills diff --git a/.env.example b/.env.example index 4601f0b..d4f1a38 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,10 @@ # Server PORT=3721 +# Docker Compose binds to localhost by default. Put a reverse proxy with HTTPS in +# front of ChatCrystal before exposing it publicly. +# BIND_ADDRESS=127.0.0.1 +# CHATCRYSTAL_HOST_PORT=3721 # DATA_DIR=./data # Data source overrides @@ -57,3 +61,15 @@ PORT=3721 # LLM_BASE_URL=https://openrouter.ai/api/v1 # LLM_API_KEY=... # LLM_MODEL=anthropic/claude-3.5-haiku + +# Docker cloud deployment +# CHATCRYSTAL_IMAGE_TAG=latest +# CHATCRYSTAL_CLOUD_MODE=true +# CHATCRYSTAL_API_TOKEN= +# CHATCRYSTAL_ALLOW_INSECURE_REMOTE_HTTP=false +# +# In Docker, localhost points inside the container. +# For Ollama on Docker Desktop, use: +# CHATCRYSTAL_DOCKER_LLM_BASE_URL=http://host.docker.internal:11434 +# CHATCRYSTAL_DOCKER_EMBEDDING_BASE_URL=http://host.docker.internal:11434 +# The default docker-compose.yml already maps host.docker.internal for Linux. diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..ede2ad5 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,175 @@ +name: Docker + +on: + pull_request: + paths: + - Dockerfile + - docker-compose.yml + - docker-compose.build.yml + - .dockerignore + - package.json + - package-lock.json + - tsconfig.base.json + - README.md + - LICENSE + - NOTICE + - scripts/** + - server/** + - client/** + - shared/** + - .github/workflows/docker.yml + push: + branches: [main] + tags: + - v* + paths: + - Dockerfile + - docker-compose.yml + - docker-compose.build.yml + - .dockerignore + - package.json + - package-lock.json + - tsconfig.base.json + - README.md + - LICENSE + - NOTICE + - scripts/** + - server/** + - client/** + - shared/** + - .github/workflows/docker.yml + workflow_dispatch: + +permissions: + contents: read + +env: + IMAGE_NAME: ghcr.io/zengliangyi/chatcrystal + +jobs: + validate: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Validate Compose + run: docker compose config + + - name: Validate Compose source build override + run: docker compose -f docker-compose.yml -f docker-compose.build.yml config + + - name: Build image + run: docker build -t chatcrystal:test . + + - name: Smoke test CLI in image + run: docker run --rm --entrypoint crystal chatcrystal:test --version + + - name: Smoke test container + run: | + docker run -d --name chatcrystal-smoke \ + -e CHATCRYSTAL_CLOUD_MODE=true \ + -e CHATCRYSTAL_API_TOKEN=ci-smoke-token-123456 \ + -e DATA_DIR=/data \ + -p 127.0.0.1::3721 \ + chatcrystal:test + mapped="$(docker port chatcrystal-smoke 3721/tcp)" + port="${mapped##*:}" + for _attempt in $(seq 1 30); do + if curl -fsS "http://127.0.0.1:${port}/api/health"; then + exit 0 + fi + sleep 1 + done + docker logs chatcrystal-smoke + exit 1 + + - name: Cleanup + if: always() + run: docker rm -f chatcrystal-smoke || true + + publish: + if: github.event_name != 'pull_request' + needs: validate + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + concurrency: + group: docker-publish-${{ github.ref }} + cancel-in-progress: true + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Verify publish ref + run: | + git fetch --no-tags origin main + if [ "${GITHUB_REF}" = "refs/heads/main" ]; then + remote_sha="$(git rev-parse origin/main)" + if [ "${GITHUB_SHA}" != "${remote_sha}" ]; then + echo "::error::Refusing to publish because ${GITHUB_SHA} is not the current origin/main (${remote_sha})." + exit 1 + fi + elif [[ "${GITHUB_REF}" == refs/tags/v* ]]; then + if ! git merge-base --is-ancestor "${GITHUB_SHA}" origin/main; then + echo "::error::Refusing to publish tag ${GITHUB_REF_NAME} because ${GITHUB_SHA} is not reachable from origin/main." + exit 1 + fi + else + echo "::error::Docker image publishing is limited to main and v* tags." + exit 1 + fi + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + type=sha,prefix=sha- + type=ref,event=tag + type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} + + - name: Publish image + id: publish + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + - name: Verify public pull + run: | + docker logout ghcr.io || true + if ! docker pull "${IMAGE_NAME}@${{ steps.publish.outputs.digest }}"; then + echo "::error::GHCR image is not publicly pullable. Open the package settings for ghcr.io/zengliangyi/chatcrystal, change visibility to Public, then rerun this workflow." + exit 1 + fi + if [ "${GITHUB_REF}" = "refs/heads/main" ]; then + docker pull "${IMAGE_NAME}:latest" + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index f247b1f..b972a85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +- Added Docker cloud deployment for a personal single-instance ChatCrystal server. +- Added GHCR publishing workflow and Compose defaults for pulling the published image. +- Added first-run setup mode and shared bearer token authentication for Web, API, CLI, and MCP. +- Added CLI cloud connection commands and remote import from local AI tool histories. +- Disabled server-side local scan import in cloud mode to avoid scanning container paths. + ## [0.4.9] - 2026-04-29 ### Experience Quality Gate diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bcff1ea --- /dev/null +++ b/Dockerfile @@ -0,0 +1,53 @@ +FROM node:22-alpine AS build + +WORKDIR /app + +COPY package.json package-lock.json tsconfig.base.json ./ +COPY server/package.json server/package.json +COPY client/package.json client/package.json +COPY shared/package.json shared/package.json + +RUN npm ci + +COPY . . +RUN npm run build + +FROM node:22-alpine AS runtime + +WORKDIR /app +LABEL org.opencontainers.image.source="https://github.com/ZengLiangYi/ChatCrystal" +LABEL org.opencontainers.image.description="ChatCrystal cloud server" +LABEL org.opencontainers.image.licenses="Apache-2.0" + +ENV NODE_ENV=production +ENV PORT=3721 +ENV DATA_DIR=/data +ENV CHATCRYSTAL_CLOUD_MODE=true + +COPY package.json package-lock.json ./ +COPY server/package.json server/package.json +COPY shared/package.json shared/package.json + +RUN npm ci --omit=dev --workspace server --workspace shared --include-workspace-root=false --ignore-scripts \ + && npm cache clean --force + +COPY --from=build /app/server/dist ./server/dist +COPY --from=build /app/client/dist ./client/dist +COPY --from=build /app/shared/types ./shared/types +COPY --from=build /app/README.md ./README.md +COPY --from=build /app/LICENSE ./LICENSE +COPY --from=build /app/NOTICE ./NOTICE + +RUN chmod +x /app/server/dist/server/src/cli/index.js \ + && ln -s /app/server/dist/server/src/cli/index.js /usr/local/bin/crystal \ + && mkdir -p /data \ + && chown -R node:node /data + +USER node +EXPOSE 3721 +VOLUME ["/data"] + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:3721/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +CMD ["npm", "start", "-w", "server"] diff --git a/LICENSE b/LICENSE index 0e4cf98..f520108 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,201 @@ -MIT License - -Copyright (c) 2026 Rayner Zeng - -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. +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, +and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by +the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all +other entities that control, are controlled by, or are under common +control with that entity. For the purposes of this definition, +"control" means (i) the power, direct or indirect, to cause the +direction or management of such entity, whether by contract or +otherwise, or (ii) ownership of fifty percent (50%) or more of the +outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity +exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, +including but not limited to software source code, documentation +source, and configuration files. + +"Object" form shall mean any form resulting from mechanical +transformation or translation of a Source form, including but +not limited to compiled object code, generated documentation, +and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or +Object form, made available under the License, as indicated by a +copyright notice that is included in or attached to the work +(an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object +form, that is based on (or derived from) the Work and for which the +editorial revisions, annotations, elaborations, or other modifications +represent, as a whole, an original work of authorship. For the purposes +of this License, Derivative Works shall not include works that remain +separable from, or merely link (or bind by name) to the interfaces of, +the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including +the original version of the Work and any modifications or additions +to that Work or Derivative Works thereof, that is intentionally +submitted to Licensor for inclusion in the Work by the copyright owner +or by an individual or Legal Entity authorized to submit on behalf of +the copyright owner. For the purposes of this definition, "submitted" +means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, +and issue tracking systems that are managed by, or on behalf of, the +Licensor for the purpose of discussing and improving the Work, but +excluding communication that is conspicuously marked or otherwise +designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity +on behalf of whom a Contribution has been received by Licensor and +subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the +Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +(except as stated in this section) patent license to make, have made, +use, offer to sell, sell, import, and otherwise transfer the Work, +where such license applies only to those patent claims licensable +by such Contributor that are necessarily infringed by their +Contribution(s) alone or by combination of their Contribution(s) +with the Work to which such Contribution(s) was submitted. If You +institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work +or a Contribution incorporated within the Work constitutes direct +or contributory patent infringement, then any patent licenses +granted to You under this License for that Work shall terminate +as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the +Work or Derivative Works thereof in any medium, with or without +modifications, and in Source or Object form, provided that You +meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + +You may add Your own copyright statement to Your modifications and +may provide additional or different license terms and conditions +for use, reproduction, or distribution of Your modifications, or +for any such Derivative Works as a whole, provided Your use, +reproduction, and distribution of the Work otherwise complies with +the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, +any Contribution intentionally submitted for inclusion in the Work +by You to the Licensor shall be under the terms and conditions of +this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify +the terms of any separate license agreement you may have executed +with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade +names, trademarks, service marks, or product names of the Licensor, +except as required for reasonable and customary use in describing the +origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or +agreed to in writing, Licensor provides the Work (and each +Contributor provides its Contributions) on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied, including, without limitation, any warranties or conditions +of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A +PARTICULAR PURPOSE. You are solely responsible for determining the +appropriateness of using or redistributing the Work and assume any +risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, +whether in tort (including negligence), contract, or otherwise, +unless required by applicable law (such as deliberate and grossly +negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, +incidental, or consequential damages of any character arising as a +result of this License or out of the use or inability to use the +Work (including but not limited to damages for loss of goodwill, +work stoppage, computer failure or malfunction, or any and all +other commercial damages or losses), even if such Contributor +has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing +the Work or Derivative Works thereof, You may choose to offer, +and charge a fee for, acceptance of support, warranty, indemnity, +or other liability obligations and/or rights consistent with this +License. However, in accepting such obligations, You may act only +on Your own behalf and on Your sole responsibility, not on behalf +of any other Contributor, and only if You agree to indemnify, +defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason +of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following +boilerplate notice, with the fields enclosed by brackets "[]" +replaced with your own identifying information. (Don't include +the brackets!) The text should be enclosed in the appropriate +comment syntax for the file format. We also recommend that a +file or class name and description of purpose be included on the +same "printed page" as the copyright notice for easier +identification within third-party archives. + +Copyright 2026 Rayner Zeng + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..edc4e39 --- /dev/null +++ b/NOTICE @@ -0,0 +1,4 @@ +ChatCrystal +Copyright 2026 Rayner Zeng + +This product is licensed under the Apache License, Version 2.0. diff --git a/README.md b/README.md index c601392..6c9b35b 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![GitHub release](https://img.shields.io/github/v/release/ZengLiangYi/ChatCrystal?style=flat-square)](https://github.com/ZengLiangYi/ChatCrystal/releases) [![npm](https://img.shields.io/npm/v/chatcrystal?style=flat-square)](https://www.npmjs.com/package/chatcrystal) -[![License: MIT](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](LICENSE) +[![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue?style=flat-square)](LICENSE) [![Node.js](https://img.shields.io/badge/node-%3E%3D20-brightgreen?style=flat-square)](https://nodejs.org/) [![Platform](https://img.shields.io/badge/platform-Windows-lightgrey?style=flat-square)](#) [![Website](https://img.shields.io/badge/website-ChatCrystal-5A5FD6?style=flat-square)](https://zengliangyi.github.io/ChatCrystal/) @@ -45,6 +45,37 @@ crystal import Then open http://localhost:3721 in your browser. +### Docker Cloud + +The default Compose deployment runs only the ChatCrystal service. It stores data in the `chatcrystal-data` volume mounted at `/data` inside the container. + +```bash +git clone https://github.com/ZengLiangYi/ChatCrystal.git +cd ChatCrystal +docker compose up -d +``` + +The default `docker-compose.yml` pulls `ghcr.io/zengliangyi/chatcrystal:latest` from GitHub Container Registry. Set `CHATCRYSTAL_IMAGE_TAG` to pin a published version. To build from source instead, run `docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build`. + +To update an existing Docker deployment, run `docker compose pull && docker compose up -d`. Maintainers only: after the first GHCR publish, make the `ghcr.io/zengliangyi/chatcrystal` package public in GitHub Packages; the release workflow verifies anonymous pull access before passing. + +Compose binds ChatCrystal to `127.0.0.1:3721` by default. Set `CHATCRYSTAL_HOST_PORT` to change the host port. For public cloud access, put an HTTPS reverse proxy in front of it and connect to the HTTPS URL. Do not expose bearer-token traffic over plain HTTP. + +On first start without `CHATCRYSTAL_API_TOKEN`, open the Web UI and enter the setup code printed in container logs or stored at `/data/setup-code`, then choose one shared API token for your devices. + +To use an existing Ollama or external API, configure provider URLs in the Web UI or environment. In Docker, `localhost` means inside the container; use `CHATCRYSTAL_DOCKER_LLM_BASE_URL` and `CHATCRYSTAL_DOCKER_EMBEDDING_BASE_URL` for Compose-time provider URL overrides. Docker Desktop can reach host Ollama at `http://host.docker.internal:11434`, or you can use a remote HTTPS/OpenAI-compatible API. + +### Import from a Device into the Cloud Instance + +Install or run the CLI on the device that has Claude Code, Cursor, Codex CLI, Trae, or GitHub Copilot history: + +```bash +crystal connect https://chatcrystal.example.com --token "your-long-token" +crystal import --yes +``` + +The CLI scans local histories, parses them locally, and uploads normalized conversations to the cloud. The cloud never scans your local filesystem. Imported conversations are not summarized automatically; use the Web UI or `crystal summarize --all` when you are ready. CLI commands refuse to send ChatCrystal API tokens to non-local `http://` URLs by default; use HTTPS for cloud access. + ## What It Does - **Imports AI coding conversations** from local tool data directories. @@ -128,4 +159,4 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for architecture, testing, build, ## License -[MIT](LICENSE) +[Apache License 2.0](LICENSE) diff --git a/README.zh-CN.md b/README.zh-CN.md index d7f75ef..709f735 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -8,7 +8,7 @@ [![GitHub release](https://img.shields.io/github/v/release/ZengLiangYi/ChatCrystal?style=flat-square)](https://github.com/ZengLiangYi/ChatCrystal/releases) [![npm](https://img.shields.io/npm/v/chatcrystal?style=flat-square)](https://www.npmjs.com/package/chatcrystal) -[![License: MIT](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](LICENSE) +[![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue?style=flat-square)](LICENSE) [![Node.js](https://img.shields.io/badge/node-%3E%3D20-brightgreen?style=flat-square)](https://nodejs.org/) [![Platform](https://img.shields.io/badge/platform-Windows-lightgrey?style=flat-square)](#) [![Website](https://img.shields.io/badge/website-ChatCrystal-5A5FD6?style=flat-square)](https://zengliangyi.github.io/ChatCrystal/zh/) @@ -45,6 +45,37 @@ crystal import 然后在浏览器打开 http://localhost:3721。 +### Docker 云端部署 + +默认 Compose 只运行 ChatCrystal 一个服务。数据保存在 `chatcrystal-data` volume 中,并挂载到容器内 `/data`。 + +```bash +git clone https://github.com/ZengLiangYi/ChatCrystal.git +cd ChatCrystal +docker compose up -d +``` + +默认 `docker-compose.yml` 会从 GitHub Container Registry 拉取 `ghcr.io/zengliangyi/chatcrystal:latest`。如果要固定已发布版本,设置 `CHATCRYSTAL_IMAGE_TAG`。如果要从源码本地构建,使用 `docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build`。 + +如果要更新已有 Docker 部署,运行 `docker compose pull && docker compose up -d`。维护者注意:GHCR 首次发布后,需要在 GitHub Packages 中将 `ghcr.io/zengliangyi/chatcrystal` 设为 Public;发布 workflow 会验证匿名拉取权限,未公开时不会通过。 + +Compose 默认只绑定到宿主机 `127.0.0.1:3721`。如需调整宿主机端口,设置 `CHATCRYSTAL_HOST_PORT`。如果要公网访问,请在前面放一个 HTTPS 反向代理,并让 CLI / 浏览器连接 HTTPS 地址。不要让 bearer token 走明文 HTTP。 + +首次启动且没有设置 `CHATCRYSTAL_API_TOKEN` 时,打开 Web UI,输入容器日志或 `/data/setup-code` 中的 setup code,然后设置一个供所有设备共享的 API token。 + +如果使用已有 Ollama 或外部 API,可在 Web UI 或环境变量中配置 provider URL。在 Docker 中,`localhost` 指向容器内部;Compose 场景请用 `CHATCRYSTAL_DOCKER_LLM_BASE_URL` 和 `CHATCRYSTAL_DOCKER_EMBEDDING_BASE_URL` 覆盖 provider URL。Docker Desktop 上访问宿主机 Ollama 可用 `http://host.docker.internal:11434`,也可以使用远程 HTTPS / OpenAI-compatible API。 + +### 从本机设备导入到云端实例 + +在保存 Claude Code、Cursor、Codex CLI、Trae 或 GitHub Copilot 历史记录的设备上安装或运行 CLI: + +```bash +crystal connect https://chatcrystal.example.com --token "your-long-token" +crystal import --yes +``` + +CLI 会在本机扫描并解析历史记录,然后把标准化后的对话上传到云端。云端不会扫描你的本机文件系统。导入的对话不会自动生成摘要;准备好后再通过 Web UI 或 `crystal summarize --all` 批量生成。默认情况下,CLI 会拒绝把 ChatCrystal API token 发送到非本机 `http://` 地址;云端访问请使用 HTTPS。 + ## 它能做什么 - **导入 AI 编程对话**,从本地工具数据目录扫描历史记录。 @@ -128,4 +159,4 @@ npm run dev ## License -[MIT](LICENSE) +[Apache License 2.0](LICENSE) diff --git a/client/package.json b/client/package.json index 7f41bd0..d84b9a0 100644 --- a/client/package.json +++ b/client/package.json @@ -1,7 +1,7 @@ { "name": "client", "private": true, - "license": "MIT", + "license": "Apache-2.0", "version": "0.0.0", "type": "module", "scripts": { diff --git a/client/src/App.tsx b/client/src/App.tsx index db61101..b81ae65 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import { ThemeProvider } from '@/providers/ThemeProvider.tsx'; import '@/i18n'; import { Layout } from '@/components/Layout.tsx'; +import { AuthGate } from '@/components/AuthGate.tsx'; const DashboardPage = lazy(() => import('@/pages/Dashboard.tsx').then((module) => ({ default: module.Dashboard }))); const ConversationsPage = lazy(() => import('@/pages/Conversations.tsx').then((module) => ({ default: module.Conversations }))); @@ -37,20 +38,22 @@ export default function App() { return ( - - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - + + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ); diff --git a/client/src/components/AuthGate.tsx b/client/src/components/AuthGate.tsx new file mode 100644 index 0000000..74d1b27 --- /dev/null +++ b/client/src/components/AuthGate.tsx @@ -0,0 +1,150 @@ +import { type ReactNode, useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + AUTH_CHANGED_EVENT, + api, + assertSafeWebAuthTransport, + getStoredToken, + isInsecureRemoteHttpLocation, + setStoredToken, +} from "@/lib/api.ts"; + +type GateState = + | { status: "loading"; providerWarnings: string[] } + | { status: "ready"; providerWarnings: string[] } + | { status: "token"; providerWarnings: string[] } + | { status: "setup"; providerWarnings: string[] }; + +export function AuthGate({ children }: { children: ReactNode }) { + const { t } = useTranslation(); + const [state, setState] = useState({ status: "loading", providerWarnings: [] }); + const [setupCode, setSetupCode] = useState(""); + const [token, setToken] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const refresh = useCallback(async () => { + setError(null); + setState((current) => ({ status: "loading", providerWarnings: current.providerWarnings })); + if (getStoredToken() && isInsecureRemoteHttpLocation()) { + setError(t("auth.insecure_http")); + setState({ status: "token", providerWarnings: [] }); + return; + } + try { + const status = await api.getSetupStatus(); + if (!status.cloudMode || status.authenticated) { + setState({ status: "ready", providerWarnings: status.providerWarnings }); + } else if (status.setupRequired) { + setState({ status: "setup", providerWarnings: status.providerWarnings }); + } else if (getStoredToken()) { + try { + await api.verifyToken(); + setState({ status: "ready", providerWarnings: status.providerWarnings }); + } catch { + setState({ status: "token", providerWarnings: status.providerWarnings }); + } + } else { + setState({ status: "token", providerWarnings: status.providerWarnings }); + } + } catch (err) { + setError(err instanceof Error ? err.message : t("auth.error")); + setState({ status: "token", providerWarnings: [] }); + } + }, [t]); + + useEffect(() => { + refresh(); + window.addEventListener(AUTH_CHANGED_EVENT, refresh); + return () => window.removeEventListener(AUTH_CHANGED_EVENT, refresh); + }, [refresh]); + + async function submit() { + setSubmitting(true); + setError(null); + try { + if (isInsecureRemoteHttpLocation()) { + setError(t("auth.insecure_http")); + return; + } + assertSafeWebAuthTransport(); + if (state.status === "setup") { + await api.completeSetup({ setupCode, token }); + } else { + setStoredToken(token); + await api.verifyToken(); + } + setStoredToken(token); + setState({ status: "ready", providerWarnings: state.providerWarnings }); + } catch (err) { + setError(err instanceof Error ? err.message : t("auth.error")); + } finally { + setSubmitting(false); + } + } + + if (state.status === "ready") return <>{children}; + + return ( +
+
+
+

{t("brand.tagline")}

+

{t("auth.title")}

+

+ {state.status === "setup" ? t("auth.setup_hint") : t("auth.token_hint")} +

+
+ + {state.providerWarnings.length > 0 && ( +
+ {state.providerWarnings.map((warning) =>

{warning}

)} +
+ )} + + {state.status === "setup" && ( + + )} + + + + {error &&

{error}

} + +
+ + +
+
+
+ ); +} diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index cef929e..30155c7 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -29,6 +29,7 @@ export function Sidebar() { const { t } = useTranslation(); const { data: status } = useStatus(); const { state: importState, start: startImport, reset: resetImport } = useImportStream(); + const cloudMode = status?.cloudMode === true; // Auto-dismiss done/error state after 5 seconds useEffect(() => { @@ -78,8 +79,9 @@ export function Sidebar() { ))} - {/* Import button */} -
+ {/* Import */} + {!cloudMode && ( +
+
+ )} + {cloudMode && ( +
+
+
{t('import.cloud_mode')}
+
{t('import.cloud_import_hint')}
+
+
+ )} {/* Stats footer */} {status && ( diff --git a/client/src/hooks/use-import-stream.ts b/client/src/hooks/use-import-stream.ts index 1d03d20..2311271 100644 --- a/client/src/hooks/use-import-stream.ts +++ b/client/src/hooks/use-import-stream.ts @@ -1,5 +1,6 @@ import { useState, useCallback } from 'react'; import { useQueryClient } from '@tanstack/react-query'; +import { assertSafeWebAuthTransport, clearStoredToken, getStoredToken } from '../lib/api'; export interface ImportProgress { total: number; @@ -32,13 +33,28 @@ export function useImportStream() { const start = useCallback(() => { setState({ status: 'running', progress: null }); + const headers = new Headers({ Accept: 'text/event-stream' }); + const token = getStoredToken(); + if (token) { + try { + assertSafeWebAuthTransport(); + } catch (err) { + setState({ status: 'error', error: err instanceof Error ? err.message : 'Import failed' }); + return; + } + headers.set('Authorization', `Bearer ${token}`); + } fetch(`${API_BASE}/api/import/scan/stream`, { - headers: { Accept: 'text/event-stream' }, + headers, }) .then(async (res) => { if (!res.ok || !res.body) { - setState({ status: 'error', error: `Request failed: ${res.status}` }); + if (res.status === 401) { + clearStoredToken(); + } + const body = await res.json().catch(() => ({})); + setState({ status: 'error', error: body.error || `Request failed: ${res.status}` }); return; } diff --git a/client/src/i18n/en.json b/client/src/i18n/en.json index ce52ab2..e993179 100644 --- a/client/src/i18n/en.json +++ b/client/src/i18n/en.json @@ -131,7 +131,9 @@ "import": { "imported": "Imported", "skipped": "Skipped", - "complete": "Done: {{imported}} imported / {{total}} scanned" + "complete": "Done: {{imported}} imported / {{total}} scanned", + "cloud_mode": "Cloud mode", + "cloud_import_hint": "Run crystal import --yes on the device with local histories" }, "data_source_conversations": "{{count}} conversations", "key_conclusions": "Key Conclusions", @@ -142,6 +144,16 @@ "configure_llm": "Configure LLM", "browse_demo": "Browse Demo Data" }, + "auth": { + "title": "Connect to Cloud Memory", + "setup_hint": "Enter the setup code from the container logs or /data/setup-code, then choose the shared access token for your devices.", + "token_hint": "Enter the access token for this ChatCrystal instance.", + "setup_code": "Setup code", + "token": "Access token", + "submit": "Continue", + "error": "Authentication failed", + "insecure_http": "Refusing to send ChatCrystal access tokens over public HTTP. Use HTTPS or a local tunnel." + }, "guide": { "step1": "1. Click \"Import\" in the sidebar to scan AI conversations", "step2": "2. Go to \"Conversations\" to browse and click \"Generate Summary\"", diff --git a/client/src/i18n/zh.json b/client/src/i18n/zh.json index 392edb5..024f9b8 100644 --- a/client/src/i18n/zh.json +++ b/client/src/i18n/zh.json @@ -131,7 +131,9 @@ "import": { "imported": "已导入", "skipped": "已跳过", - "complete": "完成:导入 {{imported}} 条 / 共扫描 {{total}} 条" + "complete": "完成:导入 {{imported}} 条 / 共扫描 {{total}} 条", + "cloud_mode": "云端模式", + "cloud_import_hint": "在保存历史记录的设备上运行 crystal import --yes" }, "data_source_conversations": "{{count}} 个对话", "key_conclusions": "关键结论", @@ -142,6 +144,16 @@ "configure_llm": "配置 LLM", "browse_demo": "浏览演示数据" }, + "auth": { + "title": "连接云端记忆库", + "setup_hint": "输入容器日志或 /data/setup-code 中的 setup code,然后设置所有设备共用的访问 token。", + "token_hint": "输入此 ChatCrystal 实例的访问 token。", + "setup_code": "Setup code", + "token": "访问 token", + "submit": "继续", + "error": "认证失败", + "insecure_http": "拒绝通过公网 HTTP 发送 ChatCrystal 访问 token。请使用 HTTPS 或本地隧道。" + }, "guide": { "step1": "1. 点击左侧「导入对话」扫描 AI 对话记录", "step2": "2. 前往「对话」页浏览对话,点击「生成摘要」提炼笔记", diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 2b4242f..b51ffb3 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -1,21 +1,57 @@ import type { DeleteNoteReviewRequest, DeleteNoteReviewResponse } from "@chatcrystal/shared"; const BASE = "/api"; +const TOKEN_KEY = "chatcrystal.apiToken"; +export const AUTH_CHANGED_EVENT = "chatcrystal-auth-changed"; +const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); type DeleteNoteWebRequest = Omit & { source: "web" }; +export function getStoredToken(): string | null { + return window.localStorage.getItem(TOKEN_KEY); +} + +export function setStoredToken(token: string): void { + window.localStorage.setItem(TOKEN_KEY, token); + window.dispatchEvent(new Event(AUTH_CHANGED_EVENT)); +} + +export function clearStoredToken(): void { + window.localStorage.removeItem(TOKEN_KEY); + window.dispatchEvent(new Event(AUTH_CHANGED_EVENT)); +} + +export function isInsecureRemoteHttpLocation(location = window.location): boolean { + return location.protocol === "http:" && !LOCAL_HOSTS.has(location.hostname); +} + +export function assertSafeWebAuthTransport(): void { + if (!isInsecureRemoteHttpLocation()) return; + throw new Error( + "Refusing to send ChatCrystal access tokens over public HTTP. Use HTTPS or a local tunnel.", + ); +} + async function request(path: string, options?: RequestInit): Promise { - const headers: Record = {}; + const headers = new Headers(options?.headers); // Only set Content-Type for requests with body if (options?.body) { - headers["Content-Type"] = "application/json"; + headers.set("Content-Type", "application/json"); + } + const token = getStoredToken(); + if (token) { + assertSafeWebAuthTransport(); + headers.set("Authorization", `Bearer ${token}`); } const res = await fetch(`${BASE}${path}`, { - headers, ...options, + headers, }); if (!res.ok) { const body = await res.json().catch(() => ({})); + if (res.status === 401) { + clearStoredToken(); + } throw new Error(body.error || `Request failed: ${res.status}`); } const json = await res.json(); @@ -28,6 +64,8 @@ export const api = { request<{ server: boolean; database: boolean; + cloudMode: boolean; + providerWarnings: string[]; stats: { totalConversations: number; totalNotes: number; @@ -35,6 +73,23 @@ export const api = { }; }>("/status"), + getSetupStatus: () => + request<{ + cloudMode: boolean; + setupRequired: boolean; + authenticated: boolean; + providerWarnings: string[]; + }>("/setup/status"), + + completeSetup: (data: { setupCode: string; token: string }) => + request<{ authenticated: boolean }>("/setup/complete", { + method: "POST", + body: JSON.stringify(data), + }), + + verifyToken: () => + request<{ authenticated: boolean }>("/auth/verify", { method: "POST" }), + triggerImport: () => request<{ total: number; diff --git a/docker-compose.build.yml b/docker-compose.build.yml new file mode 100644 index 0000000..eeb520b --- /dev/null +++ b/docker-compose.build.yml @@ -0,0 +1,4 @@ +services: + chatcrystal: + build: . + image: chatcrystal:local diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3a318db --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +services: + chatcrystal: + image: ghcr.io/zengliangyi/chatcrystal:${CHATCRYSTAL_IMAGE_TAG:-latest} + ports: + - "${BIND_ADDRESS:-127.0.0.1}:${CHATCRYSTAL_HOST_PORT:-3721}:3721" + environment: + NODE_ENV: production + PORT: 3721 + DATA_DIR: /data + CHATCRYSTAL_CLOUD_MODE: "true" + CHATCRYSTAL_API_TOKEN: ${CHATCRYSTAL_API_TOKEN:-} + LLM_PROVIDER: ${LLM_PROVIDER:-ollama} + LLM_BASE_URL: ${CHATCRYSTAL_DOCKER_LLM_BASE_URL:-http://host.docker.internal:11434} + LLM_API_KEY: ${LLM_API_KEY:-} + LLM_MODEL: ${LLM_MODEL:-qwen2.5:7b} + EMBEDDING_PROVIDER: ${EMBEDDING_PROVIDER:-ollama} + EMBEDDING_BASE_URL: ${CHATCRYSTAL_DOCKER_EMBEDDING_BASE_URL:-http://host.docker.internal:11434} + EMBEDDING_API_KEY: ${EMBEDDING_API_KEY:-} + EMBEDDING_MODEL: ${EMBEDDING_MODEL:-nomic-embed-text} + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - chatcrystal-data:/data + restart: unless-stopped + +volumes: + chatcrystal-data: diff --git a/docs/MCP.md b/docs/MCP.md index a241310..638ae22 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -37,6 +37,38 @@ Example agent configuration: If a tool separately asks for an HTTP API endpoint, use `http://localhost:3721`. Do not use a bare `http://127.0.0.1` URL without a port because HTTP defaults to port 80. +### Cloud Mode + +`crystal mcp` uses the same connection priority as the CLI. After `crystal connect`, MCP clients can keep using: + +```json +{ + "mcpServers": { + "chatcrystal": { + "command": "crystal", + "args": ["mcp"] + } + } +} +``` + +You can also pass environment variables from the MCP client: + +```json +{ + "mcpServers": { + "chatcrystal": { + "command": "crystal", + "args": ["mcp"], + "env": { + "CHATCRYSTAL_BASE_URL": "https://chatcrystal.example.com", + "CHATCRYSTAL_API_TOKEN": "your-long-token" + } + } + } +} +``` + ## MCP Tools ChatCrystal exposes six MCP tools: @@ -94,4 +126,3 @@ The currently published skill set is intentionally narrow: MCP writeback is protected by the same experience quality standard used by the summarization pipeline. Low-signal summaries, unverified work, raw logs, and informational exchanges should be filtered before they become memory assets. See [Experience Quality Gate](EXPERIENCE_GATE.md). - diff --git a/docs/MCP.zh-CN.md b/docs/MCP.zh-CN.md index 31a5499..1cc1732 100644 --- a/docs/MCP.zh-CN.md +++ b/docs/MCP.zh-CN.md @@ -37,6 +37,38 @@ Agent 配置示例: 如果某个工具另外要求填写 HTTP API endpoint,请使用 `http://localhost:3721`。不要填写没有端口的裸 `http://127.0.0.1`,因为 HTTP 会默认落到 80 端口。 +### 云端模式 + +`crystal mcp` 使用与 CLI 相同的连接优先级。执行 `crystal connect` 后,MCP 客户端可以继续使用: + +```json +{ + "mcpServers": { + "chatcrystal": { + "command": "crystal", + "args": ["mcp"] + } + } +} +``` + +也可以在 MCP 客户端配置中直接传入环境变量: + +```json +{ + "mcpServers": { + "chatcrystal": { + "command": "crystal", + "args": ["mcp"], + "env": { + "CHATCRYSTAL_BASE_URL": "https://chatcrystal.example.com", + "CHATCRYSTAL_API_TOKEN": "your-long-token" + } + } + } +} +``` + ## MCP 工具 ChatCrystal 暴露六个 MCP 工具: @@ -94,4 +126,3 @@ Full mode 需要: MCP writeback 受同一套经验质量标准保护。低信号摘要、未验证工作、原始日志和信息型问答不应进入经验资产库。 详见[经验质量门槛](EXPERIENCE_GATE.zh-CN.md)。 - diff --git a/docs/superpowers/specs/2026-05-20-cloud-containerization-design.md b/docs/superpowers/specs/2026-05-20-cloud-containerization-design.md new file mode 100644 index 0000000..fe58891 --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-cloud-containerization-design.md @@ -0,0 +1,407 @@ +# ChatCrystal Cloud Containerization Design + +Date: 2026-05-20 + +## Goal + +Turn ChatCrystal into a personal cloud memory node that one user can connect to from multiple devices. + +The work is intentionally split into two phases: + +1. **Phase 1: Docker cloud core** - make the existing ChatCrystal core deployable as a single cloud instance with safe token-based access, remote CLI/MCP support, and local-to-cloud import. +2. **Phase 2: Electron cloud companion** - let non-technical users install the desktop app, connect it to the cloud instance, import local history, and configure local MCP clients without using a terminal. + +This remains a personal single-instance product. All devices share one access token, one data directory, and one memory database. Multi-user accounts, tenant isolation, OAuth, and permissions are out of scope. + +## Decisions + +| Topic | Decision | +|---|---| +| Deployment unit | Docker Compose runs only the `chatcrystal` service by default. | +| Ollama | Not bundled in default Compose. Users connect to an external API or an existing Ollama endpoint. | +| Data path | Container uses `DATA_DIR=/data`; Compose maps a named volume such as `chatcrystal-data:/data`. | +| Cloud activation | Docker sets `CHATCRYSTAL_CLOUD_MODE=true`; local dev, CLI auto-start, and Electron local mode default to non-cloud behavior. | +| Security model | Single personal access token for Web, API, CLI, MCP, and Electron cloud mode. | +| First run | If no env token and no stored token exists, enter setup mode instead of exposing the full API. | +| Remote import | Local device parses all five sources and uploads normalized conversations to the cloud. | +| Import output | Remote import stores conversations/messages only; it does not auto-summarize or auto-embed. | +| Electron | Existing local mode remains; cloud mode is added in Phase 2. | +| Internal/VPN deployment | Supported as a documentation pattern, but still uses the same token model. | + +## Phase 1: Docker Cloud Core + +Phase 1 delivers the cloud instance itself. The Docker image builds the server and client, then runs the production Fastify server. The server continues to serve `client/dist` statically, so the cloud Web UI is available at the same origin as the API. + +The default Compose file should be safe to commit to GitHub. It must not contain a real token. It should support both advanced non-interactive deployment and beginner-friendly first-run setup. + +Example shape: + +```yaml +services: + chatcrystal: + image: chatcrystal:latest + ports: + - "${PORT:-3721}:3721" + environment: + NODE_ENV: production + PORT: 3721 + DATA_DIR: /data + CHATCRYSTAL_CLOUD_MODE: "true" + CHATCRYSTAL_API_TOKEN: ${CHATCRYSTAL_API_TOKEN:-} + volumes: + - chatcrystal-data:/data + +volumes: + chatcrystal-data: +``` + +`/data` is a container-internal path. It does not mean the host root `/data`. The default named volume avoids host path confusion. Advanced users can replace it with a bind mount such as `./chatcrystal-data:/data`. + +Cloud behaviors are active only when `CHATCRYSTAL_CLOUD_MODE=true` or when an explicit auth token is configured for test/advanced use. This prevents Docker auth/setup requirements from breaking local development, CLI local auto-start, and existing Electron local mode. + +In cloud mode, server-side source scanning is local-only and should be disabled by default: + +- the file watcher should not start unless an explicit local-source flag is added later +- `/api/import/scan` and `/api/import/scan/stream` should return a clear local-only error in cloud mode +- the Web UI import action should not imply that the browser can scan the user's local machine +- cloud Web UI should point users to CLI remote import in Phase 1 and Electron import bridge in Phase 2 + +This avoids the misleading Docker behavior where Web import scans container paths rather than the user's device. + +## Cloud Provider Configuration + +The default Docker image does not include Ollama. Existing defaults such as `http://localhost:11434` point at the container itself and usually will not work in cloud deployment. + +Phase 1 documentation and setup UI should make provider configuration explicit: + +- external hosted providers use their normal HTTPS base URLs and API keys +- existing Ollama on Docker Desktop can use `host.docker.internal` +- existing Ollama on Linux may require `extra_hosts: ["host.docker.internal:host-gateway"]` +- remote Ollama should be exposed only on a trusted network or behind its own auth boundary +- setup/status should warn when LLM or Embedding still points at an unreachable container-local default +- semantic search requires a real embedding model, not an LLM chat model + +## First-Run Setup + +If `CHATCRYSTAL_API_TOKEN` is set, it takes precedence and the server enters normal authenticated mode. + +If cloud mode is enabled, the env token is empty, and no token hash exists under the active data directory, the server enters setup mode: + +- static frontend can load +- only setup endpoints are available +- all data/config/import/search/memory APIs are blocked +- server generates a one-time setup code +- setup code is printed to container logs and written to `/data/setup-code` +- user opens the Web UI, enters the setup code, and sets an access token +- server stores only a token hash, not the plaintext token +- setup code is deleted after completion + +This makes the default Compose usable for non-technical users while avoiding a fully open unauthenticated cloud API. + +CI and advanced deployments can bypass setup by providing `CHATCRYSTAL_API_TOKEN`. CI should use a dummy token for smoke tests. + +Setup hardening requirements: + +- setup code must be high entropy, single-use, and short-lived +- setup verification must be rate limited +- `/data/setup-code` should be created with restrictive file permissions where the platform allows it +- setup completion invalidates the code immediately +- lost-token recovery is explicit: run `crystal token reset` inside the container or delete the auth file from a stopped container volume, then complete setup again +- public internet deployment should use HTTPS through a reverse proxy; token auth does not protect credentials from plaintext HTTP interception + +## Authentication + +The token applies to Web, REST API, SSE endpoints, CLI, MCP, and Electron cloud mode. + +Recommended request contract: + +```text +Authorization: Bearer +``` + +Public endpoints are limited to: + +- setup status and completion endpoints +- token verification endpoint +- health endpoint with no private data +- static frontend assets + +`/api/status` currently returns stats and recent note metadata. In cloud mode it should either require auth or a new minimal public health endpoint should be introduced for container health checks. + +The Web UI stores the token in browser local storage after successful verification. If an API returns 401, the UI returns to the token entry flow. + +Token storage and lifecycle: + +- stored token hashes live in an auth file under the active data directory, separate from provider config +- use a salted password hash such as `scrypt` and compare with a timing-safe check +- plaintext token is never persisted by the server +- if `CHATCRYSTAL_API_TOKEN` is present, it is the active token and stored hashes are ignored for verification +- `crystal token rotate` replaces the stored hash after authenticating with the current token or a valid setup/reset code +- browser logout removes the localStorage token only; it does not rotate the server token +- CLI/MCP token should come from explicit flag, environment, or a local client connection file with restrictive permissions + +## CLI And TUI Connection Model + +CLI gets a persistent cloud connection concept: + +```bash +crystal connect https://chatcrystal.example.com --token +crystal disconnect +crystal remote status +``` + +Connection priority: + +1. explicit command-line flags +2. environment variables +3. saved `crystal connect` configuration +4. default local URL + +Environment variables: + +```bash +CHATCRYSTAL_BASE_URL=https://chatcrystal.example.com +CHATCRYSTAL_API_TOKEN= +``` + +Saved client connection config should live outside the server runtime config, for example under `~/.chatcrystal/client.json`, and should be written with restrictive file permissions where possible. `crystal mcp` follows the same connection priority so MCP clients can either rely on saved config or pass env vars explicitly in their MCP configuration. + +Remote URLs must not auto-start a local server. Auto-start remains only for local hosts such as `localhost` and `127.0.0.1`. + +TUI and command output must show the active mode: + +```text +ChatCrystal · Cloud https://chatcrystal.example.com +ChatCrystal · Local http://localhost:3721 +``` + +Remote import should show a confirmation before scanning local histories and uploading parsed conversations. A `--yes` flag should bypass the prompt for scripts. + +## Remote Import Protocol + +Current import mixes scanning/parsing and database insertion on the server. Phase 1 separates that into: + +1. **Local parse layer** - runs in CLI or later Electron. +2. **Cloud ingest layer** - runs in the cloud server. + +For a remote base URL, `crystal import` scans the local machine and reuses all existing source adapters: + +- `claude-code` +- `codex` +- `cursor` +- `trae` +- `copilot` + +The local process parses each source into a normalized `ParsedConversation` plus the original `ConversationMeta`. The cloud receives normalized payloads, not raw JSONL or `state.vscdb` files. This matters because Cursor and Trae are SQLite-backed, not JSONL-backed. + +Cloud ingest endpoint: + +- `POST /api/import/ingest` +- authenticated like other cloud APIs +- versioned payload, starting with `schemaVersion: 1` +- Zod-validated request shape +- bounded batches, with CLI chunking by count and byte size +- explicit server body limit large enough for real parsed conversations +- gzip request support where the runtime stack supports it +- per-conversation transaction and per-item result status + +Payload item shape: + +```typescript +{ + schemaVersion: 1; + clientRequestId: string; + items: Array<{ + source: "claude-code" | "codex" | "cursor" | "trae" | "copilot"; + sourceConversationId: string; + conversationId: string; + contentHash: string; + parserVersion: string; + meta: ConversationMeta; + parsed: ParsedConversation; + }>; +} +``` + +Remote import must not rely on `file_path`, `file_size`, or `file_mtime` as the primary change signal because those fields are device-local. The local parser computes a canonical `contentHash` from normalized parsed conversation content and a `parserVersion` from the adapter implementation. Cloud storage adds migration-backed metadata columns such as `source_conversation_id`, `content_hash`, and `parser_version` to `conversations`. + +The current schema uses `conversations.id` and `messages.id` as primary keys. Remote ingest therefore uses stable namespaced ids instead of changing to composite primary keys: + +```text +conversation.id = ":" +message.id = ":" +``` + +Cloud ingest validates the payload, applies namespacing, then uses these semantics: + +- same namespaced conversation id with identical `contentHash` is skipped, even if path or mtime differs +- new namespaced conversation is inserted with all messages +- changed `contentHash` replaces conversation messages and records the new hash/parser version +- replacement never runs because only `file_path`, `file_size`, or `file_mtime` changed +- replacement invalidates only generated imported notes that are tied to the old content and not user-edited +- manual notes and agent-writeback memories are never deleted by remote import replacement +- invalidated vectors are queued for cleanup +- the per-item response reports inserted, skipped, replaced, invalidated note ids, and errors + +Remote import returns scanned, uploaded, imported, replaced, skipped, and errors counts. One bad conversation should not fail the entire batch. + +Remote import does not automatically summarize conversations. Users can run batch summarization later from Web UI or CLI. + +## Phase 2: Electron Cloud Companion + +Phase 2 improves onboarding for users who only install the desktop app. + +The existing Electron behavior remains as local mode: + +- starts embedded local Fastify core +- uses local `~/.chatcrystal/data` +- loads local Web UI + +Cloud mode is explicit: + +- first launch offers "Use local memory" or "Connect cloud memory" +- user enters cloud URL and token +- Electron verifies the token +- Electron does not start the local core +- window loads the cloud Web UI +- saved cloud connection is reused on next launch + +Electron cloud mode also needs a local import bridge. The bridge runs in the Electron main process or a bundled helper, scans local AI tool histories with the same five adapters, and uploads normalized conversations to the cloud ingest API. This is the desktop equivalent of Phase 1 remote CLI import. + +The local import bridge is a privileged boundary. The remote Web UI must not be able to trigger arbitrary local file scanning directly. Import should be exposed through native Electron UI with explicit user confirmation, strict origin checks, minimal IPC, and progress-only renderer events. A compromised or XSS-injected cloud page must not be able to request local paths or invoke arbitrary adapter scans. + +## MCP One-Click Setup + +Phase 2 should include a best-effort MCP setup wizard because the desktop app should be useful without terminal work. + +The app should bundle a local MCP bridge so users do not need a global `crystal` command. MCP clients launch the bridge over stdio; the bridge calls the cloud ChatCrystal API with the saved URL and token. + +Wizard behavior: + +- detect supported MCP clients where possible +- show the exact configuration that will be written +- write user-level configuration, not project-level configuration +- back up existing config first +- merge the `chatcrystal` server without overwriting existing MCP servers +- test the bridge after writing +- provide copyable manual config when automatic writing is not supported + +Required support target: + +- Claude Code +- Codex +- Cursor +- VS Code / GitHub Copilot + +Trae is best-effort because its MCP configuration surface may differ by version. + +## Error Handling + +Cloud deployment and remote clients should fail loudly when they might otherwise operate on the wrong instance. + +- token failure returns 401 with a clear message +- setup mode blocks all non-setup data APIs +- remote URL connection failure never starts a local server +- remote import shows the target endpoint in prompts and errors +- remote import records per-conversation failures and continues +- CLI/MCP explain whether they are using explicit flags, env config, saved cloud config, or local defaults +- changing embedding provider/model keeps the existing confirmation behavior + +## Testing + +Phase 1 should be delivered in smaller gates: + +1. **1A Docker/auth/setup/health** - image, Compose, cloud mode, setup mode, token auth, health endpoint, provider warnings. +2. **1B CLI/MCP cloud connection** - saved connection config, token header injection, remote no-autostart, TUI local/cloud display. +3. **1C Remote ingest/import** - namespaced ids, content hashes, versioned ingest API, five-source local parse and upload. + +Phase 1 tests: + +- Docker build succeeds +- Compose config validates +- container smoke test starts with a dummy token +- cloud mode does not affect local dev, CLI local auto-start, or Electron local mode +- server-side scan endpoints are disabled or local-only in cloud mode +- provider setup warns when Docker is still pointing at container-local Ollama defaults +- setup mode starts without env token and blocks private APIs +- setup completion stores only a hash and disables setup code +- setup code is high entropy, single-use, rate limited, and resettable +- token auth succeeds and fails correctly +- SSE endpoints require token +- CLI injects token headers +- `crystal mcp` uses the same saved/env connection flow as other CLI commands +- remote URL does not auto-start local server +- ingest namespaces conversation and message ids +- ingest skips identical `contentHash` even when path/mtime differs +- ingest replacement does not delete manual notes or agent-writeback memories +- remote import parses and uploads all five sources through focused fixtures +- ingest API handles skip, insert, replace, and per-item errors +- Web token gate stores token and retries authenticated API calls +- TUI/CLI display local/cloud mode + +Phase 2 tests: + +- local Electron mode still starts embedded core +- cloud Electron mode skips embedded core and loads remote UI +- cloud connection is persisted and can be cleared +- Electron import bridge uploads parsed conversations +- MCP bridge can be launched over stdio and call cloud tools +- MCP config writer backs up and merges config safely for supported clients + +## Acceptance Criteria + +Phase 1 is complete when: + +- `docker compose up -d` can start a cloud ChatCrystal instance +- first-run setup can create the access token from Web UI +- browser access works after token entry +- cloud mode does not require auth/setup for existing local dev or Electron local mode +- cloud Web UI does not run server-side scan/import against container paths +- `crystal connect` can save cloud URL and token +- `crystal status`, `crystal search`, `crystal notes`, and `crystal mcp` can operate against the cloud instance +- `crystal import` against a cloud base URL scans local five-source history and uploads conversations to cloud +- remote ingest uses namespaced ids and content hashes so device-local mtime/path changes do not replace content +- imported conversations are visible in the cloud Web UI +- imported conversations are not summarized until the user explicitly runs summarization + +Phase 2 is complete when: + +- a user can install Electron, choose cloud mode, and connect with URL plus token +- Electron cloud mode can import local history from all five sources +- Electron can configure supported local MCP clients or provide manual config +- local Electron mode remains unchanged for existing users + +## Workload Estimate + +Phase 1 is medium-to-large: approximately 3-6 engineering days. + +Main risks: + +- extracting reusable ingest logic from current import service +- adding cloud-only activation without regressing local-first behavior +- auth coverage across REST, SSE, CLI, MCP, and Web +- first-run setup hardening and token lifecycle +- remote ingest payload sizing, namespacing, and hash-based deduplication +- remote/local mode clarity in CLI and TUI +- disabling or replacing Web server-side import in cloud mode + +Phase 2 is larger: approximately 5-10 engineering days. + +Main risks: + +- bundling a reliable MCP bridge +- safely writing different MCP client configuration formats +- Electron token storage and cloud/local mode switching +- local import progress and error reporting inside the desktop app + +## Out Of Scope + +- multi-user login +- OAuth +- role-based permissions +- tenant isolation +- automatic VPN/Tailscale provisioning +- bundled Ollama service in default Compose +- continuous bidirectional sync +- conflict-resolution UI +- automatic summarization during remote import diff --git a/package-lock.json b/package-lock.json index 5e9c908..3d813aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,7 @@ "": { "name": "chatcrystal", "version": "0.4.10", - "license": "MIT", + "license": "Apache-2.0", "workspaces": [ "shared", "server", @@ -28,7 +28,7 @@ }, "client": { "version": "0.0.0", - "license": "MIT", + "license": "Apache-2.0", "dependencies": { "@tailwindcss/vite": "^4.2.4", "@tanstack/react-query": "^5.100.5", @@ -13720,7 +13720,7 @@ "server": { "name": "chatcrystal", "version": "0.4.10", - "license": "MIT", + "license": "Apache-2.0", "dependencies": { "@ai-sdk/anthropic": "^3.0.71", "@ai-sdk/azure": "^3.0.54", @@ -13763,7 +13763,7 @@ "shared": { "name": "@chatcrystal/shared", "version": "0.1.0", - "license": "MIT" + "license": "Apache-2.0" } } } diff --git a/package.json b/package.json index f4d0fca..db3ee04 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "chatcrystal", "version": "0.4.10", "private": true, - "license": "MIT", + "license": "Apache-2.0", "description": "Turn your AI conversations into searchable knowledge — supports Claude Code, Cursor, Codex CLI, Trae, GitHub Copilot", "keywords": [ "claude", diff --git a/server/README.md b/server/README.md index c27566d..df8412d 100644 --- a/server/README.md +++ b/server/README.md @@ -8,7 +8,7 @@ [![npm](https://img.shields.io/npm/v/chatcrystal?style=flat-square)](https://www.npmjs.com/package/chatcrystal) [![GitHub](https://img.shields.io/github/stars/ZengLiangYi/ChatCrystal?style=flat-square)](https://github.com/ZengLiangYi/ChatCrystal) -[![License: MIT](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](https://github.com/ZengLiangYi/ChatCrystal/blob/main/LICENSE) +[![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue?style=flat-square)](https://github.com/ZengLiangYi/ChatCrystal/blob/main/LICENSE) @@ -34,6 +34,19 @@ crystal search "React state" # Semantic search your knowledge The server auto-launches in background on first command. No manual setup needed. +## Cloud Connection + +For a Docker cloud instance, save the shared token once: + +```bash +crystal connect https://chatcrystal.example.com --token "your-long-token" +crystal import --yes +``` + +`crystal import` still scans and parses histories on the local device, then uploads normalized conversations to the cloud instance. The cloud server does not scan your local filesystem. `crystal mcp` reuses the same saved connection and token. + +Use HTTPS for cloud connections. CLI commands refuse to send ChatCrystal API tokens to non-local `http://` URLs by default. + ## CLI Commands ```bash @@ -63,7 +76,7 @@ crystal serve stop # Stop daemon crystal serve status # Check if running ``` -**Global options:** `--base-url ` (server address), `--json` (force JSON output), `--version` +**Global options:** `--base-url ` (server address), `--token ` (cloud API token), `--json` (force JSON output), `--version` **Output:** TTY-aware — colored tables in terminal, JSON when piped. @@ -134,4 +147,4 @@ ChatCrystal also has a web UI and Electron desktop app. See the [GitHub reposito ## License -[MIT](https://github.com/ZengLiangYi/ChatCrystal/blob/main/LICENSE) +[Apache License 2.0](https://github.com/ZengLiangYi/ChatCrystal/blob/main/LICENSE) diff --git a/server/package.json b/server/package.json index ae1f34b..dcb4c5a 100644 --- a/server/package.json +++ b/server/package.json @@ -2,7 +2,7 @@ "name": "chatcrystal", "version": "0.4.10", "private": false, - "license": "MIT", + "license": "Apache-2.0", "repository": { "type": "git", "url": "https://github.com/ZengLiangYi/ChatCrystal" diff --git a/server/src/cli/client.test.ts b/server/src/cli/client.test.ts index 4bc93c0..3f13c32 100644 --- a/server/src/cli/client.test.ts +++ b/server/src/cli/client.test.ts @@ -1,6 +1,14 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { DEFAULT_SERVER_BASE_URL, normalizeBaseUrl } from './client.js'; +import { + CrystalClient, + DEFAULT_SERVER_BASE_URL, + ServerNotAvailableError, + assertSafeAuthTransport, + assertExpectedInstanceForConnection, + isInsecureRemoteHttp, + normalizeBaseUrl, +} from './client.js'; test('normalizeBaseUrl defaults missing values to the ChatCrystal API port', () => { assert.equal(normalizeBaseUrl(), DEFAULT_SERVER_BASE_URL); @@ -11,7 +19,6 @@ test('normalizeBaseUrl defaults missing values to the ChatCrystal API port', () test('normalizeBaseUrl adds the ChatCrystal port for local HTTP URLs without explicit ports', () => { assert.equal(normalizeBaseUrl('http://localhost'), 'http://localhost:3721'); assert.equal(normalizeBaseUrl('http://127.0.0.1'), 'http://127.0.0.1:3721'); - assert.equal(normalizeBaseUrl('http://0.0.0.0'), 'http://0.0.0.0:3721'); assert.equal(normalizeBaseUrl('http://[::1]'), 'http://[::1]:3721'); assert.equal(normalizeBaseUrl('localhost'), 'http://localhost:3721'); }); @@ -20,12 +27,175 @@ test('normalizeBaseUrl preserves explicit ports and non-loopback defaults', () = assert.equal(normalizeBaseUrl('http://localhost:80'), 'http://localhost'); assert.equal(normalizeBaseUrl('http://localhost:4000'), 'http://localhost:4000'); assert.equal(normalizeBaseUrl('127.0.0.1:4000'), 'http://127.0.0.1:4000'); + assert.equal(normalizeBaseUrl('http://0.0.0.0'), 'http://0.0.0.0'); + assert.equal(normalizeBaseUrl('chatcrystal.example.com'), 'https://chatcrystal.example.com'); assert.equal(normalizeBaseUrl('https://chatcrystal.local'), 'https://chatcrystal.local'); }); +test('CrystalClient refuses to send tokens over non-local HTTP by default', () => { + assert.equal(isInsecureRemoteHttp('http://chatcrystal.example.com'), true); + assert.equal(isInsecureRemoteHttp('http://localhost:3721'), false); + assert.equal(isInsecureRemoteHttp('http://0.0.0.0:3721'), true); + assert.throws( + () => assertSafeAuthTransport('http://chatcrystal.example.com', 'secret-token'), + /Refusing to send a ChatCrystal API token over non-local HTTP/, + ); + assert.throws( + () => new CrystalClient({ + baseUrl: 'http://chatcrystal.example.com', + token: 'secret-token', + connectionSource: 'explicit', + }), + /Refusing to send a ChatCrystal API token over non-local HTTP/, + ); + assert.doesNotThrow(() => assertSafeAuthTransport('http://localhost:3721', 'secret-token')); +}); + test('normalizeBaseUrl rejects unsupported base URL schemes', () => { assert.throws( () => normalizeBaseUrl('file:///tmp/chatcrystal.sock'), /Only http and https URLs are supported/, ); }); + +test('CrystalClient uses public health for readiness and sends bearer tokens to private API requests', async () => { + const calls: Array<{ url: string; headers: Headers }> = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input, init) => { + const url = String(input); + calls.push({ url, headers: new Headers(init?.headers) }); + + if (url.endsWith('/api/health')) { + return Response.json({ success: true, data: { ok: true, cloudMode: true } }); + } + if (url.endsWith('/api/status')) { + return Response.json({ + success: true, + data: { + server: true, + database: true, + cloudMode: true, + providerWarnings: [], + stats: { totalConversations: 0, totalNotes: 0, totalTags: 0 }, + recentNotes: [], + }, + }); + } + + throw new Error(`Unexpected URL ${url}`); + }) as typeof fetch; + + try { + const client = new CrystalClient({ + baseUrl: 'https://chatcrystal.example.com', + token: 'secret-token', + connectionSource: 'explicit', + }); + + const status = await client.status(); + + assert.equal(status.cloudMode, true); + assert.deepEqual(calls.map((call) => call.url), [ + 'https://chatcrystal.example.com/api/health', + 'https://chatcrystal.example.com/api/status', + ]); + assert.equal(calls[0].headers.get('authorization'), null); + assert.equal(calls[1].headers.get('authorization'), 'Bearer secret-token'); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('CrystalClient does not auto-start when a remote server is unavailable', async () => { + const calls: string[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input) => { + calls.push(String(input)); + throw new Error('offline'); + }) as typeof fetch; + + try { + const client = new CrystalClient({ + baseUrl: 'https://chatcrystal.example.com', + connectionSource: 'explicit', + }); + + await assert.rejects( + () => client.status(), + ServerNotAvailableError, + ); + assert.deepEqual(calls, ['https://chatcrystal.example.com/api/health']); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('CrystalClient rejects saved or explicit loopback targets that are not cloud mode', async () => { + assert.throws( + () => assertExpectedInstanceForConnection( + 'http://localhost:3721', + 'saved', + { cloudMode: false }, + ), + /Refusing to use a configured loopback connection/, + ); + + assert.doesNotThrow(() => assertExpectedInstanceForConnection( + 'http://localhost:3721', + 'local-default', + { cloudMode: false }, + )); + assert.doesNotThrow(() => assertExpectedInstanceForConnection( + 'http://localhost:3721', + 'saved', + { cloudMode: true }, + )); +}); + +test('CrystalClient checks expected instance before rotating tokens on configured loopback targets', async () => { + const calls: string[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input) => { + const url = String(input); + calls.push(url); + if (url.endsWith('/api/health')) { + return Response.json({ success: true, data: { ok: true, cloudMode: false } }); + } + if (url.endsWith('/api/status')) { + return Response.json({ + success: true, + data: { + server: true, + database: true, + cloudMode: false, + providerWarnings: [], + stats: { totalConversations: 0, totalNotes: 0, totalTags: 0 }, + recentNotes: [], + }, + }); + } + if (url.endsWith('/api/auth/rotate')) { + return Response.json({ success: true, data: { rotated: true } }); + } + throw new Error(`Unexpected URL ${url}`); + }) as typeof fetch; + + try { + const client = new CrystalClient({ + baseUrl: 'http://localhost:3721', + token: 'old-token', + connectionSource: 'saved', + }); + + await assert.rejects( + () => client.rotateToken('old-token', 'new-token'), + /Refusing to use a configured loopback connection/, + ); + assert.deepEqual(calls, [ + 'http://localhost:3721/api/health', + 'http://localhost:3721/api/status', + ]); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/server/src/cli/client.ts b/server/src/cli/client.ts index 2c6863e..070ec16 100644 --- a/server/src/cli/client.ts +++ b/server/src/cli/client.ts @@ -6,12 +6,16 @@ import type { DeleteNoteReviewResponse, RecallForTaskRequest, RecallForTaskResponse, + RemoteImportRequest, + RemoteImportResponse, ValidateTaskMemoryRequest, ValidateTaskMemoryResponse, WriteTaskMemoryRequest, WriteTaskMemoryResponse, } from '@chatcrystal/shared'; +import { isLocalBaseUrl } from '../runtime/cloud.js'; import { runtimePaths } from '../runtime/paths.js'; +import type { ConnectionSource } from './connection.js'; export class ServerNotAvailableError extends Error { constructor(baseUrl: string) { @@ -29,7 +33,23 @@ export class ApiError extends Error { export const DEFAULT_SERVER_BASE_URL = 'http://localhost:3721'; -const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '[::1]']); +const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']); + +export type CrystalClientOptions = { + baseUrl?: string; + token?: string; + connectionSource?: ConnectionSource | 'direct' | string; +}; + +export type CrystalStatus = { + server: boolean; + database: boolean; + cloudMode?: boolean; + providerWarnings?: string[]; + isSeeded?: boolean; + stats: { totalConversations: number; totalNotes: number; totalTags: number }; + recentNotes: Array<{ id: number; title: string; project_name: string; created_at: string }>; +}; function hasExplicitPort(rawUrl: string): boolean { const withProtocol = /^[a-z][a-z\d+\-.]*:\/\//i.test(rawUrl) ? rawUrl : `http://${rawUrl}`; @@ -41,12 +61,20 @@ export function normalizeBaseUrl(baseUrl?: string): string { const raw = baseUrl?.trim(); if (!raw) return DEFAULT_SERVER_BASE_URL; - const input = /^[a-z][a-z\d+\-.]*:\/\//i.test(raw) ? raw : `http://${raw}`; - const explicitPort = hasExplicitPort(input); + const hasProtocol = /^[a-z][a-z\d+\-.]*:\/\//i.test(raw); + const provisionalInput = hasProtocol ? raw : `http://${raw}`; let url: URL; try { + const provisionalUrl = new URL(provisionalInput); + const input = hasProtocol || LOCAL_HOSTS.has(provisionalUrl.hostname) + ? provisionalInput + : `https://${raw}`; + const explicitPort = hasExplicitPort(input); url = new URL(input); + if (url.protocol === 'http:' && LOCAL_HOSTS.has(url.hostname) && !explicitPort) { + url.port = '3721'; + } } catch { throw new Error(`Invalid server base URL "${baseUrl}". Expected a URL like ${DEFAULT_SERVER_BASE_URL}.`); } @@ -55,19 +83,77 @@ export function normalizeBaseUrl(baseUrl?: string): string { throw new Error(`Invalid server base URL "${baseUrl}". Only http and https URLs are supported.`); } - if (url.protocol === 'http:' && LOCAL_HOSTS.has(url.hostname) && !explicitPort) { - url.port = '3721'; + return url.toString().replace(/\/+$/, ''); +} + +export function isInsecureRemoteHttp(baseUrl: string): boolean { + try { + const url = new URL(baseUrl); + return url.protocol === 'http:' && !isLocalBaseUrl(baseUrl); + } catch { + return false; } +} - return url.toString().replace(/\/+$/, ''); +export function allowInsecureRemoteHttp(): boolean { + return process.env.CHATCRYSTAL_ALLOW_INSECURE_REMOTE_HTTP === 'true'; +} + +export function assertSafeAuthTransport(baseUrl: string, token?: string): void { + if (!token?.trim()) return; + if (!isInsecureRemoteHttp(baseUrl) || allowInsecureRemoteHttp()) return; + throw new Error( + 'Refusing to send a ChatCrystal API token over non-local HTTP. Use HTTPS for cloud access, connect through a local tunnel, or set CHATCRYSTAL_ALLOW_INSECURE_REMOTE_HTTP=true only on a trusted private network.', + ); +} + +export function isUserConfiguredLoopback(baseUrl: string, connectionSource: ConnectionSource | 'direct' | string): boolean { + return isLocalBaseUrl(baseUrl) && connectionSource !== 'local-default' && connectionSource !== 'direct'; +} + +export function assertExpectedInstanceForConnection( + baseUrl: string, + connectionSource: ConnectionSource | 'direct' | string, + status: { cloudMode?: boolean }, +): void { + if (isUserConfiguredLoopback(baseUrl, connectionSource) && status.cloudMode !== true) { + throw new Error( + 'Refusing to use a configured loopback connection that did not report cloud mode. This may be an SSH tunnel or the wrong local instance; use the implicit local default for local mode.', + ); + } } export class CrystalClient { private serverChecked = false; + private expectedInstanceChecked = false; private baseUrl: string; + private token?: string; + readonly connectionSource: ConnectionSource | 'direct' | string; + + constructor(options?: string | CrystalClientOptions) { + if (typeof options === 'string' || options === undefined) { + this.baseUrl = normalizeBaseUrl(options); + this.connectionSource = 'direct'; + return; + } + + this.baseUrl = normalizeBaseUrl(options.baseUrl); + this.token = options.token?.trim() || undefined; + assertSafeAuthTransport(this.baseUrl, this.token); + this.connectionSource = options.connectionSource ?? 'direct'; + } - constructor(baseUrl?: string) { - this.baseUrl = normalizeBaseUrl(baseUrl); + getBaseUrl(): string { + return this.baseUrl; + } + + getModeLabel(status?: { cloudMode?: boolean }): string { + if (status?.cloudMode) return 'Cloud'; + return isLocalBaseUrl(this.baseUrl) ? 'Local' : 'Remote'; + } + + getConnectionSummary(status?: { cloudMode?: boolean }): string { + return `${this.getModeLabel(status)} ${this.baseUrl} (${this.connectionSource})`; } async ensureServer(): Promise { @@ -78,6 +164,10 @@ export class CrystalClient { return; } + if (!isLocalBaseUrl(this.baseUrl) || this.connectionSource !== 'local-default') { + throw new ServerNotAvailableError(this.baseUrl); + } + const started = await this.autoStartServer(); if (!started) { throw new ServerNotAvailableError(this.baseUrl); @@ -87,7 +177,7 @@ export class CrystalClient { private async isServerRunning(): Promise { try { - const res = await fetch(`${this.baseUrl}/api/status`, { + const res = await fetch(`${this.baseUrl}/api/health`, { signal: AbortSignal.timeout(2000), }); return res.ok; @@ -132,19 +222,46 @@ export class CrystalClient { return false; } - private async request(method: string, path: string, body?: unknown): Promise { + private authHeaders(tokenOverride?: string): Record { + const token = tokenOverride?.trim() || this.token; + assertSafeAuthTransport(this.baseUrl, token); + return token ? { Authorization: `Bearer ${token}` } : {}; + } + + private shouldCheckExpectedInstance(path: string): boolean { + return !( + path.startsWith('/api/status') || + path.startsWith('/api/health') || + path.startsWith('/api/setup/') || + path.startsWith('/api/auth/verify') + ); + } + + private async ensureExpectedInstance(path: string): Promise { + if (this.expectedInstanceChecked || !this.shouldCheckExpectedInstance(path)) return; + await this.status(); + } + + private async request( + method: string, + path: string, + body?: unknown, + options?: { tokenOverride?: string }, + ): Promise { await this.ensureServer(); + await this.ensureExpectedInstance(path); const url = `${this.baseUrl}${path}`; - const options: RequestInit = { method }; + const headers = new Headers(this.authHeaders(options?.tokenOverride)); + const requestOptions: RequestInit = { method, headers }; if (body !== undefined) { - options.headers = { 'Content-Type': 'application/json' }; - options.body = JSON.stringify(body); + headers.set('Content-Type', 'application/json'); + requestOptions.body = JSON.stringify(body); } let res: Response; try { - res = await fetch(url, options); + res = await fetch(url, requestOptions); } catch (err) { throw new ServerNotAvailableError(this.baseUrl); } @@ -159,12 +276,10 @@ export class CrystalClient { } async status() { - return this.request<{ - server: boolean; - database: boolean; - stats: { totalConversations: number; totalNotes: number; totalTags: number }; - recentNotes: Array<{ id: number; title: string; project_name: string; created_at: string }>; - }>('GET', '/api/status'); + const status = await this.request('GET', '/api/status'); + assertExpectedInstanceForConnection(this.baseUrl, this.connectionSource, status); + this.expectedInstanceChecked = true; + return status; } async importScan(source?: string) { @@ -173,6 +288,10 @@ export class CrystalClient { }>('POST', '/api/import/scan', source ? { source } : undefined); } + async ingestConversations(request: RemoteImportRequest) { + return this.request('POST', '/api/import/ingest', request); + } + /** * Import with SSE progress stream. * Calls onProgress for each progress event, returns final result. @@ -182,9 +301,10 @@ export class CrystalClient { imported: number; skipped: number; errors: number; }) => void): Promise<{ total: number; imported: number; skipped: number; errors: number }> { await this.ensureServer(); + await this.ensureExpectedInstance('/api/import/scan/stream'); const res = await fetch(`${this.baseUrl}/api/import/scan/stream`, { - headers: { Accept: 'text/event-stream' }, + headers: { Accept: 'text/event-stream', ...this.authHeaders() }, }); if (!res.ok || !res.body) { @@ -241,9 +361,10 @@ export class CrystalClient { }>; }) => void): Promise<{ total: number; completed: number; failed: number }> { await this.ensureServer(); + await this.ensureExpectedInstance('/api/queue/stream'); const res = await fetch(`${this.baseUrl}/api/queue/stream`, { - headers: { Accept: 'text/event-stream' }, + headers: { Accept: 'text/event-stream', ...this.authHeaders() }, }); if (!res.ok || !res.body) { @@ -401,6 +522,19 @@ export class CrystalClient { async testConfig() { return this.request<{ connected: boolean; response?: string; error?: string }>('POST', '/api/config/test'); } + + async verifyToken() { + return this.request<{ authenticated: boolean }>('POST', '/api/auth/verify'); + } + + async rotateToken(currentToken: string, nextToken: string) { + return this.request<{ rotated: boolean }>( + 'POST', + '/api/auth/rotate', + { currentToken, nextToken }, + { tokenOverride: currentToken }, + ); + } } function sleep(ms: number) { diff --git a/server/src/cli/commands/config.test.ts b/server/src/cli/commands/config.test.ts new file mode 100644 index 0000000..c2b1f09 --- /dev/null +++ b/server/src/cli/commands/config.test.ts @@ -0,0 +1,12 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { formatConfigSetSuccess, isSensitiveConfigKey } from './config.js'; + +test('config set output redacts secret-like values', () => { + assert.equal(isSensitiveConfigKey('llm.apiKey'), true); + assert.equal(isSensitiveConfigKey('embedding.api_key'), true); + assert.equal(isSensitiveConfigKey('llm.model'), false); + assert.equal(formatConfigSetSuccess('llm.apiKey', 'sk-secret-value'), 'Updated llm.apiKey = (set)'); + assert.equal(formatConfigSetSuccess('embedding.apiKey', ' '), 'Updated embedding.apiKey = (cleared)'); + assert.equal(formatConfigSetSuccess('llm.model', 'gpt-4o-mini'), 'Updated llm.model = gpt-4o-mini'); +}); diff --git a/server/src/cli/commands/config.ts b/server/src/cli/commands/config.ts index 4a77d11..f5d8540 100644 --- a/server/src/cli/commands/config.ts +++ b/server/src/cli/commands/config.ts @@ -1,10 +1,29 @@ import type { Command } from 'commander'; import { CrystalClient } from '../client.js'; +import { resolveConnection } from '../connection.js'; import { shouldOutputJson, outputJson, printHeader, printKeyValue, printSuccess, printError, } from '../formatter.js'; +export function isSensitiveConfigKey(key: string): boolean { + const normalized = key.toLowerCase(); + return ( + normalized.endsWith('.apikey') || + normalized.endsWith('.api_key') || + normalized.includes('token') || + normalized.includes('secret') || + normalized.includes('password') + ); +} + +export function formatConfigSetSuccess(key: string, value: string): string { + const displayValue = isSensitiveConfigKey(key) + ? (value.trim() ? '(set)' : '(cleared)') + : value; + return `Updated ${key} = ${displayValue}`; +} + export function registerConfigCommand(program: Command) { const config = program .command('config') @@ -15,7 +34,12 @@ export function registerConfigCommand(program: Command) { .description('Show current configuration') .action(async () => { const globalOpts = program.opts(); - const client = new CrystalClient(globalOpts.baseUrl); + const connection = resolveConnection({ baseUrl: globalOpts.baseUrl, token: globalOpts.token }); + const client = new CrystalClient({ + baseUrl: connection.baseUrl, + token: connection.token, + connectionSource: connection.source, + }); try { const data = await client.getConfig(); @@ -53,7 +77,12 @@ export function registerConfigCommand(program: Command) { .option('--confirm', 'Confirm potentially destructive changes') .action(async (key, value, opts) => { const globalOpts = program.opts(); - const client = new CrystalClient(globalOpts.baseUrl); + const connection = resolveConnection({ baseUrl: globalOpts.baseUrl, token: globalOpts.token }); + const client = new CrystalClient({ + baseUrl: connection.baseUrl, + token: connection.token, + connectionSource: connection.source, + }); try { const [section, field] = key.split('.'); @@ -88,7 +117,7 @@ export function registerConfigCommand(program: Command) { return; } - printSuccess(`Updated ${key} = ${value}`); + printSuccess(formatConfigSetSuccess(key, value)); console.log(); } catch (err) { printError(err instanceof Error ? err.message : 'Failed to update config'); @@ -101,7 +130,12 @@ export function registerConfigCommand(program: Command) { .description('Test LLM connection') .action(async () => { const globalOpts = program.opts(); - const client = new CrystalClient(globalOpts.baseUrl); + const connection = resolveConnection({ baseUrl: globalOpts.baseUrl, token: globalOpts.token }); + const client = new CrystalClient({ + baseUrl: connection.baseUrl, + token: connection.token, + connectionSource: connection.source, + }); try { const data = await client.testConfig(); diff --git a/server/src/cli/commands/connect.test.ts b/server/src/cli/commands/connect.test.ts new file mode 100644 index 0000000..c0c7718 --- /dev/null +++ b/server/src/cli/commands/connect.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { assertCloudConnectTarget, formatRemoteStatus, redactConnectionForJson } from './connect.js'; + +test('assertCloudConnectTarget accepts only cloud-mode servers', () => { + assert.doesNotThrow(() => assertCloudConnectTarget({ cloudMode: true })); + assert.throws( + () => assertCloudConnectTarget({ cloudMode: false }), + /does not report cloud mode/, + ); + assert.throws( + () => assertCloudConnectTarget({}), + /does not report cloud mode/, + ); +}); + +test('formatRemoteStatus distinguishes saved and local default connections', () => { + assert.equal(formatRemoteStatus(null), 'No saved remote connection. Using local default.'); + assert.equal( + formatRemoteStatus({ baseUrl: 'https://chatcrystal.example.com', token: 'token', source: 'saved' }), + 'Saved remote connection: https://chatcrystal.example.com (token set)', + ); + assert.equal( + formatRemoteStatus({ baseUrl: 'https://chatcrystal.example.com', source: 'saved' }), + 'Saved remote connection: https://chatcrystal.example.com (token missing)', + ); +}); + +test('redactConnectionForJson never exposes saved tokens', () => { + assert.deepEqual(redactConnectionForJson(null), { source: 'local-default', tokenSet: false }); + assert.deepEqual( + redactConnectionForJson({ + baseUrl: 'https://chatcrystal.example.com', + token: 'super-secret-token', + source: 'saved', + }), + { + baseUrl: 'https://chatcrystal.example.com', + source: 'saved', + tokenSet: true, + }, + ); + assert.equal( + Object.prototype.hasOwnProperty.call( + redactConnectionForJson({ + baseUrl: 'https://chatcrystal.example.com', + token: 'super-secret-token', + source: 'saved', + }), + 'token', + ), + false, + ); +}); diff --git a/server/src/cli/commands/connect.ts b/server/src/cli/commands/connect.ts new file mode 100644 index 0000000..88a1dd8 --- /dev/null +++ b/server/src/cli/commands/connect.ts @@ -0,0 +1,112 @@ +import type { Command } from 'commander'; +import { CrystalClient } from '../client.js'; +import type { ResolvedConnection } from '../connection.js'; +import { + clearSavedConnection, + readSavedConnection, + saveConnection, +} from '../connection.js'; +import { + outputJson, + printError, + printKeyValue, + printSuccess, + shouldOutputJson, +} from '../formatter.js'; + +export function assertCloudConnectTarget(status: { cloudMode?: boolean }): void { + if (status.cloudMode !== true) { + throw new Error('Connection target does not report cloud mode. Refusing to save it as a remote ChatCrystal connection.'); + } +} + +export function formatRemoteStatus(connection: ResolvedConnection | null): string { + if (!connection) return 'No saved remote connection. Using local default.'; + return `Saved remote connection: ${connection.baseUrl} (${connection.token ? 'token set' : 'token missing'})`; +} + +export function redactConnectionForJson(connection: ResolvedConnection | null): + | { source: 'local-default'; tokenSet: false } + | { baseUrl: string; source: ResolvedConnection['source']; tokenSet: boolean } { + if (!connection) return { source: 'local-default', tokenSet: false }; + return { + baseUrl: connection.baseUrl, + source: connection.source, + tokenSet: Boolean(connection.token), + }; +} + +export function registerConnectCommand(program: Command) { + program + .command('connect ') + .description('Save a cloud ChatCrystal connection for CLI and MCP') + .option('--token ', 'ChatCrystal API token for the cloud instance') + .action(async (url, opts) => { + const globalOpts = program.opts(); + const token = opts.token ?? globalOpts.token; + + try { + if (!token?.trim()) { + throw new Error('Use --token with a cloud ChatCrystal API token.'); + } + + const client = new CrystalClient({ + baseUrl: url, + token, + connectionSource: 'explicit', + }); + const status = await client.status(); + assertCloudConnectTarget(status); + + const saved = saveConnection({ baseUrl: url, token }); + + if (shouldOutputJson(globalOpts.json)) { + outputJson(redactConnectionForJson(saved)); + return; + } + + printSuccess('Saved ChatCrystal cloud connection'); + printKeyValue('Base URL', saved.baseUrl); + printKeyValue('Mode', client.getModeLabel(status)); + console.log(); + } catch (err) { + printError(err instanceof Error ? err.message : 'Failed to connect'); + process.exit(1); + } + }); + + program + .command('disconnect') + .description('Remove the saved ChatCrystal cloud connection') + .action(() => { + const globalOpts = program.opts(); + clearSavedConnection(); + + if (shouldOutputJson(globalOpts.json)) { + outputJson({ disconnected: true }); + return; + } + + printSuccess('Removed saved ChatCrystal connection'); + console.log(); + }); + + const remote = program + .command('remote') + .description('Manage the saved ChatCrystal remote connection'); + + remote + .command('status') + .description('Show saved remote connection status') + .action(() => { + const globalOpts = program.opts(); + const saved = readSavedConnection(); + + if (shouldOutputJson(globalOpts.json)) { + outputJson(redactConnectionForJson(saved)); + return; + } + + console.log(formatRemoteStatus(saved)); + }); +} diff --git a/server/src/cli/commands/conversations.ts b/server/src/cli/commands/conversations.ts index 3958a70..af76bd2 100644 --- a/server/src/cli/commands/conversations.ts +++ b/server/src/cli/commands/conversations.ts @@ -1,5 +1,6 @@ import type { Command } from 'commander'; import { CrystalClient } from '../client.js'; +import { resolveConnection } from '../connection.js'; import { shouldOutputJson, outputJson, printHeader, printTable, truncate, @@ -18,7 +19,12 @@ export function registerConversationsCommand(program: Command) { .option('--offset ', 'Offset for pagination', '0') .action(async (opts) => { const globalOpts = program.opts(); - const client = new CrystalClient(globalOpts.baseUrl); + const connection = resolveConnection({ baseUrl: globalOpts.baseUrl, token: globalOpts.token }); + const client = new CrystalClient({ + baseUrl: connection.baseUrl, + token: connection.token, + connectionSource: connection.source, + }); try { // Interactive mode diff --git a/server/src/cli/commands/import.test.ts b/server/src/cli/commands/import.test.ts new file mode 100644 index 0000000..810016d --- /dev/null +++ b/server/src/cli/commands/import.test.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { shouldUseRemoteImport } from './import.js'; + +test('shouldUseRemoteImport uses server cloud mode before URL shape', () => { + assert.equal(shouldUseRemoteImport('http://localhost:3721', { cloudMode: true }, 'saved'), true); + assert.equal(shouldUseRemoteImport('http://localhost:3721', { cloudMode: false }, 'local-default'), false); + assert.equal(shouldUseRemoteImport('https://chatcrystal.example.com', { cloudMode: true }, 'explicit'), true); +}); + +test('shouldUseRemoteImport fails closed for configured loopback targets that are not cloud mode', () => { + assert.throws( + () => shouldUseRemoteImport('http://localhost:3721', { cloudMode: false }, 'saved'), + /Refusing local import/, + ); + assert.throws( + () => shouldUseRemoteImport('http://0.0.0.0:3721', { cloudMode: false }, 'env'), + /did not report cloud mode/, + ); +}); + +test('shouldUseRemoteImport fails closed for non-loopback targets that are not cloud mode', () => { + assert.throws( + () => shouldUseRemoteImport('https://chatcrystal.example.com', { cloudMode: false }, 'explicit'), + /did not report cloud mode/, + ); + assert.throws( + () => shouldUseRemoteImport('https://chatcrystal.example.com', {}, 'saved'), + /did not report cloud mode/, + ); +}); diff --git a/server/src/cli/commands/import.ts b/server/src/cli/commands/import.ts index f0e5776..bd63df3 100644 --- a/server/src/cli/commands/import.ts +++ b/server/src/cli/commands/import.ts @@ -1,21 +1,99 @@ import type { Command } from 'commander'; import { CrystalClient } from '../client.js'; +import { isLocalBaseUrl } from '../../runtime/cloud.js'; +import { runRemoteImport } from '../../services/remoteImport.js'; +import { resolveConnection, type ConnectionSource } from '../connection.js'; import { shouldOutputJson, outputJson, printSuccess, printError, printKeyValue, } from '../formatter.js'; +export function shouldUseRemoteImport( + baseUrl: string, + status: { cloudMode?: boolean }, + connectionSource: ConnectionSource | string, +): boolean { + if (status.cloudMode === true) { + return true; + } + + if (isLocalBaseUrl(baseUrl) && connectionSource === 'local-default') { + return false; + } + + if (isLocalBaseUrl(baseUrl)) { + throw new Error('Refusing local import for a loopback saved/env/explicit connection that did not report cloud mode. This may be an SSH tunnel or wrong local instance; use the implicit local default or connect to a cloud-mode server.'); + } + + throw new Error('Refusing remote import because the target server did not report cloud mode. Connect to a ChatCrystal cloud-mode server before uploading local histories.'); +} + export function registerImportCommand(program: Command) { program .command('import') .description('Scan and import conversations from all sources') - .option('-s, --source ', 'Import from specific source (claude-code, codex, cursor)') + .option('-s, --source ', 'Import from specific source (claude-code, codex, cursor, trae, copilot)') + .option('-y, --yes', 'Skip confirmation prompt for remote import upload') .action(async (opts) => { const globalOpts = program.opts(); - const client = new CrystalClient(globalOpts.baseUrl); + const connection = resolveConnection({ baseUrl: globalOpts.baseUrl, token: globalOpts.token }); + const client = new CrystalClient({ + baseUrl: connection.baseUrl, + token: connection.token, + connectionSource: connection.source, + }); const isTTY = process.stdout.isTTY ?? false; try { + const status = await client.status(); + const remoteMode = shouldUseRemoteImport(connection.baseUrl, status, connection.source); + + if (remoteMode) { + if (!opts.yes) { + if (shouldOutputJson(globalOpts.json) || !process.stdin.isTTY) { + throw new Error('Use --yes when importing to a remote cloud instance from non-interactive output'); + } + console.log(`\nRemote import target: ${connection.baseUrl}`); + process.stdout.write('Scan local AI histories and upload parsed conversations? Type "import" to confirm: '); + const answer = await new Promise((resolve) => { + process.stdin.resume(); + process.stdin.once('data', (data) => { + process.stdin.pause(); + resolve(String(data).trim()); + }); + }); + if (answer !== 'import') { + console.log('\n Cancelled.\n'); + return; + } + } + + const data = await runRemoteImport(client, { source: opts.source }, (progress) => { + if (shouldOutputJson(globalOpts.json)) return; + process.stderr.write( + `\rUploading... ${progress.uploaded}/${progress.scanned} | imported:${progress.imported} replaced:${progress.replaced} skipped:${progress.skipped} errors:${progress.errors}`, + ); + }); + + if (shouldOutputJson(globalOpts.json)) { + outputJson(data); + } else { + process.stderr.write('\r' + ' '.repeat(100) + '\r'); + printSuccess('Remote import complete'); + printKeyValue('Target', connection.baseUrl); + printKeyValue('Scanned', data.scanned); + printKeyValue('Uploaded', data.uploaded); + printKeyValue('Imported', data.imported); + printKeyValue('Replaced', data.replaced); + printKeyValue('Skipped', data.skipped); + printKeyValue('Errors', data.errors); + printKeyValue('Local parse errors', data.localErrors); + console.log(); + } + process.exit(0); + return; + } + if (isTTY && !shouldOutputJson(globalOpts.json)) { // TTY: Ink panel const { renderImportPanel } = await import('../ui/ImportPanel.js'); diff --git a/server/src/cli/commands/mcp.ts b/server/src/cli/commands/mcp.ts index eddd8b5..96835df 100644 --- a/server/src/cli/commands/mcp.ts +++ b/server/src/cli/commands/mcp.ts @@ -1,4 +1,5 @@ import type { Command } from 'commander'; +import { resolveConnection } from '../connection.js'; import { startMcpServer } from '../mcp/server.js'; export function registerMcpCommand(program: Command) { @@ -6,8 +7,17 @@ export function registerMcpCommand(program: Command) { .command('mcp') .description('Start MCP stdio server for AI tool integration') .option('-b, --base-url ', 'Server base URL') + .option('--token ', 'ChatCrystal API token for cloud mode') .action(async (opts) => { const globalOpts = program.opts(); - await startMcpServer(opts.baseUrl ?? globalOpts.baseUrl); + const connection = resolveConnection({ + baseUrl: opts.baseUrl ?? globalOpts.baseUrl, + token: opts.token ?? globalOpts.token, + }); + await startMcpServer({ + baseUrl: connection.baseUrl, + token: connection.token, + connectionSource: connection.source, + }); }); } diff --git a/server/src/cli/commands/notes.ts b/server/src/cli/commands/notes.ts index 4f396b6..1e97473 100644 --- a/server/src/cli/commands/notes.ts +++ b/server/src/cli/commands/notes.ts @@ -1,6 +1,7 @@ import type { Command } from 'commander'; import type { ExperienceReviewReason } from '@chatcrystal/shared'; import { CrystalClient } from '../client.js'; +import { resolveConnection } from '../connection.js'; import { shouldOutputJson, outputJson, printHeader, printTable, printKeyValue, printError, truncate, @@ -34,7 +35,12 @@ export function registerNotesCommand(program: Command) { .option('-l, --limit ', 'Items per page', '20') .action(async (opts) => { const globalOpts = program.opts(); - const client = new CrystalClient(globalOpts.baseUrl); + const connection = resolveConnection({ baseUrl: globalOpts.baseUrl, token: globalOpts.token }); + const client = new CrystalClient({ + baseUrl: connection.baseUrl, + token: connection.token, + connectionSource: connection.source, + }); try { // Interactive mode @@ -90,7 +96,12 @@ export function registerNotesCommand(program: Command) { .description('View a note in detail') .action(async (id) => { const globalOpts = program.opts(); - const client = new CrystalClient(globalOpts.baseUrl); + const connection = resolveConnection({ baseUrl: globalOpts.baseUrl, token: globalOpts.token }); + const client = new CrystalClient({ + baseUrl: connection.baseUrl, + token: connection.token, + connectionSource: connection.source, + }); try { // Interactive mode @@ -158,7 +169,12 @@ export function registerNotesCommand(program: Command) { .option('-y, --yes', 'Skip confirmation prompt') .action(async (id, opts) => { const globalOpts = program.opts(); - const client = new CrystalClient(globalOpts.baseUrl); + const connection = resolveConnection({ baseUrl: globalOpts.baseUrl, token: globalOpts.token }); + const client = new CrystalClient({ + baseUrl: connection.baseUrl, + token: connection.token, + connectionSource: connection.source, + }); const rawId = String(id); const reason = String(opts.reason); @@ -234,7 +250,12 @@ export function registerNotesCommand(program: Command) { .description('View relations for a note') .action(async (id) => { const globalOpts = program.opts(); - const client = new CrystalClient(globalOpts.baseUrl); + const connection = resolveConnection({ baseUrl: globalOpts.baseUrl, token: globalOpts.token }); + const client = new CrystalClient({ + baseUrl: connection.baseUrl, + token: connection.token, + connectionSource: connection.source, + }); try { // Interactive mode diff --git a/server/src/cli/commands/search.ts b/server/src/cli/commands/search.ts index e6a588e..b5efa54 100644 --- a/server/src/cli/commands/search.ts +++ b/server/src/cli/commands/search.ts @@ -1,5 +1,6 @@ import type { Command } from 'commander'; import { CrystalClient } from '../client.js'; +import { resolveConnection } from '../connection.js'; import { shouldOutputJson, outputJson, printHeader, printTable, printError, truncate, @@ -14,7 +15,12 @@ export function registerSearchCommand(program: Command) { .option('-l, --limit ', 'Max results', '10') .action(async (query, opts) => { const globalOpts = program.opts(); - const client = new CrystalClient(globalOpts.baseUrl); + const connection = resolveConnection({ baseUrl: globalOpts.baseUrl, token: globalOpts.token }); + const client = new CrystalClient({ + baseUrl: connection.baseUrl, + token: connection.token, + connectionSource: connection.source, + }); try { // Interactive mode diff --git a/server/src/cli/commands/serve.test.ts b/server/src/cli/commands/serve.test.ts index 1cf2bf2..3e8f693 100644 --- a/server/src/cli/commands/serve.test.ts +++ b/server/src/cli/commands/serve.test.ts @@ -30,7 +30,7 @@ test('getPortFromBaseUrl supports explicit and default ports', () => { assert.equal(getPortFromBaseUrl('http://localhost:3721'), 3721); assert.equal(getPortFromBaseUrl('http://localhost'), 3721); assert.equal(getPortFromBaseUrl('http://127.0.0.1'), 3721); - assert.equal(getPortFromBaseUrl('http://0.0.0.0'), 3721); + assert.equal(getPortFromBaseUrl('http://0.0.0.0'), 80); assert.equal(getPortFromBaseUrl('http://localhost:80'), 80); assert.equal(getPortFromBaseUrl('https://chatcrystal.local'), 443); }); diff --git a/server/src/cli/commands/status.ts b/server/src/cli/commands/status.ts index ab40d0d..a8d2fef 100644 --- a/server/src/cli/commands/status.ts +++ b/server/src/cli/commands/status.ts @@ -1,5 +1,6 @@ import type { Command } from 'commander'; import { CrystalClient } from '../client.js'; +import { resolveConnection } from '../connection.js'; import { shouldOutputJson, outputJson, printHeader, printKeyValue, printError, @@ -11,7 +12,12 @@ export function registerStatusCommand(program: Command) { .description('Show server status and database statistics') .action(async () => { const opts = program.opts(); - const client = new CrystalClient(opts.baseUrl); + const connection = resolveConnection({ baseUrl: opts.baseUrl, token: opts.token }); + const client = new CrystalClient({ + baseUrl: connection.baseUrl, + token: connection.token, + connectionSource: connection.source, + }); try { const data = await client.status(); @@ -22,6 +28,7 @@ export function registerStatusCommand(program: Command) { } printHeader('ChatCrystal Status'); + printKeyValue('Mode', client.getConnectionSummary(data)); printKeyValue('Server', data.server ? 'running' : 'stopped'); printKeyValue('Database', data.database ? 'connected' : 'disconnected'); printKeyValue('Conversations', data.stats.totalConversations); @@ -34,6 +41,12 @@ export function registerStatusCommand(program: Command) { console.log(` #${note.id} ${note.title} (${note.project_name})`); } } + if (data.providerWarnings?.length) { + console.log('\n Warnings:'); + for (const warning of data.providerWarnings) { + console.log(` - ${warning}`); + } + } console.log(); } catch (err) { printError(err instanceof Error ? err.message : 'Failed to get status'); diff --git a/server/src/cli/commands/summarize.ts b/server/src/cli/commands/summarize.ts index a129294..bc702f0 100644 --- a/server/src/cli/commands/summarize.ts +++ b/server/src/cli/commands/summarize.ts @@ -1,5 +1,6 @@ import type { Command } from 'commander'; import { CrystalClient } from '../client.js'; +import { resolveConnection } from '../connection.js'; import { shouldOutputJson, outputJson, printSuccess, printError, printKeyValue, printHeader, printTable, truncate, @@ -13,7 +14,12 @@ export function registerSummarizeCommand(program: Command) { .option('--retry-errors', 'Reset error conversations and allow retry') .action(async (id, opts) => { const globalOpts = program.opts(); - const client = new CrystalClient(globalOpts.baseUrl); + const connection = resolveConnection({ baseUrl: globalOpts.baseUrl, token: globalOpts.token }); + const client = new CrystalClient({ + baseUrl: connection.baseUrl, + token: connection.token, + connectionSource: connection.source, + }); try { if (opts.retryErrors) { diff --git a/server/src/cli/commands/tags.ts b/server/src/cli/commands/tags.ts index b223f47..9c66e6c 100644 --- a/server/src/cli/commands/tags.ts +++ b/server/src/cli/commands/tags.ts @@ -1,5 +1,6 @@ import type { Command } from 'commander'; import { CrystalClient } from '../client.js'; +import { resolveConnection } from '../connection.js'; import { shouldOutputJson, outputJson, printHeader, printTable, printError, @@ -13,7 +14,12 @@ export function registerTagsCommand(program: Command) { .description('List all tags with usage counts') .action(async () => { const globalOpts = program.opts(); - const client = new CrystalClient(globalOpts.baseUrl); + const connection = resolveConnection({ baseUrl: globalOpts.baseUrl, token: globalOpts.token }); + const client = new CrystalClient({ + baseUrl: connection.baseUrl, + token: connection.token, + connectionSource: connection.source, + }); try { // Interactive mode diff --git a/server/src/cli/commands/token.ts b/server/src/cli/commands/token.ts new file mode 100644 index 0000000..bf07fea --- /dev/null +++ b/server/src/cli/commands/token.ts @@ -0,0 +1,89 @@ +import type { Command } from 'commander'; +import { resetStoredAuthForLocalAdmin } from '../../services/auth.js'; +import { CrystalClient } from '../client.js'; +import { resolveConnection, saveConnection } from '../connection.js'; +import { + outputJson, + printError, + printKeyValue, + printSuccess, + shouldOutputJson, +} from '../formatter.js'; + +export function registerTokenCommand(program: Command) { + const token = program + .command('token') + .description('Manage ChatCrystal cloud API tokens'); + + token + .command('rotate ') + .description('Rotate the cloud API token') + .option('--current ', 'Current ChatCrystal API token') + .action(async (nextToken, opts) => { + const globalOpts = program.opts(); + + try { + const connection = resolveConnection({ + baseUrl: globalOpts.baseUrl, + token: globalOpts.token, + }); + const currentToken = opts.current?.trim() || connection.token; + if (!currentToken) { + throw new Error('Current token is required. Use --current or configure a saved/env token.'); + } + + const client = new CrystalClient({ + baseUrl: connection.baseUrl, + token: currentToken, + connectionSource: connection.source, + }); + const result = await client.rotateToken(currentToken, nextToken); + + if (connection.source === 'saved') { + saveConnection({ baseUrl: connection.baseUrl, token: nextToken }); + } + + if (shouldOutputJson(globalOpts.json)) { + outputJson(result); + return; + } + + printSuccess('Token rotated'); + printKeyValue('Base URL', connection.baseUrl); + if (connection.source === 'saved') { + printKeyValue('Saved connection', 'updated'); + } + console.log(); + } catch (err) { + printError(err instanceof Error ? err.message : 'Failed to rotate token'); + process.exit(1); + } + }); + + token + .command('reset') + .description('Reset stored server auth on this machine/container') + .option('-y, --yes', 'Confirm local auth reset') + .action(async (opts) => { + const globalOpts = program.opts(); + + try { + if (!opts.yes) { + throw new Error('Use --yes to reset local stored server auth.'); + } + + await resetStoredAuthForLocalAdmin(); + + if (shouldOutputJson(globalOpts.json)) { + outputJson({ reset: true }); + return; + } + + printSuccess('Reset local stored server auth'); + console.log(); + } catch (err) { + printError(err instanceof Error ? err.message : 'Failed to reset token'); + process.exit(1); + } + }); +} diff --git a/server/src/cli/connection.test.ts b/server/src/cli/connection.test.ts new file mode 100644 index 0000000..c115501 --- /dev/null +++ b/server/src/cli/connection.test.ts @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict'; +import { existsSync, mkdtempSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +const configDir = mkdtempSync(join(tmpdir(), 'chatcrystal-client-config-')); +process.env.CHATCRYSTAL_CLIENT_CONFIG_PATH = join(configDir, 'client.json'); +delete process.env.CHATCRYSTAL_BASE_URL; +delete process.env.CHATCRYSTAL_API_TOKEN; + +const connection = await import('./connection.js'); +const { DEFAULT_SERVER_BASE_URL } = await import('./client.js'); + +test('resolveConnection defaults to the local server without a token', () => { + connection.clearSavedConnection(); + + assert.deepEqual(connection.resolveConnection({}), { + baseUrl: DEFAULT_SERVER_BASE_URL, + token: undefined, + source: 'local-default', + }); +}); + +test('resolveConnection prioritizes explicit flags over env and saved config', () => { + connection.saveConnection({ + baseUrl: 'https://saved.example.com', + token: 'saved-token', + }); + process.env.CHATCRYSTAL_BASE_URL = 'https://env.example.com'; + process.env.CHATCRYSTAL_API_TOKEN = 'env-token'; + + assert.deepEqual(connection.resolveConnection({ + baseUrl: 'https://flag.example.com', + token: 'flag-token', + }), { + baseUrl: 'https://flag.example.com', + token: 'flag-token', + source: 'explicit', + }); + + delete process.env.CHATCRYSTAL_BASE_URL; + delete process.env.CHATCRYSTAL_API_TOKEN; +}); + +test('resolveConnection uses env URL and token before saved config', () => { + connection.saveConnection({ + baseUrl: 'https://saved.example.com', + token: 'saved-token', + }); + process.env.CHATCRYSTAL_BASE_URL = 'https://env.example.com'; + process.env.CHATCRYSTAL_API_TOKEN = 'env-token'; + + assert.deepEqual(connection.resolveConnection({}), { + baseUrl: 'https://env.example.com', + token: 'env-token', + source: 'env', + }); + + delete process.env.CHATCRYSTAL_BASE_URL; + delete process.env.CHATCRYSTAL_API_TOKEN; +}); + +test('resolveConnection refuses a token without an explicit or env base URL', () => { + connection.clearSavedConnection(); + + assert.throws( + () => connection.resolveConnection({ token: 'flag-token' }), + /--base-url is required when --token is provided/, + ); + + process.env.CHATCRYSTAL_API_TOKEN = 'env-token'; + assert.throws( + () => connection.resolveConnection({}), + /CHATCRYSTAL_BASE_URL is required when CHATCRYSTAL_API_TOKEN is provided/, + ); + delete process.env.CHATCRYSTAL_API_TOKEN; +}); + +test('saved connection persists base URL and token for later CLI and MCP use', () => { + connection.clearSavedConnection(); + + connection.saveConnection({ + baseUrl: 'chatcrystal.example.com', + token: 'saved-token', + }); + + assert.equal(existsSync(process.env.CHATCRYSTAL_CLIENT_CONFIG_PATH!), true); + assert.deepEqual(connection.readSavedConnection(), { + baseUrl: 'https://chatcrystal.example.com', + token: 'saved-token', + source: 'saved', + }); + assert.deepEqual(connection.resolveConnection({}), { + baseUrl: 'https://chatcrystal.example.com', + token: 'saved-token', + source: 'saved', + }); + + const raw = JSON.parse(readFileSync(process.env.CHATCRYSTAL_CLIENT_CONFIG_PATH!, 'utf-8')); + assert.equal(raw.version, 1); + assert.equal(raw.baseUrl, 'https://chatcrystal.example.com'); + assert.equal(raw.token, 'saved-token'); + + connection.clearSavedConnection(); + assert.equal(connection.readSavedConnection(), null); +}); diff --git a/server/src/cli/connection.ts b/server/src/cli/connection.ts new file mode 100644 index 0000000..7592a2c --- /dev/null +++ b/server/src/cli/connection.ts @@ -0,0 +1,104 @@ +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { runtimePaths } from '../runtime/paths.js'; +import { DEFAULT_SERVER_BASE_URL, normalizeBaseUrl } from './client.js'; + +export type ConnectionSource = 'explicit' | 'env' | 'saved' | 'local-default'; + +export type ResolvedConnection = { + baseUrl: string; + token?: string; + source: ConnectionSource; +}; + +type SavedConnectionFile = { + version: 1; + baseUrl: string; + token?: string; + updatedAt: string; +}; + +function clean(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +export function readSavedConnection(): ResolvedConnection | null { + if (!existsSync(runtimePaths.clientConfigPath)) return null; + + try { + const raw = JSON.parse(readFileSync(runtimePaths.clientConfigPath, 'utf-8')) as Partial; + if (raw.version !== 1 || !raw.baseUrl) return null; + return { + baseUrl: normalizeBaseUrl(raw.baseUrl), + token: clean(raw.token), + source: 'saved', + }; + } catch { + return null; + } +} + +export function saveConnection(input: { baseUrl: string; token?: string }): ResolvedConnection { + const connection: ResolvedConnection = { + baseUrl: normalizeBaseUrl(input.baseUrl), + token: clean(input.token), + source: 'saved', + }; + const file: SavedConnectionFile = { + version: 1, + baseUrl: connection.baseUrl, + token: connection.token, + updatedAt: new Date().toISOString(), + }; + + mkdirSync(dirname(runtimePaths.clientConfigPath), { recursive: true }); + writeFileSync(runtimePaths.clientConfigPath, JSON.stringify(file, null, 2), { + encoding: 'utf-8', + mode: 0o600, + }); + return connection; +} + +export function clearSavedConnection(): void { + rmSync(runtimePaths.clientConfigPath, { force: true }); +} + +export function resolveConnection(input: { baseUrl?: string; token?: string } = {}): ResolvedConnection { + const explicitBaseUrl = clean(input.baseUrl); + const explicitToken = clean(input.token); + const envBaseUrl = clean(process.env.CHATCRYSTAL_BASE_URL); + const envToken = clean(process.env.CHATCRYSTAL_API_TOKEN); + + if (explicitToken && !explicitBaseUrl && !envBaseUrl) { + throw new Error('--base-url is required when --token is provided.'); + } + if (envToken && !explicitBaseUrl && !envBaseUrl) { + throw new Error('CHATCRYSTAL_BASE_URL is required when CHATCRYSTAL_API_TOKEN is provided.'); + } + + if (explicitBaseUrl) { + return { + baseUrl: normalizeBaseUrl(explicitBaseUrl), + token: explicitToken ?? envToken, + source: 'explicit', + }; + } + + if (envBaseUrl) { + return { + baseUrl: normalizeBaseUrl(envBaseUrl), + token: explicitToken ?? envToken, + source: 'env', + }; + } + + const saved = readSavedConnection(); + if (saved) return saved; + + return { + baseUrl: DEFAULT_SERVER_BASE_URL, + token: undefined, + source: 'local-default', + }; +} diff --git a/server/src/cli/index.ts b/server/src/cli/index.ts index 283022a..b1518e5 100644 --- a/server/src/cli/index.ts +++ b/server/src/cli/index.ts @@ -3,7 +3,6 @@ import { Command } from 'commander'; import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; -import { DEFAULT_SERVER_BASE_URL } from './client.js'; // Read version from package.json const pkgPath = resolve(import.meta.dirname, '../../../../package.json'); @@ -15,7 +14,8 @@ program .name('crystal') .description('ChatCrystal — AI conversation knowledge crystallization tool') .version(pkg.version) - .option('-b, --base-url ', 'Server base URL', DEFAULT_SERVER_BASE_URL) + .option('-b, --base-url ', 'Server base URL') + .option('--token ', 'ChatCrystal API token for cloud mode') .option('--json', 'Force JSON output (override TTY detection)') .option('--no-interactive', 'Disable interactive mode (always use plain output)'); @@ -30,7 +30,10 @@ import { registerSummarizeCommand } from './commands/summarize.js'; import { registerConfigCommand } from './commands/config.js'; import { registerServeCommand } from './commands/serve.js'; import { registerMcpCommand } from './commands/mcp.js'; +import { registerConnectCommand } from './commands/connect.js'; +import { registerTokenCommand } from './commands/token.js'; +registerConnectCommand(program); registerStatusCommand(program); registerConversationsCommand(program); registerImportCommand(program); @@ -41,5 +44,6 @@ registerSummarizeCommand(program); registerConfigCommand(program); registerServeCommand(program); registerMcpCommand(program); +registerTokenCommand(program); program.parse(); diff --git a/server/src/cli/mcp/server.ts b/server/src/cli/mcp/server.ts index 4c18eb7..5acc337 100644 --- a/server/src/cli/mcp/server.ts +++ b/server/src/cli/mcp/server.ts @@ -1,15 +1,17 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { z } from 'zod'; -import { CrystalClient } from '../client.js'; +import { CrystalClient, type CrystalClientOptions } from '../client.js'; import { RecallForTaskRequestShape, ValidateTaskMemoryRequestShape, WriteTaskMemoryRequestShape, } from '../../services/memory/schemas.js'; -export async function startMcpServer(baseUrl: string) { - const client = new CrystalClient(baseUrl); +export async function startMcpServer(options?: string | CrystalClientOptions) { + const client = new CrystalClient(options); + const status = await client.status(); + console.error(`ChatCrystal MCP: ${client.getConnectionSummary(status)}`); const server = new McpServer({ name: 'chatcrystal', version: '0.2.0', diff --git a/server/src/cli/ui/App.tsx b/server/src/cli/ui/App.tsx index b11c9e5..fe8a009 100644 --- a/server/src/cli/ui/App.tsx +++ b/server/src/cli/ui/App.tsx @@ -13,13 +13,14 @@ import type { CrystalClient } from '../client.js'; interface AppProps { client: CrystalClient; initialView: ViewState; + connectionInfo?: string; } /** * Root interactive app. Manages view stack and routes to view components. * Each command creates an with the appropriate initial view. */ -export function App({ client, initialView }: AppProps) { +export function App({ client, initialView, connectionInfo }: AppProps) { const { current, depth, push, pop, replace } = useViewStack(initialView); const { columns, rows } = useTerminalSize(); const { exit } = useApp(); @@ -66,6 +67,7 @@ export function App({ client, initialView }: AppProps) { { push({ type: 'note-detail', props: { noteId, currentIndex: index } }); @@ -80,6 +82,7 @@ export function App({ client, initialView }: AppProps) { { push({ type: 'note-detail', props: { noteId, currentIndex: index } }); @@ -109,6 +113,7 @@ export function App({ client, initialView }: AppProps) { { push({ type: 'notes-list', props: { tagFilter: tagName } }); }} @@ -121,6 +126,7 @@ export function App({ client, initialView }: AppProps) { { push({ type: 'note-detail', props: { noteId, currentIndex: index } }); diff --git a/server/src/cli/ui/components/DetailView.tsx b/server/src/cli/ui/components/DetailView.tsx index 58af354..c9f2477 100644 --- a/server/src/cli/ui/components/DetailView.tsx +++ b/server/src/cli/ui/components/DetailView.tsx @@ -28,6 +28,7 @@ interface DetailViewProps { onDelete?: () => void; /** Position string like "2/243" */ position?: string; + connectionInfo?: string; /** Related notes to show at bottom */ relations?: Array<{ id: number; title: string; relation_type: string }>; } @@ -36,7 +37,7 @@ interface DetailViewProps { * Full-screen note detail view with scrolling. * Shows title, metadata, summary, conclusions, code snippets, relations. */ -export function DetailView({ note, onBack, onPrev, onNext, onDelete, position, relations }: DetailViewProps) { +export function DetailView({ note, onBack, onPrev, onNext, onDelete, position, connectionInfo, relations }: DetailViewProps) { const [scrollY, setScrollY] = useState(0); const { rows: termRows, columns: termCols } = useTerminalSize(); const t = getLocale(); @@ -167,6 +168,7 @@ export function DetailView({ note, onBack, onPrev, onNext, onDelete, position, r )} diff --git a/server/src/cli/ui/components/InteractiveList.tsx b/server/src/cli/ui/components/InteractiveList.tsx index 821bc0b..dbfa0c2 100644 --- a/server/src/cli/ui/components/InteractiveList.tsx +++ b/server/src/cli/ui/components/InteractiveList.tsx @@ -44,6 +44,7 @@ interface InteractiveListProps { renderSidePreview?: (item: T, width: number) => React.ReactNode; /** Extra hints to show in status bar */ extraHints?: Hint[]; + connectionInfo?: string; /** Title shown in header */ title: string; /** Keyboard active (set false when search bar is open) */ @@ -63,7 +64,7 @@ export function InteractiveList({ items, columns, total, loading, error, hasMore, onLoadMore, onSelect, onSearch, onQuit, onRetry, onSummarize, onDelete, renderPreview, renderSidePreview, - extraHints, title, keyboardActive = true, + extraHints, connectionInfo, title, keyboardActive = true, }: InteractiveListProps) { const [cursor, setCursor] = useState(0); const [scrollOffset, setScrollOffset] = useState(0); @@ -299,6 +300,7 @@ export function InteractiveList({ {/* Status bar */} 0 ? t.pageInfo(cursor + 1, total) : undefined} hints={hints} /> @@ -338,6 +340,7 @@ export function InteractiveList({ {/* Status bar */} 0 ? t.pageInfo(cursor + 1, total) : undefined} hints={hints} /> diff --git a/server/src/cli/ui/components/StatusBar.tsx b/server/src/cli/ui/components/StatusBar.tsx index c5d77d3..21b3a8d 100644 --- a/server/src/cli/ui/components/StatusBar.tsx +++ b/server/src/cli/ui/components/StatusBar.tsx @@ -9,6 +9,7 @@ export interface Hint { interface StatusBarProps { /** Left-side info text, e.g., "2/243" */ info?: string; + connectionInfo?: string; /** Keyboard shortcut hints */ hints: Hint[]; } @@ -16,13 +17,14 @@ interface StatusBarProps { /** * Fixed bottom status bar showing context info (left) and keyboard hints (right). */ -export function StatusBar({ info, hints }: StatusBarProps) { +export function StatusBar({ info, connectionInfo, hints }: StatusBarProps) { const hintsText = hints.map(h => `${h.key}:${h.label}`).join(' '); + const leftInfo = [connectionInfo, info].filter(Boolean).join(' | '); return ( - {info && ( - {info} + {leftInfo && ( + {leftInfo} )} {hintsText} diff --git a/server/src/cli/ui/renderApp.tsx b/server/src/cli/ui/renderApp.tsx index 4cbf296..2974691 100644 --- a/server/src/cli/ui/renderApp.tsx +++ b/server/src/cli/ui/renderApp.tsx @@ -10,10 +10,11 @@ import type { CrystalClient } from '../client.js'; */ export async function renderApp(client: CrystalClient, initialView: ViewState): Promise { // Ensure server is available before entering interactive mode - await client.ensureServer(); + const status = await client.status(); + const connectionInfo = client.getConnectionSummary(status); const { waitUntilExit } = render( - , + , ); await waitUntilExit(); diff --git a/server/src/cli/ui/views/ConversationsView.tsx b/server/src/cli/ui/views/ConversationsView.tsx index 6f85016..5e59efc 100644 --- a/server/src/cli/ui/views/ConversationsView.tsx +++ b/server/src/cli/ui/views/ConversationsView.tsx @@ -17,6 +17,7 @@ export interface ConversationItem { interface ConversationsViewProps { client: CrystalClient; + connectionInfo?: string; source?: string; status?: string; search?: string; @@ -26,7 +27,7 @@ interface ConversationsViewProps { onQuit: () => void; } -export function ConversationsView({ client, source, status, search, onSelect, onSearch, onQuit }: ConversationsViewProps) { +export function ConversationsView({ client, connectionInfo, source, status, search, onSelect, onSearch, onQuit }: ConversationsViewProps) { const t = getLocale(); const [summarizing, setSummarizing] = useState(null); const [spinFrame, setSpinFrame] = useState(0); @@ -97,6 +98,7 @@ export function ConversationsView({ client, source, status, search, onSelect, on onRetry={retry} onSummarize={handleSummarize} extraHints={extraHints} + connectionInfo={connectionInfo} title={t.conversationsTitle} renderPreview={(item) => { if (summarizing === item.id) return `${spinChars[spinFrame]} ${t.hints.summarize.split(':')[1]}...`; diff --git a/server/src/cli/ui/views/NoteDetailView.tsx b/server/src/cli/ui/views/NoteDetailView.tsx index f84f38b..3d3ebce 100644 --- a/server/src/cli/ui/views/NoteDetailView.tsx +++ b/server/src/cli/ui/views/NoteDetailView.tsx @@ -9,6 +9,7 @@ import type { CrystalClient } from '../../client.js'; interface NoteDetailViewProps { client: CrystalClient; + connectionInfo?: string; noteId: number; /** For prev/next navigation */ noteIds?: number[]; @@ -20,7 +21,7 @@ interface NoteDetailViewProps { } export function NoteDetailView({ - client, noteId, noteIds, currentIndex, total, onBack, onNavigate, + client, connectionInfo, noteId, noteIds, currentIndex, total, onBack, onNavigate, }: NoteDetailViewProps) { const [note, setNote] = useState(null); const [relations, setRelations] = useState>([]); @@ -138,6 +139,7 @@ export function NoteDetailView({ setShowDeletePanel(true); }} position={position} + connectionInfo={connectionInfo} relations={relations} /> ); diff --git a/server/src/cli/ui/views/NotesListView.tsx b/server/src/cli/ui/views/NotesListView.tsx index aa459c4..8c60459 100644 --- a/server/src/cli/ui/views/NotesListView.tsx +++ b/server/src/cli/ui/views/NotesListView.tsx @@ -19,6 +19,7 @@ export interface NoteItem { interface NotesListViewProps { client: CrystalClient; + connectionInfo?: string; /** Pre-set tag filter (e.g., when navigating from tags view) */ tagFilter?: string; /** Called when user selects a note */ @@ -29,7 +30,7 @@ interface NotesListViewProps { onQuit: () => void; } -export function NotesListView({ client, tagFilter, onSelectNote, onSearch, onQuit }: NotesListViewProps) { +export function NotesListView({ client, connectionInfo, tagFilter, onSelectNote, onSearch, onQuit }: NotesListViewProps) { const t = getLocale(); const [deleteTarget, setDeleteTarget] = useState(null); const [deleteError, setDeleteError] = useState(null); @@ -105,6 +106,7 @@ export function NotesListView({ client, tagFilter, onSelectNote, onSearch, onQui onQuit={onQuit} onRetry={retry} onDelete={handleDelete} + connectionInfo={connectionInfo} title={title} renderPreview={(item) => item.summary} renderSidePreview={(item, width) => { diff --git a/server/src/cli/ui/views/RelationsView.tsx b/server/src/cli/ui/views/RelationsView.tsx index 14191d7..5eade27 100644 --- a/server/src/cli/ui/views/RelationsView.tsx +++ b/server/src/cli/ui/views/RelationsView.tsx @@ -14,12 +14,13 @@ interface RelationItem { interface RelationsViewProps { client: CrystalClient; + connectionInfo?: string; noteId: number; onSelectNote: (noteId: number, index: number) => void; onBack: () => void; } -export function RelationsView({ client, noteId, onSelectNote, onBack }: RelationsViewProps) { +export function RelationsView({ client, connectionInfo, noteId, onSelectNote, onBack }: RelationsViewProps) { const [relations, setRelations] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -67,6 +68,7 @@ export function RelationsView({ client, noteId, onSelectNote, onBack }: Relation onSelect={(item, index) => onSelectNote(item.relatedNoteId, index)} onQuit={onBack} onRetry={load} + connectionInfo={connectionInfo} title={`${t.relationsTitle} #${noteId}`} /> ); diff --git a/server/src/cli/ui/views/SearchView.tsx b/server/src/cli/ui/views/SearchView.tsx index 5425424..a755521 100644 --- a/server/src/cli/ui/views/SearchView.tsx +++ b/server/src/cli/ui/views/SearchView.tsx @@ -15,6 +15,7 @@ interface SearchResult { interface SearchViewProps { client: CrystalClient; + connectionInfo?: string; /** Pre-filled query (e.g., from search command argument) */ initialQuery?: string; /** Called when user selects a result */ @@ -23,7 +24,7 @@ interface SearchViewProps { onBack: () => void; } -export function SearchView({ client, initialQuery, onSelectNote, onBack }: SearchViewProps) { +export function SearchView({ client, connectionInfo, initialQuery, onSelectNote, onBack }: SearchViewProps) { const [showInput, setShowInput] = useState(!initialQuery); const [query, setQuery] = useState(initialQuery || ''); const [results, setResults] = useState([]); @@ -98,6 +99,7 @@ export function SearchView({ client, initialQuery, onSelectNote, onBack }: Searc onSelect={(item, index) => onSelectNote(item.note_id, index)} onSearch={() => setShowInput(true)} onQuit={onBack} + connectionInfo={connectionInfo} title={titleText} keyboardActive={!searching} /> diff --git a/server/src/cli/ui/views/TagsView.tsx b/server/src/cli/ui/views/TagsView.tsx index ab37c21..b5005f3 100644 --- a/server/src/cli/ui/views/TagsView.tsx +++ b/server/src/cli/ui/views/TagsView.tsx @@ -11,12 +11,13 @@ interface TagItem { interface TagsViewProps { client: CrystalClient; + connectionInfo?: string; /** Called when user selects a tag → navigate to notes filtered by this tag */ onSelectTag: (tagName: string) => void; onQuit: () => void; } -export function TagsView({ client, onSelectTag, onQuit }: TagsViewProps) { +export function TagsView({ client, connectionInfo, onSelectTag, onQuit }: TagsViewProps) { const [tags, setTags] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -49,6 +50,7 @@ export function TagsView({ client, onSelectTag, onQuit }: TagsViewProps) { onSelect={(item) => onSelectTag(item.name)} onQuit={onQuit} onRetry={load} + connectionInfo={connectionInfo} title={t.tagsTitle} /> ); diff --git a/server/src/config.ts b/server/src/config.ts index 6bd3183..26ac556 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -1,6 +1,6 @@ -import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; -import { resolve } from "node:path"; +import { dirname, resolve } from "node:path"; import { runtimePaths } from "./runtime/paths.js"; function resolveHome(p: string): string { @@ -111,5 +111,10 @@ function persistConfig() { if (!toSave.embedding.apiKey) delete (toSave.embedding as Record).apiKey; - writeFileSync(configPath, JSON.stringify(toSave, null, 2), "utf-8"); + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync(configPath, JSON.stringify(toSave, null, 2), { + encoding: "utf-8", + mode: 0o600, + }); + chmodSync(configPath, 0o600); } diff --git a/server/src/db/index.test.ts b/server/src/db/index.test.ts index f268a6c..2e43f90 100644 --- a/server/src/db/index.test.ts +++ b/server/src/db/index.test.ts @@ -116,6 +116,50 @@ test('applySchemaMigrations adds conversation experience gate audit columns', as assert.ok(columns.includes('experience_gate_details')); }); +test('applySchemaMigrations backfills remote import identity columns for legacy conversations', async () => { + const db = await createDatabase(); + + db.exec(` + CREATE TABLE conversations ( + id TEXT PRIMARY KEY, + slug TEXT, + source TEXT NOT NULL DEFAULT 'claude-code', + project_dir TEXT NOT NULL, + project_name TEXT NOT NULL, + cwd TEXT, + git_branch TEXT, + message_count INTEGER DEFAULT 0, + first_message_at TEXT, + last_message_at TEXT, + file_path TEXT, + file_size INTEGER, + file_mtime TEXT, + status TEXT DEFAULT 'imported', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + + INSERT INTO conversations ( + id, source, project_dir, project_name, first_message_at, last_message_at, file_path + ) VALUES + ('session-1', 'codex', 'C:/repo', 'repo', '2026-05-20', '2026-05-20', 'a.jsonl'), + ('codex:session-2', 'codex', 'C:/repo', 'repo', '2026-05-20', '2026-05-20', 'b.jsonl'); + `); + + applySchemaMigrations(db); + + const rows = db.exec( + `SELECT id, source_conversation_id, parser_version + FROM conversations + ORDER BY id ASC`, + )[0].values; + + assert.deepEqual(rows, [ + ['codex:session-2', 'session-2', 'codex@1'], + ['session-1', 'session-1', 'codex@1'], + ]); +}); + test('applySchemaMigrations converts confirmed low-signal errors to filtered', async () => { const db = await createDatabase(); diff --git a/server/src/db/index.ts b/server/src/db/index.ts index 0a13d43..6557b9b 100644 --- a/server/src/db/index.ts +++ b/server/src/db/index.ts @@ -49,6 +49,22 @@ export function applySchemaMigrations(db: Database): void { ensureColumn(db, 'conversations', 'experience_score', 'ALTER TABLE conversations ADD COLUMN experience_score REAL'); ensureColumn(db, 'conversations', 'experience_gate_reason', 'ALTER TABLE conversations ADD COLUMN experience_gate_reason TEXT'); ensureColumn(db, 'conversations', 'experience_gate_details', 'ALTER TABLE conversations ADD COLUMN experience_gate_details TEXT'); + ensureColumn(db, 'conversations', 'source_conversation_id', 'ALTER TABLE conversations ADD COLUMN source_conversation_id TEXT'); + ensureColumn(db, 'conversations', 'content_hash', 'ALTER TABLE conversations ADD COLUMN content_hash TEXT'); + ensureColumn(db, 'conversations', 'parser_version', 'ALTER TABLE conversations ADD COLUMN parser_version TEXT'); + db.run( + `UPDATE conversations + SET source_conversation_id = CASE + WHEN id LIKE source || ':%' THEN substr(id, length(source) + 2) + ELSE id + END + WHERE source_conversation_id IS NULL OR trim(source_conversation_id) = ''`, + ); + db.run( + `UPDATE conversations + SET parser_version = source || '@1' + WHERE parser_version IS NULL OR trim(parser_version) = ''`, + ); ensureColumn(db, 'notes', 'embedding_status', "ALTER TABLE notes ADD COLUMN embedding_status TEXT DEFAULT 'pending'"); ensureColumn(db, 'notes', 'project_key', 'ALTER TABLE notes ADD COLUMN project_key TEXT'); ensureColumn(db, 'notes', 'scope', "ALTER TABLE notes ADD COLUMN scope TEXT DEFAULT 'project'"); @@ -79,6 +95,12 @@ export function applySchemaMigrations(db: Database): void { ); db.exec(POST_MIGRATION_SQL); + db.exec(` + CREATE INDEX IF NOT EXISTS idx_conversations_source_conversation_id + ON conversations(source, source_conversation_id); + CREATE INDEX IF NOT EXISTS idx_conversations_content_hash + ON conversations(content_hash); + `); ensureIndexColumns( db, 'idx_vector_cleanup_tasks_pending', diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index de53278..c288c81 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -8,6 +8,9 @@ CREATE TABLE IF NOT EXISTS conversations ( id TEXT PRIMARY KEY, slug TEXT, source TEXT NOT NULL DEFAULT 'claude-code', + source_conversation_id TEXT, + content_hash TEXT, + parser_version TEXT, project_dir TEXT NOT NULL, project_name TEXT NOT NULL, cwd TEXT, diff --git a/server/src/index.ts b/server/src/index.ts index b55a011..2394a93 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -17,6 +17,10 @@ import { noteRoutes } from './routes/notes.js'; import { configRoutes } from './routes/config.js'; import { relationRoutes } from './routes/relations.js'; import { memoryRoutes } from './routes/memory.js'; +import { healthRoutes } from './routes/health.js'; +import { authRoutes, registerCloudAuthHook } from './routes/setup.js'; +import { getOrCreateSetupCode, setupRequired } from './services/auth.js'; +import { isCloudMode } from './runtime/cloud.js'; // Initialize parser adapters (registers built-in adapters) import './parser/index.js'; @@ -36,7 +40,7 @@ export async function createServer(options?: { port?: number; host?: string; }): Promise { - const app = Fastify({ logger: true }); + const app = Fastify({ logger: true, bodyLimit: 25 * 1024 * 1024 }); // CORS for dev (client on different port) await app.register(cors, { origin: true }); @@ -46,7 +50,18 @@ export async function createServer(options?: { startAutoSave(); seedDemoData(); - // Register routes + if (isCloudMode() && setupRequired()) { + const code = getOrCreateSetupCode(); + app.log.warn({ setupCodePath: '/data/setup-code' }, `ChatCrystal setup required. Setup code: ${code}`); + console.log(`[Setup] ChatCrystal setup required. Setup code: ${code}`); + } + + // Register public routes and cloud auth hook before private routes + await app.register(healthRoutes); + await app.register(authRoutes); + registerCloudAuthHook(app); + + // Register protected routes await app.register(statusRoutes); await app.register(importRoutes); await app.register(conversationRoutes); @@ -83,8 +98,8 @@ export async function createServer(options?: { console.log(`[LLM] Provider: ${appConfig.llm.provider} / ${appConfig.llm.model}`); console.log(`[Embedding] Provider: ${appConfig.embedding.provider} / ${appConfig.embedding.model}`); - // Start file watcher - const watcher = startWatcher(); + // Start file watcher only for local mode; cloud imports are pushed from clients. + const watcher = isCloudMode() ? null : startWatcher(); // Start server const port = options?.port ?? appConfig.port; @@ -95,7 +110,7 @@ export async function createServer(options?: { // Graceful shutdown function async function shutdown() { console.log('[Server] Shutting down...'); - await watcher.close(); + await watcher?.close(); closeDatabase(); await app.close(); } diff --git a/server/src/parser/adapter.ts b/server/src/parser/adapter.ts index b324d48..a6a9739 100644 --- a/server/src/parser/adapter.ts +++ b/server/src/parser/adapter.ts @@ -16,6 +16,9 @@ export interface SourceAdapter { /** Display name shown in UI, e.g. 'Claude Code' */ readonly displayName: string; + /** Parser contract version used by remote import deduplication/auditing. */ + readonly parserVersion?: string; + /** * Detect if this data source is available on the current machine. * Returns source info if found, null otherwise. diff --git a/server/src/parser/adapters/claude-code.ts b/server/src/parser/adapters/claude-code.ts index 174d232..28b0fbb 100644 --- a/server/src/parser/adapters/claude-code.ts +++ b/server/src/parser/adapters/claude-code.ts @@ -184,6 +184,7 @@ function extractProjectName(dirName: string): string { export const claudeCodeAdapter: SourceAdapter = { name: 'claude-code', displayName: 'Claude Code', + parserVersion: 'claude-code@1', async detect(): Promise { const dir = appConfig.claudeProjectsDir; diff --git a/server/src/parser/adapters/codex.ts b/server/src/parser/adapters/codex.ts index 869a033..d76e090 100644 --- a/server/src/parser/adapters/codex.ts +++ b/server/src/parser/adapters/codex.ts @@ -129,6 +129,7 @@ function extractProjectName(cwd: string): string { export const codexAdapter: SourceAdapter = { name: "codex", displayName: "Codex CLI", + parserVersion: "codex@1", async detect(): Promise { const dir = appConfig.codexSessionsDir; diff --git a/server/src/parser/adapters/copilot.ts b/server/src/parser/adapters/copilot.ts index ef46057..7597de1 100644 --- a/server/src/parser/adapters/copilot.ts +++ b/server/src/parser/adapters/copilot.ts @@ -286,6 +286,7 @@ export function getCopilotWatchPaths(): string[] { export const copilotAdapter: SourceAdapter = { name: "copilot", displayName: "GitHub Copilot", + parserVersion: "copilot@1", async detect(): Promise { try { diff --git a/server/src/parser/adapters/cursor.ts b/server/src/parser/adapters/cursor.ts index 4a080e5..a9db9d2 100644 --- a/server/src/parser/adapters/cursor.ts +++ b/server/src/parser/adapters/cursor.ts @@ -227,6 +227,7 @@ async function findOrphanBubbleComposers( export const cursorAdapter: SourceAdapter = { name: "cursor", displayName: "Cursor", + parserVersion: "cursor@1", async detect(): Promise { const globalDbPath = getGlobalVscdbPath(); diff --git a/server/src/parser/adapters/trae.ts b/server/src/parser/adapters/trae.ts index 6dee2eb..5a1814f 100644 --- a/server/src/parser/adapters/trae.ts +++ b/server/src/parser/adapters/trae.ts @@ -252,6 +252,7 @@ export function getTraeWatchPaths(): string[] { export const traeAdapter: SourceAdapter = { name: "trae", displayName: "Trae", + parserVersion: "trae@1", async detect(): Promise { try { diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts new file mode 100644 index 0000000..bccfab0 --- /dev/null +++ b/server/src/routes/health.ts @@ -0,0 +1,12 @@ +import type { FastifyInstance } from 'fastify'; +import { isCloudMode } from '../runtime/cloud.js'; + +export async function healthRoutes(app: FastifyInstance) { + app.get('/api/health', async () => ({ + success: true, + data: { + ok: true, + cloudMode: isCloudMode(), + }, + })); +} diff --git a/server/src/routes/import-cloud.test.ts b/server/src/routes/import-cloud.test.ts new file mode 100644 index 0000000..e2fd064 --- /dev/null +++ b/server/src/routes/import-cloud.test.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import Fastify from 'fastify'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +process.env.DATA_DIR = mkdtempSync(join(tmpdir(), 'chatcrystal-import-cloud-test-')); +process.env.CHATCRYSTAL_CLOUD_MODE = 'true'; +process.env.CHATCRYSTAL_API_TOKEN = 'cloud-import-test-token'; + +const { importRoutes } = await import('./import.js'); + +test('cloud mode rejects server-side import scan without scanning container paths', async () => { + const app = Fastify(); + await app.register(importRoutes); + + const response = await app.inject({ method: 'POST', url: '/api/import/scan' }); + + assert.equal(response.statusCode, 400); + assert.match(response.json().error, /local-only/i); + await app.close(); +}); + +test('cloud mode rejects import scan stream as local-only', async () => { + const app = Fastify(); + await app.register(importRoutes); + + const response = await app.inject({ method: 'GET', url: '/api/import/scan/stream' }); + + assert.equal(response.statusCode, 400); + assert.match(response.body, /local-only/i); + await app.close(); +}); diff --git a/server/src/routes/import-ingest.test.ts b/server/src/routes/import-ingest.test.ts new file mode 100644 index 0000000..3da2a78 --- /dev/null +++ b/server/src/routes/import-ingest.test.ts @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict'; +import Fastify from 'fastify'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +process.env.DATA_DIR = mkdtempSync(join(tmpdir(), 'chatcrystal-ingest-route-test-')); +process.env.CHATCRYSTAL_CLOUD_MODE = 'true'; +process.env.CHATCRYSTAL_API_TOKEN = 'route-ingest-secret'; + +const [{ initDatabase }, { authRoutes, registerCloudAuthHook }, { importRoutes }, { buildRemoteImportItem }] = await Promise.all([ + import('../db/index.js'), + import('./setup.js'), + import('./import.js'), + import('../services/importPayload.js'), +]); + +await initDatabase(); + +function item() { + return buildRemoteImportItem( + 'codex', + { + id: 'session-route', + source: 'codex', + filePath: 'C:/fixtures/session-route.jsonl', + fileSize: 10, + fileMtime: '2026-05-20T00:00:00Z', + projectDir: 'C:/repo', + }, + { + id: 'session-route', + slug: 'session-route', + source: 'codex', + projectDir: 'C:/repo', + projectName: 'repo', + cwd: 'C:/repo', + gitBranch: 'main', + firstMessageAt: '2026-05-20T00:00:00Z', + lastMessageAt: '2026-05-20T00:01:00Z', + messages: [ + { + id: 'm1', + parentUuid: null, + type: 'user', + role: 'user', + content: 'hello', + hasToolUse: false, + hasCode: false, + thinking: null, + timestamp: '2026-05-20T00:00:00Z', + }, + { + id: 'm2', + parentUuid: 'm1', + type: 'assistant', + role: 'assistant', + content: 'world', + hasToolUse: false, + hasCode: false, + thinking: null, + timestamp: '2026-05-20T00:01:00Z', + }, + ], + }, + ); +} + +test('remote ingest route is authenticated and stores uploaded conversations', async () => { + const app = Fastify(); + await app.register(authRoutes); + registerCloudAuthHook(app); + await app.register(importRoutes); + + const unauthorized = await app.inject({ + method: 'POST', + url: '/api/import/ingest', + payload: { version: 1, items: [item()] }, + }); + const authorized = await app.inject({ + method: 'POST', + url: '/api/import/ingest', + headers: { authorization: 'Bearer route-ingest-secret' }, + payload: { version: 1, items: [item()] }, + }); + + assert.equal(unauthorized.statusCode, 401); + assert.equal(authorized.statusCode, 200); + assert.equal(authorized.json().data.imported, 1); + await app.close(); +}); + +test('remote ingest route rejects non-cloud servers', async () => { + const previousMode = process.env.CHATCRYSTAL_CLOUD_MODE; + const previousToken = process.env.CHATCRYSTAL_API_TOKEN; + process.env.CHATCRYSTAL_CLOUD_MODE = 'false'; + delete process.env.CHATCRYSTAL_API_TOKEN; + const app = Fastify(); + await app.register(importRoutes); + + try { + const response = await app.inject({ + method: 'POST', + url: '/api/import/ingest', + payload: { version: 1, items: [item()] }, + }); + + assert.equal(response.statusCode, 400); + assert.match(response.json().error, /only available in cloud mode/i); + } finally { + if (previousMode === undefined) { + delete process.env.CHATCRYSTAL_CLOUD_MODE; + } else { + process.env.CHATCRYSTAL_CLOUD_MODE = previousMode; + } + if (previousToken === undefined) { + delete process.env.CHATCRYSTAL_API_TOKEN; + } else { + process.env.CHATCRYSTAL_API_TOKEN = previousToken; + } + await app.close(); + } +}); diff --git a/server/src/routes/import.ts b/server/src/routes/import.ts index cf03917..8fc5f7b 100644 --- a/server/src/routes/import.ts +++ b/server/src/routes/import.ts @@ -1,9 +1,45 @@ import type { FastifyInstance } from 'fastify'; +import type { RemoteImportRequest } from '@chatcrystal/shared'; +import { isCloudMode } from '../runtime/cloud.js'; +import { ingestRemoteImport } from '../services/ingest.js'; import { importAll } from '../services/import.js'; export async function importRoutes(app: FastifyInstance) { + app.post('/api/import/ingest', async (req, reply) => { + if (!isCloudMode()) { + reply.status(400); + return { + success: false, + error: 'Remote import ingest is only available in cloud mode. Use the local import scan endpoint for local servers.', + }; + } + + const body = req.body as RemoteImportRequest; + if (!body || body.version !== 1 || !Array.isArray(body.items)) { + reply.status(400); + return { success: false, error: 'Invalid remote import payload' }; + } + + try { + const result = ingestRemoteImport(body); + return { success: true, data: result }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Remote import failed'; + reply.status(400); + return { success: false, error: message }; + } + }); + // Trigger a full scan and import (JSON response, no progress) app.post('/api/import/scan', async (_req, reply) => { + if (isCloudMode()) { + reply.status(400); + return { + success: false, + error: 'Server-side import scan is local-only and is disabled in cloud mode. Run crystal import from the device that has the source histories.', + }; + } + try { const result = await importAll(); return { success: true, data: result }; @@ -16,6 +52,14 @@ export async function importRoutes(app: FastifyInstance) { // SSE endpoint for import with real-time progress app.get('/api/import/scan/stream', async (_req, reply) => { + if (isCloudMode()) { + reply.status(400); + return { + success: false, + error: 'Server-side import scan stream is local-only and is disabled in cloud mode. Run crystal import from the device that has the source histories.', + }; + } + reply.raw.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', diff --git a/server/src/routes/setup.test.ts b/server/src/routes/setup.test.ts new file mode 100644 index 0000000..dc85a26 --- /dev/null +++ b/server/src/routes/setup.test.ts @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict'; +import Fastify from 'fastify'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +process.env.DATA_DIR = mkdtempSync(join(tmpdir(), 'chatcrystal-setup-route-test-')); +process.env.CHATCRYSTAL_CLOUD_MODE = 'true'; +delete process.env.CHATCRYSTAL_API_TOKEN; + +const { authRoutes, registerCloudAuthHook } = await import('./setup.js'); +const { healthRoutes } = await import('./health.js'); +const auth = await import('../services/auth.js'); + +test('health is public and setup status reports setup required', async () => { + await auth.resetStoredAuthForLocalAdmin(); + const app = Fastify(); + await app.register(healthRoutes); + await app.register(authRoutes); + registerCloudAuthHook(app); + + const health = await app.inject({ method: 'GET', url: '/api/health' }); + const status = await app.inject({ method: 'GET', url: '/api/setup/status' }); + + assert.equal(health.statusCode, 200); + assert.equal(status.statusCode, 200); + assert.equal(status.json().data.setupRequired, true); + await app.close(); +}); + +test('cloud auth hook blocks private APIs during setup mode', async () => { + await auth.resetStoredAuthForLocalAdmin(); + const app = Fastify(); + await app.register(authRoutes); + registerCloudAuthHook(app); + app.get('/api/private-test', async () => ({ success: true })); + + const response = await app.inject({ method: 'GET', url: '/api/private-test' }); + + assert.equal(response.statusCode, 403); + assert.match(response.json().error, /setup required/i); + await app.close(); +}); + +test('setup completion stores token and protected routes require bearer token', async () => { + await auth.resetStoredAuthForLocalAdmin(); + const setupCode = auth.getOrCreateSetupCode(); + const app = Fastify(); + await app.register(authRoutes); + registerCloudAuthHook(app); + app.get('/api/private-test', async () => ({ success: true, data: { ok: true } })); + + const complete = await app.inject({ + method: 'POST', + url: '/api/setup/complete', + payload: { setupCode, token: 'route-secret-token' }, + }); + const unauthorized = await app.inject({ method: 'GET', url: '/api/private-test' }); + const authorized = await app.inject({ + method: 'GET', + url: '/api/private-test', + headers: { authorization: 'Bearer route-secret-token' }, + }); + + assert.equal(complete.statusCode, 200); + assert.equal(unauthorized.statusCode, 401); + assert.equal(authorized.statusCode, 200); + await app.close(); +}); + +test('setup complete rejects oversized and overlong public bodies without consuming setup code', async () => { + await auth.resetStoredAuthForLocalAdmin(); + const setupCode = auth.getOrCreateSetupCode(); + const app = Fastify({ bodyLimit: 25 * 1024 * 1024 }); + await app.register(authRoutes); + + const oversized = await app.inject({ + method: 'POST', + url: '/api/setup/complete', + payload: { setupCode, token: 'x'.repeat(10 * 1024) }, + }); + const overlong = await app.inject({ + method: 'POST', + url: '/api/setup/complete', + payload: { setupCode, token: 'x'.repeat(auth.TOKEN_MAX_LENGTH + 1) }, + }); + + assert.equal(oversized.statusCode, 413); + assert.equal(overlong.statusCode, 400); + assert.equal(await auth.completeSetup(setupCode, 'valid-route-secret'), true); + await app.close(); +}); + +test('token rotation rejects oversized and overlong public bodies before verification', async () => { + await auth.resetStoredAuthForLocalAdmin(); + await auth.setStoredToken('current-route-secret'); + const app = Fastify({ bodyLimit: 25 * 1024 * 1024 }); + await app.register(authRoutes); + + const oversized = await app.inject({ + method: 'POST', + url: '/api/auth/rotate', + payload: { + currentToken: 'current-route-secret', + nextToken: 'x'.repeat(10 * 1024), + }, + }); + const overlong = await app.inject({ + method: 'POST', + url: '/api/auth/rotate', + payload: { + currentToken: 'x'.repeat(auth.TOKEN_MAX_LENGTH + 1), + nextToken: 'next-route-secret', + }, + }); + + assert.equal(oversized.statusCode, 413); + assert.equal(overlong.statusCode, 400); + assert.equal(await auth.verifyToken('current-route-secret'), true); + await app.close(); +}); diff --git a/server/src/routes/setup.ts b/server/src/routes/setup.ts new file mode 100644 index 0000000..027a100 --- /dev/null +++ b/server/src/routes/setup.ts @@ -0,0 +1,157 @@ +import type { CompleteSetupRequest, RotateTokenRequest } from '@chatcrystal/shared'; +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { getProviderWarnings, isCloudMode, isPublicApiPath } from '../runtime/cloud.js'; +import { + completeSetup, + getOrCreateSetupCode, + hasActiveToken, + rotateStoredToken, + setupRequired, + TOKEN_MAX_LENGTH, + TOKEN_MIN_LENGTH, + verifyToken, +} from '../services/auth.js'; + +const SETUP_COMPLETE_BODY_LIMIT_BYTES = 8 * 1024; + +function bearerToken(req: FastifyRequest): string | undefined { + const header = req.headers.authorization; + if (!header) return undefined; + const match = /^Bearer\s+(.+)$/i.exec(header); + return match?.[1]; +} + +export function registerCloudAuthHook(app: FastifyInstance) { + app.addHook('onRequest', async (req, reply) => { + if (!isCloudMode()) return; + if (!req.url.startsWith('/api/')) return; + if (isPublicApiPath(req.url)) return; + + if (setupRequired()) { + const code = getOrCreateSetupCode(); + req.log.warn({ setupCodePath: 'setup-code' }, `ChatCrystal setup required. Setup code: ${code}`); + reply.status(403).send({ + success: false, + error: 'Cloud setup required. Open the Web UI and enter the setup code from the container logs or /data/setup-code.', + }); + return reply; + } + + const ok = await verifyToken(bearerToken(req), req.ip); + if (!ok) { + reply.status(401).send({ success: false, error: 'Invalid or missing ChatCrystal API token' }); + return reply; + } + }); +} + +const setupCompleteRouteOptions = { + bodyLimit: SETUP_COMPLETE_BODY_LIMIT_BYTES, + schema: { + body: { + type: 'object', + required: ['setupCode', 'token'], + additionalProperties: false, + properties: { + setupCode: { type: 'string', minLength: 1, maxLength: 128 }, + token: { type: 'string', minLength: TOKEN_MIN_LENGTH, maxLength: TOKEN_MAX_LENGTH }, + }, + }, + }, +} as const; + +const rotateTokenRouteOptions = { + bodyLimit: SETUP_COMPLETE_BODY_LIMIT_BYTES, + schema: { + body: { + type: 'object', + required: ['currentToken', 'nextToken'], + additionalProperties: false, + properties: { + currentToken: { type: 'string', minLength: 1, maxLength: TOKEN_MAX_LENGTH }, + nextToken: { type: 'string', minLength: TOKEN_MIN_LENGTH, maxLength: TOKEN_MAX_LENGTH }, + }, + }, + }, +} as const; + +export async function authRoutes(app: FastifyInstance) { + app.get('/api/setup/status', async (req) => { + const token = bearerToken(req); + const authenticated = !isCloudMode() || (token ? await verifyToken(token, req.ip) : false); + if (isCloudMode() && setupRequired()) { + const code = getOrCreateSetupCode(); + req.log.warn({ setupCodePath: 'setup-code' }, `ChatCrystal setup required. Setup code: ${code}`); + } + + return { + success: true, + data: { + cloudMode: isCloudMode(), + setupRequired: isCloudMode() && setupRequired(), + authenticated, + providerWarnings: getProviderWarnings(), + }, + }; + }); + + app.post('/api/setup/complete', setupCompleteRouteOptions, async (req, reply) => { + if (!isCloudMode()) { + reply.status(400); + return { success: false, error: 'Setup is only available in cloud mode' }; + } + + const body = req.body as Partial; + if (!body.setupCode || !body.token) { + reply.status(400); + return { success: false, error: 'setupCode and token are required' }; + } + + try { + const ok = await completeSetup(body.setupCode, body.token); + if (!ok) { + reply.status(401); + return { success: false, error: 'Invalid setup code' }; + } + return { success: true, data: { authenticated: true } }; + } catch (err) { + reply.status(429); + return { success: false, error: err instanceof Error ? err.message : 'Setup failed' }; + } + }); + + app.post('/api/auth/verify', async (req, reply) => { + if (!isCloudMode()) { + return { success: true, data: { authenticated: true } }; + } + if (!hasActiveToken()) { + reply.status(403); + return { success: false, error: 'Setup required before token verification' }; + } + const authenticated = await verifyToken(bearerToken(req), req.ip); + if (!authenticated) { + reply.status(401); + return { success: false, error: 'Invalid or missing ChatCrystal API token' }; + } + return { success: true, data: { authenticated } }; + }); + + app.post('/api/auth/rotate', rotateTokenRouteOptions, async (req, reply) => { + const body = req.body as Partial; + if (!body.currentToken || !body.nextToken) { + reply.status(400); + return { success: false, error: 'currentToken and nextToken are required' }; + } + try { + const ok = await rotateStoredToken(body.currentToken, body.nextToken); + if (!ok) { + reply.status(401); + return { success: false, error: 'Current token is invalid' }; + } + } catch (err) { + reply.status(409); + return { success: false, error: err instanceof Error ? err.message : 'Token rotation failed' }; + } + return { success: true, data: { rotated: true } }; + }); +} diff --git a/server/src/routes/status-cloud.test.ts b/server/src/routes/status-cloud.test.ts new file mode 100644 index 0000000..3b6f5cb --- /dev/null +++ b/server/src/routes/status-cloud.test.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import Fastify from 'fastify'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +process.env.DATA_DIR = mkdtempSync(join(tmpdir(), 'chatcrystal-status-cloud-test-')); +process.env.CHATCRYSTAL_CLOUD_MODE = 'true'; + +const db = await import('../db/index.js'); +const { statusRoutes } = await import('./status.js'); +const { registerAdapter } = await import('../parser/index.js'); + +test('status includes cloud mode and provider warnings for authenticated callers', async () => { + await db.initDatabase(); + const app = Fastify(); + await app.register(statusRoutes); + + const response = await app.inject({ method: 'GET', url: '/api/status' }); + + assert.equal(response.statusCode, 200); + assert.equal(response.json().data.cloudMode, true); + assert.ok(Array.isArray(response.json().data.providerWarnings)); + await app.close(); + db.closeDatabase(); +}); + +test('cloud config response does not detect local source adapters', async () => { + await db.initDatabase(); + registerAdapter({ + name: 'cloud-detect-should-not-run', + displayName: 'Cloud detect should not run', + detect: async () => { + throw new Error('cloud mode must not detect local source adapters'); + }, + scan: async () => [], + parse: async () => { + throw new Error('not used'); + }, + }); + const app = Fastify(); + await app.register(statusRoutes); + + const response = await app.inject({ method: 'GET', url: '/api/config' }); + + assert.equal(response.statusCode, 200); + assert.deepEqual(response.json().data.sources, []); + assert.equal(response.json().data.claudeProjectsDir, ''); + await app.close(); + db.closeDatabase(); +}); diff --git a/server/src/routes/status.ts b/server/src/routes/status.ts index 03d5b7c..6022c35 100644 --- a/server/src/routes/status.ts +++ b/server/src/routes/status.ts @@ -3,6 +3,7 @@ import { appConfig } from "../config.js"; import { getDatabase } from "../db/index.js"; import { resultToObjects } from "../db/utils.js"; import { detectAllSources } from "../parser/index.js"; +import { getProviderWarnings, isCloudMode } from "../runtime/cloud.js"; export async function statusRoutes(app: FastifyInstance) { app.get("/api/status", async () => { @@ -33,6 +34,8 @@ export async function statusRoutes(app: FastifyInstance) { data: { server: true, database: true, + cloudMode: isCloudMode(), + providerWarnings: getProviderWarnings(), isSeeded: Number(convCount) > 0 && Number(realConvCount) === 0, stats: { totalConversations: convCount, @@ -46,6 +49,7 @@ export async function statusRoutes(app: FastifyInstance) { // Current config (read-only, no secrets) app.get("/api/config", async () => { + const cloudMode = isCloudMode(); return { success: true, data: { @@ -61,9 +65,9 @@ export async function statusRoutes(app: FastifyInstance) { model: appConfig.embedding.model, hasApiKey: !!appConfig.embedding.apiKey, }, - sources: (await detectAllSources()).map((s) => s.info), + sources: cloudMode ? [] : (await detectAllSources()).map((s) => s.info), enabledSources: appConfig.enabledSources, - claudeProjectsDir: appConfig.claudeProjectsDir, + claudeProjectsDir: cloudMode ? "" : appConfig.claudeProjectsDir, }, }; }); diff --git a/server/src/runtime/cloud.test.ts b/server/src/runtime/cloud.test.ts new file mode 100644 index 0000000..12bf2af --- /dev/null +++ b/server/src/runtime/cloud.test.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + getProviderWarningsForTest, + isCloudModeForEnv, + isLocalBaseUrl, + isPublicApiPath, +} from './cloud.js'; + +test('isCloudModeForEnv enables cloud behavior only for explicit cloud mode or token', () => { + assert.equal(isCloudModeForEnv({}), false); + assert.equal(isCloudModeForEnv({ CHATCRYSTAL_CLOUD_MODE: 'false' }), false); + assert.equal(isCloudModeForEnv({ CHATCRYSTAL_CLOUD_MODE: 'true' }), true); + assert.equal(isCloudModeForEnv({ CHATCRYSTAL_API_TOKEN: 'secret-token' }), true); +}); + +test('isLocalBaseUrl detects local URLs only', () => { + assert.equal(isLocalBaseUrl('http://localhost:3721'), true); + assert.equal(isLocalBaseUrl('http://127.0.0.1:3721'), true); + assert.equal(isLocalBaseUrl('http://[::1]:3721'), true); + assert.equal(isLocalBaseUrl('http://0.0.0.0:3721'), false); + assert.equal(isLocalBaseUrl('https://chatcrystal.example.com'), false); + assert.equal(isLocalBaseUrl('http://192.168.1.20:3721'), false); +}); + +test('isPublicApiPath keeps only setup, auth verify, and health public', () => { + assert.equal(isPublicApiPath('/api/health'), true); + assert.equal(isPublicApiPath('/api/setup/status'), true); + assert.equal(isPublicApiPath('/api/setup/complete'), true); + assert.equal(isPublicApiPath('/api/auth/verify'), true); + assert.equal(isPublicApiPath('/api/status'), false); + assert.equal(isPublicApiPath('/api/import/scan/stream'), false); +}); + +test('getProviderWarningsForTest warns for container-local Ollama defaults', () => { + const warnings = getProviderWarningsForTest({ + cloudMode: true, + llm: { provider: 'ollama', baseURL: 'http://localhost:11434', model: 'qwen2.5:7b' }, + embedding: { provider: 'ollama', baseURL: 'http://127.0.0.1:11434', model: 'nomic-embed-text' }, + }); + + assert.deepEqual(warnings, [ + 'LLM provider points to localhost from inside the container. Use host.docker.internal, a remote HTTPS API, or a trusted network Ollama URL.', + 'Embedding provider points to localhost from inside the container. Use host.docker.internal, a remote HTTPS API, or a trusted network Ollama URL.', + ]); +}); diff --git a/server/src/runtime/cloud.ts b/server/src/runtime/cloud.ts new file mode 100644 index 0000000..963188d --- /dev/null +++ b/server/src/runtime/cloud.ts @@ -0,0 +1,75 @@ +import { appConfig } from '../config.js'; + +type EnvLike = Record; + +type ProviderWarningInput = { + cloudMode: boolean; + llm: { provider: string; baseURL?: string; model: string }; + embedding: { provider: string; baseURL?: string; model: string }; +}; + +const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']); +const PUBLIC_API_PATHS = new Set([ + '/api/health', + '/api/setup/status', + '/api/setup/complete', + '/api/auth/verify', +]); + +export function isCloudModeForEnv(env: EnvLike): boolean { + return env.CHATCRYSTAL_CLOUD_MODE === 'true' || Boolean(env.CHATCRYSTAL_API_TOKEN?.trim()); +} + +export function isCloudMode(): boolean { + return isCloudModeForEnv(process.env); +} + +export function isLocalBaseUrl(rawUrl: string): boolean { + try { + const url = new URL(rawUrl); + return LOCAL_HOSTS.has(url.hostname) || LOCAL_HOSTS.has(url.host); + } catch { + return false; + } +} + +export function isPublicApiPath(path: string): boolean { + const pathname = path.split('?', 1)[0] ?? path; + return PUBLIC_API_PATHS.has(pathname); +} + +function hasContainerLocalBaseUrl(baseURL?: string): boolean { + if (!baseURL) return false; + try { + const url = new URL(baseURL); + return url.protocol.startsWith('http') && LOCAL_HOSTS.has(url.hostname); + } catch { + return false; + } +} + +export function getProviderWarningsForTest(input: ProviderWarningInput): string[] { + if (!input.cloudMode) return []; + + const warnings: string[] = []; + if (input.llm.provider === 'ollama' && hasContainerLocalBaseUrl(input.llm.baseURL)) { + warnings.push( + 'LLM provider points to localhost from inside the container. Use host.docker.internal, a remote HTTPS API, or a trusted network Ollama URL.', + ); + } + if (input.embedding.provider === 'ollama' && hasContainerLocalBaseUrl(input.embedding.baseURL)) { + warnings.push( + 'Embedding provider points to localhost from inside the container. Use host.docker.internal, a remote HTTPS API, or a trusted network Ollama URL.', + ); + } + + return warnings; +} + +export function getProviderWarnings(): string[] { + return getProviderWarningsForTest({ + cloudMode: isCloudMode(), + llm: appConfig.llm, + embedding: appConfig.embedding, + }); +} diff --git a/server/src/runtime/paths.ts b/server/src/runtime/paths.ts index 3b20aaa..9b4ae21 100644 --- a/server/src/runtime/paths.ts +++ b/server/src/runtime/paths.ts @@ -107,6 +107,10 @@ export const runtimePaths = { dataDir, dbPath: path.resolve(dataDir, 'chatcrystal.db'), configPath: path.resolve(dataDir, 'config.json'), + authPath: path.resolve(dataDir, 'auth.json'), + setupCodePath: path.resolve(dataDir, 'setup-code'), + setupStatePath: path.resolve(dataDir, 'setup-state.json'), + clientConfigPath: process.env.CHATCRYSTAL_CLIENT_CONFIG_PATH ?? path.resolve(homedir(), '.chatcrystal', 'client.json'), pidPath: path.resolve(dataDir, 'crystal.pid'), logPath: path.resolve(dataDir, 'crystal-server.log'), }; diff --git a/server/src/services/auth.test.ts b/server/src/services/auth.test.ts new file mode 100644 index 0000000..c7a04a5 --- /dev/null +++ b/server/src/services/auth.test.ts @@ -0,0 +1,204 @@ +import assert from 'node:assert/strict'; +import { existsSync, mkdtempSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +const dataDir = mkdtempSync(join(tmpdir(), 'chatcrystal-auth-test-')); +process.env.DATA_DIR = dataDir; +process.env.CHATCRYSTAL_CLOUD_MODE = 'true'; +delete process.env.CHATCRYSTAL_API_TOKEN; + +const auth = await import('./auth.js'); +const { runtimePaths } = await import('../runtime/paths.js'); + +test('stored token is hashed and verifies with timing-safe comparison', async () => { + await auth.setStoredToken('first-secret-token'); + + const raw = readFileSync(runtimePaths.authPath, 'utf-8'); + assert.equal(raw.includes('first-secret-token'), false); + assert.equal(await auth.verifyToken('first-secret-token'), true); + assert.equal(await auth.verifyToken('wrong-token'), false); +}); + +test('stored token setup trims surrounding whitespace consistently', async () => { + await auth.setStoredToken(' trimmed-secret-token '); + + assert.equal(await auth.verifyToken('trimmed-secret-token'), true); + assert.equal(await auth.verifyToken(' trimmed-secret-token '), true); +}); + +test('overlong bearer tokens are rejected before verification work', async () => { + await auth.setStoredToken('length-guard-secret-token'); + + assert.equal(await auth.verifyToken('x'.repeat(auth.TOKEN_MAX_LENGTH + 1)), false); +}); + +test('repeated invalid bearer tokens are rate limited per client and token fingerprint', async () => { + await auth.resetStoredAuthForLocalAdmin(); + await auth.setStoredToken('rate-limit-secret-token'); + + for (let i = 0; i < 10; i++) { + assert.equal(await auth.verifyToken('wrong-token', 'client-a'), false); + } + + assert.equal(await auth.verifyToken('wrong-token', 'client-a'), false); + assert.equal(await auth.verifyToken('rate-limit-secret-token', 'client-a'), true); + assert.equal(await auth.verifyToken('rate-limit-secret-token', 'client-b'), true); +}); + +test('varied invalid bearer tokens are rate limited per client before unlimited scrypt work', async () => { + await auth.resetStoredAuthForLocalAdmin(); + await auth.setStoredToken('client-budget-secret-token'); + + for (let i = 0; i < 20; i++) { + assert.equal(await auth.verifyToken(`wrong-token-${i}`, 'client-c'), false); + } + + assert.equal(await auth.verifyToken('client-budget-secret-token', 'client-c'), false); + assert.equal(await auth.verifyToken('client-budget-secret-token', 'client-d'), true); +}); + +test('concurrent invalid bearer tokens are capped before unbounded scrypt work', async () => { + await auth.resetStoredAuthForLocalAdmin(); + await auth.setStoredToken('inflight-budget-secret-token'); + + const attempts = await Promise.all( + Array.from({ length: 12 }, (_, index) => + auth.verifyToken(`parallel-wrong-token-${index}`, 'client-parallel'), + ), + ); + + assert.equal(attempts.every((ok) => ok === false), true); + assert.equal(await auth.verifyToken('inflight-budget-secret-token', 'client-other'), true); +}); + +test('environment token takes precedence over stored token', async () => { + process.env.CHATCRYSTAL_API_TOKEN = 'env-secret-token'; + + assert.equal(await auth.verifyToken('env-secret-token'), true); + assert.equal(await auth.verifyToken('first-secret-token'), false); + + delete process.env.CHATCRYSTAL_API_TOKEN; +}); + +test('setup code is high entropy, persisted with metadata, single-use, and completes setup', async () => { + await auth.resetStoredAuthForLocalAdmin(); + const code = auth.getOrCreateSetupCode(); + + assert.match(code, /^[a-f0-9]{48}$/); + assert.equal(existsSync(runtimePaths.setupCodePath), true); + assert.equal(existsSync(runtimePaths.setupStatePath), true); + + const result = await auth.completeSetup(code, 'new-secret-token'); + assert.equal(result, true); + assert.equal(await auth.verifyToken('new-secret-token'), true); + assert.equal(existsSync(runtimePaths.setupCodePath), false); + assert.equal(existsSync(runtimePaths.setupStatePath), false); + assert.equal(await auth.completeSetup(code, 'another-secret-token'), false); +}); + +test('setup verifier rate limits repeated bad codes', async () => { + await auth.resetStoredAuthForLocalAdmin(); + auth.getOrCreateSetupCode(); + + for (let i = 0; i < 5; i++) { + assert.equal(await auth.completeSetup('bad-code', 'token-after-bad-code'), false); + } + + await assert.rejects( + () => auth.completeSetup('bad-code', 'token-after-limit'), + /Too many setup attempts/, + ); +}); + +test('setup code expires from persisted state', async () => { + await auth.resetStoredAuthForLocalAdmin(); + const code = auth.getOrCreateSetupCode(); + auth.expireSetupCodeForTest(); + + await assert.rejects( + () => auth.completeSetup(code, 'token-after-expiry'), + /Setup code expired/, + ); +}); + +test('setup code is consumed before async token hashing completes', async () => { + await auth.resetStoredAuthForLocalAdmin(); + const code = auth.getOrCreateSetupCode(); + + const [first, second] = await Promise.allSettled([ + auth.completeSetup(code, 'concurrent-secret-token'), + auth.completeSetup(code, 'concurrent-secret-token'), + ]); + + const successes = [first, second].filter((result) => result.status === 'fulfilled' && result.value === true); + const failures = [first, second].filter((result) => result.status === 'fulfilled' && result.value === false); + + assert.equal(successes.length, 1); + assert.equal(failures.length, 1); +}); + +test('setup completion blocks new setup code while token hashing is pending', async () => { + await auth.resetStoredAuthForLocalAdmin(); + const code = auth.getOrCreateSetupCode(); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + + auth.setBeforeStoreTokenForTest(() => gate); + const completion = auth.completeSetup(code, 'pending-secret-token'); + await Promise.resolve(); + + try { + assert.equal(auth.setupRequired(), false); + assert.throws( + () => auth.getOrCreateSetupCode(), + /already in progress/, + ); + assert.equal(await auth.completeSetup(code, 'another-secret-token'), false); + + release(); + assert.equal(await completion, true); + } finally { + release(); + auth.setBeforeStoreTokenForTest(null); + await completion.catch(() => undefined); + } +}); + +test('invalid setup token does not consume setup code', async () => { + await auth.resetStoredAuthForLocalAdmin(); + const code = auth.getOrCreateSetupCode(); + + await assert.rejects( + () => auth.completeSetup(code, 'short'), + /Token must be at least/, + ); + + assert.equal(await auth.completeSetup(code, 'valid-secret-token'), true); +}); + +test('overlong setup token is rejected before hashing and does not consume setup code', async () => { + await auth.resetStoredAuthForLocalAdmin(); + const code = auth.getOrCreateSetupCode(); + + await assert.rejects( + () => auth.completeSetup(code, 'x'.repeat(auth.TOKEN_MAX_LENGTH + 1)), + /Token must be at most/, + ); + + assert.equal(await auth.completeSetup(code, 'valid-secret-token'), true); +}); + +test('token rotation is rejected when env token is active', async () => { + process.env.CHATCRYSTAL_API_TOKEN = 'env-rotate-token'; + + await assert.rejects( + () => auth.rotateStoredToken('env-rotate-token', 'next-rotate-token'), + /environment token/, + ); + + delete process.env.CHATCRYSTAL_API_TOKEN; +}); diff --git a/server/src/services/auth.ts b/server/src/services/auth.ts new file mode 100644 index 0000000..d27fa09 --- /dev/null +++ b/server/src/services/auth.ts @@ -0,0 +1,388 @@ +import { createHash, randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto'; +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { dirname } from 'node:path'; +import { promisify } from 'node:util'; +import { runtimePaths } from '../runtime/paths.js'; + +const scrypt = promisify(scryptCallback); +export const TOKEN_MIN_LENGTH = 16; +export const TOKEN_MAX_LENGTH = 4096; +const SETUP_CODE_BYTES = 24; +const SETUP_MAX_BAD_ATTEMPTS = 5; +const SETUP_LOCK_MS = 60_000; +const SETUP_CODE_TTL_MS = 15 * 60_000; +const AUTH_MAX_BAD_ATTEMPTS = 10; +const AUTH_CLIENT_MAX_BAD_ATTEMPTS = 20; +const AUTH_CLIENT_MAX_INFLIGHT = 4; +const AUTH_LOCK_MS = 60_000; +const AUTH_FAILURE_TTL_MS = 5 * 60_000; +const AUTH_FAILURE_MAX_ENTRIES = 2048; + +type AuthFile = { + version: 1; + algorithm: 'scrypt'; + salt: string; + hash: string; + createdAt: string; + updatedAt: string; +}; + +type SetupState = { + code: string | null; + badAttempts: number; + lockedUntil: number; + createdAt: number; + expiresAt: number; +}; + +const setupState: SetupState = { + code: null, + badAttempts: 0, + lockedUntil: 0, + createdAt: 0, + expiresAt: 0, +}; +let setupCompletionInProgress = false; +let beforeStoreTokenForTest: (() => Promise) | null = null; +const authFailures = new Map(); +const authClientFailures = new Map(); +const authClientInflight = new Map(); + +function ensureDataDir(): void { + mkdirSync(dirname(runtimePaths.authPath), { recursive: true }); +} + +function validateToken(token: string): void { + const length = token.trim().length; + if (length < TOKEN_MIN_LENGTH) { + throw new Error(`Token must be at least ${TOKEN_MIN_LENGTH} characters long`); + } + if (length > TOKEN_MAX_LENGTH) { + throw new Error(`Token must be at most ${TOKEN_MAX_LENGTH} characters long`); + } +} + +async function hashToken(token: string, salt = randomBytes(16).toString('hex')): Promise { + const normalizedToken = token.trim(); + validateToken(normalizedToken); + const derived = (await scrypt(normalizedToken, salt, 64)) as Buffer; + const now = new Date().toISOString(); + return { + version: 1, + algorithm: 'scrypt', + salt, + hash: derived.toString('hex'), + createdAt: now, + updatedAt: now, + }; +} + +function readAuthFile(): AuthFile | null { + if (!existsSync(runtimePaths.authPath)) return null; + return JSON.parse(readFileSync(runtimePaths.authPath, 'utf-8')) as AuthFile; +} + +function writeAuthFile(authFile: AuthFile): void { + ensureDataDir(); + writeFileSync(runtimePaths.authPath, JSON.stringify(authFile, null, 2), { + encoding: 'utf-8', + mode: 0o600, + }); +} + +function readSetupState(): SetupState | null { + if (!existsSync(runtimePaths.setupStatePath)) return setupState.code ? setupState : null; + const raw = JSON.parse(readFileSync(runtimePaths.setupStatePath, 'utf-8')) as SetupState; + setupState.code = raw.code; + setupState.badAttempts = raw.badAttempts; + setupState.lockedUntil = raw.lockedUntil; + setupState.createdAt = raw.createdAt; + setupState.expiresAt = raw.expiresAt; + return setupState; +} + +function writeSetupState(state: SetupState): void { + ensureDataDir(); + writeFileSync(runtimePaths.setupStatePath, JSON.stringify(state, null, 2), { + encoding: 'utf-8', + mode: 0o600, + }); +} + +function clearSetupStateFiles(): void { + setupState.code = null; + setupState.badAttempts = 0; + setupState.lockedUntil = 0; + setupState.createdAt = 0; + setupState.expiresAt = 0; + rmSync(runtimePaths.setupCodePath, { force: true }); + rmSync(runtimePaths.setupStatePath, { force: true }); +} + +function activeSetupState(): SetupState | null { + const state = readSetupState(); + if (!state?.code) return null; + if (Date.now() > state.expiresAt) { + clearSetupStateFiles(); + return null; + } + return state; +} + +function authRateKey(token: string | undefined, key?: string): string { + const scope = key?.trim() || 'global'; + const tokenFingerprint = createHash('sha256') + .update(token?.trim() ?? '') + .digest('hex') + .slice(0, 16); + return `${scope.slice(0, 128)}:${tokenFingerprint}`; +} + +function authClientRateKey(key?: string): string { + return (key?.trim() || 'global').slice(0, 128); +} + +function pruneAuthFailures(): void { + const now = Date.now(); + for (const [key, state] of authFailures) { + if (state.updatedAt + AUTH_FAILURE_TTL_MS < now || (state.lockedUntil > 0 && state.lockedUntil <= now)) { + authFailures.delete(key); + } + } + for (const [key, state] of authClientFailures) { + if (state.updatedAt + AUTH_FAILURE_TTL_MS < now || (state.lockedUntil > 0 && state.lockedUntil <= now)) { + authClientFailures.delete(key); + } + } + while (authFailures.size > AUTH_FAILURE_MAX_ENTRIES) { + const oldestKey = authFailures.keys().next().value; + if (!oldestKey) break; + authFailures.delete(oldestKey); + } + while (authClientFailures.size > AUTH_FAILURE_MAX_ENTRIES) { + const oldestKey = authClientFailures.keys().next().value; + if (!oldestKey) break; + authClientFailures.delete(oldestKey); + } +} + +function isAuthLocked(token: string | undefined, key?: string): boolean { + pruneAuthFailures(); + const clientRateKey = authClientRateKey(key); + const clientState = authClientFailures.get(clientRateKey); + if (clientState?.lockedUntil && clientState.lockedUntil > Date.now()) return true; + + const rateKey = authRateKey(token, key); + const state = authFailures.get(rateKey); + if (!state) return false; + if (state.lockedUntil > Date.now()) return true; + if (state.lockedUntil > 0) authFailures.delete(rateKey); + return false; +} + +function recordAuthResult(ok: boolean, token: string | undefined, key?: string): void { + pruneAuthFailures(); + const rateKey = authRateKey(token, key); + const clientRateKey = authClientRateKey(key); + if (ok) { + authFailures.delete(rateKey); + authClientFailures.delete(clientRateKey); + return; + } + + const now = Date.now(); + const state = authFailures.get(rateKey) ?? { badAttempts: 0, lockedUntil: 0, updatedAt: now }; + state.badAttempts++; + state.updatedAt = now; + if (state.badAttempts >= AUTH_MAX_BAD_ATTEMPTS) { + state.lockedUntil = now + AUTH_LOCK_MS; + } + authFailures.set(rateKey, state); + + const clientState = authClientFailures.get(clientRateKey) ?? { badAttempts: 0, lockedUntil: 0, updatedAt: now }; + clientState.badAttempts++; + clientState.updatedAt = now; + if (clientState.badAttempts >= AUTH_CLIENT_MAX_BAD_ATTEMPTS) { + clientState.lockedUntil = now + AUTH_LOCK_MS; + } + authClientFailures.set(clientRateKey, clientState); +} + +function acquireAuthSlot(key?: string): boolean { + const clientRateKey = authClientRateKey(key); + const current = authClientInflight.get(clientRateKey) ?? 0; + if (current >= AUTH_CLIENT_MAX_INFLIGHT) return false; + authClientInflight.set(clientRateKey, current + 1); + return true; +} + +function releaseAuthSlot(key?: string): void { + const clientRateKey = authClientRateKey(key); + const current = authClientInflight.get(clientRateKey) ?? 0; + if (current <= 1) { + authClientInflight.delete(clientRateKey); + return; + } + authClientInflight.set(clientRateKey, current - 1); +} + +export function hasStoredToken(): boolean { + return readAuthFile() !== null; +} + +export function hasActiveToken(): boolean { + return Boolean(process.env.CHATCRYSTAL_API_TOKEN?.trim()) || hasStoredToken(); +} + +export async function setStoredToken(token: string): Promise { + const existing = readAuthFile(); + const next = await hashToken(token, existing?.salt); + writeAuthFile({ + ...next, + createdAt: existing?.createdAt ?? next.createdAt, + updatedAt: new Date().toISOString(), + }); +} + +export async function verifyToken(token: string | undefined, rateLimitKey?: string): Promise { + const normalizedToken = token?.trim(); + if (!normalizedToken) return false; + if (isAuthLocked(normalizedToken, rateLimitKey)) return false; + if (normalizedToken.length > TOKEN_MAX_LENGTH) { + recordAuthResult(false, normalizedToken, rateLimitKey); + return false; + } + + const envToken = process.env.CHATCRYSTAL_API_TOKEN?.trim(); + if (envToken) { + const provided = Buffer.from(normalizedToken); + const expected = Buffer.from(envToken); + const ok = provided.length === expected.length && timingSafeEqual(provided, expected); + recordAuthResult(ok, normalizedToken, rateLimitKey); + return ok; + } + + const authFile = readAuthFile(); + if (!authFile) return false; + + if (!acquireAuthSlot(rateLimitKey)) { + recordAuthResult(false, normalizedToken, rateLimitKey); + return false; + } + + try { + const derived = (await scrypt(normalizedToken, authFile.salt, 64)) as Buffer; + const expected = Buffer.from(authFile.hash, 'hex'); + const ok = derived.length === expected.length && timingSafeEqual(derived, expected); + recordAuthResult(ok, normalizedToken, rateLimitKey); + return ok; + } finally { + releaseAuthSlot(rateLimitKey); + } +} + +export function setupRequired(): boolean { + return !setupCompletionInProgress && !process.env.CHATCRYSTAL_API_TOKEN?.trim() && !hasStoredToken(); +} + +export function getOrCreateSetupCode(): string { + if (setupCompletionInProgress) { + throw new Error('Setup completion is already in progress'); + } + + const active = activeSetupState(); + if (active?.code) return active.code; + + const code = randomBytes(SETUP_CODE_BYTES).toString('hex'); + setupState.code = code; + setupState.badAttempts = 0; + setupState.lockedUntil = 0; + setupState.createdAt = Date.now(); + setupState.expiresAt = setupState.createdAt + SETUP_CODE_TTL_MS; + ensureDataDir(); + writeFileSync(runtimePaths.setupCodePath, `${code}\n`, { + encoding: 'utf-8', + mode: 0o600, + }); + writeSetupState(setupState); + return code; +} + +export async function completeSetup(setupCode: string, token: string): Promise { + if (setupCompletionInProgress || hasActiveToken()) { + return false; + } + + const now = Date.now(); + const state = readSetupState(); + if (state?.lockedUntil && state.lockedUntil > now) { + throw new Error('Too many setup attempts. Try again in 60 seconds.'); + } + + if (!state?.code) { + return false; + } + if (now > state.expiresAt) { + clearSetupStateFiles(); + throw new Error('Setup code expired. Restart setup to generate a new code.'); + } + + if (setupCode !== state.code) { + state.badAttempts++; + if (state.badAttempts >= SETUP_MAX_BAD_ATTEMPTS) { + state.lockedUntil = now + SETUP_LOCK_MS; + } + writeSetupState(state); + return false; + } + + validateToken(token); + setupCompletionInProgress = true; + clearSetupStateFiles(); + try { + if (beforeStoreTokenForTest) { + await beforeStoreTokenForTest(); + } + await setStoredToken(token); + clearSetupStateFiles(); + return true; + } finally { + setupCompletionInProgress = false; + } +} + +export async function rotateStoredToken(currentToken: string, nextToken: string): Promise { + if (process.env.CHATCRYSTAL_API_TOKEN?.trim()) { + throw new Error('Cannot rotate a stored token while CHATCRYSTAL_API_TOKEN environment token is active. Change the deployment environment token instead.'); + } + if (!(await verifyToken(currentToken))) return false; + await setStoredToken(nextToken); + return true; +} + +export async function resetStoredAuthForLocalAdmin(): Promise { + rmSync(runtimePaths.authPath, { force: true }); + setupCompletionInProgress = false; + beforeStoreTokenForTest = null; + authFailures.clear(); + authClientFailures.clear(); + authClientInflight.clear(); + clearSetupStateFiles(); +} + +export function expireSetupCodeForTest(): void { + const state = readSetupState(); + if (!state) return; + state.expiresAt = Date.now() - 1; + writeSetupState(state); +} + +export function setBeforeStoreTokenForTest(hook: (() => Promise) | null): void { + beforeStoreTokenForTest = hook; +} diff --git a/server/src/services/import.test.ts b/server/src/services/import.test.ts index 2399097..0ce709e 100644 --- a/server/src/services/import.test.ts +++ b/server/src/services/import.test.ts @@ -10,6 +10,7 @@ import type { } from '@chatcrystal/shared'; import type { Database } from 'sql.js'; import type { SourceAdapter } from '../parser/adapter.js'; +import { computeConversationContentHash } from './importPayload.js'; process.env.DATA_DIR = mkdtempSync(join(tmpdir(), 'chatcrystal-import-test-')); @@ -124,10 +125,12 @@ function testAdapter( name: string, metas: ConversationMeta[], parsedById: Map, + parserVersion?: string, ): SourceAdapter { return { name, displayName: `Test ${name}`, + parserVersion, detect: async () => ({ name, displayName: `Test ${name}`, @@ -151,6 +154,9 @@ function insertExistingConversation( status: string; fileSize?: number; fileMtime?: string; + sourceConversationId?: string | null; + contentHash?: string | null; + parserVersion?: string | null; experienceScore?: number | null; experienceGateReason?: string | null; experienceGateDetails?: string | null; @@ -158,15 +164,19 @@ function insertExistingConversation( ) { db.run( `INSERT INTO conversations ( - id, slug, source, project_dir, project_name, cwd, git_branch, + id, slug, source, source_conversation_id, content_hash, parser_version, + project_dir, project_name, cwd, git_branch, message_count, first_message_at, last_message_at, file_path, file_size, file_mtime, status, experience_score, experience_gate_reason, experience_gate_details - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ options.id, `${options.id}-old-slug`, options.source, + options.sourceConversationId ?? null, + options.contentHash ?? null, + options.parserVersion ?? null, 'C:/repo', 'repo', 'C:/repo', @@ -200,11 +210,16 @@ function insertExistingMessage(db: Database, conversationId: string, content: st ); } -function insertExistingNote(db: Database, noteId: number, conversationId: string) { +function insertExistingNote( + db: Database, + noteId: number, + conversationId: string, + options: { isEdited?: boolean; sourceType?: string } = {}, +) { db.run( `INSERT INTO notes ( - id, conversation_id, title, summary, key_conclusions, code_snippets - ) VALUES (?, ?, ?, ?, ?, ?)`, + id, conversation_id, title, summary, key_conclusions, code_snippets, is_edited, source_type + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [ noteId, conversationId, @@ -212,6 +227,8 @@ function insertExistingNote(db: Database, noteId: number, conversationId: string 'This note describes the old conversation content.', '[]', '[]', + options.isEdited ? 1 : 0, + options.sourceType ?? 'imported-conversation', ], ); } @@ -371,3 +388,128 @@ test('importAll resets ordinary changed conversations to imported and clears sta assert.equal(Number(notes[0].values[0][0]), 0); assert.deepEqual(cleanupTasks, [['note', '2', 'pending', 0, null]]); }); + +test('importAll skips changed files when parsed content hash is unchanged', async () => { + const { db, importAll, registerAdapter, appConfig } = await loadRuntime(); + resetDatabase(db); + + const source = 'test-content-hash-skip'; + const conversationId = 'conv-content-hash-skip'; + const parsed = parsedConversation(conversationId, source, [ + 'same content user message', + 'same content assistant message', + ]); + const contentHash = computeConversationContentHash(parsed); + + insertExistingConversation(db, { + id: conversationId, + source, + status: 'summarized', + fileSize: 10, + fileMtime: '2026-04-29T00:00:00Z', + sourceConversationId: conversationId, + contentHash, + parserVersion: `${source}@1`, + }); + insertExistingMessage(db, conversationId, 'same content user message'); + insertExistingNote(db, 3, conversationId, { isEdited: true }); + + registerAdapter( + testAdapter( + source, + [conversationMeta(conversationId, source, 99, '2026-04-29T00:09:00Z')], + new Map([[conversationId, parsed]]), + `${source}@2`, + ), + ); + appConfig.enabledSources = [source]; + + const progress = await importAll(); + + const conversation = db.exec( + `SELECT file_size, file_mtime, content_hash, parser_version, status + FROM conversations WHERE id = ?`, + [conversationId], + )[0].values[0]; + const noteCount = db.exec( + 'SELECT COUNT(*) FROM notes WHERE conversation_id = ?', + [conversationId], + )[0].values[0][0]; + const cleanupTasks = vectorCleanupRows(db); + + assert.equal(progress.imported, 0); + assert.equal(progress.skipped, 1); + assert.deepEqual(conversation, [ + 99, + '2026-04-29T00:09:00Z', + contentHash, + `${source}@2`, + 'summarized', + ]); + assert.equal(Number(noteCount), 1); + assert.deepEqual(cleanupTasks, []); +}); + +test('importAll preserves edited notes when replacing changed local conversations', async () => { + const { db, importAll, registerAdapter, appConfig, getUnsummarizedIds } = + await loadRuntime(); + resetDatabase(db); + + const source = 'test-edited-note-reimport'; + const conversationId = 'conv-edited-note-reimport'; + + insertExistingConversation(db, { + id: conversationId, + source, + status: 'summarized', + experienceScore: 64, + experienceGateReason: 'experience-threshold-met', + experienceGateDetails: '{"decision":"accept"}', + }); + insertExistingMessage(db, conversationId, 'old edited-note message'); + insertExistingNote(db, 4, conversationId, { isEdited: true }); + + registerAdapter( + testAdapter( + source, + [conversationMeta(conversationId, source, 40, '2026-04-29T00:04:00Z')], + new Map([ + [ + conversationId, + parsedConversation(conversationId, source, [ + 'new edited-note user message', + 'new edited-note assistant message', + ]), + ], + ]), + ), + ); + appConfig.enabledSources = [source]; + + const progress = await importAll(); + + const messages = db.exec( + `SELECT content FROM messages WHERE conversation_id = ? ORDER BY sort_order`, + [conversationId], + )[0].values.map((row) => String(row[0])); + const note = db.exec( + 'SELECT id, is_edited FROM notes WHERE conversation_id = ?', + [conversationId], + )[0].values[0]; + const conversation = db.exec( + `SELECT status, experience_score, experience_gate_reason, experience_gate_details + FROM conversations WHERE id = ?`, + [conversationId], + )[0].values[0]; + const cleanupTasks = vectorCleanupRows(db); + + assert.equal(progress.imported, 1); + assert.deepEqual(messages, [ + 'new edited-note user message', + 'new edited-note assistant message', + ]); + assert.deepEqual(note, [4, 1]); + assert.deepEqual(conversation, ['summarized', null, null, null]); + assert.deepEqual(cleanupTasks, []); + assert.equal(getUnsummarizedIds().includes(conversationId), false); +}); diff --git a/server/src/services/import.ts b/server/src/services/import.ts index 0757834..8060075 100644 --- a/server/src/services/import.ts +++ b/server/src/services/import.ts @@ -3,6 +3,7 @@ import { appConfig } from "../config.js"; import { getDatabase, saveDatabase } from "../db/index.js"; import { withTransaction } from "../db/transaction.js"; import { getAdapter, getAllAdapters } from "../parser/index.js"; +import { computeConversationContentHash } from "./importPayload.js"; import { enqueueNoteVectorCleanupTask } from "./vector-cleanup.js"; export interface ImportProgress { @@ -57,12 +58,13 @@ export async function importAll( try { // Check if already imported and unchanged const existing = db.exec( - "SELECT file_size, file_mtime FROM conversations WHERE id = ? AND source = ?", + "SELECT file_size, file_mtime, content_hash FROM conversations WHERE id = ? AND source = ?", [meta.id, meta.source], ); + const existingRow = existing[0]?.values[0]; - if (existing.length > 0 && existing[0].values.length > 0) { - const [existingSize, existingMtime] = existing[0].values[0]; + if (existingRow) { + const [existingSize, existingMtime] = existingRow; if ( Number(existingSize) === meta.fileSize && existingMtime === meta.fileMtime @@ -80,6 +82,8 @@ export async function importAll( } const parsed = await adapter.parse(meta); + const contentHash = computeConversationContentHash(parsed); + const parserVersion = adapter.parserVersion ?? `${meta.adapterName}@1`; // Skip conversations with fewer than 2 meaningful messages if (parsed.messages.length < 2) { @@ -87,11 +91,29 @@ export async function importAll( continue; } + const existingContentHash = + existingRow?.[2] === null || existingRow?.[2] === undefined + ? null + : String(existingRow[2]); + if (existingRow && existingContentHash === contentHash) { + withTransaction(db, () => { + updateImportedConversationMetadata( + db, + parsed, + meta, + contentHash, + parserVersion, + ); + }); + progress.skipped++; + continue; + } + withTransaction(db, () => { - if (existing.length > 0 && existing[0].values.length > 0) { - replaceImportedConversation(db, parsed, meta); + if (existingRow) { + replaceImportedConversation(db, parsed, meta, contentHash, parserVersion); } else { - insertConversation(db, parsed, meta); + insertConversation(db, parsed, meta, contentHash, parserVersion); insertMessages(db, parsed); } @@ -122,10 +144,97 @@ export async function importAll( return progress; } +function updateImportedConversationMetadata( + db: ReturnType, + parsed: ParsedConversation, + meta: ConversationMeta, + contentHash: string, + parserVersion: string, +) { + db.run( + `UPDATE conversations + SET slug = ?, + source_conversation_id = ?, + content_hash = ?, + parser_version = ?, + project_dir = ?, + project_name = ?, + cwd = ?, + git_branch = ?, + message_count = ?, + first_message_at = ?, + last_message_at = ?, + file_path = ?, + file_size = ?, + file_mtime = ?, + updated_at = datetime('now') + WHERE id = ? AND source = ?`, + [ + parsed.slug, + meta.id, + contentHash, + parserVersion, + parsed.projectDir, + parsed.projectName, + parsed.cwd, + parsed.gitBranch, + parsed.messages.length, + parsed.firstMessageAt, + parsed.lastMessageAt, + meta.filePath, + meta.fileSize, + meta.fileMtime, + parsed.id, + parsed.source, + ], + ); +} + +function deleteInvalidatedImportedNotes( + db: ReturnType, + conversationId: string, +) { + const oldNoteIds = + db + .exec( + `SELECT id FROM notes + WHERE conversation_id = ? + AND coalesce(is_edited, 0) = 0 + AND coalesce(source_type, 'imported-conversation') = 'imported-conversation'`, + [conversationId], + )[0] + ?.values.map((note) => Number(note[0])) ?? []; + + for (const noteId of oldNoteIds) { + enqueueNoteVectorCleanupTask(noteId, { db }); + } + + db.run( + `DELETE FROM notes + WHERE conversation_id = ? + AND coalesce(is_edited, 0) = 0 + AND coalesce(source_type, 'imported-conversation') = 'imported-conversation'`, + [conversationId], + ); +} + +function hasNoteForConversation( + db: ReturnType, + conversationId: string, +) { + const row = db.exec( + 'SELECT 1 FROM notes WHERE conversation_id = ? LIMIT 1', + [conversationId], + )[0]?.values[0]; + return Boolean(row); +} + function replaceImportedConversation( db: ReturnType, parsed: ParsedConversation, meta: ConversationMeta, + contentHash: string, + parserVersion: string, ) { const current = db.exec( `SELECT status, experience_score, experience_gate_reason, experience_gate_details @@ -143,20 +252,17 @@ function replaceImportedConversation( row?.[3] === null || row?.[3] === undefined ? null : String(row[3]); const keepUserRejectedGate = status === "filtered" && experienceGateReason === "user-rejected-note"; - const oldNoteIds = - db - .exec("SELECT id FROM notes WHERE conversation_id = ?", [parsed.id])[0] - ?.values.map((note) => Number(note[0])) ?? []; - for (const noteId of oldNoteIds) { - enqueueNoteVectorCleanupTask(noteId, { db }); - } - db.run("DELETE FROM notes WHERE conversation_id = ?", [parsed.id]); + deleteInvalidatedImportedNotes(db, parsed.id); + const hasPreservedNote = hasNoteForConversation(db, parsed.id); db.run("DELETE FROM messages WHERE conversation_id = ?", [parsed.id]); db.run( `UPDATE conversations SET slug = ?, source = ?, + source_conversation_id = ?, + content_hash = ?, + parser_version = ?, project_dir = ?, project_name = ?, cwd = ?, @@ -176,6 +282,9 @@ function replaceImportedConversation( [ parsed.slug, parsed.source, + meta.id, + contentHash, + parserVersion, parsed.projectDir, parsed.projectName, parsed.cwd, @@ -186,7 +295,7 @@ function replaceImportedConversation( meta.filePath, meta.fileSize, meta.fileMtime, - keepUserRejectedGate ? "filtered" : "imported", + keepUserRejectedGate ? "filtered" : hasPreservedNote ? "summarized" : "imported", keepUserRejectedGate ? experienceScore : null, keepUserRejectedGate ? experienceGateReason : null, keepUserRejectedGate ? experienceGateDetails : null, @@ -200,17 +309,23 @@ function insertConversation( db: ReturnType, parsed: ParsedConversation, meta: ConversationMeta, + contentHash: string, + parserVersion: string, ) { db.run( `INSERT INTO conversations ( - id, slug, source, project_dir, project_name, cwd, git_branch, + id, slug, source, source_conversation_id, content_hash, parser_version, + project_dir, project_name, cwd, git_branch, message_count, first_message_at, last_message_at, file_path, file_size, file_mtime, status - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'imported')`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'imported')`, [ parsed.id, parsed.slug, parsed.source, + meta.id, + contentHash, + parserVersion, parsed.projectDir, parsed.projectName, parsed.cwd, diff --git a/server/src/services/importPayload.test.ts b/server/src/services/importPayload.test.ts new file mode 100644 index 0000000..b8e111a --- /dev/null +++ b/server/src/services/importPayload.test.ts @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { ParsedConversation } from '@chatcrystal/shared'; +import { + buildRemoteImportItem, + computeConversationContentHash, + namespaceConversationId, + normalizeParsedConversationForRemote, +} from './importPayload.js'; + +function parsedConversation(): ParsedConversation { + return { + id: 'session-1', + slug: 'session-1', + source: 'codex', + projectDir: 'C:/repo', + projectName: 'repo', + cwd: 'C:/repo', + gitBranch: 'main', + firstMessageAt: '2026-05-20T00:00:00Z', + lastMessageAt: '2026-05-20T00:01:00Z', + messages: [ + { + id: 'm1', + parentUuid: null, + type: 'user', + role: 'user', + content: 'hello', + hasToolUse: false, + hasCode: false, + thinking: null, + timestamp: '2026-05-20T00:00:00Z', + }, + { + id: 'm2', + parentUuid: 'm1', + type: 'assistant', + role: 'assistant', + content: 'world', + hasToolUse: false, + hasCode: false, + thinking: null, + timestamp: '2026-05-20T00:01:00Z', + }, + ], + }; +} + +test('namespaceConversationId prefixes source ids once', () => { + assert.equal(namespaceConversationId('codex', 'session-1'), 'codex:session-1'); + assert.equal(namespaceConversationId('codex', 'codex:session-1'), 'codex:session-1'); +}); + +test('normalizeParsedConversationForRemote namespaces conversation and message ids', () => { + const normalized = normalizeParsedConversationForRemote('codex', 'session-1', parsedConversation()); + + assert.equal(normalized.id, 'codex:session-1'); + assert.equal(normalized.source, 'codex'); + assert.deepEqual( + normalized.messages.map((message) => [message.id, message.parentUuid]), + [ + ['codex:session-1:m1', null], + ['codex:session-1:m2', 'codex:session-1:m1'], + ], + ); +}); + +test('computeConversationContentHash is stable and content based', () => { + const first = normalizeParsedConversationForRemote('codex', 'session-1', parsedConversation()); + const second = normalizeParsedConversationForRemote('codex', 'session-1', { + ...parsedConversation(), + projectDir: 'D:/different-path', + firstMessageAt: '2026-05-21T00:00:00Z', + lastMessageAt: '2026-05-21T00:01:00Z', + messages: parsedConversation().messages.map((message) => ({ + ...message, + timestamp: '2026-05-21T00:00:00Z', + })), + }); + const changed = normalizeParsedConversationForRemote('codex', 'session-1', { + ...parsedConversation(), + messages: [ + ...parsedConversation().messages.slice(0, 1), + { ...parsedConversation().messages[1], content: 'changed' }, + ], + }); + + assert.match(computeConversationContentHash(first), /^[a-f0-9]{64}$/); + assert.equal(computeConversationContentHash(first), computeConversationContentHash(second)); + assert.notEqual(computeConversationContentHash(first), computeConversationContentHash(changed)); +}); + +test('computeConversationContentHash ignores unstable parser-generated message ids', () => { + const first = normalizeParsedConversationForRemote('codex', 'session-1', parsedConversation()); + const second = normalizeParsedConversationForRemote('codex', 'session-1', { + ...parsedConversation(), + messages: parsedConversation().messages.map((message, index) => ({ + ...message, + id: `random-${index}`, + parentUuid: index === 0 ? null : 'random-0', + })), + }); + + assert.equal(computeConversationContentHash(first), computeConversationContentHash(second)); +}); + +test('buildRemoteImportItem includes normalized parsed data and canonical hash', () => { + const item = buildRemoteImportItem( + 'codex', + { + id: 'session-1', + source: 'codex', + filePath: 'C:/fixtures/session-1.jsonl', + fileSize: 10, + fileMtime: '2026-05-20T00:00:00Z', + projectDir: 'C:/repo', + }, + parsedConversation(), + 'codex@test', + ); + + assert.equal(item.source, 'codex'); + assert.equal(item.sourceConversationId, 'session-1'); + assert.equal(item.conversationId, 'codex:session-1'); + assert.equal(item.parserVersion, 'codex@test'); + assert.equal(item.contentHash, computeConversationContentHash(item.parsed)); +}); diff --git a/server/src/services/importPayload.ts b/server/src/services/importPayload.ts new file mode 100644 index 0000000..47f7fdc --- /dev/null +++ b/server/src/services/importPayload.ts @@ -0,0 +1,89 @@ +import { createHash } from 'node:crypto'; +import type { + ChatCrystalSource, + ConversationMeta, + ParsedConversation, + RemoteImportItem, +} from '@chatcrystal/shared'; + +export const SUPPORTED_IMPORT_SOURCES: ChatCrystalSource[] = [ + 'claude-code', + 'codex', + 'cursor', + 'trae', + 'copilot', +]; + +export function isChatCrystalSource(source: string): source is ChatCrystalSource { + return SUPPORTED_IMPORT_SOURCES.includes(source as ChatCrystalSource); +} + +export function namespaceConversationId(source: ChatCrystalSource, sourceConversationId: string): string { + return sourceConversationId.startsWith(`${source}:`) + ? sourceConversationId + : `${source}:${sourceConversationId}`; +} + +export function normalizeParsedConversationForRemote( + source: ChatCrystalSource, + sourceConversationId: string, + parsed: ParsedConversation, +): ParsedConversation { + const conversationId = namespaceConversationId(source, sourceConversationId); + const idMap = new Map(parsed.messages.map((message) => [ + message.id, + `${conversationId}:${message.id}`, + ])); + + return { + ...parsed, + id: conversationId, + source, + messages: parsed.messages.map((message) => ({ + ...message, + id: idMap.get(message.id) ?? `${conversationId}:${message.id}`, + parentUuid: message.parentUuid ? (idMap.get(message.parentUuid) ?? `${conversationId}:${message.parentUuid}`) : null, + })), + }; +} + +export function computeConversationContentHash(parsed: ParsedConversation): string { + const canonical = { + source: parsed.source, + messages: parsed.messages.map((message, index) => ({ + index, + type: message.type, + role: message.role, + content: message.content, + hasToolUse: message.hasToolUse, + hasCode: message.hasCode, + thinking: message.thinking, + })), + }; + + return createHash('sha256').update(JSON.stringify(canonical)).digest('hex'); +} + +export function buildRemoteImportItem( + source: ChatCrystalSource, + meta: ConversationMeta, + parsed: ParsedConversation, + parserVersion = `${source}@1`, +): RemoteImportItem { + const sourceConversationId = meta.id; + const normalized = normalizeParsedConversationForRemote(source, sourceConversationId, parsed); + + return { + source, + sourceConversationId, + conversationId: normalized.id, + contentHash: computeConversationContentHash(normalized), + parserVersion, + meta: { + ...meta, + source, + id: sourceConversationId, + }, + parsed: normalized, + }; +} diff --git a/server/src/services/ingest.test.ts b/server/src/services/ingest.test.ts new file mode 100644 index 0000000..28cd6ea --- /dev/null +++ b/server/src/services/ingest.test.ts @@ -0,0 +1,230 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import type { Database } from 'sql.js'; +import type { RemoteImportItem } from '@chatcrystal/shared'; + +process.env.DATA_DIR = mkdtempSync(join(tmpdir(), 'chatcrystal-ingest-test-')); + +const [{ initDatabase }, { buildRemoteImportItem }, ingest] = await Promise.all([ + import('../db/index.js'), + import('./importPayload.js'), + import('./ingest.js'), +]); + +const db = await initDatabase(); + +function resetDatabase(database: Database) { + database.exec(` + PRAGMA foreign_keys = ON; + DELETE FROM experience_reviews; + DELETE FROM note_tags; + DELETE FROM embeddings; + DELETE FROM note_relations; + DELETE FROM notes; + DELETE FROM messages; + DELETE FROM conversations; + DELETE FROM import_log; + DELETE FROM vector_cleanup_tasks; + `); +} + +function remoteItem(id: string, assistantContent = 'world'): RemoteImportItem { + return buildRemoteImportItem( + 'codex', + { + id, + source: 'codex', + filePath: `C:/fixtures/${id}.jsonl`, + fileSize: 100, + fileMtime: '2026-05-20T00:00:00Z', + projectDir: 'C:/repo', + }, + { + id, + slug: id, + source: 'codex', + projectDir: 'C:/repo', + projectName: 'repo', + cwd: 'C:/repo', + gitBranch: 'main', + firstMessageAt: '2026-05-20T00:00:00Z', + lastMessageAt: '2026-05-20T00:01:00Z', + messages: [ + { + id: 'm1', + parentUuid: null, + type: 'user', + role: 'user', + content: 'hello', + hasToolUse: false, + hasCode: false, + thinking: null, + timestamp: '2026-05-20T00:00:00Z', + }, + { + id: 'm2', + parentUuid: 'm1', + type: 'assistant', + role: 'assistant', + content: assistantContent, + hasToolUse: false, + hasCode: false, + thinking: null, + timestamp: '2026-05-20T00:01:00Z', + }, + ], + }, + 'codex@test', + ); +} + +test('ingestRemoteImport inserts namespaced conversations and messages', () => { + resetDatabase(db); + + const result = ingest.ingestRemoteImport({ version: 1, items: [remoteItem('session-1')] }); + + const conversation = db.exec( + `SELECT id, source, source_conversation_id, content_hash, parser_version, file_path, file_size, file_mtime + FROM conversations WHERE id = 'codex:session-1'`, + )[0].values[0]; + const messages = db.exec( + `SELECT id, parent_uuid FROM messages WHERE conversation_id = 'codex:session-1' ORDER BY sort_order`, + )[0].values; + + assert.equal(result.imported, 1); + assert.deepEqual(conversation.slice(0, 3), ['codex:session-1', 'codex', 'session-1']); + assert.match(String(conversation[3]), /^[a-f0-9]{64}$/); + assert.equal(conversation[4], 'codex@test'); + assert.deepEqual(messages, [ + ['codex:session-1:m1', null], + ['codex:session-1:m2', 'codex:session-1:m1'], + ]); +}); + +test('ingestRemoteImport skips identical content hashes even when file metadata changes', () => { + resetDatabase(db); + const first = remoteItem('session-1'); + ingest.ingestRemoteImport({ version: 1, items: [first] }); + const second = { + ...first, + parserVersion: 'codex@test2', + parsed: { + ...first.parsed, + projectName: 'repo-renamed', + cwd: 'C:/repo-renamed', + gitBranch: 'dev', + }, + meta: { + ...first.meta, + filePath: 'C:/new-path/session-1.jsonl', + fileSize: 999, + fileMtime: '2026-05-21T00:00:00Z', + }, + }; + + const result = ingest.ingestRemoteImport({ version: 1, items: [second] }); + + assert.equal(result.skipped, 1); + assert.equal(result.items[0].status, 'skipped'); + const conversation = db.exec( + `SELECT file_path, file_size, file_mtime, parser_version, project_name, cwd, git_branch + FROM conversations WHERE id = 'codex:session-1'`, + )[0].values[0]; + assert.deepEqual(conversation, [ + 'C:/new-path/session-1.jsonl', + 999, + '2026-05-21T00:00:00Z', + 'codex@test2', + 'repo-renamed', + 'C:/repo-renamed', + 'dev', + ]); +}); + +test('ingestRemoteImport upgrades legacy local rows instead of duplicating them', () => { + resetDatabase(db); + db.run( + `INSERT INTO conversations ( + id, source, project_dir, project_name, message_count, + first_message_at, last_message_at, file_path, file_size, file_mtime, status + ) VALUES ( + 'session-1', 'codex', 'C:/repo', 'repo', 2, + '2026-05-20T00:00:00Z', '2026-05-20T00:01:00Z', + 'C:/fixtures/session-1.jsonl', 10, '2026-05-20T00:00:00Z', 'imported' + )`, + ); + + const first = ingest.ingestRemoteImport({ version: 1, items: [remoteItem('session-1')] }); + const second = ingest.ingestRemoteImport({ version: 1, items: [remoteItem('session-1')] }); + const conversations = db.exec( + `SELECT id, source_conversation_id, parser_version FROM conversations ORDER BY id`, + )[0]?.values ?? []; + const messageConversationIds = db.exec( + `SELECT DISTINCT conversation_id FROM messages ORDER BY conversation_id`, + )[0]?.values ?? []; + + assert.equal(first.replaced, 1); + assert.equal(second.skipped, 1); + assert.deepEqual(conversations, [['session-1', 'session-1', 'codex@test']]); + assert.deepEqual(messageConversationIds, [['session-1']]); +}); + +test('ingestRemoteImport replaces changed content and deletes unedited imported notes', () => { + resetDatabase(db); + ingest.ingestRemoteImport({ version: 1, items: [remoteItem('session-1')] }); + db.run( + `INSERT INTO notes (id, conversation_id, title, summary, key_conclusions, code_snippets, is_edited, source_type) + VALUES (1, 'codex:session-1', 'generated', 'old', '[]', '[]', 0, 'imported-conversation')`, + ); + + const result = ingest.ingestRemoteImport({ version: 1, items: [remoteItem('session-1', 'changed')] }); + const notes = db.exec( + `SELECT id, title FROM notes WHERE conversation_id = 'codex:session-1' ORDER BY id`, + )[0]?.values ?? []; + const cleanup = db.exec( + `SELECT target_type, target_id FROM vector_cleanup_tasks ORDER BY id`, + )[0]?.values ?? []; + + assert.equal(result.replaced, 1); + assert.deepEqual(notes, []); + assert.deepEqual(cleanup, [['note', '1']]); +}); + +test('ingestRemoteImport preserves edited imported notes during replacement', () => { + resetDatabase(db); + ingest.ingestRemoteImport({ version: 1, items: [remoteItem('session-1')] }); + db.run( + `INSERT INTO notes (id, conversation_id, title, summary, key_conclusions, code_snippets, is_edited, source_type) + VALUES (2, 'codex:session-1', 'edited', 'keep', '[]', '[]', 1, 'imported-conversation')`, + ); + + const result = ingest.ingestRemoteImport({ version: 1, items: [remoteItem('session-1', 'changed')] }); + const notes = db.exec( + `SELECT id, title FROM notes WHERE conversation_id = 'codex:session-1' ORDER BY id`, + )[0]?.values ?? []; + const status = db.exec( + `SELECT status FROM conversations WHERE id = 'codex:session-1'`, + )[0].values[0][0]; + const cleanup = db.exec( + `SELECT target_type, target_id FROM vector_cleanup_tasks ORDER BY id`, + )[0]?.values ?? []; + + assert.equal(result.replaced, 1); + assert.deepEqual(notes, [[2, 'edited']]); + assert.equal(status, 'summarized'); + assert.deepEqual(cleanup, []); +}); + +test('ingestRemoteImport rejects tampered content hashes per item', () => { + resetDatabase(db); + const item = { ...remoteItem('session-1'), contentHash: '0'.repeat(64) }; + + const result = ingest.ingestRemoteImport({ version: 1, items: [item] }); + + assert.equal(result.errors, 1); + assert.equal(result.items[0].status, 'error'); + assert.match(result.items[0].error ?? '', /content hash/i); +}); diff --git a/server/src/services/ingest.ts b/server/src/services/ingest.ts new file mode 100644 index 0000000..b9058b7 --- /dev/null +++ b/server/src/services/ingest.ts @@ -0,0 +1,385 @@ +import type { + ChatCrystalSource, + ParsedConversation, + RemoteImportItem, + RemoteImportItemResult, + RemoteImportRequest, + RemoteImportResponse, +} from '@chatcrystal/shared'; +import type { Database } from 'sql.js'; +import { getDatabase, saveDatabase } from '../db/index.js'; +import { withTransaction } from '../db/transaction.js'; +import { + computeConversationContentHash, + isChatCrystalSource, + namespaceConversationId, +} from './importPayload.js'; +import { enqueueNoteVectorCleanupTask } from './vector-cleanup.js'; + +type ExistingConversation = { + id: string; + contentHash: string | null; + status: string | null; + experienceScore: number | null; + experienceGateReason: string | null; + experienceGateDetails: string | null; +}; + +function validateItem(item: RemoteImportItem): asserts item is RemoteImportItem & { source: ChatCrystalSource } { + if (!isChatCrystalSource(item.source)) { + throw new Error(`Unsupported source: ${item.source}`); + } + if (!item.sourceConversationId?.trim()) { + throw new Error('sourceConversationId is required'); + } + const expectedConversationId = namespaceConversationId(item.source, item.sourceConversationId); + if (item.conversationId !== expectedConversationId || item.parsed.id !== expectedConversationId) { + throw new Error('Remote import item must use a server-verifiable namespaced conversation id'); + } + if (item.parsed.source !== item.source) { + throw new Error('Remote import item source does not match parsed conversation source'); + } + if (!/^[a-f0-9]{64}$/.test(item.contentHash)) { + throw new Error('Remote import item content hash must be a 64-character sha256 hex string'); + } + if (item.parsed.messages.length < 2) { + throw new Error('Remote import item must include at least two messages'); + } + for (const message of item.parsed.messages) { + if (!message.id.startsWith(`${expectedConversationId}:`)) { + throw new Error('Remote import item message ids must be namespaced by conversation id'); + } + } + + const expectedHash = computeConversationContentHash(item.parsed); + if (item.contentHash !== expectedHash) { + throw new Error('Remote import item content hash does not match parsed content'); + } +} + +function readExistingConversation( + db: Database, + source: string, + sourceConversationId: string, +): ExistingConversation | null { + const namespacedConversationId = namespaceConversationId(source as ChatCrystalSource, sourceConversationId); + const row = db.exec( + `SELECT id, content_hash, status, experience_score, experience_gate_reason, experience_gate_details + FROM conversations + WHERE source = ? + AND ( + source_conversation_id = ? + OR id = ? + OR id = ? + ) + ORDER BY CASE + WHEN source_conversation_id = ? THEN 0 + WHEN id = ? THEN 1 + ELSE 2 + END + LIMIT 1`, + [ + source, + sourceConversationId, + namespacedConversationId, + sourceConversationId, + sourceConversationId, + namespacedConversationId, + ], + )[0]?.values[0]; + if (!row) return null; + + return { + id: String(row[0]), + contentHash: row[1] === null || row[1] === undefined ? null : String(row[1]), + status: row[2] === null || row[2] === undefined ? null : String(row[2]), + experienceScore: row[3] === null || row[3] === undefined ? null : Number(row[3]), + experienceGateReason: row[4] === null || row[4] === undefined ? null : String(row[4]), + experienceGateDetails: row[5] === null || row[5] === undefined ? null : String(row[5]), + }; +} + +function parsedForStorage(parsed: ParsedConversation, conversationId: string): ParsedConversation { + if (parsed.id === conversationId) return parsed; + + const oldPrefix = `${parsed.id}:`; + const newPrefix = `${conversationId}:`; + const idMap = new Map(parsed.messages.map((message, index) => { + const nextId = message.id.startsWith(oldPrefix) + ? `${newPrefix}${message.id.slice(oldPrefix.length)}` + : `${newPrefix}${index}`; + return [message.id, nextId]; + })); + + return { + ...parsed, + id: conversationId, + messages: parsed.messages.map((message) => ({ + ...message, + id: idMap.get(message.id) ?? `${newPrefix}${message.id}`, + parentUuid: message.parentUuid ? (idMap.get(message.parentUuid) ?? null) : null, + })), + }; +} + +function deleteInvalidatedImportedNotes(db: Database, conversationId: string): void { + const noteIds = + db.exec( + `SELECT id FROM notes + WHERE conversation_id = ? + AND coalesce(is_edited, 0) = 0 + AND coalesce(source_type, 'imported-conversation') = 'imported-conversation'`, + [conversationId], + )[0]?.values.map((row) => Number(row[0])) ?? []; + + for (const noteId of noteIds) { + enqueueNoteVectorCleanupTask(noteId, { db }); + } + + db.run( + `DELETE FROM notes + WHERE conversation_id = ? + AND coalesce(is_edited, 0) = 0 + AND coalesce(source_type, 'imported-conversation') = 'imported-conversation'`, + [conversationId], + ); +} + +function hasNoteForConversation(db: Database, conversationId: string): boolean { + const row = db.exec( + 'SELECT 1 FROM notes WHERE conversation_id = ? LIMIT 1', + [conversationId], + )[0]?.values[0]; + return Boolean(row); +} + +function updateConversationImportMetadata( + db: Database, + item: RemoteImportItem, + conversationId: string, +): void { + const parsed = parsedForStorage(item.parsed, conversationId); + db.run( + `UPDATE conversations + SET slug = ?, + source_conversation_id = ?, + content_hash = ?, + parser_version = ?, + project_dir = ?, + project_name = ?, + cwd = ?, + git_branch = ?, + message_count = ?, + first_message_at = ?, + last_message_at = ?, + file_path = ?, + file_size = ?, + file_mtime = ?, + updated_at = datetime('now') + WHERE id = ?`, + [ + parsed.slug, + item.sourceConversationId, + item.contentHash, + item.parserVersion, + parsed.projectDir, + parsed.projectName, + parsed.cwd, + parsed.gitBranch, + parsed.messages.length, + parsed.firstMessageAt, + parsed.lastMessageAt, + item.meta.filePath, + item.meta.fileSize, + item.meta.fileMtime, + conversationId, + ], + ); +} + +function insertMessages(db: Database, parsed: ParsedConversation): void { + const stmt = db.prepare( + `INSERT OR REPLACE INTO messages ( + id, conversation_id, parent_uuid, type, role, + content, has_tool_use, has_code, thinking, timestamp, sort_order + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + + for (let i = 0; i < parsed.messages.length; i++) { + const message = parsed.messages[i]; + stmt.run([ + message.id, + parsed.id, + message.parentUuid, + message.type, + message.role, + message.content, + message.hasToolUse ? 1 : 0, + message.hasCode ? 1 : 0, + message.thinking, + message.timestamp, + i, + ]); + } + + stmt.free(); +} + +function insertConversation(db: Database, item: RemoteImportItem): void { + const parsed = parsedForStorage(item.parsed, item.parsed.id); + db.run( + `INSERT INTO conversations ( + id, slug, source, source_conversation_id, content_hash, parser_version, + project_dir, project_name, cwd, git_branch, + message_count, first_message_at, last_message_at, + file_path, file_size, file_mtime, status + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'imported')`, + [ + parsed.id, + parsed.slug, + item.source, + item.sourceConversationId, + item.contentHash, + item.parserVersion, + parsed.projectDir, + parsed.projectName, + parsed.cwd, + parsed.gitBranch, + parsed.messages.length, + parsed.firstMessageAt, + parsed.lastMessageAt, + item.meta.filePath, + item.meta.fileSize, + item.meta.fileMtime, + ], + ); + insertMessages(db, parsed); +} + +function replaceConversation(db: Database, item: RemoteImportItem, existing: ExistingConversation): void { + const parsed = parsedForStorage(item.parsed, existing.id); + const keepUserRejectedGate = + existing.status === 'filtered' && existing.experienceGateReason === 'user-rejected-note'; + + deleteInvalidatedImportedNotes(db, existing.id); + const hasPreservedNote = hasNoteForConversation(db, existing.id); + db.run('DELETE FROM messages WHERE conversation_id = ?', [existing.id]); + db.run( + `UPDATE conversations + SET slug = ?, + source = ?, + source_conversation_id = ?, + content_hash = ?, + parser_version = ?, + project_dir = ?, + project_name = ?, + cwd = ?, + git_branch = ?, + message_count = ?, + first_message_at = ?, + last_message_at = ?, + file_path = ?, + file_size = ?, + file_mtime = ?, + status = ?, + experience_score = ?, + experience_gate_reason = ?, + experience_gate_details = ?, + updated_at = datetime('now') + WHERE id = ?`, + [ + parsed.slug, + item.source, + item.sourceConversationId, + item.contentHash, + item.parserVersion, + parsed.projectDir, + parsed.projectName, + parsed.cwd, + parsed.gitBranch, + parsed.messages.length, + parsed.firstMessageAt, + parsed.lastMessageAt, + item.meta.filePath, + item.meta.fileSize, + item.meta.fileMtime, + keepUserRejectedGate ? 'filtered' : hasPreservedNote ? 'summarized' : 'imported', + keepUserRejectedGate ? existing.experienceScore : null, + keepUserRejectedGate ? existing.experienceGateReason : null, + keepUserRejectedGate ? existing.experienceGateDetails : null, + existing.id, + ], + ); + insertMessages(db, parsed); +} + +function ingestOne(db: Database, item: RemoteImportItem): RemoteImportItemResult { + validateItem(item); + + const existing = readExistingConversation(db, item.source, item.sourceConversationId); + if (existing?.contentHash === item.contentHash) { + withTransaction(db, () => { + updateConversationImportMetadata(db, item, existing.id); + }); + return { + source: item.source, + sourceConversationId: item.sourceConversationId, + conversationId: existing.id, + status: 'skipped', + }; + } + + withTransaction(db, () => { + if (existing) { + replaceConversation(db, item, existing); + } else { + insertConversation(db, item); + } + db.run( + `INSERT INTO import_log (file_path, status, message) VALUES (?, 'success', ?)`, + [item.meta.filePath, `${existing ? 'Replaced' : 'Imported'} ${item.parsed.messages.length} remote messages`], + ); + }); + + return { + source: item.source, + sourceConversationId: item.sourceConversationId, + conversationId: existing?.id ?? item.conversationId, + status: existing ? 'replaced' : 'imported', + }; +} + +export function ingestRemoteImport(request: RemoteImportRequest): RemoteImportResponse { + if (request.version !== 1) { + throw new Error('Unsupported remote import payload version'); + } + + const db = getDatabase(); + const items: RemoteImportItemResult[] = []; + + for (const item of request.items) { + try { + items.push(ingestOne(db, item)); + } catch (err) { + const source = isChatCrystalSource(item.source) ? item.source : 'codex'; + items.push({ + source, + sourceConversationId: item.sourceConversationId ?? '', + conversationId: item.conversationId ?? '', + status: 'error', + error: err instanceof Error ? err.message : 'Remote import item failed', + }); + } + } + + saveDatabase(); + + return { + total: request.items.length, + imported: items.filter((item) => item.status === 'imported').length, + replaced: items.filter((item) => item.status === 'replaced').length, + skipped: items.filter((item) => item.status === 'skipped').length, + errors: items.filter((item) => item.status === 'error').length, + items, + }; +} diff --git a/server/src/services/remoteImport.test.ts b/server/src/services/remoteImport.test.ts new file mode 100644 index 0000000..ab47172 --- /dev/null +++ b/server/src/services/remoteImport.test.ts @@ -0,0 +1,189 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { + ChatCrystalSource, + ConversationMeta, + ParsedConversation, + RemoteImportItem, +} from '@chatcrystal/shared'; +import type { SourceAdapter } from '../parser/adapter.js'; +import { appConfig } from '../config.js'; +import { registerAdapter } from '../parser/index.js'; +import { + chunkRemoteImportItems, + collectRemoteImportItems, + splitUploadableRemoteImportItems, + validateRemoteImportSource, +} from './remoteImport.js'; + +function item(id: string): RemoteImportItem { + return { + source: 'codex', + sourceConversationId: id, + conversationId: `codex:${id}`, + contentHash: 'a'.repeat(64), + parserVersion: 'codex@1', + meta: { + id, + source: 'codex', + filePath: `C:/fixtures/${id}.jsonl`, + fileSize: 100, + fileMtime: '2026-05-20T00:00:00Z', + projectDir: 'C:/repo', + }, + parsed: { + id: `codex:${id}`, + slug: id, + source: 'codex', + projectDir: 'C:/repo', + projectName: 'repo', + cwd: 'C:/repo', + gitBranch: 'main', + firstMessageAt: '2026-05-20T00:00:00Z', + lastMessageAt: '2026-05-20T00:01:00Z', + messages: [], + }, + }; +} + +function parsedConversation(source: ChatCrystalSource, id: string): ParsedConversation { + return { + id, + slug: id, + source, + projectDir: '', + projectName: '', + cwd: null, + gitBranch: null, + firstMessageAt: '2026-05-20T00:00:00Z', + lastMessageAt: '2026-05-20T00:01:00Z', + messages: [ + { + id: 'm1', + parentUuid: null, + type: 'user', + role: 'user', + content: `hello from ${source}`, + hasToolUse: false, + hasCode: false, + thinking: null, + timestamp: '2026-05-20T00:00:00Z', + }, + { + id: 'm2', + parentUuid: 'm1', + type: 'assistant', + role: 'assistant', + content: `world from ${source}`, + hasToolUse: false, + hasCode: false, + thinking: null, + timestamp: '2026-05-20T00:01:00Z', + }, + ], + }; +} + +function sourceAdapter(source: ChatCrystalSource): SourceAdapter { + const meta: ConversationMeta = { + id: `${source}-session`, + source, + filePath: `C:/fixtures/${source}.jsonl`, + fileSize: 100, + fileMtime: '2026-05-20T00:00:00Z', + projectDir: '', + }; + + return { + name: source, + displayName: source, + detect: async () => ({ + name: source, + displayName: source, + dataDir: 'C:/fixtures', + conversationCount: 1, + }), + scan: async () => [meta], + parse: async () => parsedConversation(source, meta.id), + parserVersion: 'test', + } as SourceAdapter; +} + +test('chunkRemoteImportItems limits batches by item count', () => { + const chunks = chunkRemoteImportItems(Array.from({ length: 26 }, (_, index) => item(`session-${index}`))); + assert.equal(chunks.length, 2); + assert.equal(chunks[0].length, 25); + assert.equal(chunks[1].length, 1); +}); + +test('chunkRemoteImportItems also limits batches by serialized byte size', () => { + const chunks = chunkRemoteImportItems([ + item('small-1'), + item('small-2'), + item('small-3'), + ], 25, Buffer.byteLength(JSON.stringify({ version: 1, items: [item('small-1'), item('small-2')] }))); + + assert.equal(chunks.length, 2); + assert.equal(chunks[0].length, 2); + assert.equal(chunks[1].length, 1); +}); + + +test('validateRemoteImportSource rejects unsupported sources', () => { + for (const source of ['claude-code', 'codex', 'cursor', 'trae', 'copilot']) { + assert.doesNotThrow(() => validateRemoteImportSource(source)); + } + assert.throws( + () => validateRemoteImportSource('unknown-source'), + /Unsupported source/, + ); +}); + +test('collectRemoteImportItems covers all five supported source adapters', async () => { + const sources: ChatCrystalSource[] = ['claude-code', 'codex', 'cursor', 'trae', 'copilot']; + for (const source of sources) { + registerAdapter(sourceAdapter(source)); + } + appConfig.enabledSources = sources; + + const result = await collectRemoteImportItems(); + + assert.equal(result.errors, 0); + assert.deepEqual( + new Set(result.items.map((entry) => entry.source)), + new Set(sources), + ); +}); + +test('splitUploadableRemoteImportItems isolates oversized conversations', () => { + const small = item('small'); + const large = item('large'); + large.parsed.messages = [ + { + id: 'large:m1', + parentUuid: null, + type: 'user', + role: 'user', + content: 'x'.repeat(9 * 1024 * 1024), + hasToolUse: false, + hasCode: false, + thinking: null, + timestamp: '2026-05-20T00:00:00Z', + }, + { + id: 'large:m2', + parentUuid: 'large:m1', + type: 'assistant', + role: 'assistant', + content: 'ok', + hasToolUse: false, + hasCode: false, + thinking: null, + timestamp: '2026-05-20T00:01:00Z', + }, + ]; + + const result = splitUploadableRemoteImportItems([small, large]); + assert.deepEqual(result.uploadableItems.map((entry) => entry.sourceConversationId), ['small']); + assert.deepEqual(result.oversizedItems.map((entry) => entry.sourceConversationId), ['large']); +}); diff --git a/server/src/services/remoteImport.ts b/server/src/services/remoteImport.ts new file mode 100644 index 0000000..0491b66 --- /dev/null +++ b/server/src/services/remoteImport.ts @@ -0,0 +1,164 @@ +import type { + ChatCrystalSource, + RemoteImportItem, + RemoteImportRequest, + RemoteImportResponse, +} from '@chatcrystal/shared'; +import { appConfig } from '../config.js'; +import { getAdapter, getAllAdapters } from '../parser/index.js'; +import type { SourceAdapter } from '../parser/adapter.js'; +import { + SUPPORTED_IMPORT_SOURCES, + buildRemoteImportItem, + isChatCrystalSource, +} from './importPayload.js'; + +export type RemoteImportCollectResult = { + items: RemoteImportItem[]; + errors: number; +}; + +export type RemoteImportProgress = { + scanned: number; + uploaded: number; + imported: number; + replaced: number; + skipped: number; + errors: number; +}; + +export type RemoteImportResult = RemoteImportProgress & { + localErrors: number; +}; + +type RemoteImportClient = { + ingestConversations(request: RemoteImportRequest): Promise; +}; + +const REMOTE_IMPORT_CHUNK_SIZE = 25; +const MAX_REMOTE_IMPORT_ITEM_BYTES = 8 * 1024 * 1024; +const MAX_REMOTE_IMPORT_BATCH_BYTES = 20 * 1024 * 1024; + +export function validateRemoteImportSource(source: string): ChatCrystalSource { + if (!isChatCrystalSource(source)) { + throw new Error(`Unsupported source: ${source}. Expected one of: ${SUPPORTED_IMPORT_SOURCES.join(', ')}`); + } + return source; +} + +function parserVersionFor(adapter: SourceAdapter): string { + return adapter.parserVersion ?? `${adapter.name}@1`; +} + +export function chunkRemoteImportItems( + items: RemoteImportItem[], + chunkSize = REMOTE_IMPORT_CHUNK_SIZE, + maxBatchBytes = MAX_REMOTE_IMPORT_BATCH_BYTES, +): RemoteImportItem[][] { + const chunks: RemoteImportItem[][] = []; + let current: RemoteImportItem[] = []; + + for (const item of items) { + const next = [...current, item]; + const nextBytes = Buffer.byteLength(JSON.stringify({ version: 1, items: next }), 'utf-8'); + if (current.length > 0 && (current.length >= chunkSize || nextBytes > maxBatchBytes)) { + chunks.push(current); + current = [item]; + } else { + current = next; + } + } + + if (current.length > 0) { + chunks.push(current); + } + return chunks; +} + +export function splitUploadableRemoteImportItems(items: RemoteImportItem[]): { + uploadableItems: RemoteImportItem[]; + oversizedItems: RemoteImportItem[]; +} { + const uploadableItems: RemoteImportItem[] = []; + const oversizedItems: RemoteImportItem[] = []; + + for (const item of items) { + const bytes = Buffer.byteLength(JSON.stringify(item), 'utf-8'); + if (bytes > MAX_REMOTE_IMPORT_ITEM_BYTES) { + oversizedItems.push(item); + } else { + uploadableItems.push(item); + } + } + + return { uploadableItems, oversizedItems }; +} + +export async function collectRemoteImportItems(options: { source?: string } = {}): Promise { + const sourceFilter = options.source ? validateRemoteImportSource(options.source) : undefined; + const adapters = sourceFilter + ? [getAdapter(sourceFilter)].filter(Boolean) as SourceAdapter[] + : getAllAdapters().filter((adapter) => + appConfig.enabledSources.includes(adapter.name) && + isChatCrystalSource(adapter.name), + ); + const items: RemoteImportItem[] = []; + let errors = 0; + + for (const adapter of adapters) { + const source = validateRemoteImportSource(adapter.name); + try { + const info = await adapter.detect(); + if (!info) continue; + + const metas = await adapter.scan(); + for (const meta of metas) { + try { + const parsed = await adapter.parse(meta); + if (parsed.messages.length < 2) continue; + items.push(buildRemoteImportItem(source, meta, parsed, parserVersionFor(adapter))); + } catch (err) { + errors++; + console.error(`[RemoteImport] Error parsing ${meta.filePath}:`, err instanceof Error ? err.message : err); + } + } + } catch (err) { + errors++; + console.error(`[RemoteImport] Error scanning ${adapter.name}:`, err instanceof Error ? err.message : err); + } + } + + return { items, errors }; +} + +export async function runRemoteImport( + client: RemoteImportClient, + options: { source?: string } = {}, + onProgress?: (progress: RemoteImportProgress) => void, +): Promise { + const collected = await collectRemoteImportItems(options); + const { uploadableItems, oversizedItems } = splitUploadableRemoteImportItems(collected.items); + const progress: RemoteImportResult = { + scanned: collected.items.length, + uploaded: 0, + imported: 0, + replaced: 0, + skipped: 0, + errors: collected.errors + oversizedItems.length, + localErrors: collected.errors + oversizedItems.length, + }; + + onProgress?.(progress); + + for (const chunk of chunkRemoteImportItems(uploadableItems)) { + const result = await client.ingestConversations({ version: 1, items: chunk }); + progress.uploaded += chunk.length; + progress.imported += result.imported; + progress.replaced += result.replaced; + progress.skipped += result.skipped; + progress.errors += result.errors; + onProgress?.(progress); + } + + return progress; +} diff --git a/server/src/services/summarize.test.ts b/server/src/services/summarize.test.ts index 5094428..a910265 100644 --- a/server/src/services/summarize.test.ts +++ b/server/src/services/summarize.test.ts @@ -283,3 +283,27 @@ test('triggerSummarize creates notes for accepted conversations', async () => { assert.equal(Number(noteCount[0].values[0][0]), 1); assert.equal(status[0].values[0][0], 'summarized'); }); + +test('triggerSummarize marks conversations with preserved notes as summarized', async () => { + const db = await createSqlDatabase(); + insertConversation(db, 'conv-preserved-note'); + db.run( + `INSERT INTO notes ( + conversation_id, title, summary, key_conclusions, code_snippets, is_edited, source_type + ) VALUES (?, ?, ?, ?, ?, 1, 'imported-conversation')`, + ['conv-preserved-note', 'Edited note', 'Keep this note.', '[]', '[]'], + ); + + const result = await triggerSummarize('conv-preserved-note', { + db: db as never, + save: () => undefined, + prepareTranscript: () => { + throw new Error('existing notes should not rebuild transcript'); + }, + }); + + const status = db.exec('SELECT status FROM conversations WHERE id = ?', ['conv-preserved-note']); + + assert.equal(typeof result, 'number'); + assert.equal(status[0].values[0][0], 'summarized'); +}); diff --git a/server/src/services/summarize.ts b/server/src/services/summarize.ts index 32acc58..a8da910 100644 --- a/server/src/services/summarize.ts +++ b/server/src/services/summarize.ts @@ -211,6 +211,13 @@ export async function triggerSummarize( // Check if already summarized const existingNote = db.exec('SELECT id FROM notes WHERE conversation_id = ?', [conversationId]); if (existingNote.length > 0 && existingNote[0].values.length > 0) { + if (status !== 'summarized') { + db.run( + `UPDATE conversations SET status = 'summarized', updated_at = datetime('now') WHERE id = ?`, + [conversationId], + ); + save(); + } return Number(existingNote[0].values[0][0]); } diff --git a/shared/package.json b/shared/package.json index ff5fa50..3a0cf60 100644 --- a/shared/package.json +++ b/shared/package.json @@ -2,7 +2,7 @@ "name": "@chatcrystal/shared", "version": "0.1.0", "private": true, - "license": "MIT", + "license": "Apache-2.0", "type": "module", "main": "./types/index.ts", "types": "./types/index.ts" diff --git a/shared/types/index.ts b/shared/types/index.ts index 97a2741..3cce733 100644 --- a/shared/types/index.ts +++ b/shared/types/index.ts @@ -33,6 +33,13 @@ export type ConversationStatus = | 'filtered' | 'error'; +export type ChatCrystalSource = + | 'claude-code' + | 'codex' + | 'cursor' + | 'trae' + | 'copilot'; + export interface Message { id: string; conversation_id: string; @@ -377,6 +384,8 @@ export interface ParsedMessage { export interface SystemStatus { server: boolean; database: boolean; + cloudMode?: boolean; + providerWarnings?: string[]; ollama: boolean; watcher: boolean; sources: SourceInfo[]; @@ -386,3 +395,54 @@ export interface SystemStatus { totalTags: number; }; } + +export interface SetupStatusResponse { + cloudMode: boolean; + setupRequired: boolean; + authenticated: boolean; + providerWarnings: string[]; +} + +export interface CompleteSetupRequest { + setupCode: string; + token: string; +} + +export interface RotateTokenRequest { + currentToken: string; + nextToken: string; +} + +export interface RemoteImportItem { + source: ChatCrystalSource; + sourceConversationId: string; + conversationId: string; + contentHash: string; + parserVersion: string; + meta: ConversationMeta; + parsed: ParsedConversation; +} + +export interface RemoteImportRequest { + version: 1; + items: RemoteImportItem[]; +} + +export type RemoteImportItemStatus = 'imported' | 'replaced' | 'skipped' | 'error'; + +export interface RemoteImportItemResult { + source: ChatCrystalSource; + sourceConversationId: string; + conversationId: string; + status: RemoteImportItemStatus; + error?: string; +} + +export interface RemoteImportResponse { + total: number; + imported: number; + replaced: number; + skipped: number; + errors: number; + items: RemoteImportItemResult[]; +} diff --git a/site/src/i18n/en.ts b/site/src/i18n/en.ts index 782ce59..3292f19 100644 --- a/site/src/i18n/en.ts +++ b/site/src/i18n/en.ts @@ -49,7 +49,7 @@ export const en = { yourMachine: 'Your machine', points: [ { title: 'Never leaves your machine', desc: 'SQLite local storage, zero cloud dependency' }, - { title: 'Fully open source', desc: 'MIT License, transparent and auditable' }, + { title: 'Fully open source', desc: 'Apache-2.0 licensed, transparent and auditable' }, { title: "You're in control", desc: 'Choose your LLM provider — supports Ollama for fully local AI' }, ], }, diff --git a/site/src/i18n/zh.ts b/site/src/i18n/zh.ts index d7de61f..230961a 100644 --- a/site/src/i18n/zh.ts +++ b/site/src/i18n/zh.ts @@ -51,7 +51,7 @@ export const zh: Translations = { yourMachine: '你的电脑', points: [ { title: '数据不出本机', desc: 'SQLite 本地存储,无需云端' }, - { title: '完全开源', desc: 'MIT 协议,代码透明可审计' }, + { title: '完全开源', desc: 'Apache-2.0 协议,代码透明可审计' }, { title: '自主可控', desc: '自选 LLM 服务商,支持 Ollama 全本地运行' }, ], },