Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions packages/cli/src/commands/build.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { describe, it, expect, afterEach } from 'vitest';
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { detectStack } from './build.js';

describe('detectStack', () => {
let tempDir: string;

function makeTempDir(): string {
tempDir = mkdtempSync(join(tmpdir(), 'sh1pt-test-'));
return tempDir;
}

afterEach(() => {
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true });
}
});

it('detects a Node.js project from package.json', () => {
const dir = makeTempDir();
writeFileSync(join(dir, 'package.json'), JSON.stringify({
name: 'my-node-app',
version: '1.0.0',
}));
const result = detectStack(dir);
expect(result).toBeDefined();
expect(result!.runtime).toBe('node');
expect(result!.projectName).toBe('my-node-app');
expect(result!.packageManager).toBe('npm');
});

it('detects pnpm from pnpm-lock.yaml', () => {
const dir = makeTempDir();
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'pnpm-app' }));
writeFileSync(join(dir, 'pnpm-lock.yaml'), '');
const result = detectStack(dir);
expect(result!.packageManager).toBe('pnpm');
});

it('detects yarn from yarn.lock', () => {
const dir = makeTempDir();
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'yarn-app' }));
writeFileSync(join(dir, 'yarn.lock'), '');
const result = detectStack(dir);
expect(result!.packageManager).toBe('yarn');
});

it('detects packageManager field in package.json', () => {
const dir = makeTempDir();
writeFileSync(join(dir, 'package.json'), JSON.stringify({
name: 'pm-field',
packageManager: 'pnpm@9.0.0',
}));
const result = detectStack(dir);
expect(result!.packageManager).toBe('pnpm');
});

it('detects a Python project from pyproject.toml', () => {
const dir = makeTempDir();
writeFileSync(join(dir, 'pyproject.toml'), `[project]\nname = "my-python-pkg"\nversion = "0.1.0"\n`);
const result = detectStack(dir);
expect(result).toBeDefined();
expect(result!.runtime).toBe('python');
expect(result!.projectName).toBe('my-python-pkg');
expect(result!.packageManager).toBe('pip');
});

it('detects poetry from pyproject.toml', () => {
const dir = makeTempDir();
writeFileSync(join(dir, 'pyproject.toml'), `[tool.poetry]\nname = "poetry-app"\nversion = "1.0.0"\n`);
const result = detectStack(dir);
expect(result!.runtime).toBe('python');
expect(result!.packageManager).toBe('poetry');
});

it('detects a Rust project from Cargo.toml', () => {
const dir = makeTempDir();
writeFileSync(join(dir, 'Cargo.toml'), `[package]\nname = "my-rust-crate"\nversion = "0.1.0"\n`);
const result = detectStack(dir);
expect(result).toBeDefined();
expect(result!.runtime).toBe('rust');
expect(result!.projectName).toBe('my-rust-crate');
expect(result!.packageManager).toBe('cargo');
});

it('detects a Go project from go.mod', () => {
const dir = makeTempDir();
writeFileSync(join(dir, 'go.mod'), `module github.com/user/my-go-app\n\ngo 1.21\n`);
const result = detectStack(dir);
expect(result).toBeDefined();
expect(result!.runtime).toBe('go');
expect(result!.projectName).toBe('my-go-app');
expect(result!.packageManager).toBe('go');
});

it('returns undefined for an empty directory', () => {
const dir = makeTempDir();
const result = detectStack(dir);
expect(result).toBeUndefined();
});

