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
80 changes: 80 additions & 0 deletions packages/cli/src/__tests__/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@ import { bootstrapCommand } from '../commands/bootstrap';
import { doctorCommand } from '../commands/doctor';
import { driftCommand } from '../commands/drift';
import type { CLIOptions } from '../index';
import { parseAdf, parseManifest } from '@stackbilt/adf';

// Controlled per-test override for execSync (module-level mock needed for ESM-treated builtins)
let execSyncOverride: (((...args: unknown[]) => unknown) | null) = null;
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:child_process')>();
return {
...actual,
execSync: (...args: unknown[]) => {
if (execSyncOverride) return execSyncOverride(...args);
return actual.execSync(...(args as Parameters<typeof actual.execSync>));
},
};
});

const baseOptions: CLIOptions = {
configPath: '.charter',
Expand Down Expand Up @@ -170,4 +184,70 @@ STATE:
violation.severity === 'BLOCKER' && violation.patternName.includes('Timing-Sensitive Equality')
)).toBe(true);
});


it('registers core.adf and state.adf in DEFAULT_LOAD when manifest uses non-canonical syntax', async () => {
// Set up a .ai/ directory with core.adf, state.adf, and a manifest that uses
// non-canonical syntax ('load X always') — no '📦 DEFAULT_LOAD:' section.
// This replicates issue #150: parseManifest returns empty defaultLoad,
// so core.adf and state.adf appear as orphans and were incorrectly registered
// as ON_DEMAND before this fix.
fs.mkdirSync('.ai', { recursive: true });

const nonCanonicalManifest = `ADF: 0.1

load core.adf always
load state.adf always
`;
fs.writeFileSync(path.join('.ai', 'manifest.adf'), nonCanonicalManifest);
fs.writeFileSync(path.join('.ai', 'core.adf'), 'ADF: 0.1\n\nCONTEXT:\n - Core rules\n');
fs.writeFileSync(path.join('.ai', 'state.adf'), 'ADF: 0.1\n\nSTATE:\n CURRENT: active\n');

const exitCode = await bootstrapCommand(
{ ...baseOptions, yes: true },
['--yes', '--preset', 'worker', '--skip-install', '--skip-doctor'],
);

expect(exitCode).toBe(0);

// Parse the resulting manifest with the structured parser — not string.includes —
// because that's what verify:adf uses, and it's what was broken.
const resultManifest = fs.readFileSync(path.join('.ai', 'manifest.adf'), 'utf-8');
const doc = parseAdf(resultManifest);
const manifest = parseManifest(doc);

expect(manifest.defaultLoad).toContain('core.adf');
expect(manifest.defaultLoad).toContain('state.adf');

// Neither should appear in ON_DEMAND
const onDemandPaths = manifest.onDemand.map(m => m.path);
expect(onDemandPaths).not.toContain('core.adf');
expect(onDemandPaths).not.toContain('state.adf');
});

it('classifies frozen-lockfile install errors and sets status to partial', async () => {
// Override execSync to throw an ERR_PNPM_FROZEN_LOCKFILE error for this test only
execSyncOverride = () => {
throw new Error('ERR_PNPM_FROZEN_LOCKFILE: Lockfile is not up-to-date');
};

logs = [];
try {
await bootstrapCommand(
{ ...baseOptions, format: 'json' },
['--preset', 'worker', '--skip-doctor'],
);
} finally {
execSyncOverride = null;
}

const report = JSON.parse(logs[0]);
expect(report.status).toBe('partial');

const installStep = report.steps.find((s: { name: string }) => s.name === 'install');
expect(installStep).toBeDefined();
expect(installStep.status).toBe('fail');
const hasHint = installStep.warnings.some((w: string) => w.includes('--no-frozen-lockfile'));
expect(hasHint).toBe(true);
});
});
166 changes: 160 additions & 6 deletions packages/cli/src/commands/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import {
manifestForPreset,
} from './adf';
import { loadPatterns } from '../config';
import { parseAdf, parseManifest } from '@stackbilt/adf';
import { parseAdf, parseManifest, formatAdf } from '@stackbilt/adf';
import { migrateSource, updateModuleIndex } from './adf-migrate';
import type { SourceMigrationResult } from './adf-migrate';

Expand Down Expand Up @@ -111,6 +111,15 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro
const contexts = detectResult.contexts;
const packageManager = detectResult.packageManager;

// Check git repo status once — used for preflight warning and gating hook next-steps
const inGitRepo = isGitRepo();
if (!inGitRepo) {
detectResult.step.warnings.push(
"Not inside a git repository. Run 'git init && git add -A && git commit -m \"initial commit\"' before installing hooks. Continuing — governance files will be written but hooks cannot be installed yet."
);
warnings++;
}

