aiwg
Version:
Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo
217 lines • 8.16 kB
JavaScript
/**
* Project-Local Bundle Promotion (Graduate)
*
* Operationalizes the identical-form portability invariant from
* @.aiwg/architecture/adr-identical-form-portability.md (#1038): a
* project-local bundle should graduate to upstream (or to a private
* corpus path) by byte-identical copy.
*
* Per the design at @.aiwg/architecture/design-doctor-log-promote.md
* (#1049), promote performs:
* 1. Pre-flight checks (bundle exists, manifest valid, no project-
* local @-refs, destination doesn't exist, identical-form layout)
* 2. Hash snapshot of source files
* 3. Recursive copy to destination
* 4. Re-hash destination — roll back (delete) on any mismatch
* 5. Update registry source: project-local → bundled (or corpus)
* 6. Optional --cleanup: remove .aiwg/<type>/<name>/ source
* 7. Activity log entry
*
* @design @.aiwg/architecture/design-doctor-log-promote.md
* @implements #1037
*/
import { createHash } from 'crypto';
import { cp, mkdir, readFile, readdir, rm, stat, } from 'fs/promises';
import { resolve, join, relative } from 'path';
import { discoverProjectLocalBundles } from './project-local-discovery.js';
import { appendProjectLocalActivity } from './project-local-activity.js';
const TYPE_TO_UPSTREAM_DIR = {
extension: 'agentic/code/addons',
addon: 'agentic/code/addons',
framework: 'agentic/code/frameworks',
plugin: 'agentic/code/addons',
};
async function sha256(absPath) {
const buf = await readFile(absPath);
return createHash('sha256').update(buf).digest('hex');
}
async function walk(rootAbs) {
const out = [];
async function recurse(dirAbs) {
let entries;
try {
entries = await readdir(dirAbs, { withFileTypes: true });
}
catch {
return;
}
for (const e of entries) {
const childAbs = join(dirAbs, e.name);
if (e.isDirectory()) {
await recurse(childAbs);
}
else if (e.isFile()) {
out.push(childAbs);
}
}
}
await recurse(rootAbs);
return out;
}
async function fileSize(absPath) {
try {
const st = await stat(absPath);
return st.size;
}
catch {
return 0;
}
}
/** Scan for `@.aiwg/...` references in artifact body files. */
async function findProjectLocalReferences(bundlePath) {
const out = [];
const files = await walk(bundlePath);
for (const f of files) {
if (!f.endsWith('.md') && !f.endsWith('.json') && !f.endsWith('.yaml') && !f.endsWith('.yml'))
continue;
try {
const content = await readFile(f, 'utf-8');
if (content.includes('@.aiwg/')) {
out.push(relative(bundlePath, f));
}
}
catch {
// Skip unreadable files
}
}
return out;
}
/**
* Promote a project-local bundle to its upstream home or a corpus path.
*
* Pure function over (config, projectDir, bundleId, opts). Mutates
* config.installed when the operation succeeds (caller persists).
*/
export async function promoteProjectLocalBundle(config, projectDir, bundleId, opts = {}) {
const { to = 'upstream', corpusPath, dryRun = false, cleanup = false, force = false, frameworkRoot = projectDir, } = opts;
// Pre-flight 1: discover the bundle
const discovery = await discoverProjectLocalBundles(projectDir);
const bundle = discovery.bundles.find(b => b.id === bundleId);
if (!bundle) {
return { ok: false, failureReason: 'bundle-not-found', message: `No project-local bundle '${bundleId}' under .aiwg/{extensions,addons,frameworks,plugins}/` };
}
// Pre-flight 2: corpus path required for --to corpus
if (to === 'corpus' && !corpusPath) {
return { ok: false, failureReason: 'destination-required', message: '--to corpus requires a path argument' };
}
// Resolve destination
const destinationParent = to === 'corpus'
? resolve(projectDir, corpusPath)
: resolve(frameworkRoot, TYPE_TO_UPSTREAM_DIR[bundle.type]);
const destination = join(destinationParent, bundleId);
// Pre-flight 3: destination must not already exist
let destExists = false;
try {
await stat(destination);
destExists = true;
}
catch {
// Doesn't exist — good
}
if (destExists) {
return { ok: false, failureReason: 'destination-exists', message: `Destination '${destination}' already exists. Promote refuses to overwrite — remove it first.` };
}
// Pre-flight 4: project-local @-references would dangle
const refs = await findProjectLocalReferences(bundle.bundlePath);
if (refs.length > 0 && !force) {
return {
ok: false,
failureReason: 'project-local-references',
message: `Bundle contains @.aiwg/ references that would dangle after promote: ${refs.slice(0, 3).join(', ')}${refs.length > 3 ? `, +${refs.length - 3} more` : ''}. Use --force to promote anyway.`,
};
}
// Build the plan
const sourceFiles = await walk(bundle.bundlePath);
const sourceRels = sourceFiles.map(f => relative(bundle.bundlePath, f));
let totalBytes = 0;
for (const f of sourceFiles)
totalBytes += await fileSize(f);
const plan = {
bundleId,
type: bundle.type,
source: bundle.localPath,
destination,
files: sourceRels,
totalBytes,
};
if (dryRun) {
return { ok: true, plan };
}
// Snapshot source hashes
const expectedHashes = {};
for (const abs of sourceFiles) {
const rel = relative(bundle.bundlePath, abs);
expectedHashes[rel] = await sha256(abs);
}
// Copy
try {
await mkdir(destinationParent, { recursive: true });
await cp(bundle.bundlePath, destination, { recursive: true });
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
await appendProjectLocalActivity({
event: 'promote-failed',
name: bundleId,
type: bundle.type,
summary: `copy failed: ${msg}`,
});
return { ok: false, failureReason: 'copy-failed', message: `Copy failed: ${msg}` };
}
// Verify hashes; roll back on mismatch
for (const [rel, expected] of Object.entries(expectedHashes)) {
const destAbs = join(destination, rel);
const actual = await sha256(destAbs).catch(() => null);
if (actual !== expected) {
try {
await rm(destination, { recursive: true, force: true });
}
catch { /* best effort */ }
await appendProjectLocalActivity({
event: 'promote-failed',
name: bundleId,
type: bundle.type,
summary: `hash mismatch on ${rel} — rolled back`,
});
return { ok: false, failureReason: 'hash-mismatch', message: `Hash mismatch on ${rel} after copy. Rolled back.` };
}
}
// Registry update: source flips
const entry = config.installed[bundleId];
if (entry) {
entry.source = to === 'upstream' ? 'bundled' : 'corpus';
delete entry.localPath;
delete entry.localType;
delete entry.manifestVersion;
// Keep artifactHashes — they remain valid for drift detection of the new install
}
// Cleanup source if requested
if (cleanup) {
try {
await rm(bundle.bundlePath, { recursive: true, force: true });
}
catch (err) {
// Non-fatal — promote already succeeded
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`promote --cleanup failed (non-fatal): ${msg}\n`);
}
}
await appendProjectLocalActivity({
event: 'promote',
name: bundleId,
type: bundle.type,
summary: `${destination}${cleanup ? ' (source cleaned up)' : ''}`,
});
return { ok: true, plan, copied: sourceRels };
}
//# sourceMappingURL=project-local-promote.js.map