Skip to content

Commit 5eaec78

Browse files
stackbilt-adminkovermierclaude
authored
feat: bootstrap @stackbilt/build package (#2)
* feat: bootstrap @stackbilt/build package — port commercial surface from @stackbilt/cli 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 <[email protected]> * fix: npm ci, stackbilt command refs, add CI workflow * fix: stackbilt command refs in architect/login, guard npm ci against missing lockfile * fix(cli): widen code type to number to satisfy strict const inference --------- Co-authored-by: Kurt Overmier <[email protected]> Co-authored-by: Claude Sonnet 4.6 <[email protected]>
1 parent b4662f2 commit 5eaec78

19 files changed

Lines changed: 1435 additions & 0 deletions

.github/workflows/ci.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name: CI
2+
3+
on:
4+
pull_request:
5+
branches: [main]
6+
push:
7+
branches: [main]
8+
9+
jobs:
10+
build-and-test:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
14+
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
15+
with:
16+
node-version: '20'
17+
- run: if [ -f package-lock.json ]; then npm ci; else npm install; fi
18+
- run: npm run build
19+
- run: npm test

.github/workflows/release.yml

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags:
6+
- 'v*'
7+
branches-ignore:
8+
- '**'
9+
workflow_dispatch:
10+
inputs:
11+
tag:
12+
description: 'Existing tag to publish (for backfill), e.g. v0.4.2'
13+
required: true
14+
type: string
15+
16+
permissions:
17+
contents: write
18+
19+
jobs:
20+
publish-release:
21+
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
22+
runs-on: ubuntu-latest
23+
steps:
24+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
25+
with:
26+
fetch-depth: 0
27+
28+
- name: Resolve tag
29+
id: tag
30+
shell: bash
31+
run: |
32+
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
33+
TAG="${{ inputs.tag }}"
34+
else
35+
TAG="${GITHUB_REF_NAME}"
36+
fi
37+
38+
if [[ -z "${TAG}" ]]; then
39+
echo "Tag could not be resolved." >&2
40+
exit 1
41+
fi
42+
43+
echo "value=${TAG}" >> "$GITHUB_OUTPUT"
44+
45+
- name: Verify tag
46+
shell: bash
47+
run: |
48+
TAG="${{ steps.tag.outputs.value }}"
49+
if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
50+
echo "Invalid tag format: ${TAG}. Expected v<major>.<minor>.<patch>" >&2
51+
exit 1
52+
fi
53+
54+
if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then
55+
PKG_VERSION="$(node -p "require('./package.json').version")"
56+
EXPECTED_TAG="v${PKG_VERSION}"
57+
58+
if [[ "${TAG}" != "${EXPECTED_TAG}" ]]; then
59+
echo "Tag/version mismatch on push: got ${TAG}, expected ${EXPECTED_TAG}" >&2
60+
exit 1
61+
fi
62+
else
63+
if ! git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
64+
echo "Tag not found in repository: ${TAG}" >&2
65+
exit 1
66+
fi
67+
fi
68+
69+
- name: Build release notes from CHANGELOG
70+
shell: bash
71+
run: |
72+
TAG="${{ steps.tag.outputs.value }}"
73+
VERSION="${TAG#v}"
74+
75+
awk -v version="${VERSION}" '
76+
BEGIN { in_section=0 }
77+
$0 ~ "^## \\[" version "\\]" { in_section=1; print; next }
78+
in_section && $0 ~ "^## \\[" { exit }
79+
in_section { print }
80+
' CHANGELOG.md > release_notes.md
81+
82+
if [[ ! -s release_notes.md ]]; then
83+
echo "## ${TAG}" > release_notes.md
84+
echo >> release_notes.md
85+
echo "See CHANGELOG.md for release details." >> release_notes.md
86+
fi
87+
88+
- name: Create or update GitHub Release
89+
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
90+
with:
91+
tag_name: ${{ steps.tag.outputs.value }}
92+
name: ${{ steps.tag.outputs.value }}
93+
body_path: release_notes.md
94+
generate_release_notes: true
95+
96+
publish-npm:
97+
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
98+
runs-on: ubuntu-latest
99+
permissions:
100+
contents: read
101+
id-token: write
102+
steps:
103+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
104+
with:
105+
fetch-depth: 0
106+
ref: ${{ inputs.tag || github.ref }}
107+
108+
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
109+
with:
110+
node-version: '20'
111+
112+
- name: Upgrade npm for trusted-publisher support
113+
run: npm install -g npm@latest
114+
115+
- name: Install dependencies
116+
run: npm ci
117+
118+
- name: Build
119+
run: npm run build
120+
121+
- name: Verify tag and workspace versions
122+
shell: bash
123+
run: |
124+
TAG="${{ github.event.inputs.tag || github.ref_name }}"
125+
if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
126+
echo "::error::Invalid tag format: ${TAG}. Expected v<major>.<minor>.<patch>"
127+
exit 1
128+
fi
129+
130+
EXPECTED="${TAG#v}"
131+
V=$(node -p "require('./package.json').version")
132+
N=$(node -p "require('./package.json').name")
133+
if [[ "$V" != "$EXPECTED" ]]; then
134+
echo "::error::$N version $V does not match tag $EXPECTED"
135+
exit 1
136+
fi
137+
138+
- name: Publish to npm
139+
shell: bash
140+
run: |
141+
TAG="${{ github.event.inputs.tag || github.ref_name }}"
142+
VERSION="${TAG#v}"
143+
mkdir -p release-tarballs
144+
145+
npm pack --pack-destination ./release-tarballs
146+
147+
TARBALL="./release-tarballs/stackbilt-build-${VERSION}.tgz"
148+
if npm view "@stackbilt/build@${VERSION}" version &>/dev/null 2>&1; then
149+
echo "Skipping @stackbilt/build@${VERSION} — already published"
150+
else
151+
npm publish "${TARBALL}" --access public --provenance
152+
fi

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# @stackbilt/build
2+
3+
`@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).

package.json

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
{
2+
"name": "@stackbilt/build",
3+
"version": "0.1.0",
4+
"description": "Stackbilt Build CLI — login, architect, run, scaffold commands",
5+
"sideEffects": false,
6+
"type": "module",
7+
"bin": {
8+
"stackbilt": "./dist/cli.js"
9+
},
10+
"main": "./dist/index.js",
11+
"types": "./dist/index.d.ts",
12+
"exports": {
13+
".": {
14+
"types": "./dist/index.d.ts",
15+
"default": "./dist/index.js"
16+
}
17+
},
18+
"files": [
19+
"dist",
20+
"README.md",
21+
"LICENSE"
22+
],
23+
"engines": {
24+
"node": ">=18.0.0"
25+
},
26+
"repository": {
27+
"type": "git",
28+
"url": "https://github.com/Stackbilt-dev/stackbilt-build.git"
29+
},
30+
"bugs": {
31+
"url": "https://github.com/Stackbilt-dev/stackbilt-build/issues"
32+
},
33+
"homepage": "https://github.com/Stackbilt-dev/stackbilt-build#readme",
34+
"publishConfig": {
35+
"access": "public",
36+
"provenance": true
37+
},
38+
"keywords": [
39+
"stackbilt",
40+
"build",
41+
"cli",
42+
"scaffold",
43+
"architect",
44+
"typescript",
45+
"cloudflare",
46+
"ai",
47+
"agent"
48+
],
49+
"scripts": {
50+
"build": "tsc -p tsconfig.json",
51+
"test": "vitest run"
52+
},
53+
"dependencies": {},
54+
"devDependencies": {
55+
"@types/node": "^20.0.0",
56+
"typescript": "^5.0.0",
57+
"vitest": "^2.0.0"
58+
},
59+
"license": "Apache-2.0",
60+
"author": "Stackbilt LLC"
61+
}

src/__tests__/auth-wiring.test.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import * as fs from 'node:fs';
2+
import * as os from 'node:os';
3+
import * as path from 'node:path';
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5+
6+
const hoisted = vi.hoisted(() => ({
7+
buildFn: vi.fn(),
8+
scaffoldFn: vi.fn(),
9+
constructorArgs: [] as Array<{ baseUrl?: string; apiKey?: string | null }>,
10+
}));
11+
12+
vi.mock('../credentials.js', async () => {
13+
const actual = await vi.importActual<typeof import('../credentials.js')>('../credentials.js');
14+
return { ...actual, resolveApiKey: vi.fn() };
15+
});
16+
17+
vi.mock('../http-client.js', () => {
18+
return {
19+
EngineClient: class {
20+
constructor(opts: { baseUrl?: string; apiKey?: string | null }) {
21+
hoisted.constructorArgs.push(opts);
22+
}
23+
build = hoisted.buildFn;
24+
scaffold = hoisted.scaffoldFn;
25+
health = vi.fn();
26+
catalog = vi.fn();
27+
},
28+
};
29+
});
30+
31+
import { resolveApiKey } from '../credentials.js';
32+
import { architectCommand } from '../commands/architect.js';
33+
import { runCommand } from '../commands/run.js';
34+
import type { CLIOptions } from '../index.js';
35+
36+
const mockedResolveApiKey = vi.mocked(resolveApiKey);
37+
38+
const options: CLIOptions = {
39+
format: 'json',
40+
configPath: '.charter',
41+
ciMode: false,
42+
yes: true,
43+
};
44+
45+
function fakeBuildResult() {
46+
return {
47+
stack: [],
48+
compatibility: {
49+
pairs: [],
50+
totalScore: 0,
51+
normalizedScore: 0,
52+
dominant: '',
53+
tensions: [],
54+
},
55+
scaffold: {},
56+
seed: 1,
57+
receipt: 'receipt',
58+
requirements: {
59+
description: 'anything',
60+
keywords: [],
61+
constraints: {},
62+
complexity: 'moderate',
63+
},
64+
};
65+
}
66+
67+
function fakeScaffoldResult() {
68+
return {
69+
files: [],
70+
fileSource: 'engine' as const,
71+
nextSteps: [],
72+
};
73+
}
74+
75+
let tmpCwd: string;
76+
77+
beforeEach(() => {
78+
tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'charter-wiring-'));
79+
process.chdir(tmpCwd);
80+
fs.mkdirSync(path.join(tmpCwd, '.charter'), { recursive: true });
81+
hoisted.buildFn.mockReset().mockResolvedValue(fakeBuildResult());
82+
hoisted.scaffoldFn.mockReset().mockResolvedValue(fakeScaffoldResult());
83+
hoisted.constructorArgs.length = 0;
84+
mockedResolveApiKey.mockReset();
85+
vi.spyOn(console, 'log').mockImplementation(() => {});
86+
vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
87+
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
88+
});
89+
90+
afterEach(() => {
91+
vi.restoreAllMocks();
92+
process.chdir(os.tmpdir());
93+
fs.rmSync(tmpCwd, { recursive: true, force: true });
94+
});
95+
96+
describe('architect — auth wiring', () => {
97+
it('forwards the env-sourced API key (and custom baseUrl) to EngineClient', async () => {
98+
mockedResolveApiKey.mockReturnValue({
99+
apiKey: 'ea_env_wiring',
100+
source: 'env',
101+
baseUrl: 'https://engine.example',
102+
});
103+
104+
await architectCommand(options, ['a simple project description']);
105+
106+
expect(hoisted.constructorArgs).toHaveLength(1);
107+
expect(hoisted.constructorArgs[0].apiKey).toBe('ea_env_wiring');
108+
expect(hoisted.constructorArgs[0].baseUrl).toBe('https://engine.example');
109+
});
110+
111+
it('passes apiKey=null to EngineClient when resolveApiKey returns null', async () => {
112+
mockedResolveApiKey.mockReturnValue(null);
113+
114+
await architectCommand(options, ['unauthenticated fallback']);
115+
116+
expect(hoisted.constructorArgs[0].apiKey).toBeNull();
117+
});
118+
});
119+
120+
describe('run — gateway vs engine routing', () => {
121+
it('uses the gateway (scaffold) when the env var provides an API key', async () => {
122+
mockedResolveApiKey.mockReturnValue({ apiKey: 'ea_env_gateway', source: 'env' });
123+
124+
await runCommand(options, ['a description', '--dry-run']);
125+
126+
expect(hoisted.scaffoldFn).toHaveBeenCalledTimes(1);
127+
expect(hoisted.buildFn).not.toHaveBeenCalled();
128+
});
129+
130+
it('falls back to engine /build when no API key is resolved', async () => {
131+
mockedResolveApiKey.mockReturnValue(null);
132+
133+
await runCommand(options, ['a description', '--dry-run']);
134+
135+
expect(hoisted.buildFn).toHaveBeenCalledTimes(1);
136+
expect(hoisted.scaffoldFn).not.toHaveBeenCalled();
137+
});
138+
139+
it('uses the gateway when login-stored credentials are resolved (parity with env path)', async () => {
140+
mockedResolveApiKey.mockReturnValue({ apiKey: 'sb_live_stored', source: 'credentials' });
141+
142+
await runCommand(options, ['a description', '--dry-run']);
143+
144+
expect(hoisted.scaffoldFn).toHaveBeenCalledTimes(1);
145+
expect(hoisted.buildFn).not.toHaveBeenCalled();
146+
});
147+
});

0 commit comments

Comments
 (0)