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
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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: if [ -f package-lock.json ]; then npm ci; else npm install; fi
- run: npm run build
- run: npm test
152 changes: 152 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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<major>.<minor>.<patch>" >&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 ci

- 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<major>.<minor>.<patch>"
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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).
61 changes: 61 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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"
}
147 changes: 147 additions & 0 deletions src/__tests__/auth-wiring.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('../credentials.js')>('../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();
});
});
Loading