Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ members = [
]

[workspace.package]
version = "2026.10324.10325"
version = "2026.10324.11958"
edition = "2024"
license = "AGPL-3.0-only"
authors = ["TrueNine"]
Expand Down
2 changes: 1 addition & 1 deletion cli/npm/darwin-arm64/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@truenine/memory-sync-cli-darwin-arm64",
"version": "2026.10324.10325",
"version": "2026.10324.11958",
"os": [
"darwin"
],
Expand Down
2 changes: 1 addition & 1 deletion cli/npm/darwin-x64/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@truenine/memory-sync-cli-darwin-x64",
"version": "2026.10324.10325",
"version": "2026.10324.11958",
"os": [
"darwin"
],
Expand Down
2 changes: 1 addition & 1 deletion cli/npm/linux-arm64-gnu/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@truenine/memory-sync-cli-linux-arm64-gnu",
"version": "2026.10324.10325",
"version": "2026.10324.11958",
"os": [
"linux"
],
Expand Down
2 changes: 1 addition & 1 deletion cli/npm/linux-x64-gnu/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@truenine/memory-sync-cli-linux-x64-gnu",
"version": "2026.10324.10325",
"version": "2026.10324.11958",
"os": [
"linux"
],
Expand Down
2 changes: 1 addition & 1 deletion cli/npm/win32-x64-msvc/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@truenine/memory-sync-cli-win32-x64-msvc",
"version": "2026.10324.10325",
"version": "2026.10324.11958",
"os": [
"win32"
],
Expand Down
2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@truenine/memory-sync-cli",
"type": "module",
"version": "2026.10324.10325",
"version": "2026.10324.11958",
"description": "TrueNine Memory Synchronization CLI",
"author": "TrueNine",
"license": "AGPL-3.0-only",
Expand Down
73 changes: 73 additions & 0 deletions cli/src/bridge/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode, Stdio};
use std::sync::{Mutex, OnceLock};

