diff --git a/.githooks/sync-versions.test.ts b/.githooks/sync-versions.test.ts index 677f554c..505ad446 100644 --- a/.githooks/sync-versions.test.ts +++ b/.githooks/sync-versions.test.ts @@ -33,7 +33,19 @@ function createFixtureRepo(): string { }) writeJson(join(rootDir, 'cli', 'package.json'), { name: '@truenine/memory-sync-cli', - version: initialVersion + version: initialVersion, + optionalDependencies: { + '@truenine/memory-sync-cli-darwin-arm64': initialVersion, + '@truenine/memory-sync-cli-linux-x64-gnu': initialVersion + } + }) + writeJson(join(rootDir, 'mcp', 'package.json'), { + name: '@truenine/memory-sync-mcp', + version: initialVersion, + optionalDependencies: { + '@truenine/memory-sync-mcp-darwin-arm64': initialVersion, + '@truenine/memory-sync-mcp-linux-x64-gnu': initialVersion + } }) writeJson(join(rootDir, 'gui', 'package.json'), { name: '@truenine/memory-sync-gui', @@ -121,7 +133,20 @@ function createFixtureRepo(): string { function expectSharedVersionSurfaces(rootDir: string, nextVersion: string): void { expect(JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf-8')) as {version: string}).toMatchObject({version: nextVersion}) - expect(JSON.parse(readFileSync(join(rootDir, 'cli', 'package.json'), 'utf-8')) as {version: string}).toMatchObject({version: nextVersion}) + expect(JSON.parse(readFileSync(join(rootDir, 'cli', 'package.json'), 'utf-8')) as {version: string, optionalDependencies: Record}).toMatchObject({ + version: nextVersion, + optionalDependencies: { + '@truenine/memory-sync-cli-darwin-arm64': nextVersion, + '@truenine/memory-sync-cli-linux-x64-gnu': nextVersion + } + }) + expect(JSON.parse(readFileSync(join(rootDir, 'mcp', 'package.json'), 'utf-8')) as {version: string, optionalDependencies: Record}).toMatchObject({ + version: nextVersion, + optionalDependencies: { + '@truenine/memory-sync-mcp-darwin-arm64': nextVersion, + '@truenine/memory-sync-mcp-linux-x64-gnu': nextVersion + } + }) expect(JSON.parse(readFileSync(join(rootDir, 'gui', 'package.json'), 'utf-8')) as {version: string}).toMatchObject({version: nextVersion}) expect(JSON.parse(readFileSync(join(rootDir, 'doc', 'package.json'), 'utf-8')) as {version: string}).toMatchObject({version: nextVersion}) expect(JSON.parse(readFileSync(join(rootDir, 'cli', 'npm', 'darwin-arm64', 'package.json'), 'utf-8')) as {version: string}).toMatchObject({version: nextVersion}) @@ -171,6 +196,7 @@ describe('sync-versions hook', () => { 'gui/package.json', 'gui/src-tauri/Cargo.toml', 'gui/src-tauri/tauri.conf.json', + 'mcp/package.json', 'package.json' ])) }) @@ -202,6 +228,7 @@ describe('sync-versions hook', () => { 'gui/package.json', 'gui/src-tauri/Cargo.toml', 'gui/src-tauri/tauri.conf.json', + 'mcp/package.json', 'package.json' ])) }) diff --git a/.githooks/sync-versions.ts b/.githooks/sync-versions.ts index 30b89ef8..7833eb41 100644 --- a/.githooks/sync-versions.ts +++ b/.githooks/sync-versions.ts @@ -207,6 +207,32 @@ function validateVersion(version: string, source: string): void { } } +function syncInternalDependencyVersions(json: VersionedJson, targetVersion: string): boolean { + let changed = false + + for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) { + const deps = json[field] + if (deps == null || typeof deps !== 'object' || Array.isArray(deps)) { + continue + } + + for (const [name, value] of Object.entries(deps as Record)) { + if (!name.startsWith('@truenine/memory-sync-')) { + continue + } + + if (typeof value !== 'string' || value === targetVersion) { + continue + } + + ;(deps as Record)[name] = targetVersion + changed = true + } + } + + return changed +} + function syncJsonVersion( filePath: string, targetVersion: string, @@ -214,7 +240,9 @@ function syncJsonVersion( ): void { try { const json = readJsonFile(filePath) - if (json.version === targetVersion) { + const dependenciesChanged = syncInternalDependencyVersions(json, targetVersion) + + if (json.version === targetVersion && !dependenciesChanged) { return } @@ -411,6 +439,11 @@ export function runSyncVersions(options: SyncVersionsOptions = {}): SyncVersions validateVersion(currentRootVersion, 'root package.json') + const target = resolveTargetVersion(rootDir, currentRootVersion, options.requestedVersion) + const changedPaths = new Set() + + syncJsonVersion(rootPackagePath, target.version, changedPaths) + const packageJsonPaths = discoverFilesByName(rootDir, 'package.json') .filter(filePath => resolve(filePath) !== rootPackagePath) .sort() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b264c045..8e281253 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,25 @@ jobs: - name: Rust unit tests run: cargo test --workspace --exclude tnmsg --exclude tnmsc-integrate-tests --exclude tnmsc-local-tests --exclude tnmsm-integrate-tests --lib --bins + packaging-smoke: + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - uses: actions/checkout@v6 + + - uses: ./.github/actions/setup-node-pnpm + + - uses: ./.github/actions/setup-rust + with: + cache-key: ci-packaging-smoke + + - name: CLI packaging smoke + run: cargo test -p tnmsc-integrate-tests packaging_smoke_covers_release_binary_and_global_install -- --exact --nocapture + + - name: MCP packaging smoke + run: cargo test -p tnmsm-integrate-tests packaging_smoke_covers_release_binary_and_global_install -- --exact --nocapture + gui-smoke: needs: changes if: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ac53003d..7a674e59 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -306,6 +306,20 @@ jobs: - name: Build CLI package run: cargo build --release -p tnmsc + - name: Smoke test CLI main package + shell: bash + run: | + set -euo pipefail + pack_dir="$(mktemp -d)" + trap 'rm -rf "$pack_dir"' EXIT + pnpm -C cli pack --pack-destination "$pack_dir" + cli_tarball="$(find "$pack_dir" -maxdepth 1 -name '*.tgz' -print -quit)" + test -n "$cli_tarball" + npm install -g "$cli_tarball" + command -v tnmsc + tnmsc help >/tmp/tnmsc-help.txt + grep -q 'install' /tmp/tnmsc-help.txt + - name: Publish CLI package uses: ./.github/actions/npm-publish-package with: @@ -382,6 +396,20 @@ jobs: - name: Build MCP package run: cargo build --release -p tnmsm + - name: Smoke test MCP main package + shell: bash + run: | + set -euo pipefail + pack_dir="$(mktemp -d)" + trap 'rm -rf "$pack_dir"' EXIT + pnpm -C mcp pack --pack-destination "$pack_dir" + mcp_tarball="$(find "$pack_dir" -maxdepth 1 -name '*.tgz' -print -quit)" + test -n "$mcp_tarball" + npm install -g "$mcp_tarball" + command -v tnmsm + printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | tnmsm >/tmp/tnmsm-initialize.json + grep -q '"jsonrpc":"2.0"' /tmp/tnmsm-initialize.json + - name: Publish MCP package uses: ./.github/actions/npm-publish-package with: diff --git a/Cargo.lock b/Cargo.lock index 14f0d3ca..38861b7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3062,7 +3062,7 @@ dependencies = [ [[package]] name = "memory-sync" -version = "2026.10422.10749" +version = "2026.10424.111" dependencies = [ "tnmsc", ] @@ -6223,7 +6223,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tnmsc" -version = "2026.10422.10749" +version = "2026.10424.111" dependencies = [ "clap", "serde_json", @@ -6232,7 +6232,7 @@ dependencies = [ [[package]] name = "tnmsc-integrate-tests" -version = "2026.10422.10749" +version = "2026.10424.111" dependencies = [ "flate2", "serde_json", @@ -6242,7 +6242,7 @@ dependencies = [ [[package]] name = "tnmsc-local-tests" -version = "2026.10422.10749" +version = "2026.10424.111" dependencies = [ "dirs", "json5", @@ -6251,7 +6251,7 @@ dependencies = [ [[package]] name = "tnmsd" -version = "2026.10422.10749" +version = "2026.10424.111" dependencies = [ "base64 0.22.1", "chrono", @@ -6279,7 +6279,7 @@ dependencies = [ [[package]] name = "tnmsg" -version = "2026.10422.10749" +version = "2026.10424.111" dependencies = [ "dirs", "proptest", @@ -6294,7 +6294,7 @@ dependencies = [ [[package]] name = "tnmsm" -version = "2026.10422.10749" +version = "2026.10424.111" dependencies = [ "clap", "serde_json", @@ -6303,7 +6303,7 @@ dependencies = [ [[package]] name = "tnmsm-integrate-tests" -version = "2026.10422.10749" +version = "2026.10424.111" dependencies = [ "serde_json", "testcontainers", @@ -7819,7 +7819,7 @@ dependencies = [ [[package]] name = "xtask" -version = "2026.10422.10749" +version = "2026.10424.111" dependencies = [ "clap", "serde", diff --git a/Cargo.toml b/Cargo.toml index 3cb4616f..53b112c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,7 @@ members = [ ] [workspace.package] -version = "2026.10422.10749" +version = "2026.10424.111" edition = "2024" rust-version = "1.88" license = "AGPL-3.0-only" diff --git a/cli/.npmignore b/cli/.npmignore index 661a6ce2..fd13bdb1 100644 --- a/cli/.npmignore +++ b/cli/.npmignore @@ -1,4 +1,7 @@ * +!bin +!bin/ +!bin/tnmsc.js !schema !schema/ !schema/tnmsc.schema.json diff --git a/cli/bin/tnmsc.js b/cli/bin/tnmsc.js new file mode 100644 index 00000000..6f301616 --- /dev/null +++ b/cli/bin/tnmsc.js @@ -0,0 +1,148 @@ +#!/usr/bin/env node +'use strict'; + +const {spawnSync} = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); + +const PACKAGE_NAME = '@truenine/memory-sync-cli'; +const BINARY_NAME = 'tnmsc'; +const SUPPORTED_TARGETS = [ + 'linux-x64-gnu', + 'linux-arm64-gnu', + 'darwin-x64', + 'darwin-arm64', + 'win32-x64-msvc', +].join(', '); + +const PLATFORM_PACKAGES = { + darwin: { + arm64: '@truenine/memory-sync-cli-darwin-arm64', + x64: '@truenine/memory-sync-cli-darwin-x64', + }, + linux: { + arm64: '@truenine/memory-sync-cli-linux-arm64-gnu', + x64: '@truenine/memory-sync-cli-linux-x64-gnu', + }, + win32: { + x64: '@truenine/memory-sync-cli-win32-x64-msvc', + }, +}; + +function fail(message) { + console.error(`${PACKAGE_NAME}: ${message}`); + process.exit(1); +} + +function detectLinuxLibc() { + const report = process.report; + if (report == null || typeof report.getReport !== 'function') { + return 'unknown'; + } + + const header = report.getReport()?.header; + if (header == null || typeof header !== 'object') { + return 'unknown'; + } + + if (header.glibcVersionRuntime || header.glibcVersionCompiler) { + return 'glibc'; + } + + return 'unknown'; +} + +function resolvePlatformPackageName() { + const archMap = PLATFORM_PACKAGES[process.platform]; + if (archMap == null) { + fail( + `Unsupported platform ${process.platform}/${process.arch}. Supported npm targets: ${SUPPORTED_TARGETS}.`, + ); + } + + const packageName = archMap[process.arch]; + if (packageName == null) { + fail( + `Unsupported architecture ${process.platform}/${process.arch}. Supported npm targets: ${SUPPORTED_TARGETS}.`, + ); + } + + if (process.platform === 'linux' && detectLinuxLibc() !== 'glibc') { + fail( + 'Linux npm binaries currently require glibc. musl/Alpine environments are not supported by the published packages.', + ); + } + + return packageName; +} + +function readJson(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch (error) { + fail(`Failed to read ${filePath}: ${error.message}`); + } +} + +function resolveBinaryPath(packageName) { + let manifestPath; + try { + manifestPath = require.resolve(`${packageName}/package.json`); + } catch (error) { + fail( + `Missing optional native package ${packageName}. Reinstall ${PACKAGE_NAME} on a supported platform so npm can fetch the matching binary package. Original error: ${error.message}`, + ); + } + + const manifest = readJson(manifestPath); + const packageDir = path.dirname(manifestPath); + const binField = manifest.bin; + let relativeBinaryPath; + + if (typeof binField === 'string') { + relativeBinaryPath = binField; + } else if (binField != null && typeof binField === 'object') { + if (typeof binField[BINARY_NAME] === 'string') { + relativeBinaryPath = binField[BINARY_NAME]; + } else { + relativeBinaryPath = Object.values(binField).find(value => typeof value === 'string'); + } + } + + if (typeof relativeBinaryPath !== 'string' || relativeBinaryPath.length === 0) { + fail(`Package ${packageName} does not declare a ${BINARY_NAME} binary entry.`); + } + + const binaryPath = path.resolve(packageDir, relativeBinaryPath); + if (!fs.existsSync(binaryPath)) { + fail( + `Native binary ${binaryPath} is missing from ${packageName}. Reinstall ${PACKAGE_NAME} and try again.`, + ); + } + + return binaryPath; +} + +function runNativeBinary(binaryPath) { + const result = spawnSync(binaryPath, process.argv.slice(2), { + env: process.env, + stdio: 'inherit', + }); + + if (result.error != null) { + fail(`Failed to launch ${binaryPath}: ${result.error.message}`); + } + + if (typeof result.status === 'number') { + process.exit(result.status); + } + + if (result.signal != null) { + process.kill(process.pid, result.signal); + return; + } + + process.exit(1); +} + +runNativeBinary(resolveBinaryPath(resolvePlatformPackageName())); diff --git a/cli/integrate-tests/src/lib.rs b/cli/integrate-tests/src/lib.rs index cb63ab53..e080c8bb 100644 --- a/cli/integrate-tests/src/lib.rs +++ b/cli/integrate-tests/src/lib.rs @@ -1,5 +1,6 @@ #![allow(dead_code)] +use std::ffi::OsStr; use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; @@ -17,6 +18,7 @@ const EXIT_MARKER: &str = "__TNMSC_EXIT_CODE__="; pub const EXPECTED_SUBCOMMANDS: &[&str] = &["install", "dry-run", "clean", "version", "help"]; pub const PACKAGED_PLATFORM_PACKAGE: &str = "@truenine/memory-sync-cli-linux-x64-gnu"; +static PNPM_VERSION: OnceLock = OnceLock::new(); static RELEASE_BINARY_BUILT: OnceLock<()> = OnceLock::new(); static RELEASE_TEST_API_BINARY_BUILT: OnceLock<()> = OnceLock::new(); static PACKED_CLI_ARTIFACTS: OnceLock = OnceLock::new(); @@ -451,6 +453,25 @@ pub fn current_package_version() -> &'static str { env!("CARGO_PKG_VERSION") } +pub fn pnpm_version() -> &'static str { + PNPM_VERSION.get_or_init(|| { + let package_json_path = workspace_root().join("package.json"); + let raw = fs::read_to_string(&package_json_path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", package_json_path.display())); + let parsed: serde_json::Value = serde_json::from_str(&raw) + .unwrap_or_else(|error| panic!("failed to parse {}: {error}", package_json_path.display())); + let package_manager = parsed + .get("packageManager") + .and_then(|value| value.as_str()) + .unwrap_or("pnpm@latest"); + + package_manager + .rsplit_once('@') + .map(|(_, version)| version.to_string()) + .unwrap_or_else(|| "latest".to_string()) + }) +} + pub fn ensure_release_binary() { RELEASE_BINARY_BUILT.get_or_init(|| { eprintln!("[tnmsc-integrate-tests] compiling debug binary (cargo build -p tnmsc)..."); @@ -544,6 +565,7 @@ pub fn create_staged_package_root() -> StagedPackageRoot { &cli_manifest_dir().join("package.json"), &package_root.join("package.json"), ); + copy_dir_all(&cli_manifest_dir().join("bin"), &package_root.join("bin")); copy_dir_all( &cli_manifest_dir().join("schema"), &package_root.join("schema"), @@ -559,8 +581,6 @@ pub fn create_staged_package_root() -> StagedPackageRoot { .join("package.json"), ); - rewrite_main_package_json(&package_root.join("package.json")); - let linux_binary = package_root .join("npm") .join("linux-x64-gnu") @@ -673,12 +693,16 @@ fn pack_cli_artifacts_once() -> PackedArtifacts { } } - let cli_tarball = pack_package(&staged.package_root, temp_dir.path(), "cli"); let linux_tarball = pack_package( &staged.package_root.join("npm").join("linux-x64-gnu"), temp_dir.path(), "linux-x64-gnu", ); + rewrite_main_package_json( + &staged.package_root.join("package.json"), + "file:/artifacts/linux-x64-gnu.tgz", + ); + let cli_tarball = pack_package(&staged.package_root, temp_dir.path(), "cli"); eprintln!( "[tnmsc-integrate-tests] artifact packing finished in {:.2}s", @@ -696,11 +720,7 @@ fn pack_cli_artifacts_once() -> PackedArtifacts { pub fn install_packaged_cli_container() -> Option { let artifacts = pack_cli_artifacts()?; let container = TestContainer::start(artifacts); - let install_command = format!( - "npm install -g {} {}", - quote_shell("/artifacts/cli.tgz"), - quote_shell("/artifacts/linux-x64-gnu.tgz") - ); + let install_command = format!("npm install -g {}", quote_shell("/artifacts/cli.tgz")); let result = container.exec_with_retries_and_timeout(&install_command, 3, 2000, 120); result.assert_success(&format!( "install tnmsc globally (attempted up to 3 times): {}", @@ -722,13 +742,6 @@ pub fn quote_shell(value: &str) -> String { format!("'{}'", value.replace('\'', "'\"'\"'")) } -fn npm_tarball_name(pkg_name: &str) -> String { - pkg_name - .strip_prefix('@') - .unwrap_or(pkg_name) - .replace('/', "-") -} - fn pack_package(package_dir: &Path, target_root: &Path, name: &str) -> PathBuf { assert!( package_dir.exists(), @@ -744,50 +757,40 @@ fn pack_package(package_dir: &Path, target_root: &Path, name: &str) -> PathBuf { ) }); - let package_json_path = package_dir.join("package.json"); - let raw = fs::read_to_string(&package_json_path) - .unwrap_or_else(|error| panic!("failed to read {}: {error}", package_json_path.display())); - let parsed: serde_json::Value = serde_json::from_str(&raw) - .unwrap_or_else(|error| panic!("failed to parse {}: {error}", package_json_path.display())); - let pkg_name = parsed.get("name").and_then(|v| v.as_str()).unwrap_or(name); - let pkg_version = parsed - .get("version") - .and_then(|v| v.as_str()) - .unwrap_or("0.0.0"); - let tarball_name = format!("{}-{}.tgz", npm_tarball_name(pkg_name), pkg_version); - let tarball_path = pack_destination.join(&tarball_name); - - let gz_file = fs::File::create(&tarball_path) - .unwrap_or_else(|error| panic!("failed to create {}: {error}", tarball_path.display())); - let gz_encoder = flate2::GzBuilder::new().write(gz_file, flate2::Compression::default()); - let mut tar_builder = tar::Builder::new(gz_encoder); - - tar_builder - .append_dir_all("package", package_dir) - .unwrap_or_else(|error| { - panic!( - "failed to append {} to tarball: {error}", - package_dir.display() - ) - }); + let package_dir = package_dir.to_string_lossy().into_owned(); + let pack_destination = pack_destination.to_string_lossy().into_owned(); + let result = run_program( + "pnpm", + &[ + "-C", + &package_dir, + "pack", + "--pack-destination", + &pack_destination, + ], + &workspace_root(), + ); + result.assert_success(&format!("pnpm pack for {}", package_dir)); - let gz_encoder = tar_builder - .into_inner() - .unwrap_or_else(|error| panic!("failed to finalize tarball: {error}")); - gz_encoder - .finish() - .unwrap_or_else(|error| panic!("failed to finalize gzip: {error}")); + let mut tarballs = fs::read_dir(&pack_destination) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", pack_destination)) + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .filter(|path| path.extension().and_then(OsStr::to_str) == Some("tgz")) + .collect::>(); + tarballs.sort(); assert!( - tarball_path.is_file(), - "expected tarball at {}", - tarball_path.display() + tarballs.len() == 1, + "expected exactly one tarball in {}, found {}", + pack_destination, + tarballs.len() ); - tarball_path + tarballs.remove(0) } -fn rewrite_main_package_json(path: &Path) { +fn rewrite_main_package_json(path: &Path, platform_dependency: &str) { let raw = fs::read_to_string(path) .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); let mut parsed: serde_json::Value = serde_json::from_str(&raw) @@ -804,7 +807,7 @@ fn rewrite_main_package_json(path: &Path) { serde_json::Value::Object( [( PACKAGED_PLATFORM_PACKAGE.to_string(), - serde_json::Value::String(current_package_version().to_string()), + serde_json::Value::String(platform_dependency.to_string()), )] .into_iter() .collect(), diff --git a/cli/integrate-tests/tests/packaging_smoke.rs b/cli/integrate-tests/tests/packaging_smoke.rs index 8a3e9c77..dc27dd39 100644 --- a/cli/integrate-tests/tests/packaging_smoke.rs +++ b/cli/integrate-tests/tests/packaging_smoke.rs @@ -80,6 +80,7 @@ MAIN_PACKAGE_JSON="$(find -L /usr/local/lib/node_modules -path '*/@truenine/memo PLATFORM_PACKAGE_JSON="$(find -L /usr/local/lib/node_modules -path '*/@truenine/memory-sync-cli-linux-x64-gnu/package.json' -print -quit)" test -n "$MAIN_PACKAGE_JSON" test -n "$PLATFORM_PACKAGE_JSON" +test -f "$(dirname "$MAIN_PACKAGE_JSON")/bin/tnmsc.js" test -x "$(dirname "$PLATFORM_PACKAGE_JSON")/bin/tnmsc" test -x "$(command -v tnmsc)" grep -q '"@truenine/memory-sync-cli-linux-x64-gnu"' "$MAIN_PACKAGE_JSON" diff --git a/cli/local-tests/tests/opencode_agent_mode_validation.rs b/cli/local-tests/tests/opencode_agent_mode_validation.rs new file mode 100644 index 00000000..2c70db34 --- /dev/null +++ b/cli/local-tests/tests/opencode_agent_mode_validation.rs @@ -0,0 +1,116 @@ +//! 回归测试:验证 opencode agent 的 `mode` 字段值在合法集合内。 +//! +//! opencode CLI 要求 agent 的 `mode` 必须是 `"subagent"`、`"primary"` 或 `"all"`。 +//! 如果生成的值不匹配这三个之一,opencode 启动时会报错: +//! Configuration is invalid at ~/project/.opencode/agents/.md +//! Invalid option: expected one of "subagent"|"primary"|"all" mode +//! +//! 本测试通过解析生成文件的 YAML front matter 来预防此类回归。 +//! +//! **前提**:项目已配置,opencode 插件已启用。 + +use tnmsc_local_tests::LocalTestRunner; + +/// opencode 接受的合法 `mode` 值集合。 +const VALID_MODES: &[&str] = &["subagent", "primary", "all"]; + +/// 从 YAML front matter 行中提取 `mode` 的值。 +/// +/// 期望格式: `mode: subagent` 或 `mode: "subagent"` 或 `mode:` 开头。 +/// 返回去掉引号的纯值字符串;如果没有 mode 行则返回 `None`。 +fn extract_mode_from_front_matter_line(line: &str) -> Option { + let trimmed = line.trim(); + if !trimmed.starts_with("mode") { + return None; + } + // 跳过 "mode" 和 ':' 及空白 + let after_key = trimmed + .strip_prefix("mode") + .and_then(|s| s.strip_prefix(':')) + .map(|s| s.trim()) + .unwrap_or(""); + if after_key.is_empty() { + return None; + } + // 去除引号 + let value = if after_key.starts_with('"') && after_key.ends_with('"') && after_key.len() >= 2 { + &after_key[1..after_key.len() - 1] + } else if after_key.starts_with('\'') && after_key.ends_with('\'') && after_key.len() >= 2 { + &after_key[1..after_key.len() - 1] + } else { + after_key + }; + Some(value.to_string()) +} + +/// 从 agent 文件的 YAML front matter 中提取 `mode` 值。 +/// +/// YAML front matter 以 `---` 起止。 +fn extract_mode_from_agent_file(content: &str) -> Option { + let mut in_front_matter = false; + let mut found_start = false; + for line in content.lines() { + let trimmed = line.trim(); + if trimmed == "---" { + if !found_start { + found_start = true; + in_front_matter = true; + continue; + } else { + // closing ---, end of front matter + break; + } + } + if in_front_matter { + if let Some(mode) = extract_mode_from_front_matter_line(line) { + return Some(mode); + } + } + } + None +} + +#[test] +fn local_opencode_agent_mode_must_be_valid() { + let runner = LocalTestRunner::new(); + runner.assert_project_ready(); + + let clean = runner.clean(); + clean.assert_success("tnmsc clean before install"); + + let install = runner.install(); + install.assert_success("tnmsc install"); + + let agents_dir = runner.cwd().join(".opencode").join("agents"); + let agent_files: Vec<_> = std::fs::read_dir(&agents_dir) + .unwrap() + .flatten() + .filter(|e| e.file_type().map(|ft| ft.is_file()).unwrap_or(false)) + .collect(); + + assert!( + !agent_files.is_empty(), + ".opencode/agents should contain at least one file" + ); + + for file in &agent_files { + let content = std::fs::read_to_string(file.path()).unwrap(); + let file_name = file.file_name().to_string_lossy().to_string(); + + let mode = extract_mode_from_agent_file(&content); + assert!( + mode.is_some(), + "agent file {} must have a `mode` field in YAML front matter", + file_name + ); + + let mode = mode.unwrap(); + assert!( + VALID_MODES.contains(&mode.as_str()), + "agent file {} has invalid mode {:?}, must be one of {:?}", + file_name, + mode, + VALID_MODES + ); + } +} diff --git a/cli/local-tests/tests/opencode_smoke.rs b/cli/local-tests/tests/opencode_smoke.rs index 6bf133e2..8968b8e4 100644 --- a/cli/local-tests/tests/opencode_smoke.rs +++ b/cli/local-tests/tests/opencode_smoke.rs @@ -68,8 +68,8 @@ fn local_opencode_install_generates_project_agents_md() { name ); assert!( - content.contains("mode: subagnet") || content.contains("mode: \"subagnet\""), - "agent file {} should contain mode: \"subagnet\" in front matter", + content.contains("mode: subagent") || content.contains("mode: \"subagent\""), + "agent file {} should contain mode: \"subagent\" in front matter", name ); } @@ -436,8 +436,8 @@ fn local_opencode_agent_md_must_include_subagent_mode() { for file in &agent_files { let content = std::fs::read_to_string(file.path()).unwrap(); assert!( - content.contains("mode: subagnet") || content.contains("mode: \"subagnet\""), - "agent file {} must include mode: \"subagnet\" in YAML front matter", + content.contains("mode: subagent") || content.contains("mode: \"subagent\""), + "agent file {} must include mode: \"subagent\" in YAML front matter", file.file_name().to_string_lossy() ); } diff --git a/cli/npm/darwin-arm64/package.json b/cli/npm/darwin-arm64/package.json index 0fc9d4ab..8f602023 100644 --- a/cli/npm/darwin-arm64/package.json +++ b/cli/npm/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-cli-darwin-arm64", - "version": "2026.10422.10749", + "version": "2026.10424.111", "description": "tnmsc native binary for macOS arm64", "author": "TrueNine", "license": "AGPL-3.0-only", diff --git a/cli/npm/darwin-x64/package.json b/cli/npm/darwin-x64/package.json index 030674f3..b69602e4 100644 --- a/cli/npm/darwin-x64/package.json +++ b/cli/npm/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-cli-darwin-x64", - "version": "2026.10422.10749", + "version": "2026.10424.111", "description": "tnmsc native binary for macOS x64", "author": "TrueNine", "license": "AGPL-3.0-only", diff --git a/cli/npm/linux-arm64-gnu/package.json b/cli/npm/linux-arm64-gnu/package.json index a5b1be96..9165ce52 100644 --- a/cli/npm/linux-arm64-gnu/package.json +++ b/cli/npm/linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-cli-linux-arm64-gnu", - "version": "2026.10422.10749", + "version": "2026.10424.111", "description": "tnmsc native binary for Linux arm64 (glibc)", "author": "TrueNine", "license": "AGPL-3.0-only", diff --git a/cli/npm/linux-x64-gnu/package.json b/cli/npm/linux-x64-gnu/package.json index 4c6b39a4..71e0c89f 100644 --- a/cli/npm/linux-x64-gnu/package.json +++ b/cli/npm/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-cli-linux-x64-gnu", - "version": "2026.10422.10749", + "version": "2026.10424.111", "description": "tnmsc native binary for Linux x64 (glibc)", "author": "TrueNine", "license": "AGPL-3.0-only", diff --git a/cli/npm/win32-x64-msvc/package.json b/cli/npm/win32-x64-msvc/package.json index f53b9349..32be222e 100644 --- a/cli/npm/win32-x64-msvc/package.json +++ b/cli/npm/win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-cli-win32-x64-msvc", - "version": "2026.10422.10749", + "version": "2026.10424.111", "description": "tnmsc native binary for Windows x64", "author": "TrueNine", "license": "AGPL-3.0-only", diff --git a/cli/package.json b/cli/package.json index 90376273..e2fd1921 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-cli", - "version": "2026.10422.10749", + "version": "2026.10424.111", "description": "TrueNine Memory Synchronization CLI metadata package", "author": "TrueNine", "license": "AGPL-3.0-only", @@ -10,11 +10,15 @@ "url": "git+https://github.com/TrueNine/memory-sync.git", "directory": "cli" }, + "bin": { + "tnmsc": "./bin/tnmsc.js" + }, "exports": { "./schema.json": "./schema/tnmsc.schema.json", "./package.json": "./package.json" }, "files": [ + "bin/tnmsc.js", "schema/tnmsc.schema.json" ], "publishConfig": { @@ -30,10 +34,10 @@ "test": "cargo test --manifest-path Cargo.toml" }, "optionalDependencies": { - "@truenine/memory-sync-cli-darwin-arm64": "2026.10422.10749", - "@truenine/memory-sync-cli-darwin-x64": "2026.10422.10749", - "@truenine/memory-sync-cli-linux-arm64-gnu": "2026.10422.10749", - "@truenine/memory-sync-cli-linux-x64-gnu": "2026.10422.10749", - "@truenine/memory-sync-cli-win32-x64-msvc": "2026.10422.10749" + "@truenine/memory-sync-cli-darwin-arm64": "2026.10424.111", + "@truenine/memory-sync-cli-darwin-x64": "2026.10424.111", + "@truenine/memory-sync-cli-linux-arm64-gnu": "2026.10424.111", + "@truenine/memory-sync-cli-linux-x64-gnu": "2026.10424.111", + "@truenine/memory-sync-cli-win32-x64-msvc": "2026.10424.111" } } diff --git a/cli/src/cli.rs b/cli/src/cli.rs index 2092b33c..02020fdd 100644 --- a/cli/src/cli.rs +++ b/cli/src/cli.rs @@ -124,6 +124,16 @@ impl ResolvedLogLevel { Self::Error => LogLevel::Error, } } + + pub fn to_sdk_logger_level(self) -> tnmsd::infra::logger::LogLevel { + match self { + Self::Trace => tnmsd::infra::logger::LogLevel::Trace, + Self::Debug => tnmsd::infra::logger::LogLevel::Debug, + Self::Info => tnmsd::infra::logger::LogLevel::Info, + Self::Warn => tnmsd::infra::logger::LogLevel::Warn, + Self::Error => tnmsd::infra::logger::LogLevel::Error, + } + } } /// Resolve log level from CLI flags. diff --git a/cli/src/commands/pipeline.rs b/cli/src/commands/pipeline.rs index 3d1ce70b..824719c7 100644 --- a/cli/src/commands/pipeline.rs +++ b/cli/src/commands/pipeline.rs @@ -1,26 +1,239 @@ use std::process::ExitCode; -fn map_result(result: Result) -> ExitCode { +use serde_json::Value; + +use crate::logger; + +#[derive(Debug, PartialEq, Eq)] +struct RenderedCommandResult { + success: bool, + stdout_lines: Vec, + stderr_lines: Vec, +} + +fn render_result( + result: Result, +) -> RenderedCommandResult { + match result { + Ok(r) => { + let mut stdout_lines = Vec::new(); + let mut stderr_lines = Vec::new(); + + if let Some(message) = r + .message + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if r.success { + stdout_lines.push(message.to_string()); + } else { + stderr_lines.push(format!("Error: {message}")); + } + } + + stderr_lines.extend(render_entries("Warning", &r.warnings)); + stderr_lines.extend(render_entries("Error", &r.errors)); + + if !r.success && stderr_lines.is_empty() { + stderr_lines.push("Error: Command failed without additional details.".to_string()); + } + + RenderedCommandResult { + success: r.success, + stdout_lines, + stderr_lines, + } + } + Err(e) => RenderedCommandResult { + success: false, + stdout_lines: Vec::new(), + stderr_lines: vec![format!("Error: {e}")], + }, + } +} + +fn render_entries(label: &str, values: &[Value]) -> Vec { + values + .iter() + .flat_map(|value| render_entry(label, value)) + .collect() +} + +fn render_entry(label: &str, value: &Value) -> Vec { + match value { + Value::String(text) => vec![format!("{label}: {text}")], + Value::Object(map) => { + if map.get("type").and_then(Value::as_str) == Some("workspace_mismatch") { + let mut lines = vec![format!( + "{label}: {}", + map + .get("message") + .and_then(Value::as_str) + .unwrap_or("Current directory is outside configured workspaceDir.") + )]; + if let Some(current_dir) = map.get("currentDir").and_then(Value::as_str) { + lines.push(format!(" currentDir: {current_dir}")); + } + if let Some(workspace_dir) = map.get("workspaceDir").and_then(Value::as_str) { + lines.push(format!(" workspaceDir: {workspace_dir}")); + } + if let Some(config_sources) = map.get("configSources").and_then(Value::as_array) + && !config_sources.is_empty() + { + lines.push(format!( + " configSources: {}", + config_sources + .iter() + .filter_map(Value::as_str) + .collect::>() + .join(", ") + )); + } + return lines; + } + + if map.get("type").and_then(Value::as_str) == Some("violation") { + let target = map + .get("target") + .and_then(Value::as_str) + .unwrap_or(""); + let protected = map + .get("protected") + .and_then(Value::as_str) + .unwrap_or(""); + let reason = map + .get("reason") + .and_then(Value::as_str) + .unwrap_or("cleanup target is protected"); + return vec![format!( + "{label}: Cleanup violation for {target} (protected: {protected}): {reason}" + )]; + } + + if map.get("type").and_then(Value::as_str) == Some("conflict") { + let output = map + .get("output") + .and_then(Value::as_str) + .unwrap_or(""); + let protected = map + .get("protected") + .and_then(Value::as_str) + .unwrap_or(""); + let reason = map + .get("reason") + .and_then(Value::as_str) + .unwrap_or("cleanup target conflicts with a protected path"); + return vec![format!( + "{label}: Cleanup conflict for {output} (protected: {protected}): {reason}" + )]; + } + + if let Some(error) = map.get("error").and_then(Value::as_str) { + if let Some(path) = map.get("path").and_then(Value::as_str) { + return vec![format!("{label}: {path}: {error}")]; + } + return vec![format!("{label}: {error}")]; + } + + if let Some(warning) = map.get("warning").and_then(Value::as_str) { + if let Some(path) = map.get("path").and_then(Value::as_str) { + return vec![format!("{label}: {path}: {warning}")]; + } + return vec![format!("{label}: {warning}")]; + } + + vec![format!( + "{label}: {}", + serde_json::to_string(value).unwrap_or_else(|_| "".to_string()) + )] + } + _ => vec![format!( + "{label}: {}", + serde_json::to_string(value).unwrap_or_else(|_| "".to_string()) + )], + } +} + +fn log_command_start(command_name: &str) { + logger::info(&format!("Running {command_name}")); + if let Ok(current_dir) = std::env::current_dir() { + logger::debug(&format!("currentDir={}", current_dir.display())); + } +} + +fn log_command_finish( + command_name: &str, + result: &Result, +) { match result { - Ok(r) if r.success => ExitCode::SUCCESS, - Ok(_) => ExitCode::FAILURE, - Err(e) => { - eprintln!("Error: {}", e); - ExitCode::FAILURE + Ok(command_result) => { + logger::debug(&format!( + "{command_name} result: success={}, filesAffected={}, dirsAffected={}, warnings={}, errors={}", + command_result.success, + command_result.files_affected, + command_result.dirs_affected, + command_result.warnings.len(), + command_result.errors.len(), + )); } + Err(error) => { + logger::error(&format!("{command_name} failed: {error}")); + } + } +} + +fn run_command( + command_name: &str, + operation: impl FnOnce( + tnmsd::MemorySyncCommandOptions, + ) -> Result, + options: tnmsd::MemorySyncCommandOptions, +) -> ExitCode { + log_command_start(command_name); + let result = operation(options); + log_command_finish(command_name, &result); + let rendered = render_result(result); + + for line in rendered.stdout_lines { + println!("{line}"); + } + for line in rendered.stderr_lines { + eprintln!("{line}"); + } + + logger::flush_output(); + tnmsd::infra::logger::flush_output(); + + if rendered.success { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE } } pub fn install() -> ExitCode { - map_result(tnmsd::install(tnmsd::MemorySyncCommandOptions::default())) + run_command( + "install", + tnmsd::install, + tnmsd::MemorySyncCommandOptions::default(), + ) } pub fn dry_run() -> ExitCode { - map_result(tnmsd::dry_run(tnmsd::MemorySyncCommandOptions::default())) + run_command( + "dry-run", + tnmsd::dry_run, + tnmsd::MemorySyncCommandOptions::default(), + ) } pub fn clean() -> ExitCode { - map_result(tnmsd::clean(tnmsd::MemorySyncCommandOptions::default())) + run_command( + "clean", + tnmsd::clean, + tnmsd::MemorySyncCommandOptions::default(), + ) } pub fn dry_run_clean() -> ExitCode { @@ -28,5 +241,82 @@ pub fn dry_run_clean() -> ExitCode { dry_run: Some(true), ..Default::default() }; - map_result(tnmsd::clean(options)) + run_command("clean --dry-run", tnmsd::clean, options) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn render_result_prints_success_message_to_stdout() { + let rendered = render_result(Ok(tnmsd::MemorySyncCommandResult { + success: true, + files_affected: 1, + dirs_affected: 0, + message: Some("Deleted 1 files and 0 directories".to_string()), + warnings: Vec::new(), + errors: Vec::new(), + })); + + assert_eq!( + rendered, + RenderedCommandResult { + success: true, + stdout_lines: vec!["Deleted 1 files and 0 directories".to_string()], + stderr_lines: Vec::new(), + } + ); + } + + #[test] + fn render_result_formats_workspace_mismatch_warning() { + let rendered = render_result(Ok(tnmsd::MemorySyncCommandResult { + success: true, + files_affected: 0, + dirs_affected: 0, + message: Some("No files needed updates".to_string()), + warnings: vec![json!({ + "type": "workspace_mismatch", + "message": "Current directory is outside configured workspaceDir. tnmsc will operate on the configured workspace instead of the current directory.", + "currentDir": "C:/workspace/memory-sync", + "workspaceDir": "C:/temp/demo", + "configSources": ["C:/Users/truen/.aindex/.tnmsc.json"] + })], + errors: Vec::new(), + })); + + assert_eq!( + rendered.stderr_lines, + vec![ + "Warning: Current directory is outside configured workspaceDir. tnmsc will operate on the configured workspace instead of the current directory.".to_string(), + " currentDir: C:/workspace/memory-sync".to_string(), + " workspaceDir: C:/temp/demo".to_string(), + " configSources: C:/Users/truen/.aindex/.tnmsc.json".to_string(), + ] + ); + } + + #[test] + fn render_result_formats_path_errors() { + let rendered = render_result(Ok(tnmsd::MemorySyncCommandResult { + success: false, + files_affected: 0, + dirs_affected: 0, + message: Some("Cleanup blocked".to_string()), + warnings: Vec::new(), + errors: vec![json!({ + "path": "C:/workspace/file.md", + "error": "access denied" + })], + })); + + assert_eq!(rendered.stderr_lines[0], "Error: Cleanup blocked"); + assert_eq!( + rendered.stderr_lines[1], + "Error: C:/workspace/file.md: access denied" + ); + } } diff --git a/cli/src/lib.rs b/cli/src/lib.rs index f86e01dc..96dbf2d2 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -11,6 +11,7 @@ pub fn run() -> ExitCode { if let Some(level) = cli::resolve_log_level(&args) { logger::set_global_log_level(level.to_logger_level()); + tnmsd::infra::logger::set_global_log_level(level.to_sdk_logger_level()); } match cli::resolve_command(&args) { diff --git a/doc/content/cli/install.mdx b/doc/content/cli/install.mdx index 430d0e6d..2e86528d 100644 --- a/doc/content/cli/install.mdx +++ b/doc/content/cli/install.mdx @@ -25,13 +25,21 @@ keywords: ## 安装 CLI -`@truenine/memory-sync-cli` 现在是 metadata-only 主包,不再直接暴露 `tnmsc`。全局安装时请直接选择与你宿主机匹配的 native 子包: +`@truenine/memory-sync-cli` 现在会自动为当前受支持的平台安装对应的 native 子包,并直接暴露 `tnmsc`。全局安装时优先使用主包: -- Linux x64 (glibc): `npm install -g @truenine/memory-sync-cli-linux-x64-gnu` -- Linux arm64 (glibc): `npm install -g @truenine/memory-sync-cli-linux-arm64-gnu` -- macOS arm64: `npm install -g @truenine/memory-sync-cli-darwin-arm64` -- macOS x64: `npm install -g @truenine/memory-sync-cli-darwin-x64` -- Windows x64: `npm install -g @truenine/memory-sync-cli-win32-x64-msvc` +```sh +npm install -g @truenine/memory-sync-cli +``` + +当前发布的 npm native 子包仍然覆盖下面这些平台组合: + +- Linux x64 (glibc) +- Linux arm64 (glibc) +- macOS arm64 +- macOS x64 +- Windows x64 + +如果你在 Linux 上使用 musl 或 Alpine,当前发布的 npm 二进制还不能直接使用;请改用受支持的 glibc 环境。 如果你是在 monorepo 内部开发,通常会改为在仓库根目录运行: diff --git a/doc/content/mcp/index.mdx b/doc/content/mcp/index.mdx index 828e1cdc..0ca62629 100644 --- a/doc/content/mcp/index.mdx +++ b/doc/content/mcp/index.mdx @@ -7,7 +7,7 @@ status: stable # MCP -`mcp/` 是当前仓库中的独立包。它以 `@truenine/memory-sync-mcp` 的形式发布,实际可执行入口由 `@truenine/memory-sync-mcp-` 平台子包提供,命令名是 `tnmsm`。它不是文档里的抽象概念,而是一个真实的 stdio server。 +`mcp/` 是当前仓库中的独立包。它以 `@truenine/memory-sync-mcp` 的形式发布,主包会自动解析并安装匹配的平台 native 子包,命令名是 `tnmsm`。它不是文档里的抽象概念,而是一个真实的 stdio server。 ## 它负责什么 diff --git a/doc/content/mcp/server-tools.mdx b/doc/content/mcp/server-tools.mdx index 26c508f7..b67c616e 100644 --- a/doc/content/mcp/server-tools.mdx +++ b/doc/content/mcp/server-tools.mdx @@ -11,10 +11,18 @@ status: stable 当前公开入口是 Rust 二进制 `tnmsm`: -- npm metadata 主包:`@truenine/memory-sync-mcp` +- npm 主包:`@truenine/memory-sync-mcp` - 平台二进制子包:`@truenine/memory-sync-mcp-` - stdio 命令入口:`tnmsm` +推荐安装方式是直接安装主包: + +```sh +npm install -g @truenine/memory-sync-mcp +``` + +当前 npm 二进制仍然只覆盖 glibc Linux、macOS 和 Windows x64;Linux musl/Alpine 还不在发布范围内。 + 仓库内部还保留了隐藏的 `assemble-npm` 打包命令,用来把 release binary 组装进各个平台子包,但它不是给最终用户直接调用的公开能力。 ## 当前暴露的工具 diff --git a/doc/content/quick-guide/quick-install.mdx b/doc/content/quick-guide/quick-install.mdx index 49b4a807..97c47604 100644 --- a/doc/content/quick-guide/quick-install.mdx +++ b/doc/content/quick-guide/quick-install.mdx @@ -12,15 +12,13 @@ keywords: # 快速安装 -如果你只是想先把 CLI 装上,直接使用你偏好的包管理器: +如果你只是想先把 CLI 装上,直接安装主包: -请直接安装与你平台匹配的 native CLI 包: +```sh +npm install -g @truenine/memory-sync-cli +``` -- Linux x64 (glibc): `@truenine/memory-sync-cli-linux-x64-gnu` -- Linux arm64 (glibc): `@truenine/memory-sync-cli-linux-arm64-gnu` -- macOS arm64: `@truenine/memory-sync-cli-darwin-arm64` -- macOS x64: `@truenine/memory-sync-cli-darwin-x64` -- Windows x64: `@truenine/memory-sync-cli-win32-x64-msvc` +当前主包会自动为受支持的平台拉取对应的 native 子包。已覆盖的平台组合是 Linux x64/arm64 (glibc)、macOS x64/arm64 和 Windows x64。Linux musl 或 Alpine 目前还不在 npm 二进制支持范围内。 然后确认命令可以正常工作: diff --git a/doc/content/technical-details/documentation-components.mdx b/doc/content/technical-details/documentation-components.mdx index 8636468b..80d2a17d 100644 --- a/doc/content/technical-details/documentation-components.mdx +++ b/doc/content/technical-details/documentation-components.mdx @@ -152,14 +152,12 @@ status: stable ## 5. 包管理器安装标签 -`PackageManagerTabs` 仍然适合“同一个包名,不同包管理器命令”的场景。现在 CLI 改成了按平台/架构发布 native 包,所以安装页更适合直接列出平台包名,而不是继续硬套一个统一包名示例。 +`PackageManagerTabs` 仍然适合“同一个包名,不同包管理器命令”的场景。现在 CLI 主包会自动解析并安装对应的平台 native 子包,所以安装页更适合直接展示统一主包命令,再额外说明 Linux musl/Alpine 这类暂不支持的环境。 ```mdx -- Linux x64 (glibc): `npm install -g @truenine/memory-sync-cli-linux-x64-gnu` -- Linux arm64 (glibc): `npm install -g @truenine/memory-sync-cli-linux-arm64-gnu` -- macOS arm64: `npm install -g @truenine/memory-sync-cli-darwin-arm64` -- macOS x64: `npm install -g @truenine/memory-sync-cli-darwin-x64` -- Windows x64: `npm install -g @truenine/memory-sync-cli-win32-x64-msvc` +npm install -g @truenine/memory-sync-cli + +> Linux musl / Alpine is not covered by the published npm native binaries yet. ``` ## 用法 diff --git a/doc/package.json b/doc/package.json index 8637fb45..269400e5 100644 --- a/doc/package.json +++ b/doc/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-docs", - "version": "2026.10422.10749", + "version": "2026.10424.111", "private": true, "packageManager": "pnpm@10.33.0", "description": "Chinese-first manifesto-led documentation site for @truenine/memory-sync.", diff --git a/gui/package.json b/gui/package.json index 4d5a8c2f..f1bf5139 100644 --- a/gui/package.json +++ b/gui/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-gui", - "version": "2026.10422.10749", + "version": "2026.10424.111", "private": true, "engines": { "node": ">= 22" diff --git a/gui/src-tauri/Cargo.toml b/gui/src-tauri/Cargo.toml index 553b9246..f01a81b9 100644 --- a/gui/src-tauri/Cargo.toml +++ b/gui/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tnmsg" -version = "2026.10422.10749" +version = "2026.10424.111" description = "Memory Sync desktop GUI application" authors.workspace = true edition.workspace = true diff --git a/gui/src-tauri/tauri.conf.json b/gui/src-tauri/tauri.conf.json index 67066f4c..28f8c704 100644 --- a/gui/src-tauri/tauri.conf.json +++ b/gui/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "$schema": "https://schema.tauri.app/config/2", - "version": "2026.10422.10749", + "version": "2026.10424.111", "productName": "Memory Sync", "identifier": "org.truenine.memory-sync", "build": { diff --git a/mcp/bin/tnmsm.js b/mcp/bin/tnmsm.js new file mode 100644 index 00000000..8f6bb584 --- /dev/null +++ b/mcp/bin/tnmsm.js @@ -0,0 +1,148 @@ +#!/usr/bin/env node +'use strict'; + +const {spawnSync} = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); + +const PACKAGE_NAME = '@truenine/memory-sync-mcp'; +const BINARY_NAME = 'tnmsm'; +const SUPPORTED_TARGETS = [ + 'linux-x64-gnu', + 'linux-arm64-gnu', + 'darwin-x64', + 'darwin-arm64', + 'win32-x64-msvc', +].join(', '); + +const PLATFORM_PACKAGES = { + darwin: { + arm64: '@truenine/memory-sync-mcp-darwin-arm64', + x64: '@truenine/memory-sync-mcp-darwin-x64', + }, + linux: { + arm64: '@truenine/memory-sync-mcp-linux-arm64-gnu', + x64: '@truenine/memory-sync-mcp-linux-x64-gnu', + }, + win32: { + x64: '@truenine/memory-sync-mcp-win32-x64-msvc', + }, +}; + +function fail(message) { + console.error(`${PACKAGE_NAME}: ${message}`); + process.exit(1); +} + +function detectLinuxLibc() { + const report = process.report; + if (report == null || typeof report.getReport !== 'function') { + return 'unknown'; + } + + const header = report.getReport()?.header; + if (header == null || typeof header !== 'object') { + return 'unknown'; + } + + if (header.glibcVersionRuntime || header.glibcVersionCompiler) { + return 'glibc'; + } + + return 'unknown'; +} + +function resolvePlatformPackageName() { + const archMap = PLATFORM_PACKAGES[process.platform]; + if (archMap == null) { + fail( + `Unsupported platform ${process.platform}/${process.arch}. Supported npm targets: ${SUPPORTED_TARGETS}.`, + ); + } + + const packageName = archMap[process.arch]; + if (packageName == null) { + fail( + `Unsupported architecture ${process.platform}/${process.arch}. Supported npm targets: ${SUPPORTED_TARGETS}.`, + ); + } + + if (process.platform === 'linux' && detectLinuxLibc() !== 'glibc') { + fail( + 'Linux npm binaries currently require glibc. musl/Alpine environments are not supported by the published packages.', + ); + } + + return packageName; +} + +function readJson(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch (error) { + fail(`Failed to read ${filePath}: ${error.message}`); + } +} + +function resolveBinaryPath(packageName) { + let manifestPath; + try { + manifestPath = require.resolve(`${packageName}/package.json`); + } catch (error) { + fail( + `Missing optional native package ${packageName}. Reinstall ${PACKAGE_NAME} on a supported platform so npm can fetch the matching binary package. Original error: ${error.message}`, + ); + } + + const manifest = readJson(manifestPath); + const packageDir = path.dirname(manifestPath); + const binField = manifest.bin; + let relativeBinaryPath; + + if (typeof binField === 'string') { + relativeBinaryPath = binField; + } else if (binField != null && typeof binField === 'object') { + if (typeof binField[BINARY_NAME] === 'string') { + relativeBinaryPath = binField[BINARY_NAME]; + } else { + relativeBinaryPath = Object.values(binField).find(value => typeof value === 'string'); + } + } + + if (typeof relativeBinaryPath !== 'string' || relativeBinaryPath.length === 0) { + fail(`Package ${packageName} does not declare a ${BINARY_NAME} binary entry.`); + } + + const binaryPath = path.resolve(packageDir, relativeBinaryPath); + if (!fs.existsSync(binaryPath)) { + fail( + `Native binary ${binaryPath} is missing from ${packageName}. Reinstall ${PACKAGE_NAME} and try again.`, + ); + } + + return binaryPath; +} + +function runNativeBinary(binaryPath) { + const result = spawnSync(binaryPath, process.argv.slice(2), { + env: process.env, + stdio: 'inherit', + }); + + if (result.error != null) { + fail(`Failed to launch ${binaryPath}: ${result.error.message}`); + } + + if (typeof result.status === 'number') { + process.exit(result.status); + } + + if (result.signal != null) { + process.kill(process.pid, result.signal); + return; + } + + process.exit(1); +} + +runNativeBinary(resolveBinaryPath(resolvePlatformPackageName())); diff --git a/mcp/integrate-tests/src/lib.rs b/mcp/integrate-tests/src/lib.rs index 7c0b7caa..3fc09398 100644 --- a/mcp/integrate-tests/src/lib.rs +++ b/mcp/integrate-tests/src/lib.rs @@ -255,6 +255,7 @@ pub fn create_staged_package_root() -> StagedPackageRoot { &mcp_manifest_dir().join("package.json"), &package_root.join("package.json"), ); + copy_dir_all(&mcp_manifest_dir().join("bin"), &package_root.join("bin")); copy_file( &mcp_manifest_dir() .join("npm") @@ -266,8 +267,6 @@ pub fn create_staged_package_root() -> StagedPackageRoot { .join("package.json"), ); - rewrite_main_package_json(&package_root.join("package.json")); - let linux_binary = package_root .join("npm") .join("linux-x64-gnu") @@ -299,12 +298,16 @@ pub fn pack_mcp_artifacts() -> PackedArtifacts { ); assemble.assert_success("tnmsm assemble-npm for staged package root"); - let mcp_tarball = pack_package(&staged.package_root, temp_dir.path(), "mcp"); let linux_tarball = pack_package( &staged.package_root.join("npm").join("linux-x64-gnu"), temp_dir.path(), "linux-x64-gnu", ); + rewrite_main_package_json( + &staged.package_root.join("package.json"), + "file:/artifacts/linux-x64-gnu.tgz", + ); + let mcp_tarball = pack_package(&staged.package_root, temp_dir.path(), "mcp"); PackedArtifacts { _temp_dir: temp_dir, @@ -317,10 +320,9 @@ pub fn install_packaged_mcp_container() -> TestContainer { let artifacts = pack_mcp_artifacts(); let container = TestContainer::start(&artifacts); let install_command = format!( - "corepack enable && corepack prepare pnpm@{} --activate && pnpm add -g {} {}", + "corepack enable && corepack prepare pnpm@{} --activate && pnpm add -g {}", quote_shell(pnpm_version()), - quote_shell("/artifacts/mcp.tgz"), - quote_shell("/artifacts/linux-x64-gnu.tgz") + quote_shell("/artifacts/mcp.tgz") ); container.exec_success(&install_command); container @@ -392,7 +394,7 @@ fn pack_package(package_dir: &Path, target_root: &Path, name: &str) -> PathBuf { tarballs.remove(0) } -fn rewrite_main_package_json(path: &Path) { +fn rewrite_main_package_json(path: &Path, platform_dependency: &str) { let raw = fs::read_to_string(path) .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); let mut parsed: serde_json::Value = serde_json::from_str(&raw) @@ -407,7 +409,7 @@ fn rewrite_main_package_json(path: &Path) { object.insert( "optionalDependencies".to_string(), serde_json::json!({ - "@truenine/memory-sync-mcp-linux-x64-gnu": current_package_version() + "@truenine/memory-sync-mcp-linux-x64-gnu": platform_dependency }), ); @@ -434,6 +436,31 @@ fn copy_file(source: &Path, destination: &Path) { }); } +fn copy_dir_all(source: &Path, destination: &Path) { + fs::create_dir_all(destination) + .unwrap_or_else(|error| panic!("failed to create {}: {error}", destination.display())); + + for entry in fs::read_dir(source) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", source.display())) + { + let entry = + entry.unwrap_or_else(|error| panic!("failed to read entry in {}: {error}", source.display())); + let file_type = entry.file_type().unwrap_or_else(|error| { + panic!( + "failed to read file type for {}: {error}", + entry.path().display() + ) + }); + let destination_path = destination.join(entry.file_name()); + + if file_type.is_dir() { + copy_dir_all(&entry.path(), &destination_path); + } else { + copy_file(&entry.path(), &destination_path); + } + } +} + fn command_output(command: &mut Command, label: &str) -> CommandResult { let output = command .output() diff --git a/mcp/integrate-tests/tests/packaging_smoke.rs b/mcp/integrate-tests/tests/packaging_smoke.rs index afb710b7..4c865f8b 100644 --- a/mcp/integrate-tests/tests/packaging_smoke.rs +++ b/mcp/integrate-tests/tests/packaging_smoke.rs @@ -70,6 +70,7 @@ MAIN_PACKAGE_JSON="$(find -L /pnpm/global -path '*/@truenine/memory-sync-mcp/pac PLATFORM_PACKAGE_JSON="$(find -L /pnpm/global -path '*/@truenine/memory-sync-mcp-linux-x64-gnu/package.json' -print -quit)" test -n "$MAIN_PACKAGE_JSON" test -n "$PLATFORM_PACKAGE_JSON" +test -f "$(dirname "$MAIN_PACKAGE_JSON")/bin/tnmsm.js" test -x "$(dirname "$PLATFORM_PACKAGE_JSON")/bin/tnmsm" test -x "$(command -v tnmsm)" "#, diff --git a/mcp/npm/darwin-arm64/package.json b/mcp/npm/darwin-arm64/package.json index 80b5f95c..90f0ef87 100644 --- a/mcp/npm/darwin-arm64/package.json +++ b/mcp/npm/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-mcp-darwin-arm64", - "version": "2026.10422.10749", + "version": "2026.10424.111", "description": "tnmsm native binary for macOS arm64", "author": "TrueNine", "license": "AGPL-3.0-only", diff --git a/mcp/npm/darwin-x64/package.json b/mcp/npm/darwin-x64/package.json index 9cd4ff35..aac18393 100644 --- a/mcp/npm/darwin-x64/package.json +++ b/mcp/npm/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-mcp-darwin-x64", - "version": "2026.10422.10749", + "version": "2026.10424.111", "description": "tnmsm native binary for macOS x64", "author": "TrueNine", "license": "AGPL-3.0-only", diff --git a/mcp/npm/linux-arm64-gnu/package.json b/mcp/npm/linux-arm64-gnu/package.json index 48206775..e95b95d4 100644 --- a/mcp/npm/linux-arm64-gnu/package.json +++ b/mcp/npm/linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-mcp-linux-arm64-gnu", - "version": "2026.10422.10749", + "version": "2026.10424.111", "description": "tnmsm native binary for Linux arm64 (glibc)", "author": "TrueNine", "license": "AGPL-3.0-only", diff --git a/mcp/npm/linux-x64-gnu/package.json b/mcp/npm/linux-x64-gnu/package.json index 3bd73b60..f5cd7cd0 100644 --- a/mcp/npm/linux-x64-gnu/package.json +++ b/mcp/npm/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-mcp-linux-x64-gnu", - "version": "2026.10422.10749", + "version": "2026.10424.111", "description": "tnmsm native binary for Linux x64 (glibc)", "author": "TrueNine", "license": "AGPL-3.0-only", diff --git a/mcp/npm/win32-x64-msvc/package.json b/mcp/npm/win32-x64-msvc/package.json index b5177d54..2c450d08 100644 --- a/mcp/npm/win32-x64-msvc/package.json +++ b/mcp/npm/win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-mcp-win32-x64-msvc", - "version": "2026.10422.10749", + "version": "2026.10424.111", "description": "tnmsm native binary for Windows x64", "author": "TrueNine", "license": "AGPL-3.0-only", diff --git a/mcp/package.json b/mcp/package.json index e28b38e6..1618821a 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-mcp", - "version": "2026.10422.10749", + "version": "2026.10424.111", "description": "TrueNine Memory Sync MCP metadata package", "author": "TrueNine", "license": "AGPL-3.0-only", @@ -10,10 +10,15 @@ "url": "git+https://github.com/TrueNine/memory-sync.git", "directory": "mcp" }, + "bin": { + "tnmsm": "./bin/tnmsm.js" + }, "exports": { "./package.json": "./package.json" }, - "files": [], + "files": [ + "bin/tnmsm.js" + ], "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" @@ -27,10 +32,10 @@ "test": "cargo test --manifest-path Cargo.toml" }, "optionalDependencies": { - "@truenine/memory-sync-mcp-darwin-arm64": "2026.10422.10749", - "@truenine/memory-sync-mcp-darwin-x64": "2026.10422.10749", - "@truenine/memory-sync-mcp-linux-arm64-gnu": "2026.10422.10749", - "@truenine/memory-sync-mcp-linux-x64-gnu": "2026.10422.10749", - "@truenine/memory-sync-mcp-win32-x64-msvc": "2026.10422.10749" + "@truenine/memory-sync-mcp-darwin-arm64": "2026.10424.111", + "@truenine/memory-sync-mcp-darwin-x64": "2026.10424.111", + "@truenine/memory-sync-mcp-linux-arm64-gnu": "2026.10424.111", + "@truenine/memory-sync-mcp-linux-x64-gnu": "2026.10424.111", + "@truenine/memory-sync-mcp-win32-x64-msvc": "2026.10424.111" } } diff --git a/package.json b/package.json index 04b22b03..322fecbb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync", - "version": "2026.10422.10749", + "version": "2026.10424.111", "description": "Cross-AI-tool prompt synchronisation toolkit (CLI + Tauri desktop GUI) — one ruleset, multi-target adaptation. Monorepo powered by pnpm + Turbo.", "license": "AGPL-3.0-only", "keywords": [ diff --git a/sdk/src/domain/output_plans/opencode_output_plan.rs b/sdk/src/domain/output_plans/opencode_output_plan.rs index aff251b4..07ccb0ce 100644 --- a/sdk/src/domain/output_plans/opencode_output_plan.rs +++ b/sdk/src/domain/output_plans/opencode_output_plan.rs @@ -280,7 +280,7 @@ fn build_agent_content(agent: &crate::domain::plugin_shared::SubAgentPrompt) -> metadata.insert("agent".to_string(), Value::String(agent_source)); // opencode requires an explicit subagent mode marker here. // Without this field, the generated entry is treated as a main agent instead of a subagent. - metadata.insert("mode".to_string(), Value::String("subagnet".to_string())); + metadata.insert("mode".to_string(), Value::String("subagent".to_string())); // NOTE: `model` is a future feature for per-agent model override. // It is intentionally stripped from output until the feature is designed and implemented. @@ -660,7 +660,7 @@ mod tests { let agent = make_test_agent(Some("blue".to_string())); let result = build_agent_content(&agent); assert!( - result.contains("mode: subagnet") || result.contains("mode: \"subagnet\""), + result.contains("mode: subagent") || result.contains("mode: \"subagent\""), "subagent mode should always be emitted, got:\n{result}" ); assert!( @@ -688,7 +688,7 @@ mod tests { let agent = make_test_agent(Some("#0000FF".to_string())); let result = build_agent_content(&agent); assert!( - result.contains("mode: subagnet") || result.contains("mode: \"subagnet\""), + result.contains("mode: subagent") || result.contains("mode: \"subagent\""), "subagent mode should always be emitted, got:\n{result}" ); assert!( @@ -698,13 +698,13 @@ mod tests { } /// Regression guard: opencode must see generated entries as subagents. - /// Without `mode: "subagnet"`, opencode treats the generated file as a main agent. + /// Without `mode: "subagent"`, opencode treats the generated file as a main agent. #[test] fn build_agent_content_forces_subagent_mode() { let agent = make_test_agent(None); let result = build_agent_content(&agent); assert!( - result.contains("mode: subagnet") || result.contains("mode: \"subagnet\""), + result.contains("mode: subagent") || result.contains("mode: \"subagent\""), "subagent mode should always be emitted, got:\n{result}" ); } diff --git a/sdk/src/services/clean_service.rs b/sdk/src/services/clean_service.rs index 05f707f1..22c89187 100644 --- a/sdk/src/services/clean_service.rs +++ b/sdk/src/services/clean_service.rs @@ -9,12 +9,14 @@ use crate::policy::cleanup::{ CleanupDeclarationsDto, CleanupSnapshot, CleanupTargetDto, CleanupTargetKindDto, PluginCleanupSnapshotDto, }; +use crate::services::command_diagnostics::build_workspace_mismatch_warning; use crate::{CliError, MemorySyncCommandOptions, MemorySyncCommandResult}; pub fn clean(options: MemorySyncCommandOptions) -> Result { let cwd = resolve_cwd(options.cwd.as_deref())?; let config_result = load_config(&cwd, options.load_user_config)?; let workspace_dir = resolve_workspace_dir(&cwd, &config_result.config)?; + let workspace_warning = build_workspace_mismatch_warning(&cwd, &workspace_dir, &config_result); let workspace_dir_str = workspace_dir.to_string_lossy().into_owned(); let global_scope = build_global_scope(&config_result.config); @@ -33,6 +35,15 @@ pub fn clean(options: MemorySyncCommandOptions) -> Result>(); + warnings.extend(plan.violations.iter().map(|v| { + json!({ + "type": "violation", + "target": v.target_path, + "protected": v.protected_path, + "reason": v.reason + }) + })); Ok(MemorySyncCommandResult { success: plan.conflicts.is_empty() && plan.violations.is_empty(), files_affected: plan.files_to_delete.len() as i32, @@ -45,18 +56,7 @@ pub fn clean(options: MemorySyncCommandOptions) -> Result Result>(); + warnings.extend(result.violations.iter().map(|v| { + json!({ + "type": "violation", + "target": v.target_path, + "protected": v.protected_path, + "reason": v.reason }) - .collect::>(); + })); let mut errors = result .conflicts .iter() diff --git a/sdk/src/services/command_diagnostics.rs b/sdk/src/services/command_diagnostics.rs new file mode 100644 index 00000000..2a51c557 --- /dev/null +++ b/sdk/src/services/command_diagnostics.rs @@ -0,0 +1,107 @@ +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; + +use crate::domain::config::MergedConfigResult; + +pub(crate) fn build_workspace_mismatch_warning( + cwd: &Path, + workspace_dir: &Path, + config_result: &MergedConfigResult, +) -> Option { + if is_same_or_descendant(cwd, workspace_dir) { + return None; + } + + Some(json!({ + "type": "workspace_mismatch", + "message": "Current directory is outside configured workspaceDir. tnmsc will operate on the configured workspace instead of the current directory.", + "currentDir": normalize_display_path(cwd), + "workspaceDir": normalize_display_path(workspace_dir), + "configSources": config_result.sources, + })) +} + +fn is_same_or_descendant(path: &Path, base: &Path) -> bool { + let normalized_path = normalize_compare_path(path); + let normalized_base = normalize_compare_path(base); + + normalized_path == normalized_base || normalized_path.starts_with(&format!("{normalized_base}/")) +} + +fn normalize_compare_path(path: &Path) -> String { + let value = normalize_display_path(path).replace('\\', "/"); + if cfg!(windows) { + value.to_ascii_lowercase() + } else { + value + } +} + +fn normalize_display_path(path: &Path) -> String { + strip_unc_prefix(path).to_string_lossy().into_owned() +} + +fn strip_unc_prefix(path: &Path) -> PathBuf { + let value = path.to_string_lossy(); + if let Some(stripped) = value.strip_prefix(r"\\?\") { + PathBuf::from(stripped) + } else { + path.to_path_buf() + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use serde_json::json; + + use super::*; + use crate::domain::config::{MergedConfigResult, UserConfigFile}; + + fn merged_config_result() -> MergedConfigResult { + MergedConfigResult { + config: UserConfigFile::default(), + sources: vec!["C:/Users/truen/.aindex/.tnmsc.json".to_string()], + found: true, + } + } + + #[test] + fn workspace_mismatch_warning_is_none_for_workspace_root() { + let cwd = PathBuf::from("C:/workspace/memory-sync"); + let workspace_dir = PathBuf::from("C:/workspace/memory-sync"); + + let warning = build_workspace_mismatch_warning(&cwd, &workspace_dir, &merged_config_result()); + + assert!(warning.is_none()); + } + + #[test] + fn workspace_mismatch_warning_is_none_for_workspace_child() { + let cwd = PathBuf::from("C:/workspace/memory-sync/cli"); + let workspace_dir = PathBuf::from("C:/workspace/memory-sync"); + + let warning = build_workspace_mismatch_warning(&cwd, &workspace_dir, &merged_config_result()); + + assert!(warning.is_none()); + } + + #[test] + fn workspace_mismatch_warning_includes_context_when_cwd_is_outside_workspace() { + let cwd = PathBuf::from("C:/workspace/memory-sync"); + let workspace_dir = PathBuf::from("C:/temp/demo"); + + let warning = + build_workspace_mismatch_warning(&cwd, &workspace_dir, &merged_config_result()).unwrap(); + + assert_eq!(warning["type"], "workspace_mismatch"); + assert_eq!(warning["currentDir"], json!("C:/workspace/memory-sync")); + assert_eq!(warning["workspaceDir"], json!("C:/temp/demo")); + assert_eq!( + warning["configSources"], + json!(["C:/Users/truen/.aindex/.tnmsc.json"]) + ); + } +} diff --git a/sdk/src/services/dry_run_service.rs b/sdk/src/services/dry_run_service.rs index 8bc31851..5faf1436 100644 --- a/sdk/src/services/dry_run_service.rs +++ b/sdk/src/services/dry_run_service.rs @@ -7,12 +7,16 @@ use crate::context::OutputContext; use crate::domain::base_output_plans::{BaseOutputFileDeclarationDto, BaseOutputPlansDto}; use crate::domain::config::{self, ConfigLoader, PluginsConfig, UserConfigFile}; use crate::domain::output_plans::droid_output_plan::DroidOutputPlanDto; +use crate::services::command_diagnostics::build_workspace_mismatch_warning; use crate::{CliError, MemorySyncCommandOptions, MemorySyncCommandResult}; pub fn dry_run(options: MemorySyncCommandOptions) -> Result { let cwd = resolve_cwd(options.cwd.as_deref())?; let config_result = load_config(&cwd, options.load_user_config)?; let workspace_dir = resolve_workspace_dir(&cwd, &config_result.config)?; + let warnings = build_workspace_mismatch_warning(&cwd, &workspace_dir, &config_result) + .into_iter() + .collect(); let workspace_dir_str = workspace_dir.to_string_lossy().into_owned(); let global_scope = build_global_scope(&config_result.config); let enabled_plugins = EnabledPlugins::from_config(config_result.config.plugins.as_ref()); @@ -50,7 +54,7 @@ pub fn dry_run(options: MemorySyncCommandOptions) -> Result>(); let workspace_dir_str = workspace_dir.to_string_lossy().into_owned(); let global_scope = build_global_scope(&config_result.config); let enabled_plugins = EnabledPlugins::from_config(config_result.config.plugins.as_ref()); @@ -259,13 +263,23 @@ pub(crate) fn install( let context = collect_context(&workspace_dir_str, global_scope.as_ref(), enabled_plugins)?; let planned_outputs = build_output_files(&context, enabled_plugins)?; let execution = write_output_files(&planned_outputs)?; + warnings.extend(execution.warnings); Ok(MemorySyncCommandResult { success: execution.errors.is_empty(), files_affected: execution.files_affected as i32, dirs_affected: execution.dirs_affected as i32, - message: None, - warnings: execution.warnings, + message: Some( + if execution.files_affected == 0 && execution.dirs_affected == 0 { + "No files needed updates".to_string() + } else { + format!( + "Updated {} files and prepared {} directories", + execution.files_affected, execution.dirs_affected + ) + }, + ), + warnings, errors: execution.errors, }) } diff --git a/sdk/src/services/mod.rs b/sdk/src/services/mod.rs index 5c4dfc16..3afe57e0 100644 --- a/sdk/src/services/mod.rs +++ b/sdk/src/services/mod.rs @@ -1,4 +1,5 @@ pub mod clean_service; +pub mod command_diagnostics; pub mod dry_run_service; pub mod install_service; pub mod prompts;