chore: consolidate local WIP (repo cleanup 20260707)
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
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
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
951
scripts/scan-projects.ts
Normal file
951
scripts/scan-projects.ts
Normal file
@@ -0,0 +1,951 @@
|
||||
#!/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 }
|
||||
|
||||
Reference in New Issue
Block a user