use crate::{
BridgeCommandResult, CliError,
Expand All @@ -25,6 +26,41 @@ fn strip_win_prefix(path: PathBuf) -> PathBuf {
}

const PACKAGE_NAME: &str = "@truenine/memory-sync-cli";
static PLUGIN_RUNTIME_CACHE: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
static NODE_CACHE: OnceLock<Mutex<Option<String>>> = OnceLock::new();

fn read_cached_success<T: Clone>(cache: &Mutex<Option<T>>) -> Option<T> {
match cache.lock() {
Ok(guard) => guard.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
}
}

fn store_cached_success<T: Clone>(cache: &Mutex<Option<T>>, value: &T) {
match cache.lock() {
Ok(mut guard) => {
*guard = Some(value.clone());
}
Err(poisoned) => {
*poisoned.into_inner() = Some(value.clone());
}
}
}

fn detect_with_cached_success<T: Clone, F>(cache: &Mutex<Option<T>>, detect: F) -> Option<T>
where
F: FnOnce() -> Option<T>,
{
if let Some(cached) = read_cached_success(cache) {
return Some(cached);
}

let detected = detect();
if let Some(value) = detected.as_ref() {
store_cached_success(cache, value);
}
detected
}

/// Locate the plugin runtime JS entry point.
///
Expand All @@ -37,6 +73,11 @@ const PACKAGE_NAME: &str = "@truenine/memory-sync-cli";
/// 6. npm/pnpm global install: `<global_root>/@truenine/memory-sync-cli/dist/plugin-runtime.mjs`
/// 7. Embedded JS extracted to `~/.aindex/.cache/plugin-runtime-<version>.mjs`
pub(crate) fn find_plugin_runtime() -> Option<PathBuf> {
let cache = PLUGIN_RUNTIME_CACHE.get_or_init(|| Mutex::new(None));
detect_with_cached_success(cache, detect_plugin_runtime)
}

fn detect_plugin_runtime() -> Option<PathBuf> {
let mut candidates: Vec<PathBuf> = Vec::new();

// Relative to binary location
Expand Down Expand Up @@ -166,6 +207,11 @@ fn extract_embedded_runtime() -> Option<PathBuf> {

/// Find the `node` executable.
pub(crate) fn find_node() -> Option<String> {
let cache = NODE_CACHE.get_or_init(|| Mutex::new(None));
detect_with_cached_success(cache, detect_node)
}

fn detect_node() -> Option<String> {
// Try `node` in PATH
if Command::new("node")
.arg("--version")
Expand Down Expand Up @@ -452,6 +498,8 @@ fn find_index_mjs() -> Option<PathBuf> {
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
use std::sync::Mutex;

#[test]
fn test_strip_win_prefix_with_prefix() {
Expand All @@ -473,4 +521,29 @@ mod tests {
let result = strip_win_prefix(path.clone());
assert_eq!(result, path);
}

#[test]
fn test_detect_with_cached_success_retries_until_success() {
let cache = Mutex::new(None);
let attempts = Cell::new(0);

let first = detect_with_cached_success(&cache, || {
attempts.set(attempts.get() + 1);
Option::<String>::None
});
assert_eq!(first, None);

let second = detect_with_cached_success(&cache, || {
attempts.set(attempts.get() + 1);
Some(String::from("node"))
});
assert_eq!(second, Some(String::from("node")));

let third = detect_with_cached_success(&cache, || {
attempts.set(attempts.get() + 1);
Some(String::from("other"))
});
assert_eq!(third, Some(String::from("node")));
assert_eq!(attempts.get(), 2);
}
}
50 changes: 50 additions & 0 deletions cli/src/config.plugins-fast-path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import {afterEach, describe, expect, it, vi} from 'vitest'

import {defineConfig} from './config'

const {collectInputContextMock} = vi.hoisted(() => ({
collectInputContextMock: vi.fn(async () => {
throw new Error('collectInputContext should not run for plugins fast path')
})
}))

vi.mock('./inputs/runtime', async importOriginal => {
const actual = await importOriginal<typeof import('./inputs/runtime')>()

return {
...actual,
collectInputContext: collectInputContextMock
}
})

afterEach(() => {
vi.clearAllMocks()
})

describe('defineConfig plugins fast path', () => {
it('skips input collection for plugins runtime commands', async () => {
const tempWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-plugins-fast-path-'))

try {
const result = await defineConfig({
loadUserConfig: false,
pipelineArgs: ['node', 'tnmsc', 'plugins', '--json'],
pluginOptions: {
workspaceDir: tempWorkspace,
plugins: []
}
})

expect(collectInputContextMock).not.toHaveBeenCalled()
expect(result.context.workspace.directory.path).toBe(tempWorkspace)
expect(result.context.aindexDir).toBe(path.join(tempWorkspace, 'aindex'))
expect(result.outputPlugins).toEqual([])
}
finally {
fs.rmSync(tempWorkspace, {recursive: true, force: true})
}
})
})
29 changes: 29 additions & 0 deletions cli/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,35 @@ describe('defineConfig', () => {
}
})

it('does not run builtin mutating input effects when shorthand plugins is explicitly empty', async () => {
const tempWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-shorthand-empty-plugins-'))
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-shorthand-empty-home-'))
const orphanSkillDir = path.join(tempWorkspace, 'aindex', 'dist', 'skills', 'orphan-skill')
const orphanSkillFile = path.join(orphanSkillDir, 'SKILL.md')

process.env.HOME = tempHome
process.env.USERPROFILE = tempHome
delete process.env.HOMEDRIVE
delete process.env.HOMEPATH

fs.mkdirSync(orphanSkillDir, {recursive: true})
fs.writeFileSync(orphanSkillFile, 'orphan\n', 'utf8')

try {
const result = await defineConfig({
workspaceDir: tempWorkspace,
plugins: []
})

expect(result.context.workspace.directory.path).toBe(tempWorkspace)
expect(fs.existsSync(orphanSkillFile)).toBe(true)
}
finally {
fs.rmSync(tempWorkspace, {recursive: true, force: true})
fs.rmSync(tempHome, {recursive: true, force: true})
}
})

it('accepts legacy input capabilities in pluginOptions.plugins without crashing', async () => {
const tempWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-legacy-input-capabilities-'))

Expand Down
Loading
Loading