it('prefers package.json over other manifests when multiple exist', () => {
const dir = makeTempDir();
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'multi' }));
writeFileSync(join(dir, 'go.mod'), 'module example.com/go\n');
const result = detectStack(dir);
expect(result!.runtime).toBe('node');
});
});
148 changes: 145 additions & 3 deletions packages/cli/src/commands/build.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { Command } from 'commander';
import { spawnSync } from 'node:child_process';
import { readFileSync, rmSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { randomBytes } from 'node:crypto';
import kleur from 'kleur';
import { describeInput, resolveInput } from '../input.js';
import type { ResolvedInput } from '../input.js';
import { entityCmd } from './entity.js';

function run(argv: string[], env?: Record<string, string>): number {
Expand All @@ -15,20 +20,157 @@ function run(argv: string[], env?: Record<string, string>): number {
return r.status ?? 0;
}

// --- Stack detection ---------------------------------------------------------

export interface DetectedStack {
/** Primary runtime/language detected. */
runtime: string;
/** Package manager or build tool, if identifiable. */
packageManager?: string;
/** Project name from the manifest. */
projectName?: string;
}

/**
* Inspect a directory root and return detected stack info based on manifest
* files. Returns undefined if nothing recognizable is found.
*/
export function detectStack(dir: string): DetectedStack | undefined {
// Node (package.json)
const pkgPath = join(dir, 'package.json');
if (existsSync(pkgPath)) {
try {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as Record<string, unknown>;
const pm = typeof pkg['packageManager'] === 'string'
? pkg['packageManager'].split('@')[0]
: existsSync(join(dir, 'pnpm-lock.yaml')) ? 'pnpm'
: existsSync(join(dir, 'yarn.lock')) ? 'yarn'
: 'npm';
return {
runtime: 'node',
packageManager: pm,
projectName: typeof pkg['name'] === 'string' ? pkg['name'] : undefined,
};
} catch { /* malformed json — skip */ }
}

// Python (pyproject.toml)
const pyPath = join(dir, 'pyproject.toml');
if (existsSync(pyPath)) {
try {
const content = readFileSync(pyPath, 'utf-8');
const nameMatch = content.match(/^name\s*=\s*"([^"]+)"/m);
return {
runtime: 'python',
packageManager: content.includes('[tool.poetry]') ? 'poetry' : 'pip',
projectName: nameMatch?.[1],
};
} catch { /* skip */ }
}

// Rust (Cargo.toml)
const cargoPath = join(dir, 'Cargo.toml');
if (existsSync(cargoPath)) {
try {
const content = readFileSync(cargoPath, 'utf-8');
const nameMatch = content.match(/^name\s*=\s*"([^"]+)"/m);
return {
runtime: 'rust',
packageManager: 'cargo',
projectName: nameMatch?.[1],
};
} catch { /* skip */ }
}

// Go (go.mod)
const goPath = join(dir, 'go.mod');
if (existsSync(goPath)) {
try {
const content = readFileSync(goPath, 'utf-8');
const modMatch = content.match(/^module\s+(\S+)/m);
const modName = modMatch?.[1];
return {
runtime: 'go',
packageManager: 'go',
projectName: modName ? modName.split('/').pop() : undefined,
};
} catch { /* skip */ }
}

return undefined;
}

// --- Git clone ---------------------------------------------------------------

export interface CloneResult {
cloneDir: string;
stack: DetectedStack | undefined;
projectName: string;
}

/**
* Shallow-clone a git repo into a temp directory and detect the stack.
* Throws on clone failure.
*/
export function cloneAndDetect(input: ResolvedInput): CloneResult {
const name = input.inferredName ?? 'repo';
const rand = randomBytes(4).toString('hex');
const cloneDir = join(tmpdir(), `sh1pt-build-${name}-${rand}`);

const result = spawnSync('git', ['clone', '--depth=1', input.value, cloneDir], {
stdio: 'pipe',
timeout: 60_000,
});

if (result.status !== 0) {
const stderr = result.stderr?.toString().trim() ?? 'unknown error';
throw new Error(`git clone failed: ${stderr}`);
}

const stack = detectStack(cloneDir);
const projectName = stack?.projectName ?? name;

return { cloneDir, stack, projectName };
}

// --- Command -----------------------------------------------------------------

export const buildCmd = new Command('build')
.description('Build one or more targets locally or in the sh1pt cloud')
.option('-t, --target <id...>', 'target ids to build (default: all enabled)')
.option('-c, --channel <name>', 'release channel', 'stable')
.option('--cloud', 'run build in sh1pt cloud instead of locally')
.option('--from <input>', 'existing git repo, live url, local path, or manifest doc to build from')
.action((opts: { target?: string[]; channel: string; cloud?: boolean; from?: string }) => {
.option('--keep-clone', 'keep the cloned repo instead of cleaning up after build')
.action((opts: { target?: string[]; channel: string; cloud?: boolean; from?: string; keepClone?: boolean }) => {
const targets = opts.target?.join(', ') ?? 'all enabled';
const where = opts.cloud ? 'cloud' : 'local';
if (opts.from) {
const input = resolveInput(opts.from);

if (input.kind === 'git') {
const { cloneDir, stack, projectName } = cloneAndDetect(input);

console.log(kleur.green('✔ Cloned successfully'));
console.log();
console.log(kleur.bold('Build summary'));
console.log(` project: ${projectName}`);
console.log(` stack: ${stack ? `${stack.runtime} (${stack.packageManager ?? 'unknown'})` : 'unknown'}`);
console.log(` channel: ${opts.channel}`);
console.log(` target: ${where}`);
console.log(` clone: ${cloneDir}`);

if (!opts.keepClone) {
rmSync(cloneDir, { recursive: true, force: true });
console.log(kleur.dim(' (clone removed — use --keep-clone to retain)'));
}
return;
}

// Other kinds remain stubs for now.
console.log(kleur.cyan(`[stub] build (${where}) · channel=${opts.channel} · from=${describeInput(input)}`));
// TODO: kind==='git' → clone and detect stack; kind==='path' → load manifest;
// kind==='doc' → parse manifest; kind==='url' → HEAD/fetch to infer stack.
// TODO: kind==='path' → load manifest; kind==='doc' → parse manifest;
// kind==='url' → HEAD/fetch to infer stack.
return;
}
console.log(kleur.cyan(`[stub] build (${where}) · channel=${opts.channel} · targets=${targets}`));
Expand Down
Loading