Skip to content

Expose public programmatic Node.js API #32

@gregpriday

Description

@gregpriday

Summary

Expose a clean, typed programmatic API for using CopyTree as a library in Node.js applications, enabling developers to integrate file discovery, transformation, and formatting into their build tools and scripts.

Problem Statement

CopyTree is currently CLI-only with no programmatic API:

No entry point:

// ❌ This doesn't work
import { scan, format } from 'copytree';
import copytree from 'copytree';  // No default export

Current workarounds:

// Ugly: Spawn CLI process
import { exec } from 'child_process';
exec('copytree --format json -o output.json', ...);

// Brittle: Directly import internal modules
import copyCommand from 'copytree/src/commands/copy.js'; // Undocumented

Blocked use cases:

  • Custom build tools (Vite plugins, Webpack loaders)
  • GitHub Actions (programmatic file collection)
  • Documentation generators (introspecting codebases)
  • Testing frameworks (fixture generation)
  • CI/CD pipelines (programmatic output processing)

User Story

As a tool developer
I want a stable programmatic API for CopyTree
So that I can integrate file discovery and transformation into my Node.js applications

Current State

Package structure:

  • Entry point: None (package.json only defines bin)
  • Exports: Only copyCommand() from src/commands/copy.js
  • Internal modules: Not documented for public use
  • TypeScript: No .d.ts type definitions

Key internal modules (undocumented):

Expected Behavior

Public API Surface

// Main functions
import { scan, format, copy } from 'copytree';

// Core classes (for advanced usage)
import { Pipeline, ProfileLoader, TransformerRegistry } from 'copytree';

// Utilities
import { parseProfile, validateConfig } from 'copytree/utils';

// Types (if TypeScript support added)
import type { ScanOptions, FormatOptions, FileResult } from 'copytree';

Primary API: scan()

Discover and filter files without formatting:

import { scan } from 'copytree';

// Simple scan
const files = await scan('/path/to/project');
// Returns: AsyncIterable<FileResult>

// With options
const files = await scan('/path/to/project', {
  profile: 'default',          // Profile name or object
  filter: ['**/*.js'],         // Additional patterns
  exclude: ['node_modules'],   // Exclusions
  respectGitignore: true,      // Use .gitignore
  modified: true,              // Git modified files only
  maxDepth: 3,                 // Directory depth limit
  transform: true,             // Apply transformers
  transformers: ['pdf', 'ai-summary']  // Specific transformers
});

// Iterate results
for await (const file of files) {
  console.log(file.path, file.size, file.content);
}

// Collect all (be careful with memory)
const allFiles = [];
for await (const file of files) {
  allFiles.push(file);
}

Secondary API: format()

Format a list of files into output:

import { format } from 'copytree';

const files = [...]; // From scan() or custom source

// Format to string
const xml = await format(files, { format: 'xml' });
const json = await format(files, { format: 'json' });
const markdown = await format(files, { format: 'markdown' });
const ndjson = await format(files, { format: 'ndjson' });

// Format with options
const output = await format(files, {
  format: 'xml',
  onlyTree: false,
  addLineNumbers: true,
  basePath: '/project',
  instructions: 'Please review this code'
});

Convenience API: copy()

Complete end-to-end operation (equivalent to CLI):

import { copy } from 'copytree';

// Simple copy (returns formatted string)
const result = await copy('/path/to/project');

// With options (matches CLI flags)
const result = await copy('/path/to/project', {
  profile: 'default',
  format: 'json',
  output: './output.json',  // Write to file
  display: true,            // Also log to console
  clipboard: false,         // Don't copy to clipboard
  modified: true,           // Git modified only
  charLimit: 50000,         // Character budget
  // ... all CLI options supported
});

// Result structure
{
  output: '...',           // Formatted string
  files: [...],            // File list
  stats: {
    totalFiles: 123,
    duration: 523,
    totalSize: 456789,
    secretsGuard: {...}    // If enabled
  }
}

Advanced API: Classes

For complex use cases:

import { Pipeline, ProfileLoader, TransformerRegistry } from 'copytree';

// Custom pipeline
const pipeline = new Pipeline({ continueOnError: true });
pipeline.through([
  new FileDiscoveryStage({ basePath: '/project' }),
  new ProfileFilterStage({ profile }),
  new TransformStage({ transformers: registry }),
  new OutputFormattingStage({ format: 'json' })
]);

const result = await pipeline.process({ basePath: '/project' });

// Load custom profile
const loader = new ProfileLoader();
const profile = await loader.load('my-profile');

// Create transformer registry
const registry = await TransformerRegistry.createDefault();
const transformers = registry.getAll();

Affected Components

  • New file: src/index.js - Main API exports
  • New file: types/index.d.ts - TypeScript definitions
  • package.json - Add main, exports, types fields
  • src/commands/copy.js - Refactor for programmatic use
  • New file: docs/api/programmatic-usage.md - API documentation

Implementation Approach

1. Create src/index.js

// Main API
export { scan } from './api/scan.js';
export { format } from './api/format.js';
export { copy } from './api/copy.js';

// Core classes (advanced)
export { default as Pipeline } from './pipeline/Pipeline.js';
export { default as ProfileLoader } from './profiles/ProfileLoader.js';
export { default as TransformerRegistry } from './transforms/TransformerRegistry.js';

