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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ All notable changes to this project are documented in this file.

The format is based on Keep a Changelog and follows Semantic Versioning.

## [Unreleased]

### Added
- **`STACKBILT_API_KEY` environment variable** — `charter run` and `charter architect` now resolve the API key from `STACKBILT_API_KEY` first, falling back to stored credentials only if the env var is absent or blank. This lets users authenticate the commercial commands without writing a token to `~/.charter/credentials.json`.
- **`STACKBILT_API_BASE_URL` environment variable** — companion to `STACKBILT_API_KEY`; sets a custom engine base URL for env-var-authenticated callers. Preserves parity with the stored-credentials path (`charter login --url …`).
- `resolveApiKey()` helper exported from `@stackbilt/cli`'s credentials module (env-var precedence, trimmed, returns `{ apiKey, source: 'env' | 'credentials', baseUrl? }`).

### Deprecated
- **`charter login`** — emits a deprecation notice on every invocation. Functionality unchanged; scheduled for removal in 1.0 when gateway-bound commands (`login`, `run`, `architect`, `scaffold`) move out of `@stackbilt/cli` into a separate `@stackbilt/build` package.

### Changed
- Scaffold auth-error message now points users at `STACKBILT_API_KEY` as the primary path, with `charter login` marked deprecated.
- CLI README gains a short "Authentication (optional)" section documenting the env-var path.

## [0.10.0] - 2026-04-09

Synchronized version bump for all `@stackbilt/*` packages to 0.10.0.
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,21 @@ charter blast src/foo.ts --depth 3 # reverse dep graph → files affected by ch
charter surface --markdown # extract routes (Hono/Express) + D1 schema as markdown
```

## Authentication (optional)

Governance commands (`validate`, `drift`, `blast`, `surface`, etc.) run locally and require no authentication.

Commands that reach the Stackbilt engine (`run`, `architect`) read their API key from the `STACKBILT_API_KEY` environment variable. A custom engine URL can be supplied via `STACKBILT_API_BASE_URL`:

```bash
export STACKBILT_API_KEY=ea_xxx # or sb_live_xxx, sb_test_xxx
export STACKBILT_API_BASE_URL=https://engine.example # optional, for self-hosted engines
```

Environment variables are inherited by any child processes spawned from the same shell and may appear in `/proc/<pid>/environ`. In CI, prefer setting the variable per-invocation (e.g., a job-scoped secret) rather than exporting it globally in a shared developer shell.

The legacy `charter login --key …` command still works but is deprecated and will be removed in `@stackbilt/cli` 1.0 when gateway-bound commands move to a separate package.

## Human Onboarding (Copy/Paste)

Run this in the target repository:
Expand Down
149 changes: 149 additions & 0 deletions packages/cli/src/__tests__/auth-wiring.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
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';

// Hoisted mock state: vi.mock factories run before any imports, so the
// EngineClient class mock must reach these via vi.hoisted.
const hoisted = vi.hoisted(() => ({
buildFn: vi.fn(),
scaffoldFn: vi.fn(),
constructorArgs: [] as Array<{ baseUrl?: string; apiKey?: string | null }>,
}));

vi.mock('../credentials', async () => {
const actual = await vi.importActual<typeof import('../credentials')>('../credentials');
return { ...actual, resolveApiKey: vi.fn() };
});

vi.mock('../http-client', () => {
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';
import { architectCommand } from '../commands/architect';
import { runCommand } from '../commands/run';
import type { CLIOptions } from '../index';

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();
});
});
143 changes: 143 additions & 0 deletions packages/cli/src/__tests__/credentials.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

// Isolate loadCredentials from the developer's real ~/.charter/credentials.json
// by mocking node:fs. Each test configures the fs mock explicitly.
vi.mock('node:fs', async () => {
const actual = await vi.importActual<typeof import('node:fs')>('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';

const mockedFs = fs as unknown as {
existsSync: ReturnType<typeof vi.fn>;
readFileSync: ReturnType<typeof vi.fn>;
};

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');
});
});
50 changes: 50 additions & 0 deletions packages/cli/src/__tests__/login.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { loginCommand } from '../commands/login';
import type { CLIOptions } from '../index';
import { API_KEY_ENV_VAR } from '../credentials';

const options: CLIOptions = {
format: 'text',
configPath: '.charter',
ciMode: false,
yes: false,
};

describe('charter login — deprecation notice', () => {
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('writes a deprecation notice to stderr when invoked without args', async () => {
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
vi.spyOn(console, 'log').mockImplementation(() => {});

await loginCommand(options, []);

const stderrOutput = stderr.mock.calls.map((c) => String(c[0])).join('');
expect(stderrOutput).toMatch(/deprecated/i);
expect(stderrOutput).toContain(API_KEY_ENV_VAR);
});

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`));
});
});
Loading
Loading