diff --git a/packages/cli/src/commands/build.test.ts b/packages/cli/src/commands/build.test.ts index 2e797686..4688f7fa 100644 --- a/packages/cli/src/commands/build.test.ts +++ b/packages/cli/src/commands/build.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'node: import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { execSync } from 'node:child_process'; -import { detectStack, cloneAndDetect } from './build.js'; +import { detectStack, cloneAndDetect, detectLocalPath } from './build.js'; import type { ResolvedInput } from '../input.js'; describe('detectStack', () => { @@ -103,6 +103,39 @@ describe('detectStack', () => { expect(result).toBeUndefined(); }); + it('detects a local --from path project', () => { + const dir = makeTempDir(); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + name: 'local-path-app', + packageManager: 'pnpm@9.12.0', + })); + + const result = detectLocalPath({ + kind: 'path', + raw: dir, + value: dir, + inferredName: 'fallback-name', + exists: true, + }); + + expect(result.path).toBe(dir); + expect(result.projectName).toBe('local-path-app'); + expect(result.stack!.runtime).toBe('node'); + expect(result.stack!.packageManager).toBe('pnpm'); + }); + + it('throws when a local --from path is missing', () => { + const dir = join(makeTempDir(), 'missing'); + + expect(() => detectLocalPath({ + kind: 'path', + raw: dir, + value: dir, + inferredName: 'missing', + exists: false, + })).toThrow('local path not found'); + }); + it('prefers package.json over other manifests when multiple exist', () => { const dir = makeTempDir(); writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'multi' })); diff --git a/packages/cli/src/commands/build.ts b/packages/cli/src/commands/build.ts index b432ce7d..ed39ba1c 100644 --- a/packages/cli/src/commands/build.ts +++ b/packages/cli/src/commands/build.ts @@ -1,6 +1,6 @@ import { Command } from 'commander'; import { spawnSync } from 'node:child_process'; -import { readFileSync, rmSync, existsSync } from 'node:fs'; +import { readFileSync, rmSync, existsSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { randomBytes } from 'node:crypto'; @@ -36,16 +36,6 @@ export interface DetectedStack { * Inspect a directory root and return detected stack info based on manifest * files. Returns undefined if nothing recognizable is found. */ -/** - * Extract a human-readable project name from a Go module path. - * Strips major-version suffixes so `github.com/user/my-go-app/v2` - * returns `my-go-app` rather than `v2`. - */ -function goProjectName(module: string): string { - const stripped = module.replace(/\/v\d+$/, ''); - return stripped.split('/').pop() ?? stripped; -} - export function detectStack(dir: string): DetectedStack | undefined { // Node (package.json) const pkgPath = join(dir, 'package.json'); @@ -103,7 +93,7 @@ export function detectStack(dir: string): DetectedStack | undefined { return { runtime: 'go', packageManager: 'go', - projectName: modName ? goProjectName(modName) : undefined, + projectName: modName ? modName.split('/').pop() : undefined, }; } catch { /* skip */ } } @@ -119,6 +109,12 @@ export interface CloneResult { projectName: string; } +export interface LocalPathResult { + path: string; + stack: DetectedStack | undefined; + projectName: string; +} + /** * Shallow-clone a git repo into a temp directory and detect the stack. * Throws on clone failure. @@ -144,6 +140,41 @@ export function cloneAndDetect(input: ResolvedInput): CloneResult { return { cloneDir, stack, projectName }; } +/** + * Inspect a local project directory and detect the same stack metadata used + * by git inputs. Throws when the path is missing or points at a file. + */ +export function detectLocalPath(input: ResolvedInput): LocalPathResult { + if (!input.exists || !existsSync(input.value)) { + throw new Error(`local path not found: ${input.value}`); + } + if (!statSync(input.value).isDirectory()) { + throw new Error(`local path must be a directory: ${input.value}`); + } + + const stack = detectStack(input.value); + const projectName = stack?.projectName ?? input.inferredName ?? 'project'; + + return { path: input.value, stack, projectName }; +} + +function printBuildSummary(args: { + projectName: string; + stack: DetectedStack | undefined; + channel: string; + where: string; + sourceLabel: string; + sourceValue: string; +}): void { + console.log(); + console.log(kleur.bold('Build summary')); + console.log(` project: ${args.projectName}`); + console.log(` stack: ${args.stack ? `${args.stack.runtime} (${args.stack.packageManager ?? 'unknown'})` : 'unknown'}`); + console.log(` channel: ${args.channel}`); + console.log(` target: ${args.where}`); + console.log(` ${args.sourceLabel}: ${args.sourceValue}`); +} + // --- Command ----------------------------------------------------------------- export const buildCmd = new Command('build') @@ -178,6 +209,21 @@ export const buildCmd = new Command('build') return; } + if (input.kind === 'path') { + const { path, stack, projectName } = detectLocalPath(input); + + console.log(kleur.green('Local path inspected')); + printBuildSummary({ + projectName, + stack, + channel: opts.channel, + where, + sourceLabel: 'path', + sourceValue: path, + }); + return; + } + // Other kinds remain stubs for now. console.log(kleur.cyan(`[stub] build (${where}) · channel=${opts.channel} · from=${describeInput(input)}`)); // TODO: kind==='path' → load manifest; kind==='doc' → parse manifest;