if (options.format === 'text') {
console.log('[1/7] Detecting stack...');
console.log(` Stack: ${selectedPreset} (${detection.confidence} confidence)`);
Expand All @@ -120,6 +129,11 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro
console.log(` Warning: ${w}`);
}
}
if (!inGitRepo) {
console.log(` Warning: Not inside a git repository.`);
console.log(` Run 'git init && git add -A && git commit -m "initial commit"' before installing hooks.`);
console.log(` Continuing — governance files will be written but hooks cannot be installed yet.`);
}
console.log('');
}

Expand Down Expand Up @@ -176,15 +190,47 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro
shouldRegister = await promptYesNo(' Register these modules now? (y/N) ');
}
if (shouldRegister) {
registerOrphansInManifest(path.join('.ai', 'manifest.adf'), orphans);
const manifestFilePath = path.join('.ai', 'manifest.adf');
// Split orphans: core.adf and state.adf belong in DEFAULT_LOAD, everything else in ON_DEMAND
const defaultLoadOrphans = orphans.filter(m => DEFAULT_LOAD_MODULES.has(m));
const onDemandOrphans = orphans.filter(m => !DEFAULT_LOAD_MODULES.has(m));

if (defaultLoadOrphans.length > 0) {
registerModulesInDefaultLoad(manifestFilePath, defaultLoadOrphans);
if (options.format === 'text') {
console.log(` Registered ${defaultLoadOrphans.length} module(s) as DEFAULT_LOAD in manifest.adf`);
}
}
if (onDemandOrphans.length > 0) {
registerOrphansInManifest(manifestFilePath, onDemandOrphans);
if (options.format === 'text') {
console.log(` Registered ${onDemandOrphans.length} module(s) as ON_DEMAND in manifest.adf`);
}
}

updateModuleIndex('CLAUDE.md', '.ai');
if (options.format === 'text') {
console.log(` Registered ${orphans.length} module(s) as ON_DEMAND in manifest.adf`);
console.log('');
}
}
}

// Post-write manifest self-check: warn if DEFAULT_LOAD is empty but core.adf exists
if (fs.existsSync(path.join('.ai', 'core.adf'))) {
const manifestCheckPath = path.join('.ai', 'manifest.adf');
if (fs.existsSync(manifestCheckPath)) {
try {
const manifestDoc = parseAdf(fs.readFileSync(manifestCheckPath, 'utf-8'));
const manifestParsed = parseManifest(manifestDoc);
if (manifestParsed.defaultLoad.length === 0 && options.format === 'text') {
console.log(" Warning: manifest.adf parsed with 0 DEFAULT_LOAD entries — run 'charter adf register core.adf --load default' to repair.");
}
} catch {
// Parse failure already flagged elsewhere
}
}
}

// ========================================================================
// Phase 4: Migrate Agent Configs
// ========================================================================
Expand Down Expand Up @@ -299,6 +345,20 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro
reason: 'Commit governance baseline',
});

// Gate hook next-steps on being inside a git repo
if (inGitRepo) {
result.nextSteps.push({
cmd: 'charter hook install --pre-commit',
required: false,
reason: 'Install pre-commit hook for ADF evidence gate',
});
result.nextSteps.push({
cmd: 'charter hook install --commit-msg',
required: false,
reason: 'Install commit-msg hook for trailer enforcement',
});
}

// ========================================================================
// Governance Gaps — surface what's configured but not enforced
// ========================================================================
Expand Down Expand Up @@ -368,6 +428,39 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro
result.nextSteps.forEach((step, i) => {
console.log(` ${i + 1}. ${step.cmd}`);
});

// Partial/failure summary banner
if (result.status === 'partial' || result.status === 'failure') {
const failedSteps = result.steps.filter(s => s.status === 'fail');
console.log('');
console.log(`⚠ Bootstrap partially complete — ${failedSteps.length} step${failedSteps.length === 1 ? '' : 's'} failed:`);
for (const s of failedSteps) {
const rawErr = s.details.error ? String(s.details.error).split('\n')[0].slice(0, 120) : '';
const errDetail = rawErr ? ` (${rawErr})` : '';
const hintLine = s.warnings.find(w => w.startsWith('Hint:'));
const hint = hintLine ? ` — ${hintLine}` : '';
console.log(` • ${s.name}${errDetail}${hint}`);
}
console.log('');
console.log('Next steps to complete setup:');
let n = 1;
const installFailed = failedSteps.some(s => s.name === 'install');
if (installFailed) {
const installStep = failedSteps.find(s => s.name === 'install');
const frozenHint = installStep?.warnings.find(w => w.includes('--no-frozen-lockfile'));
if (frozenHint) {
console.log(` ${n++}. pnpm install --no-frozen-lockfile (or see hint above)`);
} else {
console.log(` ${n++}. ${installStep?.details.command ?? 'npm install'} (see hint above)`);
}
console.log(` ${n++}. charter doctor`);
if (inGitRepo) {
console.log(` ${n++}. charter hook install --pre-commit`);
}
} else {
console.log(` ${n++}. charter doctor`);
}
}
}

