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
283 lines • 10.7 kB
JavaScript
/**
* Project-Local Bundle Discovery
*
* Scans `.aiwg/{extensions,addons,frameworks,plugins}/<name>/manifest.json`
* and validates each manifest against the unified schema. Read-only — no
* deployment side effects.
*
* @implements #1034
* @architecture .aiwg/architecture/adr-aiwg-directory-layout.md (#1039)
* @architecture .aiwg/architecture/design-manifest-schema.md (#1044)
*/
import { readFile, readdir, lstat, stat, access } from 'fs/promises';
import { resolve, join } from 'path';
import { BundleManifestSchema, zodErrorToValidationErrors, MANIFEST_MAX_BYTES, MAX_BUNDLES_PER_PROJECT, } from './manifest.js';
const AIWG_DIR = '.aiwg';
const SCAN_TYPES = ['extensions', 'addons', 'frameworks', 'plugins'].map(
// typed elsewhere; the directory names are the plural of the type
(s) => s).map((dir) => {
// Map directory name to canonical singular type. ExtensionS → extension, etc.
const map = {
extensions: 'extension',
addons: 'addon',
frameworks: 'framework',
plugins: 'plugin',
};
return map[dir];
});
/** Directory name → singular type */
const DIR_TO_TYPE = {
extensions: 'extension',
addons: 'addon',
frameworks: 'framework',
plugins: 'plugin',
};
const SCAN_DIRS = Object.keys(DIR_TO_TYPE);
/**
* Scan all four `.aiwg/<type>/` directories for project-local bundles. Returns
* a structured result with bundles, errors, and counts. Missing directories
* are silently skipped (per #1039 §3 / UC-PL-6).
*/
export async function discoverProjectLocalBundles(projectDir, options = {}) {
const bundles = [];
const errors = [];
const counts = {
extension: 0,
addon: 0,
framework: 0,
plugin: 0,
};
let totalScanned = 0;
for (const dirName of SCAN_DIRS) {
const type = DIR_TO_TYPE[dirName];
const dirPath = resolve(projectDir, AIWG_DIR, dirName);
let bundleNames;
try {
bundleNames = await readdir(dirPath);
}
catch {
// Directory absent — silently skip (no-op when absent per UC-PL-6)
continue;
}
for (const bundleName of bundleNames) {
const bundlePath = join(dirPath, bundleName);
// Skip non-directory entries (e.g., the legacy registry.json file)
let isDir;
try {
const st = await lstat(bundlePath);
if (st.isSymbolicLink()) {
if (!options.allowSymlinks) {
errors.push({
path: bundlePath,
field: '(bundle directory)',
expected: 'regular directory',
actual: 'symlink',
hint: 'Pass --allow-symlinks to opt in (per #1042 threat model T3)',
severity: 'error',
});
continue;
}
// Resolve the symlink and check the target is a directory
const target = await stat(bundlePath);
isDir = target.isDirectory();
}
else {
isDir = st.isDirectory();
}
}
catch {
continue;
}
if (!isDir)
continue;
const manifestPath = join(bundlePath, 'manifest.json');
// Silently skip directories without manifest.json — these are not
// project-local bundles. Most commonly, .aiwg/frameworks/<id>/ holds
// workspace state from initializeFrameworkWorkspace() (archive/,
// projects/, repo/, working/), not a bundle. Issue #1058.
try {
await access(manifestPath);
}
catch {
continue;
}
totalScanned++;
// Enforce per-project bundle count cap (#1042 D2 / NFR-PL-12)
if (totalScanned > MAX_BUNDLES_PER_PROJECT) {
errors.push({
path: dirPath,
field: '(bundle count)',
expected: `<= ${MAX_BUNDLES_PER_PROJECT} bundles per project`,
actual: `>${MAX_BUNDLES_PER_PROJECT}`,
hint: 'Refusing to scan further bundles. Reduce project-local artifact count.',
severity: 'error',
});
const isEmpty = bundles.length === 0;
return { bundles, errors, isEmpty, counts };
}
const result = await loadAndValidateManifest(manifestPath, type, projectDir);
if (result.bundle) {
bundles.push(result.bundle);
counts[result.bundle.type]++;
}
errors.push(...result.errors);
}
}
// Cross-bundle: check for case-insensitive id collisions within a single type
// (NFR-PL-6) and duplicate ids (per #1041 §4 case 6)
for (const type of ['extension', 'addon', 'framework', 'plugin']) {
const bundlesOfType = bundles.filter((b) => b.type === type);
const idsLower = new Map();
for (const b of bundlesOfType) {
const key = b.id.toLowerCase();
const existing = idsLower.get(key) ?? [];
existing.push(b);
idsLower.set(key, existing);
}
for (const [, group] of idsLower) {
if (group.length > 1) {
for (const b of group) {
errors.push({
path: b.manifestPath,
field: 'id',
expected: 'unique id (case-insensitive) within type',
actual: `${b.id} (collides with ${group.filter((g) => g !== b).map((g) => g.id).join(', ')})`,
hint: 'Two bundles within the same type may not share an id, even differing only in case',
severity: 'error',
});
}
// Drop colliding bundles from the result so they don't get treated as
// valid downstream
for (const b of group) {
const idx = bundles.indexOf(b);
if (idx >= 0) {
bundles.splice(idx, 1);
counts[b.type]--;
}
}
}
}
}
const isEmpty = bundles.length === 0 && errors.length === 0;
return { bundles, errors, isEmpty, counts };
}
/**
* Read a single manifest.json, validate it, and return either a bundle or
* structured errors. Used by the scanner; exported for tests.
*/
export async function loadAndValidateManifest(manifestPath, expectedType, projectDir) {
// Size cap (#1042 D1 / NFR-PL-11): refuse before parse
let st;
try {
st = await stat(manifestPath);
}
catch {
return {
errors: [{
path: manifestPath,
field: '(file)',
expected: 'manifest.json present',
actual: 'absent',
hint: 'Project-local bundle directory missing manifest.json',
severity: 'error',
}],
};
}
if (st.size > MANIFEST_MAX_BYTES) {
return {
errors: [{
path: manifestPath,
field: '(file size)',
expected: `<= ${MANIFEST_MAX_BYTES} bytes`,
actual: `${st.size} bytes`,
hint: 'Manifest exceeds size limit; refusing to parse',
severity: 'error',
}],
};
}
let raw;
try {
raw = await readFile(manifestPath, 'utf-8');
}
catch (err) {
return {
errors: [{
path: manifestPath,
field: '(read)',
expected: 'readable manifest.json',
actual: err.message,
severity: 'error',
}],
};
}
// Encoding check (NFR-PL-5): UTF-8 BOM is allowed; we'll normalize. Other
// BOMs (UTF-16 LE/BE, Latin-1) are rejected.
if (raw.charCodeAt(0) === 0xFEFF) {
raw = raw.slice(1);
}
else if (raw.charCodeAt(0) === 0xFFFE || raw.charCodeAt(0) === 0xFEFF) {
return {
errors: [{
path: manifestPath,
field: '(encoding)',
expected: 'UTF-8',
actual: 'non-UTF-8 BOM detected',
hint: 'Manifest must be UTF-8 encoded',
severity: 'error',
}],
};
}
let parsed;
try {
parsed = JSON.parse(raw);
}
catch (err) {
return {
errors: [{
path: manifestPath,
field: '(JSON parse)',
expected: 'valid JSON',
actual: err.message,
severity: 'error',
}],
};
}
const result = BundleManifestSchema.safeParse(parsed);
if (!result.success) {
return { errors: zodErrorToValidationErrors(result.error, manifestPath) };
}
const manifest = result.data;
// Cross-check: manifest declared type must match the directory it lives in.
if (manifest.type !== expectedType) {
return {
errors: [{
path: manifestPath,
field: 'type',
expected: `"${expectedType}" (matching directory .aiwg/${expectedType}s/)`,
actual: `"${manifest.type}"`,
hint: `Bundle in .aiwg/${expectedType}s/ must declare type: "${expectedType}"`,
severity: 'error',
}],
};
}
const bundlePath = manifestPath.slice(0, -'/manifest.json'.length);
const localPath = bundlePath.startsWith(projectDir + '/')
? bundlePath.slice(projectDir.length + 1) + '/'
: bundlePath + '/';
return {
bundle: {
id: manifest.id,
type: manifest.type,
manifest,
bundlePath,
localPath,
manifestPath: manifestPath.startsWith(projectDir + '/')
? manifestPath.slice(projectDir.length + 1)
: manifestPath,
},
errors: [],
};
}
// Suppress unused import noise from the SCAN_TYPES helper (kept for clarity)
void SCAN_TYPES;
//# sourceMappingURL=project-local-discovery.js.map