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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules/
dist/
*.tsbuildinfo
.charter/
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
},
"dependencies": {
"@stackbilt/core": "^1.0.0",
"@stackbilt/scaffold-core": "^1.1.0"
"@stackbilt/scaffold-core": "^1.2.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
Expand Down
15 changes: 8 additions & 7 deletions src/__tests__/auth-wiring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,29 +123,30 @@ describe('architect — governance-only (no network)', () => {
});
});

describe('run — gateway vs engine routing', () => {
it('uses the gateway (scaffold) when the env var provides an API key', async () => {
describe('run — offline-first with explicit --gateway opt-in', () => {
it('uses local buildScaffold() by default — no gateway call even with API key', async () => {
mockedResolveApiKey.mockReturnValue({ apiKey: 'ea_env_gateway', source: 'env' });

await runCommand(options, ['a description', '--dry-run']);

expect(hoisted.scaffoldFn).toHaveBeenCalledTimes(1);
// No EngineClient methods called — local path is zero-network
expect(hoisted.scaffoldFn).not.toHaveBeenCalled();
expect(hoisted.buildFn).not.toHaveBeenCalled();
});

it('falls back to engine /build when no API key is resolved', async () => {
it('uses local buildScaffold() with no API key — no credentials required', async () => {
mockedResolveApiKey.mockReturnValue(null);

await runCommand(options, ['a description', '--dry-run']);

expect(hoisted.buildFn).toHaveBeenCalledTimes(1);
expect(hoisted.scaffoldFn).not.toHaveBeenCalled();
expect(hoisted.buildFn).not.toHaveBeenCalled();
});

it('uses the gateway when login-stored credentials are resolved (parity with env path)', async () => {
it('uses the gateway scaffold when --gateway flag is passed with an API key', async () => {
mockedResolveApiKey.mockReturnValue({ apiKey: 'sb_live_stored', source: 'credentials' });

await runCommand(options, ['a description', '--dry-run']);
await runCommand(options, ['a description', '--dry-run', '--gateway']);

expect(hoisted.scaffoldFn).toHaveBeenCalledTimes(1);
expect(hoisted.buildFn).not.toHaveBeenCalled();
Expand Down
16 changes: 11 additions & 5 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,21 +101,27 @@ Examples:
Usage: stackbilt run <description> [options]

Run the full scaffold pipeline: classify, build, and write project files.
Default: fully offline using @stackbilt/scaffold-core (zero network, no API key required).

Options:
--file <path> Read description from a file instead of inline argument
--output <dir> Output directory (default: ./<slugified-description>)
--seed <n> Deterministic seed for stack selection
--url <url> Override engine base URL
--framework <name> Constrain framework selection
--database <name> Constrain database selection
--cloudflare-only Only consider Cloudflare-native primitives
--gateway Use the Stackbilt gateway (requires API key, TarotScript tier-2)
--persist Save scaffold to your platform account (requires API key)
--oracle Request LLM polish on the persisted scaffold (requires --persist)
--seed <n> Deterministic seed (gateway only)
--url <url> Override engine base URL (gateway only)
--framework <name> Constrain framework selection (gateway only)
--database <name> Constrain database selection (gateway only)
--cloudflare-only Only consider Cloudflare-native primitives (gateway only)
--dry-run Show what would be written, without writing files
--format json Emit scaffold result as JSON

Examples:
stackbilt run "real-time chat app with Durable Objects"
stackbilt run "multi-tenant SaaS API" --persist --oracle
stackbilt run --file spec.md --output ./my-project
stackbilt run "webhook handler" --gateway
`.trimStart(),

scaffold: `
Expand Down
113 changes: 90 additions & 23 deletions src/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { resolveApiKey } from '../credentials.js';
import { EngineClient, type BuildRequest, type ScaffoldResult } from '../http-client.js';
import { buildScaffold } from '@stackbilt/scaffold-core';

const PLATFORM_BASE_URL = process.env.STACKBILT_URL ?? 'https://stackbilder.com';

// Write the unified cache contract so `stackbilt scaffold` can use it.
// Shape: { intention, pattern, classification, governance, files?, createdAt }
function writeCachedBuild(
Expand All @@ -30,6 +32,62 @@ function writeCachedBuild(
);
}

// Adapt LocalScaffoldResult to the ScaffoldResult shape used by the rest of the command.
function localScaffoldToResult(intention: string): ScaffoldResult {
const core = buildScaffold(intention);
const roleMap: Record<string, 'config' | 'scaffold' | 'governance' | 'test' | 'doc'> = {
entry: 'scaffold',
config: 'config',
test: 'test',
migration: 'scaffold',
contract: 'governance',
adf: 'governance',
readme: 'doc',
};
return {
files: core.files.map(f => ({
path: f.path,
content: f.content,
role: roleMap[f.role] ?? 'scaffold',
})),
fileSource: 'basic',
nextSteps: [
'npm install',
'npx wrangler dev',
`Pattern: ${core.classification.pattern} (confidence: ${Math.round(core.classification.confidence * 100)}%)`,
],
seed: undefined,
facts: core.facts as unknown as Record<string, unknown>,
};
}

// POST the scaffold result to the platform /api/flows for platform record.
async function persistToPlatform(
intention: string,
oracle: boolean,
apiKey: string,
): Promise<{ id: string } | null> {
try {
const res = await fetch(`${PLATFORM_BASE_URL}/api/flows`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify({ intention, oracle }),
});
if (!res.ok) {
const text = await res.text();
console.error(` [warn] Persist failed (${res.status}): ${text}`);
return null;
}
return res.json() as Promise<{ id: string }>;
} catch (err) {
console.error(` [warn] Persist error: ${err}`);
return null;
}
}

const PHASE_LABELS = ['PRODUCT', 'UX', 'RISK', 'ARCHITECT', 'TDD', 'SPRINT'];
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];

Expand Down Expand Up @@ -98,58 +156,58 @@ export async function runCommand(options: CLIOptions, args: string[]): Promise<n

const resolvedOutput = outputDir ?? `./${slugify(description)}`;
const dryRun = args.includes('--dry-run');
const useGateway = args.includes('--gateway');
const persist = args.includes('--persist');
const oracle = args.includes('--oracle');

const resolved = resolveApiKey();
const baseUrl = urlOverride;
const client = new EngineClient({
baseUrl: baseUrl ?? resolved?.baseUrl,
apiKey: resolved?.apiKey ?? null,
});

const useGateway = !!resolved?.apiKey;
if (useGateway && !resolved?.apiKey) {
throw new CLIError('--gateway requires an API key. Run `stackbilt login --key sb_live_xxx` first.');
}

if (persist && !resolved?.apiKey) {
throw new CLIError('--persist requires an API key. Run `stackbilt login --key sb_live_xxx` first.');
}

let scaffoldPromise: Promise<ScaffoldResult>;

if (useGateway) {
const client = new EngineClient({
baseUrl: urlOverride ?? resolved?.baseUrl,
apiKey: resolved?.apiKey ?? null,
});
scaffoldPromise = client.scaffold({
description,
project_type: args.includes('--cloudflare-only') ? 'worker' : undefined,
complexity: undefined,
seed: seedStr ? parseInt(seedStr, 10) : undefined,
});
} else {
const request: BuildRequest = { description, constraints: {} };
if (args.includes('--cloudflare-only')) request.constraints!.cloudflareOnly = true;
if (fwOverride) request.constraints!.framework = fwOverride;
if (dbOverride) request.constraints!.database = dbOverride;
if (seedStr) request.seed = parseInt(seedStr, 10);

scaffoldPromise = client.build(request).then(r => {
return {
files: Object.entries(r.scaffold).map(([p, content]) => ({ path: p, content, role: 'scaffold' as const })),
fileSource: 'engine' as const,
nextSteps: ['npm install', 'npm run dev'],
seed: r.seed,
receipt: r.receipt,
};
});
// Default: fully offline, zero network
scaffoldPromise = Promise.resolve(localScaffoldToResult(description));
}

if (options.format === 'json') {
const result = await scaffoldPromise;
console.log(JSON.stringify({ ...result, outputDir: resolvedOutput, dryRun }, null, 2));
const output: Record<string, unknown> = { ...result, outputDir: resolvedOutput, dryRun };
if (!dryRun) {
writeFiles(resolvedOutput, result.files);
writeCachedBuild(description, result.files.map(({ path: p, content }) => ({ path: p, content })), options.configPath);
}
if (persist && resolved?.apiKey) {
const persisted = await persistToPlatform(description, oracle, resolved.apiKey);
if (persisted) output.flowId = persisted.id;
}
console.log(JSON.stringify(output, null, 2));
return EXIT_CODE.SUCCESS;
}

const isTTY = process.stdout.isTTY === true;

console.log('');
if (!useGateway) {
console.log(' \x1b[2m(tip: run `stackbilt login --key sb_live_xxx` for deployment-ready scaffolds)\x1b[0m');
console.log(' \x1b[2m(offline · @stackbilt/scaffold-core · zero network)\x1b[0m');
console.log('');
}

Expand Down Expand Up @@ -215,6 +273,15 @@ export async function runCommand(options: CLIOptions, args: string[]): Promise<n
}
}

if (persist && resolved?.apiKey && !dryRun) {
const persisted = await persistToPlatform(description, oracle, resolved.apiKey);
if (persisted) {
console.log('');
console.log(` → Persisted to platform · flow ID: ${persisted.id}`);
if (oracle) console.log(' → Oracle polish queued');
}
}

console.log('');
return EXIT_CODE.SUCCESS;
}
Expand Down