From 27d5c072e642d07bc714b5eab759e5464ebfd355 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E6=97=A5=E5=A4=A9?= Date: Fri, 3 Apr 2026 11:10:36 +0800 Subject: [PATCH 1/7] Pass npm token into composite publish actions --- .github/actions/npm-auth-preflight/action.yml | 5 ++++- .github/actions/npm-publish-package/action.yml | 5 ++++- .github/workflows/release-cli.yml | 5 +++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/actions/npm-auth-preflight/action.yml b/.github/actions/npm-auth-preflight/action.yml index 65737313..f24d0a70 100644 --- a/.github/actions/npm-auth-preflight/action.yml +++ b/.github/actions/npm-auth-preflight/action.yml @@ -2,6 +2,9 @@ name: NPM Auth Preflight description: Validate npm authentication and report package access inputs: + npm-token: + description: npm authentication token + required: true registry-url: description: npm registry URL required: false @@ -19,7 +22,7 @@ runs: - name: Preflight npm auth shell: bash env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NODE_AUTH_TOKEN: ${{ inputs.npm-token }} REGISTRY_URL: ${{ inputs.registry-url }} PACKAGE_DIR: ${{ inputs.package-dir }} PACKAGE_NAME: ${{ inputs.package-name }} diff --git a/.github/actions/npm-publish-package/action.yml b/.github/actions/npm-publish-package/action.yml index 5f933126..0a62f444 100644 --- a/.github/actions/npm-publish-package/action.yml +++ b/.github/actions/npm-publish-package/action.yml @@ -2,6 +2,9 @@ name: NPM Publish Package description: Publish a package to npm with retry and verification logic inputs: + npm-token: + description: npm authentication token + required: true registry-url: description: npm registry URL required: false @@ -24,7 +27,7 @@ runs: - name: Publish to npm shell: bash env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NODE_AUTH_TOKEN: ${{ inputs.npm-token }} REGISTRY_URL: ${{ inputs.registry-url }} PACKAGE_DIR: ${{ inputs.package-dir }} VERIFY_ATTEMPTS: ${{ inputs.verify-attempts }} diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index 51b4f6f4..96d7a6db 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -251,6 +251,7 @@ jobs: - name: Publish CLI platform sub-packages uses: ./.github/actions/npm-publish-package with: + npm-token: ${{ secrets.NPM_TOKEN }} registry-url: ${{ env.NPM_REGISTRY_URL }} package-dir: cli/npm @@ -267,6 +268,7 @@ jobs: registry-url: https://registry.npmjs.org/ - uses: ./.github/actions/npm-auth-preflight with: + npm-token: ${{ secrets.NPM_TOKEN }} registry-url: ${{ env.NPM_REGISTRY_URL }} package-dir: cli package-name: "@truenine/memory-sync-cli" @@ -274,6 +276,7 @@ jobs: run: pnpm -F @truenine/memory-sync-cli run build - uses: ./.github/actions/npm-publish-package with: + npm-token: ${{ secrets.NPM_TOKEN }} registry-url: ${{ env.NPM_REGISTRY_URL }} package-dir: cli @@ -292,6 +295,7 @@ jobs: registry-url: https://registry.npmjs.org/ - uses: ./.github/actions/npm-auth-preflight with: + npm-token: ${{ secrets.NPM_TOKEN }} registry-url: ${{ env.NPM_REGISTRY_URL }} package-dir: mcp package-name: "@truenine/memory-sync-mcp" @@ -299,6 +303,7 @@ jobs: run: pnpm exec turbo run build --filter=@truenine/memory-sync-mcp - uses: ./.github/actions/npm-publish-package with: + npm-token: ${{ secrets.NPM_TOKEN }} registry-url: ${{ env.NPM_REGISTRY_URL }} package-dir: mcp From 97d727a98d3fea8f47301755dd18abab610f9e07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E6=97=A5=E5=A4=A9?= Date: Fri, 3 Apr 2026 14:49:34 +0800 Subject: [PATCH 2/7] Consolidate docs config guidance and docs build setup --- doc/app/docs/[[...mdxPath]]/layout.tsx | 7 - doc/app/globals.scss | 15 +- doc/app/layout.tsx | 8 +- doc/components/docs-callout.tsx | 9 +- doc/content/cli/cli-commands.mdx | 16 +- doc/content/cli/index.mdx | 7 +- doc/content/cli/schema.mdx | 109 +++++-- doc/content/cli/troubleshooting.mdx | 2 +- doc/content/cli/workspace-setup.mdx | 17 +- doc/content/gui/index.mdx | 7 + doc/content/quick-guide/_meta.ts | 3 +- doc/content/quick-guide/aindex-and-config.mdx | 270 ++++++++++++++++++ doc/content/quick-guide/index.mdx | 6 +- doc/next.config.ts | 56 +--- doc/package.json | 4 +- doc/scripts/run-pagefind.ts | 63 ++++ doc/tsconfig.json | 7 + 17 files changed, 490 insertions(+), 116 deletions(-) create mode 100644 doc/content/quick-guide/aindex-and-config.mdx create mode 100644 doc/scripts/run-pagefind.ts diff --git a/doc/app/docs/[[...mdxPath]]/layout.tsx b/doc/app/docs/[[...mdxPath]]/layout.tsx index 75f132d3..a9f8afce 100644 --- a/doc/app/docs/[[...mdxPath]]/layout.tsx +++ b/doc/app/docs/[[...mdxPath]]/layout.tsx @@ -67,13 +67,6 @@ export default async function DocsLayout({ light: '亮色', system: '系统' }} - nextThemes={{ - attribute: 'class', - defaultTheme: 'dark', - disableTransitionOnChange: true, - forcedTheme: 'dark', - storageKey: 'memory-sync-docs-theme' - }} > {children} diff --git a/doc/app/globals.scss b/doc/app/globals.scss index 2a045563..ae938e49 100644 --- a/doc/app/globals.scss +++ b/doc/app/globals.scss @@ -620,10 +620,17 @@ samp { .nextra-toc a { font-size: 0.85rem; +} + +.nextra-toc a[class*='x:text-gray-600'] { color: var(--page-fg-soft); } -.nextra-toc a[data-active='true'] { +.nextra-toc a[class*='x:text-gray-600']:hover { + color: var(--page-fg); +} + +.nextra-toc a[class*='x:text-primary-600'] { color: var(--page-fg); font-weight: 600; } @@ -1169,6 +1176,12 @@ main[data-pagefind-body] blockquote p, --callout-symbol: '!'; } +.docs-callout--danger { + --callout-accent: #ef4444; + --callout-accent-strong: #fda4af; + --callout-symbol: '!'; +} + main[data-pagefind-body] table, .nextra-body-typesetting-article table { width: 100%; diff --git a/doc/app/layout.tsx b/doc/app/layout.tsx index b7398e36..48a62c3e 100644 --- a/doc/app/layout.tsx +++ b/doc/app/layout.tsx @@ -46,7 +46,13 @@ export const metadata: Metadata = { export default function RootLayout({children}: {readonly children: React.ReactNode}) { return ( - + {children} diff --git a/doc/components/docs-callout.tsx b/doc/components/docs-callout.tsx index ab3aecf9..998989db 100644 --- a/doc/components/docs-callout.tsx +++ b/doc/components/docs-callout.tsx @@ -1,19 +1,20 @@ import type {ComponentPropsWithoutRef, ReactElement, ReactNode} from 'react' import {Children, cloneElement, isValidElement} from 'react' -type CalloutTone = 'note' | 'tip' | 'important' | 'warning' | 'caution' +type CalloutTone = 'note' | 'tip' | 'important' | 'warning' | 'caution' | 'danger' type BlockquoteProps = ComponentPropsWithoutRef<'blockquote'> -const CALLOUT_PATTERN = /^\s*\[!(note|tip|important|warning|caution)\]\s*/i -const CALLOUT_TONES = new Set(['note', 'tip', 'important', 'warning', 'caution']) +const CALLOUT_PATTERN = /^\s*\[!(note|tip|important|warning|caution|danger)\]\s*/i +const CALLOUT_TONES = new Set(['note', 'tip', 'important', 'warning', 'caution', 'danger']) const CALLOUT_LABELS: Record = { note: 'Note', tip: 'Tip', important: 'Important', warning: 'Warning', - caution: 'Caution' + caution: 'Caution', + danger: 'Danger' } function extractText(node: ReactNode): string { diff --git a/doc/content/cli/cli-commands.mdx b/doc/content/cli/cli-commands.mdx index 707b6939..aaab96e1 100644 --- a/doc/content/cli/cli-commands.mdx +++ b/doc/content/cli/cli-commands.mdx @@ -28,19 +28,7 @@ The current implementation treats it as deprecated, so new docs no longer presen ### `config` Can Only Change a Whitelisted Set of Keys -The current `ConfigCommand` allows these keys: - -- `workspaceDir` -- `aindex.skills.src/dist` -- `aindex.commands.src/dist` -- `aindex.subAgents.src/dist` -- `aindex.rules.src/dist` -- `aindex.globalPrompt.src/dist` -- `aindex.workspacePrompt.src/dist` -- `aindex.app.src/dist` -- `aindex.ext.src/dist` -- `aindex.arch.src/dist` -- `logLevel` +The full `.aindex` and `.tnmsc.json` key list now lives in [aindex and `.tnmsc.json`](/docs/quick-guide/aindex-and-config), together with the config location and default path rules. ### `logLevel` Is Also Strictly Enumerated @@ -55,4 +43,4 @@ trace / debug / info / warn / error 1. Run `tnmsc help` first in a new environment. 2. Run `tnmsc dry-run` before writing outputs. 3. Run `tnmsc clean --dry-run` before cleanup. -4. If you really need to change global config, use `tnmsc config` instead of hand-editing JSON that can drift from the schema. +4. If you really need to change global config, use `tnmsc config` and verify the supported keys in [aindex and `.tnmsc.json`](/docs/quick-guide/aindex-and-config). diff --git a/doc/content/cli/index.mdx b/doc/content/cli/index.mdx index fa8665b6..532823db 100644 --- a/doc/content/cli/index.mdx +++ b/doc/content/cli/index.mdx @@ -12,17 +12,18 @@ This section is organized around the public `tnmsc` command surface. Questions s ## What This Section Covers - [Installation and Requirements](/docs/cli/install): confirm the Node, pnpm, Rust, and higher GUI development-engine boundaries. -- [Workspace and aindex](/docs/cli/workspace-setup): prepare the source directories and the project configuration entrypoint. +- [aindex and `.tnmsc.json`](/docs/quick-guide/aindex-and-config): prepare the source directories, the config file, and the path mapping from one page. +- [Workspace and aindex](/docs/cli/workspace-setup): understand the remaining workspace-side separation from `plugin.config.ts`. - [First Sync](/docs/cli/first-sync): run `help`, `dry-run`, and the real write flow in the recommended order. - [CLI Commands](/docs/cli/cli-commands): check the command surface currently exposed by `tnmsc --help`. - [dry-run and clean](/docs/cli/dry-run-and-clean): preview first, write second, clean last. -- [plugin.config.ts](/docs/cli/plugin-config) and [JSON Schema](/docs/cli/schema): verify configuration facts in one place. +- [plugin.config.ts](/docs/cli/plugin-config) and [JSON Schema](/docs/cli/schema): verify runtime assembly and schema-specific behavior. - [Output Scopes](/docs/cli/output-scopes), [Front Matter](/docs/cli/frontmatter), and [Cleanup Protection](/docs/cli/cleanup-protection): confirm boundary-control behavior. - [Supported Outputs](/docs/cli/supported-outputs), [Troubleshooting](/docs/cli/troubleshooting), and [Upgrade Notes](/docs/cli/upgrade-notes): handle day-to-day usage and version migration. ## Recommended Order 1. Start with [Installation and Requirements](/docs/cli/install). -2. Continue with [Workspace and aindex](/docs/cli/workspace-setup). +2. Continue with [aindex and `.tnmsc.json`](/docs/quick-guide/aindex-and-config). 3. Then use [First Sync](/docs/cli/first-sync) to complete one real run. 4. When you need to verify facts, come back to [CLI Commands](/docs/cli/cli-commands) and [JSON Schema](/docs/cli/schema). diff --git a/doc/content/cli/schema.mdx b/doc/content/cli/schema.mdx index e0b5e299..5f1ebd72 100644 --- a/doc/content/cli/schema.mdx +++ b/doc/content/cli/schema.mdx @@ -7,33 +7,32 @@ status: stable # JSON Schema -`cli/dist/tnmsc.schema.json` currently exposes these root fields: +The `.aindex` and `.tnmsc.json` setup details are now centralized in [aindex and `.tnmsc.json`](/docs/quick-guide/aindex-and-config). -| Field | Type | Description | -| --- | --- | --- | -| `version` | `string` | Config version or release version marker | -| `workspaceDir` | `string` | Project root directory | -| `aindex` | `object` | Mapping of source inputs to export directories | -| `logLevel` | enum | `trace` / `debug` / `info` / `warn` / `error` | -| `commandSeriesOptions` | `object` | Command-series naming and per-plugin overrides | -| `outputScopes` | `object` | Output-scope overrides per plugin | -| `frontMatter` | `object` | Front matter handling during output | -| `cleanupProtection` | `object` | Cleanup protection rules | -| `profile` | `object` | Profile fields | - -## Key `aindex` Children +This page keeps the schema-level behavior that is not specific to the config file location or aindex path layout. -- `skills` -- `commands` -- `subAgents` -- `rules` -- `globalPrompt` -- `workspacePrompt` -- `app` -- `ext` -- `arch` +## `commandSeriesOptions` + +```json +{ + "commandSeriesOptions": { + "includeSeriesPrefix": true, + "pluginOverrides": { + "some-plugin-name": { + "includeSeriesPrefix": false, + "seriesSeparator": "/" + } + } + } +} +``` -Each child contains `src` and `dist`. +| Field | Type | Meaning | +| --- | --- | --- | +| `commandSeriesOptions.includeSeriesPrefix` | `boolean` | Whether command output names include the series prefix | +| `commandSeriesOptions.pluginOverrides` | `Record` | Per-plugin overrides | +| `commandSeriesOptions.pluginOverrides..includeSeriesPrefix` | `boolean` | Override the top-level prefix behavior for one plugin | +| `commandSeriesOptions.pluginOverrides..seriesSeparator` | `string` | Override the separator used by one plugin | ## Topics Supported by `outputScopes` @@ -46,6 +45,26 @@ The schema currently allows these topics: - `skills` - `mcp` +Each topic accepts either a single scope or an array of scopes: + +```json +{ + "outputScopes": { + "plugins": { + "some-plugin-name": { + "prompt": "project", + "skills": ["global", "project"] + } + } + } +} +``` + +The only valid scope values are: + +- `project` +- `global` + See [Output Scopes](/docs/cli/output-scopes) for detailed behavior. ## `frontMatter` @@ -64,6 +83,21 @@ See [Front Matter](/docs/cli/frontmatter) for the distinction. ## `cleanupProtection` +```json +{ + "cleanupProtection": { + "rules": [ + { + "path": ".codex/skills/.system", + "protectionMode": "recursive", + "matcher": "path", + "reason": "Preserve built-in skills" + } + ] + } +} +``` + Each rule supports: - `path` @@ -72,3 +106,30 @@ Each rule supports: - `reason` See [Cleanup Protection](/docs/cli/cleanup-protection) for the semantics. + +## `windows` + +```json +{ + "windows": { + "wsl2": { + "instances": ["Ubuntu", "Ubuntu-24.04"] + } + } +} +``` + +| Field | Type | Meaning | +| --- | --- | --- | +| `windows.wsl2.instances` | `string \| string[]` | WSL instance name or names used by Windows-specific integration | + +## `profile` + +`profile` is an open object. The schema explicitly recognizes these optional fields: + +- `name` +- `username` +- `gender` +- `birthday` + +Additional keys are also accepted. diff --git a/doc/content/cli/troubleshooting.mdx b/doc/content/cli/troubleshooting.mdx index 3efa63e7..898277c8 100644 --- a/doc/content/cli/troubleshooting.mdx +++ b/doc/content/cli/troubleshooting.mdx @@ -13,7 +13,7 @@ The docs have moved from the old mixed grouping into seven top-level sections. S ## Symptom: `tnmsc init` Did Not Generate Anything -That is expected in the current implementation. It is deprecated and no longer initializes aindex. Maintain your source directories and global config manually instead. +That is expected in the current implementation. It is deprecated and no longer initializes aindex. Maintain your source directories and global config manually instead, and use [aindex and `.tnmsc.json`](/docs/quick-guide/aindex-and-config) as the single setup page. ## Symptom: Rules, Prompts, or MCP-Related Content Was Written to the Wrong Place diff --git a/doc/content/cli/workspace-setup.mdx b/doc/content/cli/workspace-setup.mdx index 26019f10..ebd936a9 100644 --- a/doc/content/cli/workspace-setup.mdx +++ b/doc/content/cli/workspace-setup.mdx @@ -7,16 +7,19 @@ status: stable # Workspace and aindex -## Remember This First +This topic is now centralized in [aindex and `.tnmsc.json`](/docs/quick-guide/aindex-and-config). -`memory-sync` does not treat target-tool config files as the source of truth. It generates outputs from the aindex source directory and the project configuration entrypoint. +Use that page if you need: + +- where `.aindex` actually lives +- where `~/.aindex/.tnmsc.json` is loaded from +- what each `aindex.*` field means +- which keys `tnmsc config` can change +- the current defaults and path rules -## What You Need at Minimum +## What Still Belongs Here -- A project root directory -- An aindex-style source-content directory -- A global config file -- A project-level `plugin.config.ts` +`memory-sync` does not treat target-tool config files as the source of truth. It generates outputs from the aindex source directory and the project configuration entrypoint. ## How Source Content Is Usually Split diff --git a/doc/content/gui/index.mdx b/doc/content/gui/index.mdx index f79c6cb4..47f8804d 100644 --- a/doc/content/gui/index.mdx +++ b/doc/content/gui/index.mdx @@ -7,6 +7,13 @@ status: stable # GUI +> [!danger] +> The GUI is not the primary maintenance target right now. The CLI is the main focus. +> +> If the GUI feels under-maintained, please excuse that. The team is small, and I may not actively maintain the GUI for a while. +> +> I expect to come back and continue maintaining the GUI after the core functionality is more complete. + `gui/` is the desktop invocation layer built with Tauri and React. Its job is not to become the center of the system architecture. Its job is to turn the configuration editing, execution, display, and log inspection exposed by the `tnmsc` crate in `sdk/` into a desktop workflow. ## What This Layer Owns diff --git a/doc/content/quick-guide/_meta.ts b/doc/content/quick-guide/_meta.ts index 4963ba39..8de6ddc6 100644 --- a/doc/content/quick-guide/_meta.ts +++ b/doc/content/quick-guide/_meta.ts @@ -1,4 +1,5 @@ export default { 'index': 'Overview', - 'quick-install': 'Quick Install' + 'quick-install': 'Quick Install', + 'aindex-and-config': 'aindex and Config' } diff --git a/doc/content/quick-guide/aindex-and-config.mdx b/doc/content/quick-guide/aindex-and-config.mdx new file mode 100644 index 00000000..66028ae9 --- /dev/null +++ b/doc/content/quick-guide/aindex-and-config.mdx @@ -0,0 +1,270 @@ +--- +title: aindex and .tnmsc.json +description: Explains in one place where `.aindex` lives, how `~/.aindex/.tnmsc.json` is loaded, and what every supported config field means. +sidebarTitle: aindex and Config +status: stable +--- + +# aindex and `.tnmsc.json` + +If you only need one page for project setup and config, use this one. + +## Two Things to Separate + +`memory-sync` treats these as different layers: + +- The source-content tree under your workspace aindex directory +- The canonical global user config file at `~/.aindex/.tnmsc.json` + +They are related, but they are not the same thing. + +## What Lives Where + +### Workspace content + +The aindex content tree lives under: + +```text +/ +``` + +With default settings, that becomes: + +```text +/aindex +``` + +This is where source content usually lives: + +- `skills/` +- `commands/` +- `subagents/` +- `rules/` +- `app/` +- `ext/` +- `arch/` +- `softwares/` +- `global.src.mdx` +- `workspace.src.mdx` + +### User config + +The only user config file that `tnmsc` auto-loads today is: + +```text +~/.aindex/.tnmsc.json +``` + +The current loader does not auto-discover `./.tnmsc.json` from the current project directory. + +## Minimal setup + +At minimum you need: + +- A project root directory +- An aindex-style source tree +- `~/.aindex/.tnmsc.json` +- A project-level [plugin.config.ts](/docs/cli/plugin-config) + +The separation is intentional: + +- `~/.aindex/.tnmsc.json` stores user or machine-level config data +- `plugin.config.ts` stores project-side plugin assembly and programmatic overrides + +## Minimal example + +```json +{ + "workspaceDir": "/repo/demo", + "aindex": { + "dir": "aindex" + }, + "logLevel": "info" +} +``` + +With that config: + +- the workspace root is `/repo/demo` +- the aindex root is `/repo/demo/aindex` +- `skills` resolves to `/repo/demo/aindex/skills` +- the default global prompt source resolves to `/repo/demo/aindex/global.src.mdx` + +## Root fields in `~/.aindex/.tnmsc.json` + +| Field | Type | Default | Meaning | +| --- | --- | --- | --- | +| `version` | `string` | `"0.0.0"` in merged runtime options | Version or release marker passed through the runtime | +| `workspaceDir` | `string` | `"~/project"` in merged runtime options | Project root directory | +| `aindex` | `object` | See the table below | Source and output path mapping | +| `logLevel` | enum | `info` | `trace` / `debug` / `info` / `warn` / `error` | +| `commandSeriesOptions` | `object` | `{}` | Command-series naming and per-plugin overrides | +| `outputScopes` | `object` | `{}` | Output-scope overrides per plugin | +| `frontMatter` | `object` | `{ "blankLineAfter": true }` | Front matter formatting behavior | +| `cleanupProtection` | `object` | `{}` | Cleanup protection rules | +| `windows` | `object` | `{}` | Windows and WSL-specific runtime options | +| `profile` | `object` | omitted | User profile metadata | + +## `aindex` fields + +All `aindex.*.src` and `aindex.*.dist` paths are resolved relative to: + +```text +/ +``` + +| Field | Type | Default | Meaning | +| --- | --- | --- | --- | +| `aindex.dir` | `string` | `aindex` | Name of the aindex root inside `workspaceDir` | +| `aindex.skills.src` | `string` | `skills` | Skill source tree | +| `aindex.skills.dist` | `string` | `dist/skills` | Compiled skill output tree | +| `aindex.commands.src` | `string` | `commands` | Command source tree | +| `aindex.commands.dist` | `string` | `dist/commands` | Compiled command output tree | +| `aindex.subAgents.src` | `string` | `subagents` | Sub-agent source tree | +| `aindex.subAgents.dist` | `string` | `dist/subagents` | Compiled sub-agent output tree | +| `aindex.rules.src` | `string` | `rules` | Rule source tree | +| `aindex.rules.dist` | `string` | `dist/rules` | Compiled rule output tree | +| `aindex.globalPrompt.src` | `string` | `global.src.mdx` | Source file for the root global prompt | +| `aindex.globalPrompt.dist` | `string` | `dist/global.mdx` | Generated global prompt file | +| `aindex.workspacePrompt.src` | `string` | `workspace.src.mdx` | Source file for the root workspace prompt | +| `aindex.workspacePrompt.dist` | `string` | `dist/workspace.mdx` | Generated workspace prompt file | +| `aindex.app.src` | `string` | `app` | Project-memory source tree for app projects | +| `aindex.app.dist` | `string` | `dist/app` | Generated app project-memory tree | +| `aindex.ext.src` | `string` | `ext` | Project-memory source tree for extension projects | +| `aindex.ext.dist` | `string` | `dist/ext` | Generated extension project-memory tree | +| `aindex.arch.src` | `string` | `arch` | Project-memory source tree for architecture projects | +| `aindex.arch.dist` | `string` | `dist/arch` | Generated architecture project-memory tree | +| `aindex.softwares.src` | `string` | `softwares` | Software-series source tree | +| `aindex.softwares.dist` | `string` | `dist/softwares` | Generated software-series tree | + +## `tnmsc config` can change only these config keys + +The current `ConfigCommand` allows: + +- `workspaceDir` +- `aindex.skills.src` +- `aindex.skills.dist` +- `aindex.commands.src` +- `aindex.commands.dist` +- `aindex.subAgents.src` +- `aindex.subAgents.dist` +- `aindex.rules.src` +- `aindex.rules.dist` +- `aindex.globalPrompt.src` +- `aindex.globalPrompt.dist` +- `aindex.workspacePrompt.src` +- `aindex.workspacePrompt.dist` +- `aindex.app.src` +- `aindex.app.dist` +- `aindex.ext.src` +- `aindex.ext.dist` +- `aindex.arch.src` +- `aindex.arch.dist` +- `aindex.softwares.src` +- `aindex.softwares.dist` +- `logLevel` + +`logLevel` can only be: + +```text +trace / debug / info / warn / error +``` + +## Other supported config objects + +### `commandSeriesOptions` + +```json +{ + "commandSeriesOptions": { + "includeSeriesPrefix": true, + "pluginOverrides": { + "some-plugin-name": { + "includeSeriesPrefix": false, + "seriesSeparator": "/" + } + } + } +} +``` + +| Field | Type | Meaning | +| --- | --- | --- | +| `commandSeriesOptions.includeSeriesPrefix` | `boolean` | Whether command output names include the series prefix | +| `commandSeriesOptions.pluginOverrides` | `Record` | Per-plugin overrides | +| `commandSeriesOptions.pluginOverrides..includeSeriesPrefix` | `boolean` | Override the top-level prefix behavior for one plugin | +| `commandSeriesOptions.pluginOverrides..seriesSeparator` | `string` | Override the separator used by one plugin | + +### `outputScopes` + +The schema currently allows these topics: + +- `prompt` +- `rules` +- `commands` +- `subagents` +- `skills` +- `mcp` + +Each topic accepts either a single scope or an array of scopes. The only valid scope values are: + +- `project` +- `global` + +See [Output Scopes](/docs/cli/output-scopes) for behavior details. + +### `frontMatter` + +```json +{ + "frontMatter": { + "blankLineAfter": true + } +} +``` + +See [Front Matter](/docs/cli/frontmatter) for the formatting distinction. + +### `cleanupProtection` + +Each rule supports: + +- `path` +- `protectionMode`: `direct` / `recursive` +- `matcher`: `path` / `glob` +- `reason` + +See [Cleanup Protection](/docs/cli/cleanup-protection) for cleanup semantics. + +### `windows` + +```json +{ + "windows": { + "wsl2": { + "instances": ["Ubuntu", "Ubuntu-24.04"] + } + } +} +``` + +| Field | Type | Meaning | +| --- | --- | --- | +| `windows.wsl2.instances` | `string \| string[]` | WSL instance name or names used by Windows-specific integration | + +### `profile` + +`profile` is an open object. The schema explicitly recognizes these optional fields: + +- `name` +- `username` +- `gender` +- `birthday` + +Additional keys are also accepted. + +## What to read next + +- If you want the command workflow, continue with [First Sync](/docs/cli/first-sync) +- If you need plugin assembly, read [plugin.config.ts](/docs/cli/plugin-config) +- If you need cleanup or output-boundary behavior, continue with [CLI](/docs/cli) diff --git a/doc/content/quick-guide/index.mdx b/doc/content/quick-guide/index.mdx index 0c166228..8fc78e0e 100644 --- a/doc/content/quick-guide/index.mdx +++ b/doc/content/quick-guide/index.mdx @@ -42,12 +42,16 @@ This diagram highlights the current dependency direction only: `sdk/` is the sin If you want to install the command first and decide between CLI, GUI, and MCP later, go straight to [Quick Install](/docs/quick-guide/quick-install). +## Config Setup in One Page + +If you need the real setup and config facts for `.aindex` and `~/.aindex/.tnmsc.json`, use [aindex and `.tnmsc.json`](/docs/quick-guide/aindex-and-config). That page now collects the setup path, config location, field meanings, defaults, and `tnmsc config` key whitelist in one place. + ## Shortest Starting Paths ### If You Start with CLI 1. Read [Installation and Requirements](/docs/cli/install). -2. Continue with [Workspace and aindex](/docs/cli/workspace-setup). +2. Continue with [aindex and `.tnmsc.json`](/docs/quick-guide/aindex-and-config). 3. Then use [First Sync](/docs/cli/first-sync) to run through the real flow once. ### If You Start with GUI diff --git a/doc/next.config.ts b/doc/next.config.ts index 298b3f2d..fe50beca 100644 --- a/doc/next.config.ts +++ b/doc/next.config.ts @@ -1,26 +1,8 @@ import type {NextConfig} from 'next' -import {fileURLToPath} from 'node:url' import nextra from 'nextra' -const mermaidTurbopackAlias = './components/mermaid' -const mermaidWebpackAlias = fileURLToPath(new URL('./components/mermaid.tsx', import.meta.url)) -const nextThemesTurbopackAlias = './lib/next-themes-compat' -const nextThemesWebpackAlias = fileURLToPath(new URL('./lib/next-themes-compat.tsx', import.meta.url)) - -interface WebpackResolveAliasEntry { - readonly alias: string - readonly name: string - readonly onlyModule: boolean - readonly target: string -} - -interface WebpackResolveConfig { - alias?: Record | WebpackResolveAliasEntry[] -} - -interface WebpackConfig { - resolve?: WebpackResolveConfig -} +const mermaidAliasPath = '@/components/mermaid' +const nextThemesAliasPath = '@/lib/next-themes-compat' const withNextra = nextra({ search: { @@ -71,37 +53,11 @@ const nextConfig: NextConfig = { }, turbopack: { resolveAlias: { - '@theguild/remark-mermaid/mermaid': mermaidTurbopackAlias, - 'next-themes': nextThemesTurbopackAlias + // Keep docs on the Turbopack path for both dev and build so our local + // compatibility shims are resolved the same way in every environment. + '@theguild/remark-mermaid/mermaid': mermaidAliasPath, + 'next-themes': nextThemesAliasPath } - }, - webpack(config: WebpackConfig) { - const resolve = config.resolve ?? {} - const {alias} = resolve - - config.resolve = resolve - - if (Array.isArray(alias)) { - alias.push({ - alias: '@theguild/remark-mermaid/mermaid', - name: '@theguild/remark-mermaid/mermaid', - onlyModule: false, - target: mermaidWebpackAlias - }, { - alias: 'next-themes', - name: 'next-themes', - onlyModule: false, - target: nextThemesWebpackAlias - }) - } else { - resolve.alias = { - ...alias, - '@theguild/remark-mermaid/mermaid': mermaidWebpackAlias, - 'next-themes': nextThemesWebpackAlias - } - } - - return config } } diff --git a/doc/package.json b/doc/package.json index 56b635b1..3fca9828 100644 --- a/doc/package.json +++ b/doc/package.json @@ -8,8 +8,8 @@ }, "scripts": { "dev": "next dev", - "build": "pnpm run validate:content && next build --webpack", - "postbuild": "pagefind --site .next/server/app --output-path public/_pagefind", + "build": "pnpm run validate:content && next build", + "postbuild": "tsx scripts/run-pagefind.ts", "check": "run-p lint check:type", "validate:content": "tsx scripts/validate-content.ts", "check:type": "next typegen && tsc --project tsconfig.typecheck.json --noEmit --incremental false", diff --git a/doc/scripts/run-pagefind.ts b/doc/scripts/run-pagefind.ts new file mode 100644 index 00000000..225a3767 --- /dev/null +++ b/doc/scripts/run-pagefind.ts @@ -0,0 +1,63 @@ +import {spawn} from 'node:child_process' +import {fileURLToPath} from 'node:url' + +const pagefindBin = fileURLToPath( + new URL('../node_modules/pagefind/lib/runner/bin.cjs', import.meta.url) +) +const pagefindArgs = [ + pagefindBin, + '--site', + '.next/server/app', + '--output-path', + 'public/_pagefind' +] as const + +const STEMMING_WARNING_LINES = new Set([ + "Note: Pagefind doesn't support stemming for the language zh-cn.", + 'Search will still work, but will not match across root words.' +]) + +function filterKnownNoise(output: string): string { + return output + .split(/\r?\n/u) + .filter(line => !STEMMING_WARNING_LINES.has(line.trim())) + .join('\n') + .replace(/\n+$/u, '\n') +} + +const child = spawn(process.execPath, pagefindArgs, { + cwd: process.cwd(), + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'] +}) + +let stdout = '' +let stderr = '' + +child.stdout.on('data', chunk => { + stdout += chunk.toString() +}) + +child.stderr.on('data', chunk => { + stderr += chunk.toString() +}) + +child.on('close', code => { + const filteredStdout = filterKnownNoise(stdout) + const filteredStderr = filterKnownNoise(stderr) + + if (filteredStdout !== '') { + process.stdout.write(filteredStdout) + } + + if (filteredStderr !== '') { + process.stderr.write(filteredStderr) + } + + process.exit(code ?? 1) +}) + +child.on('error', error => { + process.stderr.write(`${error.message}\n`) + process.exit(1) +}) diff --git a/doc/tsconfig.json b/doc/tsconfig.json index 540f9a5c..633c609f 100644 --- a/doc/tsconfig.json +++ b/doc/tsconfig.json @@ -3,6 +3,12 @@ "incremental": true, "target": "ES2022", "jsx": "react-jsx", + "baseUrl": ".", + "paths": { + "@/*": [ + "./*" + ] + }, "lib": [ "DOM", "DOM.Iterable", @@ -17,6 +23,7 @@ "allowJs": false, "strict": true, "noEmit": true, + "ignoreDeprecations": "6.0", "esModuleInterop": true, "isolatedModules": true, "skipLibCheck": true, From 98c977d5b6c35a04f2e6322363115afceb914335 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E6=97=A5=E5=A4=A9?= Date: Fri, 3 Apr 2026 17:46:12 +0800 Subject: [PATCH 3/7] Migrate GUI bridge output and remove deprecated init references --- cli/src/PluginPipeline.ts | 61 +++- cli/src/cli-runtime.test.ts | 66 +++- cli/src/cli-runtime.ts | 102 +++--- cli/src/cli.rs | 4 - cli/src/commands/CleanCommand.ts | 4 + cli/src/commands/Command.ts | 13 +- cli/src/commands/DryRunCleanCommand.ts | 4 + cli/src/commands/DryRunOutputCommand.ts | 4 + cli/src/commands/ExecuteCommand.ts | 4 + cli/src/commands/HelpCommand.ts | 2 - cli/src/commands/InitCommand.test.ts | 76 ----- cli/src/commands/InitCommand.ts | 24 -- cli/src/commands/JsonOutputCommand.ts | 32 +- cli/src/commands/bridge.rs | 20 +- cli/src/commands/execution-preflight.ts | 128 ++++++++ cli/src/commands/execution-routing.test.ts | 209 +++++++++++++ .../commands/factories/InitCommandFactory.ts | 14 - cli/src/commands/help.rs | 1 - cli/src/main.rs | 15 +- cli/src/pipeline/CliArgumentParser.test.ts | 6 +- cli/src/pipeline/CliArgumentParser.ts | 93 ++++-- cli/src/plugin-runtime.ts | 69 +++-- cli/src/plugin.config.ts | 4 +- doc/content/cli/cli-commands.mdx | 5 - doc/content/cli/install.mdx | 5 +- doc/content/cli/migration.mdx | 2 +- doc/content/cli/troubleshooting.mdx | 4 - doc/content/cli/upgrade-notes.mdx | 1 - doc/content/quick-guide/quick-install.mdx | 2 +- gui/src-tauri/src/commands.rs | 205 +++++++++++-- sdk/src/bridge/node.rs | 17 +- sdk/src/config.test.ts | 25 ++ sdk/src/config.ts | 34 +- sdk/src/execution-plan.test.ts | 138 +++++++++ sdk/src/execution-plan.ts | 273 +++++++++++++++++ sdk/src/index.ts | 1 + sdk/src/lib.rs | 15 +- .../plugins/OpencodeCLIOutputPlugin.test.ts | 4 +- sdk/src/plugins/OpencodeCLIOutputPlugin.ts | 71 ++++- sdk/src/plugins/plugin-core/plugin.ts | 8 +- sdk/src/plugins/plugin-core/types.ts | 1 + .../runtime/cleanup.execution-scope.test.ts | 172 +++++++++++ sdk/src/runtime/cleanup.ts | 290 +++++++++++++----- 43 files changed, 1785 insertions(+), 443 deletions(-) delete mode 100644 cli/src/commands/InitCommand.test.ts delete mode 100644 cli/src/commands/InitCommand.ts create mode 100644 cli/src/commands/execution-preflight.ts create mode 100644 cli/src/commands/execution-routing.test.ts delete mode 100644 cli/src/commands/factories/InitCommandFactory.ts create mode 100644 sdk/src/execution-plan.test.ts create mode 100644 sdk/src/execution-plan.ts create mode 100644 sdk/src/runtime/cleanup.execution-scope.test.ts diff --git a/cli/src/PluginPipeline.ts b/cli/src/PluginPipeline.ts index b5e15090..94dd8106 100644 --- a/cli/src/PluginPipeline.ts +++ b/cli/src/PluginPipeline.ts @@ -8,11 +8,22 @@ import type { PipelineConfig, PluginOptions } from '@truenine/memory-sync-sdk' -import type {Command, CommandContext, CommandResult} from '@/commands/Command' +import type { + Command, + CommandContext, + CommandResult +} from '@/commands/Command' import type {ParsedCliArgs} from '@/pipeline/CliArgumentParser' -import {createLogger, discoverOutputRuntimeTargets, setGlobalLogLevel} from '@truenine/memory-sync-sdk' -import {JsonOutputCommand} from '@/commands/JsonOutputCommand' -import {extractUserArgs, parseArgs, resolveCommand} from '@/pipeline/CliArgumentParser' +import { + createLogger, + discoverOutputRuntimeTargets, + setGlobalLogLevel +} from '@truenine/memory-sync-sdk' +import { + extractUserArgs, + parseArgs, + resolveCommand +} from '@/pipeline/CliArgumentParser' export class PluginPipeline { private readonly logger: ILogger @@ -34,44 +45,60 @@ export class PluginPipeline { } async run(config: PipelineConfig): Promise { - const {context, outputPlugins, userConfigOptions} = config + const {context, outputPlugins, userConfigOptions, executionPlan} = config this.registerOutputPlugins([...outputPlugins]) - let command: Command = resolveCommand(this.args) - - if (!this.args.jsonFlag) return command.execute(this.createCommandContext(context, userConfigOptions)) - - setGlobalLogLevel('silent') - if (!new Set(['config-show', 'plugins']).has(command.name)) command = new JsonOutputCommand(command) - return command.execute(this.createCommandContext(context, userConfigOptions)) + const command: Command = resolveCommand(this.args) + return command.execute( + this.createCommandContext(context, userConfigOptions, executionPlan) + ) } - private createCommandContext(ctx: OutputCollectedContext, userConfigOptions: Required): CommandContext { + private createCommandContext( + ctx: OutputCollectedContext, + userConfigOptions: Required, + executionPlan: PipelineConfig['executionPlan'] + ): CommandContext { return { logger: this.logger, outputPlugins: this.outputPlugins, collectedOutputContext: ctx, userConfigOptions, - createCleanContext: dryRun => this.createCleanContext(ctx, userConfigOptions, dryRun), - createWriteContext: dryRun => this.createWriteContext(ctx, userConfigOptions, dryRun) + executionPlan, + createCleanContext: dryRun => + this.createCleanContext(ctx, userConfigOptions, executionPlan, dryRun), + createWriteContext: dryRun => + this.createWriteContext(ctx, userConfigOptions, executionPlan, dryRun) } } - private createCleanContext(ctx: OutputCollectedContext, userConfigOptions: Required, dryRun: boolean): OutputCleanContext { + private createCleanContext( + ctx: OutputCollectedContext, + userConfigOptions: Required, + executionPlan: PipelineConfig['executionPlan'], + dryRun: boolean + ): OutputCleanContext { return { logger: this.logger, collectedOutputContext: ctx, pluginOptions: userConfigOptions, runtimeTargets: this.getRuntimeTargets(), + executionPlan, dryRun } } - private createWriteContext(ctx: OutputCollectedContext, userConfigOptions: Required, dryRun: boolean): OutputWriteContext { + private createWriteContext( + ctx: OutputCollectedContext, + userConfigOptions: Required, + executionPlan: PipelineConfig['executionPlan'], + dryRun: boolean + ): OutputWriteContext { return { logger: this.logger, collectedOutputContext: ctx, pluginOptions: userConfigOptions, runtimeTargets: this.getRuntimeTargets(), + executionPlan, dryRun, registeredPluginNames: this.outputPlugins.map(plugin => plugin.name) } diff --git a/cli/src/cli-runtime.test.ts b/cli/src/cli-runtime.test.ts index f7d2f320..8a534b84 100644 --- a/cli/src/cli-runtime.test.ts +++ b/cli/src/cli-runtime.test.ts @@ -1,11 +1,24 @@ import {afterEach, describe, expect, it, vi} from 'vitest' -const {createDefaultPluginConfigMock, pipelineRunMock, pluginPipelineCtorMock} = vi.hoisted(() => ({ +const { + createDefaultPluginConfigMock, + pipelineRunMock, + pluginPipelineCtorMock +} = vi.hoisted(() => ({ createDefaultPluginConfigMock: vi.fn(), pipelineRunMock: vi.fn(), pluginPipelineCtorMock: vi.fn() })) +function createEmptyProjectsBySeries() { + return { + app: [], + ext: [], + arch: [], + softwares: [] + } +} + vi.mock('./plugin.config', () => ({ createDefaultPluginConfig: createDefaultPluginConfigMock })) @@ -32,20 +45,43 @@ describe('cli runtime lightweight commands', () => { expect(pipelineRunMock).not.toHaveBeenCalled() }) - it('emits JSON for --version --json without loading plugin config', async () => { + it('passes the real cwd into the standard plugin config path', async () => { const {runCli} = await import('./cli-runtime') - const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - try { - const exitCode = await runCli(['node', 'tnmsc', '--version', '--json']) - expect(exitCode).toBe(0) - expect(createDefaultPluginConfigMock).not.toHaveBeenCalled() - expect(pluginPipelineCtorMock).not.toHaveBeenCalled() - expect(pipelineRunMock).not.toHaveBeenCalled() - const payload = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) as {readonly success: boolean, readonly message?: string} - expect(payload.success).toBe(true) - expect(payload.message).toBe('Version displayed') - } finally { - writeSpy.mockRestore() - } + createDefaultPluginConfigMock.mockResolvedValue({ + context: { + workspace: { + directory: { + pathKind: 'absolute', + path: process.cwd(), + getDirectoryName: () => 'cwd' + }, + projects: [] + } + }, + outputPlugins: [], + userConfigOptions: {}, + executionPlan: { + scope: 'workspace', + cwd: process.cwd(), + workspaceDir: process.cwd(), + projectsBySeries: createEmptyProjectsBySeries() + } + }) + pipelineRunMock.mockResolvedValue({ + success: true, + filesAffected: 0, + dirsAffected: 0 + }) + + const exitCode = await runCli(['node', 'tnmsc']) + + expect(exitCode).toBe(0) + expect(createDefaultPluginConfigMock).toHaveBeenCalledWith( + ['node', 'tnmsc'], + void 0, + process.cwd() + ) + expect(pluginPipelineCtorMock).toHaveBeenCalledWith('node', 'tnmsc') + expect(pipelineRunMock).toHaveBeenCalledTimes(1) }) }) diff --git a/cli/src/cli-runtime.ts b/cli/src/cli-runtime.ts index 15b9da60..6825e7a6 100644 --- a/cli/src/cli-runtime.ts +++ b/cli/src/cli-runtime.ts @@ -1,51 +1,55 @@ -import type {Command, CommandContext, CommandResult} from '@/commands/Command' +import type { + Command, + CommandContext, + CommandResult +} from '@/commands/Command' import * as path from 'node:path' import process from 'node:process' import { buildUnhandledExceptionDiagnostic, createLogger, - drainBufferedDiagnostics, FilePathKind, flushOutput, mergeConfig, setGlobalLogLevel } from '@truenine/memory-sync-sdk' -import {JsonOutputCommand, toJsonCommandResult} from '@/commands/JsonOutputCommand' -import {extractUserArgs, parseArgs, resolveCommand} from '@/pipeline/CliArgumentParser' +import { + extractUserArgs, + parseArgs, + resolveCommand +} from '@/pipeline/CliArgumentParser' import {PluginPipeline} from '@/PluginPipeline' import {createDefaultPluginConfig} from './plugin.config' const LIGHTWEIGHT_COMMAND_NAMES = new Set(['help', 'version', 'unknown']) -export function isJsonMode(argv: readonly string[]): boolean { - return argv.some(arg => arg === '--json' || arg === '-j' || /^-[^-]*j/u.test(arg)) -} - -function writeJsonFailure(error: unknown): void { - const logger = createLogger('main', 'silent') - logger.error(buildUnhandledExceptionDiagnostic('main', error)) - process.stdout.write( - `${JSON.stringify( - toJsonCommandResult( - { - success: false, - filesAffected: 0, - dirsAffected: 0, - message: error instanceof Error ? error.message : String(error) - }, - drainBufferedDiagnostics() - ) - )}\n` - ) +function createEmptyProjectsBySeries(): { + readonly app: readonly never[] + readonly ext: readonly never[] + readonly arch: readonly never[] + readonly softwares: readonly never[] +} { + return { + app: [], + ext: [], + arch: [], + softwares: [] + } as const } function createUnavailableContext(kind: 'cleanup' | 'write'): never { throw new Error(`${kind} context is unavailable for lightweight commands`) } -function createLightweightCommandContext(logLevel: ReturnType['logLevel']): CommandContext { - const workspaceDir = process.cwd() - const userConfigOptions = mergeConfig({workspaceDir, ...logLevel != null ? {logLevel} : {}}) +function createLightweightCommandContext( + logLevel: ReturnType['logLevel'] +): CommandContext { + const cwd = process.cwd() + const workspaceDir = cwd + const userConfigOptions = mergeConfig({ + workspaceDir, + ...logLevel != null ? {logLevel} : {} + }) return { logger: createLogger('PluginPipeline', logLevel), outputPlugins: [], @@ -60,43 +64,55 @@ function createLightweightCommandContext(logLevel: ReturnType[ } }, userConfigOptions, + executionPlan: { + scope: 'workspace', + cwd, + workspaceDir, + projectsBySeries: createEmptyProjectsBySeries() + }, createCleanContext: () => createUnavailableContext('cleanup'), createWriteContext: () => createUnavailableContext('write') } } -function resolveLightweightCommand(argv: readonly string[]): {readonly command: Command, readonly context: CommandContext} | undefined { - const parsedArgs = parseArgs(extractUserArgs(argv.filter((arg): arg is string => arg != null))) - let command: Command = resolveCommand(parsedArgs) +function resolveLightweightCommand( + argv: readonly string[] +): {readonly command: Command, readonly context: CommandContext} | undefined { + const parsedArgs = parseArgs( + extractUserArgs(argv.filter((arg): arg is string => arg != null)) + ) + const command: Command = resolveCommand(parsedArgs) if (!LIGHTWEIGHT_COMMAND_NAMES.has(command.name)) return void 0 if (parsedArgs.logLevel != null) setGlobalLogLevel(parsedArgs.logLevel) - if (!parsedArgs.jsonFlag) return {command, context: createLightweightCommandContext(parsedArgs.logLevel)} - - setGlobalLogLevel('silent') - command = new JsonOutputCommand(command) - return {command, context: createLightweightCommandContext(parsedArgs.logLevel)} + return { + command, + context: createLightweightCommandContext(parsedArgs.logLevel) + } } -export async function runCli(argv: readonly string[] = process.argv): Promise { +export async function runCli( + argv: readonly string[] = process.argv +): Promise { try { const lightweightCommand = resolveLightweightCommand(argv) if (lightweightCommand != null) { - const result: CommandResult = await lightweightCommand.command.execute(lightweightCommand.context) + const result: CommandResult = await lightweightCommand.command.execute( + lightweightCommand.context + ) flushOutput() return result.success ? 0 : 1 } const pipeline = new PluginPipeline(...argv) - const userPluginConfig = await createDefaultPluginConfig(argv) + const userPluginConfig = await createDefaultPluginConfig( + argv, + void 0, + process.cwd() + ) const result = await pipeline.run(userPluginConfig) flushOutput() return result.success ? 0 : 1 } catch (error) { - if (isJsonMode(argv)) { - writeJsonFailure(error) - flushOutput() - return 1 - } const logger = createLogger('main', 'error') logger.error(buildUnhandledExceptionDiagnostic('main', error)) flushOutput() diff --git a/cli/src/cli.rs b/cli/src/cli.rs index b64ceaf0..077aec39 100644 --- a/cli/src/cli.rs +++ b/cli/src/cli.rs @@ -16,10 +16,6 @@ pub struct Cli { #[command(subcommand)] pub command: Option, - /// Output results as JSON (suppresses all log output) - #[arg(short = 'j', long = "json", global = true)] - pub json: bool, - /// Set log level to trace (most verbose) #[arg(long = "trace", global = true)] pub trace: bool, diff --git a/cli/src/commands/CleanCommand.ts b/cli/src/commands/CleanCommand.ts index 0d10e57b..c6bcb2ba 100644 --- a/cli/src/commands/CleanCommand.ts +++ b/cli/src/commands/CleanCommand.ts @@ -1,10 +1,14 @@ import type {Command, CommandContext, CommandResult} from './Command' import {performCleanup} from '@truenine/memory-sync-sdk' +import {runExecutionPreflight} from './execution-preflight' export class CleanCommand implements Command { readonly name = 'clean' async execute(ctx: CommandContext): Promise { + const preflightResult = runExecutionPreflight(ctx, this.name) + if (preflightResult != null) return preflightResult + const {logger, outputPlugins, createCleanContext, collectedOutputContext} = ctx logger.info('started', { command: 'clean', diff --git a/cli/src/commands/Command.ts b/cli/src/commands/Command.ts index 789aadb9..13793886 100644 --- a/cli/src/commands/Command.ts +++ b/cli/src/commands/Command.ts @@ -1,6 +1,6 @@ import type { + ExecutionPlan, ILogger, - LoggerDiagnosticRecord, OutputCleanContext, OutputCollectedContext, OutputPlugin, @@ -14,6 +14,7 @@ export interface CommandContext { readonly outputPlugins: readonly OutputPlugin[] readonly collectedOutputContext: OutputCollectedContext readonly userConfigOptions: Required + readonly executionPlan: ExecutionPlan readonly createCleanContext: (dryRun: boolean) => OutputCleanContext readonly createWriteContext: (dryRun: boolean) => OutputWriteContext } @@ -34,16 +35,6 @@ export interface PluginExecutionResult { readonly duration?: number } -export interface JsonCommandResult { - readonly success: boolean - readonly filesAffected: number - readonly dirsAffected: number - readonly message?: string - readonly pluginResults: readonly PluginExecutionResult[] - readonly warnings: readonly LoggerDiagnosticRecord[] - readonly errors: readonly LoggerDiagnosticRecord[] -} - export interface JsonConfigInfo { readonly merged: UserConfigFile readonly sources: readonly ConfigSource[] diff --git a/cli/src/commands/DryRunCleanCommand.ts b/cli/src/commands/DryRunCleanCommand.ts index 12b20913..90933b96 100644 --- a/cli/src/commands/DryRunCleanCommand.ts +++ b/cli/src/commands/DryRunCleanCommand.ts @@ -1,11 +1,15 @@ import type {Command, CommandContext, CommandResult} from './Command' import * as path from 'node:path' import {collectAllPluginOutputs, collectDeletionTargets, logProtectedDeletionGuardError} from '@truenine/memory-sync-sdk' +import {runExecutionPreflight} from './execution-preflight' export class DryRunCleanCommand implements Command { readonly name = 'dry-run-clean' async execute(ctx: CommandContext): Promise { + const preflightResult = runExecutionPreflight(ctx, this.name) + if (preflightResult != null) return preflightResult + const {logger, outputPlugins, createCleanContext} = ctx logger.info('running clean pipeline', {command: 'dry-run-clean', dryRun: true}) const cleanCtx = createCleanContext(true) diff --git a/cli/src/commands/DryRunOutputCommand.ts b/cli/src/commands/DryRunOutputCommand.ts index fbf92733..d5aeb416 100644 --- a/cli/src/commands/DryRunOutputCommand.ts +++ b/cli/src/commands/DryRunOutputCommand.ts @@ -1,10 +1,14 @@ import type {Command, CommandContext, CommandResult} from './Command' import {collectOutputDeclarations, executeDeclarativeWriteOutputs, syncWindowsConfigIntoWsl} from '@truenine/memory-sync-sdk' +import {runExecutionPreflight} from './execution-preflight' export class DryRunOutputCommand implements Command { readonly name = 'dry-run-output' async execute(ctx: CommandContext): Promise { + const preflightResult = runExecutionPreflight(ctx, this.name) + if (preflightResult != null) return preflightResult + const {logger, outputPlugins, createWriteContext} = ctx logger.info('started', {command: 'dry-run-output', dryRun: true}) const writeCtx = createWriteContext(true) diff --git a/cli/src/commands/ExecuteCommand.ts b/cli/src/commands/ExecuteCommand.ts index dbc62a92..9fb046bc 100644 --- a/cli/src/commands/ExecuteCommand.ts +++ b/cli/src/commands/ExecuteCommand.ts @@ -1,10 +1,14 @@ import type {Command, CommandContext, CommandResult} from './Command' import {collectOutputDeclarations, executeDeclarativeWriteOutputs, performCleanup, syncWindowsConfigIntoWsl} from '@truenine/memory-sync-sdk' +import {runExecutionPreflight} from './execution-preflight' export class ExecuteCommand implements Command { readonly name = 'execute' async execute(ctx: CommandContext): Promise { + const preflightResult = runExecutionPreflight(ctx, this.name) + if (preflightResult != null) return preflightResult + const {logger, outputPlugins, createCleanContext, createWriteContext, collectedOutputContext} = ctx logger.info('started', { command: 'execute', diff --git a/cli/src/commands/HelpCommand.ts b/cli/src/commands/HelpCommand.ts index 1ca4f8f9..5bbf3bdc 100644 --- a/cli/src/commands/HelpCommand.ts +++ b/cli/src/commands/HelpCommand.ts @@ -14,7 +14,6 @@ USAGE: ${CLI_NAME} Run the sync pipeline (default) ${CLI_NAME} help Show this help message ${CLI_NAME} version Show version information - ${CLI_NAME} init Deprecated; no longer initializes aindex ${CLI_NAME} dry-run Preview what would be written ${CLI_NAME} clean Remove all generated files ${CLI_NAME} clean --dry-run Preview what would be cleaned @@ -23,7 +22,6 @@ USAGE: SUBCOMMANDS: help Show this help message version Show version information - init Deprecated; keep public target-relative definitions manually dry-run Preview changes without writing files clean Remove all generated output files and directories config Set configuration values in global config file (~/.aindex/.tnmsc.json) diff --git a/cli/src/commands/InitCommand.test.ts b/cli/src/commands/InitCommand.test.ts deleted file mode 100644 index 0b265c43..00000000 --- a/cli/src/commands/InitCommand.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type {CommandContext} from './Command' -import * as fs from 'node:fs' -import * as path from 'node:path' -import {createLogger, FilePathKind, mergeConfig} from '@truenine/memory-sync-sdk' -import {describe, expect, it} from 'vitest' -import {InitCommand} from './InitCommand' - -function createCommandContext(): CommandContext { - const workspaceDir = path.resolve('tmp-init-command') - const userConfigOptions = mergeConfig({workspaceDir}) - - return { - logger: createLogger('InitCommandTest', 'error'), - outputPlugins: [], - userConfigOptions, - collectedOutputContext: { - workspace: { - directory: { - pathKind: FilePathKind.Absolute, - path: workspaceDir, - getDirectoryName: () => path.basename(workspaceDir) - }, - projects: [] - } - }, - createCleanContext: dryRun => - ({ - logger: createLogger('InitCommandTest', 'error'), - fs, - path, - glob: {} as never, - runtimeTargets: {jetbrainsCodexDirs: []}, - dryRun, - collectedOutputContext: { - workspace: { - directory: { - pathKind: FilePathKind.Absolute, - path: workspaceDir, - getDirectoryName: () => path.basename(workspaceDir) - }, - projects: [] - } - } - }) as unknown as CommandContext['createCleanContext'] extends (dryRun: boolean) => infer T ? T : never, - createWriteContext: dryRun => - ({ - logger: createLogger('InitCommandTest', 'error'), - fs, - path, - glob: {} as never, - runtimeTargets: {jetbrainsCodexDirs: []}, - dryRun, - collectedOutputContext: { - workspace: { - directory: { - pathKind: FilePathKind.Absolute, - path: workspaceDir, - getDirectoryName: () => path.basename(workspaceDir) - }, - projects: [] - } - } - }) as unknown as CommandContext['createWriteContext'] extends (dryRun: boolean) => infer T ? T : never - } -} - -describe('init command', () => { - it('returns a deprecation failure without creating files', async () => { - const result = await new InitCommand().execute(createCommandContext()) - expect(result.success).toBe(false) - expect(result.filesAffected).toBe(0) - expect(result.dirsAffected).toBe(0) - expect(result.message).toContain('deprecated') - expect(result.message).toContain('~/workspace/aindex/public/') - }) -}) diff --git a/cli/src/commands/InitCommand.ts b/cli/src/commands/InitCommand.ts deleted file mode 100644 index 54ef706f..00000000 --- a/cli/src/commands/InitCommand.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type {Command, CommandContext, CommandResult} from './Command' -import {buildUsageDiagnostic, diagnosticLines} from '@truenine/memory-sync-sdk' - -const INIT_DEPRECATION_MESSAGE - = '`tnmsc init` is deprecated and no longer initializes aindex. Maintain the public target-relative definitions manually under `~/workspace/aindex/public/`.' - -export class InitCommand implements Command { - readonly name = 'init' - - async execute(ctx: CommandContext): Promise { - const {logger} = ctx - logger.warn( - buildUsageDiagnostic({ - code: 'INIT_COMMAND_DEPRECATED', - title: 'The init command is deprecated', - rootCause: diagnosticLines('`tnmsc init` no longer initializes aindex content or project definitions.'), - exactFix: diagnosticLines('Maintain the target-relative definitions manually under `~/workspace/aindex/public/`.'), - possibleFixes: [diagnosticLines('Run `tnmsc help` to find a supported replacement command for your workflow.')], - details: {command: 'init'} - }) - ) - return {success: false, filesAffected: 0, dirsAffected: 0, message: INIT_DEPRECATION_MESSAGE} - } -} diff --git a/cli/src/commands/JsonOutputCommand.ts b/cli/src/commands/JsonOutputCommand.ts index 6d49cea0..c4d61637 100644 --- a/cli/src/commands/JsonOutputCommand.ts +++ b/cli/src/commands/JsonOutputCommand.ts @@ -1,6 +1,21 @@ -import type {Command, CommandContext, CommandResult, JsonCommandResult} from './Command' +import type {LoggerDiagnosticRecord} from '@truenine/memory-sync-sdk' +import type {Command, CommandContext, CommandResult} from './Command' import process from 'node:process' -import {clearBufferedDiagnostics, drainBufferedDiagnostics, partitionBufferedDiagnostics} from '@truenine/memory-sync-sdk' +import { + clearBufferedDiagnostics, + drainBufferedDiagnostics, + partitionBufferedDiagnostics +} from '@truenine/memory-sync-sdk' + +interface JsonCommandResult { + readonly success: boolean + readonly filesAffected: number + readonly dirsAffected: number + readonly message?: string + readonly pluginResults: readonly [] + readonly warnings: readonly LoggerDiagnosticRecord[] + readonly errors: readonly LoggerDiagnosticRecord[] +} export class JsonOutputCommand implements Command { readonly name: string @@ -12,18 +27,25 @@ export class JsonOutputCommand implements Command { async execute(ctx: CommandContext): Promise { clearBufferedDiagnostics() const result = await this.inner.execute(ctx) - process.stdout.write(`${JSON.stringify(toJsonCommandResult(result, drainBufferedDiagnostics()))}\n`) + process.stdout.write( + `${JSON.stringify( + toJsonCommandResult(result, drainBufferedDiagnostics()) + )}\n` + ) return result } } -export function toJsonCommandResult(result: CommandResult, diagnostics = drainBufferedDiagnostics()): JsonCommandResult { +export function toJsonCommandResult( + result: CommandResult, + diagnostics = drainBufferedDiagnostics() +): JsonCommandResult { const {warnings, errors} = partitionBufferedDiagnostics(diagnostics) return { success: result.success, filesAffected: result.filesAffected, dirsAffected: result.dirsAffected, - ...result.message != null && {message: result.message}, + ...result.message != null ? {message: result.message} : {}, pluginResults: [], warnings, errors diff --git a/cli/src/commands/bridge.rs b/cli/src/commands/bridge.rs index a068e599..da2340b2 100644 --- a/cli/src/commands/bridge.rs +++ b/cli/src/commands/bridge.rs @@ -1,21 +1,21 @@ use std::process::ExitCode; -pub fn execute(json_mode: bool) -> ExitCode { - tnmsc::bridge::node::run_node_command("execute", json_mode, &[]) +pub fn execute() -> ExitCode { + tnmsc::bridge::node::run_node_command("execute", &[]) } -pub fn dry_run(json_mode: bool) -> ExitCode { - tnmsc::bridge::node::run_node_command("dry-run", json_mode, &[]) +pub fn dry_run() -> ExitCode { + tnmsc::bridge::node::run_node_command("dry-run", &[]) } -pub fn clean(json_mode: bool) -> ExitCode { - tnmsc::bridge::node::run_node_command("clean", json_mode, &[]) +pub fn clean() -> ExitCode { + tnmsc::bridge::node::run_node_command("clean", &[]) } -pub fn dry_run_clean(json_mode: bool) -> ExitCode { - tnmsc::bridge::node::run_node_command("clean", json_mode, &["--dry-run"]) +pub fn dry_run_clean() -> ExitCode { + tnmsc::bridge::node::run_node_command("clean", &["--dry-run"]) } -pub fn plugins(json_mode: bool) -> ExitCode { - tnmsc::bridge::node::run_node_command("plugins", json_mode, &[]) +pub fn plugins() -> ExitCode { + tnmsc::bridge::node::run_node_command("plugins", &[]) } diff --git a/cli/src/commands/execution-preflight.ts b/cli/src/commands/execution-preflight.ts new file mode 100644 index 00000000..5e100e9b --- /dev/null +++ b/cli/src/commands/execution-preflight.ts @@ -0,0 +1,128 @@ +import type {AindexProjectSeriesName, ExecutionPlanProjectSummary} from '@truenine/memory-sync-sdk' +import type {CommandContext, CommandResult} from './Command' +import {buildDiagnostic, diagnosticLines} from '@truenine/memory-sync-sdk' + +const SERIES_ORDER: readonly AindexProjectSeriesName[] = ['app', 'ext', 'arch', 'softwares'] + +function buildUnsupportedMessage(ctx: CommandContext): string { + return [ + `Unsupported execution directory "${ctx.executionPlan.cwd}".`, + `The directory is inside workspace "${ctx.executionPlan.workspaceDir}" but is not managed by tnmsc.`, + 'Run tnmsc from the workspace root, from a managed project directory, or from outside the workspace.' + ].join(' ') +} + +function logExternalProjectGroups(ctx: CommandContext): void { + for (const series of SERIES_ORDER) { + const projects = ctx.executionPlan.projectsBySeries[series] + if (projects.length === 0) continue + ctx.logger.info('external execution project group', { + phase: 'execution-scope', + scope: 'external', + series, + projectCount: projects.length, + projects: projects.map(project => project.name) + }) + } +} + +function logProjectSummary( + ctx: CommandContext, + commandName: string, + project: ExecutionPlanProjectSummary +): void { + ctx.logger.info('execution scope resolved to project', { + phase: 'execution-scope', + command: commandName, + scope: 'project', + cwd: ctx.executionPlan.cwd, + workspaceDir: ctx.executionPlan.workspaceDir, + projectName: project.name, + ...project.series != null ? {projectSeries: project.series} : {} + }) + ctx.logger.info('project-scoped execution only targets the matched project and global outputs', { + phase: 'execution-scope', + command: commandName, + projectName: project.name + }) +} + +export function runExecutionPreflight( + ctx: CommandContext, + commandName: string +): CommandResult | undefined { + switch (ctx.executionPlan.scope) { + case 'workspace': + ctx.logger.warn(buildDiagnostic({ + code: 'EXECUTION_SCOPE_WORKSPACE', + title: 'Execution is limited to workspace-level outputs', + rootCause: diagnosticLines( + `tnmsc resolved the current execution directory "${ctx.executionPlan.cwd}" to the workspace root.`, + 'This run will sync or clean only workspace-level outputs plus global outputs to improve performance.' + ), + exactFix: diagnosticLines( + 'Run tnmsc from a managed project directory to target one project, or from outside the workspace to process every managed project.' + ), + details: { + phase: 'execution-scope', + command: commandName, + scope: 'workspace', + cwd: ctx.executionPlan.cwd, + workspaceDir: ctx.executionPlan.workspaceDir + } + })) + return void 0 + case 'project': + logProjectSummary(ctx, commandName, ctx.executionPlan.matchedProject) + return void 0 + case 'external': + ctx.logger.warn(buildDiagnostic({ + code: 'EXECUTION_SCOPE_EXTERNAL', + title: 'Execution will process the full workspace and all managed projects', + rootCause: diagnosticLines( + `tnmsc resolved the current execution directory "${ctx.executionPlan.cwd}" as external to workspace "${ctx.executionPlan.workspaceDir}".`, + 'This run may take longer because it will process workspace-level outputs, all managed projects, and global outputs.' + ), + exactFix: diagnosticLines( + `Run tnmsc from "${ctx.executionPlan.workspaceDir}" for workspace-only execution, or from a managed project directory for project-only execution.` + ), + details: { + phase: 'execution-scope', + command: commandName, + scope: 'external', + cwd: ctx.executionPlan.cwd, + workspaceDir: ctx.executionPlan.workspaceDir + } + })) + logExternalProjectGroups(ctx) + return void 0 + case 'unsupported': { + const message = buildUnsupportedMessage(ctx) + ctx.logger.error(buildDiagnostic({ + code: 'EXECUTION_SCOPE_UNSUPPORTED', + title: 'Execution directory is inside the workspace but not managed by tnmsc', + rootCause: diagnosticLines( + `tnmsc resolved "${ctx.executionPlan.cwd}" inside workspace "${ctx.executionPlan.workspaceDir}", but the directory is not the workspace root and does not belong to any managed project.`, + 'Running from this location is unsupported because tnmsc cannot map the request to a workspace-level or project-level execution target.' + ), + exactFix: diagnosticLines( + 'Run tnmsc from the workspace root, from a managed project directory, or from outside the workspace.' + ), + details: { + phase: 'execution-scope', + command: commandName, + scope: 'unsupported', + cwd: ctx.executionPlan.cwd, + workspaceDir: ctx.executionPlan.workspaceDir, + managedProjectCount: ctx.executionPlan.managedProjects.length + } + })) + return { + success: false, + filesAffected: 0, + dirsAffected: 0, + message + } + } + } +} diff --git a/cli/src/commands/execution-routing.test.ts b/cli/src/commands/execution-routing.test.ts new file mode 100644 index 00000000..05ecd620 --- /dev/null +++ b/cli/src/commands/execution-routing.test.ts @@ -0,0 +1,209 @@ +import type {ExecutionPlan} from '@truenine/memory-sync-sdk' +import type {CommandContext} from './Command' +import * as path from 'node:path' +import {createLogger, FilePathKind, mergeConfig} from '@truenine/memory-sync-sdk' +import {afterEach, describe, expect, it, vi} from 'vitest' +import {CleanCommand} from './CleanCommand' +import {DryRunCleanCommand} from './DryRunCleanCommand' +import {ExecuteCommand} from './ExecuteCommand' + +function createEmptyProjectsBySeries() { + return { + app: [], + ext: [], + arch: [], + softwares: [] + } +} + +const { + collectAllPluginOutputsMock, + collectDeletionTargetsMock, + collectOutputDeclarationsMock, + executeDeclarativeWriteOutputsMock, + performCleanupMock, + syncWindowsConfigIntoWslMock +} = vi.hoisted(() => ({ + collectAllPluginOutputsMock: vi.fn(), + collectDeletionTargetsMock: vi.fn(), + collectOutputDeclarationsMock: vi.fn(), + executeDeclarativeWriteOutputsMock: vi.fn(), + performCleanupMock: vi.fn(), + syncWindowsConfigIntoWslMock: vi.fn() +})) + +vi.mock('@truenine/memory-sync-sdk', async importOriginal => { + const actual = await importOriginal() + + return { + ...actual, + collectAllPluginOutputs: collectAllPluginOutputsMock, + collectDeletionTargets: collectDeletionTargetsMock, + collectOutputDeclarations: collectOutputDeclarationsMock, + executeDeclarativeWriteOutputs: executeDeclarativeWriteOutputsMock, + performCleanup: performCleanupMock, + syncWindowsConfigIntoWsl: syncWindowsConfigIntoWslMock + } +}) + +function createBaseContext(executionPlan: ExecutionPlan): { + readonly ctx: CommandContext + readonly infoSpy: ReturnType + readonly warnSpy: ReturnType + readonly errorSpy: ReturnType +} { + const workspaceDir = executionPlan.workspaceDir + const logger = createLogger('execution-routing-test', 'debug') + const infoSpy = vi.spyOn(logger, 'info') + const warnSpy = vi.spyOn(logger, 'warn') + const errorSpy = vi.spyOn(logger, 'error') + + const collectedOutputContext = { + workspace: { + directory: { + pathKind: FilePathKind.Absolute, + path: workspaceDir, + getDirectoryName: () => path.basename(workspaceDir) + }, + projects: [] + } + } + + const createCleanContext = vi.fn((dryRun: boolean) => ({ + logger, + collectedOutputContext, + pluginOptions: mergeConfig({workspaceDir}), + runtimeTargets: {jetbrainsCodexDirs: []}, + executionPlan, + dryRun + })) + const createWriteContext = vi.fn((dryRun: boolean) => ({ + logger, + collectedOutputContext, + pluginOptions: mergeConfig({workspaceDir}), + runtimeTargets: {jetbrainsCodexDirs: []}, + executionPlan, + dryRun, + registeredPluginNames: [] + })) + + return { + ctx: { + logger, + outputPlugins: [], + collectedOutputContext, + userConfigOptions: mergeConfig({workspaceDir}), + executionPlan, + createCleanContext, + createWriteContext + } as unknown as CommandContext, + infoSpy, + warnSpy, + errorSpy + } +} + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('execution-aware command routing', () => { + it('short-circuits execute when cwd is unsupported inside workspace', async () => { + const workspaceDir = path.resolve('/tmp/tnmsc-execute-unsupported') + const {ctx} = createBaseContext({ + scope: 'unsupported', + cwd: path.join(workspaceDir, 'scripts'), + workspaceDir, + projectsBySeries: createEmptyProjectsBySeries(), + managedProjects: [] + }) + + const result = await new ExecuteCommand().execute(ctx) + + expect(result.success).toBe(false) + expect(result.message).toContain('not managed by tnmsc') + expect(collectOutputDeclarationsMock).not.toHaveBeenCalled() + expect(performCleanupMock).not.toHaveBeenCalled() + expect(executeDeclarativeWriteOutputsMock).not.toHaveBeenCalled() + }) + + it('logs project scope details before running clean', async () => { + const workspaceDir = path.resolve('/tmp/tnmsc-clean-project') + const {ctx, infoSpy} = createBaseContext({ + scope: 'project', + cwd: path.join(workspaceDir, 'plugin-one', 'docs'), + workspaceDir, + projectsBySeries: { + ...createEmptyProjectsBySeries(), + ext: [{ + name: 'plugin-one', + rootDir: path.join(workspaceDir, 'plugin-one'), + series: 'ext' + }] + }, + matchedProject: { + name: 'plugin-one', + rootDir: path.join(workspaceDir, 'plugin-one'), + series: 'ext' + } + }) + performCleanupMock.mockResolvedValue({ + deletedFiles: 0, + deletedDirs: 0, + errors: [], + violations: [], + conflicts: [] + }) + + const result = await new CleanCommand().execute(ctx) + + expect(result.success).toBe(true) + expect(performCleanupMock).toHaveBeenCalledTimes(1) + expect(infoSpy.mock.calls).toEqual(expect.arrayContaining([ + ['execution scope resolved to project', expect.objectContaining({projectName: 'plugin-one', projectSeries: 'ext'})] + ])) + }) + + it('logs external project groups before running dry-run clean', async () => { + const workspaceDir = path.resolve('/tmp/tnmsc-dry-run-clean-external') + const {ctx, infoSpy, warnSpy} = createBaseContext({ + scope: 'external', + cwd: path.resolve('/tmp/outside-workspace'), + workspaceDir, + projectsBySeries: { + app: [{name: 'app-one', rootDir: path.join(workspaceDir, 'app-one'), series: 'app'}], + ext: [{name: 'plugin-one', rootDir: path.join(workspaceDir, 'plugin-one'), series: 'ext'}], + arch: [], + softwares: [{name: 'tool-one', rootDir: path.join(workspaceDir, 'tool-one'), series: 'softwares'}] + } + }) + collectAllPluginOutputsMock.mockResolvedValue({ + projectDirs: [], + projectFiles: [], + globalDirs: [], + globalFiles: [] + }) + collectDeletionTargetsMock.mockResolvedValue({ + filesToDelete: [], + dirsToDelete: [], + emptyDirsToDelete: [], + violations: [], + conflicts: [], + excludedScanGlobs: [] + }) + + const result = await new DryRunCleanCommand().execute(ctx) + + expect(result.success).toBe(true) + expect(collectAllPluginOutputsMock).toHaveBeenCalledTimes(1) + expect(collectDeletionTargetsMock).toHaveBeenCalledTimes(1) + expect(warnSpy.mock.calls).toEqual(expect.arrayContaining([ + [expect.objectContaining({code: 'EXECUTION_SCOPE_EXTERNAL', title: 'Execution will process the full workspace and all managed projects'})] + ])) + expect(infoSpy.mock.calls).toEqual(expect.arrayContaining([ + ['external execution project group', expect.objectContaining({series: 'app', projects: ['app-one']})], + ['external execution project group', expect.objectContaining({series: 'ext', projects: ['plugin-one']})], + ['external execution project group', expect.objectContaining({series: 'softwares', projects: ['tool-one']})] + ])) + }) +}) diff --git a/cli/src/commands/factories/InitCommandFactory.ts b/cli/src/commands/factories/InitCommandFactory.ts deleted file mode 100644 index afe09f8e..00000000 --- a/cli/src/commands/factories/InitCommandFactory.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type {Command} from '../Command' -import type {CommandFactory} from '../CommandFactory' -import type {ParsedCliArgs} from '@/pipeline/CliArgumentParser' -import {InitCommand} from '../InitCommand' - -export class InitCommandFactory implements CommandFactory { - canHandle(args: ParsedCliArgs): boolean { - return args.subcommand === 'init' - } - - createCommand(): Command { - return new InitCommand() - } -} diff --git a/cli/src/commands/help.rs b/cli/src/commands/help.rs index 94b02bd9..d731677f 100644 --- a/cli/src/commands/help.rs +++ b/cli/src/commands/help.rs @@ -16,7 +16,6 @@ pub fn execute() -> ExitCode { println!(" help Show this help message"); println!(); println!("OPTIONS:"); - println!(" -j, --json Output results as JSON"); println!(" --trace Set log level to trace"); println!(" --debug Set log level to debug"); println!(" --info Set log level to info"); diff --git a/cli/src/main.rs b/cli/src/main.rs index 0d37f665..d7526231 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -20,11 +20,6 @@ fn main() -> ExitCode { set_global_log_level(level.to_logger_level()); } - let json_mode = cli.json; - if json_mode { - set_global_log_level(tnmsc_logger::LogLevel::Silent); - } - let command = resolve_command(&cli); let exit_code = match command { @@ -32,11 +27,11 @@ fn main() -> ExitCode { ResolvedCommand::Version => commands::version::execute(), ResolvedCommand::Config(pairs) => commands::config_cmd::execute(&pairs), ResolvedCommand::ConfigShow => commands::config_show::execute(), - ResolvedCommand::Execute => commands::bridge::execute(json_mode), - ResolvedCommand::DryRun => commands::bridge::dry_run(json_mode), - ResolvedCommand::Clean => commands::bridge::clean(json_mode), - ResolvedCommand::DryRunClean => commands::bridge::dry_run_clean(json_mode), - ResolvedCommand::Plugins => commands::bridge::plugins(json_mode), + ResolvedCommand::Execute => commands::bridge::execute(), + ResolvedCommand::DryRun => commands::bridge::dry_run(), + ResolvedCommand::Clean => commands::bridge::clean(), + ResolvedCommand::DryRunClean => commands::bridge::dry_run_clean(), + ResolvedCommand::Plugins => commands::bridge::plugins(), }; flush_output(); diff --git a/cli/src/pipeline/CliArgumentParser.test.ts b/cli/src/pipeline/CliArgumentParser.test.ts index ad49ff88..eff6fc48 100644 --- a/cli/src/pipeline/CliArgumentParser.test.ts +++ b/cli/src/pipeline/CliArgumentParser.test.ts @@ -2,8 +2,8 @@ import {describe, expect, it} from 'vitest' import {parseArgs, resolveCommand} from './CliArgumentParser' describe('cli argument parser', () => { - it('resolves the init subcommand to InitCommand', () => { - const command = resolveCommand(parseArgs(['init'])) - expect(command.name).toBe('init') + it('resolves the dry-run subcommand to DryRunOutputCommand', () => { + const command = resolveCommand(parseArgs(['dry-run'])) + expect(command.name).toBe('dry-run-output') }) }) diff --git a/cli/src/pipeline/CliArgumentParser.ts b/cli/src/pipeline/CliArgumentParser.ts index 0ead16dd..c4bff28b 100644 --- a/cli/src/pipeline/CliArgumentParser.ts +++ b/cli/src/pipeline/CliArgumentParser.ts @@ -6,12 +6,17 @@ import {ConfigCommandFactory} from '@/commands/factories/ConfigCommandFactory' import {DryRunCommandFactory} from '@/commands/factories/DryRunCommandFactory' import {ExecuteCommandFactory} from '@/commands/factories/ExecuteCommandFactory' import {HelpCommandFactory} from '@/commands/factories/HelpCommandFactory' -import {InitCommandFactory} from '@/commands/factories/InitCommandFactory' import {PluginsCommandFactory} from '@/commands/factories/PluginsCommandFactory' import {UnknownCommandFactory} from '@/commands/factories/UnknownCommandFactory' import {VersionCommandFactory} from '@/commands/factories/VersionCommandFactory' -export type Subcommand = 'help' | 'version' | 'init' | 'dry-run' | 'clean' | 'config' | 'plugins' +export type Subcommand + = | 'help' + | 'version' + | 'dry-run' + | 'clean' + | 'config' + | 'plugins' export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' export interface ParsedCliArgs { @@ -19,7 +24,6 @@ export interface ParsedCliArgs { readonly helpFlag: boolean readonly versionFlag: boolean readonly dryRun: boolean - readonly jsonFlag: boolean readonly showFlag: boolean readonly logLevel: LogLevel | undefined readonly setOption: readonly [key: string, value: string][] @@ -28,7 +32,14 @@ export interface ParsedCliArgs { readonly unknown: readonly string[] } -const VALID_SUBCOMMANDS: ReadonlySet = new Set(['help', 'version', 'init', 'dry-run', 'clean', 'config', 'plugins']) +const VALID_SUBCOMMANDS: ReadonlySet = new Set([ + 'help', + 'version', + 'dry-run', + 'clean', + 'config', + 'plugins' +]) const LOG_LEVEL_FLAGS: ReadonlyMap = new Map([ ['--trace', 'trace'], ['--debug', 'debug'], @@ -54,9 +65,25 @@ export function extractUserArgs(argv: readonly string[]): string[] { } function isRuntimeExecutable(arg: string): boolean { - const runtimes = ['node', 'nodejs', 'bun', 'deno', 'tsx', 'ts-node', 'npx', 'pnpx', 'yarn', 'pnpm'] + const runtimes = [ + 'node', + 'nodejs', + 'bun', + 'deno', + 'tsx', + 'ts-node', + 'npx', + 'pnpx', + 'yarn', + 'pnpm' + ] const normalized = arg.toLowerCase().replaceAll('\\', '/') - return runtimes.some(runtime => new RegExp(`(?:^|/)${runtime}(?:\\.exe|\\.cmd|\\.ps1)?$`, 'i').test(normalized) || normalized === runtime) + return runtimes.some( + runtime => + new RegExp(`(?:^|/)${runtime}(?:\\.exe|\\.cmd|\\.ps1)?$`, 'i').test( + normalized + ) || normalized === runtime + ) } function isScriptOrPackage(arg: string): boolean { @@ -65,7 +92,10 @@ function isScriptOrPackage(arg: string): boolean { return /^(?:@[\w-]+\/)?[\w-]+$/u.test(arg) && !arg.startsWith('-') } -function pickMoreVerbose(current: LogLevel | undefined, candidate: LogLevel): LogLevel { +function pickMoreVerbose( + current: LogLevel | undefined, + candidate: LogLevel +): LogLevel { if (current == null) return candidate const currentPriority = LOG_LEVEL_PRIORITY.get(current) ?? 4 const candidatePriority = LOG_LEVEL_PRIORITY.get(candidate) ?? 4 @@ -78,7 +108,6 @@ export function parseArgs(args: readonly string[]): ParsedCliArgs { helpFlag: boolean versionFlag: boolean dryRun: boolean - jsonFlag: boolean showFlag: boolean logLevel: LogLevel | undefined setOption: [key: string, value: string][] @@ -90,7 +119,6 @@ export function parseArgs(args: readonly string[]): ParsedCliArgs { helpFlag: false, versionFlag: false, dryRun: false, - jsonFlag: false, showFlag: false, logLevel: void 0, setOption: [], @@ -104,7 +132,9 @@ export function parseArgs(args: readonly string[]): ParsedCliArgs { const arg = args[i] if (arg == null) continue if (arg === '--') { - result.positional.push(...args.slice(i + 1).filter((value): value is string => value != null)) + result.positional.push( + ...args.slice(i + 1).filter((value): value is string => value != null) + ) break } @@ -127,9 +157,6 @@ export function parseArgs(args: readonly string[]): ParsedCliArgs { case '--dry-run': result.dryRun = true break - case '--json': - result.jsonFlag = true - break case '--show': result.showFlag = true break @@ -137,13 +164,20 @@ export function parseArgs(args: readonly string[]): ParsedCliArgs { if (parts.length > 1) { const keyValue = parts.slice(1).join('=') const eqIndex = keyValue.indexOf('=') - if (eqIndex > 0) result.setOption.push([keyValue.slice(0, eqIndex), keyValue.slice(eqIndex + 1)]) + if (eqIndex > 0) + { result.setOption.push([ + keyValue.slice(0, eqIndex), + keyValue.slice(eqIndex + 1) + ]) } } else { const nextArg = args[i + 1] if (nextArg != null) { const eqIndex = nextArg.indexOf('=') if (eqIndex > 0) { - result.setOption.push([nextArg.slice(0, eqIndex), nextArg.slice(eqIndex + 1)]) + result.setOption.push([ + nextArg.slice(0, eqIndex), + nextArg.slice(eqIndex + 1) + ]) i++ } } @@ -168,9 +202,6 @@ export function parseArgs(args: readonly string[]): ParsedCliArgs { case 'n': result.dryRun = true break - case 'j': - result.jsonFlag = true - break default: result.unknown.push(`-${flag}`) } @@ -198,12 +229,26 @@ function createDefaultCommandRegistry(): CommandRegistry { registry.register(new VersionCommandFactory()) registry.register(new HelpCommandFactory()) registry.register(new UnknownCommandFactory()) - registry.registerWithPriority(new InitCommandFactory(), FactoryPriority.Subcommand) - registry.registerWithPriority(new DryRunCommandFactory(), FactoryPriority.Subcommand) - registry.registerWithPriority(new CleanCommandFactory(), FactoryPriority.Subcommand) - registry.registerWithPriority(new PluginsCommandFactory(), FactoryPriority.Subcommand) - registry.registerWithPriority(new ConfigCommandFactory(), FactoryPriority.Subcommand) - registry.registerWithPriority(new ExecuteCommandFactory(), FactoryPriority.Subcommand) + registry.registerWithPriority( + new DryRunCommandFactory(), + FactoryPriority.Subcommand + ) + registry.registerWithPriority( + new CleanCommandFactory(), + FactoryPriority.Subcommand + ) + registry.registerWithPriority( + new PluginsCommandFactory(), + FactoryPriority.Subcommand + ) + registry.registerWithPriority( + new ConfigCommandFactory(), + FactoryPriority.Subcommand + ) + registry.registerWithPriority( + new ExecuteCommandFactory(), + FactoryPriority.Subcommand + ) return registry } diff --git a/cli/src/plugin-runtime.ts b/cli/src/plugin-runtime.ts index 00f34463..6243532b 100644 --- a/cli/src/plugin-runtime.ts +++ b/cli/src/plugin-runtime.ts @@ -1,4 +1,8 @@ -import type {OutputCleanContext, OutputWriteContext, RuntimeCommand} from '@truenine/memory-sync-sdk' +import type { + OutputCleanContext, + OutputWriteContext, + RuntimeCommand +} from '@truenine/memory-sync-sdk' import type {Command, CommandContext} from '@/commands/Command' import process from 'node:process' import { @@ -17,22 +21,34 @@ import {JsonOutputCommand, toJsonCommandResult} from '@/commands/JsonOutputComma import {PluginsCommand} from '@/commands/PluginsCommand' import {createDefaultPluginConfig} from './plugin.config' -function parseRuntimeArgs(argv: string[]): {subcommand: RuntimeCommand, json: boolean, dryRun: boolean} { +const INTERNAL_BRIDGE_JSON_FLAG = '--bridge-json' + +function parseRuntimeArgs(argv: string[]): { + subcommand: RuntimeCommand + bridgeJson: boolean + dryRun: boolean +} { const args = argv.slice(2) let subcommand: RuntimeCommand = 'execute' - let json = false + let bridgeJson = false let dryRun = false for (const arg of args) { - if (arg === '--json' || arg === '-j') json = true + if (arg === INTERNAL_BRIDGE_JSON_FLAG) bridgeJson = true else if (arg === '--dry-run' || arg === '-n') dryRun = true else if (!arg.startsWith('-')) { - subcommand = arg === 'plugins' || arg === 'clean' || arg === 'dry-run' ? arg : 'execute' + subcommand + = arg === 'plugins' || arg === 'clean' || arg === 'dry-run' + ? arg + : 'execute' } } - return {subcommand, json, dryRun} + return {subcommand, bridgeJson, dryRun} } -function resolveRuntimeCommand(subcommand: RuntimeCommand, dryRun: boolean): Command { +function resolveRuntimeCommand( + subcommand: RuntimeCommand, + dryRun: boolean +): Command { switch (subcommand) { case 'execute': return new ExecuteCommand() @@ -45,7 +61,12 @@ function resolveRuntimeCommand(subcommand: RuntimeCommand, dryRun: boolean): Com } } -function writeJsonFailure(error: unknown): void { +function flushAndExit(code: number): never { + flushOutput() + process.exit(code) +} + +function writeBridgeJsonFailure(error: unknown): void { const logger = createLogger('plugin-runtime', 'silent') logger.error(buildUnhandledExceptionDiagnostic('plugin-runtime', error)) process.stdout.write( @@ -63,23 +84,25 @@ function writeJsonFailure(error: unknown): void { ) } -function flushAndExit(code: number): never { - flushOutput() - process.exit(code) -} - async function main(): Promise { - const {subcommand, json, dryRun} = parseRuntimeArgs(process.argv) - if (json) setGlobalLogLevel('silent') + const {subcommand, bridgeJson, dryRun} = parseRuntimeArgs(process.argv) + if (bridgeJson) setGlobalLogLevel('silent') const logger = createLogger('PluginRuntime') - logger.info('runtime bootstrap started', {subcommand, json, dryRun}) + logger.info('runtime bootstrap started', {subcommand, bridgeJson, dryRun}) - const userPluginConfig = await createDefaultPluginConfig(process.argv, subcommand) + const userPluginConfig = await createDefaultPluginConfig( + process.argv, + subcommand, + process.cwd() + ) let command = resolveRuntimeCommand(subcommand, dryRun) - if (json && !new Set(['plugins']).has(command.name)) command = new JsonOutputCommand(command) + if (bridgeJson && command.name !== 'plugins') { + command = new JsonOutputCommand(command) + } - const {context, outputPlugins, userConfigOptions} = userPluginConfig + const {context, outputPlugins, userConfigOptions, executionPlan} + = userPluginConfig logger.info('runtime configuration resolved', { command: command.name, pluginCount: outputPlugins.length, @@ -97,6 +120,7 @@ async function main(): Promise { collectedOutputContext: context, pluginOptions: userConfigOptions, runtimeTargets, + executionPlan, dryRun: dry }) const createWriteContext = (dry: boolean): OutputWriteContext => ({ @@ -104,6 +128,7 @@ async function main(): Promise { collectedOutputContext: context, pluginOptions: userConfigOptions, runtimeTargets, + executionPlan, dryRun: dry, registeredPluginNames: Array.from(outputPlugins, plugin => plugin.name) }) @@ -112,6 +137,7 @@ async function main(): Promise { outputPlugins: [...outputPlugins], collectedOutputContext: context, userConfigOptions, + executionPlan, createCleanContext, createWriteContext } @@ -129,9 +155,8 @@ async function main(): Promise { } main().catch(error => { - const {json} = parseRuntimeArgs(process.argv) - if (json) { - writeJsonFailure(error) + if (parseRuntimeArgs(process.argv).bridgeJson) { + writeBridgeJsonFailure(error) flushAndExit(1) } const logger = createLogger('plugin-runtime', 'error') diff --git a/cli/src/plugin.config.ts b/cli/src/plugin.config.ts index afae340a..20a49526 100644 --- a/cli/src/plugin.config.ts +++ b/cli/src/plugin.config.ts @@ -36,9 +36,11 @@ export function resolveRuntimeCommandFromArgv(argv: readonly string[] = process. export async function createDefaultPluginConfig( argv: readonly string[] = process.argv, - runtimeCommand: RuntimeCommand = resolveRuntimeCommandFromArgv(argv) + runtimeCommand: RuntimeCommand = resolveRuntimeCommandFromArgv(argv), + executionCwd: string = process.cwd() ): Promise { return defineConfig({ + executionCwd, runtimeCommand, pluginOptions: { plugins: [ diff --git a/doc/content/cli/cli-commands.mdx b/doc/content/cli/cli-commands.mdx index aaab96e1..40678d61 100644 --- a/doc/content/cli/cli-commands.mdx +++ b/doc/content/cli/cli-commands.mdx @@ -14,7 +14,6 @@ The commands currently exposed by `tnmsc --help` are: | `tnmsc` | Run the default sync pipeline | | `tnmsc help` | Show help | | `tnmsc version` | Show the version | -| `tnmsc init` | Deprecated and no longer initializes aindex | | `tnmsc dry-run` | Preview the files that would be written | | `tnmsc clean` | Delete generated outputs and continue cleaning empty directories from the project source tree | | `tnmsc clean --dry-run` | Preview what would be cleaned, including empty directories that would be swept afterward | @@ -22,10 +21,6 @@ The commands currently exposed by `tnmsc --help` are: ## Key Takeaways -### `init` Should No Longer Appear in New Workflows - -The current implementation treats it as deprecated, so new docs no longer present it as a recommended entrypoint. - ### `config` Can Only Change a Whitelisted Set of Keys The full `.aindex` and `.tnmsc.json` key list now lives in [aindex and `.tnmsc.json`](/docs/quick-guide/aindex-and-config), together with the config location and default path rules. diff --git a/doc/content/cli/install.mdx b/doc/content/cli/install.mdx index 11b2994c..13dec394 100644 --- a/doc/content/cli/install.mdx +++ b/doc/content/cli/install.mdx @@ -49,13 +49,10 @@ The core commands currently visible in CLI help are: - the default sync pipeline - `help` - `version` -- `init`, but deprecated - `dry-run` - `clean` - `config key=value` -That means any older docs that still describe `tnmsc init` as the setup entrypoint are outdated. It now only returns a deprecation message and no longer generates aindex for you. - ## First Check Run this immediately after installation: @@ -64,4 +61,4 @@ Run this immediately after installation: tnmsc help ``` -You should see `dry-run`, `clean`, and `config`, with `init` marked as deprecated. If that is not what you see, stop there instead of following the rest of the docs. +You should see `dry-run`, `clean`, and `config`. If that is not what you see, stop there instead of following the rest of the docs. diff --git a/doc/content/cli/migration.mdx b/doc/content/cli/migration.mdx index 90da4f62..f0fabf68 100644 --- a/doc/content/cli/migration.mdx +++ b/doc/content/cli/migration.mdx @@ -27,4 +27,4 @@ This is not just a rename. It is a responsibility-based split between reading en ## Command Expectations Must Move Too -The most misleading part of the older docs is that they still present `tnmsc init` as the recommended entrypoint. In the current implementation it is deprecated, so the usage path should follow [CLI Commands](/docs/cli/cli-commands) instead. +The most misleading part of the older docs is that they still present `tnmsc init` as the recommended entrypoint. The usage path should follow [CLI Commands](/docs/cli/cli-commands) instead. diff --git a/doc/content/cli/troubleshooting.mdx b/doc/content/cli/troubleshooting.mdx index 898277c8..6c824574 100644 --- a/doc/content/cli/troubleshooting.mdx +++ b/doc/content/cli/troubleshooting.mdx @@ -11,10 +11,6 @@ status: stable The docs have moved from the old mixed grouping into seven top-level sections. Start from [/docs](/docs) or the [Quick Guide](/docs/quick-guide) instead of following older paths. -## Symptom: `tnmsc init` Did Not Generate Anything - -That is expected in the current implementation. It is deprecated and no longer initializes aindex. Maintain your source directories and global config manually instead, and use [aindex and `.tnmsc.json`](/docs/quick-guide/aindex-and-config) as the single setup page. - ## Symptom: Rules, Prompts, or MCP-Related Content Was Written to the Wrong Place Check these first: diff --git a/doc/content/cli/upgrade-notes.mdx b/doc/content/cli/upgrade-notes.mdx index 276e0be0..c25e9956 100644 --- a/doc/content/cli/upgrade-notes.mdx +++ b/doc/content/cli/upgrade-notes.mdx @@ -21,7 +21,6 @@ If you are maintaining or adding pages, place them under one of those five categ ## Command Model Upgrade -- `tnmsc init` is no longer the initialization entrypoint - `dry-run` should be the default validation step - `clean --dry-run` should be the default pre-clean check diff --git a/doc/content/quick-guide/quick-install.mdx b/doc/content/quick-guide/quick-install.mdx index 81dbcdae..020f2514 100644 --- a/doc/content/quick-guide/quick-install.mdx +++ b/doc/content/quick-guide/quick-install.mdx @@ -29,7 +29,7 @@ Then confirm that the command works: tnmsc help ``` -You should see `dry-run`, `clean`, and `config`, and `init` should already be marked as deprecated. +You should see `dry-run`, `clean`, and `config`. ## Local Monorepo Development diff --git a/gui/src-tauri/src/commands.rs b/gui/src-tauri/src/commands.rs index 19e4c9e1..ed92e5cd 100644 --- a/gui/src-tauri/src/commands.rs +++ b/gui/src-tauri/src/commands.rs @@ -8,6 +8,7 @@ use std::path::{Path, PathBuf}; use std::process::Command as StdCommand; use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; const PRIMARY_SOURCE_MDX_EXTENSION: &str = ".src.mdx"; const SOURCE_MDX_FILE_TYPE: &str = "sourceMdx"; @@ -31,6 +32,7 @@ const DEFAULT_GLOBAL_PROMPT_DIST: &str = "dist/global.mdx"; const DEFAULT_WORKSPACE_PROMPT_SRC: &str = "workspace.src.mdx"; const DEFAULT_WORKSPACE_PROMPT_DIST: &str = "dist/workspace.mdx"; const PROJECT_SERIES_CATEGORIES: [&str; 3] = ["app", "ext", "arch"]; +const INTERNAL_BRIDGE_JSON_FLAG: &str = "--bridge-json"; fn has_source_mdx_extension(name: &str) -> bool { name.ends_with(PRIMARY_SOURCE_MDX_EXTENSION) @@ -89,6 +91,27 @@ pub struct LogEntry { pub payload: serde_json::Value, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct BridgeJsonCommandResult { + success: bool, + #[serde(default)] + files_affected: i32, + #[serde(default)] + dirs_affected: i32, + #[serde(default)] + message: Option, + #[serde(default)] + warnings: Vec, + #[serde(default)] + errors: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct PluginListEntry { + name: String, +} + // --------------------------------------------------------------------------- // Tauri commands // --------------------------------------------------------------------------- @@ -97,10 +120,9 @@ pub struct LogEntry { #[tauri::command] pub fn execute_pipeline(cwd: String, dry_run: bool) -> Result { let subcommand = if dry_run { "dry-run" } else { "execute" }; - let result = tnmsc::run_bridge_command(subcommand, Path::new(&cwd), true, &[]) + let result = tnmsc::run_bridge_command(subcommand, Path::new(&cwd), &[INTERNAL_BRIDGE_JSON_FLAG]) .map_err(|e| e.to_string())?; - serde_json::from_str::(&result.stdout) - .map_err(|e| format!("Failed to parse pipeline JSON output: {e}")) + parse_pipeline_result(&result.stdout, subcommand, dry_run) } /// Load the merged configuration via the tnmsc library API. @@ -113,20 +135,28 @@ pub fn load_config(cwd: String) -> Result { /// List all registered plugins via the tnmsc bridge command. #[tauri::command] pub fn list_plugins(cwd: String) -> Result, String> { - let result = tnmsc::run_bridge_command("plugins", Path::new(&cwd), true, &[]) + let result = tnmsc::run_bridge_command("plugins", Path::new(&cwd), &[INTERNAL_BRIDGE_JSON_FLAG]) .map_err(|e| e.to_string())?; - serde_json::from_str::>(&result.stdout) - .map_err(|e| format!("Failed to parse plugins JSON output: {e}")) + let plugins = serde_json::from_str::>(&result.stdout) + .map_err(|e| format!("Failed to parse plugins output: {e}"))?; + Ok(plugins + .into_iter() + .map(|plugin| PluginExecutionResult { + plugin: plugin.name, + files: 0, + dirs: 0, + dry_run: false, + }) + .collect()) } /// Clean previously generated output files. #[tauri::command] pub fn clean_outputs(cwd: String, dry_run: bool) -> Result { let subcommand = if dry_run { "dry-run-clean" } else { "clean" }; - let result = tnmsc::run_bridge_command(subcommand, Path::new(&cwd), true, &[]) + let result = tnmsc::run_bridge_command(subcommand, Path::new(&cwd), &[INTERNAL_BRIDGE_JSON_FLAG]) .map_err(|e| e.to_string())?; - serde_json::from_str::(&result.stdout) - .map_err(|e| format!("Failed to parse clean JSON output: {e}")) + parse_pipeline_result(&result.stdout, subcommand, dry_run) } /// Get log output from a CLI bridge command. @@ -139,10 +169,8 @@ pub fn get_logs(cwd: String, command: String) -> Result, String> { let args: Vec<&str> = command.split_whitespace().collect(); let subcommand = args.first().copied().unwrap_or("execute"); let extra_args: Vec<&str> = args.iter().skip(1).copied().collect(); - let result = tnmsc::run_bridge_command(subcommand, Path::new(&cwd), false, &extra_args) + let result = tnmsc::run_bridge_command(subcommand, Path::new(&cwd), &extra_args) .map_err(|e| e.to_string())?; - // Try parsing stderr first (log output goes to stderr in non-JSON mode), - // fall back to stdout if stderr has no parseable entries. let logs = parse_log_lines(&result.stderr); if logs.is_empty() { Ok(parse_log_lines(&result.stdout)) @@ -151,29 +179,142 @@ pub fn get_logs(cwd: String, command: String) -> Result, String> { } } -/// Parse log lines from raw CLI output using JSON. -/// -/// Each line is expected to be a JSON object with `$` (metadata array) and `_` (payload). -/// Format: `{"$":["timestamp","LEVEL","logger"],"_":{...payload...}}` +fn parse_pipeline_result(raw: &str, command: &str, dry_run: bool) -> Result { + let parsed = serde_json::from_str::(raw) + .map_err(|e| format!("Failed to parse bridge result: {e}"))?; + + Ok(PipelineResult { + success: parsed.success, + total_files: parsed.files_affected, + total_dirs: parsed.dirs_affected, + dry_run, + command: Some(command.to_string()), + plugin_results: Vec::new(), + logs: Vec::new(), + errors: collect_bridge_messages(&parsed), + }) +} + +fn collect_bridge_messages(result: &BridgeJsonCommandResult) -> Vec { + let mut messages = Vec::new(); + + if let Some(message) = result.message.as_ref() + && !message.is_empty() + { + messages.push(message.clone()); + } + + for diagnostic in &result.errors { + if let Some(message) = extract_diagnostic_message(diagnostic) { + messages.push(message); + } + } + + for diagnostic in &result.warnings { + if let Some(message) = extract_diagnostic_message(diagnostic) { + messages.push(message); + } + } + + messages +} + +fn extract_diagnostic_message(diagnostic: &Value) -> Option { + let object = diagnostic.as_object()?; + if let Some(copy_text) = object.get("copyText").and_then(Value::as_array) { + let lines = copy_text + .iter() + .filter_map(Value::as_str) + .map(ToOwned::to_owned) + .collect::>(); + if !lines.is_empty() { + return Some(lines.join("\n")); + } + } + + let title = object.get("title").and_then(Value::as_str)?; + let code = object.get("code").and_then(Value::as_str).unwrap_or("DIAGNOSTIC"); + Some(format!("[{code}] {title}")) +} + +/// Parse markdown-style log output into lightweight GUI log entries. fn parse_log_lines(raw: &str) -> Vec { - raw.lines() - .filter_map(|line| { - let trimmed = line.trim(); - let val: serde_json::Value = serde_json::from_str(trimmed).ok()?; - let obj = val.as_object()?; - let meta = obj.get("$")?.as_array()?; - let timestamp = meta.first()?.as_str()?.to_string(); - let level = meta.get(1)?.as_str()?.to_string(); - let logger = meta.get(2)?.as_str()?.to_string(); - let payload = obj.get("_").cloned().unwrap_or(serde_json::Value::Null); - Some(LogEntry { - timestamp, + let mut entries = Vec::new(); + let mut current: Option = None; + + for raw_line in raw.lines() { + let line = raw_line.trim_end(); + if let Some((level, logger, message)) = parse_log_header(line) { + if let Some(entry) = current.take() { + entries.push(entry); + } + + let mut payload = Map::new(); + if let Some(message) = message { + payload.insert("message".to_string(), Value::String(message)); + } + + current = Some(LogEntry { + timestamp: String::new(), level, logger, - payload, - }) - }) - .collect() + payload: Value::Object(payload), + }); + continue; + } + + if let Some(entry) = current.as_mut() { + append_log_body_line(&mut entry.payload, line); + } + } + + if let Some(entry) = current.take() { + entries.push(entry); + } + + entries +} + +fn parse_log_header(line: &str) -> Option<(String, String, Option)> { + if !line.starts_with("**") { + return None; + } + + let remainder = line.strip_prefix("**")?; + let level_end = remainder.find("**")?; + let level = remainder[..level_end].trim().to_string(); + let after_level = remainder[level_end + 2..].trim_start(); + let logger_start = after_level.find('`')?; + let after_logger_start = &after_level[logger_start + 1..]; + let logger_end = after_logger_start.find('`')?; + let logger = after_logger_start[..logger_end].to_string(); + let message = after_logger_start[logger_end + 1..].trim(); + + Some(( + level, + logger, + if message.is_empty() { + None + } else { + Some(message.to_string()) + }, + )) +} + +fn append_log_body_line(payload: &mut Value, line: &str) { + let object = match payload { + Value::Object(object) => object, + _ => return, + }; + + let entry = object + .entry("body".to_string()) + .or_insert_with(|| Value::Array(Vec::new())); + if let Value::Array(lines) = entry + && !line.trim().is_empty() + { + lines.push(Value::String(line.trim().to_string())); + } } /// Resolve the canonical global config file path. diff --git a/sdk/src/bridge/node.rs b/sdk/src/bridge/node.rs index de33803a..cc2087f5 100644 --- a/sdk/src/bridge/node.rs +++ b/sdk/src/bridge/node.rs @@ -227,9 +227,9 @@ fn detect_node() -> Option { /// Run a Node.js plugin runtime command. /// -/// Spawns: `node [--json] [extra_args...]` +/// Spawns: `node [extra_args...]` /// Inherits stdin/stdout/stderr so the Node.js process output goes directly to terminal. -pub fn run_node_command(subcommand: &str, json_mode: bool, extra_args: &[&str]) -> ExitCode { +pub fn run_node_command(subcommand: &str, extra_args: &[&str]) -> ExitCode { let logger = create_logger("NodeBridge", None); // Find node @@ -282,7 +282,6 @@ pub fn run_node_command(subcommand: &str, json_mode: bool, extra_args: &[&str]) "node": &node, "runtime": runtime_path.to_string_lossy(), "subcommand": subcommand, - "json": json_mode })), ); @@ -290,10 +289,6 @@ pub fn run_node_command(subcommand: &str, json_mode: bool, extra_args: &[&str]) cmd.arg(&runtime_path); cmd.arg(subcommand); - if json_mode { - cmd.arg("--json"); - } - for arg in extra_args { cmd.arg(arg); } @@ -338,7 +333,6 @@ pub fn run_node_command(subcommand: &str, json_mode: bool, extra_args: &[&str]) pub fn run_node_command_captured( subcommand: &str, cwd: &Path, - json_mode: bool, extra_args: &[&str], ) -> Result { let node = find_node().ok_or(CliError::NodeNotFound)?; @@ -351,10 +345,6 @@ pub fn run_node_command_captured( cmd.arg(&runtime_path); cmd.arg(subcommand); - if json_mode { - cmd.arg("--json"); - } - for arg in extra_args { cmd.arg(arg); } @@ -368,8 +358,9 @@ pub fn run_node_command_captured( let exit_code = output.status.code().unwrap_or(-1); let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let expects_structured_stdout = extra_args.iter().any(|arg| *arg == "--bridge-json"); - if output.status.success() || (json_mode && !stdout.trim().is_empty()) { + if output.status.success() || (expects_structured_stdout && !stdout.trim().is_empty()) { Ok(BridgeCommandResult { stdout, stderr, diff --git a/sdk/src/config.test.ts b/sdk/src/config.test.ts index 578bc82f..627eba44 100644 --- a/sdk/src/config.test.ts +++ b/sdk/src/config.test.ts @@ -98,6 +98,31 @@ describe('defineConfig', () => { } }) + it('keeps executionCwd separate from workspaceDir', async () => { + const tempWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-execution-cwd-workspace-')) + const externalCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-execution-cwd-external-')) + + try { + const result = await defineConfig({ + loadUserConfig: false, + executionCwd: externalCwd, + pluginOptions: { + workspaceDir: tempWorkspace, + plugins: [new WorkspaceInputCapability()] + } + }) + + expect(result.userConfigOptions.workspaceDir).toBe(tempWorkspace) + expect(result.context.workspace.directory.path).toBe(tempWorkspace) + expect(result.executionPlan.cwd).toBe(externalCwd) + expect(result.executionPlan.workspaceDir).toBe(tempWorkspace) + expect(result.executionPlan.scope).toBe('external') + } finally { + fs.rmSync(tempWorkspace, {recursive: true, force: true}) + fs.rmSync(externalCwd, {recursive: true, force: true}) + } + }) + it('does not run builtin mutating input effects when plugins is explicitly empty', async () => { const tempWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-explicit-empty-plugins-')) const orphanSkillDir = path.join(tempWorkspace, 'aindex', 'dist', 'skills', 'orphan-skill') diff --git a/sdk/src/config.ts b/sdk/src/config.ts index f19f27d1..235cb059 100644 --- a/sdk/src/config.ts +++ b/sdk/src/config.ts @@ -16,9 +16,11 @@ import type { } from './plugins/plugin-core' import type {RuntimeCommand} from './runtime-command' import * as path from 'node:path' +import process from 'node:process' import {createLogger} from '@truenine/logger' import {checkVersionControl} from './Aindex' import {getConfigLoader} from './ConfigLoader' +import {resolveExecutionPlan} from './execution-plan' import {collectInputContext} from './inputs/runtime' import { buildDefaultAindexConfig, @@ -37,6 +39,7 @@ export interface PipelineConfig { readonly context: OutputCollectedContext readonly outputPlugins: readonly OutputPlugin[] readonly userConfigOptions: Required + readonly executionPlan: import('./execution-plan').ExecutionPlan } interface ResolvedPluginSetup { @@ -101,6 +104,8 @@ export interface DefineConfigOptions { readonly cwd?: string + readonly executionCwd?: string + readonly runtimeCommand?: RuntimeCommand } @@ -242,7 +247,12 @@ function mergeWindowsOptions(base?: WindowsOptions, override?: WindowsOptions): * Check if options is DefineConfigOptions */ function isDefineConfigOptions(options: PluginOptions | DefineConfigOptions): options is DefineConfigOptions { - return 'pluginOptions' in options || 'configLoaderOptions' in options || 'loadUserConfig' in options || 'cwd' in options || 'runtimeCommand' in options + return 'pluginOptions' in options + || 'configLoaderOptions' in options + || 'loadUserConfig' in options + || 'cwd' in options + || 'executionCwd' in options + || 'runtimeCommand' in options } function getProgrammaticPluginDeclaration(options: PluginOptions | DefineConfigOptions): { @@ -295,6 +305,7 @@ function shouldUsePluginsFastPath(runtimeCommand?: RuntimeCommand): boolean { async function resolvePluginSetup(options: PluginOptions | DefineConfigOptions = {}): Promise< ResolvedPluginSetup & { + readonly executionCwd: string readonly runtimeCommand?: RuntimeCommand readonly userConfigFound: boolean readonly userConfigSources: readonly string[] @@ -302,6 +313,7 @@ async function resolvePluginSetup(options: PluginOptions | DefineConfigOptions = > { let shouldLoadUserConfig: boolean, cwd: string | undefined, + executionCwd: string | undefined, pluginOptions: PluginOptions, configLoaderOptions: ConfigLoaderOptions | undefined, runtimeCommand: RuntimeCommand | undefined @@ -310,11 +322,13 @@ async function resolvePluginSetup(options: PluginOptions | DefineConfigOptions = ({ pluginOptions = {}, cwd, + executionCwd, configLoaderOptions, runtimeCommand } = { pluginOptions: options.pluginOptions, cwd: options.cwd, + executionCwd: options.executionCwd, configLoaderOptions: options.configLoaderOptions, runtimeCommand: options.runtimeCommand }) @@ -349,6 +363,7 @@ async function resolvePluginSetup(options: PluginOptions | DefineConfigOptions = const mergedOptions = mergeConfig(userConfigOptions, pluginOptions) const {plugins = [], logLevel} = mergedOptions const logger = createLogger('defineConfig', logLevel) + const resolvedExecutionCwd = path.resolve(executionCwd ?? cwd ?? process.cwd()) if (userConfigFound) { logger.info('user config loaded', {sources: userConfigSources}) @@ -368,6 +383,7 @@ async function resolvePluginSetup(options: PluginOptions | DefineConfigOptions = mergedOptions, outputPlugins, inputCapabilities, + executionCwd: resolvedExecutionCwd, ...userConfigFile != null && {userConfigFile}, ...runtimeCommand != null && {runtimeCommand}, userConfigFound, @@ -387,12 +403,17 @@ async function resolvePluginSetup(options: PluginOptions | DefineConfigOptions = */ export async function defineConfig(options: PluginOptions | DefineConfigOptions = {}): Promise { const {hasExplicitProgrammaticPlugins, explicitProgrammaticPlugins} = getProgrammaticPluginDeclaration(options) - const {mergedOptions, outputPlugins, inputCapabilities, userConfigFile, runtimeCommand} = await resolvePluginSetup(options) + const {mergedOptions, outputPlugins, inputCapabilities, userConfigFile, runtimeCommand, executionCwd} = await resolvePluginSetup(options) const logger = createLogger('defineConfig', mergedOptions.logLevel) if (shouldUsePluginsFastPath(runtimeCommand)) { const context = createMinimalOutputCollectedContext(mergedOptions) - return {context, outputPlugins, userConfigOptions: mergedOptions} + return { + context, + outputPlugins, + userConfigOptions: mergedOptions, + executionPlan: resolveExecutionPlan(context, executionCwd) + } } const merged = await collectInputContext({ @@ -429,5 +450,10 @@ export async function defineConfig(options: PluginOptions | DefineConfigOptions checkVersionControl(merged.aindexDir, logger) } - return {context, outputPlugins, userConfigOptions: mergedOptions} + return { + context, + outputPlugins, + userConfigOptions: mergedOptions, + executionPlan: resolveExecutionPlan(context, executionCwd) + } } diff --git a/sdk/src/execution-plan.test.ts b/sdk/src/execution-plan.test.ts new file mode 100644 index 00000000..e80754ae --- /dev/null +++ b/sdk/src/execution-plan.test.ts @@ -0,0 +1,138 @@ +import type {OutputCollectedContext, Project} from './plugins/plugin-core' +import * as path from 'node:path' +import {describe, expect, it} from 'vitest' +import {filterPathScopedEntriesForExecutionPlan, resolveExecutionPlan} from './execution-plan' +import {FilePathKind} from './plugins/plugin-core' + +function createProject(workspaceDir: string, name: string, series: Project['promptSeries']): Project { + return { + name, + promptSeries: series, + dirFromWorkspacePath: { + pathKind: FilePathKind.Relative, + path: name, + basePath: workspaceDir, + getDirectoryName: () => name, + getAbsolutePath: () => path.join(workspaceDir, name) + } + } +} + +function createContext(workspaceDir: string): OutputCollectedContext { + return { + workspace: { + directory: { + pathKind: FilePathKind.Absolute, + path: workspaceDir, + getDirectoryName: () => path.basename(workspaceDir) + }, + projects: [ + createProject(workspaceDir, 'app-one', 'app'), + createProject(workspaceDir, 'plugin-one', 'ext'), + createProject(workspaceDir, 'system-one', 'arch'), + createProject(workspaceDir, 'tool-one', 'softwares') + ] + } + } +} + +describe('execution plan resolution', () => { + it('resolves workspace scope only when cwd exactly matches workspaceDir', () => { + const workspaceDir = path.resolve('/tmp/tnmsc-execution-plan-workspace') + const context = createContext(workspaceDir) + + const result = resolveExecutionPlan(context, workspaceDir) + + expect(result.scope).toBe('workspace') + }) + + it('resolves project scope for a managed project root and nested path', () => { + const workspaceDir = path.resolve('/tmp/tnmsc-execution-plan-project') + const context = createContext(workspaceDir) + + const nestedPath = path.join(workspaceDir, 'plugin-one', 'docs', 'nested') + const result = resolveExecutionPlan(context, nestedPath) + + expect(result.scope).toBe('project') + expect(result.scope === 'project' ? result.matchedProject.name : void 0).toBe('plugin-one') + expect(result.scope === 'project' ? result.matchedProject.series : void 0).toBe('ext') + }) + + it('resolves unsupported scope for a workspace subdirectory outside managed projects', () => { + const workspaceDir = path.resolve('/tmp/tnmsc-execution-plan-unsupported') + const context = createContext(workspaceDir) + + const result = resolveExecutionPlan(context, path.join(workspaceDir, 'scripts')) + + expect(result.scope).toBe('unsupported') + }) + + it('resolves external scope outside the workspace tree', () => { + const workspaceDir = path.resolve('/tmp/tnmsc-execution-plan-external') + const context = createContext(workspaceDir) + + const result = resolveExecutionPlan(context, path.resolve('/tmp/another-location')) + + expect(result.scope).toBe('external') + expect(result.projectsBySeries.app.map(project => project.name)).toEqual(['app-one']) + expect(result.projectsBySeries.softwares.map(project => project.name)).toEqual(['tool-one']) + }) +}) + +describe('execution-scoped entry filtering', () => { + it('keeps only workspace and global entries in workspace mode', () => { + const workspaceDir = path.resolve('/tmp/tnmsc-execution-filter-workspace') + const context = createContext(workspaceDir) + const plan = resolveExecutionPlan(context, workspaceDir) + const entries = [ + {path: path.join(workspaceDir, 'WARP.md'), scope: 'project'}, + {path: path.join(workspaceDir, 'app-one', 'AGENTS.md'), scope: 'project'}, + {path: path.join(workspaceDir, 'plugin-one', 'WARP.md'), scope: 'project'}, + {path: path.resolve('/tmp/global-config/CODEX.md'), scope: 'global'} + ] as const + + const filtered = filterPathScopedEntriesForExecutionPlan(entries, plan, context) + + expect(filtered).toEqual([ + {path: path.join(workspaceDir, 'WARP.md'), scope: 'project'}, + {path: path.resolve('/tmp/global-config/CODEX.md'), scope: 'global'} + ]) + }) + + it('keeps only the matched project and global entries in project mode, including Warp-style project outputs', () => { + const workspaceDir = path.resolve('/tmp/tnmsc-execution-filter-project') + const context = createContext(workspaceDir) + const plan = resolveExecutionPlan(context, path.join(workspaceDir, 'plugin-one', 'nested')) + const entries = [ + {path: path.join(workspaceDir, 'WARP.md'), scope: 'project'}, + {path: path.join(workspaceDir, 'plugin-one', 'WARP.md'), scope: 'project'}, + {path: path.join(workspaceDir, 'plugin-one', 'docs', 'WARP.md'), scope: 'project'}, + {path: path.join(workspaceDir, 'app-one', 'AGENTS.md'), scope: 'project'}, + {path: path.resolve('/tmp/global-config/CODEX.md'), scope: 'global'} + ] as const + + const filtered = filterPathScopedEntriesForExecutionPlan(entries, plan, context) + + expect(filtered).toEqual([ + {path: path.join(workspaceDir, 'plugin-one', 'WARP.md'), scope: 'project'}, + {path: path.join(workspaceDir, 'plugin-one', 'docs', 'WARP.md'), scope: 'project'}, + {path: path.resolve('/tmp/global-config/CODEX.md'), scope: 'global'} + ]) + }) + + it('keeps workspace, project, and global entries in external mode', () => { + const workspaceDir = path.resolve('/tmp/tnmsc-execution-filter-external') + const context = createContext(workspaceDir) + const plan = resolveExecutionPlan(context, path.resolve('/tmp/outside-workspace')) + const entries = [ + {path: path.join(workspaceDir, 'WARP.md'), scope: 'project'}, + {path: path.join(workspaceDir, 'plugin-one', 'WARP.md'), scope: 'project'}, + {path: path.join(workspaceDir, 'app-one', 'AGENTS.md'), scope: 'project'}, + {path: path.resolve('/tmp/global-config/CODEX.md'), scope: 'global'} + ] as const + + const filtered = filterPathScopedEntriesForExecutionPlan(entries, plan, context) + + expect(filtered).toEqual(entries) + }) +}) diff --git a/sdk/src/execution-plan.ts b/sdk/src/execution-plan.ts new file mode 100644 index 00000000..791a859c --- /dev/null +++ b/sdk/src/execution-plan.ts @@ -0,0 +1,273 @@ +import type {AindexProjectSeriesName} from './plugins/plugin-core/AindexConfigDefaults' +import type { + OutputCollectedContext, + Project +} from './plugins/plugin-core/InputTypes' +import * as path from 'node:path' + +export type ExecutionScope + = | 'workspace' + | 'project' + | 'external' + | 'unsupported' + +export interface ExecutionPlanProjectSummary { + readonly name: string + readonly rootDir: string + readonly series?: AindexProjectSeriesName +} + +export interface ExecutionPlanProjectsBySeries { + readonly app: readonly ExecutionPlanProjectSummary[] + readonly ext: readonly ExecutionPlanProjectSummary[] + readonly arch: readonly ExecutionPlanProjectSummary[] + readonly softwares: readonly ExecutionPlanProjectSummary[] +} + +interface ExecutionPlanBase { + readonly cwd: string + readonly workspaceDir: string + readonly projectsBySeries: ExecutionPlanProjectsBySeries +} + +export interface WorkspaceExecutionPlan extends ExecutionPlanBase { + readonly scope: 'workspace' +} + +export interface ProjectExecutionPlan extends ExecutionPlanBase { + readonly scope: 'project' + readonly matchedProject: ExecutionPlanProjectSummary +} + +export interface ExternalExecutionPlan extends ExecutionPlanBase { + readonly scope: 'external' +} + +export interface UnsupportedExecutionPlan extends ExecutionPlanBase { + readonly scope: 'unsupported' + readonly managedProjects: readonly ExecutionPlanProjectSummary[] +} + +export type ExecutionPlan + = | WorkspaceExecutionPlan + | ProjectExecutionPlan + | ExternalExecutionPlan + | UnsupportedExecutionPlan + +interface PathScopedEntry { + readonly path: string + readonly scope?: string +} + +type ScopedTargetOwnership = 'global' | 'workspace' | 'project' | 'external' + +const EMPTY_PROJECTS_BY_SERIES: ExecutionPlanProjectsBySeries = Object.freeze({ + app: Object.freeze([]), + ext: Object.freeze([]), + arch: Object.freeze([]), + softwares: Object.freeze([]) +}) + +function normalizeAbsolutePath(rawPath: string): string { + return path.resolve(rawPath) +} + +function isSameOrChildPath(candidatePath: string, parentPath: string): boolean { + const normalizedCandidate = normalizeAbsolutePath(candidatePath) + const normalizedParent = normalizeAbsolutePath(parentPath) + if (normalizedCandidate === normalizedParent) return true + const relativePath = path.relative(normalizedParent, normalizedCandidate) + return ( + relativePath.length > 0 + && relativePath !== '..' + && !relativePath.startsWith(`..${path.sep}`) + && !path.isAbsolute(relativePath) + ) +} + +function toManagedProjectSummary( + project: Project +): ExecutionPlanProjectSummary | undefined { + if (project.isWorkspaceRootProject === true) return void 0 + const projectName = project.name + const projectRootDir = project.dirFromWorkspacePath?.getAbsolutePath() + if (projectName == null || projectRootDir == null) return void 0 + + return { + name: projectName, + rootDir: normalizeAbsolutePath(projectRootDir), + ...project.promptSeries != null ? {series: project.promptSeries} : {} + } +} + +function sortProjects( + projects: readonly ExecutionPlanProjectSummary[] +): readonly ExecutionPlanProjectSummary[] { + return [...projects].sort((left, right) => { + const leftSeries = left.series ?? '' + const rightSeries = right.series ?? '' + if (leftSeries !== rightSeries) + { return leftSeries.localeCompare(rightSeries) } + return left.name.localeCompare(right.name) + }) +} + +function collectManagedProjects( + context: OutputCollectedContext +): readonly ExecutionPlanProjectSummary[] { + return sortProjects( + context.workspace.projects + .map(toManagedProjectSummary) + .filter( + (project): project is ExecutionPlanProjectSummary => project != null + ) + ) +} + +function groupProjectsBySeries( + projects: readonly ExecutionPlanProjectSummary[] +): ExecutionPlanProjectsBySeries { + const grouped: Record< + AindexProjectSeriesName, + ExecutionPlanProjectSummary[] + > = { + app: [], + ext: [], + arch: [], + softwares: [] + } + + for (const project of projects) { + if (project.series == null) continue + grouped[project.series].push(project) + } + + return { + app: Object.freeze([...grouped.app]), + ext: Object.freeze([...grouped.ext]), + arch: Object.freeze([...grouped.arch]), + softwares: Object.freeze([...grouped.softwares]) + } +} + +function findMatchedProject( + cwd: string, + managedProjects: readonly ExecutionPlanProjectSummary[] +): ExecutionPlanProjectSummary | undefined { + const matches = managedProjects.filter(project => + isSameOrChildPath(cwd, project.rootDir)) + if (matches.length === 0) return void 0 + + return [...matches].sort( + (left, right) => right.rootDir.length - left.rootDir.length + )[0] +} + +export function createEmptyExecutionPlanProjectsBySeries(): ExecutionPlanProjectsBySeries { + return EMPTY_PROJECTS_BY_SERIES +} + +export function resolveExecutionPlan( + context: OutputCollectedContext, + executionCwd: string +): ExecutionPlan { + const cwd = normalizeAbsolutePath(executionCwd) + const workspaceDir = normalizeAbsolutePath(context.workspace.directory.path) + const managedProjects = collectManagedProjects(context) + const projectsBySeries + = managedProjects.length === 0 + ? createEmptyExecutionPlanProjectsBySeries() + : groupProjectsBySeries(managedProjects) + + if (cwd === workspaceDir) { + return { + scope: 'workspace', + cwd, + workspaceDir, + projectsBySeries + } + } + + const matchedProject = findMatchedProject(cwd, managedProjects) + if (matchedProject != null) { + return { + scope: 'project', + cwd, + workspaceDir, + projectsBySeries, + matchedProject + } + } + + if (isSameOrChildPath(cwd, workspaceDir)) { + return { + scope: 'unsupported', + cwd, + workspaceDir, + projectsBySeries, + managedProjects + } + } + + return { + scope: 'external', + cwd, + workspaceDir, + projectsBySeries + } +} + +function isGlobalScopedEntry(scope: string | undefined): boolean { + return scope === 'global' || scope === 'xdgConfig' +} + +function classifyPathScopedEntry( + entry: PathScopedEntry, + workspaceDir: string, + managedProjects: readonly ExecutionPlanProjectSummary[] +): ScopedTargetOwnership { + if (isGlobalScopedEntry(entry.scope)) return 'global' + + const entryPath = normalizeAbsolutePath(entry.path) + const ownerProject = findMatchedProject(entryPath, managedProjects) + if (ownerProject != null) return 'project' + if (isSameOrChildPath(entryPath, workspaceDir)) return 'workspace' + return 'external' +} + +function shouldIncludeTargetOwnership( + plan: ExecutionPlan, + ownership: ScopedTargetOwnership, + entryPath: string, + managedProjects: readonly ExecutionPlanProjectSummary[] +): boolean { + if (plan.scope === 'unsupported') return false + if (ownership === 'global') return true + if (plan.scope === 'external') return true + if (plan.scope === 'workspace') return ownership === 'workspace' + if (ownership !== 'project') return false + + const matchedProject = findMatchedProject(entryPath, managedProjects) + return matchedProject?.rootDir === plan.matchedProject.rootDir +} + +export function filterPathScopedEntriesForExecutionPlan< + T extends PathScopedEntry +>( + entries: readonly T[], + plan: ExecutionPlan | undefined, + context: OutputCollectedContext +): T[] { + if (plan == null) return [...entries] + + const workspaceDir = normalizeAbsolutePath(context.workspace.directory.path) + const managedProjects = collectManagedProjects(context) + + return entries.filter(entry => + shouldIncludeTargetOwnership( + plan, + classifyPathScopedEntry(entry, workspaceDir, managedProjects), + entry.path, + managedProjects + )) +} diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 87ffd314..423e9cc5 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -2,6 +2,7 @@ export * from './Aindex' export * from './config' export * from './ConfigLoader' export * from './diagnostics' +export * from './execution-plan' export * from './pipeline/OutputRuntimeTargets' export * from './plugins/plugin-agentskills-compact' export * from './plugins/plugin-agentsmd' diff --git a/sdk/src/lib.rs b/sdk/src/lib.rs index 49f0797c..e47804ea 100644 --- a/sdk/src/lib.rs +++ b/sdk/src/lib.rs @@ -99,10 +99,9 @@ pub fn update_global_config_from_pairs( pub fn run_bridge_command( subcommand: &str, cwd: &Path, - json_mode: bool, extra_args: &[&str], ) -> Result { - bridge::node::run_node_command_captured(subcommand, cwd, json_mode, extra_args) + bridge::node::run_node_command_captured(subcommand, cwd, extra_args) } // --------------------------------------------------------------------------- @@ -250,7 +249,7 @@ mod property_tests { if !node_available { // Verify NodeNotFound is returned as a typed error let tmp = tempfile::TempDir::new().unwrap(); - let result = run_bridge_command("version", tmp.path(), false, &[]); + let result = run_bridge_command("version", tmp.path(), &[]); assert!( matches!(result, Err(CliError::NodeNotFound)), "expected NodeNotFound when node is absent, got: {:?}", @@ -259,7 +258,7 @@ mod property_tests { } else if !runtime_available { // Verify PluginRuntimeNotFound is returned as a typed error let tmp = tempfile::TempDir::new().unwrap(); - let result = run_bridge_command("version", tmp.path(), false, &[]); + let result = run_bridge_command("version", tmp.path(), &[]); assert!( matches!(result, Err(CliError::PluginRuntimeNotFound(_))), "expected PluginRuntimeNotFound when runtime is absent, got: {:?}", @@ -269,7 +268,7 @@ mod property_tests { // Both available — verify the function signature compiles and returns Result // We do NOT actually spawn a process here to avoid hanging on unknown subcommands. // The typed return type is verified at compile time. - let _: fn(&str, &Path, bool, &[&str]) -> Result = + let _: fn(&str, &Path, &[&str]) -> Result = run_bridge_command; } } @@ -374,7 +373,7 @@ mod property_tests_cwd { return Ok(()); } - let result = run_bridge_command("execute", cwd, true, &[]); + let result = run_bridge_command("execute", cwd, &[]); match result { Ok(_) => { @@ -437,7 +436,7 @@ mod property_tests_cwd { let cwd = tmp.path(); assert!(cwd.exists(), "temp dir must exist"); - let result = run_bridge_command("execute", cwd, true, &[]); + let result = run_bridge_command("execute", cwd, &[]); match result { Ok(_) @@ -472,7 +471,7 @@ mod property_tests_cwd { let nonexistent = std::path::Path::new("/this/path/does/not/exist/tnmsc_test_8_1"); assert!(!nonexistent.exists(), "path must not exist for this test"); - let result = run_bridge_command("execute", nonexistent, true, &[]); + let result = run_bridge_command("execute", nonexistent, &[]); // Must NOT be Ok — a non-existent cwd should never produce a successful result. assert!( diff --git a/sdk/src/plugins/OpencodeCLIOutputPlugin.test.ts b/sdk/src/plugins/OpencodeCLIOutputPlugin.test.ts index ed51fbc8..b97bc408 100644 --- a/sdk/src/plugins/OpencodeCLIOutputPlugin.test.ts +++ b/sdk/src/plugins/OpencodeCLIOutputPlugin.test.ts @@ -101,7 +101,7 @@ describe('opencodeCLIOutputPlugin synthetic workspace project output', () => { }) describe('opencodeCLIOutputPlugin cleanup', () => { - it('keeps global opencode.json out of cleanup delete targets', async () => { + it('includes global and xdgConfig opencode.json in cleanup delete targets', async () => { const tempHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-opencode-cleanup-')) try { @@ -110,7 +110,7 @@ describe('opencodeCLIOutputPlugin cleanup', () => { const deletePaths = cleanup.delete?.map(target => target.path.replaceAll('\\', '/')) ?? [] expect(deletePaths).toContain(path.join(tempHomeDir, '.config', 'opencode', 'AGENTS.md').replaceAll('\\', '/')) - expect(deletePaths).not.toContain(path.join(tempHomeDir, '.config', 'opencode', 'opencode.json').replaceAll('\\', '/')) + expect(deletePaths).toContain(path.join(tempHomeDir, '.config', 'opencode', 'opencode.json').replaceAll('\\', '/')) } finally { fs.rmSync(tempHomeDir, {recursive: true, force: true}) } diff --git a/sdk/src/plugins/OpencodeCLIOutputPlugin.ts b/sdk/src/plugins/OpencodeCLIOutputPlugin.ts index 72f6564a..81ed6774 100644 --- a/sdk/src/plugins/OpencodeCLIOutputPlugin.ts +++ b/sdk/src/plugins/OpencodeCLIOutputPlugin.ts @@ -19,10 +19,21 @@ type OpencodeOutputSource | {readonly kind: 'projectChildMemory', readonly content: string} | {readonly kind: 'command', readonly command: CommandPrompt} | {readonly kind: 'subAgent', readonly agent: SubAgentPrompt} - | {readonly kind: 'skillMain', readonly skill: SkillPrompt, readonly normalizedSkillName: string} + | { + readonly kind: 'skillMain' + readonly skill: SkillPrompt + readonly normalizedSkillName: string + } | {readonly kind: 'skillReference', readonly content: string} - | {readonly kind: 'skillResource', readonly content: string, readonly encoding: 'text' | 'base64'} - | {readonly kind: 'mcpConfig', readonly mcpServers: Record>} + | { + readonly kind: 'skillResource' + readonly content: string + readonly encoding: 'text' | 'base64' + } + | { + readonly kind: 'mcpConfig' + readonly mcpServers: Record> + } | {readonly kind: 'rule', readonly rule: RulePrompt} function transformOpencodeCommandFrontMatter( @@ -34,7 +45,9 @@ function transformOpencodeCommandFrontMatter( const frontMatter: Record = {} const source = context.sourceFrontMatter - if (source?.['description'] != null) frontMatter['description'] = source['description'] + if (source?.['description'] != null) { + frontMatter['description'] = source['description'] + } if (source?.['agent'] != null) frontMatter['agent'] = source['agent'] if (source?.['model'] != null) frontMatter['model'] = source['model'] @@ -45,7 +58,9 @@ function transformOpencodeCommandFrontMatter( } for (const [key, value] of Object.entries(source ?? {})) { - if (!['description', 'agent', 'model', 'allowTools', 'namingCase', 'argumentHint'].includes(key)) frontMatter[key] = value + if (!['description', 'agent', 'model', 'allowTools', 'namingCase', 'argumentHint'].includes(key)) { + frontMatter[key] = value + } } return frontMatter @@ -79,11 +94,11 @@ export class OpencodeCLIOutputPlugin extends AbstractOutputPlugin { dirs: ['.opencode/commands', '.opencode/agents', '.opencode/skills', '.opencode/rules'] }, global: { - files: ['.config/opencode/AGENTS.md'], + files: ['.config/opencode/AGENTS.md', '.config/opencode/opencode.json'], dirs: ['.config/opencode/commands', '.config/opencode/agents', '.config/opencode/skills', '.config/opencode/rules'] }, xdgConfig: { - files: ['opencode/AGENTS.md'], + files: ['opencode/AGENTS.md', 'opencode/opencode.json'], dirs: ['opencode/commands', 'opencode/agents', 'opencode/skills', 'opencode/rules'] } } @@ -229,7 +244,9 @@ export class OpencodeCLIOutputPlugin extends AbstractOutputPlugin { }) } - const transformOptions = this.getTransformOptionsFromContext(ctx, {includeSeriesPrefix: true}) + const transformOptions = this.getTransformOptionsFromContext(ctx, { + includeSeriesPrefix: true + }) for (const project of promptProjects) { const projectRootDir = this.resolveProjectRootDir(ctx, project) if (projectRootDir == null) continue @@ -276,7 +293,10 @@ export class OpencodeCLIOutputPlugin extends AbstractOutputPlugin { declarations.push({ path: path.join(basePath, COMMANDS_SUBDIR, this.transformCommandName(command, transformOptions)), scope: 'project', - source: {kind: 'command', command} satisfies OpencodeOutputSource + source: { + kind: 'command', + command + } satisfies OpencodeOutputSource }) } } @@ -287,13 +307,18 @@ export class OpencodeCLIOutputPlugin extends AbstractOutputPlugin { declarations.push({ path: path.join(basePath, AGENTS_SUBDIR, this.transformSubAgentName(agent)), scope: 'project', - source: {kind: 'subAgent', agent} satisfies OpencodeOutputSource + source: { + kind: 'subAgent', + agent + } satisfies OpencodeOutputSource }) } } const filteredSkills = filterByProjectConfig(selectedSkills.items, project.projectConfig, 'skills') - if (selectedSkills.selectedScope === 'project') pushSkillDeclarations(basePath, 'project', filteredSkills) + if (selectedSkills.selectedScope === 'project') { + pushSkillDeclarations(basePath, 'project', filteredSkills) + } if (selectedMcpSkills.selectedScope === 'project') { const filteredMcpSkills = filterByProjectConfig(selectedMcpSkills.items, project.projectConfig, 'skills') @@ -398,6 +423,10 @@ export class OpencodeCLIOutputPlugin extends AbstractOutputPlugin { case 'skillResource': return source.encoding === 'base64' ? Buffer.from(source.content, 'base64') : source.content case 'mcpConfig': + // lsp and formatter are disabled because memory-sync's self-execution + // support for these features is not yet mature. The fields are omitted + // from the generated config; running `tnmsc clean` will delete any + // legacy opencode.json files that still contain them. return JSON.stringify( { $schema: 'https://opencode.ai/config.json', @@ -418,13 +447,19 @@ export class OpencodeCLIOutputPlugin extends AbstractOutputPlugin { const frontMatter: Record = {} const source = agent.yamlFrontMatter as Record | undefined - if (source?.['description'] != null) frontMatter['description'] = source['description'] + if (source?.['description'] != null) { + frontMatter['description'] = source['description'] + } frontMatter['mode'] = source?.['mode'] ?? 'subagent' if (source?.['model'] != null) frontMatter['model'] = source['model'] - if (source?.['temperature'] != null) frontMatter['temperature'] = source['temperature'] - if (source?.['maxSteps'] != null) frontMatter['maxSteps'] = source['maxSteps'] + if (source?.['temperature'] != null) { + frontMatter['temperature'] = source['temperature'] + } + if (source?.['maxSteps'] != null) { + frontMatter['maxSteps'] = source['maxSteps'] + } if (source?.['hidden'] != null) frontMatter['hidden'] = source['hidden'] if (source?.['allowTools'] != null && Array.isArray(source['allowTools'])) { @@ -433,7 +468,9 @@ export class OpencodeCLIOutputPlugin extends AbstractOutputPlugin { frontMatter['tools'] = tools } - if (source?.['permission'] != null && typeof source['permission'] === 'object') frontMatter['permission'] = source['permission'] + if (source?.['permission'] != null && typeof source['permission'] === 'object') { + frontMatter['permission'] = source['permission'] + } for (const [key, value] of Object.entries(source ?? {})) { if (!['description', 'mode', 'model', 'temperature', 'maxSteps', 'hidden', 'allowTools', 'permission', 'namingCase', 'name', 'color'].includes(key)) { @@ -449,7 +486,9 @@ export class OpencodeCLIOutputPlugin extends AbstractOutputPlugin { const source = skill.yamlFrontMatter as Record | undefined frontMatter['name'] = skillName - if (source?.['description'] != null) frontMatter['description'] = source['description'] + if (source?.['description'] != null) { + frontMatter['description'] = source['description'] + } frontMatter['license'] = source?.['license'] ?? 'MIT' frontMatter['compatibility'] = source?.['compatibility'] ?? 'opencode' diff --git a/sdk/src/plugins/plugin-core/plugin.ts b/sdk/src/plugins/plugin-core/plugin.ts index 6572f661..9bd97ec6 100644 --- a/sdk/src/plugins/plugin-core/plugin.ts +++ b/sdk/src/plugins/plugin-core/plugin.ts @@ -13,10 +13,12 @@ import type { } from './ConfigTypes.schema' import type {PluginKind} from './enums' import type {InputCollectedContext, OutputCollectedContext, Project} from './InputTypes' +import type {ExecutionPlan} from '@/execution-plan' import type {RuntimeCommand} from '@/runtime-command' import {Buffer} from 'node:buffer' import * as fs from 'node:fs' import * as path from 'node:path' +import {filterPathScopedEntriesForExecutionPlan} from '@/execution-plan' export type FastGlobType = typeof import('fast-glob') @@ -80,6 +82,7 @@ export interface OutputPluginContext { readonly collectedOutputContext: OutputCollectedContext readonly pluginOptions?: PluginOptions readonly runtimeTargets: OutputRuntimeTargets + readonly executionPlan: ExecutionPlan } /** @@ -394,7 +397,10 @@ export async function collectOutputDeclarations( ): Promise> { validateOutputScopeOverridesForPlugins(plugins, ctx.pluginOptions) - const declarationEntries = await Promise.all(plugins.map(async plugin => [plugin, await plugin.declareOutputFiles(ctx)] as const)) + const declarationEntries = await Promise.all(plugins.map(async plugin => { + const declarations = await plugin.declareOutputFiles(ctx) + return [plugin, filterPathScopedEntriesForExecutionPlan(declarations, ctx.executionPlan, ctx.collectedOutputContext)] as const + })) return new Map(declarationEntries) } diff --git a/sdk/src/plugins/plugin-core/types.ts b/sdk/src/plugins/plugin-core/types.ts index 3a766dfe..91d3965d 100644 --- a/sdk/src/plugins/plugin-core/types.ts +++ b/sdk/src/plugins/plugin-core/types.ts @@ -1,3 +1,4 @@ +export * from '../../execution-plan' export * from './AindexConfigDefaults' export * from './AindexTypes' export * from './ConfigTypes.schema' diff --git a/sdk/src/runtime/cleanup.execution-scope.test.ts b/sdk/src/runtime/cleanup.execution-scope.test.ts new file mode 100644 index 00000000..6c27eb89 --- /dev/null +++ b/sdk/src/runtime/cleanup.execution-scope.test.ts @@ -0,0 +1,172 @@ +import type {OutputCleanContext, OutputPlugin, Project} from '../plugins/plugin-core' +import * as path from 'node:path' +import {afterEach, describe, expect, it} from 'vitest' +import { + createEmptyExecutionPlanProjectsBySeries, + createLogger, + FilePathKind +} from '../plugins/plugin-core' +import {collectDeletionTargets} from './cleanup' + +function createProject(workspaceDir: string, name: string, series: Project['promptSeries']): Project { + return { + name, + promptSeries: series, + dirFromWorkspacePath: { + pathKind: FilePathKind.Relative, + path: name, + basePath: workspaceDir, + getDirectoryName: () => name, + getAbsolutePath: () => path.join(workspaceDir, name) + } + } +} + +afterEach(() => { + const testGlobals = globalThis as typeof globalThis & {__TNMSC_TEST_NATIVE_BINDING__?: object} + delete testGlobals.__TNMSC_TEST_NATIVE_BINDING__ +}) + +describe('cleanup execution scope filtering', () => { + it('filters outputs and cleanup targets down to the matched project plus global entries', async () => { + const workspaceDir = path.resolve('/tmp/tnmsc-cleanup-execution-scope') + const globalConfigPath = path.resolve('/tmp/tnmsc-cleanup-execution-scope-global/CODEX.md') + let capturedSnapshot: Record | undefined + + const testGlobals = globalThis as typeof globalThis & {__TNMSC_TEST_NATIVE_BINDING__?: object} + testGlobals.__TNMSC_TEST_NATIVE_BINDING__ = { + planCleanup(snapshotJson: string) { + capturedSnapshot = JSON.parse(snapshotJson) as Record + return JSON.stringify({ + filesToDelete: [], + dirsToDelete: [], + emptyDirsToDelete: [], + violations: [], + conflicts: [], + excludedScanGlobs: [] + }) + }, + performCleanup() { + throw new Error('performCleanup should not be called in this test') + } + } + + const plugin: OutputPlugin = { + name: 'ExecutionScopeCleanupPlugin', + type: 'output', + log: createLogger('ExecutionScopeCleanupPlugin', 'error'), + declarativeOutput: true, + outputCapabilities: {}, + async declareOutputFiles() { + return [ + {path: path.join(workspaceDir, 'WARP.md'), scope: 'project', source: {}}, + {path: path.join(workspaceDir, 'plugin-one', 'WARP.md'), scope: 'project', source: {}}, + {path: path.join(workspaceDir, 'app-one', 'AGENTS.md'), scope: 'project', source: {}}, + {path: globalConfigPath, scope: 'global', source: {}} + ] + }, + async convertContent() { + return '' + }, + async declareCleanupPaths() { + return { + delete: [ + {path: path.join(workspaceDir, 'WARP.md'), kind: 'file', scope: 'project'}, + {path: path.join(workspaceDir, 'plugin-one', 'WARP.md'), kind: 'file', scope: 'project'}, + {path: path.join(workspaceDir, 'app-one', 'AGENTS.md'), kind: 'file', scope: 'project'}, + {path: globalConfigPath, kind: 'file', scope: 'global'} + ], + protect: [ + {path: path.join(workspaceDir, 'plugin-one', 'docs'), kind: 'directory', scope: 'project'}, + {path: path.join(workspaceDir, 'app-one', 'docs'), kind: 'directory', scope: 'project'} + ] + } + } + } + + const executionPlan = { + scope: 'project' as const, + cwd: path.join(workspaceDir, 'plugin-one', 'nested'), + workspaceDir, + projectsBySeries: { + ...createEmptyExecutionPlanProjectsBySeries(), + ext: [{ + name: 'plugin-one', + rootDir: path.join(workspaceDir, 'plugin-one'), + series: 'ext' + }], + app: [{ + name: 'app-one', + rootDir: path.join(workspaceDir, 'app-one'), + series: 'app' + }] + }, + matchedProject: { + name: 'plugin-one', + rootDir: path.join(workspaceDir, 'plugin-one'), + series: 'ext' + } + } + + const cleanCtx: OutputCleanContext = { + logger: createLogger('cleanup.execution-scope.test', 'error'), + collectedOutputContext: { + workspace: { + directory: { + pathKind: FilePathKind.Absolute, + path: workspaceDir, + getDirectoryName: () => path.basename(workspaceDir) + }, + projects: [ + createProject(workspaceDir, 'app-one', 'app'), + createProject(workspaceDir, 'plugin-one', 'ext') + ] + } + }, + pluginOptions: { + version: '0.0.0', + workspaceDir, + logLevel: 'error', + aindex: { + dir: 'aindex', + skills: {src: 'skills', dist: 'dist/skills'}, + commands: {src: 'commands', dist: 'dist/commands'}, + subAgents: {src: 'subagents', dist: 'dist/subagents'}, + rules: {src: 'rules', dist: 'dist/rules'}, + globalPrompt: {src: 'global.src.mdx', dist: 'dist/global.mdx'}, + workspacePrompt: {src: 'workspace.src.mdx', dist: 'dist/workspace.mdx'}, + app: {src: 'app', dist: 'dist/app'}, + ext: {src: 'ext', dist: 'dist/ext'}, + arch: {src: 'arch', dist: 'dist/arch'}, + softwares: {src: 'softwares', dist: 'dist/softwares'} + }, + commandSeriesOptions: {}, + outputScopes: {}, + frontMatter: {blankLineAfter: true}, + cleanupProtection: {}, + windows: {}, + plugins: [] + }, + runtimeTargets: {jetbrainsCodexDirs: []}, + executionPlan, + dryRun: true + } + + await collectDeletionTargets([plugin], cleanCtx) + + const pluginSnapshot = (capturedSnapshot?.['pluginSnapshots'] as Record[] | undefined)?.[0] + expect(pluginSnapshot?.['outputs']).toEqual([ + path.join(workspaceDir, 'plugin-one', 'WARP.md'), + globalConfigPath + ]) + expect(pluginSnapshot?.['cleanup']).toEqual({ + delete: [ + {path: path.join(workspaceDir, 'plugin-one', 'WARP.md'), kind: 'file', scope: 'project'}, + {path: globalConfigPath, kind: 'file', scope: 'global'} + ], + protect: [ + {path: path.join(workspaceDir, 'plugin-one', 'docs'), kind: 'directory', scope: 'project'} + ] + }) + }) +}) diff --git a/sdk/src/runtime/cleanup.ts b/sdk/src/runtime/cleanup.ts index f430c635..5b4f00bb 100644 --- a/sdk/src/runtime/cleanup.ts +++ b/sdk/src/runtime/cleanup.ts @@ -6,9 +6,17 @@ import type { OutputFileDeclaration, OutputPlugin } from '../plugins/plugin-core' -import type {ProtectionMode, ProtectionRuleMatcher} from '../ProtectedDeletionGuard' +import type { + ProtectionMode, + ProtectionRuleMatcher +} from '../ProtectedDeletionGuard' import * as path from 'node:path' -import {buildDiagnostic, buildFileOperationDiagnostic, diagnosticLines} from '@/diagnostics' +import { + buildDiagnostic, + buildFileOperationDiagnostic, + diagnosticLines +} from '@/diagnostics' +import {filterPathScopedEntriesForExecutionPlan} from '@/execution-plan' import {loadAindexProjectConfig} from '../aindex-config/AindexProjectConfigLoader' import {getNativeBinding} from '../core/native-binding' import {collectAllPluginOutputs} from '../plugins/plugin-core' @@ -151,14 +159,17 @@ export function hasNativeCleanupBinding(): boolean { return nativeCleanupBindingCheck } const nativeBinding = getNativeBinding() - nativeCleanupBindingCheck = nativeBinding?.planCleanup != null && nativeBinding.performCleanup != null + nativeCleanupBindingCheck + = nativeBinding?.planCleanup != null && nativeBinding.performCleanup != null return nativeCleanupBindingCheck } function requireNativeCleanupBinding(): NativeCleanupBinding { const nativeBinding = getNativeBinding() if (nativeBinding == null) { - throw new Error('Native cleanup binding is required. Build or install the Rust NAPI package before running tnmsc.') + throw new Error( + 'Native cleanup binding is required. Build or install the Rust NAPI package before running tnmsc.' + ) } return nativeBinding } @@ -167,64 +178,124 @@ function mapProtectionMode(mode: ProtectionMode): NativeProtectionMode { return mode } -function mapProtectionRuleMatcher(matcher: ProtectionRuleMatcher | undefined): NativeProtectionRuleMatcher | undefined { +function mapProtectionRuleMatcher( + matcher: ProtectionRuleMatcher | undefined +): NativeProtectionRuleMatcher | undefined { return matcher } -function mapCleanupTarget(target: OutputCleanupPathDeclaration): NativeCleanupTarget { +function mapCleanupTarget( + target: OutputCleanupPathDeclaration +): NativeCleanupTarget { return { path: target.path, kind: target.kind, - ...target.excludeBasenames != null && target.excludeBasenames.length > 0 ? {excludeBasenames: [...target.excludeBasenames]} : {}, - ...target.protectionMode != null ? {protectionMode: mapProtectionMode(target.protectionMode)} : {}, + ...target.excludeBasenames != null && target.excludeBasenames.length > 0 + ? {excludeBasenames: [...target.excludeBasenames]} + : {}, + ...target.protectionMode != null + ? {protectionMode: mapProtectionMode(target.protectionMode)} + : {}, ...target.scope != null ? {scope: target.scope} : {}, ...target.label != null ? {label: target.label} : {} } } -async function collectPluginCleanupDeclarations(plugin: OutputPlugin, cleanCtx: OutputCleanContext): Promise { +async function collectPluginCleanupDeclarations( + plugin: OutputPlugin, + cleanCtx: OutputCleanContext +): Promise { if (plugin.declareCleanupPaths == null) return {} - return plugin.declareCleanupPaths({...cleanCtx, dryRun: true}) + const declarations = await plugin.declareCleanupPaths({ + ...cleanCtx, + dryRun: true + }) + return { + ...declarations.delete != null + ? { + delete: filterPathScopedEntriesForExecutionPlan( + declarations.delete, + cleanCtx.executionPlan, + cleanCtx.collectedOutputContext + ) + } + : {}, + ...declarations.protect != null + ? { + protect: filterPathScopedEntriesForExecutionPlan( + declarations.protect, + cleanCtx.executionPlan, + cleanCtx.collectedOutputContext + ) + } + : {}, + ...declarations.excludeScanGlobs != null + ? {excludeScanGlobs: declarations.excludeScanGlobs} + : {} + } } async function collectPluginCleanupSnapshot( plugin: OutputPlugin, cleanCtx: OutputCleanContext, - predeclaredOutputs?: ReadonlyMap + predeclaredOutputs?: ReadonlyMap< + OutputPlugin, + readonly OutputFileDeclaration[] + > ): Promise { const existingOutputDeclarations = predeclaredOutputs?.get(plugin) - const [outputs, cleanup] = await Promise.all([ - existingOutputDeclarations != null ? Promise.resolve(existingOutputDeclarations) : plugin.declareOutputFiles({...cleanCtx, dryRun: true}), - collectPluginCleanupDeclarations(plugin, cleanCtx) - ]) + const declaredOutputs + = existingOutputDeclarations ?? await plugin.declareOutputFiles({...cleanCtx, dryRun: true}) + const outputs = filterPathScopedEntriesForExecutionPlan( + declaredOutputs, + cleanCtx.executionPlan, + cleanCtx.collectedOutputContext + ) + const cleanup = await collectPluginCleanupDeclarations(plugin, cleanCtx) return { pluginName: plugin.name, outputs: outputs.map(output => output.path), cleanup: { - ...cleanup.delete != null && cleanup.delete.length > 0 ? {delete: cleanup.delete.map(mapCleanupTarget)} : {}, - ...cleanup.protect != null && cleanup.protect.length > 0 ? {protect: cleanup.protect.map(mapCleanupTarget)} : {}, - ...cleanup.excludeScanGlobs != null && cleanup.excludeScanGlobs.length > 0 ? {excludeScanGlobs: [...cleanup.excludeScanGlobs]} : {} + ...cleanup.delete != null && cleanup.delete.length > 0 + ? {delete: cleanup.delete.map(mapCleanupTarget)} + : {}, + ...cleanup.protect != null && cleanup.protect.length > 0 + ? {protect: cleanup.protect.map(mapCleanupTarget)} + : {}, + ...cleanup.excludeScanGlobs != null + && cleanup.excludeScanGlobs.length > 0 + ? {excludeScanGlobs: [...cleanup.excludeScanGlobs]} + : {} } } } -function collectConfiguredCleanupProtectionRules(cleanCtx: OutputCleanContext): NativeProtectedRule[] { - return (cleanCtx.pluginOptions?.cleanupProtection?.rules ?? []).map(rule => ({ - path: rule.path, - protectionMode: mapProtectionMode(rule.protectionMode), - reason: rule.reason ?? 'configured cleanup protection rule', - source: 'configured-cleanup-protection', - matcher: mapProtectionRuleMatcher(rule.matcher ?? 'path') - })) +function collectConfiguredCleanupProtectionRules( + cleanCtx: OutputCleanContext +): NativeProtectedRule[] { + return (cleanCtx.pluginOptions?.cleanupProtection?.rules ?? []).map( + rule => ({ + path: rule.path, + protectionMode: mapProtectionMode(rule.protectionMode), + reason: rule.reason ?? 'configured cleanup protection rule', + source: 'configured-cleanup-protection', + matcher: mapProtectionRuleMatcher(rule.matcher ?? 'path') + }) + ) } -function buildCleanupProtectionConflictMessage(conflicts: readonly NativeCleanupProtectionConflict[]): string { +function buildCleanupProtectionConflictMessage( + conflicts: readonly NativeCleanupProtectionConflict[] +): string { const pathList = conflicts.map(conflict => conflict.outputPath).join(', ') return `Cleanup protection conflict: ${conflicts.length} output path(s) are also protected: ${pathList}` } -function logCleanupProtectionConflicts(logger: ILogger, conflicts: readonly NativeCleanupProtectionConflict[]): void { +function logCleanupProtectionConflicts( + logger: ILogger, + conflicts: readonly NativeCleanupProtectionConflict[] +): void { const firstConflict = conflicts[0] logger.error( @@ -237,10 +308,16 @@ function logCleanupProtectionConflicts(logger: ILogger, conflicts: readonly Nati ? 'No conflict details were captured.' : `Example conflict: "${firstConflict.outputPath}" is protected by "${firstConflict.protectedPath}".` ), - exactFix: diagnosticLines('Separate generated output paths from protected source or reserved workspace paths before running cleanup again.'), + exactFix: diagnosticLines( + 'Separate generated output paths from protected source or reserved workspace paths before running cleanup again.' + ), possibleFixes: [ - diagnosticLines('Update cleanup protect declarations so they do not overlap generated outputs.'), - diagnosticLines('Move the conflicting output target to a generated-only directory.') + diagnosticLines( + 'Update cleanup protect declarations so they do not overlap generated outputs.' + ), + diagnosticLines( + 'Move the conflicting output target to a generated-only directory.' + ) ], details: { count: conflicts.length, @@ -254,7 +331,12 @@ function logCleanupPlanDiagnostics( logger: ILogger, plan: Pick< NativeCleanupPlan | NativeCleanupResult, - 'filesToDelete' | 'dirsToDelete' | 'emptyDirsToDelete' | 'violations' | 'conflicts' | 'excludedScanGlobs' + | 'filesToDelete' + | 'dirsToDelete' + | 'emptyDirsToDelete' + | 'violations' + | 'conflicts' + | 'excludedScanGlobs' > ): void { logger.debug('cleanup plan built', { @@ -267,7 +349,9 @@ function logCleanupPlanDiagnostics( }) } -function collectExactSafeFilePaths(snapshot: NativeCleanupSnapshot): Set { +function collectExactSafeFilePaths( + snapshot: NativeCleanupSnapshot +): Set { const exactSafeFilePaths = new Set() for (const pluginSnapshot of snapshot.pluginSnapshots) { @@ -284,16 +368,18 @@ function collectExactSafeFilePaths(snapshot: NativeCleanupSnapshot): Set return exactSafeFilePaths } -function reconcileExactSafeFileViolations( - result: T, - exactSafeFilePaths: ReadonlySet -): T { - if (exactSafeFilePaths.size === 0 || result.violations.length === 0) return result - - const rescuedFiles = new Set(result.filesToDelete.map(filePath => path.resolve(filePath))) +function reconcileExactSafeFileViolations< + T extends { + filesToDelete: string[] + violations: readonly NativeProtectedPathViolation[] + } +>(result: T, exactSafeFilePaths: ReadonlySet): T { + if (exactSafeFilePaths.size === 0 || result.violations.length === 0) + { return result } + + const rescuedFiles = new Set( + result.filesToDelete.map(filePath => path.resolve(filePath)) + ) const remainingViolations: NativeProtectedPathViolation[] = [] for (const violation of result.violations) { @@ -325,10 +411,22 @@ function summarizeCleanupSnapshot(snapshot: NativeCleanupSnapshot): { } { return { pluginCount: snapshot.pluginSnapshots.length, - outputCount: snapshot.pluginSnapshots.reduce((total, plugin) => total + plugin.outputs.length, 0), - cleanupDeleteCount: snapshot.pluginSnapshots.reduce((total, plugin) => total + (plugin.cleanup.delete?.length ?? 0), 0), - cleanupProtectCount: snapshot.pluginSnapshots.reduce((total, plugin) => total + (plugin.cleanup.protect?.length ?? 0), 0), - cleanupExcludeScanGlobs: snapshot.pluginSnapshots.reduce((total, plugin) => total + (plugin.cleanup.excludeScanGlobs?.length ?? 0), 0), + outputCount: snapshot.pluginSnapshots.reduce( + (total, plugin) => total + plugin.outputs.length, + 0 + ), + cleanupDeleteCount: snapshot.pluginSnapshots.reduce( + (total, plugin) => total + (plugin.cleanup.delete?.length ?? 0), + 0 + ), + cleanupProtectCount: snapshot.pluginSnapshots.reduce( + (total, plugin) => total + (plugin.cleanup.protect?.length ?? 0), + 0 + ), + cleanupExcludeScanGlobs: snapshot.pluginSnapshots.reduce( + (total, plugin) => total + (plugin.cleanup.excludeScanGlobs?.length ?? 0), + 0 + ), protectedRuleCount: snapshot.protectedRules.length, projectRootCount: snapshot.projectRoots.length, emptyDirExcludeGlobs: snapshot.emptyDirExcludeGlobs?.length ?? 0 @@ -343,8 +441,14 @@ function logNativeCleanupErrors( const type = currentError.kind === 'directory' ? 'directory' : 'file' logger.warn( buildFileOperationDiagnostic({ - code: type === 'file' ? 'CLEANUP_FILE_DELETE_FAILED' : 'CLEANUP_DIRECTORY_DELETE_FAILED', - title: type === 'file' ? 'Cleanup could not delete a file' : 'Cleanup could not delete a directory', + code: + type === 'file' + ? 'CLEANUP_FILE_DELETE_FAILED' + : 'CLEANUP_DIRECTORY_DELETE_FAILED', + title: + type === 'file' + ? 'Cleanup could not delete a file' + : 'Cleanup could not delete a directory', operation: 'delete', targetKind: type, path: currentError.path, @@ -362,9 +466,15 @@ function logNativeCleanupErrors( async function buildCleanupSnapshot( outputPlugins: readonly OutputPlugin[], cleanCtx: OutputCleanContext, - predeclaredOutputs?: ReadonlyMap + predeclaredOutputs?: ReadonlyMap< + OutputPlugin, + readonly OutputFileDeclaration[] + > ): Promise { - const pluginSnapshots = await Promise.all(outputPlugins.map(async plugin => collectPluginCleanupSnapshot(plugin, cleanCtx, predeclaredOutputs))) + const pluginSnapshots = await Promise.all( + outputPlugins.map(async plugin => + collectPluginCleanupSnapshot(plugin, cleanCtx, predeclaredOutputs)) + ) // Collect all delete targets from plugin snapshots - these should bypass protection rules const deleteTargetPaths = new Set() @@ -377,7 +487,9 @@ async function buildCleanupSnapshot( } const protectedRules: NativeProtectedRule[] = [] - for (const rule of collectProtectedInputSourceRules(cleanCtx.collectedOutputContext)) { + for (const rule of collectProtectedInputSourceRules( + cleanCtx.collectedOutputContext + )) { // Skip protection rules for paths that are explicitly marked as delete targets if (deleteTargetPaths.has(path.resolve(rule.path))) continue protectedRules.push({ @@ -385,7 +497,9 @@ async function buildCleanupSnapshot( protectionMode: mapProtectionMode(rule.protectionMode), reason: rule.reason, source: rule.source, - ...rule.matcher != null ? {matcher: mapProtectionRuleMatcher(rule.matcher)} : {} + ...rule.matcher != null + ? {matcher: mapProtectionRuleMatcher(rule.matcher)} + : {} }) } @@ -393,7 +507,9 @@ async function buildCleanupSnapshot( let emptyDirExcludeGlobs: string[] | undefined if (cleanCtx.collectedOutputContext.aindexDir != null) { - const aindexConfig = await loadAindexProjectConfig(cleanCtx.collectedOutputContext.aindexDir) + const aindexConfig = await loadAindexProjectConfig( + cleanCtx.collectedOutputContext.aindexDir + ) if (aindexConfig.found) { const exclude = aindexConfig.config.emptyDirCleanup?.exclude if (exclude != null && exclude.length > 0) { @@ -404,11 +520,15 @@ async function buildCleanupSnapshot( return { workspaceDir: cleanCtx.collectedOutputContext.workspace.directory.path, - ...cleanCtx.collectedOutputContext.aindexDir != null ? {aindexDir: cleanCtx.collectedOutputContext.aindexDir} : {}, + ...cleanCtx.collectedOutputContext.aindexDir != null + ? {aindexDir: cleanCtx.collectedOutputContext.aindexDir} + : {}, projectRoots: collectProjectRoots(cleanCtx.collectedOutputContext), protectedRules, pluginSnapshots, - ...emptyDirExcludeGlobs != null && emptyDirExcludeGlobs.length > 0 ? {emptyDirExcludeGlobs} : {} + ...emptyDirExcludeGlobs != null && emptyDirExcludeGlobs.length > 0 + ? {emptyDirExcludeGlobs} + : {} } } @@ -416,24 +536,37 @@ function parseNativeJson(json: string): T { return JSON.parse(json) as T } -export async function planCleanupWithNative(snapshot: NativeCleanupSnapshot): Promise { +export async function planCleanupWithNative( + snapshot: NativeCleanupSnapshot +): Promise { const nativeBinding = requireNativeCleanupBinding() - if (nativeBinding?.planCleanup == null) throw new Error('Native cleanup planning is unavailable') - const result = await Promise.resolve(nativeBinding.planCleanup(JSON.stringify(snapshot))) + if (nativeBinding?.planCleanup == null) + { throw new Error('Native cleanup planning is unavailable') } + const result = await Promise.resolve( + nativeBinding.planCleanup(JSON.stringify(snapshot)) + ) return parseNativeJson(result) } -export async function performCleanupWithNative(snapshot: NativeCleanupSnapshot): Promise { +export async function performCleanupWithNative( + snapshot: NativeCleanupSnapshot +): Promise { const nativeBinding = requireNativeCleanupBinding() - if (nativeBinding?.performCleanup == null) throw new Error('Native cleanup execution is unavailable') - const result = await Promise.resolve(nativeBinding.performCleanup(JSON.stringify(snapshot))) + if (nativeBinding?.performCleanup == null) + { throw new Error('Native cleanup execution is unavailable') } + const result = await Promise.resolve( + nativeBinding.performCleanup(JSON.stringify(snapshot)) + ) return parseNativeJson(result) } export async function collectDeletionTargets( outputPlugins: readonly OutputPlugin[], cleanCtx: OutputCleanContext, - predeclaredOutputs?: ReadonlyMap + predeclaredOutputs?: ReadonlyMap< + OutputPlugin, + readonly OutputFileDeclaration[] + > ): Promise<{ filesToDelete: string[] dirsToDelete: string[] @@ -448,7 +581,11 @@ export async function collectDeletionTargets( pluginCount: outputPlugins.length, workspaceDir: cleanCtx.collectedOutputContext.workspace.directory.path }) - const snapshot = await buildCleanupSnapshot(outputPlugins, cleanCtx, predeclaredOutputs) + const snapshot = await buildCleanupSnapshot( + outputPlugins, + cleanCtx, + predeclaredOutputs + ) cleanCtx.logger.info('cleanup snapshot prepared', { phase: 'cleanup-plan', ...summarizeCleanupSnapshot(snapshot) @@ -473,7 +610,8 @@ export async function collectDeletionTargets( return { filesToDelete: plan.filesToDelete, dirsToDelete: plan.dirsToDelete.sort((a, b) => a.localeCompare(b)), - emptyDirsToDelete: plan.emptyDirsToDelete.sort((a, b) => a.localeCompare(b)), + emptyDirsToDelete: plan.emptyDirsToDelete.sort((a, b) => + a.localeCompare(b)), violations: [...plan.violations], conflicts: [], excludedScanGlobs: plan.excludedScanGlobs @@ -484,7 +622,10 @@ export async function performCleanup( outputPlugins: readonly OutputPlugin[], cleanCtx: OutputCleanContext, logger: ILogger, - predeclaredOutputs?: ReadonlyMap + predeclaredOutputs?: ReadonlyMap< + OutputPlugin, + readonly OutputFileDeclaration[] + > ): Promise { logger.info('cleanup execution started', { phase: 'cleanup-execute', @@ -493,7 +634,11 @@ export async function performCleanup( workspaceDir: cleanCtx.collectedOutputContext.workspace.directory.path }) if (predeclaredOutputs != null) { - const outputs = await collectAllPluginOutputs(outputPlugins, cleanCtx, predeclaredOutputs) + const outputs = await collectAllPluginOutputs( + outputPlugins, + cleanCtx, + predeclaredOutputs + ) logger.info('cleanup outputs collected', { phase: 'cleanup-execute', projectDirs: outputs.projectDirs.length, @@ -503,7 +648,11 @@ export async function performCleanup( }) } - const snapshot = await buildCleanupSnapshot(outputPlugins, cleanCtx, predeclaredOutputs) + const snapshot = await buildCleanupSnapshot( + outputPlugins, + cleanCtx, + predeclaredOutputs + ) logger.info('cleanup snapshot prepared', { phase: 'cleanup-execute', ...summarizeCleanupSnapshot(snapshot) @@ -511,7 +660,10 @@ export async function performCleanup( logger.info('cleanup native execution started', { phase: 'cleanup-execute', pluginCount: snapshot.pluginSnapshots.length, - outputCount: snapshot.pluginSnapshots.reduce((total, plugin) => total + plugin.outputs.length, 0) + outputCount: snapshot.pluginSnapshots.reduce( + (total, plugin) => total + plugin.outputs.length, + 0 + ) }) const result = reconcileExactSafeFileViolations( await performCleanupWithNative(snapshot), From 7ee0c8e6def27892639db37a0af643d2939e43e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E6=97=A5=E5=A4=A9?= Date: Fri, 3 Apr 2026 17:46:51 +0800 Subject: [PATCH 4/7] Remove init remnants and restore GUI bridge JSON flow --- Cargo.lock | 12 ++++++------ Cargo.toml | 2 +- cli/npm/darwin-arm64/package.json | 2 +- cli/npm/darwin-x64/package.json | 2 +- cli/npm/linux-arm64-gnu/package.json | 2 +- cli/npm/linux-x64-gnu/package.json | 2 +- cli/npm/win32-x64-msvc/package.json | 2 +- cli/package.json | 2 +- doc/package.json | 2 +- gui/package.json | 2 +- gui/src-tauri/Cargo.toml | 2 +- gui/src-tauri/tauri.conf.json | 2 +- libraries/logger/package.json | 2 +- libraries/md-compiler/package.json | 2 +- libraries/script-runtime/package.json | 2 +- mcp/package.json | 2 +- package.json | 2 +- sdk/package.json | 2 +- 18 files changed, 23 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fddad2da..de6f340d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2147,7 +2147,7 @@ dependencies = [ [[package]] name = "memory-sync-gui" -version = "2026.10402.116" +version = "2026.10403.117" dependencies = [ "dirs", "proptest", @@ -4436,7 +4436,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tnmsc" -version = "2026.10402.116" +version = "2026.10403.117" dependencies = [ "clap", "dirs", @@ -4458,7 +4458,7 @@ dependencies = [ [[package]] name = "tnmsc-cli-shell" -version = "2026.10402.116" +version = "2026.10403.117" dependencies = [ "clap", "serde_json", @@ -4468,7 +4468,7 @@ dependencies = [ [[package]] name = "tnmsc-logger" -version = "2026.10402.116" +version = "2026.10403.117" dependencies = [ "napi", "napi-build", @@ -4479,7 +4479,7 @@ dependencies = [ [[package]] name = "tnmsc-md-compiler" -version = "2026.10402.116" +version = "2026.10403.117" dependencies = [ "markdown", "napi", @@ -4494,7 +4494,7 @@ dependencies = [ [[package]] name = "tnmsc-script-runtime" -version = "2026.10402.116" +version = "2026.10403.117" dependencies = [ "napi", "napi-build", diff --git a/Cargo.toml b/Cargo.toml index 76a4d744..bd031d59 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ members = [ ] [workspace.package] -version = "2026.10402.116" +version = "2026.10403.117" edition = "2024" rust-version = "1.88" license = "AGPL-3.0-only" diff --git a/cli/npm/darwin-arm64/package.json b/cli/npm/darwin-arm64/package.json index 847b0167..0e1d2569 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.10402.116", + "version": "2026.10403.117", "os": [ "darwin" ], diff --git a/cli/npm/darwin-x64/package.json b/cli/npm/darwin-x64/package.json index cc8a9953..95287001 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.10402.116", + "version": "2026.10403.117", "os": [ "darwin" ], diff --git a/cli/npm/linux-arm64-gnu/package.json b/cli/npm/linux-arm64-gnu/package.json index 19469889..7fa95b27 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.10402.116", + "version": "2026.10403.117", "os": [ "linux" ], diff --git a/cli/npm/linux-x64-gnu/package.json b/cli/npm/linux-x64-gnu/package.json index 71514b3c..d8d83a4a 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.10402.116", + "version": "2026.10403.117", "os": [ "linux" ], diff --git a/cli/npm/win32-x64-msvc/package.json b/cli/npm/win32-x64-msvc/package.json index 85c818fa..ee4def0d 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.10402.116", + "version": "2026.10403.117", "os": [ "win32" ], diff --git a/cli/package.json b/cli/package.json index c903bdf1..6c501f2b 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/memory-sync-cli", "type": "module", - "version": "2026.10402.116", + "version": "2026.10403.117", "description": "TrueNine Memory Synchronization CLI shell", "author": "TrueNine", "license": "AGPL-3.0-only", diff --git a/doc/package.json b/doc/package.json index 3fca9828..7cec0e16 100644 --- a/doc/package.json +++ b/doc/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-docs", - "version": "2026.10402.116", + "version": "2026.10403.117", "private": true, "description": "Chinese-first manifesto-led documentation site for @truenine/memory-sync.", "engines": { diff --git a/gui/package.json b/gui/package.json index d7f1fdfc..cb3f411d 100644 --- a/gui/package.json +++ b/gui/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-gui", - "version": "2026.10402.116", + "version": "2026.10403.117", "private": true, "engines": { "node": ">=25.2.1", diff --git a/gui/src-tauri/Cargo.toml b/gui/src-tauri/Cargo.toml index f687fe05..5cc2e7ae 100644 --- a/gui/src-tauri/Cargo.toml +++ b/gui/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "memory-sync-gui" -version = "2026.10402.116" +version = "2026.10403.117" 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 b0f1ba80..8fa7cd94 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.10402.116", + "version": "2026.10403.117", "productName": "Memory Sync", "identifier": "org.truenine.memory-sync", "build": { diff --git a/libraries/logger/package.json b/libraries/logger/package.json index 6deaf53b..a3997ffd 100644 --- a/libraries/logger/package.json +++ b/libraries/logger/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/logger", "type": "module", - "version": "2026.10402.116", + "version": "2026.10403.117", "private": true, "description": "Rust-powered AI-friendly Markdown logger for Node.js via N-API", "license": "AGPL-3.0-only", diff --git a/libraries/md-compiler/package.json b/libraries/md-compiler/package.json index 4d1f4956..ce486f35 100644 --- a/libraries/md-compiler/package.json +++ b/libraries/md-compiler/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/md-compiler", "type": "module", - "version": "2026.10402.116", + "version": "2026.10403.117", "private": true, "description": "Rust-powered MDX→Markdown compiler for Node.js with pure-TS fallback", "license": "AGPL-3.0-only", diff --git a/libraries/script-runtime/package.json b/libraries/script-runtime/package.json index ec3f49b1..d1666ce8 100644 --- a/libraries/script-runtime/package.json +++ b/libraries/script-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/script-runtime", "type": "module", - "version": "2026.10402.116", + "version": "2026.10403.117", "private": true, "description": "Rust-backed TypeScript proxy runtime for tnmsc", "license": "AGPL-3.0-only", diff --git a/mcp/package.json b/mcp/package.json index da03c1d1..81f0c747 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/memory-sync-mcp", "type": "module", - "version": "2026.10402.116", + "version": "2026.10403.117", "description": "MCP stdio server for managing memory-sync prompt sources and translation artifacts", "author": "TrueNine", "license": "AGPL-3.0-only", diff --git a/package.json b/package.json index 8ac3e906..22f99253 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync", - "version": "2026.10402.116", + "version": "2026.10403.117", "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/package.json b/sdk/package.json index f68e5bae..32209341 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/memory-sync-sdk", "type": "module", - "version": "2026.10402.116", + "version": "2026.10403.117", "private": true, "description": "TrueNine Memory Synchronization SDK", "author": "TrueNine", From 16ea57e6f9b4b89fcfaeb9fb5212cdbbfa087717 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E6=97=A5=E5=A4=A9?= Date: Fri, 3 Apr 2026 17:57:56 +0800 Subject: [PATCH 5/7] Disable Turbo cache for doc checks and clean up doc formatting --- doc/components/home-contributors.tsx | 54 ++++++++++------------------ doc/scripts/run-pagefind.ts | 31 ++++++++-------- doc/tsconfig.json | 33 ++++------------- turbo.json | 9 ++--- 4 files changed, 43 insertions(+), 84 deletions(-) diff --git a/doc/components/home-contributors.tsx b/doc/components/home-contributors.tsx index d83072b6..0cbe05be 100644 --- a/doc/components/home-contributors.tsx +++ b/doc/components/home-contributors.tsx @@ -74,53 +74,44 @@ function getGitHubHeaders() { async function fetchContributorsPage(page: number): Promise { const {owner, repo} = getRepoCoordinates(siteConfig.repoUrl) - const response = await fetch( - `https://api.github.com/repos/${owner}/${repo}/contributors?per_page=${CONTRIBUTORS_PER_PAGE}&page=${page}`, - { - headers: getGitHubHeaders(), - next: {revalidate: CONTRIBUTORS_REVALIDATE_SECONDS} - } - ) + const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/contributors?per_page=${CONTRIBUTORS_PER_PAGE}&page=${page}`, { + headers: getGitHubHeaders(), + next: {revalidate: CONTRIBUTORS_REVALIDATE_SECONDS} + }) if (!response.ok) { throw new Error(`Failed to fetch GitHub contributors: ${response.status}`) } - return await response.json() as GitHubContributor[] + return (await response.json()) as GitHubContributor[] } async function searchGitHubUser(query: string): Promise { - const response = await fetch( - `https://api.github.com/search/users?q=${encodeURIComponent(query)}`, - { - headers: getGitHubHeaders(), - next: {revalidate: CONTRIBUTORS_REVALIDATE_SECONDS} - } - ) + const response = await fetch(`https://api.github.com/search/users?q=${encodeURIComponent(query)}`, { + headers: getGitHubHeaders(), + next: {revalidate: CONTRIBUTORS_REVALIDATE_SECONDS} + }) if (!response.ok) { return null } - const payload = await response.json() as {items?: ResolvedGitHubUser[]} + const payload = (await response.json()) as {items?: ResolvedGitHubUser[]} return payload.items?.[0] ?? null } async function fetchGitHubUser(login: string): Promise { - const response = await fetch( - `https://api.github.com/users/${encodeURIComponent(login)}`, - { - headers: getGitHubHeaders(), - next: {revalidate: CONTRIBUTORS_REVALIDATE_SECONDS} - } - ) + const response = await fetch(`https://api.github.com/users/${encodeURIComponent(login)}`, { + headers: getGitHubHeaders(), + next: {revalidate: CONTRIBUTORS_REVALIDATE_SECONDS} + }) if (!response.ok) { return null } - return await response.json() as ResolvedGitHubUser + return (await response.json()) as ResolvedGitHubUser } function parseCoAuthors(message: string) { @@ -135,9 +126,7 @@ function parseCoAuthors(message: string) { const footer = line.slice(CO_AUTHOR_PREFIX.length).trim() const openAngleBracketIndex = footer.lastIndexOf('<') - const closeAngleBracketIndex = footer.endsWith('>') - ? footer.length - 1 - : -1 + const closeAngleBracketIndex = footer.endsWith('>') ? footer.length - 1 : -1 if (openAngleBracketIndex <= 0 || closeAngleBracketIndex <= openAngleBracketIndex) { continue @@ -404,8 +393,7 @@ async function getContributorCards() { } return contributor - }) - .sort((left, right) => right.sortWeight - left.sortWeight) + }).sort((left, right) => right.sortWeight - left.sortWeight) } function groupContributorCards(contributors: ContributorCard[]) { @@ -475,6 +463,7 @@ export async function HomeContributors() { className="home-contributor" title={`${contributor.label} · ${contributor.subtitle}`} > + {/* eslint-disable-next-line next/no-img-element */} {`${contributor.label} ))} - + View Contributors on GitHub diff --git a/doc/scripts/run-pagefind.ts b/doc/scripts/run-pagefind.ts index 225a3767..bd181881 100644 --- a/doc/scripts/run-pagefind.ts +++ b/doc/scripts/run-pagefind.ts @@ -1,28 +1,25 @@ +import type {Buffer} from 'node:buffer' import {spawn} from 'node:child_process' +import process from 'node:process' import {fileURLToPath} from 'node:url' -const pagefindBin = fileURLToPath( - new URL('../node_modules/pagefind/lib/runner/bin.cjs', import.meta.url) -) -const pagefindArgs = [ - pagefindBin, - '--site', - '.next/server/app', - '--output-path', - 'public/_pagefind' -] as const +const pagefindBin = fileURLToPath(new URL('../node_modules/pagefind/lib/runner/bin.cjs', import.meta.url)) +const pagefindArgs = [pagefindBin, '--site', '.next/server/app', '--output-path', 'public/_pagefind'] as const const STEMMING_WARNING_LINES = new Set([ - "Note: Pagefind doesn't support stemming for the language zh-cn.", + 'Note: Pagefind doesn\'t support stemming for the language zh-cn.', 'Search will still work, but will not match across root words.' ]) +const LINE_BREAK_REGEX = /\r?\n/u +const TRAILING_NEWLINES_REGEX = /\n+$/u + function filterKnownNoise(output: string): string { return output - .split(/\r?\n/u) + .split(LINE_BREAK_REGEX) .filter(line => !STEMMING_WARNING_LINES.has(line.trim())) .join('\n') - .replace(/\n+$/u, '\n') + .replace(TRAILING_NEWLINES_REGEX, '\n') } const child = spawn(process.execPath, pagefindArgs, { @@ -34,15 +31,15 @@ const child = spawn(process.execPath, pagefindArgs, { let stdout = '' let stderr = '' -child.stdout.on('data', chunk => { +child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() }) -child.stderr.on('data', chunk => { +child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() }) -child.on('close', code => { +child.on('close', (code: number | null) => { const filteredStdout = filterKnownNoise(stdout) const filteredStderr = filterKnownNoise(stderr) @@ -57,7 +54,7 @@ child.on('close', code => { process.exit(code ?? 1) }) -child.on('error', error => { +child.on('error', (error: Error) => { process.stderr.write(`${error.message}\n`) process.exit(1) }) diff --git a/doc/tsconfig.json b/doc/tsconfig.json index 633c609f..639a06c9 100644 --- a/doc/tsconfig.json +++ b/doc/tsconfig.json @@ -3,23 +3,15 @@ "incremental": true, "target": "ES2022", "jsx": "react-jsx", + "lib": ["DOM", "DOM.Iterable", "ES2022"], "baseUrl": ".", - "paths": { - "@/*": [ - "./*" - ] - }, - "lib": [ - "DOM", - "DOM.Iterable", - "ES2022" - ], "module": "ESNext", "moduleResolution": "Bundler", + "paths": { + "@/*": ["./*"] + }, "resolveJsonModule": true, - "types": [ - "node" - ], + "types": ["node"], "allowJs": false, "strict": true, "noEmit": true, @@ -33,17 +25,6 @@ } ] }, - "include": [ - "next-env.d.ts", - "**/*.d.ts", - "**/*.ts", - "**/*.tsx", - "**/*.mdx", - "**/_meta.ts", - ".next/dev/types/**/*.ts", - ".next/types/**/*.ts" - ], - "exclude": [ - "node_modules" - ] + "include": ["next-env.d.ts", "**/*.d.ts", "**/*.ts", "**/*.tsx", "**/*.mdx", "**/_meta.ts", ".next/dev/types/**/*.ts", ".next/types/**/*.ts"], + "exclude": ["node_modules"] } diff --git a/turbo.json b/turbo.json index fd165d95..a944ba83 100644 --- a/turbo.json +++ b/turbo.json @@ -11,17 +11,14 @@ }, "lint": { "dependsOn": ["^build"], - "outputs": [".eslintcache"], - "cache": true + "cache": false }, "lint:fix": { - "outputs": [".eslintcache"], - "cache": true + "cache": false }, "check:type": { "dependsOn": ["^build"], - "outputs": [], - "cache": true + "cache": false } } } From 56fc58f2bd2d3b6ecbcb7b881da35bf7cc098b47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E6=97=A5=E5=A4=A9?= Date: Fri, 3 Apr 2026 18:02:38 +0800 Subject: [PATCH 6/7] Consolidate logger type imports in native binding tests --- sdk/test/native-binding/cleanup.ts | 2 +- sdk/test/setup-native-binding.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/sdk/test/native-binding/cleanup.ts b/sdk/test/native-binding/cleanup.ts index dd00fdf1..93926c58 100644 --- a/sdk/test/native-binding/cleanup.ts +++ b/sdk/test/native-binding/cleanup.ts @@ -1,5 +1,5 @@ -import type {ILogger} from '@truenine/logger' import type { + ILogger, OutputCleanContext, OutputCleanupDeclarations, OutputCleanupPathDeclaration, diff --git a/sdk/test/setup-native-binding.ts b/sdk/test/setup-native-binding.ts index 507e43a8..297cd3f2 100644 --- a/sdk/test/setup-native-binding.ts +++ b/sdk/test/setup-native-binding.ts @@ -1,5 +1,9 @@ -import type {ILogger} from '@truenine/logger' -import type {OutputCleanContext, OutputCleanupDeclarations, OutputPlugin} from '../src/plugins/plugin-core' +import type { + ILogger, + OutputCleanContext, + OutputCleanupDeclarations, + OutputPlugin +} from '../src/plugins/plugin-core' import * as fs from 'node:fs' import * as path from 'node:path' import glob from 'fast-glob' From fa16bea1bc366c31d615cbeb556b4812c465c1a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E6=97=A5=E5=A4=A9?= Date: Fri, 3 Apr 2026 20:08:22 +0800 Subject: [PATCH 7/7] Fix sdk ESLint project resolution for workspace types --- sdk/eslint.config.ts | 4 +++- sdk/tsconfig.eslint.json | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/sdk/eslint.config.ts b/sdk/eslint.config.ts index bdf13167..da4d6ee6 100644 --- a/sdk/eslint.config.ts +++ b/sdk/eslint.config.ts @@ -11,7 +11,9 @@ const config = await eslint10({ strictTypescriptEslint: true, tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), parserOptions: { - allowDefaultProject: ['*.config.ts', 'test/**/*.ts'] + project: [resolve(configDir, 'tsconfig.eslint.json')], + projectService: false, + tsconfigRootDir: configDir } }, ignores: [ diff --git a/sdk/tsconfig.eslint.json b/sdk/tsconfig.eslint.json index 62f8268e..a1ff95ae 100644 --- a/sdk/tsconfig.eslint.json +++ b/sdk/tsconfig.eslint.json @@ -2,6 +2,42 @@ "$schema": "https://json.schemastore.org/tsconfig", "extends": "./tsconfig.json", "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"], + "@truenine/desk-paths": ["./src/core/desk-paths.ts"], + "@truenine/desk-paths/*": ["./src/core/desk-paths/*"], + "@truenine/logger": ["../libraries/logger/src/index.ts"], + "@truenine/md-compiler": ["../libraries/md-compiler/src/index.ts"], + "@truenine/md-compiler/errors": ["../libraries/md-compiler/src/errors/index.ts"], + "@truenine/md-compiler/globals": ["../libraries/md-compiler/src/globals/index.ts"], + "@truenine/md-compiler/markdown": ["../libraries/md-compiler/src/markdown/index.ts"], + "@truenine/plugin-output-shared": ["./src/plugins/plugin-output-shared/index.ts"], + "@truenine/plugin-output-shared/*": ["./src/plugins/plugin-output-shared/*"], + "@truenine/plugin-input-shared": ["./src/plugins/plugin-input-shared/index.ts"], + "@truenine/plugin-input-shared/*": ["./src/plugins/plugin-input-shared/*"], + "@truenine/plugin-agentskills-compact": ["./src/plugins/plugin-agentskills-compact.ts"], + "@truenine/plugin-agentsmd": ["./src/plugins/plugin-agentsmd.ts"], + "@truenine/plugin-antigravity": ["./src/plugins/plugin-antigravity/index.ts"], + "@truenine/plugin-claude-code-cli": ["./src/plugins/plugin-claude-code-cli.ts"], + "@truenine/plugin-cursor": ["./src/plugins/plugin-cursor.ts"], + "@truenine/plugin-droid-cli": ["./src/plugins/plugin-droid-cli.ts"], + "@truenine/plugin-editorconfig": ["./src/plugins/plugin-editorconfig.ts"], + "@truenine/plugin-gemini-cli": ["./src/plugins/plugin-gemini-cli.ts"], + "@truenine/plugin-git-exclude": ["./src/plugins/plugin-git-exclude.ts"], + "@truenine/plugin-jetbrains-ai-codex": ["./src/plugins/plugin-jetbrains-ai-codex.ts"], + "@truenine/plugin-jetbrains-codestyle": ["./src/plugins/plugin-jetbrains-codestyle.ts"], + "@truenine/plugin-openai-codex-cli": ["./src/plugins/plugin-openai-codex-cli.ts"], + "@truenine/plugin-opencode-cli": ["./src/plugins/plugin-opencode-cli.ts"], + "@truenine/plugin-qoder-ide": ["./src/plugins/plugin-qoder-ide.ts"], + "@truenine/plugin-readme": ["./src/plugins/plugin-readme.ts"], + "@truenine/plugin-trae-ide": ["./src/plugins/plugin-trae-ide.ts"], + "@truenine/plugin-vscode": ["./src/plugins/plugin-vscode.ts"], + "@truenine/plugin-warp-ide": ["./src/plugins/plugin-warp-ide.ts"], + "@truenine/plugin-windsurf": ["./src/plugins/plugin-windsurf.ts"], + "@truenine/plugin-zed": ["./src/plugins/plugin-zed.ts"], + "@truenine/script-runtime": ["../libraries/script-runtime/src/index.ts"] + }, "noEmit": true, "skipLibCheck": true },