Skip to content
Closed
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
109 changes: 109 additions & 0 deletions packages/cli/src/build-project.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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');
});
});
108 changes: 108 additions & 0 deletions packages/cli/src/build-project.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}

export async function buildProject(options: BuildProjectOptions): Promise<TargetBuildResult[]> {
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<string, TargetSpec>, 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<Target<unknown>> {
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<unknown> {
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<unknown>;
}

function currentEnv(): Record<string, string> {
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined) env[key] = value;
}
return env;
}
47 changes: 37 additions & 10 deletions packages/cli/src/commands/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>): number {
Expand All @@ -21,18 +22,44 @@ export const buildCmd = new Command('build')
.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 }) => {
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
Expand Down
73 changes: 73 additions & 0 deletions packages/cli/src/manifest-loader.ts
Original file line number Diff line number Diff line change
@@ -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<LoadedManifest> {
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<unknown> {
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<unknown> {
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;
}