UNPKG

launchpad-installer

Version:

GitHub Action to install packages using Launchpad.

528 lines (486 loc) 15.9 kB
import { readdirSync, readFileSync, statSync } from 'node:fs' import { join, resolve } from 'node:path' import process from 'node:process' import * as core from '@actions/core' // Simple semver range implementation for Node.js compatibility class SemverRange { private range: string constructor(range: string) { this.range = range } toString(): string { return this.range } } // Package requirement interface interface PackageRequirement { project: string constraint: SemverRange } // Simple path utility for Node.js class SimplePath { public string: string constructor(path: string) { this.string = resolve(path) } isDirectory(): boolean { try { return statSync(this.string).isDirectory() } catch { return false } } read(): string { return readFileSync(this.string, 'utf8') } * ls(): Generator<[SimplePath, { name: string, isFile: boolean, isSymlink: boolean, isDirectory: boolean }]> { try { const entries = readdirSync(this.string, { withFileTypes: true }) for (const entry of entries) { const fullPath = new SimplePath(join(this.string, entry.name)) yield [fullPath, { name: entry.name, isFile: entry.isFile(), isSymlink: entry.isSymbolicLink(), isDirectory: entry.isDirectory(), }] } } catch { // Directory doesn't exist or can't be read } } } // Simple package parsing function parsePackage(input: string): PackageRequirement { const atIndex = input.lastIndexOf('@') if (atIndex > 0) { const project = input.substring(0, atIndex) const constraint = input.substring(atIndex + 1) return { project, constraint: new SemverRange(constraint) } } return { project: input, constraint: new SemverRange('*') } } // Simple JSON with comments parser function parseJSONC(content: string): any { try { const cleaned = content.replace(/\/\*[\s\S]*?\*\/|\/\/.*$/gm, '') return JSON.parse(cleaned) } catch { return {} } } // Simple YAML parser (basic implementation) function parseYAML(content: string): any { try { // For JSON-like YAML if (content.trim().startsWith('{')) { return JSON.parse(content) } // Basic YAML parsing for simple key-value pairs const lines = content.split('\n') const result: any = {} const currentSection: any = result let currentKey = '' for (const line of lines) { let trimmed = line.trim() if (!trimmed || trimmed.startsWith('#')) continue // Strip inline comments (but preserve quoted strings) const commentIndex = trimmed.indexOf('#') if (commentIndex > -1) { // Simple check: if we're not inside quotes, strip the comment const beforeComment = trimmed.substring(0, commentIndex) const quoteCount = (beforeComment.match(/"/g) || []).length if (quoteCount % 2 === 0) { // Even number of quotes means we're not inside a quoted string trimmed = beforeComment.trim() } } if (!trimmed) continue // Handle arrays (simple case) if (trimmed.startsWith('- ')) { const value = trimmed.substring(2).trim() if (!currentSection[currentKey]) { currentSection[currentKey] = [] } if (Array.isArray(currentSection[currentKey])) { currentSection[currentKey].push(value) } continue } const colonIndex = trimmed.indexOf(':') if (colonIndex > 0) { const key = trimmed.substring(0, colonIndex).trim() let value = trimmed.substring(colonIndex + 1).trim() if (value === '') { // This is a section header currentSection[key] = [] currentKey = key } else { // Remove quotes if present value = value.replace(/^["']|["']$/g, '') // Handle boolean values if (value === 'true') { currentSection[key] = true } else if (value === 'false') { currentSection[key] = false } else { currentSection[key] = value } } } } return result } catch { return {} } } // Parse pkgx dependencies from YAML function parsePkgxDependencies(deps: any): PackageRequirement[] { const packages: PackageRequirement[] = [] if (typeof deps === 'string') { // Single dependency as string packages.push(parsePackage(deps)) } else if (Array.isArray(deps)) { // Array of dependencies for (const dep of deps) { if (typeof dep === 'string') { packages.push(parsePackage(dep)) } } } else if (typeof deps === 'object' && deps !== null) { // Object with package: version pairs or complex objects for (const [pkg, versionOrConfig] of Object.entries(deps)) { if (typeof versionOrConfig === 'string') { if (versionOrConfig === 'latest' || versionOrConfig === '*') { packages.push({ project: pkg, constraint: new SemverRange('*') }) } else { packages.push(parsePackage(`${pkg}@${versionOrConfig}`)) } } else if (typeof versionOrConfig === 'object' && versionOrConfig !== null) { // Handle complex dependency objects with version and global flags const config = versionOrConfig as any const version = config.version || '*' const constraint = version === 'latest' || version === '*' ? new SemverRange('*') : new SemverRange(version) packages.push({ project: pkg, constraint }) // Note: global flag is handled at the environment level, not per package in GitHub Actions } else { packages.push({ project: pkg, constraint: new SemverRange('*') }) } } } return packages } // Simplified sniff implementation for GitHub Actions function sniffDirectory(dirPath: SimplePath): { pkgs: PackageRequirement[], env: Record<string, string> } { const constraint = new SemverRange('*') let has_package_json = false const pkgs: PackageRequirement[] = [] const env: Record<string, string> = {} for (const [path, { name, isFile, isSymlink, isDirectory }] of dirPath.ls()) { if (isFile || isSymlink) { switch (name) { case 'deno.json': case 'deno.jsonc': pkgs.push({ project: 'deno.land', constraint }) try { const json = parseJSONC(path.read()) if (json?.pkgx) { // Handle pkgx dependencies in deno.json } } catch { // Ignore parsing errors } break case '.nvmrc': case '.node-version': try { let version = path.read().trim() if (version.startsWith('v')) version = version.slice(1) if (version.match(/^\d/)) version = `@${version}` pkgs.push(parsePackage(`nodejs.org${version}`)) } catch { pkgs.push({ project: 'nodejs.org', constraint }) } break case '.ruby-version': try { let version = path.read().trim() if (version.startsWith('v')) version = version.slice(1) if (version.match(/^\d/)) version = `@${version}` pkgs.push(parsePackage(`ruby-lang.org${version}`)) } catch { pkgs.push({ project: 'ruby-lang.org', constraint }) } break case '.python-version': try { const content = path.read().trim() const lines = content.split('\n') for (const line of lines) { const l = line.trim() if (!l || l.startsWith('#')) continue try { pkgs.push(parsePackage(`python.org@${l}`)) break } catch { // Skip invalid versions } } } catch { pkgs.push({ project: 'python.org', constraint }) } break case 'package.json': has_package_json = true try { const json = JSON.parse(path.read()) // Check engines if (json?.engines) { if (json.engines.node) pkgs.push(parsePackage(`nodejs.org@${json.engines.node}`)) if (json.engines.npm) pkgs.push(parsePackage(`npmjs.com@${json.engines.npm}`)) if (json.engines.yarn) pkgs.push(parsePackage(`yarnpkg.com@${json.engines.yarn}`)) if (json.engines.pnpm) pkgs.push(parsePackage(`pnpm.io@${json.engines.pnpm}`)) } // Check packageManager (corepack) if (json?.packageManager) { const match = json.packageManager.match(/^([^@]+)@([^+]+)/) if (match) { const [, pkg, version] = match switch (pkg) { case 'npm': pkgs.push(parsePackage(`npmjs.com@${version}`)) break case 'yarn': pkgs.push(parsePackage(`yarnpkg.com@${version}`)) break case 'pnpm': pkgs.push(parsePackage(`pnpm.io@${version}`)) break } } } // Check volta if (json?.volta) { if (json.volta.node) pkgs.push(parsePackage(`nodejs.org@${json.volta.node}`)) if (json.volta.npm) pkgs.push(parsePackage(`npmjs.com@${json.volta.npm}`)) if (json.volta.yarn) pkgs.push(parsePackage(`yarnpkg.com@${json.volta.yarn}`)) if (json.volta.pnpm) pkgs.push(parsePackage(`pnpm.io@${json.volta.pnpm}`)) } } catch { // If we can't parse package.json, just add node pkgs.push({ project: 'nodejs.org', constraint }) } break case 'Cargo.toml': pkgs.push({ project: 'rust-lang.org', constraint }) break case 'go.mod': case 'go.sum': pkgs.push({ project: 'go.dev', constraint }) break case 'requirements.txt': case 'pipfile': case 'pipfile.lock': case 'setup.py': pkgs.push({ project: 'pip.pypa.io', constraint }) break case 'pyproject.toml': try { const content = path.read() if (content.includes('poetry.core.masonry.api')) { pkgs.push({ project: 'python-poetry.org', constraint }) } else { pkgs.push({ project: 'pip.pypa.io', constraint }) } } catch { pkgs.push({ project: 'pip.pypa.io', constraint }) } break case 'Gemfile': pkgs.push({ project: 'ruby-lang.org', constraint }) break case '.yarnrc': pkgs.push({ project: 'classic.yarnpkg.com', constraint }) break case 'yarn.lock': pkgs.push({ project: 'yarnpkg.com', constraint }) break case '.yarnrc.yml': pkgs.push({ project: 'yarnpkg.com', constraint }) break case 'bun.lock': case 'bun.lockb': pkgs.push({ project: 'bun.sh', constraint: new SemverRange('>=1') }) break case 'pnpm-lock.yaml': pkgs.push({ project: 'pnpm.io', constraint }) break case 'composer.json': pkgs.push({ project: 'php.net', constraint }) break case 'pom.xml': case 'build.gradle': case 'build.gradle.kts': pkgs.push({ project: 'java.oracle.com', constraint }) break case 'justfile': case 'Justfile': pkgs.push({ project: 'just.systems', constraint }) break case 'Taskfile.yml': pkgs.push({ project: 'taskfile.dev', constraint }) break case 'uv.lock': pkgs.push({ project: 'astral.sh/uv', constraint }) break case 'pkgx.yml': case 'pkgx.yaml': case '.pkgx.yml': case '.pkgx.yaml': case 'launchpad.yml': case 'launchpad.yaml': case '.launchpad.yml': case '.launchpad.yaml': case 'dependencies.yml': case 'dependencies.yaml': case '.dependencies.yml': case '.dependencies.yaml': case 'deps.yml': case 'deps.yaml': case '.deps.yml': case '.deps.yaml': try { const content = path.read() const yaml = parseYAML(content) if (yaml?.dependencies) { const deps = parsePkgxDependencies(yaml.dependencies) pkgs.push(...deps) } } catch { // Ignore parsing errors } break } } else if (isDirectory) { switch (name) { case '.git': if (process.platform !== 'darwin') { pkgs.push({ project: 'git-scm.org', constraint }) } break case '.hg': pkgs.push({ project: 'mercurial-scm.org', constraint }) break case '.svn': pkgs.push({ project: 'apache.org/subversion', constraint }) break } } } // Add Node.js if we have package.json but no specific runtime if (has_package_json && !pkgs.some(pkg => pkg.project === 'bun.sh') && !pkgs.some(pkg => pkg.project === 'nodejs.org')) { pkgs.push({ project: 'nodejs.org', constraint }) } return { pkgs, env } } /** * Detect project dependencies using simplified sniff functionality */ export async function detectProjectDependencies(_configPath?: string): Promise<string[]> { try { const currentDir = new SimplePath(process.cwd()) core.info(`Sniffing dependencies in: ${currentDir.string}`) if (!currentDir.isDirectory()) { throw new Error(`not a directory: ${currentDir.string}`) } const { pkgs } = sniffDirectory(currentDir) const dependencies = pkgs.map((pkg) => { return convertPackageName(pkg.project) }) core.info(`Detected dependencies: ${dependencies.join(', ')}`) return [...new Set(dependencies)] // Remove duplicates } catch (error) { core.warning(`Failed to detect dependencies: ${error instanceof Error ? error.message : String(error)}`) return [] } } /** * Convert full package names to simple names for launchpad install */ function convertPackageName(fullName: string): string { // Manual mapping for packages const packageMap: Record<string, string> = { 'nodejs.org': 'node', 'python.org': 'python', 'pip.pypa.io': 'python', 'python-poetry.org': 'python', 'go.dev': 'go', 'rust-lang.org': 'rust', 'ruby-lang.org': 'ruby', 'php.net': 'php', 'java.oracle.com': 'java', 'openjdk.org': 'java', 'bun.sh': 'bun', 'deno.land': 'deno', 'yarnpkg.com': 'yarn', 'classic.yarnpkg.com': 'yarn', 'pnpm.io': 'pnpm', 'npmjs.com': 'npm', 'git-scm.org': 'git', 'mercurial-scm.org': 'hg', 'apache.org/subversion': 'svn', 'docker.com/cli': 'docker', 'kubernetes.io/kubectl': 'kubectl', 'kubernetes.io/minikube': 'minikube', 'helm.sh': 'helm', 'terraform.io': 'terraform', 'just.systems': 'just', 'taskfile.dev': 'task', 'skaffold.dev': 'skaffold', 'aws.amazon.com/cdk': 'aws-cdk', 'astral.sh/uv': 'uv', 'prefix.dev': 'pixi', 'kpt.dev': 'kpt', 'kubernetes.io/kustomize': 'kustomize', } return packageMap[fullName] || fullName }