feat(coding): add PI-105 product file host API
This commit is contained in:
1 parent
7680bfb0cf
commit
9520872d2f
17 files changed
+1419
-38
No files matched your search
@@ -0,0 +1,116 @@
|
||||
# Task: Implement PI-105 Pi Host API cutover
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260823-pi-host-cutover-c4e8a1d7
|
||||
- Mode: Feature
|
||||
- Branch: codex/20260823-pi-host-cutover-c4e8a1d7-pi-host-cutover
|
||||
- Worktree: D:\Datas\OthersProjects\makelore-pi-host-cutover-c4e8a1d7
|
||||
- Base commit: 7680bfb0cf206142a164b740d56f8be5ea59362a
|
||||
- Owner: codex
|
||||
- Status: In Progress
|
||||
|
||||
## Scope
|
||||
|
||||
- Implement `PI-105 — Files, changes, skills, and command Host API` from the
|
||||
`【架构】opencode2pi` Spec/ticket graph on cumulative PI-090 baseline
|
||||
`7680bfb0cf206142a164b740d56f8be5ea59362a`.
|
||||
- Add a Main-owned vendor-neutral Project File Service for active-project
|
||||
status, find, bounded content and bounded text search.
|
||||
- Add typed `/api/coding` routes for files, search, Conversation changes,
|
||||
managed Skills and the Conversation command catalog, plus the Renderer
|
||||
facade contract half owned by this ticket.
|
||||
- Compose those routes with the same production `PiProductTools`, attachment
|
||||
store and `ConversationChangeTracker` instances used by the Pi extension.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- All file inputs are active-project-relative. Reject absolute, traversal and
|
||||
project-external paths; never project the absolute project root or expose an
|
||||
arbitrary filesystem proxy.
|
||||
- Preserve the currently supported file/status/find/content/search UX while
|
||||
bounding file sizes, result counts and search output. Do not depend on the
|
||||
OpenCode runtime or its wire DTOs.
|
||||
- Conversation changes come only from the shared PI-090 tracker and are
|
||||
associated with the requested Conversation/run. Do not query Pi session
|
||||
diff, parse message text, or create a second tracker/state source.
|
||||
- Skills come only from packaged managed resources and the target Agent's
|
||||
explicit selection. Commands merge Makelore, valid Pi and selected-Skill
|
||||
entries with stable DTOs and Makelore-first deduplication.
|
||||
- The new `/api/coding` route/type/test surface contains no todo, share,
|
||||
revert/unrevert, checkpoint or file-rollback shape. Existing
|
||||
`/api/opencode` code remains untouched for PI-140 removal.
|
||||
- Exclude PI-100 project/Agent/Conversation CRUD, prompt, SSE, interactions and
|
||||
runtime diagnostics; exclude PI-110/120/130 Renderer state/UI work.
|
||||
- Do not invent an attachment read endpoint: neither PI-105 nor the normative
|
||||
route table assigns its transport.
|
||||
- Real external Provider verification remains **Explicitly Waived / Accepted
|
||||
Risk** with `realTurnVerified=false`; macOS x64/arm64 remains deferred to
|
||||
mandatory PI-150. Neither is a Pass.
|
||||
- Do not create subagents. Keep all writes in this isolated worktree and leave
|
||||
canonical project memory to a later integration task.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Define the smallest vendor-neutral file/change/skill/command contracts and
|
||||
lock the supported file UX and path boundaries with focused tests.
|
||||
2. Implement the Main-owned Project File Service and the PI-105 leaf routes,
|
||||
including the minimal planner-approved
|
||||
`GET /api/coding/conversations/:id/changes` route.
|
||||
3. Wire one shared product-services composition into `HostApiContext`, route
|
||||
registration and Electron Main without taking PI-100 route ownership.
|
||||
4. Add focused service/route/facade/dispatcher/Electron seam coverage for path
|
||||
rejection, redaction, bounds, tracker identity and managed catalog rules.
|
||||
5. Run focused tests, typecheck, lint, full unit, Vite build, Windows Electron
|
||||
verification and project-document gates; commit and request planner review.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Added shared vendor-neutral file/change/Skill/command DTOs and a typed
|
||||
Renderer facade for the seven PI-105 endpoints.
|
||||
- Added a Main-owned `CodingProjectFileService` with Git-aware discovery,
|
||||
bounded non-Git fallback, bounded UTF-8 previews/search, binary rejection,
|
||||
ignore-directory handling, and lexical/realpath containment.
|
||||
- Added the five normative file/search/Skill routes plus Conversation command
|
||||
and planner-approved changes routes. Known input errors are typed and
|
||||
unknown filesystem/service failures are redacted.
|
||||
- Added `CodingProductHost` composition and wired Electron Main to one shared
|
||||
attachment store, `PiProductTools`, and its single
|
||||
`ConversationChangeTracker`. The current active-project catalog is injected
|
||||
as a temporary project-selection adapter until PI-100 replaces composition;
|
||||
no OpenCode runtime call or fallback is used by the new service.
|
||||
- Product command projection is Makelore-first, accepts only safe Pi metadata,
|
||||
includes only selected managed Skills, and strips raw Pi/source/provider
|
||||
fields. The host has a `get_commands` result injection seam for PI-100's
|
||||
runtime composition.
|
||||
- Added focused unit and Windows Electron coverage for Git/non-Git files,
|
||||
Unicode truncation, path rejection/redaction, shared tracker identity,
|
||||
catalog filtering, seven route DTOs, Renderer encoding, and Main dispatcher.
|
||||
|
||||
## Verification
|
||||
|
||||
- `corepack pnpm exec vitest run tests/unit/coding-project-files.test.ts tests/unit/coding-product-services.test.ts tests/unit/coding-files-routes.test.ts tests/unit/coding-product-tools-facade.test.ts` — Pass, 4 files / 10 tests.
|
||||
- `corepack pnpm exec vitest run --config vitest.electron.config.ts tests/electron-runtime/coding-files-host.test.ts` — Pass, 1 file / 1 test (focused seam).
|
||||
- `corepack pnpm run typecheck` — Pass.
|
||||
- `corepack pnpm run lint:check` — Pass with 6 pre-existing warnings and no errors.
|
||||
- `corepack pnpm run build:vite` — Pass; existing dynamic-import/chunk-size warnings remain.
|
||||
- `corepack pnpm test` — Pass on clean rerun, 207 files / 2237 passed / 2 skipped. The first run had one Windows temporary-file `EPERM` and one async AI-hardware loading timeout; both passed when isolated and the complete suite then passed.
|
||||
- `corepack pnpm run test:electron:windows` — Pass, 3 files / 7 tests. A direct Vitest invocation was intentionally discarded because it bypassed the repository's local-Electron wrapper.
|
||||
- Real Provider verification — **Explicitly Waived / Accepted Risk**;
|
||||
`realTurnVerified=false`, not Pass.
|
||||
- macOS x64/arm64 — deferred to mandatory PI-150, not Pass.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- PI-100 must pass the target Conversation worker's read-only Pi
|
||||
`get_commands` projection into `createCodingProductHost.listPiCommands` when
|
||||
it composes the production `CodingConversationRuntime`. Until that runtime
|
||||
exists in Main, the route deterministically returns Makelore plus selected
|
||||
managed-Skill commands.
|
||||
- PI-100 should replace the injected current active-project catalog adapter
|
||||
with its vendor-neutral project service without changing the PI-105 host
|
||||
contract or creating another product-tools/change-tracker instance.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- None recorded.
|
||||
@@ -0,0 +1,171 @@
|
||||
import type {
|
||||
CodingProjectFileContent,
|
||||
CodingProjectFileEntry,
|
||||
CodingTextSearchResult,
|
||||
ConversationChangesSnapshot,
|
||||
ProductCodingCommand,
|
||||
ProductCodingSkill,
|
||||
ProductPiCommandInput,
|
||||
} from '../../shared/coding-product-tools';
|
||||
import { createCodingConversationStore } from '../coding-projects/conversation-store';
|
||||
import type { CodingAttachmentStore } from '../coding-projects/attachment-store';
|
||||
import { readCodingProjectConfigV2 } from '../coding-projects/project-config';
|
||||
import { CodingProjectFileService } from '../coding-projects/project-files';
|
||||
import type { PiProductTools } from '../coding-runtime/pi/product-tools';
|
||||
|
||||
export interface ActiveCodingProject {
|
||||
id: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface CodingProductHost {
|
||||
fileStatus(): Promise<CodingProjectFileEntry[]>;
|
||||
findFiles(query: string, limit?: number): Promise<CodingProjectFileEntry[]>;
|
||||
fileContent(path: string): Promise<CodingProjectFileContent>;
|
||||
searchText(pattern: string): Promise<CodingTextSearchResult[]>;
|
||||
listSkills(agentId?: string): Promise<ProductCodingSkill[]>;
|
||||
listCommands(conversationId: string): Promise<ProductCodingCommand[]>;
|
||||
getChanges(conversationId: string): Promise<ConversationChangesSnapshot | null>;
|
||||
}
|
||||
|
||||
export interface CodingProductComposition {
|
||||
attachments: CodingAttachmentStore;
|
||||
productTools: PiProductTools;
|
||||
host: CodingProductHost;
|
||||
}
|
||||
|
||||
export class CodingProductHostError extends Error {
|
||||
constructor(
|
||||
readonly status: 404 | 409,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export interface CodingProductHostOptions {
|
||||
getActiveProject(): Promise<ActiveCodingProject | null>;
|
||||
productTools: PiProductTools;
|
||||
files?: CodingProjectFileService;
|
||||
listPiCommands?(conversationId: string): Promise<unknown>;
|
||||
}
|
||||
|
||||
function normalizePiCommands(value: unknown): ProductPiCommandInput[] {
|
||||
const record = value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
const candidates = Array.isArray(value)
|
||||
? value
|
||||
: Array.isArray(record?.commands)
|
||||
? record.commands
|
||||
: Array.isArray(record?.data)
|
||||
? record.data
|
||||
: [];
|
||||
return candidates.flatMap((candidate) => {
|
||||
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return [];
|
||||
const command = candidate as Record<string, unknown>;
|
||||
if (typeof command.name !== 'string') return [];
|
||||
return [{
|
||||
name: command.name,
|
||||
...(typeof command.description === 'string' ? { description: command.description } : {}),
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
export function createCodingProductHost(options: CodingProductHostOptions): CodingProductHost {
|
||||
const files = options.files ?? new CodingProjectFileService();
|
||||
|
||||
async function activeProject(): Promise<ActiveCodingProject> {
|
||||
const project = await options.getActiveProject();
|
||||
if (!project) {
|
||||
throw new CodingProductHostError(
|
||||
409,
|
||||
'CODING_ACTIVE_PROJECT_REQUIRED',
|
||||
'No active coding project is selected',
|
||||
);
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
async function selectedSkillIds(
|
||||
projectPath: string,
|
||||
agentId?: string,
|
||||
): Promise<readonly string[]> {
|
||||
if (!agentId) return [];
|
||||
const config = await readCodingProjectConfigV2(projectPath);
|
||||
if (config.status !== 'valid') {
|
||||
throw new CodingProductHostError(
|
||||
409,
|
||||
'CODING_PROJECT_CONFIG_INVALID',
|
||||
'Coding project configuration is unavailable',
|
||||
);
|
||||
}
|
||||
const agent = config.config.agents.find((candidate) => (
|
||||
candidate.id === agentId && candidate.enabled && !candidate.archivedAt
|
||||
));
|
||||
if (!agent) {
|
||||
throw new CodingProductHostError(
|
||||
404,
|
||||
'CODING_AGENT_NOT_FOUND',
|
||||
'Coding project Agent does not exist',
|
||||
);
|
||||
}
|
||||
return agent.skillIds;
|
||||
}
|
||||
|
||||
async function conversationContext(conversationId: string): Promise<{
|
||||
project: ActiveCodingProject;
|
||||
skillIds: readonly string[];
|
||||
}> {
|
||||
const project = await activeProject();
|
||||
const conversation = await createCodingConversationStore(project.path).get(conversationId);
|
||||
if (!conversation) {
|
||||
throw new CodingProductHostError(
|
||||
404,
|
||||
'CODING_CONVERSATION_NOT_FOUND',
|
||||
'Coding Conversation does not exist',
|
||||
);
|
||||
}
|
||||
return {
|
||||
project,
|
||||
skillIds: await selectedSkillIds(project.path, conversation.agentId),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
async fileStatus() {
|
||||
const project = await activeProject();
|
||||
return await files.status(project.path);
|
||||
},
|
||||
async findFiles(query, limit) {
|
||||
const project = await activeProject();
|
||||
return await files.find(project.path, query, limit);
|
||||
},
|
||||
async fileContent(filePath) {
|
||||
const project = await activeProject();
|
||||
return await files.content(project.path, filePath);
|
||||
},
|
||||
async searchText(pattern) {
|
||||
const project = await activeProject();
|
||||
return await files.search(project.path, pattern);
|
||||
},
|
||||
async listSkills(agentId) {
|
||||
const project = await activeProject();
|
||||
return await options.productTools.listSkills(
|
||||
await selectedSkillIds(project.path, agentId),
|
||||
);
|
||||
},
|
||||
async listCommands(conversationId) {
|
||||
const context = await conversationContext(conversationId);
|
||||
const piCommands = options.listPiCommands
|
||||
? normalizePiCommands(await options.listPiCommands(conversationId))
|
||||
: [];
|
||||
return await options.productTools.listCommands(context.skillIds, piCommands);
|
||||
},
|
||||
async getChanges(conversationId) {
|
||||
await conversationContext(conversationId);
|
||||
return options.productTools.getChanges(conversationId);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
import type { StaticArtifactSnapshot } from '../services/static-release-server';
|
||||
import type { BackgroundLifecycleController } from '../main/background-lifecycle';
|
||||
import type { ReleaseJobManager } from '../services/release-job';
|
||||
import type { CodingProductComposition } from './coding-product-services';
|
||||
|
||||
export type WorksSubmissionBindingStore = ReturnType<typeof createWorksSubmissionBindingStore>;
|
||||
|
||||
@@ -77,4 +78,5 @@ export interface HostApiContext {
|
||||
imageWorkspace?: DesignWorkspaceModule;
|
||||
lifecycle?: BackgroundLifecycleController;
|
||||
releaseJobs?: ReleaseJobManager;
|
||||
codingProducts?: CodingProductComposition;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { handleUsageRoutes } from './routes/usage';
|
||||
import { handleFileRoutes } from './routes/files';
|
||||
import { handleMeowaGameAssetsRoutes } from './routes/meowa-game-assets';
|
||||
import { handleAgentBrowserRoutes } from './routes/agent-browser';
|
||||
import { handleCodingFileRoutes } from './routes/coding-files';
|
||||
|
||||
export type HostApiRouteHandler = (
|
||||
req: IncomingMessage,
|
||||
@@ -42,6 +43,7 @@ export const hostApiRouteHandlers: readonly HostApiRouteHandler[] = [
|
||||
handleWorksRoutes,
|
||||
handleAgentBrowserRoutes,
|
||||
handleUserSyncRoutes,
|
||||
handleCodingFileRoutes,
|
||||
handleOpencodeRoutes,
|
||||
handleSettingsRoutes,
|
||||
handleProviderRoutes,
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { CodingProductHostError } from '../coding-product-services';
|
||||
import { sendJson } from '../route-utils';
|
||||
|
||||
function unavailable(res: ServerResponse): void {
|
||||
sendJson(res, 503, {
|
||||
success: false,
|
||||
code: 'CODING_PRODUCT_TOOLS_UNAVAILABLE',
|
||||
error: 'Coding product tools are unavailable',
|
||||
});
|
||||
}
|
||||
|
||||
function serviceError(res: ServerResponse, error: unknown): void {
|
||||
if (error instanceof CodingProductHostError) {
|
||||
sendJson(res, error.status, {
|
||||
success: false,
|
||||
code: error.code,
|
||||
error: error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const code = error && typeof error === 'object' && 'code' in error
|
||||
? String(error.code)
|
||||
: '';
|
||||
if (code === 'ENOENT') {
|
||||
sendJson(res, 404, {
|
||||
success: false,
|
||||
code: 'CODING_FILE_NOT_FOUND',
|
||||
error: 'Project file does not exist',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
const knownInputError = new Set([
|
||||
'File query is required',
|
||||
'File query is too long',
|
||||
'Search pattern is required',
|
||||
'Search pattern is too long',
|
||||
'Project file path must be relative',
|
||||
'Project file path escapes the active project',
|
||||
'Project file path is not a file',
|
||||
'Binary project files cannot be previewed',
|
||||
'Project file is not valid UTF-8 text',
|
||||
]);
|
||||
if (knownInputError.has(message)) {
|
||||
sendJson(res, 400, {
|
||||
success: false,
|
||||
code: 'CODING_FILE_REQUEST_INVALID',
|
||||
error: message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendJson(res, 500, {
|
||||
success: false,
|
||||
code: 'CODING_PRODUCT_TOOL_FAILED',
|
||||
error: 'Coding product request failed',
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleCodingFileRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
const host = ctx.codingProducts?.host;
|
||||
const fixedGetRoutes = new Set([
|
||||
'/api/coding/files/status',
|
||||
'/api/coding/files/find',
|
||||
'/api/coding/files/content',
|
||||
'/api/coding/search',
|
||||
'/api/coding/skills',
|
||||
]);
|
||||
const commandMatch = url.pathname.match(/^\/api\/coding\/conversations\/([^/]+)\/commands$/);
|
||||
const changesMatch = url.pathname.match(/^\/api\/coding\/conversations\/([^/]+)\/changes$/);
|
||||
if (!fixedGetRoutes.has(url.pathname) && !commandMatch && !changesMatch) return false;
|
||||
if (req.method !== 'GET') return false;
|
||||
if (!host) {
|
||||
unavailable(res);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
if (url.pathname === '/api/coding/files/status') {
|
||||
sendJson(res, 200, { files: await host.fileStatus() });
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === '/api/coding/files/find') {
|
||||
const query = url.searchParams.get('query')?.trim() ?? '';
|
||||
const rawLimit = url.searchParams.get('limit');
|
||||
const parsedLimit = rawLimit ? Number(rawLimit) : undefined;
|
||||
sendJson(res, 200, {
|
||||
files: await host.findFiles(
|
||||
query,
|
||||
parsedLimit !== undefined && Number.isSafeInteger(parsedLimit) && parsedLimit > 0
|
||||
? parsedLimit
|
||||
: undefined,
|
||||
),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === '/api/coding/files/content') {
|
||||
sendJson(res, 200, {
|
||||
file: await host.fileContent(url.searchParams.get('path')?.trim() ?? ''),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === '/api/coding/search') {
|
||||
sendJson(res, 200, {
|
||||
matches: await host.searchText(url.searchParams.get('pattern')?.trim() ?? ''),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === '/api/coding/skills') {
|
||||
sendJson(res, 200, {
|
||||
skills: await host.listSkills(url.searchParams.get('agentId')?.trim() || undefined),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (commandMatch) {
|
||||
sendJson(res, 200, {
|
||||
commands: await host.listCommands(decodeURIComponent(commandMatch[1])),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (changesMatch) {
|
||||
sendJson(res, 200, {
|
||||
changes: await host.getChanges(decodeURIComponent(changesMatch[1])),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
serviceError(res, error);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1,29 +1,23 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { open, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type {
|
||||
ConversationChangedFile,
|
||||
ConversationChangesSnapshot,
|
||||
CodingProjectFileStatus,
|
||||
} from '../../shared/coding-product-tools';
|
||||
|
||||
export type {
|
||||
ConversationChangedFile,
|
||||
ConversationChangesSnapshot,
|
||||
} from '../../shared/coding-product-tools';
|
||||
|
||||
const MAX_CHANGED_FILES = 200;
|
||||
const MAX_DIFF_BYTES = 64 * 1024;
|
||||
const MAX_UNTRACKED_PREVIEW_BYTES = 8 * 1024;
|
||||
const MAX_GIT_OUTPUT_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
export type ConversationChangedFileStatus = 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked';
|
||||
|
||||
export interface ConversationChangedFile {
|
||||
path: string;
|
||||
status: ConversationChangedFileStatus;
|
||||
diff?: string;
|
||||
preview?: string;
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
export interface ConversationChangesSnapshot {
|
||||
conversationId: string;
|
||||
runId: string;
|
||||
git: boolean;
|
||||
baselineHead: string | null;
|
||||
files: ConversationChangedFile[];
|
||||
}
|
||||
export type ConversationChangedFileStatus = CodingProjectFileStatus;
|
||||
|
||||
export interface GitCommandResult {
|
||||
code: number;
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
import { open, lstat, readdir, realpath } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type {
|
||||
CodingProjectFileContent,
|
||||
CodingProjectFileEntry,
|
||||
CodingProjectFileStatus,
|
||||
CodingTextSearchResult,
|
||||
} from '../../shared/coding-product-tools';
|
||||
import {
|
||||
ProcessConversationGitAdapter,
|
||||
type ConversationGitAdapter,
|
||||
type GitCommandResult,
|
||||
} from './conversation-change-tracker';
|
||||
|
||||
const MAX_STATUS_RESULTS = 200;
|
||||
const DEFAULT_FIND_RESULTS = 20;
|
||||
const MAX_FIND_RESULTS = 200;
|
||||
const MAX_DISCOVERED_FILES = 20_000;
|
||||
const MAX_CONTENT_BYTES = 256 * 1024;
|
||||
const MAX_SEARCH_FILE_BYTES = 1024 * 1024;
|
||||
const MAX_SEARCH_RESULTS = 200;
|
||||
const MAX_SEARCH_LINE_CHARS = 2_048;
|
||||
const SKIPPED_DIRECTORIES = new Set([
|
||||
'.git',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.svelte-kit',
|
||||
'build',
|
||||
'coverage',
|
||||
'dist',
|
||||
'node_modules',
|
||||
'out',
|
||||
]);
|
||||
|
||||
function normalizeRelativePath(value: string): string {
|
||||
const raw = value.trim();
|
||||
const slashPath = raw.replaceAll('\\', '/');
|
||||
if (!slashPath || slashPath.includes('\0') || path.isAbsolute(raw)
|
||||
|| path.win32.isAbsolute(raw) || path.posix.isAbsolute(slashPath)) {
|
||||
throw new Error('Project file path must be relative');
|
||||
}
|
||||
const normalized = path.posix.normalize(slashPath).replace(/^\.\//, '');
|
||||
if (!normalized || normalized === '.' || normalized === '..' || normalized.startsWith('../')) {
|
||||
throw new Error('Project file path escapes the active project');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function projectTarget(projectPath: string, relativePath: string): string {
|
||||
const root = path.resolve(projectPath);
|
||||
const target = path.resolve(root, ...relativePath.split('/'));
|
||||
const relative = path.relative(root, target);
|
||||
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
throw new Error('Project file path escapes the active project');
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
async function containedExistingTarget(projectPath: string, relativePath: string): Promise<string> {
|
||||
const root = await realpath(path.resolve(projectPath));
|
||||
const target = await realpath(projectTarget(root, relativePath));
|
||||
const relative = path.relative(root, target);
|
||||
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
throw new Error('Project file path escapes the active project');
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
function statusFromSignature(signature: string, renamed: boolean): CodingProjectFileStatus {
|
||||
if (signature === '??') return 'untracked';
|
||||
if (renamed || signature.includes('R')) return 'renamed';
|
||||
if (signature.includes('D')) return 'deleted';
|
||||
if (signature.includes('A')) return 'added';
|
||||
return 'modified';
|
||||
}
|
||||
|
||||
function parseStatus(value: string): CodingProjectFileEntry[] {
|
||||
const records = value.split('\0');
|
||||
const files: CodingProjectFileEntry[] = [];
|
||||
for (let index = 0; index < records.length; index += 1) {
|
||||
const record = records[index];
|
||||
if (!record || record.startsWith('! ')) continue;
|
||||
let filePath: string | undefined;
|
||||
let status: CodingProjectFileStatus;
|
||||
if (record.startsWith('? ')) {
|
||||
filePath = record.slice(2);
|
||||
status = 'untracked';
|
||||
} else {
|
||||
const renamed = record.startsWith('2 ');
|
||||
const match = renamed
|
||||
? record.match(/^2 ([^ ]+) [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ (.*)$/s)
|
||||
: record.match(/^1 ([^ ]+) [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ (.*)$/s);
|
||||
if (!match) continue;
|
||||
filePath = match[2];
|
||||
status = statusFromSignature(match[1], renamed);
|
||||
if (renamed) index += 1;
|
||||
}
|
||||
const normalized = normalizeRelativePath(filePath);
|
||||
files.push({
|
||||
path: normalized,
|
||||
name: path.posix.basename(normalized),
|
||||
type: 'file',
|
||||
status,
|
||||
});
|
||||
if (files.length >= MAX_STATUS_RESULTS) break;
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function readBoundedFile(
|
||||
projectPath: string,
|
||||
relativePath: string,
|
||||
maxBytes: number,
|
||||
): Promise<{ data: Buffer; truncated: boolean }> {
|
||||
const target = await containedExistingTarget(projectPath, relativePath);
|
||||
const metadata = await lstat(target);
|
||||
if (!metadata.isFile()) throw new Error('Project file path is not a file');
|
||||
const handle = await open(target, 'r');
|
||||
const data = Buffer.alloc(Math.min(metadata.size, maxBytes + 1));
|
||||
let bytesRead: number;
|
||||
try {
|
||||
({ bytesRead } = await handle.read(data, 0, data.byteLength, 0));
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
return {
|
||||
data: data.subarray(0, Math.min(bytesRead, maxBytes)),
|
||||
truncated: metadata.size > maxBytes || bytesRead > maxBytes,
|
||||
};
|
||||
}
|
||||
|
||||
function decodeText(data: Buffer, allowIncompleteSuffix = false): string {
|
||||
if (data.includes(0)) throw new Error('Binary project files cannot be previewed');
|
||||
try {
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(
|
||||
data,
|
||||
allowIncompleteSuffix ? { stream: true } : undefined,
|
||||
);
|
||||
} catch {
|
||||
throw new Error('Project file is not valid UTF-8 text');
|
||||
}
|
||||
}
|
||||
|
||||
async function fallbackFileList(projectPath: string): Promise<string[]> {
|
||||
const root = path.resolve(projectPath);
|
||||
const files: string[] = [];
|
||||
const pending = [''];
|
||||
while (pending.length > 0 && files.length < MAX_DISCOVERED_FILES) {
|
||||
const relativeDirectory = pending.shift() as string;
|
||||
const target = relativeDirectory
|
||||
? path.join(root, ...relativeDirectory.split('/'))
|
||||
: root;
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(target, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
entries.sort((left, right) => left.name.localeCompare(right.name));
|
||||
for (const entry of entries) {
|
||||
const relativePath = relativeDirectory
|
||||
? `${relativeDirectory}/${entry.name}`
|
||||
: entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
if (!SKIPPED_DIRECTORIES.has(entry.name)) pending.push(relativePath);
|
||||
} else if (entry.isFile()) {
|
||||
files.push(relativePath);
|
||||
if (files.length >= MAX_DISCOVERED_FILES) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
export class CodingProjectFileService {
|
||||
constructor(
|
||||
private readonly git: ConversationGitAdapter = new ProcessConversationGitAdapter(),
|
||||
) {}
|
||||
|
||||
async status(projectPath: string): Promise<CodingProjectFileEntry[]> {
|
||||
const result = await this.gitResult(projectPath, [
|
||||
'status',
|
||||
'--porcelain=v2',
|
||||
'-z',
|
||||
'--untracked-files=all',
|
||||
'--',
|
||||
'.',
|
||||
]);
|
||||
return result?.code === 0 ? parseStatus(result.stdout) : [];
|
||||
}
|
||||
|
||||
async find(
|
||||
projectPath: string,
|
||||
query: string,
|
||||
requestedLimit = DEFAULT_FIND_RESULTS,
|
||||
): Promise<CodingProjectFileEntry[]> {
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase();
|
||||
if (!normalizedQuery) throw new Error('File query is required');
|
||||
if (normalizedQuery.length > 200) throw new Error('File query is too long');
|
||||
const limit = Math.min(
|
||||
Number.isSafeInteger(requestedLimit) && requestedLimit > 0 ? requestedLimit : DEFAULT_FIND_RESULTS,
|
||||
MAX_FIND_RESULTS,
|
||||
);
|
||||
const paths = await this.listFiles(projectPath);
|
||||
const matches = paths
|
||||
.filter((filePath) => filePath.toLocaleLowerCase().includes(normalizedQuery))
|
||||
.sort((left, right) => {
|
||||
const leftName = path.posix.basename(left).toLocaleLowerCase();
|
||||
const rightName = path.posix.basename(right).toLocaleLowerCase();
|
||||
const leftPrefix = leftName.startsWith(normalizedQuery) ? 0 : 1;
|
||||
const rightPrefix = rightName.startsWith(normalizedQuery) ? 0 : 1;
|
||||
return leftPrefix - rightPrefix || left.length - right.length || left.localeCompare(right);
|
||||
})
|
||||
.slice(0, limit);
|
||||
return await Promise.all(matches.map(async (filePath) => {
|
||||
let size: number | undefined;
|
||||
try {
|
||||
const metadata = await lstat(projectTarget(projectPath, filePath));
|
||||
if (metadata.isFile()) size = metadata.size;
|
||||
} catch {
|
||||
// A concurrently removed result remains useful by path.
|
||||
}
|
||||
return {
|
||||
path: filePath,
|
||||
name: path.posix.basename(filePath),
|
||||
type: 'file' as const,
|
||||
...(size === undefined ? {} : { size }),
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
async content(projectPath: string, requestedPath: string): Promise<CodingProjectFileContent> {
|
||||
const relativePath = normalizeRelativePath(requestedPath);
|
||||
const result = await readBoundedFile(projectPath, relativePath, MAX_CONTENT_BYTES);
|
||||
return {
|
||||
path: relativePath,
|
||||
content: decodeText(result.data, result.truncated),
|
||||
truncated: result.truncated,
|
||||
};
|
||||
}
|
||||
|
||||
async search(projectPath: string, pattern: string): Promise<CodingTextSearchResult[]> {
|
||||
const needle = pattern.trim();
|
||||
if (!needle) throw new Error('Search pattern is required');
|
||||
if (needle.length > 512) throw new Error('Search pattern is too long');
|
||||
const foldedNeedle = needle.toLocaleLowerCase();
|
||||
const matches: CodingTextSearchResult[] = [];
|
||||
for (const filePath of await this.listFiles(projectPath)) {
|
||||
if (matches.length >= MAX_SEARCH_RESULTS) break;
|
||||
let content: string;
|
||||
try {
|
||||
const bounded = await readBoundedFile(projectPath, filePath, MAX_SEARCH_FILE_BYTES);
|
||||
if (bounded.truncated) continue;
|
||||
content = decodeText(bounded.data);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const lines = content.split(/\r?\n/);
|
||||
for (let lineIndex = 0; lineIndex < lines.length && matches.length < MAX_SEARCH_RESULTS; lineIndex += 1) {
|
||||
const line = lines[lineIndex];
|
||||
const foldedLine = line.toLocaleLowerCase();
|
||||
const first = foldedLine.indexOf(foldedNeedle);
|
||||
if (first < 0) continue;
|
||||
const windowStart = Math.max(0, first - 256);
|
||||
const lineText = line.slice(windowStart, windowStart + MAX_SEARCH_LINE_CHARS);
|
||||
const foldedText = lineText.toLocaleLowerCase();
|
||||
const submatches = [];
|
||||
let offset = 0;
|
||||
while (submatches.length < 20) {
|
||||
const start = foldedText.indexOf(foldedNeedle, offset);
|
||||
if (start < 0) break;
|
||||
const end = start + needle.length;
|
||||
submatches.push({ text: lineText.slice(start, end), start, end });
|
||||
offset = Math.max(end, start + 1);
|
||||
}
|
||||
matches.push({
|
||||
path: filePath,
|
||||
name: path.posix.basename(filePath),
|
||||
lineNumber: lineIndex + 1,
|
||||
lineText,
|
||||
submatches,
|
||||
});
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
private async listFiles(projectPath: string): Promise<string[]> {
|
||||
const repository = await this.gitResult(projectPath, ['rev-parse', '--is-inside-work-tree']);
|
||||
if (repository?.code === 0 && repository.stdout.trim() === 'true') {
|
||||
const listed = await this.gitResult(projectPath, [
|
||||
'ls-files',
|
||||
'-co',
|
||||
'--exclude-standard',
|
||||
'-z',
|
||||
'--',
|
||||
'.',
|
||||
]);
|
||||
if (listed?.code === 0) {
|
||||
return [...new Set(listed.stdout
|
||||
.split('\0')
|
||||
.filter(Boolean)
|
||||
.map(normalizeRelativePath))]
|
||||
.sort()
|
||||
.slice(0, MAX_DISCOVERED_FILES);
|
||||
}
|
||||
}
|
||||
return await fallbackFileList(projectPath);
|
||||
}
|
||||
|
||||
private async gitResult(projectPath: string, args: readonly string[]): Promise<GitCommandResult | null> {
|
||||
try {
|
||||
return await this.git.run(projectPath, args);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,21 +4,17 @@ import {
|
||||
BUNDLED_CODING_SKILL_IDS,
|
||||
type BundledCodingSkillId,
|
||||
} from '../../shared/coding-skills';
|
||||
import type {
|
||||
ProductCodingCommand,
|
||||
ProductCodingSkill,
|
||||
ProductPiCommandInput,
|
||||
} from '../../shared/coding-product-tools';
|
||||
|
||||
export interface ProductCodingSkill {
|
||||
id: BundledCodingSkillId;
|
||||
name: string;
|
||||
description: string;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
export interface ProductCodingCommand {
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
source: 'makelore' | 'pi' | 'skill';
|
||||
skillId?: BundledCodingSkillId;
|
||||
}
|
||||
export type {
|
||||
ProductCodingCommand,
|
||||
ProductCodingSkill,
|
||||
ProductPiCommandInput,
|
||||
} from '../../shared/coding-product-tools';
|
||||
|
||||
const MAKELORE_COMMANDS: readonly ProductCodingCommand[] = [
|
||||
{ name: 'models', title: '切换模型', description: '切换当前会话后续轮次使用的模型', source: 'makelore' },
|
||||
@@ -28,6 +24,8 @@ const MAKELORE_COMMANDS: readonly ProductCodingCommand[] = [
|
||||
{ name: 'recover', title: '恢复会话', description: '重新创建当前会话 worker 并恢复状态', source: 'makelore' },
|
||||
] as const;
|
||||
|
||||
const COMMAND_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
|
||||
|
||||
function frontmatterScalar(content: string, key: string): string | undefined {
|
||||
const block = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1];
|
||||
if (!block) return undefined;
|
||||
@@ -70,14 +68,15 @@ export async function listProductCodingSkills(
|
||||
|
||||
export function buildProductCodingCommandCatalog(
|
||||
skills: readonly ProductCodingSkill[],
|
||||
piCommands: readonly { name: string; description?: string }[] = [],
|
||||
piCommands: readonly ProductPiCommandInput[] = [],
|
||||
): ProductCodingCommand[] {
|
||||
const commands = [...MAKELORE_COMMANDS];
|
||||
const used = new Set(commands.map((command) => command.name));
|
||||
const used = new Set(commands.map((command) => command.name.toLocaleLowerCase()));
|
||||
for (const command of piCommands) {
|
||||
const name = command.name.trim();
|
||||
if (!name || /\s/u.test(name) || used.has(name)) continue;
|
||||
used.add(name);
|
||||
const key = name.toLocaleLowerCase();
|
||||
if (!COMMAND_NAME_PATTERN.test(name) || used.has(key)) continue;
|
||||
used.add(key);
|
||||
commands.push({
|
||||
name,
|
||||
title: name,
|
||||
@@ -86,8 +85,9 @@ export function buildProductCodingCommandCatalog(
|
||||
});
|
||||
}
|
||||
for (const skill of skills) {
|
||||
if (!skill.selected || used.has(skill.id)) continue;
|
||||
used.add(skill.id);
|
||||
const key = skill.id.toLocaleLowerCase();
|
||||
if (!skill.selected || used.has(key)) continue;
|
||||
used.add(key);
|
||||
commands.push({
|
||||
name: skill.id,
|
||||
title: skill.name,
|
||||
|
||||
@@ -8,6 +8,11 @@ import {
|
||||
buildProductCodingCommandCatalog,
|
||||
listProductCodingSkills,
|
||||
} from '../../coding-projects/skill-registry';
|
||||
import type {
|
||||
ProductCodingCommand,
|
||||
ProductCodingSkill,
|
||||
ProductPiCommandInput,
|
||||
} from '../../../shared/coding-product-tools';
|
||||
import type { KnownToolDetails, RuntimeContextDetailsV1 } from '../contracts';
|
||||
import { PiAgentBrowserTool } from './extensions/agent-browser';
|
||||
import { reportChangedFiles } from './extensions/changed-file';
|
||||
@@ -65,6 +70,17 @@ export class PiProductTools {
|
||||
return this.changeTracker.getSnapshot(conversationId);
|
||||
}
|
||||
|
||||
listSkills(skillIds: readonly string[]): Promise<ProductCodingSkill[]> {
|
||||
return listProductCodingSkills(this.options.bundledSkillsDir, skillIds);
|
||||
}
|
||||
|
||||
async listCommands(
|
||||
skillIds: readonly string[],
|
||||
piCommands: readonly ProductPiCommandInput[] = [],
|
||||
): Promise<ProductCodingCommand[]> {
|
||||
return buildProductCodingCommandCatalog(await this.listSkills(skillIds), piCommands);
|
||||
}
|
||||
|
||||
async markBash(conversationId: string, runId: string): Promise<void> {
|
||||
await this.changeTracker.markProjectRefresh(conversationId, runId);
|
||||
}
|
||||
@@ -92,7 +108,7 @@ export class PiProductTools {
|
||||
return await reportChangedFiles(this.changeTracker, context, input);
|
||||
}
|
||||
if (toolName !== 'runtime_context') throw new Error('Product tool is unavailable');
|
||||
const skills = await listProductCodingSkills(this.options.bundledSkillsDir, context.skillIds);
|
||||
const skills = await this.listSkills(context.skillIds);
|
||||
const details: RuntimeContextDetailsV1 = {
|
||||
schema: 'runtime-context.v1',
|
||||
skills,
|
||||
|
||||
@@ -95,6 +95,10 @@ import {
|
||||
} from '../image-workspace/local-workspace';
|
||||
import { WorksSquareDesignWorkspace } from '../image-workspace/works-square-workspace';
|
||||
import type { DesignWorkspaceModule } from '../image-workspace/module';
|
||||
import { CodingAttachmentStore } from '../coding-projects/attachment-store';
|
||||
import { PiProductTools } from '../coding-runtime/pi/product-tools';
|
||||
import { resolveBundledCodingSkillsDir } from '../coding-runtime/pi/resource-loader';
|
||||
import { createCodingProductHost } from '../api/coding-product-services';
|
||||
|
||||
// Diagnostic package: force Chromium networking onto HTTP/1.1 for transport A/B testing.
|
||||
app.commandLine.appendSwitch('disable-http2');
|
||||
@@ -549,6 +553,29 @@ async function initialize(): Promise<void> {
|
||||
window.webContents.send('release-job:status', status);
|
||||
}
|
||||
});
|
||||
const codingAttachments = new CodingAttachmentStore(
|
||||
join(app.getPath('userData'), 'coding-runtime', 'attachments'),
|
||||
);
|
||||
const piProductTools = new PiProductTools({
|
||||
browser: agentBrowser,
|
||||
attachments: codingAttachments,
|
||||
bundledSkillsDir: resolveBundledCodingSkillsDir({
|
||||
isPackaged: app.isPackaged,
|
||||
resourcesPath: process.resourcesPath,
|
||||
appPath: app.getAppPath(),
|
||||
}),
|
||||
});
|
||||
const codingProducts = {
|
||||
attachments: codingAttachments,
|
||||
productTools: piProductTools,
|
||||
host: createCodingProductHost({
|
||||
productTools: piProductTools,
|
||||
getActiveProject: async () => {
|
||||
const project = await opencodeProjectStore.getActiveProject();
|
||||
return project ? { id: project.id, path: project.path } : null;
|
||||
},
|
||||
}),
|
||||
};
|
||||
const hostApiContext: HostApiContext = {
|
||||
opencodeManager,
|
||||
opencodeProjectStore,
|
||||
@@ -559,6 +586,7 @@ async function initialize(): Promise<void> {
|
||||
imageWorkspace,
|
||||
lifecycle: backgroundLifecycle,
|
||||
releaseJobs,
|
||||
codingProducts,
|
||||
};
|
||||
registerIpcHandlers(undefined, opencodeManager, undefined, window, backgroundLifecycle, hostApiContext);
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { BundledCodingSkillId } from './coding-skills';
|
||||
|
||||
export type CodingProjectFileStatus =
|
||||
| 'added'
|
||||
| 'modified'
|
||||
| 'deleted'
|
||||
| 'renamed'
|
||||
| 'untracked';
|
||||
|
||||
export interface CodingProjectFileEntry {
|
||||
path: string;
|
||||
name: string;
|
||||
type: 'file';
|
||||
status?: CodingProjectFileStatus;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface CodingProjectFileContent {
|
||||
path: string;
|
||||
content: string;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export interface CodingTextSearchSubmatch {
|
||||
text: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface CodingTextSearchResult {
|
||||
path: string;
|
||||
name: string;
|
||||
lineNumber: number;
|
||||
lineText: string;
|
||||
submatches: CodingTextSearchSubmatch[];
|
||||
}
|
||||
|
||||
export interface ConversationChangedFile {
|
||||
path: string;
|
||||
status: CodingProjectFileStatus;
|
||||
diff?: string;
|
||||
preview?: string;
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
export interface ConversationChangesSnapshot {
|
||||
conversationId: string;
|
||||
runId: string;
|
||||
git: boolean;
|
||||
baselineHead: string | null;
|
||||
files: ConversationChangedFile[];
|
||||
}
|
||||
|
||||
export interface ProductCodingSkill {
|
||||
id: BundledCodingSkillId;
|
||||
name: string;
|
||||
description: string;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
export interface ProductCodingCommand {
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
source: 'makelore' | 'pi' | 'skill';
|
||||
skillId?: BundledCodingSkillId;
|
||||
}
|
||||
|
||||
export interface ProductPiCommandInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CodingFileStatusResponse {
|
||||
files: CodingProjectFileEntry[];
|
||||
}
|
||||
|
||||
export interface CodingFileFindResponse {
|
||||
files: CodingProjectFileEntry[];
|
||||
}
|
||||
|
||||
export interface CodingFileContentResponse {
|
||||
file: CodingProjectFileContent;
|
||||
}
|
||||
|
||||
export interface CodingTextSearchResponse {
|
||||
matches: CodingTextSearchResult[];
|
||||
}
|
||||
|
||||
export interface CodingSkillsResponse {
|
||||
skills: ProductCodingSkill[];
|
||||
}
|
||||
|
||||
export interface CodingCommandsResponse {
|
||||
commands: ProductCodingCommand[];
|
||||
}
|
||||
|
||||
export interface CodingChangesResponse {
|
||||
changes: ConversationChangesSnapshot | null;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type {
|
||||
CodingFileContentResponse,
|
||||
CodingFileFindResponse,
|
||||
CodingFileStatusResponse,
|
||||
CodingTextSearchResponse,
|
||||
CodingChangesResponse,
|
||||
CodingCommandsResponse,
|
||||
CodingSkillsResponse,
|
||||
} from '../../shared/coding-product-tools';
|
||||
import { hostApiFetch } from './host-api';
|
||||
|
||||
export async function getCodingFileStatus(): Promise<CodingFileStatusResponse> {
|
||||
return await hostApiFetch<CodingFileStatusResponse>('/api/coding/files/status');
|
||||
}
|
||||
|
||||
export async function findCodingFiles(
|
||||
query: string,
|
||||
limit?: number,
|
||||
): Promise<CodingFileFindResponse> {
|
||||
const params = new URLSearchParams({ query });
|
||||
if (limit !== undefined) params.set('limit', String(limit));
|
||||
return await hostApiFetch<CodingFileFindResponse>(`/api/coding/files/find?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function getCodingFileContent(path: string): Promise<CodingFileContentResponse> {
|
||||
const params = new URLSearchParams({ path });
|
||||
return await hostApiFetch<CodingFileContentResponse>(`/api/coding/files/content?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function searchCodingText(pattern: string): Promise<CodingTextSearchResponse> {
|
||||
const params = new URLSearchParams({ pattern });
|
||||
return await hostApiFetch<CodingTextSearchResponse>(`/api/coding/search?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function getCodingSkills(agentId?: string): Promise<CodingSkillsResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (agentId) params.set('agentId', agentId);
|
||||
const query = params.toString();
|
||||
return await hostApiFetch<CodingSkillsResponse>(`/api/coding/skills${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
export async function getCodingConversationCommands(
|
||||
conversationId: string,
|
||||
): Promise<CodingCommandsResponse> {
|
||||
return await hostApiFetch<CodingCommandsResponse>(
|
||||
`/api/coding/conversations/${encodeURIComponent(conversationId)}/commands`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getCodingConversationChanges(
|
||||
conversationId: string,
|
||||
): Promise<CodingChangesResponse> {
|
||||
return await hostApiFetch<CodingChangesResponse>(
|
||||
`/api/coding/conversations/${encodeURIComponent(conversationId)}/changes`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import type { CodingProductHost } from '../../electron/api/coding-product-services';
|
||||
import type { HostApiContext } from '../../electron/api/context';
|
||||
import { dispatchHostApiRequest } from '../../electron/api/host-api-dispatcher';
|
||||
import { CodingProjectFileService } from '../../electron/coding-projects/project-files';
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe('PI-105 Electron Host API seam', () => {
|
||||
it('reads a project-relative file through the Main dispatcher without loopback or path leakage', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-host-dispatch-'));
|
||||
roots.push(root);
|
||||
await writeFile(path.join(root, 'notes.txt'), 'Main-owned content\n', 'utf8');
|
||||
const files = new CodingProjectFileService();
|
||||
const host = {
|
||||
fileContent: async (filePath: string) => await files.content(root, filePath),
|
||||
} as CodingProductHost;
|
||||
const ctx = {
|
||||
codingProducts: { host, attachments: {}, productTools: {} },
|
||||
} as HostApiContext;
|
||||
|
||||
const response = await dispatchHostApiRequest(ctx, {
|
||||
path: '/api/coding/files/content?path=notes.txt',
|
||||
});
|
||||
expect(response).toMatchObject({
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: {
|
||||
file: { path: 'notes.txt', content: 'Main-owned content\n', truncated: false },
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(response.json)).not.toContain(root);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { CodingProductHost } from '../../electron/api/coding-product-services';
|
||||
import type { HostApiContext } from '../../electron/api/context';
|
||||
import { dispatchHostApiRequest } from '../../electron/api/host-api-dispatcher';
|
||||
|
||||
function context(host?: CodingProductHost): HostApiContext {
|
||||
return (host
|
||||
? { codingProducts: { host, attachments: {}, productTools: {} } }
|
||||
: {}) as HostApiContext;
|
||||
}
|
||||
|
||||
function productHost(): CodingProductHost {
|
||||
return {
|
||||
fileStatus: vi.fn(async () => [{
|
||||
path: 'src/app.ts', name: 'app.ts', type: 'file' as const, status: 'modified' as const,
|
||||
}]),
|
||||
findFiles: vi.fn(async () => [{
|
||||
path: 'src/app.ts', name: 'app.ts', type: 'file' as const, size: 12,
|
||||
}]),
|
||||
fileContent: vi.fn(async () => ({
|
||||
path: 'src/app.ts', content: 'export {};\n', truncated: false,
|
||||
})),
|
||||
searchText: vi.fn(async () => [{
|
||||
path: 'src/app.ts', name: 'app.ts', lineNumber: 1, lineText: 'export {};', submatches: [],
|
||||
}]),
|
||||
listSkills: vi.fn(async () => [{
|
||||
id: 'agent-browser' as const, name: 'Agent Browser', description: 'Browser', selected: true,
|
||||
}]),
|
||||
listCommands: vi.fn(async () => [{
|
||||
name: 'compact', title: '压缩会话', description: 'Compact', source: 'makelore' as const,
|
||||
}]),
|
||||
getChanges: vi.fn(async () => ({
|
||||
conversationId: 'conversation/a', runId: 'run-a', git: false, baselineHead: null, files: [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe('PI-105 coding product routes', () => {
|
||||
it('dispatches the seven vendor-neutral GET routes with stable DTOs', async () => {
|
||||
const host = productHost();
|
||||
const cases = [
|
||||
['/api/coding/files/status', { files: [expect.objectContaining({ path: 'src/app.ts' })] }],
|
||||
['/api/coding/files/find?query=app&limit=2', { files: [expect.objectContaining({ size: 12 })] }],
|
||||
['/api/coding/files/content?path=src%2Fapp.ts', { file: expect.objectContaining({ content: 'export {};\n' }) }],
|
||||
['/api/coding/search?pattern=export', { matches: [expect.objectContaining({ lineNumber: 1 })] }],
|
||||
['/api/coding/skills?agentId=builder', { skills: [expect.objectContaining({ selected: true })] }],
|
||||
['/api/coding/conversations/conversation%2Fa/commands', {
|
||||
commands: [expect.objectContaining({ name: 'compact', source: 'makelore' })],
|
||||
}],
|
||||
['/api/coding/conversations/conversation%2Fa/changes', {
|
||||
changes: expect.objectContaining({ conversationId: 'conversation/a', runId: 'run-a' }),
|
||||
}],
|
||||
] as const;
|
||||
|
||||
for (const [route, expected] of cases) {
|
||||
const response = await dispatchHostApiRequest(context(host), { path: route });
|
||||
expect(response).toMatchObject({ status: 200, ok: true, json: expected });
|
||||
const serialized = JSON.stringify(response.json);
|
||||
expect(serialized).not.toMatch(/"(todo|share|revert|unrevert)"/);
|
||||
}
|
||||
expect(host.findFiles).toHaveBeenCalledWith('app', 2);
|
||||
expect(host.fileContent).toHaveBeenCalledWith('src/app.ts');
|
||||
expect(host.listSkills).toHaveBeenCalledWith('builder');
|
||||
expect(host.listCommands).toHaveBeenCalledWith('conversation/a');
|
||||
expect(host.getChanges).toHaveBeenCalledWith('conversation/a');
|
||||
});
|
||||
|
||||
it('reports unavailable composition and redacts absolute-path service failures', async () => {
|
||||
expect(await dispatchHostApiRequest(context(), { path: '/api/coding/files/status' }))
|
||||
.toMatchObject({
|
||||
status: 503,
|
||||
json: { success: false, code: 'CODING_PRODUCT_TOOLS_UNAVAILABLE' },
|
||||
});
|
||||
|
||||
const host = productHost();
|
||||
const absolute = path.resolve('private', 'secret.txt');
|
||||
vi.mocked(host.fileContent).mockRejectedValueOnce(
|
||||
Object.assign(new Error(`ENOENT: ${absolute}`), { code: 'ENOENT' }),
|
||||
);
|
||||
const response = await dispatchHostApiRequest(context(host), {
|
||||
path: '/api/coding/files/content?path=missing.txt',
|
||||
});
|
||||
expect(response).toMatchObject({
|
||||
status: 404,
|
||||
json: { success: false, code: 'CODING_FILE_NOT_FOUND', error: 'Project file does not exist' },
|
||||
});
|
||||
expect(JSON.stringify(response.json)).not.toContain(absolute);
|
||||
});
|
||||
|
||||
it('does not claim non-GET methods or unrelated coding routes', async () => {
|
||||
expect(await dispatchHostApiRequest(context(productHost()), {
|
||||
path: '/api/coding/files/status', method: 'POST',
|
||||
headers: { 'content-type': 'application/json' }, body: '{}',
|
||||
})).toMatchObject({ status: 404 });
|
||||
expect(await dispatchHostApiRequest(context(productHost()), {
|
||||
path: '/api/coding/conversations/conversation-a/prompt',
|
||||
})).toMatchObject({ status: 404 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import type { AgentBrowserModule } from '../../electron/agent-browser';
|
||||
import {
|
||||
CodingProductHostError,
|
||||
createCodingProductHost,
|
||||
} from '../../electron/api/coding-product-services';
|
||||
import { CodingAttachmentStore } from '../../electron/coding-projects/attachment-store';
|
||||
import { createCodingConversationStore } from '../../electron/coding-projects/conversation-store';
|
||||
import {
|
||||
createCodingProjectAgent,
|
||||
createCodingProjectMetadata,
|
||||
} from '../../electron/coding-projects/project-config';
|
||||
import { PiProductTools } from '../../electron/coding-runtime/pi/product-tools';
|
||||
|
||||
const roots: string[] = [];
|
||||
const conversationId = '11111111-1111-4111-8111-111111111111';
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
async function temporaryRoot(prefix: string): Promise<string> {
|
||||
const root = await mkdtemp(path.join(tmpdir(), prefix));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
async function configuredProject(): Promise<string> {
|
||||
const root = await temporaryRoot('makelore-pi-products-');
|
||||
await createCodingProjectMetadata(root, { now: '2026-08-23T00:00:00.000Z' });
|
||||
await createCodingProjectAgent(root, {
|
||||
id: 'builder',
|
||||
avatarId: 'avatar-01',
|
||||
roleName: '实现者',
|
||||
name: 'Builder',
|
||||
model: null,
|
||||
modelResolution: 'required',
|
||||
skillIds: ['agent-browser', 'grilling'],
|
||||
responsibility: {
|
||||
mission: 'Implement changes', owns: [], boundaries: [], collaborators: [], principles: [],
|
||||
},
|
||||
}, { now: '2026-08-23T00:00:00.000Z' });
|
||||
await createCodingConversationStore(root, {
|
||||
createId: () => conversationId,
|
||||
now: () => '2026-08-23T00:00:00.000Z',
|
||||
}).create({
|
||||
agentId: 'builder', title: 'PI-105', model: null, modelResolution: 'required',
|
||||
});
|
||||
return root;
|
||||
}
|
||||
|
||||
function productTools(root: string): PiProductTools {
|
||||
return new PiProductTools({
|
||||
browser: {} as AgentBrowserModule,
|
||||
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
|
||||
bundledSkillsDir: path.resolve('resources/coding-skills'),
|
||||
});
|
||||
}
|
||||
|
||||
describe('PI-105 product Host composition', () => {
|
||||
it('projects managed skills and a stable command catalog without raw Pi fields', async () => {
|
||||
const root = await configuredProject();
|
||||
const tools = productTools(root);
|
||||
const host = createCodingProductHost({
|
||||
getActiveProject: async () => ({ id: 'project-a', path: root }),
|
||||
productTools: tools,
|
||||
listPiCommands: async () => ({
|
||||
commands: [
|
||||
{ name: 'custom', description: 'Custom Pi command', token: 'pi-secret' },
|
||||
{ name: 'compact', description: 'Must not shadow Makelore' },
|
||||
{ name: '../unsafe', description: 'Invalid command' },
|
||||
],
|
||||
apiKey: 'raw-provider-secret',
|
||||
}),
|
||||
});
|
||||
|
||||
const skills = await host.listSkills('builder');
|
||||
expect(skills.filter(({ selected }) => selected).map(({ id }) => id)).toEqual([
|
||||
'agent-browser', 'grilling',
|
||||
]);
|
||||
const commands = await host.listCommands(conversationId);
|
||||
expect(commands.slice(0, 5).map(({ source }) => source)).toEqual([
|
||||
'makelore', 'makelore', 'makelore', 'makelore', 'makelore',
|
||||
]);
|
||||
expect(commands).toContainEqual(expect.objectContaining({ name: 'custom', source: 'pi' }));
|
||||
expect(commands).toContainEqual(expect.objectContaining({ name: 'agent-browser', source: 'skill' }));
|
||||
expect(commands.filter(({ name }) => name === 'compact')).toHaveLength(1);
|
||||
const serialized = JSON.stringify({ skills, commands });
|
||||
expect(serialized).not.toContain(root);
|
||||
expect(serialized).not.toContain('pi-secret');
|
||||
expect(serialized).not.toContain('raw-provider-secret');
|
||||
expect(serialized).not.toMatch(/"(todo|share|revert|unrevert)"/);
|
||||
});
|
||||
|
||||
it('reads exact-run changes from the same PiProductTools tracker instance', async () => {
|
||||
const root = await configuredProject();
|
||||
await writeFile(path.join(root, 'notes.txt'), 'baseline\n', 'utf8');
|
||||
const tools = productTools(root);
|
||||
const host = createCodingProductHost({
|
||||
getActiveProject: async () => ({ id: 'project-a', path: root }),
|
||||
productTools: tools,
|
||||
});
|
||||
await tools.beginRun({ conversationId, runId: 'run-a', projectPath: root });
|
||||
await writeFile(path.join(root, 'notes.txt'), 'changed\n', 'utf8');
|
||||
await tools.recordTouchedPaths(conversationId, 'run-a', ['notes.txt']);
|
||||
|
||||
expect(await host.getChanges(conversationId)).toMatchObject({
|
||||
conversationId, runId: 'run-a', git: false,
|
||||
files: [{ path: 'notes.txt', status: 'modified', preview: 'changed\n' }],
|
||||
});
|
||||
expect(JSON.stringify(await host.getChanges(conversationId))).not.toContain(root);
|
||||
});
|
||||
|
||||
it('returns typed project, Agent, and Conversation errors', async () => {
|
||||
const root = await configuredProject();
|
||||
const tools = productTools(root);
|
||||
const unavailable = createCodingProductHost({
|
||||
getActiveProject: async () => null,
|
||||
productTools: tools,
|
||||
});
|
||||
await expect(unavailable.fileStatus()).rejects.toMatchObject<CodingProductHostError>({
|
||||
status: 409, code: 'CODING_ACTIVE_PROJECT_REQUIRED',
|
||||
});
|
||||
|
||||
const host = createCodingProductHost({
|
||||
getActiveProject: async () => ({ id: 'project-a', path: root }),
|
||||
productTools: tools,
|
||||
});
|
||||
await expect(host.listSkills('missing')).rejects.toMatchObject<CodingProductHostError>({
|
||||
status: 404, code: 'CODING_AGENT_NOT_FOUND',
|
||||
});
|
||||
await expect(host.listCommands('22222222-2222-4222-8222-222222222222'))
|
||||
.rejects.toMatchObject<CodingProductHostError>({
|
||||
status: 404, code: 'CODING_CONVERSATION_NOT_FOUND',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const hostApiFetchMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../src/lib/host-api', () => ({
|
||||
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
|
||||
}));
|
||||
|
||||
import {
|
||||
findCodingFiles,
|
||||
getCodingConversationChanges,
|
||||
getCodingConversationCommands,
|
||||
getCodingFileContent,
|
||||
getCodingFileStatus,
|
||||
getCodingSkills,
|
||||
searchCodingText,
|
||||
} from '../../src/lib/coding-product-tools';
|
||||
|
||||
describe('PI-105 Renderer coding product facade', () => {
|
||||
beforeEach(() => {
|
||||
hostApiFetchMock.mockReset().mockResolvedValue({});
|
||||
});
|
||||
|
||||
it('uses only the vendor-neutral coding file and catalog routes', async () => {
|
||||
await getCodingFileStatus();
|
||||
await findCodingFiles('app file', 25);
|
||||
await getCodingFileContent('src/app file.ts');
|
||||
await searchCodingText('hello world');
|
||||
await getCodingSkills('builder/one');
|
||||
await getCodingConversationCommands('conversation/one');
|
||||
await getCodingConversationChanges('conversation/one');
|
||||
|
||||
expect(hostApiFetchMock.mock.calls.map(([route]) => route)).toEqual([
|
||||
'/api/coding/files/status',
|
||||
'/api/coding/files/find?query=app+file&limit=25',
|
||||
'/api/coding/files/content?path=src%2Fapp+file.ts',
|
||||
'/api/coding/search?pattern=hello+world',
|
||||
'/api/coding/skills?agentId=builder%2Fone',
|
||||
'/api/coding/conversations/conversation%2Fone/commands',
|
||||
'/api/coding/conversations/conversation%2Fone/changes',
|
||||
]);
|
||||
expect(JSON.stringify(hostApiFetchMock.mock.calls)).not.toContain('/api/opencode');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { CodingProjectFileService } from '../../electron/coding-projects/project-files';
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
async function temporaryRoot(prefix: string): Promise<string> {
|
||||
const root = await mkdtemp(path.join(tmpdir(), prefix));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
async function git(root: string, ...args: string[]): Promise<void> {
|
||||
await exec('git', ['-C', root, ...args], { windowsHide: true });
|
||||
}
|
||||
|
||||
async function initializeRepository(root: string): Promise<void> {
|
||||
await git(root, 'init');
|
||||
await git(root, 'config', 'user.email', 'pi-files@example.invalid');
|
||||
await git(root, 'config', 'user.name', 'PI Files');
|
||||
await mkdir(path.join(root, 'src'), { recursive: true });
|
||||
await writeFile(path.join(root, 'src', 'app.ts'), 'export const message = "baseline";\n', 'utf8');
|
||||
await writeFile(path.join(root, '.gitignore'), 'ignored.txt\n', 'utf8');
|
||||
await git(root, 'add', '.');
|
||||
await git(root, 'commit', '-m', 'baseline');
|
||||
}
|
||||
|
||||
describe('PI-105 project file service', () => {
|
||||
it('reports Git status and finds tracked or untracked files using project-relative DTOs', async () => {
|
||||
const root = await temporaryRoot('makelore-pi-files-git-');
|
||||
await initializeRepository(root);
|
||||
await writeFile(path.join(root, 'src', 'app.ts'), 'export const message = "changed";\n', 'utf8');
|
||||
await writeFile(path.join(root, 'src', 'app.test.ts'), 'test("changed", () => true);\n', 'utf8');
|
||||
await writeFile(path.join(root, 'ignored.txt'), 'ignored\n', 'utf8');
|
||||
|
||||
const service = new CodingProjectFileService();
|
||||
expect(await service.status(root)).toEqual([
|
||||
expect.objectContaining({ path: 'src/app.ts', name: 'app.ts', status: 'modified' }),
|
||||
expect.objectContaining({ path: 'src/app.test.ts', name: 'app.test.ts', status: 'untracked' }),
|
||||
]);
|
||||
const found = await service.find(root, 'app', 1);
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0]).toMatchObject({ path: 'src/app.ts', name: 'app.ts', type: 'file' });
|
||||
expect(JSON.stringify({ status: await service.status(root), found })).not.toContain(root);
|
||||
expect((await service.find(root, 'ignored')).map(({ path: filePath }) => filePath)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns bounded UTF-8 content and rejects absolute, traversal, and binary reads', async () => {
|
||||
const root = await temporaryRoot('makelore-pi-files-content-');
|
||||
await writeFile(path.join(root, 'notes.txt'), 'hello project\n', 'utf8');
|
||||
await writeFile(path.join(root, 'large.txt'), 'x'.repeat((256 * 1024) + 100), 'utf8');
|
||||
await writeFile(path.join(root, 'large-unicode.txt'), '你'.repeat(90_000), 'utf8');
|
||||
await writeFile(path.join(root, 'binary.bin'), Buffer.from([1, 0, 2]));
|
||||
const service = new CodingProjectFileService();
|
||||
|
||||
expect(await service.content(root, 'notes.txt')).toEqual({
|
||||
path: 'notes.txt', content: 'hello project\n', truncated: false,
|
||||
});
|
||||
const large = await service.content(root, 'large.txt');
|
||||
expect(Buffer.byteLength(large.content, 'utf8')).toBe(256 * 1024);
|
||||
expect(large.truncated).toBe(true);
|
||||
const largeUnicode = await service.content(root, 'large-unicode.txt');
|
||||
expect(Buffer.byteLength(largeUnicode.content, 'utf8')).toBeLessThanOrEqual(256 * 1024);
|
||||
expect(largeUnicode.content).toMatch(/^你+$/u);
|
||||
expect(largeUnicode.truncated).toBe(true);
|
||||
await expect(service.content(root, '../secret.txt')).rejects.toThrow('escapes the active project');
|
||||
await expect(service.content(root, path.resolve(root, 'notes.txt'))).rejects.toThrow('must be relative');
|
||||
await expect(service.content(root, 'binary.bin')).rejects.toThrow('Binary project files');
|
||||
});
|
||||
|
||||
it('searches literal text with bounded relative matches and falls back outside Git', async () => {
|
||||
const root = await temporaryRoot('makelore-pi-files-search-');
|
||||
await mkdir(path.join(root, 'docs'), { recursive: true });
|
||||
await mkdir(path.join(root, 'node_modules', 'private-package'), { recursive: true });
|
||||
await writeFile(path.join(root, 'docs', 'guide.md'), 'First line\nNeedle and needle again\n', 'utf8');
|
||||
await writeFile(path.join(root, 'node_modules', 'private-package', 'secret.txt'), 'needle\n', 'utf8');
|
||||
const service = new CodingProjectFileService({
|
||||
async run() {
|
||||
throw Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT' });
|
||||
},
|
||||
});
|
||||
|
||||
expect((await service.find(root, 'guide')).map(({ path: filePath }) => filePath)).toEqual([
|
||||
'docs/guide.md',
|
||||
]);
|
||||
const matches = await service.search(root, 'needle');
|
||||
expect(matches).toEqual([{
|
||||
path: 'docs/guide.md',
|
||||
name: 'guide.md',
|
||||
lineNumber: 2,
|
||||
lineText: 'Needle and needle again',
|
||||
submatches: [
|
||||
{ text: 'Needle', start: 0, end: 6 },
|
||||
{ text: 'needle', start: 11, end: 17 },
|
||||
],
|
||||
}]);
|
||||
expect(JSON.stringify(matches)).not.toContain(root);
|
||||
expect(JSON.stringify(matches)).not.toContain('private-package');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user