return EXIT_CODE.SUCCESS;
Expand Down Expand Up @@ -862,10 +955,10 @@ function runInstallPhase(
};
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
const isPermError = msg.includes('EPERM') || msg.includes('EACCES') || msg.includes('permission denied');
warnings.push(`Install failed: ${msg}`);
if (isPermError) {
warnings.push(`Hint: permission error detected. Retry outside the sandbox or with elevated privileges: ${command}`);
const hint = classifyInstallError(msg, pm);
if (hint) {
warnings.push(`Hint: ${hint}`);
}
warnings.push(`Retry manually: ${command}`);
return {
Expand All @@ -879,6 +972,16 @@ function runInstallPhase(
}
}

function classifyInstallError(msg: string, pm: string): string {
if (/ERR_PNPM_FROZEN_LOCKFILE|frozen[-. ]lockfile|--frozen-lockfile/i.test(msg))
return 'Lockfile is out of date. Retry with: pnpm install --no-frozen-lockfile';
if (/EPERM|EACCES|permission denied/i.test(msg))
return 'Permission error. On WSL/NTFS try: pnpm install --force (or move project to ~/projects/)';
if (/ENOTFOUND|ETIMEDOUT|fetch failed|503|network/i.test(msg))
return `Network error. Check connectivity and retry: ${pm} install`;
return '';
}

function detectPackageManagerFromLockfiles(): 'pnpm' | 'npm' | 'yarn' {
if (fs.existsSync(path.resolve('pnpm-lock.yaml'))) return 'pnpm';
if (fs.existsSync(path.resolve('yarn.lock'))) return 'yarn';
Expand Down Expand Up @@ -1052,6 +1155,12 @@ function runDoctorPhase(
// Helpers
// ============================================================================

/**
* Modules that must always appear in DEFAULT_LOAD rather than ON_DEMAND.
* core.adf and state.adf are always loaded — they are not optional.
*/
const DEFAULT_LOAD_MODULES = new Set(['core.adf', 'state.adf']);

function isValidPreset(value: string | undefined): value is StackPreset {
return value === 'worker' || value === 'frontend' || value === 'backend' || value === 'fullstack' || value === 'docs';
}
Expand Down Expand Up @@ -1124,3 +1233,48 @@ function registerOrphansInManifest(manifestPath: string, orphans: string[]): voi
lines.splice(insertIdx, 0, ...newEntries);
fs.writeFileSync(manifestPath, lines.join('\n'));
}

/**
* Register modules in the DEFAULT_LOAD section of manifest.adf.
* Uses the structured parseAdf/formatAdf round-trip so the result is canonical.
* DEFAULT_LOAD entries are plain filenames with no "(Triggers on: ...)" suffix.
*/
function registerModulesInDefaultLoad(manifestPath: string, modules: string[]): void {
const manifestDoc = parseAdf(fs.readFileSync(manifestPath, 'utf-8'));
const sectionKey = 'DEFAULT_LOAD';

let section = manifestDoc.sections.find(s => s.key === sectionKey);
if (!section) {
section = {
key: sectionKey,
decoration: '📦',
content: { type: 'list', items: [] },
};
// Prepend DEFAULT_LOAD before any ON_DEMAND section
const onDemandIdx = manifestDoc.sections.findIndex(s => s.key === 'ON_DEMAND');
if (onDemandIdx !== -1) {
manifestDoc.sections.splice(onDemandIdx, 0, section);
} else {
manifestDoc.sections.push(section);
}
}

if (section.content.type !== 'list') {
// Fallback: append raw text rather than throwing — bootstrap should not crash
const raw = '\n\n📦 DEFAULT_LOAD:\n' + modules.map(m => ` - ${m}`).join('\n') + '\n';
fs.writeFileSync(manifestPath, fs.readFileSync(manifestPath, 'utf-8').trimEnd() + raw);
return;
}

let updated = false;
for (const mod of modules) {
if (!section.content.items.includes(mod)) {
section.content.items.push(mod);
updated = true;
}
}

if (updated) {
fs.writeFileSync(manifestPath, formatAdf(manifestDoc));
}
}
Loading