mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 10:05:14 +00:00
- Add PackageSource type for npm/git sources with optional filtering - Migrate npm:/git: sources from extensions to packages array - Add getPackages(), setPackages(), setProjectPackages() methods - Update package-manager to resolve from packages array - Support selective loading: extensions, skills, prompts, themes per package - Update pi list to show packages - Add migration tests for settings closes #645
759 lines
24 KiB
TypeScript
759 lines
24 KiB
TypeScript
import { spawn, spawnSync } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
import { homedir, tmpdir } from "node:os";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { CONFIG_DIR_NAME } from "../config.js";
|
|
import type { PackageSource, SettingsManager } from "./settings-manager.js";
|
|
|
|
export interface ResolvedPaths {
|
|
extensions: string[];
|
|
skills: string[];
|
|
prompts: string[];
|
|
themes: string[];
|
|
}
|
|
|
|
export type MissingSourceAction = "install" | "skip" | "error";
|
|
|
|
export interface ProgressEvent {
|
|
type: "start" | "progress" | "complete" | "error";
|
|
action: "install" | "remove" | "update" | "clone" | "pull";
|
|
source: string;
|
|
message?: string;
|
|
}
|
|
|
|
export type ProgressCallback = (event: ProgressEvent) => void;
|
|
|
|
export interface PackageManager {
|
|
resolve(onMissing?: (source: string) => Promise<MissingSourceAction>): Promise<ResolvedPaths>;
|
|
install(source: string, options?: { local?: boolean }): Promise<void>;
|
|
remove(source: string, options?: { local?: boolean }): Promise<void>;
|
|
update(source?: string): Promise<void>;
|
|
resolveExtensionSources(
|
|
sources: string[],
|
|
options?: { local?: boolean; temporary?: boolean },
|
|
): Promise<ResolvedPaths>;
|
|
setProgressCallback(callback: ProgressCallback | undefined): void;
|
|
}
|
|
|
|
interface PackageManagerOptions {
|
|
cwd: string;
|
|
agentDir: string;
|
|
settingsManager: SettingsManager;
|
|
}
|
|
|
|
type SourceScope = "global" | "project" | "temporary";
|
|
|
|
type NpmSource = {
|
|
type: "npm";
|
|
spec: string;
|
|
name: string;
|
|
pinned: boolean;
|
|
};
|
|
|
|
type GitSource = {
|
|
type: "git";
|
|
repo: string;
|
|
host: string;
|
|
path: string;
|
|
ref?: string;
|
|
pinned: boolean;
|
|
};
|
|
|
|
type LocalSource = {
|
|
type: "local";
|
|
path: string;
|
|
};
|
|
|
|
type ParsedSource = NpmSource | GitSource | LocalSource;
|
|
|
|
interface PiManifest {
|
|
extensions?: string[];
|
|
skills?: string[];
|
|
prompts?: string[];
|
|
themes?: string[];
|
|
}
|
|
|
|
interface ResourceAccumulator {
|
|
extensions: Set<string>;
|
|
skills: Set<string>;
|
|
prompts: Set<string>;
|
|
themes: Set<string>;
|
|
}
|
|
|
|
interface PackageFilter {
|
|
extensions?: string[];
|
|
skills?: string[];
|
|
prompts?: string[];
|
|
themes?: string[];
|
|
}
|
|
|
|
export class DefaultPackageManager implements PackageManager {
|
|
private cwd: string;
|
|
private agentDir: string;
|
|
private settingsManager: SettingsManager;
|
|
private globalNpmRoot: string | undefined;
|
|
private progressCallback: ProgressCallback | undefined;
|
|
|
|
constructor(options: PackageManagerOptions) {
|
|
this.cwd = options.cwd;
|
|
this.agentDir = options.agentDir;
|
|
this.settingsManager = options.settingsManager;
|
|
this.ensureGitIgnoreDirs();
|
|
}
|
|
|
|
setProgressCallback(callback: ProgressCallback | undefined): void {
|
|
this.progressCallback = callback;
|
|
}
|
|
|
|
private emitProgress(event: ProgressEvent): void {
|
|
this.progressCallback?.(event);
|
|
}
|
|
|
|
async resolve(onMissing?: (source: string) => Promise<MissingSourceAction>): Promise<ResolvedPaths> {
|
|
const accumulator = this.createAccumulator();
|
|
const globalSettings = this.settingsManager.getGlobalSettings();
|
|
const projectSettings = this.settingsManager.getProjectSettings();
|
|
|
|
// Resolve packages (npm/git sources)
|
|
const packageSources: Array<{ pkg: PackageSource; scope: SourceScope }> = [];
|
|
for (const pkg of globalSettings.packages ?? []) {
|
|
packageSources.push({ pkg, scope: "global" });
|
|
}
|
|
for (const pkg of projectSettings.packages ?? []) {
|
|
packageSources.push({ pkg, scope: "project" });
|
|
}
|
|
await this.resolvePackageSources(packageSources, accumulator, onMissing);
|
|
|
|
// Resolve local extensions
|
|
for (const ext of globalSettings.extensions ?? []) {
|
|
this.resolveLocalPath(ext, accumulator.extensions);
|
|
}
|
|
for (const ext of projectSettings.extensions ?? []) {
|
|
this.resolveLocalPath(ext, accumulator.extensions);
|
|
}
|
|
|
|
// Resolve local skills
|
|
for (const skill of globalSettings.skills ?? []) {
|
|
this.addPath(accumulator.skills, this.resolvePath(skill));
|
|
}
|
|
for (const skill of projectSettings.skills ?? []) {
|
|
this.addPath(accumulator.skills, this.resolvePath(skill));
|
|
}
|
|
|
|
// Resolve local prompts
|
|
for (const prompt of globalSettings.prompts ?? []) {
|
|
this.addPath(accumulator.prompts, this.resolvePath(prompt));
|
|
}
|
|
for (const prompt of projectSettings.prompts ?? []) {
|
|
this.addPath(accumulator.prompts, this.resolvePath(prompt));
|
|
}
|
|
|
|
// Resolve local themes
|
|
for (const theme of globalSettings.themes ?? []) {
|
|
this.addPath(accumulator.themes, this.resolvePath(theme));
|
|
}
|
|
for (const theme of projectSettings.themes ?? []) {
|
|
this.addPath(accumulator.themes, this.resolvePath(theme));
|
|
}
|
|
|
|
return this.toResolvedPaths(accumulator);
|
|
}
|
|
|
|
private resolveLocalPath(path: string, target: Set<string>): void {
|
|
const resolved = this.resolvePath(path);
|
|
if (existsSync(resolved)) {
|
|
this.addPath(target, resolved);
|
|
}
|
|
}
|
|
|
|
async resolveExtensionSources(
|
|
sources: string[],
|
|
options?: { local?: boolean; temporary?: boolean },
|
|
): Promise<ResolvedPaths> {
|
|
const accumulator = this.createAccumulator();
|
|
const scope: SourceScope = options?.temporary ? "temporary" : options?.local ? "project" : "global";
|
|
const packageSources = sources.map((source) => ({ pkg: source as PackageSource, scope }));
|
|
await this.resolvePackageSources(packageSources, accumulator);
|
|
return this.toResolvedPaths(accumulator);
|
|
}
|
|
|
|
async install(source: string, options?: { local?: boolean }): Promise<void> {
|
|
const parsed = this.parseSource(source);
|
|
const scope: SourceScope = options?.local ? "project" : "global";
|
|
this.emitProgress({ type: "start", action: "install", source, message: `Installing ${source}...` });
|
|
try {
|
|
if (parsed.type === "npm") {
|
|
await this.installNpm(parsed, scope, false);
|
|
this.emitProgress({ type: "complete", action: "install", source });
|
|
return;
|
|
}
|
|
if (parsed.type === "git") {
|
|
await this.installGit(parsed, scope);
|
|
this.emitProgress({ type: "complete", action: "install", source });
|
|
return;
|
|
}
|
|
throw new Error(`Unsupported install source: ${source}`);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
this.emitProgress({ type: "error", action: "install", source, message });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async remove(source: string, options?: { local?: boolean }): Promise<void> {
|
|
const parsed = this.parseSource(source);
|
|
const scope: SourceScope = options?.local ? "project" : "global";
|
|
this.emitProgress({ type: "start", action: "remove", source, message: `Removing ${source}...` });
|
|
try {
|
|
if (parsed.type === "npm") {
|
|
await this.uninstallNpm(parsed, scope);
|
|
this.emitProgress({ type: "complete", action: "remove", source });
|
|
return;
|
|
}
|
|
if (parsed.type === "git") {
|
|
await this.removeGit(parsed, scope);
|
|
this.emitProgress({ type: "complete", action: "remove", source });
|
|
return;
|
|
}
|
|
throw new Error(`Unsupported remove source: ${source}`);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
this.emitProgress({ type: "error", action: "remove", source, message });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async update(source?: string): Promise<void> {
|
|
if (source) {
|
|
await this.updateSourceForScope(source, "global");
|
|
await this.updateSourceForScope(source, "project");
|
|
return;
|
|
}
|
|
|
|
const globalSettings = this.settingsManager.getGlobalSettings();
|
|
const projectSettings = this.settingsManager.getProjectSettings();
|
|
for (const extension of globalSettings.extensions ?? []) {
|
|
await this.updateSourceForScope(extension, "global");
|
|
}
|
|
for (const extension of projectSettings.extensions ?? []) {
|
|
await this.updateSourceForScope(extension, "project");
|
|
}
|
|
}
|
|
|
|
private async updateSourceForScope(source: string, scope: SourceScope): Promise<void> {
|
|
const parsed = this.parseSource(source);
|
|
if (parsed.type === "npm") {
|
|
if (parsed.pinned) return;
|
|
this.emitProgress({ type: "start", action: "update", source, message: `Updating ${source}...` });
|
|
try {
|
|
await this.installNpm(parsed, scope, false);
|
|
this.emitProgress({ type: "complete", action: "update", source });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
this.emitProgress({ type: "error", action: "update", source, message });
|
|
throw error;
|
|
}
|
|
return;
|
|
}
|
|
if (parsed.type === "git") {
|
|
if (parsed.pinned) return;
|
|
this.emitProgress({ type: "start", action: "update", source, message: `Updating ${source}...` });
|
|
try {
|
|
await this.updateGit(parsed, scope);
|
|
this.emitProgress({ type: "complete", action: "update", source });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
this.emitProgress({ type: "error", action: "update", source, message });
|
|
throw error;
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
private async resolvePackageSources(
|
|
sources: Array<{ pkg: PackageSource; scope: SourceScope }>,
|
|
accumulator: ResourceAccumulator,
|
|
onMissing?: (source: string) => Promise<MissingSourceAction>,
|
|
): Promise<void> {
|
|
for (const { pkg, scope } of sources) {
|
|
const sourceStr = typeof pkg === "string" ? pkg : pkg.source;
|
|
const filter = typeof pkg === "object" ? pkg : undefined;
|
|
const parsed = this.parseSource(sourceStr);
|
|
|
|
if (parsed.type === "local") {
|
|
this.resolveLocalExtensionSource(parsed, accumulator);
|
|
continue;
|
|
}
|
|
|
|
const installMissing = async (): Promise<boolean> => {
|
|
if (!onMissing) {
|
|
await this.installParsedSource(parsed, scope);
|
|
return true;
|
|
}
|
|
const action = await onMissing(sourceStr);
|
|
if (action === "skip") return false;
|
|
if (action === "error") throw new Error(`Missing source: ${sourceStr}`);
|
|
await this.installParsedSource(parsed, scope);
|
|
return true;
|
|
};
|
|
|
|
if (parsed.type === "npm") {
|
|
const installedPath = this.getNpmInstallPath(parsed, scope);
|
|
if (!existsSync(installedPath)) {
|
|
const installed = await installMissing();
|
|
if (!installed) continue;
|
|
}
|
|
this.collectPackageResources(installedPath, accumulator, filter);
|
|
continue;
|
|
}
|
|
|
|
if (parsed.type === "git") {
|
|
const installedPath = this.getGitInstallPath(parsed, scope);
|
|
if (!existsSync(installedPath)) {
|
|
const installed = await installMissing();
|
|
if (!installed) continue;
|
|
}
|
|
this.collectPackageResources(installedPath, accumulator, filter);
|
|
}
|
|
}
|
|
}
|
|
|
|
private resolveLocalExtensionSource(source: LocalSource, accumulator: ResourceAccumulator): void {
|
|
const resolved = this.resolvePath(source.path);
|
|
if (!existsSync(resolved)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const stats = statSync(resolved);
|
|
if (stats.isFile()) {
|
|
this.addPath(accumulator.extensions, resolved);
|
|
return;
|
|
}
|
|
if (stats.isDirectory()) {
|
|
const resources = this.collectPackageResources(resolved, accumulator);
|
|
if (!resources) {
|
|
this.addPath(accumulator.extensions, resolved);
|
|
}
|
|
}
|
|
} catch {
|
|
return;
|
|
}
|
|
}
|
|
|
|
private async installParsedSource(parsed: ParsedSource, scope: SourceScope): Promise<void> {
|
|
if (parsed.type === "npm") {
|
|
await this.installNpm(parsed, scope, scope === "temporary");
|
|
return;
|
|
}
|
|
if (parsed.type === "git") {
|
|
await this.installGit(parsed, scope);
|
|
return;
|
|
}
|
|
}
|
|
|
|
private parseSource(source: string): ParsedSource {
|
|
if (source.startsWith("npm:")) {
|
|
const spec = source.slice("npm:".length).trim();
|
|
const { name, version } = this.parseNpmSpec(spec);
|
|
return {
|
|
type: "npm",
|
|
spec,
|
|
name,
|
|
pinned: Boolean(version),
|
|
};
|
|
}
|
|
|
|
// Accept git: prefix or raw URLs (https://github.com/..., github.com/...)
|
|
if (source.startsWith("git:") || this.looksLikeGitUrl(source)) {
|
|
const repoSpec = source.startsWith("git:") ? source.slice("git:".length).trim() : source;
|
|
const [repo, ref] = repoSpec.split("@");
|
|
const normalized = repo.replace(/^https?:\/\//, "").replace(/\.git$/, "");
|
|
const parts = normalized.split("/");
|
|
const host = parts.shift() ?? "";
|
|
const repoPath = parts.join("/");
|
|
return {
|
|
type: "git",
|
|
repo: normalized,
|
|
host,
|
|
path: repoPath,
|
|
ref,
|
|
pinned: Boolean(ref),
|
|
};
|
|
}
|
|
|
|
return { type: "local", path: source };
|
|
}
|
|
|
|
private looksLikeGitUrl(source: string): boolean {
|
|
// Match URLs like https://github.com/..., github.com/..., gitlab.com/...
|
|
const gitHosts = ["github.com", "gitlab.com", "bitbucket.org", "codeberg.org"];
|
|
const normalized = source.replace(/^https?:\/\//, "");
|
|
return gitHosts.some((host) => normalized.startsWith(`${host}/`));
|
|
}
|
|
|
|
private parseNpmSpec(spec: string): { name: string; version?: string } {
|
|
const match = spec.match(/^(@?[^@]+(?:\/[^@]+)?)(?:@(.+))?$/);
|
|
if (!match) {
|
|
return { name: spec };
|
|
}
|
|
const name = match[1] ?? spec;
|
|
const version = match[2];
|
|
return { name, version };
|
|
}
|
|
|
|
private async installNpm(source: NpmSource, scope: SourceScope, temporary: boolean): Promise<void> {
|
|
if (scope === "global" && !temporary) {
|
|
await this.runCommand("npm", ["install", "-g", source.spec]);
|
|
return;
|
|
}
|
|
const installRoot = this.getNpmInstallRoot(scope, temporary);
|
|
this.ensureNpmProject(installRoot);
|
|
await this.runCommand("npm", ["install", source.spec, "--prefix", installRoot]);
|
|
}
|
|
|
|
private async uninstallNpm(source: NpmSource, scope: SourceScope): Promise<void> {
|
|
if (scope === "global") {
|
|
await this.runCommand("npm", ["uninstall", "-g", source.name]);
|
|
return;
|
|
}
|
|
const installRoot = this.getNpmInstallRoot(scope, false);
|
|
if (!existsSync(installRoot)) {
|
|
return;
|
|
}
|
|
await this.runCommand("npm", ["uninstall", source.name, "--prefix", installRoot]);
|
|
}
|
|
|
|
private async installGit(source: GitSource, scope: SourceScope): Promise<void> {
|
|
const targetDir = this.getGitInstallPath(source, scope);
|
|
if (existsSync(targetDir)) {
|
|
return;
|
|
}
|
|
mkdirSync(dirname(targetDir), { recursive: true });
|
|
const cloneUrl = source.repo.startsWith("http") ? source.repo : `https://${source.repo}`;
|
|
await this.runCommand("git", ["clone", cloneUrl, targetDir]);
|
|
if (source.ref) {
|
|
await this.runCommand("git", ["checkout", source.ref], { cwd: targetDir });
|
|
}
|
|
// Install npm dependencies if package.json exists
|
|
const packageJsonPath = join(targetDir, "package.json");
|
|
if (existsSync(packageJsonPath)) {
|
|
await this.runCommand("npm", ["install"], { cwd: targetDir });
|
|
}
|
|
}
|
|
|
|
private async updateGit(source: GitSource, scope: SourceScope): Promise<void> {
|
|
const targetDir = this.getGitInstallPath(source, scope);
|
|
if (!existsSync(targetDir)) {
|
|
await this.installGit(source, scope);
|
|
return;
|
|
}
|
|
await this.runCommand("git", ["pull"], { cwd: targetDir });
|
|
// Reinstall npm dependencies if package.json exists (in case deps changed)
|
|
const packageJsonPath = join(targetDir, "package.json");
|
|
if (existsSync(packageJsonPath)) {
|
|
await this.runCommand("npm", ["install"], { cwd: targetDir });
|
|
}
|
|
}
|
|
|
|
private async removeGit(source: GitSource, scope: SourceScope): Promise<void> {
|
|
const targetDir = this.getGitInstallPath(source, scope);
|
|
if (!existsSync(targetDir)) return;
|
|
rmSync(targetDir, { recursive: true, force: true });
|
|
}
|
|
|
|
private ensureNpmProject(installRoot: string): void {
|
|
if (!existsSync(installRoot)) {
|
|
mkdirSync(installRoot, { recursive: true });
|
|
}
|
|
this.ensureGitIgnore(installRoot);
|
|
const packageJsonPath = join(installRoot, "package.json");
|
|
if (!existsSync(packageJsonPath)) {
|
|
const pkgJson = { name: "pi-extensions", private: true };
|
|
writeFileSync(packageJsonPath, JSON.stringify(pkgJson, null, 2), "utf-8");
|
|
}
|
|
}
|
|
|
|
private ensureGitIgnoreDirs(): void {
|
|
this.ensureGitIgnore(join(this.agentDir, "git"));
|
|
this.ensureGitIgnore(join(this.agentDir, "npm"));
|
|
this.ensureGitIgnore(join(this.cwd, CONFIG_DIR_NAME, "git"));
|
|
this.ensureGitIgnore(join(this.cwd, CONFIG_DIR_NAME, "npm"));
|
|
}
|
|
|
|
private ensureGitIgnore(dir: string): void {
|
|
if (!existsSync(dir)) {
|
|
mkdirSync(dir, { recursive: true });
|
|
}
|
|
const ignorePath = join(dir, ".gitignore");
|
|
if (!existsSync(ignorePath)) {
|
|
writeFileSync(ignorePath, "*\n!.gitignore\n", "utf-8");
|
|
}
|
|
}
|
|
|
|
private getNpmInstallRoot(scope: SourceScope, temporary: boolean): string {
|
|
if (temporary) {
|
|
return this.getTemporaryDir("npm");
|
|
}
|
|
if (scope === "project") {
|
|
return join(this.cwd, CONFIG_DIR_NAME, "npm");
|
|
}
|
|
return join(this.getGlobalNpmRoot(), "..");
|
|
}
|
|
|
|
private getGlobalNpmRoot(): string {
|
|
if (this.globalNpmRoot) {
|
|
return this.globalNpmRoot;
|
|
}
|
|
const result = this.runCommandSync("npm", ["root", "-g"]);
|
|
this.globalNpmRoot = result.trim();
|
|
return this.globalNpmRoot;
|
|
}
|
|
|
|
private getNpmInstallPath(source: NpmSource, scope: SourceScope): string {
|
|
if (scope === "temporary") {
|
|
return join(this.getTemporaryDir("npm"), "node_modules", source.name);
|
|
}
|
|
if (scope === "project") {
|
|
return join(this.cwd, CONFIG_DIR_NAME, "npm", "node_modules", source.name);
|
|
}
|
|
return join(this.getGlobalNpmRoot(), source.name);
|
|
}
|
|
|
|
private getGitInstallPath(source: GitSource, scope: SourceScope): string {
|
|
if (scope === "temporary") {
|
|
return this.getTemporaryDir(`git-${source.host}`, source.path);
|
|
}
|
|
if (scope === "project") {
|
|
return join(this.cwd, CONFIG_DIR_NAME, "git", source.host, source.path);
|
|
}
|
|
return join(this.agentDir, "git", source.host, source.path);
|
|
}
|
|
|
|
private getTemporaryDir(prefix: string, suffix?: string): string {
|
|
const hash = createHash("sha256")
|
|
.update(`${prefix}-${suffix ?? ""}`)
|
|
.digest("hex")
|
|
.slice(0, 8);
|
|
return join(tmpdir(), "pi-extensions", prefix, hash, suffix ?? "");
|
|
}
|
|
|
|
private resolvePath(input: string): string {
|
|
const trimmed = input.trim();
|
|
if (trimmed === "~") return homedir();
|
|
if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2));
|
|
if (trimmed.startsWith("~")) return join(homedir(), trimmed.slice(1));
|
|
return resolve(this.cwd, trimmed);
|
|
}
|
|
|
|
private collectPackageResources(
|
|
packageRoot: string,
|
|
accumulator: ResourceAccumulator,
|
|
filter?: PackageFilter,
|
|
): boolean {
|
|
// If filter is provided, use it to selectively load resources
|
|
if (filter) {
|
|
// Empty array means "load none", undefined means "load all"
|
|
if (filter.extensions !== undefined) {
|
|
for (const entry of filter.extensions) {
|
|
const resolved = resolve(packageRoot, entry);
|
|
if (existsSync(resolved)) {
|
|
this.addPath(accumulator.extensions, resolved);
|
|
}
|
|
}
|
|
} else {
|
|
this.collectDefaultExtensions(packageRoot, accumulator);
|
|
}
|
|
|
|
if (filter.skills !== undefined) {
|
|
for (const entry of filter.skills) {
|
|
const resolved = resolve(packageRoot, entry);
|
|
if (existsSync(resolved)) {
|
|
this.addPath(accumulator.skills, resolved);
|
|
}
|
|
}
|
|
} else {
|
|
this.collectDefaultSkills(packageRoot, accumulator);
|
|
}
|
|
|
|
if (filter.prompts !== undefined) {
|
|
for (const entry of filter.prompts) {
|
|
const resolved = resolve(packageRoot, entry);
|
|
if (existsSync(resolved)) {
|
|
this.addPath(accumulator.prompts, resolved);
|
|
}
|
|
}
|
|
} else {
|
|
this.collectDefaultPrompts(packageRoot, accumulator);
|
|
}
|
|
|
|
if (filter.themes !== undefined) {
|
|
for (const entry of filter.themes) {
|
|
const resolved = resolve(packageRoot, entry);
|
|
if (existsSync(resolved)) {
|
|
this.addPath(accumulator.themes, resolved);
|
|
}
|
|
}
|
|
} else {
|
|
this.collectDefaultThemes(packageRoot, accumulator);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// No filter: load everything based on manifest or directory structure
|
|
const manifest = this.readPiManifest(packageRoot);
|
|
if (manifest) {
|
|
this.addManifestEntries(manifest.extensions, packageRoot, accumulator.extensions);
|
|
this.addManifestEntries(manifest.skills, packageRoot, accumulator.skills);
|
|
this.addManifestEntries(manifest.prompts, packageRoot, accumulator.prompts);
|
|
this.addManifestEntries(manifest.themes, packageRoot, accumulator.themes);
|
|
return true;
|
|
}
|
|
|
|
const extensionsDir = join(packageRoot, "extensions");
|
|
const skillsDir = join(packageRoot, "skills");
|
|
const promptsDir = join(packageRoot, "prompts");
|
|
const themesDir = join(packageRoot, "themes");
|
|
|
|
const hasAnyDir =
|
|
existsSync(extensionsDir) || existsSync(skillsDir) || existsSync(promptsDir) || existsSync(themesDir);
|
|
if (!hasAnyDir) {
|
|
return false;
|
|
}
|
|
|
|
if (existsSync(extensionsDir)) {
|
|
this.addPath(accumulator.extensions, extensionsDir);
|
|
}
|
|
if (existsSync(skillsDir)) {
|
|
this.addPath(accumulator.skills, skillsDir);
|
|
}
|
|
if (existsSync(promptsDir)) {
|
|
this.addPath(accumulator.prompts, promptsDir);
|
|
}
|
|
if (existsSync(themesDir)) {
|
|
this.addPath(accumulator.themes, themesDir);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private collectDefaultExtensions(packageRoot: string, accumulator: ResourceAccumulator): void {
|
|
const manifest = this.readPiManifest(packageRoot);
|
|
if (manifest?.extensions) {
|
|
this.addManifestEntries(manifest.extensions, packageRoot, accumulator.extensions);
|
|
return;
|
|
}
|
|
const extensionsDir = join(packageRoot, "extensions");
|
|
if (existsSync(extensionsDir)) {
|
|
this.addPath(accumulator.extensions, extensionsDir);
|
|
}
|
|
}
|
|
|
|
private collectDefaultSkills(packageRoot: string, accumulator: ResourceAccumulator): void {
|
|
const manifest = this.readPiManifest(packageRoot);
|
|
if (manifest?.skills) {
|
|
this.addManifestEntries(manifest.skills, packageRoot, accumulator.skills);
|
|
return;
|
|
}
|
|
const skillsDir = join(packageRoot, "skills");
|
|
if (existsSync(skillsDir)) {
|
|
this.addPath(accumulator.skills, skillsDir);
|
|
}
|
|
}
|
|
|
|
private collectDefaultPrompts(packageRoot: string, accumulator: ResourceAccumulator): void {
|
|
const manifest = this.readPiManifest(packageRoot);
|
|
if (manifest?.prompts) {
|
|
this.addManifestEntries(manifest.prompts, packageRoot, accumulator.prompts);
|
|
return;
|
|
}
|
|
const promptsDir = join(packageRoot, "prompts");
|
|
if (existsSync(promptsDir)) {
|
|
this.addPath(accumulator.prompts, promptsDir);
|
|
}
|
|
}
|
|
|
|
private collectDefaultThemes(packageRoot: string, accumulator: ResourceAccumulator): void {
|
|
const manifest = this.readPiManifest(packageRoot);
|
|
if (manifest?.themes) {
|
|
this.addManifestEntries(manifest.themes, packageRoot, accumulator.themes);
|
|
return;
|
|
}
|
|
const themesDir = join(packageRoot, "themes");
|
|
if (existsSync(themesDir)) {
|
|
this.addPath(accumulator.themes, themesDir);
|
|
}
|
|
}
|
|
|
|
private readPiManifest(packageRoot: string): PiManifest | null {
|
|
const packageJsonPath = join(packageRoot, "package.json");
|
|
if (!existsSync(packageJsonPath)) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const content = readFileSync(packageJsonPath, "utf-8");
|
|
const pkg = JSON.parse(content) as { pi?: PiManifest };
|
|
return pkg.pi ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private addManifestEntries(entries: string[] | undefined, root: string, target: Set<string>): void {
|
|
if (!entries) return;
|
|
for (const entry of entries) {
|
|
const resolved = resolve(root, entry);
|
|
this.addPath(target, resolved);
|
|
}
|
|
}
|
|
|
|
private addPath(set: Set<string>, value: string): void {
|
|
if (!value) return;
|
|
set.add(value);
|
|
}
|
|
|
|
private createAccumulator(): ResourceAccumulator {
|
|
return {
|
|
extensions: new Set<string>(),
|
|
skills: new Set<string>(),
|
|
prompts: new Set<string>(),
|
|
themes: new Set<string>(),
|
|
};
|
|
}
|
|
|
|
private toResolvedPaths(accumulator: ResourceAccumulator): ResolvedPaths {
|
|
return {
|
|
extensions: Array.from(accumulator.extensions),
|
|
skills: Array.from(accumulator.skills),
|
|
prompts: Array.from(accumulator.prompts),
|
|
themes: Array.from(accumulator.themes),
|
|
};
|
|
}
|
|
|
|
private runCommand(command: string, args: string[], options?: { cwd?: string }): Promise<void> {
|
|
return new Promise((resolvePromise, reject) => {
|
|
const child = spawn(command, args, {
|
|
cwd: options?.cwd,
|
|
stdio: "inherit",
|
|
});
|
|
child.on("error", reject);
|
|
child.on("exit", (code) => {
|
|
if (code === 0) {
|
|
resolvePromise();
|
|
} else {
|
|
reject(new Error(`${command} ${args.join(" ")} failed with code ${code}`));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
private runCommandSync(command: string, args: string[]): string {
|
|
const result = spawnSync(command, args, { stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" });
|
|
if (result.status !== 0) {
|
|
throw new Error(`Failed to run ${command} ${args.join(" ")}: ${result.stderr || result.stdout}`);
|
|
}
|
|
return (result.stdout || result.stderr || "").trim();
|
|
}
|
|
}
|