Files
Sankofa/scripts/scan-projects.ts
defiQUG 33d50fb91e
Some checks failed
API CI / API Lint (push) Successful in 47s
API CI / API Type Check (push) Failing after 47s
API CI / API Test (push) Successful in 1m0s
API CI / API Build (push) Failing after 50s
API CI / Build Docker Image (push) Has been skipped
Build Crossplane Provider / build (push) Failing after 5m51s
CD Pipeline / Deploy to Staging (push) Failing after 29s
CI Pipeline / Lint and Type Check (push) Failing after 36s
CI Pipeline / Build (push) Has been skipped
CI Pipeline / Test Backend (push) Failing after 1m33s
CI Pipeline / Test Frontend (push) Failing after 30s
CI Pipeline / Security Scan (push) Failing after 1m16s
Crossplane Provider CI / Go Test (push) Failing after 3m23s
Crossplane Provider CI / Go Lint (push) Failing after 7m27s
Crossplane Provider CI / Go Build (push) Failing after 3m27s
Deploy to Staging / Deploy to Staging (push) Failing after 30s
Portal CI / Portal Lint (push) Failing after 21s
Portal CI / Portal Type Check (push) Failing after 21s
Portal CI / Portal Test (push) Failing after 21s
Portal CI / Portal Build (push) Failing after 22s
Test Suite / frontend-tests (push) Failing after 30s
Test Suite / api-tests (push) Failing after 49s
Test Suite / blockchain-tests (push) Failing after 30s
Type Check / type-check (map[directory:. name:root]) (push) Failing after 23s
Type Check / type-check (map[directory:api name:api]) (push) Failing after 21s
Type Check / type-check (map[directory:portal name:portal]) (push) Failing after 19s
Validate Configuration Files / validate (push) Failing after 1m52s
CD Pipeline / Deploy to Production (push) Has been skipped
chore: consolidate local WIP (repo cleanup 20260707)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 09:41:34 -07:00

952 lines
28 KiB
TypeScript