// Utilities
export { parseProfile, validateProfile } from './profiles/ProfileLoader.js';
export { config, validateConfig } from './config/ConfigManager.js';

// Errors
export * from './utils/errors.js';

// Default export (convenience)
export { copy as default } from './api/copy.js';

2. Create src/api/scan.js

export async function* scan(basePath, options = {}) {
  const pipeline = new Pipeline();
  pipeline.through([
    new FileDiscoveryStage({ basePath, ...options }),
    new ProfileFilterStage({ ...options }),
    ...(options.transform ? [new TransformStage({ ...options })] : [])
  ]);
  
  const result = await pipeline.process({ basePath, options });
  
  for (const file of result.files) {
    yield file;
  }
}

3. Update package.json

{
  "main": "./src/index.js",
  "types": "./types/index.d.ts",
  "exports": {
    ".": {
      "import": "./src/index.js",
      "types": "./types/index.d.ts"
    },
    "./utils": {
      "import": "./src/api/utils.js",
      "types": "./types/utils.d.ts"
    }
  },
  "files": [
    "src/",
    "bin/",
    "types/",
    "profiles/",
    "config/"
  ]
}

4. Create TypeScript Definitions

// types/index.d.ts
export interface FileResult {
  path: string;
  absolutePath: string;
  size: number;
  modified: Date;
  content?: string;
  isBinary: boolean;
  encoding?: string;
}

export interface ScanOptions {
  profile?: string | Profile;
  filter?: string[];
  exclude?: string[];
  respectGitignore?: boolean;
  modified?: boolean;
  changed?: string;
  maxDepth?: number;
  transform?: boolean;
  transformers?: string[];
}

export interface FormatOptions {
  format?: 'xml' | 'json' | 'markdown' | 'tree' | 'ndjson' | 'sarif';
  onlyTree?: boolean;
  addLineNumbers?: boolean;
  basePath?: string;
  instructions?: string;
}

export interface CopyResult {
  output: string;
  files: FileResult[];
  stats: {
    totalFiles: number;
    duration: number;
    totalSize: number;
    secretsGuard?: object;
  };
}

export function scan(basePath: string, options?: ScanOptions): AsyncIterable<FileResult>;
export function format(files: FileResult[], options?: FormatOptions): Promise<string>;
export function copy(basePath: string, options?: ScanOptions & FormatOptions): Promise<CopyResult>;

// Classes
export class Pipeline { /* ... */ }
export class ProfileLoader { /* ... */ }
export class TransformerRegistry { /* ... */ }

Tasks

  • Create src/index.js with public API exports
  • Create src/api/scan.js implementing async iteration
  • Create src/api/format.js implementing formatting
  • Create src/api/copy.js (wrapper around copyCommand)
  • Refactor src/commands/copy.js to support both CLI and programmatic use
  • Update package.json with main, exports, types fields
  • Create types/index.d.ts with TypeScript definitions
  • Add JSDoc comments to all public APIs
  • Write unit tests for scan(), format(), copy()
  • Write integration tests for programmatic usage
  • Create docs/api/programmatic-usage.md with examples
  • Add examples to examples/ directory:
    • examples/scan-files.js - Basic scanning
    • examples/custom-pipeline.js - Advanced pipeline
    • examples/build-tool-integration.js - Vite/Webpack example
  • Update README with programmatic usage section
  • Create migration guide for users importing internal modules

Acceptance Criteria

  • import { scan } from 'copytree' works without errors
  • scan() returns AsyncIterable that can be used with for await
  • format() accepts file list and returns formatted string
  • copy() supports all CLI options programmatically
  • TypeScript definitions provide autocomplete in IDEs
  • JSDoc provides inline documentation in editors
  • All public APIs have JSDoc comments
  • Breaking changes to internal APIs don't affect public API
  • Tests achieve 80% coverage for API surface
  • Documentation includes at least 5 practical examples
  • Semver versioning promise documented (e.g., minor for new features, major for breaking)

Additional Context

Semver commitment:

  • Major (1.0.0): Breaking changes to public API
  • Minor (0.x.0): New features, backward compatible
  • Patch (0.0.x): Bug fixes, no API changes

Competitor examples:

  • fast-glob: import fg from 'fast-glob'; const files = await fg('**/*.js');
  • globby: import { globby } from 'globby'; const paths = await globby('**/*.js');
  • eslint: import { ESLint } from 'eslint'; const eslint = new ESLint();

Usage examples:

Vite plugin:

import { scan, format } from 'copytree';

export function copytreePlugin() {
  return {
    name: 'copytree',
    async buildStart() {
      const files = await scan('./src', { profile: 'typescript' });
      const xml = await format(files, { format: 'xml' });
      this.emitFile({ type: 'asset', fileName: 'structure.xml', source: xml });
    }
  };
}

GitHub Action:

import { copy } from 'copytree';
import * as core from '@actions/core';

const result = await copy(process.env.GITHUB_WORKSPACE, {
  format: 'sarif',
  output: 'results.sarif'
});

core.setOutput('file-count', result.stats.totalFiles);

Related issues:

Metadata

Metadata

Assignees

Labels

priority-2 ⚙️Planned/normal priority. Product work or quality improvements to schedule this cycle.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions