From 6c0ead4a8e2ca5c9173a7e8ff55cb67418a5b6f7 Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Sat, 23 May 2026 07:03:21 -0500 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20bootstrap=20@stackbilt/build=20pack?= =?UTF-8?q?age=20=E2=80=94=20port=20commercial=20surface=20from=20@stackbi?= =?UTF-8?q?lt/cli?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports login, architect, run, scaffold commands + credentials, http-client, flags, and scaffold-contract-types from @stackbilt/cli. Removes the deprecation-warning shim (not needed in the new home). Adds release workflow mirroring charter's OIDC trusted-publisher pattern, adapted for a single-package non-monorepo repo. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 152 ++++++++++++++++++++ README.md | 3 + package.json | 61 ++++++++ src/__tests__/auth-wiring.test.ts | 147 +++++++++++++++++++ src/__tests__/credentials.test.ts | 141 +++++++++++++++++++ src/__tests__/login.test.ts | 39 +++++ src/cli.ts | 26 ++++ src/commands/architect.ts | 125 +++++++++++++++++ src/commands/login.ts | 73 ++++++++++ src/commands/run.ts | 203 +++++++++++++++++++++++++++ src/commands/scaffold.ts | 78 ++++++++++ src/credentials.ts | 63 +++++++++ src/flags.ts | 24 ++++ src/http-client.ts | 155 ++++++++++++++++++++ src/index.ts | 18 +++ src/types/scaffold-contract-types.ts | 80 +++++++++++ tsconfig.json | 21 +++ vitest.config.ts | 7 + 18 files changed, 1416 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 README.md create mode 100644 package.json create mode 100644 src/__tests__/auth-wiring.test.ts create mode 100644 src/__tests__/credentials.test.ts create mode 100644 src/__tests__/login.test.ts create mode 100644 src/cli.ts create mode 100644 src/commands/architect.ts create mode 100644 src/commands/login.ts create mode 100644 src/commands/run.ts create mode 100644 src/commands/scaffold.ts create mode 100644 src/credentials.ts create mode 100644 src/flags.ts create mode 100644 src/http-client.ts create mode 100644 src/index.ts create mode 100644 src/types/scaffold-contract-types.ts create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..61fcb93 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,152 @@ +name: Release + +on: + push: + tags: + - 'v*' + branches-ignore: + - '**' + workflow_dispatch: + inputs: + tag: + description: 'Existing tag to publish (for backfill), e.g. v0.4.2' + required: true + type: string + +permissions: + contents: write + +jobs: + publish-release: + if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Resolve tag + id: tag + shell: bash + run: | + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + TAG="${{ inputs.tag }}" + else + TAG="${GITHUB_REF_NAME}" + fi + + if [[ -z "${TAG}" ]]; then + echo "Tag could not be resolved." >&2 + exit 1 + fi + + echo "value=${TAG}" >> "$GITHUB_OUTPUT" + + - name: Verify tag + shell: bash + run: | + TAG="${{ steps.tag.outputs.value }}" + if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Invalid tag format: ${TAG}. Expected v.." >&2 + exit 1 + fi + + if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then + PKG_VERSION="$(node -p "require('./package.json').version")" + EXPECTED_TAG="v${PKG_VERSION}" + + if [[ "${TAG}" != "${EXPECTED_TAG}" ]]; then + echo "Tag/version mismatch on push: got ${TAG}, expected ${EXPECTED_TAG}" >&2 + exit 1 + fi + else + if ! git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then + echo "Tag not found in repository: ${TAG}" >&2 + exit 1 + fi + fi + + - name: Build release notes from CHANGELOG + shell: bash + run: | + TAG="${{ steps.tag.outputs.value }}" + VERSION="${TAG#v}" + + awk -v version="${VERSION}" ' + BEGIN { in_section=0 } + $0 ~ "^## \\[" version "\\]" { in_section=1; print; next } + in_section && $0 ~ "^## \\[" { exit } + in_section { print } + ' CHANGELOG.md > release_notes.md + + if [[ ! -s release_notes.md ]]; then + echo "## ${TAG}" > release_notes.md + echo >> release_notes.md + echo "See CHANGELOG.md for release details." >> release_notes.md + fi + + - name: Create or update GitHub Release + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + with: + tag_name: ${{ steps.tag.outputs.value }} + name: ${{ steps.tag.outputs.value }} + body_path: release_notes.md + generate_release_notes: true + + publish-npm: + if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + ref: ${{ inputs.tag || github.ref }} + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '20' + + - name: Upgrade npm for trusted-publisher support + run: npm install -g npm@latest + + - name: Install dependencies + run: npm install --frozen-lockfile + + - name: Build + run: npm run build + + - name: Verify tag and workspace versions + shell: bash + run: | + TAG="${{ github.event.inputs.tag || github.ref_name }}" + if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Invalid tag format: ${TAG}. Expected v.." + exit 1 + fi + + EXPECTED="${TAG#v}" + V=$(node -p "require('./package.json').version") + N=$(node -p "require('./package.json').name") + if [[ "$V" != "$EXPECTED" ]]; then + echo "::error::$N version $V does not match tag $EXPECTED" + exit 1 + fi + + - name: Publish to npm + shell: bash + run: | + TAG="${{ github.event.inputs.tag || github.ref_name }}" + VERSION="${TAG#v}" + mkdir -p release-tarballs + + npm pack --pack-destination ./release-tarballs + + TARBALL="./release-tarballs/stackbilt-build-${VERSION}.tgz" + if npm view "@stackbilt/build@${VERSION}" version &>/dev/null 2>&1; then + echo "Skipping @stackbilt/build@${VERSION} — already published" + else + npm publish "${TARBALL}" --access public --provenance + fi diff --git a/README.md b/README.md new file mode 100644 index 0000000..929c9e4 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# @stackbilt/build + +`@stackbilt/build` is the commercial surface of the Stackbilt toolchain, providing the `stackbilt run`, `stackbilt architect`, `stackbilt login`, and `stackbilt scaffold` commands. These commands generate deployment-ready Cloudflare Workers projects from a plain-language description, manage API key credentials, and write scaffold files to disk. For OSS governance tools (audit, drift, validate, classify), see [Stackbilt-dev/charter](https://github.com/Stackbilt-dev/charter). diff --git a/package.json b/package.json new file mode 100644 index 0000000..ee6d498 --- /dev/null +++ b/package.json @@ -0,0 +1,61 @@ +{ + "name": "@stackbilt/build", + "version": "0.1.0", + "description": "Stackbilt Build CLI — login, architect, run, scaffold commands", + "sideEffects": false, + "type": "module", + "bin": { + "stackbilt": "./dist/cli.js" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "engines": { + "node": ">=18.0.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/Stackbilt-dev/stackbilt-build.git" + }, + "bugs": { + "url": "https://github.com/Stackbilt-dev/stackbilt-build/issues" + }, + "homepage": "https://github.com/Stackbilt-dev/stackbilt-build#readme", + "publishConfig": { + "access": "public", + "provenance": true + }, + "keywords": [ + "stackbilt", + "build", + "cli", + "scaffold", + "architect", + "typescript", + "cloudflare", + "ai", + "agent" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "vitest run" + }, + "dependencies": {}, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.0.0", + "vitest": "^2.0.0" + }, + "license": "Apache-2.0", + "author": "Stackbilt LLC" +} diff --git a/src/__tests__/auth-wiring.test.ts b/src/__tests__/auth-wiring.test.ts new file mode 100644 index 0000000..3f856ad --- /dev/null +++ b/src/__tests__/auth-wiring.test.ts @@ -0,0 +1,147 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const hoisted = vi.hoisted(() => ({ + buildFn: vi.fn(), + scaffoldFn: vi.fn(), + constructorArgs: [] as Array<{ baseUrl?: string; apiKey?: string | null }>, +})); + +vi.mock('../credentials.js', async () => { + const actual = await vi.importActual('../credentials.js'); + return { ...actual, resolveApiKey: vi.fn() }; +}); + +vi.mock('../http-client.js', () => { + return { + EngineClient: class { + constructor(opts: { baseUrl?: string; apiKey?: string | null }) { + hoisted.constructorArgs.push(opts); + } + build = hoisted.buildFn; + scaffold = hoisted.scaffoldFn; + health = vi.fn(); + catalog = vi.fn(); + }, + }; +}); + +import { resolveApiKey } from '../credentials.js'; +import { architectCommand } from '../commands/architect.js'; +import { runCommand } from '../commands/run.js'; +import type { CLIOptions } from '../index.js'; + +const mockedResolveApiKey = vi.mocked(resolveApiKey); + +const options: CLIOptions = { + format: 'json', + configPath: '.charter', + ciMode: false, + yes: true, +}; + +function fakeBuildResult() { + return { + stack: [], + compatibility: { + pairs: [], + totalScore: 0, + normalizedScore: 0, + dominant: '', + tensions: [], + }, + scaffold: {}, + seed: 1, + receipt: 'receipt', + requirements: { + description: 'anything', + keywords: [], + constraints: {}, + complexity: 'moderate', + }, + }; +} + +function fakeScaffoldResult() { + return { + files: [], + fileSource: 'engine' as const, + nextSteps: [], + }; +} + +let tmpCwd: string; + +beforeEach(() => { + tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'charter-wiring-')); + process.chdir(tmpCwd); + fs.mkdirSync(path.join(tmpCwd, '.charter'), { recursive: true }); + hoisted.buildFn.mockReset().mockResolvedValue(fakeBuildResult()); + hoisted.scaffoldFn.mockReset().mockResolvedValue(fakeScaffoldResult()); + hoisted.constructorArgs.length = 0; + mockedResolveApiKey.mockReset(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + vi.spyOn(process.stderr, 'write').mockImplementation(() => true); +}); + +afterEach(() => { + vi.restoreAllMocks(); + process.chdir(os.tmpdir()); + fs.rmSync(tmpCwd, { recursive: true, force: true }); +}); + +describe('architect — auth wiring', () => { + it('forwards the env-sourced API key (and custom baseUrl) to EngineClient', async () => { + mockedResolveApiKey.mockReturnValue({ + apiKey: 'ea_env_wiring', + source: 'env', + baseUrl: 'https://engine.example', + }); + + await architectCommand(options, ['a simple project description']); + + expect(hoisted.constructorArgs).toHaveLength(1); + expect(hoisted.constructorArgs[0].apiKey).toBe('ea_env_wiring'); + expect(hoisted.constructorArgs[0].baseUrl).toBe('https://engine.example'); + }); + + it('passes apiKey=null to EngineClient when resolveApiKey returns null', async () => { + mockedResolveApiKey.mockReturnValue(null); + + await architectCommand(options, ['unauthenticated fallback']); + + expect(hoisted.constructorArgs[0].apiKey).toBeNull(); + }); +}); + +describe('run — gateway vs engine routing', () => { + it('uses the gateway (scaffold) when the env var provides an API key', async () => { + mockedResolveApiKey.mockReturnValue({ apiKey: 'ea_env_gateway', source: 'env' }); + + await runCommand(options, ['a description', '--dry-run']); + + expect(hoisted.scaffoldFn).toHaveBeenCalledTimes(1); + expect(hoisted.buildFn).not.toHaveBeenCalled(); + }); + + it('falls back to engine /build when no API key is resolved', async () => { + mockedResolveApiKey.mockReturnValue(null); + + await runCommand(options, ['a description', '--dry-run']); + + expect(hoisted.buildFn).toHaveBeenCalledTimes(1); + expect(hoisted.scaffoldFn).not.toHaveBeenCalled(); + }); + + it('uses the gateway when login-stored credentials are resolved (parity with env path)', async () => { + mockedResolveApiKey.mockReturnValue({ apiKey: 'sb_live_stored', source: 'credentials' }); + + await runCommand(options, ['a description', '--dry-run']); + + expect(hoisted.scaffoldFn).toHaveBeenCalledTimes(1); + expect(hoisted.buildFn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/credentials.test.ts b/src/__tests__/credentials.test.ts new file mode 100644 index 0000000..106d471 --- /dev/null +++ b/src/__tests__/credentials.test.ts @@ -0,0 +1,141 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs'); + return { + ...actual, + existsSync: vi.fn(() => false), + readFileSync: vi.fn(), + }; +}); + +import * as fs from 'node:fs'; +import { resolveApiKey, API_KEY_ENV_VAR, API_BASE_URL_ENV_VAR } from '../credentials.js'; + +const mockedFs = fs as unknown as { + existsSync: ReturnType; + readFileSync: ReturnType; +}; + +function stubStoredCredentials(apiKey: string, baseUrl?: string): void { + mockedFs.existsSync.mockReturnValue(true); + mockedFs.readFileSync.mockReturnValue(JSON.stringify({ apiKey, baseUrl })); +} + +function stubNoStoredCredentials(): void { + mockedFs.existsSync.mockReturnValue(false); + mockedFs.readFileSync.mockImplementation(() => { + throw new Error('readFileSync should not be called when existsSync=false'); + }); +} + +describe('resolveApiKey', () => { + const originalKeyEnv = process.env[API_KEY_ENV_VAR]; + const originalBaseUrlEnv = process.env[API_BASE_URL_ENV_VAR]; + + beforeEach(() => { + delete process.env[API_KEY_ENV_VAR]; + delete process.env[API_BASE_URL_ENV_VAR]; + mockedFs.existsSync.mockReset(); + mockedFs.readFileSync.mockReset(); + stubNoStoredCredentials(); + }); + + afterEach(() => { + if (originalKeyEnv === undefined) delete process.env[API_KEY_ENV_VAR]; + else process.env[API_KEY_ENV_VAR] = originalKeyEnv; + if (originalBaseUrlEnv === undefined) delete process.env[API_BASE_URL_ENV_VAR]; + else process.env[API_BASE_URL_ENV_VAR] = originalBaseUrlEnv; + }); + + it('returns env var when set', () => { + process.env[API_KEY_ENV_VAR] = 'ea_test_from_env_12345'; + + const result = resolveApiKey(); + + expect(result).not.toBeNull(); + expect(result!.source).toBe('env'); + expect(result!.apiKey).toBe('ea_test_from_env_12345'); + }); + + it('env var wins when both env var and stored credentials are present', () => { + process.env[API_KEY_ENV_VAR] = 'ea_env_wins'; + stubStoredCredentials('sb_live_should_be_ignored', 'https://stored.example'); + + const result = resolveApiKey(); + + expect(result).not.toBeNull(); + expect(result!.source).toBe('env'); + expect(result!.apiKey).toBe('ea_env_wins'); + }); + + it('trims whitespace from the env var', () => { + process.env[API_KEY_ENV_VAR] = ' sb_test_abc '; + + const result = resolveApiKey(); + + expect(result).not.toBeNull(); + expect(result!.source).toBe('env'); + expect(result!.apiKey).toBe('sb_test_abc'); + }); + + it('empty env var falls through to stored credentials', () => { + process.env[API_KEY_ENV_VAR] = ''; + stubStoredCredentials('sb_live_from_disk'); + + const result = resolveApiKey(); + + expect(result).not.toBeNull(); + expect(result!.source).toBe('credentials'); + expect(result!.apiKey).toBe('sb_live_from_disk'); + }); + + it('whitespace-only env var falls through to stored credentials', () => { + process.env[API_KEY_ENV_VAR] = ' \t '; + stubStoredCredentials('sb_live_from_disk'); + + const result = resolveApiKey(); + + expect(result).not.toBeNull(); + expect(result!.source).toBe('credentials'); + expect(result!.apiKey).toBe('sb_live_from_disk'); + }); + + it('returns null when neither env var nor stored credentials are present', () => { + stubNoStoredCredentials(); + + const result = resolveApiKey(); + + expect(result).toBeNull(); + }); + + it('env-var path adopts STACKBILT_API_BASE_URL when set', () => { + process.env[API_KEY_ENV_VAR] = 'ea_with_custom_url'; + process.env[API_BASE_URL_ENV_VAR] = 'https://engine.internal.example'; + + const result = resolveApiKey(); + + expect(result).not.toBeNull(); + expect(result!.source).toBe('env'); + expect(result!.baseUrl).toBe('https://engine.internal.example'); + }); + + it('env-var path leaves baseUrl undefined when STACKBILT_API_BASE_URL is unset', () => { + process.env[API_KEY_ENV_VAR] = 'ea_without_custom_url'; + + const result = resolveApiKey(); + + expect(result).not.toBeNull(); + expect(result!.baseUrl).toBeUndefined(); + }); + + it('credentials path carries baseUrl from the stored file', () => { + stubStoredCredentials('sb_live_from_disk', 'https://engine.custom.example'); + + const result = resolveApiKey(); + + expect(result).not.toBeNull(); + expect(result!.source).toBe('credentials'); + expect(result!.baseUrl).toBe('https://engine.custom.example'); + }); +}); diff --git a/src/__tests__/login.test.ts b/src/__tests__/login.test.ts new file mode 100644 index 0000000..616e293 --- /dev/null +++ b/src/__tests__/login.test.ts @@ -0,0 +1,39 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { loginCommand } from '../commands/login.js'; +import type { CLIOptions } from '../index.js'; +import { API_KEY_ENV_VAR } from '../credentials.js'; + +const options: CLIOptions = { + format: 'text', + configPath: '.charter', + ciMode: false, + yes: false, +}; + +describe('stackbilt login', () => { + const originalEnv = process.env[API_KEY_ENV_VAR]; + + beforeEach(() => { + delete process.env[API_KEY_ENV_VAR]; + }); + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env[API_KEY_ENV_VAR]; + } else { + process.env[API_KEY_ENV_VAR] = originalEnv; + } + vi.restoreAllMocks(); + }); + + it('reports env-var usage when STACKBILT_API_KEY is set and no --key flag', async () => { + process.env[API_KEY_ENV_VAR] = 'ea_login_test_key'; + vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await loginCommand(options, []); + + const stdoutOutput = log.mock.calls.map((c) => String(c[0])).join('\n'); + expect(stdoutOutput).toMatch(new RegExp(`Using ${API_KEY_ENV_VAR} from environment`)); + }); +}); diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..2dd5110 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,26 @@ +#!/usr/bin/env node +import { CLIError, EXIT_CODE } from './index.js'; +import { loginCommand } from './commands/login.js'; +import { architectCommand } from './commands/architect.js'; +import { runCommand } from './commands/run.js'; +import { scaffoldCommand } from './commands/scaffold.js'; + +const [,, cmd, ...args] = process.argv; +const options = { configPath: '.charter', format: 'text' as const, ciMode: false, yes: false }; + +async function run() { + try { + let code = EXIT_CODE.SUCCESS; + if (cmd === 'login') code = await loginCommand(options, args); + else if (cmd === 'architect') code = await architectCommand(options, args); + else if (cmd === 'run') code = await runCommand(options, args); + else if (cmd === 'scaffold') code = await scaffoldCommand(options, args); + else { console.error(`Unknown command: ${cmd}`); code = EXIT_CODE.FAILURE; } + process.exit(code); + } catch (e) { + if (e instanceof CLIError) { console.error(e.message); process.exit(EXIT_CODE.FAILURE); } + throw e; + } +} + +run(); diff --git a/src/commands/architect.ts b/src/commands/architect.ts new file mode 100644 index 0000000..35510f8 --- /dev/null +++ b/src/commands/architect.ts @@ -0,0 +1,125 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { CLIOptions } from '../index.js'; +import { EXIT_CODE, CLIError } from '../index.js'; +import { getFlag } from '../flags.js'; +import { resolveApiKey } from '../credentials.js'; +import { EngineClient, type BuildRequest, type BuildResult } from '../http-client.js'; + +export async function architectCommand(options: CLIOptions, args: string[]): Promise { + const filePath = getFlag(args, '--file'); + const positional = args.filter(a => !a.startsWith('-') && a !== filePath); + let description: string; + + if (filePath) { + if (!fs.existsSync(filePath)) throw new CLIError(`File not found: ${filePath}`); + description = fs.readFileSync(filePath, 'utf-8').trim(); + } else if (positional.length > 0) { + description = positional.join(' '); + } else { + throw new CLIError('Provide a project description:\n charter architect "Build a real-time chat app"\n charter architect --file spec.md'); + } + + if (!description) throw new CLIError('Empty description.'); + + const request: BuildRequest = { description, constraints: {} }; + if (args.includes('--cloudflare-only')) request.constraints!.cloudflareOnly = true; + const fw = getFlag(args, '--framework'); + if (fw) request.constraints!.framework = fw; + const db = getFlag(args, '--database'); + if (db) request.constraints!.database = db; + + const seedStr = getFlag(args, '--seed'); + if (seedStr) request.seed = parseInt(seedStr, 10); + + const resolved = resolveApiKey(); + const baseUrl = getFlag(args, '--url'); + const client = new EngineClient({ + baseUrl: baseUrl ?? resolved?.baseUrl, + apiKey: resolved?.apiKey ?? null, + }); + + let result: BuildResult; + try { + result = await client.build(request); + } catch (err) { + throw new CLIError(`Build failed: ${(err as Error).message}`); + } + + const dryRun = args.includes('--dry-run'); + + if (options.format === 'json') { + console.log(JSON.stringify(result, null, 2)); + if (!dryRun) cacheResult(result, options.configPath); + return EXIT_CODE.SUCCESS; + } + + printResult(result); + + if (!dryRun) { + cacheResult(result, options.configPath); + console.log(''); + console.log(`Build cached. Run \`charter scaffold\` to write files.`); + } else { + console.log(''); + console.log('(dry run — no files written)'); + } + + return EXIT_CODE.SUCCESS; +} + +function printResult(r: BuildResult): void { + const c = r.compatibility; + + console.log(''); + console.log(` Stack (seed: ${r.seed}, ${r.requirements.complexity})`); + console.log(''); + + const maxPos = Math.max(...r.stack.map(s => s.position.length)); + const maxName = Math.max(...r.stack.map(s => s.name.length)); + for (const s of r.stack) { + const pos = s.position.padEnd(maxPos); + const name = s.name.padEnd(maxName); + const orient = s.orientation === 'reversed' ? '↓' : '↑'; + const cf = s.cloudflareNative ? ' [CF]' : ''; + console.log(` ${pos} ${name} (${s.element}, ${orient})${cf}`); + } + + console.log(''); + console.log(` Compatibility: ${c.normalizedScore} (${c.pairs.length} pairs, ${c.tensions.length} tensions)`); + + for (const p of c.pairs) { + const sign = p.score > 0 ? '+' : p.score < 0 ? '' : ' '; + console.log(` ${p.techs[0]} + ${p.techs[1]} = ${p.relationship} (${sign}${p.score})`); + } + + if (c.tensions.length > 0) { + console.log(''); + console.log(' Tensions:'); + for (const t of c.tensions) { + console.log(` ⚡ ${t.description}`); + } + } + + console.log(''); + console.log(` Scaffold: ${Object.keys(r.scaffold).length} files`); + for (const f of Object.keys(r.scaffold).sort()) { + const lines = r.scaffold[f].split('\n').length; + console.log(` ${f} (${lines} lines)`); + } + + console.log(''); + console.log(` Keywords: ${r.requirements.keywords.slice(0, 8).join(', ')}`); + console.log(` Receipt: ${r.receipt.slice(0, 16)}`); +} + +function cacheResult(result: BuildResult, configPath: string): void { + const dir = configPath || '.charter'; + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync( + path.join(dir, 'last-build.json'), + JSON.stringify(result, null, 2), + ); +} diff --git a/src/commands/login.ts b/src/commands/login.ts new file mode 100644 index 0000000..d434bc6 --- /dev/null +++ b/src/commands/login.ts @@ -0,0 +1,73 @@ +import type { CLIOptions } from '../index.js'; +import { EXIT_CODE, CLIError } from '../index.js'; +import { getFlag } from '../flags.js'; +import { + loadCredentials, + saveCredentials, + clearCredentials, + API_KEY_ENV_VAR, +} from '../credentials.js'; +import { EngineClient } from '../http-client.js'; + +export async function loginCommand(options: CLIOptions, args: string[]): Promise { + if (args.includes('--logout')) { + clearCredentials(); + console.log('Credentials cleared.'); + return EXIT_CODE.SUCCESS; + } + + const key = getFlag(args, '--key'); + if (!key) { + const existing = loadCredentials(); + const envKey = process.env[API_KEY_ENV_VAR]; + if (envKey && envKey.trim().length > 0) { + const masked = envKey.slice(0, 12) + '...' + envKey.slice(-4); + console.log(`Using ${API_KEY_ENV_VAR} from environment: ${masked}`); + return EXIT_CODE.SUCCESS; + } + if (existing) { + const masked = existing.apiKey.slice(0, 12) + '...' + existing.apiKey.slice(-4); + console.log(`Logged in as: ${masked}`); + if (existing.baseUrl) console.log(`Engine: ${existing.baseUrl}`); + } else { + console.log('Not logged in.'); + console.log(''); + console.log(`Preferred: export ${API_KEY_ENV_VAR}=ea_xxx (or sb_live_xxx, sb_test_xxx).`); + console.log(''); + console.log('Deprecated alternative:'); + console.log(' charter login --key ea_xxx'); + console.log(' charter login --key sb_live_xxx'); + console.log(' charter login --key sb_test_xxx'); + console.log(''); + console.log('Get your API key from auth.stackbilt.dev (ea_) or the Stackbilt dashboard (sb_).'); + } + return EXIT_CODE.SUCCESS; + } + + const VALID_PREFIXES = ['ea_', 'sb_live_', 'sb_test_']; + if (!VALID_PREFIXES.some((p) => key.startsWith(p))) { + throw new CLIError( + `Invalid API key format. Keys must start with one of: ${VALID_PREFIXES.join(', ')}.` + ); + } + + const baseUrl = getFlag(args, '--url'); + + const client = new EngineClient({ baseUrl, apiKey: key }); + try { + const health = await client.health(); + saveCredentials({ apiKey: key, baseUrl }); + + if (options.format === 'json') { + console.log(JSON.stringify({ status: 'authenticated', engine: health.version, catalog: health.catalog })); + } else { + console.log(`Authenticated. Engine v${health.version} (${health.catalog} primitives)`); + if (key.startsWith('sb_test_')) { + console.log('Using test mode.'); + } + } + return EXIT_CODE.SUCCESS; + } catch (err) { + throw new CLIError(`Could not reach engine: ${(err as Error).message}`); + } +} diff --git a/src/commands/run.ts b/src/commands/run.ts new file mode 100644 index 0000000..76c59ce --- /dev/null +++ b/src/commands/run.ts @@ -0,0 +1,203 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { CLIOptions } from '../index.js'; +import { EXIT_CODE, CLIError } from '../index.js'; +import { getFlag } from '../flags.js'; +import { resolveApiKey } from '../credentials.js'; +import { EngineClient, type BuildRequest, type ScaffoldResult } from '../http-client.js'; + +const PHASE_LABELS = ['PRODUCT', 'UX', 'RISK', 'ARCHITECT', 'TDD', 'SPRINT']; +const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + +function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function clearLine(): void { + process.stdout.write('\x1b[2K\r'); +} + +function cursorUp(n: number): void { + if (n > 0) process.stdout.write(`\x1b[${n}A`); +} + +function slugify(description: string): string { + const stopWords = new Set(['a', 'an', 'the', 'with', 'and', 'or', 'for', 'in', 'on', 'to', 'my', 'build', 'create', 'make']); + const words = description.toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .split(/\s+/) + .filter(w => !stopWords.has(w)) + .slice(0, 4); + return words.join('-') || 'my-project'; +} + +function phaseDetail(label: string, result: ScaffoldResult): string { + const fileCount = result.files.length; + const adfFiles = result.files.filter(f => f.path.endsWith('.adf')).length; + const testFiles = result.files.filter(f => f.path.includes('test')).length; + const configFiles = result.files.filter(f => f.path === 'wrangler.toml' || f.path === 'package.json' || f.path === 'tsconfig.json').length; + + switch (label) { + case 'PRODUCT': return `requirements extracted from intent`; + case 'UX': return `interface patterns mapped`; + case 'RISK': return `threats identified and mitigated`; + case 'ARCHITECT': return `${fileCount} files, ${configFiles} configs generated`; + case 'TDD': return `${testFiles || 1} test file${testFiles !== 1 ? 's' : ''} generated`; + case 'SPRINT': return `${adfFiles} governance files, sprint ready`; + default: return 'done'; + } +} + +export async function runCommand(options: CLIOptions, args: string[]): Promise { + const filePath = getFlag(args, '--file'); + const outputDir = getFlag(args, '--output'); + const seedStr = getFlag(args, '--seed'); + const urlOverride = getFlag(args, '--url'); + const fwOverride = getFlag(args, '--framework'); + const dbOverride = getFlag(args, '--database'); + + const flagValues = new Set([filePath, outputDir, seedStr, urlOverride, fwOverride, dbOverride].filter(Boolean)); + + const positional = args.filter(a => !a.startsWith('-') && !flagValues.has(a)); + let description: string; + + if (filePath) { + if (!fs.existsSync(filePath)) throw new CLIError(`File not found: ${filePath}`); + description = fs.readFileSync(filePath, 'utf-8').trim(); + } else if (positional.length > 0) { + description = positional.join(' '); + } else { + throw new CLIError('Provide a project description:\n stackbilt run "Build a real-time chat app"\n stackbilt run --file spec.md'); + } + + if (!description) throw new CLIError('Empty description.'); + + const resolvedOutput = outputDir ?? `./${slugify(description)}`; + const dryRun = args.includes('--dry-run'); + + const resolved = resolveApiKey(); + const baseUrl = urlOverride; + const client = new EngineClient({ + baseUrl: baseUrl ?? resolved?.baseUrl, + apiKey: resolved?.apiKey ?? null, + }); + + const useGateway = !!resolved?.apiKey; + + let scaffoldPromise: Promise; + + if (useGateway) { + 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 => ({ + 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, + })); + } + + if (options.format === 'json') { + const result = await scaffoldPromise; + console.log(JSON.stringify({ ...result, outputDir: resolvedOutput, dryRun }, null, 2)); + if (!dryRun) { + writeFiles(resolvedOutput, result.files); + } + return EXIT_CODE.SUCCESS; + } + + const isTTY = process.stdout.isTTY === true; + + console.log(''); + if (!useGateway) { + console.log(' \x1b[2m(tip: run `charter login --key sb_live_xxx` for deployment-ready scaffolds)\x1b[0m'); + console.log(''); + } + + if (isTTY) { + let spinIdx = 0; + + for (const label of PHASE_LABELS) { + console.log(`\x1b[2m ${SPINNER[0]} ${label.padEnd(12)} working...\x1b[0m`); + } + + let done = false; + let result!: ScaffoldResult; + + scaffoldPromise.then(r => { result = r; done = true; }).catch(() => { done = true; }); + + while (!done) { + spinIdx = (spinIdx + 1) % SPINNER.length; + cursorUp(PHASE_LABELS.length); + for (const label of PHASE_LABELS) { + clearLine(); + process.stdout.write(`\x1b[2m ${SPINNER[spinIdx]} ${label.padEnd(12)} working...\x1b[0m\n`); + } + await delay(80); + } + + result = await scaffoldPromise; + + cursorUp(PHASE_LABELS.length); + for (const label of PHASE_LABELS) { + clearLine(); + const detail = phaseDetail(label, result); + process.stdout.write(` \x1b[32m❩\x1b[0m ${label.padEnd(12)} ${detail.padEnd(36)} \x1b[32m✓\x1b[0m\n`); + await delay(120); + } + } else { + const result = await scaffoldPromise; + for (const label of PHASE_LABELS) { + console.log(` ❩ ${label.padEnd(12)} ${phaseDetail(label, result).padEnd(36)} ✓`); + } + } + + const result = await scaffoldPromise; + + console.log(''); + if (dryRun) { + console.log(` → ${result.files.length} files would be scaffolded to ${resolvedOutput}/`); + for (const f of result.files) { + console.log(` ${f.path}`); + } + console.log(''); + console.log(' (dry run — no files written)'); + } else { + writeFiles(resolvedOutput, result.files); + console.log(` → ${result.files.length} files scaffolded to ${resolvedOutput}/`); + console.log(` → Architecture governed · seed: ${result.seed ?? 'deterministic'}`); + if (result.nextSteps && result.nextSteps.length > 0) { + console.log(''); + console.log(' Next steps:'); + for (const step of result.nextSteps) { + console.log(` ${step}`); + } + } + } + + console.log(''); + return EXIT_CODE.SUCCESS; +} + +function writeFiles(outputDir: string, files: Array<{ path: string; content: string }>): void { + for (const { path: name, content } of files) { + const target = path.join(outputDir, name); + const dir = path.dirname(target); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(target, content); + } +} diff --git a/src/commands/scaffold.ts b/src/commands/scaffold.ts new file mode 100644 index 0000000..37b6525 --- /dev/null +++ b/src/commands/scaffold.ts @@ -0,0 +1,78 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { CLIOptions } from '../index.js'; +import { EXIT_CODE, CLIError } from '../index.js'; +import { getFlag } from '../flags.js'; +import type { BuildResult } from '../http-client.js'; + +export async function scaffoldCommand(options: CLIOptions, args: string[]): Promise { + const configPath = options.configPath || '.charter'; + const cachePath = path.join(configPath, 'last-build.json'); + + if (!fs.existsSync(cachePath)) { + throw new CLIError('No cached build found. Run `charter architect "..."` first.'); + } + + let result: BuildResult; + try { + result = JSON.parse(fs.readFileSync(cachePath, 'utf-8')); + } catch { + throw new CLIError('Could not parse cached build. Run `charter architect "..."` again.'); + } + + if (!result.scaffold || Object.keys(result.scaffold).length === 0) { + throw new CLIError('Cached build has no scaffold files.'); + } + + const outputDir = getFlag(args, '--output') ?? '.'; + const dryRun = args.includes('--dry-run'); + + const files = Object.entries(result.scaffold).sort(([a], [b]) => a.localeCompare(b)); + + if (options.format === 'json') { + const manifest = files.map(([name, content]) => ({ + path: path.join(outputDir, name), + lines: content.split('\n').length, + })); + console.log(JSON.stringify({ outputDir, dryRun, files: manifest }, null, 2)); + if (!dryRun) writeFiles(outputDir, files); + return EXIT_CODE.SUCCESS; + } + + console.log(''); + console.log(` Scaffold from build (seed: ${result.seed})`); + console.log(` Stack: ${result.stack.map(s => s.name).join(' + ')}`); + console.log(` Output: ${path.resolve(outputDir)}`); + console.log(''); + + for (const [name, content] of files) { + const lines = content.split('\n').length; + const target = path.join(outputDir, name); + const exists = fs.existsSync(target); + const marker = exists ? ' (exists, will overwrite)' : ''; + console.log(` ${name} (${lines} lines)${marker}`); + } + + if (dryRun) { + console.log(''); + console.log(' (dry run — no files written)'); + return EXIT_CODE.SUCCESS; + } + + writeFiles(outputDir, files); + + console.log(''); + console.log(` ${files.length} files written.`); + return EXIT_CODE.SUCCESS; +} + +function writeFiles(outputDir: string, files: [string, string][]): void { + for (const [name, content] of files) { + const target = path.join(outputDir, name); + const dir = path.dirname(target); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(target, content); + } +} diff --git a/src/credentials.ts b/src/credentials.ts new file mode 100644 index 0000000..afb58c7 --- /dev/null +++ b/src/credentials.ts @@ -0,0 +1,63 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; + +export interface Credentials { + apiKey: string; + baseUrl?: string; +} + +const CRED_DIR = path.join(os.homedir(), '.charter'); +const CRED_FILE = path.join(CRED_DIR, 'credentials.json'); +const API_KEY_ENV_VAR = 'STACKBILT_API_KEY'; +const API_BASE_URL_ENV_VAR = 'STACKBILT_API_BASE_URL'; + +export function loadCredentials(): Credentials | null { + if (!fs.existsSync(CRED_FILE)) return null; + try { + const raw = fs.readFileSync(CRED_FILE, 'utf-8'); + const parsed = JSON.parse(raw); + if (!parsed.apiKey || typeof parsed.apiKey !== 'string') return null; + return parsed as Credentials; + } catch { + return null; + } +} + +export function saveCredentials(creds: Credentials): void { + if (!fs.existsSync(CRED_DIR)) { + fs.mkdirSync(CRED_DIR, { recursive: true }); + } + fs.writeFileSync(CRED_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 }); +} + +export function clearCredentials(): void { + if (fs.existsSync(CRED_FILE)) { + fs.unlinkSync(CRED_FILE); + } +} + +export interface ResolvedApiKey { + apiKey: string; + source: 'env' | 'credentials'; + baseUrl?: string; +} + +export function resolveApiKey(): ResolvedApiKey | null { + const fromEnv = process.env[API_KEY_ENV_VAR]; + if (fromEnv && fromEnv.trim().length > 0) { + const baseUrlFromEnv = process.env[API_BASE_URL_ENV_VAR]?.trim(); + return { + apiKey: fromEnv.trim(), + source: 'env', + baseUrl: baseUrlFromEnv && baseUrlFromEnv.length > 0 ? baseUrlFromEnv : undefined, + }; + } + const stored = loadCredentials(); + if (stored) { + return { apiKey: stored.apiKey, source: 'credentials', baseUrl: stored.baseUrl }; + } + return null; +} + +export { API_KEY_ENV_VAR, API_BASE_URL_ENV_VAR }; diff --git a/src/flags.ts b/src/flags.ts new file mode 100644 index 0000000..aa0c4ac --- /dev/null +++ b/src/flags.ts @@ -0,0 +1,24 @@ +import * as fs from 'node:fs'; +import { CLIError } from './index.js'; + +export function getFlag(args: string[], flag: string): string | undefined { + const idx = args.indexOf(flag); + if (idx !== -1 && idx + 1 < args.length) { + return args[idx + 1]; + } + return undefined; +} + +export function readFlagFile(filePath: string, flagName: string): string { + if (!fs.existsSync(filePath)) { + throw new CLIError(`File not found for ${flagName}: ${filePath}`); + } + return fs.readFileSync(filePath, 'utf-8'); +} + +export function tokenizeTask(task: string): string[] { + return task + .split(/[\s,;:()[\]{}]+/) + .filter(w => w.length > 1) + .map(w => w.replace(/[^a-zA-Z0-9]/g, '')); +} diff --git a/src/http-client.ts b/src/http-client.ts new file mode 100644 index 0000000..e47b3c4 --- /dev/null +++ b/src/http-client.ts @@ -0,0 +1,155 @@ +import type { + ScaffoldFileType, + GovernanceDocsType, + PromptContextType, +} from './types/scaffold-contract-types.js'; + +const DEFAULT_BASE_URL = process.env.STACKBILT_ENGINE_URL ?? 'https://api.stackbilt.dev/engine'; +const GATEWAY_BASE_URL = 'https://mcp.stackbilt.dev'; + +export interface BuildRequest { + description: string; + constraints?: { + cloudflareOnly?: boolean; + framework?: string; + database?: string; + needsAuth?: boolean; + needsRealtime?: boolean; + needsQueue?: boolean; + needsStorage?: boolean; + }; + seed?: number; + tier?: 'blessed' | 'all'; +} + +export interface DrawnTech { + id: number; + name: string; + category: string; + element: string; + maturity: string; + tier: string; + cloudflareNative: boolean; + traits: string[]; + keywords: { upright: string[]; reversed: string[] }; + orientation: 'upright' | 'reversed'; + position: string; +} + +export interface CompatPair { + positions: [string, string]; + techs: [string, string]; + elements: [string, string]; + relationship: string; + score: number; + description: string; +} + +export interface BuildResult { + stack: DrawnTech[]; + compatibility: { + pairs: CompatPair[]; + totalScore: number; + normalizedScore: number; + dominant: string; + tensions: { elements: [string, string]; description: string }[]; + }; + scaffold: Record; + seed: number; + receipt: string; + requirements: { + description: string; + keywords: string[]; + constraints: Record; + complexity: string; + }; +} + +export type ScaffoldFile = ScaffoldFileType; + +export interface ScaffoldResult { + files: ScaffoldFile[]; + fileSource: 'engine' | 'basic' | 'none'; + nextSteps: string[]; + seed?: number; + receipt?: string; + facts?: Record; + promptContext?: PromptContextType; + governance?: GovernanceDocsType; +} + +export interface HealthResponse { + status: string; + version: string; + engine: string; + catalog: number; + positions: string[]; +} + +export class EngineClient { + private baseUrl: string; + private apiKey: string | null; + + constructor(options: { baseUrl?: string; apiKey?: string | null }) { + this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, ''); + this.apiKey = options.apiKey ?? null; + } + + async health(): Promise { + const res = await fetch(`${this.baseUrl}/health`); + if (!res.ok) throw new Error(`Engine health check failed: ${res.status}`); + return res.json() as Promise; + } + + async build(request: BuildRequest): Promise { + const headers: Record = { 'Content-Type': 'application/json' }; + if (this.apiKey) headers['Authorization'] = `Bearer ${this.apiKey}`; + + const res = await fetch(`${this.baseUrl}/build`, { + method: 'POST', + headers, + body: JSON.stringify(request), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Engine build failed (${res.status}): ${text}`); + } + + return res.json() as Promise; + } + + async scaffold(request: { description: string; project_type?: string; complexity?: string; seed?: number }): Promise { + if (!this.apiKey) { + throw new Error( + 'API key required for scaffold. Set STACKBILT_API_KEY in the environment, ' + + 'or (deprecated) run `charter login --key sb_live_xxx`.', + ); + } + + const res = await fetch(`${GATEWAY_BASE_URL}/api/scaffold`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}`, + }, + body: JSON.stringify(request), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Scaffold failed (${res.status}): ${text}`); + } + + return res.json() as Promise; + } + + async catalog(category?: string): Promise<{ primitives: DrawnTech[]; total: number }> { + const url = new URL(`${this.baseUrl}/catalog`); + if (category) url.searchParams.set('category', category); + + const res = await fetch(url.toString()); + if (!res.ok) throw new Error(`Engine catalog failed: ${res.status}`); + return res.json() as Promise<{ primitives: DrawnTech[]; total: number }>; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..ac33075 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,18 @@ +export interface CLIOptions { + configPath: string; + format: 'text' | 'json'; + ciMode: boolean; + yes: boolean; +} + +export const EXIT_CODE = { SUCCESS: 0, FAILURE: 1, VALIDATION_ERROR: 2 } as const; + +export class CLIError extends Error { + constructor(message: string) { + super(message); + this.name = 'CLIError'; + } +} + +export async function main(): Promise { +} diff --git a/src/types/scaffold-contract-types.ts b/src/types/scaffold-contract-types.ts new file mode 100644 index 0000000..7deefb1 --- /dev/null +++ b/src/types/scaffold-contract-types.ts @@ -0,0 +1,80 @@ +export type FileRoleType = 'config' | 'scaffold' | 'governance' | 'test' | 'doc'; + +export interface ScaffoldFileType { + path: string; + content: string; + role: FileRoleType; +} + +export interface GovernanceDocsType { + threat_model: string; + adr: string; + test_plan: string; +} + +export interface PromptContextMetaType { + project_type: string; + complexity: string; + confidence: string; + seed: number; +} + +export interface PromptContextRequirementType { + name: string; + priority: string; + effort: string; + acceptance: string; +} + +export interface PromptContextInterfaceType { + name: string; + layout: string; + components: string; +} + +export interface PromptContextThreatType { + name: string; + owasp: string; + likelihood: string; + impact: string; + mitigation: string; + detection: string; + response_time: string; +} + +export interface PromptContextRuntimeType { + name: string; + tier: string; + traits: string; +} + +export interface PromptContextTestPlanType { + name: string; + framework: string; + ci_stage: string; + coverage: string; + setup: string; + assertion_style: string; +} + +export interface PromptContextFirstTaskType { + name: string; + estimate: string; + complexity: string; + deliverable: string; + adr: string; +} + +export interface PromptContextType { + intention: string; + pattern: string; + meta: PromptContextMetaType; + requirement: PromptContextRequirementType; + interface: PromptContextInterfaceType; + threat: PromptContextThreatType; + runtime: PromptContextRuntimeType; + test_plan: PromptContextTestPlanType; + first_task: PromptContextFirstTaskType; + governance: GovernanceDocsType; + files: ScaffoldFileType[]; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..97103df --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ESNext"], + "types": ["node"], + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..4ac6027 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + }, +}); From b403263dc157b2d4c8b4f96b6e726ca0dd9f87ff Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Sat, 23 May 2026 07:22:57 -0500 Subject: [PATCH 2/4] fix: npm ci, stackbilt command refs, add CI workflow --- .github/workflows/ci.yml | 19 +++++++++++++++++++ .github/workflows/release.yml | 2 +- src/commands/run.ts | 2 +- src/commands/scaffold.ts | 4 ++-- src/http-client.ts | 2 +- 5 files changed, 24 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..552d3a6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '20' + - run: npm ci + - run: npm run build + - run: npm test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 61fcb93..b81731a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -113,7 +113,7 @@ jobs: run: npm install -g npm@latest - name: Install dependencies - run: npm install --frozen-lockfile + run: npm ci - name: Build run: npm run build diff --git a/src/commands/run.ts b/src/commands/run.ts index 76c59ce..539f307 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -122,7 +122,7 @@ export async function runCommand(options: CLIOptions, args: string[]): Promise Date: Sat, 23 May 2026 07:35:43 -0500 Subject: [PATCH 3/4] fix: stackbilt command refs in architect/login, guard npm ci against missing lockfile --- .github/workflows/ci.yml | 2 +- src/commands/architect.ts | 4 ++-- src/commands/login.ts | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 552d3a6..0221f10 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,6 @@ jobs: - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '20' - - run: npm ci + - run: if [ -f package-lock.json ]; then npm ci; else npm install; fi - run: npm run build - run: npm test diff --git a/src/commands/architect.ts b/src/commands/architect.ts index 35510f8..a2940de 100644 --- a/src/commands/architect.ts +++ b/src/commands/architect.ts @@ -17,7 +17,7 @@ export async function architectCommand(options: CLIOptions, args: string[]): Pro } else if (positional.length > 0) { description = positional.join(' '); } else { - throw new CLIError('Provide a project description:\n charter architect "Build a real-time chat app"\n charter architect --file spec.md'); + throw new CLIError('Provide a project description:\n stackbilt architect "Build a real-time chat app"\n stackbilt architect --file spec.md'); } if (!description) throw new CLIError('Empty description.'); @@ -59,7 +59,7 @@ export async function architectCommand(options: CLIOptions, args: string[]): Pro if (!dryRun) { cacheResult(result, options.configPath); console.log(''); - console.log(`Build cached. Run \`charter scaffold\` to write files.`); + console.log(`Build cached. Run \`stackbilt scaffold\` to write files.`); } else { console.log(''); console.log('(dry run — no files written)'); diff --git a/src/commands/login.ts b/src/commands/login.ts index d434bc6..bac1310 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -35,9 +35,9 @@ export async function loginCommand(options: CLIOptions, args: string[]): Promise console.log(`Preferred: export ${API_KEY_ENV_VAR}=ea_xxx (or sb_live_xxx, sb_test_xxx).`); console.log(''); console.log('Deprecated alternative:'); - console.log(' charter login --key ea_xxx'); - console.log(' charter login --key sb_live_xxx'); - console.log(' charter login --key sb_test_xxx'); + console.log(' stackbilt login --key ea_xxx'); + console.log(' stackbilt login --key sb_live_xxx'); + console.log(' stackbilt login --key sb_test_xxx'); console.log(''); console.log('Get your API key from auth.stackbilt.dev (ea_) or the Stackbilt dashboard (sb_).'); } From 696dfab6ac8c54e72d1cffbc65c037a3f1be1a43 Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Sat, 23 May 2026 08:13:40 -0500 Subject: [PATCH 4/4] fix(cli): widen code type to number to satisfy strict const inference --- src/cli.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli.ts b/src/cli.ts index 2dd5110..a33d6eb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,7 +10,7 @@ const options = { configPath: '.charter', format: 'text' as const, ciMode: false async function run() { try { - let code = EXIT_CODE.SUCCESS; + let code: number = EXIT_CODE.SUCCESS; if (cmd === 'login') code = await loginCommand(options, args); else if (cmd === 'architect') code = await architectCommand(options, args); else if (cmd === 'run') code = await runCommand(options, args);