#!/usr/bin/env tsx
/**
* Project Scanner Script
*
* Scans ~/projects/ directory for Git repos and monorepos,
* then adds them as Tenants (clients) in the Sankofa system.
*
* Features:
* - Detects Git repositories and monorepos
* - Extracts project metadata (Git remote, branch, package manager, workspaces)
* - Creates Tenants via GraphQL API with proper authentication
* - Duplicate detection and validation
* - Progress reporting and structured logging
*
* Usage:
* tsx scripts/scan-projects.ts [options]
*
* Options:
* --dry-run Show what would be created without actually creating
* --skip-existing Skip projects that already exist as tenants
* --projects-dir=PATH Directory to scan (default: ~/projects/)
* --api-url=URL GraphQL API URL (default: http://localhost:4000/graphql)
* --email=EMAIL Email for authentication (or set GRAPHQL_EMAIL env var)
* --password=PASSWORD Password for authentication (or set GRAPHQL_PASSWORD env var)
* --auth-token=TOKEN Direct authentication token (or set GRAPHQL_AUTH_TOKEN env var)
* --output=json Output results as JSON
* --verbose Enable verbose logging
* --batch-size=N Number of projects to process in parallel (default: 1)
*/
import { readdir, stat, readFile } from 'fs/promises'
import { join, basename } from 'path'
import { existsSync } from 'fs'
import { execSync } from 'child_process'
import { program } from 'commander'
import cliProgress from 'cli-progress'
import * as dotenv from 'dotenv'
import { z } from 'zod'
// Load environment variables
dotenv.config()
// Validation schemas
const projectNameSchema = z.string().min(1).max(255).regex(/^[a-zA-Z0-9_-]+$/, {
message: 'Project name must contain only alphanumeric characters, hyphens, and underscores'
})
const gitUrlSchema = z.string().url().or(z.literal(''))
interface ProjectInfo {
name: string
path: string
type: 'repo' | 'monorepo' | 'other'
gitRemote?: string
gitBranch?: string
packageManager?: 'npm' | 'pnpm' | 'yarn' | 'unknown'
workspaces?: string[]
metadata: Record<string, unknown>
}
interface GraphQLResponse<T = unknown> {
data?: T
errors?: Array<{ message: string; extensions?: { code?: string } }>
}
interface AuthPayload {
token: string
user: {
id: string
email: string
name: string
role: string
}
}
interface Tenant {
id: string
name: string
domain?: string | null
}
interface ScanOptions {
projectsDir: string
dryRun: boolean
skipExisting: boolean
apiUrl: string
email?: string
password?: string
authToken?: string
outputFormat: 'text' | 'json'
verbose: boolean
batchSize: number
}
interface ScanResult {
created: Array<{ name: string; id: string }>
skipped: Array<{ name: string; reason: string }>
errors: Array<{ name: string; error: string }>
}
class ProjectScanner {
private options: ScanOptions
private authToken?: string
private existingTenants: Map<string, Tenant> = new Map()
constructor(options: ScanOptions) {
this.options = options
}
/**
* Authenticate with the GraphQL API
*/
async authenticate(): Promise<void> {
if (this.options.authToken) {
this.authToken = this.options.authToken
if (this.options.verbose) {
console.log('Using provided authentication token')
}
return
}
if (!this.options.email || !this.options.password) {
throw new Error(
'Authentication required. Provide --email and --password, or --auth-token, ' +
'or set GRAPHQL_EMAIL/GRAPHQL_PASSWORD or GRAPHQL_AUTH_TOKEN environment variables'
)
}
const mutation = `
mutation Login($email: String!, $password: String!) {
login(email: $email, password: $password) {
token
user {
id
email
name
role
}
}
}
`
try {
const response = await this.executeGraphQL<{ login: AuthPayload }>(
mutation,
{ email: this.options.email, password: this.options.password },
'login'
)
if (!response) {
throw new Error('Login failed: No response received')
}
this.authToken = response.login.token
if (response.login.user.role !== 'ADMIN') {
throw new Error(
`Admin role required. Current role: ${response.login.user.role}. ` +
'Only ADMIN users can create tenants.'
)
}
if (this.options.verbose) {
console.log(`Authenticated as ${response.login.user.email} (${response.login.user.role})`)
}
} catch (error) {
throw new Error(`Authentication failed: ${error instanceof Error ? error.message : String(error)}`)
}
}
/**
* Load existing tenants for duplicate detection
*/
async loadExistingTenants(): Promise<void> {
if (this.options.dryRun || !this.options.skipExisting) {
return
}
const query = `
query ListTenants {
tenants {
id
name
domain
}
}
`
try {
const response = await this.executeGraphQL<{ tenants: Tenant[] }>(
query,
{},
'tenants'
)
if (response) {
for (const tenant of response.tenants) {
this.existingTenants.set(tenant.name.toLowerCase(), tenant)
if (tenant.domain) {
this.existingTenants.set(tenant.domain.toLowerCase(), tenant)
}
}
if (this.options.verbose) {
console.log(`Loaded ${this.existingTenants.size} existing tenants for duplicate detection`)
}
}
} catch (error) {
if (this.options.verbose) {
console.warn(`Warning: Could not load existing tenants: ${error instanceof Error ? error.message : String(error)}`)
}
}
}
/**
* Check if a directory is a Git repository
*/
private async isGitRepo(dirPath: string): Promise<boolean> {
return existsSync(join(dirPath, '.git'))
}
/**
* Get Git remote URL if available
*/
private async getGitRemote(dirPath: string): Promise<string | undefined> {
try {
const result = execSync('git remote get-url origin', {
cwd: dirPath,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
})
const url = result.trim()
// Validate URL
gitUrlSchema.parse(url)
return url
} catch {
return undefined
}
}
/**
* Get current Git branch
*/
private async getGitBranch(dirPath: string): Promise<string | undefined> {
try {
const result = execSync('git rev-parse --abbrev-ref HEAD', {
cwd: dirPath,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
})
return result.trim()
} catch {
return undefined
}
}
/**
* Extract domain from Git remote URL
*/
private extractDomainFromGitRemote(remote?: string): string | undefined {
if (!remote) return undefined
try {
// Handle various Git URL formats
// https://github.com/user/repo.git -> github.com
// git@github.com:user/repo.git -> github.com
// https://gitlab.com/user/repo.git -> gitlab.com
// https://bitbucket.org/user/repo.git -> bitbucket.org
const patterns = [
/^https?:\/\/([^\/]+)\//,
/^git@([^:]+):/,
/^ssh:\/\/git@([^\/]+)\//,
]
for (const pattern of patterns) {
const match = remote.match(pattern)
if (match && match[1]) {
const host = match[1].replace(/^www\./, '')
// Generate a safe domain name
return `${host.replace(/\./g, '-')}.dev`
}
}
} catch {
// Ignore errors
}
return undefined
}
/**
* Detect package manager from lock files
*/
private detectPackageManager(dirPath: string): 'npm' | 'pnpm' | 'yarn' | 'unknown' {
if (existsSync(join(dirPath, 'pnpm-lock.yaml'))) return 'pnpm'
if (existsSync(join(dirPath, 'yarn.lock'))) return 'yarn'
if (existsSync(join(dirPath, 'package-lock.json'))) return 'npm'
return 'unknown'
}
/**
* Check if a directory is a monorepo
*/
private async isMonorepo(dirPath: string): Promise<boolean> {
const packageJsonPath = join(dirPath, 'package.json')
if (!existsSync(packageJsonPath)) return false
try {
const content = await readFile(packageJsonPath, 'utf-8')
const pkg = JSON.parse(content)
// Check for workspaces (npm/yarn)
if (pkg.workspaces && Array.isArray(pkg.workspaces) && pkg.workspaces.length > 0) {
return true
}
// Check for pnpm workspace
if (existsSync(join(dirPath, 'pnpm-workspace.yaml'))) {
return true
}
// Check for lerna
if (existsSync(join(dirPath, 'lerna.json'))) {
return true
}
// Check for nx
if (existsSync(join(dirPath, 'nx.json'))) {
return true
}
// Check for turborepo
if (existsSync(join(dirPath, 'turbo.json'))) {
return true
}
// Check for multiple package.json files in subdirectories
const entries = await readdir(dirPath, { withFileTypes: true })
let packageJsonCount = 0
for (const entry of entries) {
if (entry.isDirectory() && !entry.name.startsWith('.')) {
if (existsSync(join(dirPath, entry.name, 'package.json'))) {
packageJsonCount++
if (packageJsonCount >= 2) return true
}
}
}
} catch {
// If we can't read/parse package.json, it's not a monorepo
}
return false
}
/**
* Get workspace packages for a monorepo
*/
private async getWorkspaces(dirPath: string): Promise<string[]> {
const workspaces: string[] = []
const packageJsonPath = join(dirPath, 'package.json')
if (existsSync(packageJsonPath)) {
try {
const content = await readFile(packageJsonPath, 'utf-8')
const pkg = JSON.parse(content)
if (pkg.workspaces && Array.isArray(pkg.workspaces)) {
// Expand workspace patterns
for (const pattern of pkg.workspaces) {
const glob = pattern.replace('/*', '').replace('*', '')
const workspaceDir = join(dirPath, glob)
if (existsSync(workspaceDir)) {
const entries = await readdir(workspaceDir, { withFileTypes: true })
for (const entry of entries) {
if (entry.isDirectory()) {
const workspacePath = join(workspaceDir, entry.name)
if (existsSync(join(workspacePath, 'package.json'))) {
workspaces.push(entry.name)
}
}
}
}
}
}
} catch {
// Ignore errors
}
}
// Check pnpm-workspace.yaml
const pnpmWorkspacePath = join(dirPath, 'pnpm-workspace.yaml')
if (existsSync(pnpmWorkspacePath)) {
try {
const content = await readFile(pnpmWorkspacePath, 'utf-8')
// Simple parsing for pnpm workspace
const lines = content.split('\n')
for (const line of lines) {
const match = line.match(/^[\s-]+['"]?([^'"]+)['"]?/)
if (match) {
workspaces.push(match[1])
}
}
} catch {
// Ignore errors
}
}
return workspaces
}
/**
* Validate and sanitize project name
*/
private validateProjectName(name: string): string {
// Remove invalid characters and replace with hyphens
let sanitized = name.replace(/[^a-zA-Z0-9_-]/g, '-')
// Remove consecutive hyphens
sanitized = sanitized.replace(/-+/g, '-')
// Remove leading/trailing hyphens
sanitized = sanitized.replace(/^-+|-+$/g, '')
// Ensure it's not empty
if (!sanitized) {
sanitized = 'project'
}
// Truncate to max length
if (sanitized.length > 255) {
sanitized = sanitized.substring(0, 255)
}
// Validate with schema
projectNameSchema.parse(sanitized)
return sanitized
}
/**
* Sanitize metadata to prevent injection and size issues
*/
private sanitizeMetadata(metadata: Record<string, unknown>): Record<string, unknown> {
const sanitized: Record<string, unknown> = {}
const maxSize = 10 * 1024 // 10KB limit
for (const [key, value] of Object.entries(metadata)) {
// Only allow string, number, boolean, null, and arrays/objects of these types
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean' ||
value === null
) {
sanitized[key] = value
} else if (Array.isArray(value)) {
sanitized[key] = value.filter(
(v) => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' || v === null
)
} else if (typeof value === 'object') {
// Recursively sanitize nested objects
sanitized[key] = this.sanitizeMetadata(value as Record<string, unknown>)
}
}
// Check size
const jsonString = JSON.stringify(sanitized)
if (jsonString.length > maxSize) {
// Truncate by removing less important fields
delete sanitized.description
delete sanitized.keywords
const truncated = JSON.stringify(sanitized)
if (truncated.length > maxSize) {
throw new Error(`Metadata too large (${jsonString.length} bytes, max ${maxSize} bytes)`)
}
}
return sanitized
}
/**
* Scan a directory and collect project information
*/
private async scanDirectory(dirPath: string): Promise<ProjectInfo | null> {
const name = basename(dirPath)
const stats = await stat(dirPath)
if (!stats.isDirectory()) {
return null
}
// Skip hidden directories and common non-project directories
if (name.startsWith('.') || ['node_modules', 'dist', 'build', '.git'].includes(name)) {
return null
}
const isGit = await this.isGitRepo(dirPath)
const isMono = await this.isMonorepo(dirPath)
// Only process Git repos or directories with package.json
if (!isGit && !existsSync(join(dirPath, 'package.json'))) {
return null
}
// Validate and sanitize project name
const sanitizedName = this.validateProjectName(name)
const projectInfo: ProjectInfo = {
name: sanitizedName,
path: dirPath,
type: isMono ? 'monorepo' : isGit ? 'repo' : 'other',
metadata: {},
}
if (isGit) {
projectInfo.gitRemote = await this.getGitRemote(dirPath)
projectInfo.gitBranch = await this.getGitBranch(dirPath)
}
if (existsSync(join(dirPath, 'package.json'))) {
projectInfo.packageManager = this.detectPackageManager(dirPath)
if (isMono) {
projectInfo.workspaces = await this.getWorkspaces(dirPath)
}
// Read package.json for additional metadata
try {
const content = await readFile(join(dirPath, 'package.json'), 'utf-8')
const pkg = JSON.parse(content)
projectInfo.metadata = {
description: pkg.description,
version: pkg.version,
author: typeof pkg.author === 'string' ? pkg.author : pkg.author?.name,
license: pkg.license,
keywords: Array.isArray(pkg.keywords) ? pkg.keywords.slice(0, 10) : undefined,
}
} catch {
// Ignore errors
}
}
// Sanitize metadata
projectInfo.metadata = this.sanitizeMetadata(projectInfo.metadata)
return projectInfo
}
/**
* Scan all projects in the projects directory
*/
async scanAllProjects(): Promise<ProjectInfo[]> {
const projects: ProjectInfo[] = []
if (!existsSync(this.options.projectsDir)) {
throw new Error(`Projects directory does not exist: ${this.options.projectsDir}`)
}
if (this.options.verbose) {
console.log(`Scanning projects in: ${this.options.projectsDir}`)
}
const entries = await readdir(this.options.projectsDir, { withFileTypes: true })
for (const entry of entries) {
if (entry.isDirectory()) {
const projectPath = join(this.options.projectsDir, entry.name)
try {
const projectInfo = await this.scanDirectory(projectPath)
if (projectInfo) {
projects.push(projectInfo)
}
} catch (error) {
if (this.options.verbose) {
console.warn(`Warning: Failed to scan ${entry.name}: ${error instanceof Error ? error.message : String(error)}`)
}
}
}
}
return projects
}
/**
* Check if tenant already exists
*/
private isDuplicate(project: ProjectInfo): Tenant | null {
const nameKey = project.name.toLowerCase()
if (this.existingTenants.has(nameKey)) {
return this.existingTenants.get(nameKey)!
}
if (project.gitRemote) {
const domain = this.extractDomainFromGitRemote(project.gitRemote)
if (domain) {
const domainKey = domain.toLowerCase()
if (this.existingTenants.has(domainKey)) {
return this.existingTenants.get(domainKey)!
}
}
}
return null
}
/**
* Create a Tenant via GraphQL
*/
private async createTenant(project: ProjectInfo): Promise<string | null> {
const mutation = `
mutation CreateTenant($input: CreateTenantInput!) {
createTenant(input: $input) {
id
name
domain
status
tier
}
}
`
const domain = this.extractDomainFromGitRemote(project.gitRemote)
const variables = {
input: {
name: project.name,
domain: domain || undefined,
tier: 'STANDARD' as const,
metadata: {
type: project.type,
path: project.path,
gitRemote: project.gitRemote,
gitBranch: project.gitBranch,
packageManager: project.packageManager,
workspaces: project.workspaces,
...project.metadata,
},
},
}
return this.executeGraphQLWithRetry(mutation, variables, 'createTenant', (data) => data?.createTenant?.id)
}
/**
* Execute a GraphQL query/mutation with retry logic
*/
private async executeGraphQLWithRetry<T>(
query: string,
variables: Record<string, unknown>,
operationName: string,
extractId?: (data: T) => string | null | undefined
): Promise<string | null> {
const maxRetries = 3
let lastError: Error | null = null
for (let attempt = 0; attempt <= maxRetries; attempt++) {
if (attempt > 0) {
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000) // Exponential backoff, max 10s
if (this.options.verbose) {
console.log(`Retrying ${operationName} (attempt ${attempt + 1}/${maxRetries + 1}) after ${delay}ms...`)
}
await new Promise((resolve) => setTimeout(resolve, delay))
}
try {
const response = await this.executeGraphQL<T>(query, variables, operationName)
if (response) {
if (extractId) {
const id = extractId(response)
return id || null
}
return (response as { id?: string })?.id || null
}
return null
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error))
// Don't retry on validation or authentication errors
if (error instanceof Error) {
if (error.message.includes('UNAUTHENTICATED') || error.message.includes('FORBIDDEN')) {
throw error
}
if (error.message.includes('VALIDATION_ERROR') || error.message.includes('BAD_USER_INPUT')) {
throw error
}
}
if (attempt < maxRetries) {
continue
}
}
}
throw lastError || new Error(`Failed to execute ${operationName} after ${maxRetries + 1} attempts`)
}
/**
* Execute a GraphQL query/mutation
*/
private async executeGraphQL<T>(
query: string,
variables: Record<string, unknown>,
operationName: string
): Promise<T | null> {
if (this.options.dryRun) {
if (this.options.verbose) {
console.log(`[DRY RUN] Would execute: ${operationName}`)
console.log(` Variables:`, JSON.stringify(variables, null, 2))
}
return null
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (this.authToken) {
headers['Authorization'] = `Bearer ${this.authToken}`
}
try {
const response = await fetch(this.options.apiUrl, {
method: 'POST',
headers,
body: JSON.stringify({
query,
variables,
operationName,
}),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const result: GraphQLResponse<T> = await response.json()
if (result.errors) {
const errorMessages = result.errors.map((e) => e.message).join(', ')
const errorCodes = result.errors.map((e) => e.extensions?.code).filter(Boolean).join(', ')
throw new Error(`${errorCodes ? `[${errorCodes}] ` : ''}${errorMessages}`)
}
return result.data || null
} catch (error) {
if (error instanceof Error) {
throw error
}
throw new Error(`Failed to execute ${operationName}: ${String(error)}`)
}
}
/**
* Import all scanned projects
*/
async importProjects(projects: ProjectInfo[]): Promise<ScanResult> {
const result: ScanResult = {
created: [],
skipped: [],
errors: [],
}
if (projects.length === 0) {
console.log('No projects found to import.')
return result
}
if (!this.options.outputFormat || this.options.outputFormat === 'text') {
console.log(`\nFound ${projects.length} projects to import:`)
projects.forEach((p) => {
console.log(` - ${p.name} (${p.type})`)
})
}
if (this.options.dryRun) {
if (!this.options.outputFormat || this.options.outputFormat === 'text') {
console.log('\n[DRY RUN] Would create the following tenants:')
}
} else {
if (!this.options.outputFormat || this.options.outputFormat === 'text') {
console.log(`\nCreating tenants...`)
}
}
// Create progress bar
const progressBar = this.options.outputFormat !== 'json' && !this.options.verbose
? new cliProgress.SingleBar({
format: 'Progress |{bar}| {percentage}% | {value}/{total} projects | ETA: {eta}s',
barCompleteChar: '\u2588',
barIncompleteChar: '\u2591',
hideCursor: true,
})
: null
if (progressBar) {
progressBar.start(projects.length, 0)
}
// Process projects in batches
const batchSize = this.options.batchSize || 1
for (let i = 0; i < projects.length; i += batchSize) {
const batch = projects.slice(i, i + batchSize)
const batchPromises = batch.map(async (project) => {
try {
// Check for duplicates
if (this.options.skipExisting) {
const existing = this.isDuplicate(project)
if (existing) {
result.skipped.push({
name: project.name,
reason: `Already exists as tenant: ${existing.name} (ID: ${existing.id})`,
})
if (progressBar) progressBar.increment()
return
}
}
const id = await this.createTenant(project)
if (id) {
result.created.push({ name: project.name, id })
if (this.options.verbose && this.options.outputFormat !== 'json') {
console.log(`✓ Created tenant for ${project.name} (ID: ${id})`)
}
} else if (!this.options.dryRun) {
result.errors.push({
name: project.name,
error: 'Failed to create tenant (no ID returned)',
})
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
result.errors.push({
name: project.name,
error: errorMessage,
})
if (this.options.verbose && this.options.outputFormat !== 'json') {
console.error(`✗ Error processing ${project.name}: ${errorMessage}`)
}
} finally {
if (progressBar) progressBar.increment()
}
})
await Promise.all(batchPromises)
}
if (progressBar) {
progressBar.stop()
}
// Print summary
if (this.options.outputFormat === 'json') {
console.log(JSON.stringify(result, null, 2))
} else {
console.log(`\nSummary:`)
console.log(` Created: ${result.created.length}`)
console.log(` Skipped: ${result.skipped.length}`)
console.log(` Errors: ${result.errors.length}`)
console.log(` Total: ${projects.length}`)
if (result.errors.length > 0 && this.options.verbose) {
console.log(`\nErrors:`)
result.errors.forEach((e) => {
console.log(` - ${e.name}: ${e.error}`)
})
}
}
return result
}
}
// Main execution
async function main() {
program
.name('scan-projects')
.description('Scan projects directory and create Tenants in Sankofa system')
.option('--dry-run', 'Show what would be created without actually creating', false)
.option('--skip-existing', 'Skip projects that already exist as tenants', false)
.option('--projects-dir <path>', 'Directory to scan', process.env.PROJECTS_DIR || join(process.env.HOME || '~', 'projects'))
.option('--api-url <url>', 'GraphQL API URL', process.env.GRAPHQL_API_URL || 'http://localhost:4000/graphql')
.option('--email <email>', 'Email for authentication', process.env.GRAPHQL_EMAIL)
.option('--password <password>', 'Password for authentication', process.env.GRAPHQL_PASSWORD)
.option('--auth-token <token>', 'Direct authentication token', process.env.GRAPHQL_AUTH_TOKEN)
.option('--output <format>', 'Output format: text or json', 'text')
.option('--verbose', 'Enable verbose logging', false)
.option('--batch-size <number>', 'Number of projects to process in parallel', '1')
.parse(process.argv)
const rawOptions = program.opts<Record<string, unknown>>()
// Map commander options to ScanOptions
const options: ScanOptions = {
projectsDir: (rawOptions.projectsDir as string) || process.env.PROJECTS_DIR || join(process.env.HOME || '~', 'projects'),
dryRun: rawOptions.dryRun === true,
skipExisting: rawOptions.skipExisting === true,
apiUrl: (rawOptions.apiUrl as string) || process.env.GRAPHQL_API_URL || 'http://localhost:4000/graphql',
email: (rawOptions.email as string) || process.env.GRAPHQL_EMAIL,
password: (rawOptions.password as string) || process.env.GRAPHQL_PASSWORD,
authToken: (rawOptions.authToken as string) || process.env.GRAPHQL_AUTH_TOKEN,
outputFormat: ((rawOptions.output as string) || 'text') as 'text' | 'json',
verbose: rawOptions.verbose === true,
batchSize: 1,
}
// Validate batch size
const batchSize = parseInt((rawOptions.batchSize as string) || '1', 10)
if (isNaN(batchSize) || batchSize < 1) {
console.error('Error: --batch-size must be a positive integer')
process.exit(1)
}
options.batchSize = batchSize
// Validate output format
if (options.outputFormat !== 'text' && options.outputFormat !== 'json') {
console.error('Error: --output must be "text" or "json"')
process.exit(1)
}
const scanner = new ProjectScanner(options)
try {
// Authenticate (skip in dry-run mode)
if (!options.dryRun) {
await scanner.authenticate()
// Load existing tenants for duplicate detection
await scanner.loadExistingTenants()
} else if (options.verbose) {
console.log('[DRY RUN] Skipping authentication')
}
// Scan projects
const projects = await scanner.scanAllProjects()
// Import projects
await scanner.importProjects(projects)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
console.error('Fatal error:', errorMessage)
if (options.verbose && error instanceof Error && error.stack) {
console.error(error.stack)
}
process.exit(1)
}
}
if (require.main === module) {
main()
}
export { ProjectScanner, ProjectInfo, ScanResult, ScanOptions }