mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 10:16:49 +00:00
refactor(web-server): modularize shared-routes and Windows-aware symlink status
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Shared Routes — Collection item list builders
|
||||
*
|
||||
* Builds SharedItem arrays for commands, skills, and agents with
|
||||
* a short-lived in-memory cache keyed by collection type.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { getCcsDir } from '../config/config-loader-facade';
|
||||
import { safeRealPath, isPathWithinAny, resolveAllowedRoots } from './shared-routes-path-guards';
|
||||
import {
|
||||
readMarkdownDescription,
|
||||
readFirstMarkdownDescription,
|
||||
collectMarkdownFiles,
|
||||
} from './shared-routes-markdown';
|
||||
import { getPluginItems } from './shared-routes-plugins';
|
||||
import type {
|
||||
SharedItem,
|
||||
SharedItemsCacheEntry,
|
||||
SharedCollectionType,
|
||||
} from './shared-routes-types';
|
||||
|
||||
const SHARED_ITEMS_CACHE_TTL_MS = 1000;
|
||||
|
||||
const sharedItemsCache = new Map<SharedCollectionType, SharedItemsCacheEntry>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-type item builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getCommandItems(sharedDir: string, allowedRoots: Set<string>): SharedItem[] {
|
||||
const markdownFiles = collectMarkdownFiles(sharedDir, allowedRoots);
|
||||
const items: SharedItem[] = [];
|
||||
|
||||
for (const markdownFile of markdownFiles) {
|
||||
const description = readMarkdownDescription(markdownFile.resolvedPath, allowedRoots);
|
||||
if (!description) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const relativePath = path.relative(sharedDir, markdownFile.displayPath);
|
||||
const normalizedName = relativePath.split(path.sep).join('/').replace(/\.md$/i, '');
|
||||
if (!normalizedName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
items.push({
|
||||
name: normalizedName,
|
||||
description,
|
||||
path: markdownFile.displayPath,
|
||||
type: 'command',
|
||||
});
|
||||
}
|
||||
|
||||
return items.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export function getSkillOrAgentItem(
|
||||
type: 'skills' | 'agents',
|
||||
entry: fs.Dirent,
|
||||
entryPath: string,
|
||||
resolvedEntryPath: string,
|
||||
allowedRoots: Set<string>,
|
||||
stats: fs.Stats
|
||||
): SharedItem | null {
|
||||
if (type === 'skills') {
|
||||
if (!stats.isDirectory()) {
|
||||
return null;
|
||||
}
|
||||
const description = readMarkdownDescription(
|
||||
path.join(resolvedEntryPath, 'SKILL.md'),
|
||||
allowedRoots
|
||||
);
|
||||
if (!description) {
|
||||
return null;
|
||||
}
|
||||
return { name: entry.name, description, path: entryPath, type: 'skill' };
|
||||
}
|
||||
|
||||
// agents
|
||||
if (stats.isDirectory()) {
|
||||
const description = readFirstMarkdownDescription(
|
||||
[
|
||||
path.join(resolvedEntryPath, 'prompt.md'),
|
||||
path.join(resolvedEntryPath, 'AGENT.md'),
|
||||
path.join(resolvedEntryPath, 'agent.md'),
|
||||
],
|
||||
allowedRoots
|
||||
);
|
||||
if (!description) {
|
||||
return null;
|
||||
}
|
||||
return { name: entry.name, description, path: entryPath, type: 'agent' };
|
||||
}
|
||||
|
||||
if (!stats.isFile() || !entry.name.toLowerCase().endsWith('.md')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const description = readMarkdownDescription(resolvedEntryPath, allowedRoots);
|
||||
if (!description) {
|
||||
return null;
|
||||
}
|
||||
return { name: entry.name.replace(/\.md$/i, ''), description, path: entryPath, type: 'agent' };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public cache-backed entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getSharedItems(type: SharedCollectionType): SharedItem[] {
|
||||
const ccsDir = getCcsDir();
|
||||
const sharedDir = path.join(ccsDir, 'shared', type);
|
||||
const now = Date.now();
|
||||
|
||||
if (!fs.existsSync(sharedDir)) {
|
||||
sharedItemsCache.delete(type);
|
||||
return [];
|
||||
}
|
||||
|
||||
const cached = sharedItemsCache.get(type);
|
||||
if (cached && cached.sharedDir === sharedDir && cached.expiresAt > now) {
|
||||
return cached.items;
|
||||
}
|
||||
|
||||
const sharedDirRoot = safeRealPath(sharedDir) ?? path.resolve(sharedDir);
|
||||
const allowedRoots = resolveAllowedRoots(type, ccsDir, sharedDirRoot);
|
||||
|
||||
if (type === 'commands') {
|
||||
const commandItems = getCommandItems(sharedDir, allowedRoots);
|
||||
sharedItemsCache.set(type, {
|
||||
items: commandItems,
|
||||
sharedDir,
|
||||
expiresAt: now + SHARED_ITEMS_CACHE_TTL_MS,
|
||||
});
|
||||
return commandItems;
|
||||
}
|
||||
|
||||
if (type === 'plugins') {
|
||||
const pluginItems = getPluginItems(sharedDir, allowedRoots);
|
||||
sharedItemsCache.set(type, {
|
||||
items: pluginItems,
|
||||
sharedDir,
|
||||
expiresAt: now + SHARED_ITEMS_CACHE_TTL_MS,
|
||||
});
|
||||
return pluginItems;
|
||||
}
|
||||
|
||||
// skills | agents
|
||||
const items: SharedItem[] = [];
|
||||
try {
|
||||
const entries = fs.readdirSync(sharedDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
const entryPath = path.join(sharedDir, entry.name);
|
||||
const resolvedEntryPath = safeRealPath(entryPath);
|
||||
if (!resolvedEntryPath || !isPathWithinAny(resolvedEntryPath, allowedRoots)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const stats = fs.statSync(resolvedEntryPath);
|
||||
const item = getSkillOrAgentItem(
|
||||
type,
|
||||
entry,
|
||||
entryPath,
|
||||
resolvedEntryPath,
|
||||
allowedRoots,
|
||||
stats
|
||||
);
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
items.push(item);
|
||||
} catch {
|
||||
// Fail soft per entry so one bad item does not hide valid results.
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory read failed — return empty list
|
||||
}
|
||||
|
||||
const sortedItems = items.sort((a, b) => a.name.localeCompare(b.name));
|
||||
sharedItemsCache.set(type, {
|
||||
items: sortedItems,
|
||||
sharedDir,
|
||||
expiresAt: now + SHARED_ITEMS_CACHE_TTL_MS,
|
||||
});
|
||||
return sortedItems;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Shared Routes — Item content readers
|
||||
*
|
||||
* Resolves the full markdown content for a given shared item path,
|
||||
* routing by collection type and enforcing allowed-root checks.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { safeRealPath, isPathWithinAny } from './shared-routes-path-guards';
|
||||
import {
|
||||
readMarkdownContent,
|
||||
resolveReadableMarkdownPath,
|
||||
MAX_CONTENT_FILE_BYTES,
|
||||
} from './shared-routes-markdown';
|
||||
import { isPluginInfrastructurePath } from './shared-routes-plugins';
|
||||
import { getPluginRegistryContent } from './shared-routes-plugin-registry-content';
|
||||
import type { SharedContentType } from './shared-routes-types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getSharedSettingsContent(
|
||||
itemPath: string,
|
||||
allowedRoots: Set<string>
|
||||
): { content: string; contentPath: string } | null {
|
||||
if (path.basename(itemPath) !== 'settings.json') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sharedRoot = Array.from(allowedRoots)[0];
|
||||
if (!sharedRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const settingsPath = path.join(sharedRoot, 'settings.json');
|
||||
const resolvedSettingsPath = safeRealPath(settingsPath);
|
||||
if (!resolvedSettingsPath || !isPathWithinAny(resolvedSettingsPath, allowedRoots)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let stats: fs.Stats;
|
||||
try {
|
||||
stats = fs.statSync(resolvedSettingsPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!stats.isFile() || stats.size > MAX_CONTENT_FILE_BYTES) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const rawContent = fs.readFileSync(resolvedSettingsPath, 'utf8');
|
||||
const parsed = JSON.parse(rawContent) as unknown;
|
||||
return {
|
||||
content: JSON.stringify(parsed, null, 2),
|
||||
contentPath: resolvedSettingsPath,
|
||||
};
|
||||
} catch {
|
||||
const content = readMarkdownContent(resolvedSettingsPath, allowedRoots);
|
||||
return content ? { content, contentPath: resolvedSettingsPath } : null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic item content
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getSharedItemContent(
|
||||
type: SharedContentType,
|
||||
itemPath: string,
|
||||
allowedRoots: Set<string>,
|
||||
sharedDir: string
|
||||
): { content: string; contentPath: string } | null {
|
||||
if (type === 'settings') {
|
||||
return getSharedSettingsContent(itemPath, allowedRoots);
|
||||
}
|
||||
|
||||
if (type === 'plugins') {
|
||||
const pluginRegistryContent = getPluginRegistryContent(itemPath, sharedDir, allowedRoots);
|
||||
if (pluginRegistryContent) {
|
||||
return pluginRegistryContent;
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedItemPath = safeRealPath(itemPath);
|
||||
if (!resolvedItemPath || !isPathWithinAny(resolvedItemPath, allowedRoots)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let itemStats: fs.Stats;
|
||||
try {
|
||||
itemStats = fs.statSync(resolvedItemPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let markdownPath: string | null = null;
|
||||
|
||||
if (type === 'commands') {
|
||||
if (!itemStats.isFile() || !itemPath.toLowerCase().endsWith('.md')) {
|
||||
return null;
|
||||
}
|
||||
markdownPath = resolvedItemPath;
|
||||
} else if (type === 'skills') {
|
||||
if (!itemStats.isDirectory()) {
|
||||
return null;
|
||||
}
|
||||
markdownPath = resolveReadableMarkdownPath(
|
||||
[path.join(resolvedItemPath, 'SKILL.md')],
|
||||
allowedRoots
|
||||
);
|
||||
} else if (type === 'plugins') {
|
||||
if (!itemStats.isDirectory() || isPluginInfrastructurePath(resolvedItemPath, sharedDir)) {
|
||||
return null;
|
||||
}
|
||||
markdownPath = resolveReadableMarkdownPath(
|
||||
[
|
||||
path.join(resolvedItemPath, 'README.md'),
|
||||
path.join(resolvedItemPath, 'readme.md'),
|
||||
path.join(resolvedItemPath, 'PLUGIN.md'),
|
||||
],
|
||||
allowedRoots
|
||||
);
|
||||
if (!markdownPath) {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
// agents
|
||||
if (itemStats.isDirectory()) {
|
||||
markdownPath = resolveReadableMarkdownPath(
|
||||
[
|
||||
path.join(resolvedItemPath, 'prompt.md'),
|
||||
path.join(resolvedItemPath, 'AGENT.md'),
|
||||
path.join(resolvedItemPath, 'agent.md'),
|
||||
],
|
||||
allowedRoots
|
||||
);
|
||||
} else if (itemStats.isFile() && itemPath.toLowerCase().endsWith('.md')) {
|
||||
markdownPath = resolvedItemPath;
|
||||
}
|
||||
}
|
||||
|
||||
if (!markdownPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = readMarkdownContent(markdownPath, allowedRoots);
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { content, contentPath: markdownPath };
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Shared Routes — Markdown directory walker
|
||||
*
|
||||
* Iterative BFS/DFS over a shared directory collecting all .md files
|
||||
* within allowed roots and the configured traversal depth limit.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { safeRealPath, isPathWithinAny } from './shared-routes-path-guards';
|
||||
|
||||
const MAX_DIRECTORY_TRAVERSAL_DEPTH = 10;
|
||||
|
||||
export interface MarkdownFileEntry {
|
||||
displayPath: string;
|
||||
resolvedPath: string;
|
||||
}
|
||||
|
||||
export function collectMarkdownFiles(
|
||||
sharedDir: string,
|
||||
allowedRoots: Set<string>
|
||||
): MarkdownFileEntry[] {
|
||||
const directoriesToVisit: Array<{ path: string; depth: number }> = [
|
||||
{ path: sharedDir, depth: 0 },
|
||||
];
|
||||
const visitedDirectories = new Set<string>();
|
||||
const markdownFiles: MarkdownFileEntry[] = [];
|
||||
|
||||
while (directoriesToVisit.length > 0) {
|
||||
const current = directoriesToVisit.pop();
|
||||
if (!current) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentDir = current.path;
|
||||
const resolvedCurrentDir = safeRealPath(currentDir);
|
||||
if (!resolvedCurrentDir || !isPathWithinAny(resolvedCurrentDir, allowedRoots)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedDirPath =
|
||||
process.platform === 'win32'
|
||||
? path.resolve(resolvedCurrentDir).toLowerCase()
|
||||
: path.resolve(resolvedCurrentDir);
|
||||
|
||||
if (visitedDirectories.has(normalizedDirPath)) {
|
||||
continue;
|
||||
}
|
||||
visitedDirectories.add(normalizedDirPath);
|
||||
|
||||
let entries: fs.Dirent[] = [];
|
||||
try {
|
||||
entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(currentDir, entry.name);
|
||||
const resolvedEntryPath = safeRealPath(entryPath);
|
||||
if (!resolvedEntryPath || !isPathWithinAny(resolvedEntryPath, allowedRoots)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let stats: fs.Stats;
|
||||
try {
|
||||
stats = fs.statSync(resolvedEntryPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
if (current.depth < MAX_DIRECTORY_TRAVERSAL_DEPTH) {
|
||||
directoriesToVisit.push({ path: entryPath, depth: current.depth + 1 });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stats.isFile() && entry.name.toLowerCase().endsWith('.md')) {
|
||||
markdownFiles.push({
|
||||
displayPath: entryPath,
|
||||
resolvedPath: resolvedEntryPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return markdownFiles;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Shared Routes — Markdown file reading and description extraction
|
||||
*
|
||||
* Handles YAML frontmatter parsing, body-line extraction, and safe
|
||||
* bounded reads for both description snippets and full content.
|
||||
*
|
||||
* Directory walking is in shared-routes-markdown-walker.ts.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as yaml from 'js-yaml';
|
||||
|
||||
import { safeRealPath, isPathWithinAny } from './shared-routes-path-guards';
|
||||
|
||||
const MAX_DESCRIPTION_LENGTH = 140;
|
||||
const MAX_MARKDOWN_FILE_BYTES = 1024 * 1024; // 1 MiB
|
||||
|
||||
/** Exported for content module which uses a larger limit. */
|
||||
export const MAX_CONTENT_FILE_BYTES = 2 * 1024 * 1024; // 2 MiB
|
||||
|
||||
// Re-export walker types so callers only need one import for markdown concerns.
|
||||
export type { MarkdownFileEntry } from './shared-routes-markdown-walker';
|
||||
export { collectMarkdownFiles } from './shared-routes-markdown-walker';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Description extraction helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isDescriptionBodyLine(line: string): boolean {
|
||||
if (!line) {
|
||||
return false;
|
||||
}
|
||||
if (line === '---' || line === '...') {
|
||||
return false;
|
||||
}
|
||||
return !line.startsWith('#') && !line.startsWith('<!--');
|
||||
}
|
||||
|
||||
function extractFrontmatterDescription(content: string): string | null {
|
||||
const frontmatterMatch = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\s*\r?\n|$)/);
|
||||
if (!frontmatterMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = yaml.load(frontmatterMatch[1]) as Record<string, unknown> | null;
|
||||
const description = parsed?.description;
|
||||
if (typeof description !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = description.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stripFrontmatter(content: string): string {
|
||||
return content.replace(/^---\s*\r?\n[\s\S]*?\r?\n---\s*\r?\n?/, '');
|
||||
}
|
||||
|
||||
function trimDescription(description: string): string {
|
||||
if (description.length <= MAX_DESCRIPTION_LENGTH) {
|
||||
return description;
|
||||
}
|
||||
return `${description.slice(0, MAX_DESCRIPTION_LENGTH - 3).trimEnd()}...`;
|
||||
}
|
||||
|
||||
export function extractDescription(content: string): string {
|
||||
const frontmatterDescription = extractFrontmatterDescription(content);
|
||||
if (frontmatterDescription) {
|
||||
return trimDescription(frontmatterDescription);
|
||||
}
|
||||
|
||||
// Fall back to first non-empty, non-heading body line.
|
||||
const lines = stripFrontmatter(content).split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (isDescriptionBodyLine(trimmed)) {
|
||||
return trimDescription(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
return 'No description';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File readers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function readMarkdownDescription(
|
||||
markdownPath: string,
|
||||
allowedRoots: Set<string>
|
||||
): string | null {
|
||||
try {
|
||||
const resolvedMarkdownPath = safeRealPath(markdownPath);
|
||||
if (!resolvedMarkdownPath || !isPathWithinAny(resolvedMarkdownPath, allowedRoots)) {
|
||||
return null;
|
||||
}
|
||||
const stats = fs.statSync(resolvedMarkdownPath);
|
||||
if (!stats.isFile() || stats.size > MAX_MARKDOWN_FILE_BYTES) {
|
||||
return null;
|
||||
}
|
||||
const content = fs.readFileSync(resolvedMarkdownPath, 'utf8');
|
||||
return extractDescription(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readFirstMarkdownDescription(
|
||||
markdownPaths: string[],
|
||||
allowedRoots: Set<string>
|
||||
): string | null {
|
||||
for (const markdownPath of markdownPaths) {
|
||||
const description = readMarkdownDescription(markdownPath, allowedRoots);
|
||||
if (description) {
|
||||
return description;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function readMarkdownContent(
|
||||
markdownPath: string,
|
||||
allowedRoots: Set<string>
|
||||
): string | null {
|
||||
try {
|
||||
const resolvedMarkdownPath = safeRealPath(markdownPath);
|
||||
if (!resolvedMarkdownPath || !isPathWithinAny(resolvedMarkdownPath, allowedRoots)) {
|
||||
return null;
|
||||
}
|
||||
const stats = fs.statSync(resolvedMarkdownPath);
|
||||
if (!stats.isFile() || stats.size > MAX_CONTENT_FILE_BYTES) {
|
||||
return null;
|
||||
}
|
||||
return fs.readFileSync(resolvedMarkdownPath, 'utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveReadableMarkdownPath(
|
||||
markdownPaths: string[],
|
||||
allowedRoots: Set<string>
|
||||
): string | null {
|
||||
for (const markdownPath of markdownPaths) {
|
||||
const resolvedMarkdownPath = safeRealPath(markdownPath);
|
||||
if (!resolvedMarkdownPath || !isPathWithinAny(resolvedMarkdownPath, allowedRoots)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const stats = fs.statSync(resolvedMarkdownPath);
|
||||
if (!stats.isFile() || stats.size > MAX_CONTENT_FILE_BYTES) {
|
||||
continue;
|
||||
}
|
||||
return resolvedMarkdownPath;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Shared Routes — Path safety helpers
|
||||
*
|
||||
* Prevents directory traversal by resolving real paths and checking
|
||||
* containment within allowed roots before any file I/O.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { getClaudeConfigDir } from '../utils/claude-config-path';
|
||||
import { getCcsDir } from '../config/config-loader-facade';
|
||||
import type { SharedCollectionType } from './shared-routes-types';
|
||||
|
||||
export function safeRealPath(targetPath: string): string | null {
|
||||
try {
|
||||
return fs.realpathSync(targetPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeForPathComparison(targetPath: string): string {
|
||||
const normalized = path.resolve(targetPath);
|
||||
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
export function isPathWithin(candidatePath: string, basePath: string): boolean {
|
||||
const normalizedCandidate = normalizeForPathComparison(candidatePath);
|
||||
const normalizedBase = normalizeForPathComparison(basePath);
|
||||
const relative = path.relative(normalizedBase, normalizedCandidate);
|
||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
export function isPathWithinAny(candidatePath: string, basePaths: Set<string>): boolean {
|
||||
for (const basePath of basePaths) {
|
||||
if (isPathWithin(candidatePath, basePath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function resolveAllowedRoots(
|
||||
type: SharedCollectionType,
|
||||
ccsDir: string,
|
||||
sharedDirRoot: string
|
||||
): Set<string> {
|
||||
if (type === 'commands' || type === 'plugins') {
|
||||
return new Set<string>([sharedDirRoot]);
|
||||
}
|
||||
|
||||
return new Set<string>([
|
||||
sharedDirRoot,
|
||||
...[
|
||||
path.join(getClaudeConfigDir(), type),
|
||||
path.join(ccsDir, '.claude', type),
|
||||
path.join(ccsDir, 'shared', type),
|
||||
]
|
||||
.map((dirPath) => safeRealPath(dirPath))
|
||||
.filter((dirPath): dirPath is string => typeof dirPath === 'string'),
|
||||
]);
|
||||
}
|
||||
|
||||
export function resolveSettingsAllowedRoots(ccsDir: string, sharedDirRoot: string): Set<string> {
|
||||
const claudeConfigDir = getClaudeConfigDir();
|
||||
return new Set<string>(
|
||||
[
|
||||
sharedDirRoot,
|
||||
safeRealPath(path.join(claudeConfigDir, 'settings.json')),
|
||||
safeRealPath(path.join(ccsDir, '.claude', 'settings.json')),
|
||||
].filter((dirPath): dirPath is string => typeof dirPath === 'string')
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export getCcsDir so callers that need both path guards and ccsDir can import from one place
|
||||
export { getCcsDir };
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Shared Routes — Plugin registry content renderer
|
||||
*
|
||||
* Renders the markdown content for an installed plugin entry,
|
||||
* pulling from installed_plugins.json, known_marketplaces.json,
|
||||
* and blocklist.json within the allowed roots.
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
|
||||
import { safeRealPath, isPathWithinAny } from './shared-routes-path-guards';
|
||||
import {
|
||||
readJsonObject,
|
||||
readPluginInstalledRegistry,
|
||||
decodePluginRegistryPath,
|
||||
extractMarketplaceFromPluginName,
|
||||
stringifyJson,
|
||||
} from './shared-routes-plugins';
|
||||
|
||||
function findPluginBlocklistEntry(
|
||||
sharedDir: string,
|
||||
allowedRoots: Set<string>,
|
||||
pluginName: string
|
||||
): unknown | null {
|
||||
const blocklist = readJsonObject(path.join(sharedDir, 'blocklist.json'), allowedRoots);
|
||||
const plugins = blocklist?.plugins;
|
||||
if (!Array.isArray(plugins)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
plugins.find(
|
||||
(entry) =>
|
||||
entry &&
|
||||
typeof entry === 'object' &&
|
||||
'plugin' in entry &&
|
||||
(entry as { plugin?: unknown }).plugin === pluginName
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function getPluginRegistryContent(
|
||||
itemPath: string,
|
||||
sharedDir: string,
|
||||
allowedRoots: Set<string>
|
||||
): { content: string; contentPath: string } | null {
|
||||
const pluginName = decodePluginRegistryPath(itemPath);
|
||||
if (!pluginName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const registryPath = path.join(sharedDir, 'installed_plugins.json');
|
||||
const resolvedRegistryPath = safeRealPath(registryPath);
|
||||
if (!resolvedRegistryPath || !isPathWithinAny(resolvedRegistryPath, allowedRoots)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const registry = readPluginInstalledRegistry(sharedDir, allowedRoots);
|
||||
if (!registry || !Object.prototype.hasOwnProperty.call(registry.plugins, pluginName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metadata = registry.plugins[pluginName];
|
||||
const marketplace = extractMarketplaceFromPluginName(pluginName);
|
||||
const marketplaceEntry = marketplace
|
||||
? readJsonObject(path.join(sharedDir, 'known_marketplaces.json'), allowedRoots)?.[marketplace]
|
||||
: null;
|
||||
const blocklistEntry = findPluginBlocklistEntry(sharedDir, allowedRoots, pluginName);
|
||||
|
||||
const lines = [
|
||||
`# ${pluginName}`,
|
||||
'',
|
||||
`Registry: \`${resolvedRegistryPath}\``,
|
||||
'',
|
||||
'## Installed Plugin',
|
||||
'',
|
||||
`- Source: shared \`installed_plugins.json\` registry`,
|
||||
`- Registry version: ${registry.version ?? 'unknown'}`,
|
||||
`- Marketplace: ${marketplace ?? 'not recorded in plugin name'}`,
|
||||
`- Install records: ${Array.isArray(metadata) ? metadata.length : 1}`,
|
||||
];
|
||||
|
||||
if (marketplaceEntry) {
|
||||
lines.push(
|
||||
'',
|
||||
'## Marketplace Registry Entry',
|
||||
'',
|
||||
'```json',
|
||||
stringifyJson(marketplaceEntry),
|
||||
'```'
|
||||
);
|
||||
}
|
||||
|
||||
if (blocklistEntry) {
|
||||
lines.push('', '## Blocklist Notice', '', '```json', stringifyJson(blocklistEntry), '```');
|
||||
}
|
||||
|
||||
lines.push('', '## Plugin Registry Entry', '', '```json', stringifyJson(metadata), '```');
|
||||
|
||||
return {
|
||||
content: lines.join('\n'),
|
||||
contentPath: resolvedRegistryPath,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Shared Routes — Plugin collection and registry helpers
|
||||
*
|
||||
* Handles installed_plugins.json registry reads, legacy plugin directory
|
||||
* scanning, and plugin path encode/decode.
|
||||
*
|
||||
* Registry content rendering is in shared-routes-plugin-registry-content.ts.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { safeRealPath, isPathWithin, isPathWithinAny } from './shared-routes-path-guards';
|
||||
import { readFirstMarkdownDescription, MAX_CONTENT_FILE_BYTES } from './shared-routes-markdown';
|
||||
import type { SharedItem, InstalledPluginRegistry } from './shared-routes-types';
|
||||
|
||||
const PLUGIN_REGISTRY_PATH_PREFIX = 'plugin-registry:';
|
||||
const PLUGIN_INFRASTRUCTURE_DIRECTORIES = new Set(['cache', 'data', 'marketplaces']);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path encode / decode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function encodePluginRegistryPath(pluginName: string): string {
|
||||
return `${PLUGIN_REGISTRY_PATH_PREFIX}${encodeURIComponent(pluginName)}`;
|
||||
}
|
||||
|
||||
export function decodePluginRegistryPath(itemPath: string): string | null {
|
||||
if (!itemPath.startsWith(PLUGIN_REGISTRY_PATH_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const encodedName = itemPath.slice(PLUGIN_REGISTRY_PATH_PREFIX.length);
|
||||
const pluginName = decodeURIComponent(encodedName);
|
||||
return pluginName.length > 0 ? pluginName : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function readJsonObject(
|
||||
filePath: string,
|
||||
allowedRoots: Set<string>
|
||||
): Record<string, unknown> | null {
|
||||
const resolvedPath = safeRealPath(filePath);
|
||||
if (!resolvedPath || !isPathWithinAny(resolvedPath, allowedRoots)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const stats = fs.statSync(resolvedPath);
|
||||
if (!stats.isFile() || stats.size > MAX_CONTENT_FILE_BYTES) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(fs.readFileSync(resolvedPath, 'utf8')) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readPluginInstalledRegistry(
|
||||
sharedDir: string,
|
||||
allowedRoots: Set<string>
|
||||
): InstalledPluginRegistry | null {
|
||||
const parsed = readJsonObject(path.join(sharedDir, 'installed_plugins.json'), allowedRoots);
|
||||
if (
|
||||
!parsed ||
|
||||
!parsed.plugins ||
|
||||
typeof parsed.plugins !== 'object' ||
|
||||
Array.isArray(parsed.plugins)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
version: typeof parsed.version === 'number' ? parsed.version : undefined,
|
||||
plugins: parsed.plugins as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Description helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function extractMarketplaceFromPluginName(pluginName: string): string | null {
|
||||
const atIndex = pluginName.lastIndexOf('@');
|
||||
if (atIndex <= 0 || atIndex === pluginName.length - 1) {
|
||||
return null;
|
||||
}
|
||||
return pluginName.slice(atIndex + 1);
|
||||
}
|
||||
|
||||
export function describeInstalledPlugin(pluginName: string, metadata: unknown): string {
|
||||
const recordCount = Array.isArray(metadata) ? metadata.length : 1;
|
||||
const marketplace = extractMarketplaceFromPluginName(pluginName);
|
||||
const recordLabel = `${recordCount} ${recordCount === 1 ? 'record' : 'records'}`;
|
||||
return marketplace
|
||||
? `Installed from ${marketplace}; ${recordLabel} in shared registry`
|
||||
: `Installed plugin; ${recordLabel} in shared registry`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Infrastructure path guard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isPluginInfrastructurePath(candidatePath: string, sharedDir: string): boolean {
|
||||
for (const directoryName of PLUGIN_INFRASTRUCTURE_DIRECTORIES) {
|
||||
const resolvedInfrastructurePath = safeRealPath(path.join(sharedDir, directoryName));
|
||||
if (resolvedInfrastructurePath && isPathWithin(candidatePath, resolvedInfrastructurePath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON serialization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function stringifyJson(value: unknown): string {
|
||||
return JSON.stringify(value, null, 2) ?? 'null';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Item list builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getPluginRegistryItems(sharedDir: string, allowedRoots: Set<string>): SharedItem[] {
|
||||
const registry = readPluginInstalledRegistry(sharedDir, allowedRoots);
|
||||
if (!registry) {
|
||||
return [];
|
||||
}
|
||||
return Object.entries(registry.plugins).map(([pluginName, metadata]) => ({
|
||||
name: pluginName,
|
||||
description: describeInstalledPlugin(pluginName, metadata),
|
||||
path: encodePluginRegistryPath(pluginName),
|
||||
type: 'plugin' as const,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getLegacyPluginDirectoryItems(
|
||||
sharedDir: string,
|
||||
allowedRoots: Set<string>
|
||||
): SharedItem[] {
|
||||
let entries: fs.Dirent[] = [];
|
||||
try {
|
||||
entries = fs.readdirSync(sharedDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items: SharedItem[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || PLUGIN_INFRASTRUCTURE_DIRECTORIES.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryPath = path.join(sharedDir, entry.name);
|
||||
const resolvedEntryPath = safeRealPath(entryPath);
|
||||
if (!resolvedEntryPath || !isPathWithinAny(resolvedEntryPath, allowedRoots)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const description = readFirstMarkdownDescription(
|
||||
[
|
||||
path.join(resolvedEntryPath, 'README.md'),
|
||||
path.join(resolvedEntryPath, 'readme.md'),
|
||||
path.join(resolvedEntryPath, 'PLUGIN.md'),
|
||||
],
|
||||
allowedRoots
|
||||
);
|
||||
|
||||
if (!description) {
|
||||
continue;
|
||||
}
|
||||
|
||||
items.push({ name: entry.name, description, path: entryPath, type: 'plugin' });
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export function getPluginItems(sharedDir: string, allowedRoots: Set<string>): SharedItem[] {
|
||||
const registryItems = getPluginRegistryItems(sharedDir, allowedRoots);
|
||||
const legacyDirectoryItems = getLegacyPluginDirectoryItems(sharedDir, allowedRoots);
|
||||
const itemsByPath = new Map<string, SharedItem>();
|
||||
|
||||
for (const item of [...registryItems, ...legacyDirectoryItems]) {
|
||||
itemsByPath.set(item.path, item);
|
||||
}
|
||||
|
||||
return [...itemsByPath.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Shared Routes — Symlink / copy status checker
|
||||
*
|
||||
* Reports whether shared resources (commands, skills, agents) under
|
||||
* ~/.ccs/shared/ are configured as symlinks pointing to ~/.claude/,
|
||||
* as directory copies (Windows fallback), or are missing.
|
||||
*
|
||||
* Return shape is a superset of the original { valid, message } so
|
||||
* existing callers remain unaffected while new callers can inspect
|
||||
* mode and per-link details.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { getClaudeConfigDir } from '../utils/claude-config-path';
|
||||
import { getCcsDir } from '../config/config-loader-facade';
|
||||
|
||||
type LinkMode = 'symlink' | 'copy' | 'missing';
|
||||
|
||||
interface LinkDetail {
|
||||
mode: LinkMode;
|
||||
}
|
||||
|
||||
interface SymlinkStatusResult {
|
||||
valid: boolean;
|
||||
message: string;
|
||||
/** Overall configuration mode derived from the three entries. */
|
||||
mode: 'symlink' | 'copy' | 'mixed' | 'none';
|
||||
/** Per-entry classification for diagnostics. */
|
||||
links: {
|
||||
commands: LinkMode;
|
||||
skills: LinkMode;
|
||||
agents: LinkMode;
|
||||
};
|
||||
}
|
||||
|
||||
const SHARED_ENTRY_TYPES = ['commands', 'skills', 'agents'] as const;
|
||||
type SharedEntryType = (typeof SHARED_ENTRY_TYPES)[number];
|
||||
|
||||
function classifyEntry(linkPath: string, expectedTarget: string): LinkMode {
|
||||
try {
|
||||
const lstats = fs.lstatSync(linkPath);
|
||||
|
||||
if (lstats.isSymbolicLink()) {
|
||||
// Validate it points to the expected Claude config sub-directory.
|
||||
const target = fs.readlinkSync(linkPath);
|
||||
const resolvedTarget = path.resolve(path.dirname(linkPath), target);
|
||||
if (resolvedTarget === path.resolve(expectedTarget)) {
|
||||
return 'symlink';
|
||||
}
|
||||
// Symlink exists but points elsewhere — treat as missing for validity.
|
||||
return 'missing';
|
||||
}
|
||||
|
||||
if (lstats.isDirectory()) {
|
||||
// Best-effort non-empty check to distinguish a real copy from an
|
||||
// accidental empty directory.
|
||||
try {
|
||||
const entries = fs.readdirSync(linkPath);
|
||||
if (entries.length > 0) {
|
||||
return 'copy';
|
||||
}
|
||||
} catch {
|
||||
// readdirSync failed — fall through to missing
|
||||
}
|
||||
return 'missing';
|
||||
}
|
||||
} catch {
|
||||
// lstatSync threw — path does not exist or is unreadable
|
||||
}
|
||||
|
||||
return 'missing';
|
||||
}
|
||||
|
||||
export function checkSymlinkStatus(): SymlinkStatusResult {
|
||||
const ccsDir = getCcsDir();
|
||||
const sharedDir = path.join(ccsDir, 'shared');
|
||||
const claudeConfigDir = getClaudeConfigDir();
|
||||
|
||||
if (!fs.existsSync(sharedDir)) {
|
||||
return {
|
||||
valid: false,
|
||||
message: 'Shared directory not found',
|
||||
mode: 'none',
|
||||
links: { commands: 'missing', skills: 'missing', agents: 'missing' },
|
||||
};
|
||||
}
|
||||
|
||||
const details: Record<SharedEntryType, LinkDetail> = {
|
||||
commands: { mode: 'missing' },
|
||||
skills: { mode: 'missing' },
|
||||
agents: { mode: 'missing' },
|
||||
};
|
||||
|
||||
for (const entryType of SHARED_ENTRY_TYPES) {
|
||||
const linkPath = path.join(sharedDir, entryType);
|
||||
const expectedTarget = path.join(claudeConfigDir, entryType);
|
||||
|
||||
if (!fs.existsSync(linkPath)) {
|
||||
details[entryType] = { mode: 'missing' };
|
||||
continue;
|
||||
}
|
||||
|
||||
details[entryType] = { mode: classifyEntry(linkPath, expectedTarget) };
|
||||
}
|
||||
|
||||
const modes = SHARED_ENTRY_TYPES.map((t) => details[t].mode);
|
||||
const links = {
|
||||
commands: details.commands.mode,
|
||||
skills: details.skills.mode,
|
||||
agents: details.agents.mode,
|
||||
};
|
||||
|
||||
const allSymlink = modes.every((m) => m === 'symlink');
|
||||
const allCopy = modes.every((m) => m === 'copy');
|
||||
const allMissing = modes.every((m) => m === 'missing');
|
||||
|
||||
if (allSymlink) {
|
||||
return { valid: true, message: 'Symlinks active', mode: 'symlink', links };
|
||||
}
|
||||
|
||||
if (allCopy) {
|
||||
return {
|
||||
valid: true,
|
||||
message: 'Copies active (Windows fallback)',
|
||||
mode: 'copy',
|
||||
links,
|
||||
};
|
||||
}
|
||||
|
||||
if (allMissing) {
|
||||
return { valid: false, message: 'Shared resources not configured', mode: 'none', links };
|
||||
}
|
||||
|
||||
// Mixed — at least one differs from the others.
|
||||
const details_str = SHARED_ENTRY_TYPES.map((t) => `${t}:${details[t].mode}`).join(', ');
|
||||
return {
|
||||
valid: false,
|
||||
message: `Mixed configuration: ${details_str}`,
|
||||
mode: 'mixed',
|
||||
links,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Shared Routes — Type definitions and type guards
|
||||
*/
|
||||
|
||||
export type SharedCollectionType = 'commands' | 'skills' | 'agents' | 'plugins';
|
||||
export type SharedContentType = SharedCollectionType | 'settings';
|
||||
|
||||
export interface SharedItem {
|
||||
name: string;
|
||||
description: string;
|
||||
path: string;
|
||||
type: 'command' | 'skill' | 'agent' | 'plugin';
|
||||
}
|
||||
|
||||
export interface SharedItemsCacheEntry {
|
||||
items: SharedItem[];
|
||||
sharedDir: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface InstalledPluginRegistry {
|
||||
version?: number;
|
||||
plugins: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function isSharedCollectionType(value: unknown): value is SharedCollectionType {
|
||||
return value === 'commands' || value === 'skills' || value === 'agents' || value === 'plugins';
|
||||
}
|
||||
|
||||
export function isSharedContentType(value: unknown): value is SharedContentType {
|
||||
return isSharedCollectionType(value) || value === 'settings';
|
||||
}
|
||||
+21
-926
@@ -1,17 +1,32 @@
|
||||
/**
|
||||
* Shared Data Routes (Phase 07)
|
||||
*
|
||||
* Thin Express router — delegates all logic to focused sub-modules.
|
||||
* API routes for commands, skills, agents, and plugins from ~/.ccs/shared/
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as yaml from 'js-yaml';
|
||||
|
||||
import { getClaudeConfigDir } from '../utils/claude-config-path';
|
||||
import { requireLocalAccessWhenAuthDisabled } from './middleware/auth-middleware';
|
||||
import { getCcsDir } from '../config/config-loader-facade';
|
||||
import { requireLocalAccessWhenAuthDisabled } from './middleware/auth-middleware';
|
||||
import { isSharedContentType } from './shared-routes-types';
|
||||
import {
|
||||
safeRealPath,
|
||||
resolveAllowedRoots,
|
||||
resolveSettingsAllowedRoots,
|
||||
isPathWithinAny,
|
||||
} from './shared-routes-path-guards';
|
||||
import { getSharedItems } from './shared-routes-collections';
|
||||
import { getSharedItemContent } from './shared-routes-content';
|
||||
import { checkSymlinkStatus } from './shared-routes-symlink-status';
|
||||
|
||||
/** Strips extended fields so the summary endpoint stays wire-compatible. */
|
||||
function symlinkStatusForSummary(): { valid: boolean; message: string } {
|
||||
const { valid, message } = checkSymlinkStatus();
|
||||
return { valid, message };
|
||||
}
|
||||
|
||||
export const sharedRoutes = Router();
|
||||
|
||||
@@ -27,37 +42,6 @@ sharedRoutes.use((req: Request, res: Response, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
const MAX_DIRECTORY_TRAVERSAL_DEPTH = 10;
|
||||
const MAX_DESCRIPTION_LENGTH = 140;
|
||||
const MAX_MARKDOWN_FILE_BYTES = 1024 * 1024; // 1 MiB
|
||||
const MAX_CONTENT_FILE_BYTES = 2 * 1024 * 1024; // 2 MiB
|
||||
const SHARED_ITEMS_CACHE_TTL_MS = 1000;
|
||||
const PLUGIN_REGISTRY_PATH_PREFIX = 'plugin-registry:';
|
||||
const PLUGIN_INFRASTRUCTURE_DIRECTORIES = new Set(['cache', 'data', 'marketplaces']);
|
||||
|
||||
type SharedCollectionType = 'commands' | 'skills' | 'agents' | 'plugins';
|
||||
type SharedContentType = SharedCollectionType | 'settings';
|
||||
|
||||
interface SharedItem {
|
||||
name: string;
|
||||
description: string;
|
||||
path: string;
|
||||
type: 'command' | 'skill' | 'agent' | 'plugin';
|
||||
}
|
||||
|
||||
interface SharedItemsCacheEntry {
|
||||
items: SharedItem[];
|
||||
sharedDir: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface InstalledPluginRegistry {
|
||||
version?: number;
|
||||
plugins: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const sharedItemsCache = new Map<SharedCollectionType, SharedItemsCacheEntry>();
|
||||
|
||||
/**
|
||||
* GET /api/shared/commands
|
||||
*/
|
||||
@@ -109,6 +93,7 @@ sharedRoutes.get('/content', (req: Request, res: Response) => {
|
||||
const ccsDir = getCcsDir();
|
||||
const sharedDir =
|
||||
typeParam === 'settings' ? path.join(ccsDir, 'shared') : path.join(ccsDir, 'shared', typeParam);
|
||||
|
||||
if (!fs.existsSync(sharedDir)) {
|
||||
res.status(404).json({ error: 'Shared directory not found' });
|
||||
return;
|
||||
@@ -119,6 +104,7 @@ sharedRoutes.get('/content', (req: Request, res: Response) => {
|
||||
typeParam === 'settings'
|
||||
? resolveSettingsAllowedRoots(ccsDir, sharedDirRoot)
|
||||
: resolveAllowedRoots(typeParam, ccsDir, sharedDirRoot);
|
||||
|
||||
const contentResult = getSharedItemContent(typeParam, itemPathParam, allowedRoots, sharedDirRoot);
|
||||
|
||||
if (!contentResult) {
|
||||
@@ -157,897 +143,6 @@ sharedRoutes.get('/summary', (_req: Request, res: Response) => {
|
||||
plugins,
|
||||
settings: hasSettings,
|
||||
total: commands + skills + agents + plugins + (hasSettings ? 1 : 0),
|
||||
symlinkStatus: checkSymlinkStatus(),
|
||||
symlinkStatus: symlinkStatusForSummary(),
|
||||
});
|
||||
});
|
||||
|
||||
function isSharedCollectionType(value: unknown): value is SharedCollectionType {
|
||||
return value === 'commands' || value === 'skills' || value === 'agents' || value === 'plugins';
|
||||
}
|
||||
|
||||
function isSharedContentType(value: unknown): value is SharedContentType {
|
||||
return isSharedCollectionType(value) || value === 'settings';
|
||||
}
|
||||
|
||||
function resolveAllowedRoots(
|
||||
type: SharedCollectionType,
|
||||
ccsDir: string,
|
||||
sharedDirRoot: string
|
||||
): Set<string> {
|
||||
if (type === 'commands' || type === 'plugins') {
|
||||
return new Set<string>([sharedDirRoot]);
|
||||
}
|
||||
|
||||
return new Set<string>([
|
||||
sharedDirRoot,
|
||||
...[
|
||||
path.join(getClaudeConfigDir(), type),
|
||||
path.join(ccsDir, '.claude', type),
|
||||
path.join(ccsDir, 'shared', type),
|
||||
]
|
||||
.map((dirPath) => safeRealPath(dirPath))
|
||||
.filter((dirPath): dirPath is string => typeof dirPath === 'string'),
|
||||
]);
|
||||
}
|
||||
|
||||
function resolveSettingsAllowedRoots(ccsDir: string, sharedDirRoot: string): Set<string> {
|
||||
const claudeConfigDir = getClaudeConfigDir();
|
||||
return new Set<string>(
|
||||
[
|
||||
sharedDirRoot,
|
||||
safeRealPath(path.join(claudeConfigDir, 'settings.json')),
|
||||
safeRealPath(path.join(ccsDir, '.claude', 'settings.json')),
|
||||
].filter((dirPath): dirPath is string => typeof dirPath === 'string')
|
||||
);
|
||||
}
|
||||
|
||||
function getSharedItems(type: SharedCollectionType): SharedItem[] {
|
||||
const ccsDir = getCcsDir();
|
||||
const sharedDir = path.join(ccsDir, 'shared', type);
|
||||
const now = Date.now();
|
||||
|
||||
if (!fs.existsSync(sharedDir)) {
|
||||
sharedItemsCache.delete(type);
|
||||
return [];
|
||||
}
|
||||
|
||||
const cached = sharedItemsCache.get(type);
|
||||
if (cached && cached.sharedDir === sharedDir && cached.expiresAt > now) {
|
||||
return cached.items;
|
||||
}
|
||||
|
||||
const items: SharedItem[] = [];
|
||||
const sharedDirRoot = safeRealPath(sharedDir) ?? path.resolve(sharedDir);
|
||||
const allowedRoots = resolveAllowedRoots(type, ccsDir, sharedDirRoot);
|
||||
|
||||
if (type === 'commands') {
|
||||
const commandItems = getCommandItems(sharedDir, allowedRoots);
|
||||
sharedItemsCache.set(type, {
|
||||
items: commandItems,
|
||||
sharedDir,
|
||||
expiresAt: now + SHARED_ITEMS_CACHE_TTL_MS,
|
||||
});
|
||||
return commandItems;
|
||||
}
|
||||
|
||||
if (type === 'plugins') {
|
||||
const pluginItems = getPluginItems(sharedDir, allowedRoots);
|
||||
sharedItemsCache.set(type, {
|
||||
items: pluginItems,
|
||||
sharedDir,
|
||||
expiresAt: now + SHARED_ITEMS_CACHE_TTL_MS,
|
||||
});
|
||||
return pluginItems;
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(sharedDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
const entryPath = path.join(sharedDir, entry.name);
|
||||
const resolvedEntryPath = safeRealPath(entryPath);
|
||||
if (!resolvedEntryPath || !isPathWithinAny(resolvedEntryPath, allowedRoots)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const stats = fs.statSync(resolvedEntryPath);
|
||||
const item = getSkillOrAgentItem(
|
||||
type,
|
||||
entry,
|
||||
entryPath,
|
||||
resolvedEntryPath,
|
||||
allowedRoots,
|
||||
stats
|
||||
);
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
|
||||
items.push(item);
|
||||
} catch {
|
||||
// Fail soft per entry so one bad item does not hide valid results.
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory read failed
|
||||
}
|
||||
|
||||
const sortedItems = items.sort((a, b) => a.name.localeCompare(b.name));
|
||||
sharedItemsCache.set(type, {
|
||||
items: sortedItems,
|
||||
sharedDir,
|
||||
expiresAt: now + SHARED_ITEMS_CACHE_TTL_MS,
|
||||
});
|
||||
return sortedItems;
|
||||
}
|
||||
|
||||
function getPluginItems(sharedDir: string, allowedRoots: Set<string>): SharedItem[] {
|
||||
const registryItems = getPluginRegistryItems(sharedDir, allowedRoots);
|
||||
const legacyDirectoryItems = getLegacyPluginDirectoryItems(sharedDir, allowedRoots);
|
||||
const itemsByPath = new Map<string, SharedItem>();
|
||||
|
||||
for (const item of [...registryItems, ...legacyDirectoryItems]) {
|
||||
itemsByPath.set(item.path, item);
|
||||
}
|
||||
|
||||
return [...itemsByPath.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function getPluginRegistryItems(sharedDir: string, allowedRoots: Set<string>): SharedItem[] {
|
||||
const registry = readPluginInstalledRegistry(sharedDir, allowedRoots);
|
||||
if (!registry) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(registry.plugins).map(([pluginName, metadata]) => ({
|
||||
name: pluginName,
|
||||
description: describeInstalledPlugin(pluginName, metadata),
|
||||
path: encodePluginRegistryPath(pluginName),
|
||||
type: 'plugin' as const,
|
||||
}));
|
||||
}
|
||||
|
||||
function getLegacyPluginDirectoryItems(sharedDir: string, allowedRoots: Set<string>): SharedItem[] {
|
||||
let entries: fs.Dirent[] = [];
|
||||
try {
|
||||
entries = fs.readdirSync(sharedDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items: SharedItem[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || PLUGIN_INFRASTRUCTURE_DIRECTORIES.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryPath = path.join(sharedDir, entry.name);
|
||||
const resolvedEntryPath = safeRealPath(entryPath);
|
||||
if (!resolvedEntryPath || !isPathWithinAny(resolvedEntryPath, allowedRoots)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const description = readFirstMarkdownDescription(
|
||||
[
|
||||
path.join(resolvedEntryPath, 'README.md'),
|
||||
path.join(resolvedEntryPath, 'readme.md'),
|
||||
path.join(resolvedEntryPath, 'PLUGIN.md'),
|
||||
],
|
||||
allowedRoots
|
||||
);
|
||||
|
||||
if (!description) {
|
||||
continue;
|
||||
}
|
||||
|
||||
items.push({
|
||||
name: entry.name,
|
||||
description,
|
||||
path: entryPath,
|
||||
type: 'plugin',
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function getCommandItems(sharedDir: string, allowedRoots: Set<string>): SharedItem[] {
|
||||
const markdownFiles = collectMarkdownFiles(sharedDir, allowedRoots);
|
||||
const items: SharedItem[] = [];
|
||||
|
||||
for (const markdownFile of markdownFiles) {
|
||||
const description = readMarkdownDescription(markdownFile.resolvedPath, allowedRoots);
|
||||
if (!description) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const relativePath = path.relative(sharedDir, markdownFile.displayPath);
|
||||
const normalizedName = relativePath.split(path.sep).join('/').replace(/\.md$/i, '');
|
||||
if (!normalizedName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
items.push({
|
||||
name: normalizedName,
|
||||
description,
|
||||
path: markdownFile.displayPath,
|
||||
type: 'command',
|
||||
});
|
||||
}
|
||||
|
||||
return items.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function getSkillOrAgentItem(
|
||||
type: 'skills' | 'agents',
|
||||
entry: fs.Dirent,
|
||||
entryPath: string,
|
||||
resolvedEntryPath: string,
|
||||
allowedRoots: Set<string>,
|
||||
stats: fs.Stats
|
||||
): SharedItem | null {
|
||||
if (type === 'skills') {
|
||||
if (!stats.isDirectory()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const description = readMarkdownDescription(
|
||||
path.join(resolvedEntryPath, 'SKILL.md'),
|
||||
allowedRoots
|
||||
);
|
||||
if (!description) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: entry.name,
|
||||
description,
|
||||
path: entryPath,
|
||||
type: 'skill',
|
||||
};
|
||||
}
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
const description = readFirstMarkdownDescription(
|
||||
[
|
||||
path.join(resolvedEntryPath, 'prompt.md'),
|
||||
path.join(resolvedEntryPath, 'AGENT.md'),
|
||||
path.join(resolvedEntryPath, 'agent.md'),
|
||||
],
|
||||
allowedRoots
|
||||
);
|
||||
if (!description) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: entry.name,
|
||||
description,
|
||||
path: entryPath,
|
||||
type: 'agent',
|
||||
};
|
||||
}
|
||||
|
||||
if (!stats.isFile() || !entry.name.toLowerCase().endsWith('.md')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const description = readMarkdownDescription(resolvedEntryPath, allowedRoots);
|
||||
if (!description) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: entry.name.replace(/\.md$/i, ''),
|
||||
description,
|
||||
path: entryPath,
|
||||
type: 'agent',
|
||||
};
|
||||
}
|
||||
|
||||
interface MarkdownFileEntry {
|
||||
displayPath: string;
|
||||
resolvedPath: string;
|
||||
}
|
||||
|
||||
function collectMarkdownFiles(sharedDir: string, allowedRoots: Set<string>): MarkdownFileEntry[] {
|
||||
const directoriesToVisit: Array<{ path: string; depth: number }> = [
|
||||
{ path: sharedDir, depth: 0 },
|
||||
];
|
||||
const visitedDirectories = new Set<string>();
|
||||
const markdownFiles: MarkdownFileEntry[] = [];
|
||||
|
||||
while (directoriesToVisit.length > 0) {
|
||||
const current = directoriesToVisit.pop();
|
||||
if (!current) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentDir = current.path;
|
||||
const resolvedCurrentDir = safeRealPath(currentDir);
|
||||
if (!resolvedCurrentDir || !isPathWithinAny(resolvedCurrentDir, allowedRoots)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedDirPath = normalizeForPathComparison(resolvedCurrentDir);
|
||||
if (visitedDirectories.has(normalizedDirPath)) {
|
||||
continue;
|
||||
}
|
||||
visitedDirectories.add(normalizedDirPath);
|
||||
|
||||
let entries: fs.Dirent[] = [];
|
||||
try {
|
||||
entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(currentDir, entry.name);
|
||||
const resolvedEntryPath = safeRealPath(entryPath);
|
||||
if (!resolvedEntryPath || !isPathWithinAny(resolvedEntryPath, allowedRoots)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let stats: fs.Stats;
|
||||
try {
|
||||
stats = fs.statSync(resolvedEntryPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
if (current.depth < MAX_DIRECTORY_TRAVERSAL_DEPTH) {
|
||||
directoriesToVisit.push({ path: entryPath, depth: current.depth + 1 });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stats.isFile() && entry.name.toLowerCase().endsWith('.md')) {
|
||||
markdownFiles.push({
|
||||
displayPath: entryPath,
|
||||
resolvedPath: resolvedEntryPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return markdownFiles;
|
||||
}
|
||||
|
||||
function extractDescription(content: string): string {
|
||||
const frontmatterDescription = extractFrontmatterDescription(content);
|
||||
if (frontmatterDescription) {
|
||||
return trimDescription(frontmatterDescription);
|
||||
}
|
||||
|
||||
// Extract first non-empty, non-heading line from the markdown body.
|
||||
const lines = stripFrontmatter(content).split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (isDescriptionBodyLine(trimmed)) {
|
||||
return trimDescription(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
return 'No description';
|
||||
}
|
||||
|
||||
function isDescriptionBodyLine(line: string): boolean {
|
||||
if (!line) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (line === '---' || line === '...') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !line.startsWith('#') && !line.startsWith('<!--');
|
||||
}
|
||||
|
||||
function extractFrontmatterDescription(content: string): string | null {
|
||||
const frontmatterMatch = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\s*\r?\n|$)/);
|
||||
if (!frontmatterMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = yaml.load(frontmatterMatch[1]) as Record<string, unknown> | null;
|
||||
const description = parsed?.description;
|
||||
if (typeof description !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = description.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stripFrontmatter(content: string): string {
|
||||
return content.replace(/^---\s*\r?\n[\s\S]*?\r?\n---\s*\r?\n?/, '');
|
||||
}
|
||||
|
||||
function trimDescription(description: string): string {
|
||||
if (description.length <= MAX_DESCRIPTION_LENGTH) {
|
||||
return description;
|
||||
}
|
||||
|
||||
return `${description.slice(0, MAX_DESCRIPTION_LENGTH - 3).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function readFirstMarkdownDescription(
|
||||
markdownPaths: string[],
|
||||
allowedRoots: Set<string>
|
||||
): string | null {
|
||||
for (const markdownPath of markdownPaths) {
|
||||
const description = readMarkdownDescription(markdownPath, allowedRoots);
|
||||
if (description) {
|
||||
return description;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function readMarkdownDescription(markdownPath: string, allowedRoots: Set<string>): string | null {
|
||||
try {
|
||||
const resolvedMarkdownPath = safeRealPath(markdownPath);
|
||||
if (!resolvedMarkdownPath || !isPathWithinAny(resolvedMarkdownPath, allowedRoots)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stats = fs.statSync(resolvedMarkdownPath);
|
||||
if (!stats.isFile()) {
|
||||
return null;
|
||||
}
|
||||
if (stats.size > MAX_MARKDOWN_FILE_BYTES) {
|
||||
return null;
|
||||
}
|
||||
const content = fs.readFileSync(resolvedMarkdownPath, 'utf8');
|
||||
return extractDescription(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readMarkdownContent(markdownPath: string, allowedRoots: Set<string>): string | null {
|
||||
try {
|
||||
const resolvedMarkdownPath = safeRealPath(markdownPath);
|
||||
if (!resolvedMarkdownPath || !isPathWithinAny(resolvedMarkdownPath, allowedRoots)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stats = fs.statSync(resolvedMarkdownPath);
|
||||
if (!stats.isFile()) {
|
||||
return null;
|
||||
}
|
||||
if (stats.size > MAX_CONTENT_FILE_BYTES) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fs.readFileSync(resolvedMarkdownPath, 'utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function encodePluginRegistryPath(pluginName: string): string {
|
||||
return `${PLUGIN_REGISTRY_PATH_PREFIX}${encodeURIComponent(pluginName)}`;
|
||||
}
|
||||
|
||||
function decodePluginRegistryPath(itemPath: string): string | null {
|
||||
if (!itemPath.startsWith(PLUGIN_REGISTRY_PATH_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const encodedName = itemPath.slice(PLUGIN_REGISTRY_PATH_PREFIX.length);
|
||||
const pluginName = decodeURIComponent(encodedName);
|
||||
return pluginName.length > 0 ? pluginName : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readJsonObject(
|
||||
filePath: string,
|
||||
allowedRoots: Set<string>
|
||||
): Record<string, unknown> | null {
|
||||
const resolvedPath = safeRealPath(filePath);
|
||||
if (!resolvedPath || !isPathWithinAny(resolvedPath, allowedRoots)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = fs.statSync(resolvedPath);
|
||||
if (!stats.isFile() || stats.size > MAX_CONTENT_FILE_BYTES) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(fs.readFileSync(resolvedPath, 'utf8')) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readPluginInstalledRegistry(
|
||||
sharedDir: string,
|
||||
allowedRoots: Set<string>
|
||||
): InstalledPluginRegistry | null {
|
||||
const parsed = readJsonObject(path.join(sharedDir, 'installed_plugins.json'), allowedRoots);
|
||||
if (
|
||||
!parsed ||
|
||||
!parsed.plugins ||
|
||||
typeof parsed.plugins !== 'object' ||
|
||||
Array.isArray(parsed.plugins)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
version: typeof parsed.version === 'number' ? parsed.version : undefined,
|
||||
plugins: parsed.plugins as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
function describeInstalledPlugin(pluginName: string, metadata: unknown): string {
|
||||
const recordCount = Array.isArray(metadata) ? metadata.length : 1;
|
||||
const marketplace = extractMarketplaceFromPluginName(pluginName);
|
||||
const recordLabel = `${recordCount} ${recordCount === 1 ? 'record' : 'records'}`;
|
||||
|
||||
return marketplace
|
||||
? `Installed from ${marketplace}; ${recordLabel} in shared registry`
|
||||
: `Installed plugin; ${recordLabel} in shared registry`;
|
||||
}
|
||||
|
||||
function extractMarketplaceFromPluginName(pluginName: string): string | null {
|
||||
const atIndex = pluginName.lastIndexOf('@');
|
||||
if (atIndex <= 0 || atIndex === pluginName.length - 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return pluginName.slice(atIndex + 1);
|
||||
}
|
||||
|
||||
function resolveReadableMarkdownPath(
|
||||
markdownPaths: string[],
|
||||
allowedRoots: Set<string>
|
||||
): string | null {
|
||||
for (const markdownPath of markdownPaths) {
|
||||
const resolvedMarkdownPath = safeRealPath(markdownPath);
|
||||
if (!resolvedMarkdownPath || !isPathWithinAny(resolvedMarkdownPath, allowedRoots)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = fs.statSync(resolvedMarkdownPath);
|
||||
if (!stats.isFile() || stats.size > MAX_CONTENT_FILE_BYTES) {
|
||||
continue;
|
||||
}
|
||||
return resolvedMarkdownPath;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSharedItemContent(
|
||||
type: SharedContentType,
|
||||
itemPath: string,
|
||||
allowedRoots: Set<string>,
|
||||
sharedDir: string
|
||||
): { content: string; contentPath: string } | null {
|
||||
if (type === 'settings') {
|
||||
return getSharedSettingsContent(itemPath, allowedRoots);
|
||||
}
|
||||
|
||||
if (type === 'plugins') {
|
||||
const pluginRegistryContent = getPluginRegistryContent(itemPath, sharedDir, allowedRoots);
|
||||
if (pluginRegistryContent) {
|
||||
return pluginRegistryContent;
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedItemPath = safeRealPath(itemPath);
|
||||
if (!resolvedItemPath || !isPathWithinAny(resolvedItemPath, allowedRoots)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let itemStats: fs.Stats;
|
||||
try {
|
||||
itemStats = fs.statSync(resolvedItemPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let markdownPath: string | null = null;
|
||||
if (type === 'commands') {
|
||||
if (!itemStats.isFile() || !itemPath.toLowerCase().endsWith('.md')) {
|
||||
return null;
|
||||
}
|
||||
markdownPath = resolvedItemPath;
|
||||
} else if (type === 'skills') {
|
||||
if (!itemStats.isDirectory()) {
|
||||
return null;
|
||||
}
|
||||
markdownPath = resolveReadableMarkdownPath(
|
||||
[path.join(resolvedItemPath, 'SKILL.md')],
|
||||
allowedRoots
|
||||
);
|
||||
} else if (type === 'plugins') {
|
||||
if (!itemStats.isDirectory() || isPluginInfrastructurePath(resolvedItemPath, sharedDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
markdownPath = resolveReadableMarkdownPath(
|
||||
[
|
||||
path.join(resolvedItemPath, 'README.md'),
|
||||
path.join(resolvedItemPath, 'readme.md'),
|
||||
path.join(resolvedItemPath, 'PLUGIN.md'),
|
||||
],
|
||||
allowedRoots
|
||||
);
|
||||
|
||||
if (!markdownPath) {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
if (itemStats.isDirectory()) {
|
||||
markdownPath = resolveReadableMarkdownPath(
|
||||
[
|
||||
path.join(resolvedItemPath, 'prompt.md'),
|
||||
path.join(resolvedItemPath, 'AGENT.md'),
|
||||
path.join(resolvedItemPath, 'agent.md'),
|
||||
],
|
||||
allowedRoots
|
||||
);
|
||||
} else if (itemStats.isFile() && itemPath.toLowerCase().endsWith('.md')) {
|
||||
markdownPath = resolvedItemPath;
|
||||
}
|
||||
}
|
||||
|
||||
if (!markdownPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = readMarkdownContent(markdownPath, allowedRoots);
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
contentPath: markdownPath,
|
||||
};
|
||||
}
|
||||
|
||||
function isPluginInfrastructurePath(candidatePath: string, sharedDir: string): boolean {
|
||||
for (const directoryName of PLUGIN_INFRASTRUCTURE_DIRECTORIES) {
|
||||
const resolvedInfrastructurePath = safeRealPath(path.join(sharedDir, directoryName));
|
||||
if (resolvedInfrastructurePath && isPathWithin(candidatePath, resolvedInfrastructurePath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function getPluginRegistryContent(
|
||||
itemPath: string,
|
||||
sharedDir: string,
|
||||
allowedRoots: Set<string>
|
||||
): { content: string; contentPath: string } | null {
|
||||
const pluginName = decodePluginRegistryPath(itemPath);
|
||||
if (!pluginName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const registryPath = path.join(sharedDir, 'installed_plugins.json');
|
||||
const resolvedRegistryPath = safeRealPath(registryPath);
|
||||
if (!resolvedRegistryPath || !isPathWithinAny(resolvedRegistryPath, allowedRoots)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const registry = readPluginInstalledRegistry(sharedDir, allowedRoots);
|
||||
if (!registry || !Object.prototype.hasOwnProperty.call(registry.plugins, pluginName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metadata = registry.plugins[pluginName];
|
||||
const marketplace = extractMarketplaceFromPluginName(pluginName);
|
||||
const marketplaceEntry = marketplace
|
||||
? readJsonObject(path.join(sharedDir, 'known_marketplaces.json'), allowedRoots)?.[marketplace]
|
||||
: null;
|
||||
const blocklistEntry = findPluginBlocklistEntry(sharedDir, allowedRoots, pluginName);
|
||||
const lines = [
|
||||
`# ${pluginName}`,
|
||||
'',
|
||||
`Registry: \`${resolvedRegistryPath}\``,
|
||||
'',
|
||||
'## Installed Plugin',
|
||||
'',
|
||||
`- Source: shared \`installed_plugins.json\` registry`,
|
||||
`- Registry version: ${registry.version ?? 'unknown'}`,
|
||||
`- Marketplace: ${marketplace ?? 'not recorded in plugin name'}`,
|
||||
`- Install records: ${Array.isArray(metadata) ? metadata.length : 1}`,
|
||||
];
|
||||
|
||||
if (marketplaceEntry) {
|
||||
lines.push(
|
||||
'',
|
||||
'## Marketplace Registry Entry',
|
||||
'',
|
||||
'```json',
|
||||
stringifyJson(marketplaceEntry),
|
||||
'```'
|
||||
);
|
||||
}
|
||||
|
||||
if (blocklistEntry) {
|
||||
lines.push('', '## Blocklist Notice', '', '```json', stringifyJson(blocklistEntry), '```');
|
||||
}
|
||||
|
||||
lines.push('', '## Plugin Registry Entry', '', '```json', stringifyJson(metadata), '```');
|
||||
|
||||
return {
|
||||
content: lines.join('\n'),
|
||||
contentPath: resolvedRegistryPath,
|
||||
};
|
||||
}
|
||||
|
||||
function findPluginBlocklistEntry(
|
||||
sharedDir: string,
|
||||
allowedRoots: Set<string>,
|
||||
pluginName: string
|
||||
): unknown | null {
|
||||
const blocklist = readJsonObject(path.join(sharedDir, 'blocklist.json'), allowedRoots);
|
||||
const plugins = blocklist?.plugins;
|
||||
if (!Array.isArray(plugins)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
plugins.find(
|
||||
(entry) =>
|
||||
entry &&
|
||||
typeof entry === 'object' &&
|
||||
'plugin' in entry &&
|
||||
(entry as { plugin?: unknown }).plugin === pluginName
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function stringifyJson(value: unknown): string {
|
||||
return JSON.stringify(value, null, 2) ?? 'null';
|
||||
}
|
||||
|
||||
function safeRealPath(targetPath: string): string | null {
|
||||
try {
|
||||
return fs.realpathSync(targetPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSharedSettingsContent(
|
||||
itemPath: string,
|
||||
allowedRoots: Set<string>
|
||||
): { content: string; contentPath: string } | null {
|
||||
if (path.basename(itemPath) !== 'settings.json') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sharedRoot = Array.from(allowedRoots)[0];
|
||||
if (!sharedRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const settingsPath = path.join(sharedRoot, 'settings.json');
|
||||
const resolvedSettingsPath = safeRealPath(settingsPath);
|
||||
if (!resolvedSettingsPath || !isPathWithinAny(resolvedSettingsPath, allowedRoots)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let stats: fs.Stats;
|
||||
try {
|
||||
stats = fs.statSync(resolvedSettingsPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!stats.isFile() || stats.size > MAX_CONTENT_FILE_BYTES) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const rawContent = fs.readFileSync(resolvedSettingsPath, 'utf8');
|
||||
const parsed = JSON.parse(rawContent) as unknown;
|
||||
return {
|
||||
content: JSON.stringify(parsed, null, 2),
|
||||
contentPath: resolvedSettingsPath,
|
||||
};
|
||||
} catch {
|
||||
const content = readMarkdownContent(resolvedSettingsPath, allowedRoots);
|
||||
return content
|
||||
? {
|
||||
content,
|
||||
contentPath: resolvedSettingsPath,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
function isPathWithin(candidatePath: string, basePath: string): boolean {
|
||||
const normalizedCandidate = normalizeForPathComparison(candidatePath);
|
||||
const normalizedBase = normalizeForPathComparison(basePath);
|
||||
const relative = path.relative(normalizedBase, normalizedCandidate);
|
||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function isPathWithinAny(candidatePath: string, basePaths: Set<string>): boolean {
|
||||
for (const basePath of basePaths) {
|
||||
if (isPathWithin(candidatePath, basePath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeForPathComparison(targetPath: string): string {
|
||||
const normalized = path.resolve(targetPath);
|
||||
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
function checkSymlinkStatus(): { valid: boolean; message: string } {
|
||||
const ccsDir = getCcsDir();
|
||||
const sharedDir = path.join(ccsDir, 'shared');
|
||||
|
||||
if (!fs.existsSync(sharedDir)) {
|
||||
return { valid: false, message: 'Shared directory not found' };
|
||||
}
|
||||
|
||||
// Check all three symlinks: commands, skills, agents
|
||||
const linkTypes = ['commands', 'skills', 'agents'];
|
||||
let validLinks = 0;
|
||||
|
||||
for (const linkType of linkTypes) {
|
||||
const linkPath = path.join(sharedDir, linkType);
|
||||
|
||||
try {
|
||||
if (fs.existsSync(linkPath)) {
|
||||
const stats = fs.lstatSync(linkPath);
|
||||
if (stats.isSymbolicLink()) {
|
||||
const target = fs.readlinkSync(linkPath);
|
||||
// Check if it points to Claude config dir.
|
||||
const expectedTarget = path.join(getClaudeConfigDir(), linkType);
|
||||
if (path.resolve(path.dirname(linkPath), target) === path.resolve(expectedTarget)) {
|
||||
validLinks++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not a symlink or read error
|
||||
}
|
||||
}
|
||||
|
||||
if (validLinks === linkTypes.length) {
|
||||
return { valid: true, message: 'Symlinks active' };
|
||||
} else if (validLinks > 0) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `Symlinks partially configured (${validLinks}/${linkTypes.length})`,
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: false, message: 'Symlinks not configured' };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { checkSymlinkStatus } from '../../../src/web-server/shared-routes-symlink-status';
|
||||
|
||||
/**
|
||||
* All tests set process.env.CCS_HOME to a temp directory so getCcsDir()
|
||||
* never touches the user's real ~/.ccs/. CLAUDE_CONFIG_DIR is also
|
||||
* overridden so checkSymlinkStatus() does not reference ~/.claude/.
|
||||
*/
|
||||
|
||||
describe('checkSymlinkStatus', () => {
|
||||
let tempHome: string;
|
||||
let ccsDir: string;
|
||||
let sharedDir: string;
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalClaudeConfigDir: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-symlink-status-test-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
|
||||
process.env.CCS_HOME = tempHome;
|
||||
// Point CLAUDE_CONFIG_DIR to a dir we control so expected targets resolve correctly.
|
||||
process.env.CLAUDE_CONFIG_DIR = path.join(tempHome, 'claude-config');
|
||||
|
||||
ccsDir = path.join(tempHome, '.ccs');
|
||||
sharedDir = path.join(ccsDir, 'shared');
|
||||
fs.mkdirSync(sharedDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
if (originalClaudeConfigDir !== undefined) {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
|
||||
} else {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
}
|
||||
if (tempHome && fs.existsSync(tempHome)) {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Missing shared directory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('reports none when shared directory does not exist', () => {
|
||||
fs.rmSync(sharedDir, { recursive: true, force: true });
|
||||
const result = checkSymlinkStatus();
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.mode).toBe('none');
|
||||
expect(result.message).toContain('not found');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// All-missing (no entries at all)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('reports none when no entries exist under shared/', () => {
|
||||
const result = checkSymlinkStatus();
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.mode).toBe('none');
|
||||
expect(result.message).toBe('Shared resources not configured');
|
||||
expect(result.links).toEqual({ commands: 'missing', skills: 'missing', agents: 'missing' });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Windows copy-fallback mode: all three are non-empty directories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('reports valid copy mode when all three entries are non-empty directories (Windows fallback)', () => {
|
||||
for (const entryType of ['commands', 'skills', 'agents']) {
|
||||
const dir = path.join(sharedDir, entryType);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
// Place a file so the directory is non-empty.
|
||||
fs.writeFileSync(path.join(dir, 'placeholder.md'), `# ${entryType}`);
|
||||
}
|
||||
|
||||
const result = checkSymlinkStatus();
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.mode).toBe('copy');
|
||||
expect(result.message).toBe('Copies active (Windows fallback)');
|
||||
expect(result.links).toEqual({ commands: 'copy', skills: 'copy', agents: 'copy' });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Symlink mode: all three are valid symlinks to CLAUDE_CONFIG_DIR/<type>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('reports valid symlink mode when all three entries are correct symlinks', () => {
|
||||
const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR as string;
|
||||
for (const entryType of ['commands', 'skills', 'agents']) {
|
||||
const target = path.join(claudeConfigDir, entryType);
|
||||
fs.mkdirSync(target, { recursive: true });
|
||||
const symlinkType = process.platform === 'win32' ? 'junction' : 'dir';
|
||||
fs.symlinkSync(target, path.join(sharedDir, entryType), symlinkType as fs.symlink.Type);
|
||||
}
|
||||
|
||||
const result = checkSymlinkStatus();
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.mode).toBe('symlink');
|
||||
expect(result.message).toBe('Symlinks active');
|
||||
expect(result.links).toEqual({ commands: 'symlink', skills: 'symlink', agents: 'symlink' });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mixed / partial: only commands exists as a copy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('reports invalid mixed mode when only commands exists as a copy', () => {
|
||||
const commandsDir = path.join(sharedDir, 'commands');
|
||||
fs.mkdirSync(commandsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(commandsDir, 'build.md'), 'Run build');
|
||||
|
||||
const result = checkSymlinkStatus();
|
||||
expect(result.valid).toBe(false);
|
||||
// mode must reflect a non-uniform state
|
||||
expect(['mixed', 'none']).toContain(result.mode);
|
||||
// message must reference the mixed/partial state
|
||||
expect(result.message.toLowerCase()).toMatch(/mixed|not configured/);
|
||||
expect(result.links.commands).toBe('copy');
|
||||
expect(result.links.skills).toBe('missing');
|
||||
expect(result.links.agents).toBe('missing');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mixed: two copies, one missing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('reports invalid mixed mode when two entries are copies and one is missing', () => {
|
||||
for (const entryType of ['commands', 'skills']) {
|
||||
const dir = path.join(sharedDir, entryType);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'placeholder.md'), `# ${entryType}`);
|
||||
}
|
||||
// agents intentionally absent
|
||||
|
||||
const result = checkSymlinkStatus();
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.mode).toBe('mixed');
|
||||
expect(result.message).toContain('Mixed');
|
||||
expect(result.links.commands).toBe('copy');
|
||||
expect(result.links.skills).toBe('copy');
|
||||
expect(result.links.agents).toBe('missing');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Empty directory is treated as missing (not a valid copy)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('treats an empty directory as missing, not a valid copy', () => {
|
||||
for (const entryType of ['commands', 'skills', 'agents']) {
|
||||
fs.mkdirSync(path.join(sharedDir, entryType), { recursive: true });
|
||||
// intentionally empty
|
||||
}
|
||||
|
||||
const result = checkSymlinkStatus();
|
||||
// All entries empty -> all missing -> mode none
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.mode).toBe('none');
|
||||
expect(result.links).toEqual({ commands: 'missing', skills: 'missing', agents: 'missing' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user