From bc40207699287c86d397a3f78db0d8364e4e7d46 Mon Sep 17 00:00:00 2001 From: Yuli Date: Thu, 14 May 2026 15:54:55 +0800 Subject: [PATCH] Implement local build from manifest --- packages/cli/src/build-project.test.ts | 109 +++++++++++++++++++++++++ packages/cli/src/build-project.ts | 108 ++++++++++++++++++++++++ packages/cli/src/commands/build.ts | 47 ++++++++--- packages/cli/src/manifest-loader.ts | 73 +++++++++++++++++ 4 files changed, 327 insertions(+), 10 deletions(-) create mode 100644 packages/cli/src/build-project.test.ts create mode 100644 packages/cli/src/build-project.ts create mode 100644 packages/cli/src/manifest-loader.ts diff --git a/packages/cli/src/build-project.test.ts b/packages/cli/src/build-project.test.ts new file mode 100644 index 00000000..013af058 --- /dev/null +++ b/packages/cli/src/build-project.test.ts @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { buildProject } from './build-project.js'; +import { loadManifestFromProject } from './manifest-loader.js'; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +async function tempProject(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'sh1pt-cli-build-')); + tempDirs.push(dir); + return dir; +} + +describe('loadManifestFromProject', () => { + it('loads a TypeScript sh1pt config with defineConfig', async () => { + const projectDir = await tempProject(); + await writeFile(join(projectDir, 'sh1pt.config.ts'), ` + import { defineConfig } from '@profullstack/sh1pt-core'; + + export default defineConfig({ + name: 'demo', + version: '1.2.3', + targets: { + web: { use: 'web-static', config: { dir: './dist', provider: 'netlify' } }, + }, + }); + `); + + const loaded = await loadManifestFromProject(projectDir); + + expect(loaded.manifest.name).toBe('demo'); + expect(loaded.manifest.channels).toEqual(['stable', 'beta', 'canary']); + expect(loaded.manifest.targets.web?.use).toBe('web-static'); + }); +}); + +describe('buildProject', () => { + it('runs selected target adapters from the local manifest', async () => { + const projectDir = await tempProject(); + await writeFile(join(projectDir, 'sh1pt.config.ts'), ` + import { defineConfig } from '@profullstack/sh1pt-core'; + + export default defineConfig({ + name: 'demo', + version: '1.2.3', + targets: { + web: { + use: 'web-static', + config: { dir: './dist/web', provider: 'netlify' }, + }, + brew: { + use: 'pkg-homebrew', + config: { + tap: 'acme/homebrew-tools', + formulaName: 'demo', + binaries: [ + { + platform: 'darwin-arm64', + url: 'https://downloads.example.com/demo-1.2.3-darwin-arm64.tar.gz', + sha256: '${'a'.repeat(64)}', + }, + ], + }, + }, + }, + }); + `); + + const results = await buildProject({ + projectDir, + channel: 'beta', + targets: ['brew'], + }); + + expect(results).toEqual([ + expect.objectContaining({ + targetId: 'brew', + adapterId: 'pkg-homebrew', + }), + ]); + + const formula = await readFile(results[0]!.artifact, 'utf8'); + expect(formula).toContain('class Demo < Formula'); + expect(formula).toContain('version "1.2.3"'); + }); + + it('rejects unknown target names with the available target list', async () => { + const projectDir = await tempProject(); + await writeFile(join(projectDir, 'sh1pt.config.json'), JSON.stringify({ + name: 'demo', + version: '1.2.3', + targets: { + web: { use: 'web-static', config: { dir: './dist', provider: 'netlify' } }, + }, + })); + + await expect(buildProject({ + projectDir, + channel: 'stable', + targets: ['missing'], + })).rejects.toThrow('Available targets: web'); + }); +}); diff --git a/packages/cli/src/build-project.ts b/packages/cli/src/build-project.ts new file mode 100644 index 00000000..1ea49a78 --- /dev/null +++ b/packages/cli/src/build-project.ts @@ -0,0 +1,108 @@ +import { mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { BuildContext, BuildResult, Target, TargetSpec } from '@profullstack/sh1pt-core'; +import { loadManifestFromProject } from './manifest-loader.js'; + +export interface BuildProjectOptions { + projectDir: string; + channel: string; + targets?: string[]; + dryRun?: boolean; + log?: BuildContext['log']; +} + +export interface TargetBuildResult { + targetId: string; + adapterId: string; + artifact: string; + meta?: Record; +} + +export async function buildProject(options: BuildProjectOptions): Promise { + const { manifest, projectDir } = await loadManifestFromProject(options.projectDir); + const targetEntries = selectTargets(manifest.targets, options.targets); + const results: TargetBuildResult[] = []; + + for (const [targetId, spec] of targetEntries) { + if (spec.enabled === false) continue; + + const adapter = await loadTargetAdapter(spec.use); + const config = adapter.validate ? adapter.validate(spec.config) : spec.config; + const outDir = join(projectDir, '.sh1pt', 'build', options.channel, targetId); + await mkdir(outDir, { recursive: true }); + + const result: BuildResult = await adapter.build({ + projectDir, + outDir, + version: manifest.version, + channel: options.channel, + env: currentEnv(), + secret: (key) => process.env[key], + log: options.log ?? (() => {}), + dryRun: options.dryRun, + }, config); + + results.push({ + targetId, + adapterId: adapter.id, + artifact: result.artifact, + meta: result.meta, + }); + } + + return results; +} + +function selectTargets(targets: Record, requested?: string[]): Array<[string, TargetSpec]> { + if (!requested?.length) return Object.entries(targets); + + const selected: Array<[string, TargetSpec]> = []; + for (const targetId of requested) { + const spec = targets[targetId]; + if (!spec) { + const available = Object.keys(targets).sort().join(', '); + throw new Error(`Unknown target "${targetId}". Available targets: ${available}`); + } + selected.push([targetId, spec]); + } + return selected; +} + +async function loadTargetAdapter(use: string): Promise> { + if (!/^[a-z0-9][a-z0-9-]*$/i.test(use)) { + throw new Error(`Invalid target adapter id: ${use}`); + } + + const packageName = `@profullstack/sh1pt-target-${use}`; + try { + return normalizeAdapter(await import(packageName), packageName); + } catch (packageError) { + const localUrl = new URL(`../../targets/${use}/src/index.ts`, import.meta.url); + try { + return normalizeAdapter(await import(localUrl.href), localUrl.href); + } catch { + const message = packageError instanceof Error ? packageError.message : String(packageError); + throw new Error(`Could not load target adapter "${use}" (${packageName}): ${message}`); + } + } +} + +function normalizeAdapter(mod: unknown, source: string): Target { + const maybeDefault = mod && typeof mod === 'object' && 'default' in mod + ? (mod as { default: unknown }).default + : mod; + + if (!maybeDefault || typeof maybeDefault !== 'object' || !('build' in maybeDefault)) { + throw new Error(`Target adapter ${source} did not export a buildable adapter`); + } + + return maybeDefault as Target; +} + +function currentEnv(): Record { + const env: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) env[key] = value; + } + return env; +} diff --git a/packages/cli/src/commands/build.ts b/packages/cli/src/commands/build.ts index 93770ace..e36f4227 100644 --- a/packages/cli/src/commands/build.ts +++ b/packages/cli/src/commands/build.ts @@ -2,6 +2,7 @@ import { Command } from 'commander'; import { spawnSync } from 'node:child_process'; import kleur from 'kleur'; import { describeInput, resolveInput } from '../input.js'; +import { buildProject } from '../build-project.js'; import { entityCmd } from './entity.js'; function run(argv: string[], env?: Record): number { @@ -21,18 +22,44 @@ export const buildCmd = new Command('build') .option('-c, --channel ', 'release channel', 'stable') .option('--cloud', 'run build in sh1pt cloud instead of locally') .option('--from ', 'existing git repo, live url, local path, or manifest doc to build from') - .action((opts: { target?: string[]; channel: string; cloud?: boolean; from?: string }) => { - const targets = opts.target?.join(', ') ?? 'all enabled'; - const where = opts.cloud ? 'cloud' : 'local'; - if (opts.from) { - const input = resolveInput(opts.from); - 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. + .option('--dry-run', 'validate and render side-effect-free build outputs when supported') + .action(async (opts: { target?: string[]; channel: string; cloud?: boolean; from?: string; dryRun?: boolean }) => { + if (opts.cloud) { + const targets = opts.target?.join(', ') ?? 'all enabled'; + console.log(kleur.cyan(`[stub] build (cloud) · channel=${opts.channel} · targets=${targets}`)); return; } - console.log(kleur.cyan(`[stub] build (${where}) · channel=${opts.channel} · targets=${targets}`)); - // TODO: load manifest, resolve targets, invoke Target.build(), stream logs + + const input = resolveInput(opts.from ?? process.cwd()); + if (input.kind !== 'path') { + console.log(kleur.yellow(`build --from currently runs local project paths; got ${describeInput(input)}`)); + return; + } + if (input.exists === false) { + console.error(kleur.red(`project path does not exist: ${input.value}`)); + process.exit(1); + } + + const results = await buildProject({ + projectDir: input.value, + channel: opts.channel, + targets: opts.target, + dryRun: opts.dryRun, + log: (message, level = 'info') => { + const prefix = level === 'error' ? kleur.red('error') : level === 'warn' ? kleur.yellow('warn') : kleur.dim('info'); + console.log(`${prefix} ${message}`); + }, + }); + + if (results.length === 0) { + console.log(kleur.dim('No enabled targets to build.')); + return; + } + + console.log(kleur.green(`Built ${results.length} target${results.length === 1 ? '' : 's'} from ${describeInput(input)}`)); + for (const result of results) { + console.log(` ${kleur.cyan(result.targetId)} (${result.adapterId}) → ${result.artifact}`); + } }); // Entity-ops lives under `build` — an entity (certificate, bylaws, filing diff --git a/packages/cli/src/manifest-loader.ts b/packages/cli/src/manifest-loader.ts new file mode 100644 index 00000000..1de9b426 --- /dev/null +++ b/packages/cli/src/manifest-loader.ts @@ -0,0 +1,73 @@ +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { manifestSchema, type Manifest } from '@profullstack/sh1pt-core'; + +const CONFIG_FILES = [ + 'sh1pt.config.mjs', + 'sh1pt.config.js', + 'sh1pt.config.ts', + 'sh1pt.config.json', +]; + +export interface LoadedManifest { + manifest: Manifest; + configPath: string; + projectDir: string; +} + +export async function loadManifestFromProject(projectDir: string): Promise { + const configPath = findConfig(projectDir); + if (!configPath) { + throw new Error(`No sh1pt config found in ${projectDir}`); + } + + const raw = await loadConfigModule(configPath); + const manifest = manifestSchema.parse(raw); + return { + manifest, + configPath, + projectDir: dirname(configPath), + }; +} + +function findConfig(projectDir: string): string | undefined { + for (const name of CONFIG_FILES) { + const candidate = join(projectDir, name); + if (existsSync(candidate)) return candidate; + } + return undefined; +} + +async function loadConfigModule(configPath: string): Promise { + if (configPath.endsWith('.json')) { + return JSON.parse(await readFile(configPath, 'utf8')) as unknown; + } + + try { + const url = pathToFileURL(configPath); + url.searchParams.set('t', String(Date.now())); + const mod = await import(url.href); + return 'default' in mod ? mod.default : mod; + } catch (err) { + if (!configPath.endsWith('.ts')) throw err; + return loadSimpleTsConfig(configPath); + } +} + +async function loadSimpleTsConfig(configPath: string): Promise { + const source = await readFile(configPath, 'utf8'); + const withoutDefineConfigImport = source.replace( + /^\s*import\s+\{\s*defineConfig\s*\}\s+from\s+['"]@profullstack\/sh1pt-core['"];?\s*/m, + '', + ); + const runnable = withoutDefineConfigImport.replace(/\bexport\s+default\s+/, 'return '); + + if (runnable === withoutDefineConfigImport) { + throw new Error(`Could not load ${configPath}: expected an export default config`); + } + + const evaluate = new Function('defineConfig', runnable); + return evaluate((manifest: unknown) => manifest) as unknown; +}