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 @@
[](https://github.com/ZengLiangYi/ChatCrystal/releases)
[](https://www.npmjs.com/package/chatcrystal)
-[](LICENSE)
+[](LICENSE)
[](https://nodejs.org/)
[](#)
[](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 @@
[](https://github.com/ZengLiangYi/ChatCrystal/releases)
[](https://www.npmjs.com/package/chatcrystal)
-[](LICENSE)
+[](LICENSE)
[](https://nodejs.org/)
[](#)
[](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 (
+