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
562 lines (561 loc) • 21.4 kB
JavaScript
/**
* Ops Workspace Registry
*
* Manages the ops.yaml file in the user config directory.
* Tracks workspace definitions, repo locations, and cross-repo wiring.
*
* @implements #544
*/
import { readFile, writeFile, mkdir, readdir } from 'fs/promises';
import { resolve, dirname, basename, sep } from 'path';
import { homedir } from 'os';
import { existsSync, readdirSync, statSync } from 'fs';
import { execSync } from 'child_process';
import { resolveConfigDir } from '../config/user-config.js';
/**
* Extension type abbreviations
*/
const EXTENSION_NAMES = {
sys: 'sysops',
it: 'itops',
dev: 'devops',
stream: 'streamops',
};
/**
* Default empty registry
*/
const DEFAULT_REGISTRY = {
apiVersion: 'aiwg.io/v1',
kind: 'OpsRegistry',
defaultWorkspace: 'default',
workspaces: {},
};
/**
* Ops workspace registry manager
*/
export class OpsRegistry {
configDir;
registryPath;
constructor(configDirOverride) {
this.configDir = resolveConfigDir(configDirOverride);
this.registryPath = resolve(this.configDir, 'ops.json');
}
/**
* Load the ops registry, creating defaults if missing
*/
async load() {
if (!existsSync(this.registryPath)) {
return { ...DEFAULT_REGISTRY, workspaces: {} };
}
try {
const content = await readFile(this.registryPath, 'utf-8');
const parsed = JSON.parse(content);
return {
...DEFAULT_REGISTRY,
...parsed,
workspaces: parsed.workspaces ? { ...parsed.workspaces } : {},
};
}
catch {
return { ...DEFAULT_REGISTRY, workspaces: {} };
}
}
/**
* Save the ops registry
*/
async save(data) {
await mkdir(this.configDir, { recursive: true });
await writeFile(this.registryPath, JSON.stringify(data, null, 2), 'utf-8');
}
/**
* Initialize a new ops workspace
*/
async initWorkspace(opts) {
const data = await this.load();
// Check for existing workspace
if (data.workspaces[opts.name]) {
throw new Error(`Workspace "${opts.name}" already exists. Use a different name or remove the existing workspace.`);
}
// Resolve home directory
const opsHome = opts.home || resolve(homedir(), 'ops', opts.name);
// Refuse to create nested ops workspaces — walk up from the parent of
// opsHome looking for OpsInventory.yaml. Sibling layout is required.
const enclosing = findEnclosingOpsWorkspace(opsHome);
if (enclosing) {
const suggested = resolve(dirname(enclosing), opts.name);
throw new Error(`${enclosing} is already an ops workspace.\n` +
`Ops workspaces must be siblings of one another, not nested.\n` +
`Suggested location: ${suggested}`);
}
// Build workspace
const workspace = {
home: opsHome,
mode: opts.mode,
repos: {},
};
if (opts.from && opts.mode === 'multi-repo' && opts.extensions.length > 1) {
throw new Error('--from <url> requires single-repo mode or exactly one extension. ' +
'Use --mode single-repo, or pass --ext with a single value.');
}
if (opts.mode === 'multi-repo') {
// Create separate repo for each extension
for (const ext of opts.extensions) {
const fullName = EXTENSION_NAMES[ext] || ext;
const repoName = opts.prefix ? `${opts.prefix}-${fullName}` : fullName;
const repoPath = resolve(opsHome, repoName);
const provisioned = await provisionRepo(repoPath, opts.from, !!opts.silent);
workspace.repos[repoName] = {
path: repoPath,
remote: provisioned.remote,
extensions: [ext],
};
// Seed OpsInventory stub (only if missing — never overwrite)
await seedInventory(repoPath, repoName, ext);
if (!opts.silent)
console.log(` ${provisioned.action} ${repoName} at ${repoPath}`);
}
}
else {
// Single-repo mode: one repo, subdirectories per domain
const repoName = opts.prefix ? `${opts.prefix}-ops` : 'ops';
const repoPath = resolve(opsHome, repoName);
const provisioned = await provisionRepo(repoPath, opts.from, !!opts.silent);
// Create subdirectories for each extension
for (const ext of opts.extensions) {
const fullName = EXTENSION_NAMES[ext] || ext;
const subDir = resolve(repoPath, fullName);
await mkdir(subDir, { recursive: true });
await seedInventory(subDir, fullName, ext);
}
workspace.repos[repoName] = {
path: repoPath,
remote: provisioned.remote,
extensions: opts.extensions,
};
if (!opts.silent)
console.log(` ${provisioned.action} ${repoName} at ${repoPath}`);
}
// Register workspace
data.workspaces[opts.name] = workspace;
if (Object.keys(data.workspaces).length === 1) {
data.defaultWorkspace = opts.name;
}
await this.save(data);
// Post-init summary
console.log('');
console.log(`Workspace "${opts.name}" initialized`);
console.log(` Mode: ${opts.mode}`);
console.log(` Home: ${opsHome}`);
console.log(` Extensions: ${opts.extensions.join(', ')}`);
console.log(` Repos: ${Object.keys(workspace.repos).join(', ')}`);
console.log(` Registry: ${this.registryPath}`);
if (opts.provider) {
console.log('');
console.log(`Remote push to ${opts.provider} requested — use 'aiwg ops push' to push repos.`);
}
}
/**
* Show workspace status
*/
async showStatus(showAll) {
const data = await this.load();
if (Object.keys(data.workspaces).length === 0) {
console.log('No ops workspaces registered.');
console.log('Run "aiwg ops init" to create one.');
return;
}
const workspaces = showAll
? Object.entries(data.workspaces)
: [[data.defaultWorkspace, data.workspaces[data.defaultWorkspace]]].filter(([, ws]) => ws !== undefined);
for (const [name, ws] of workspaces) {
const workspace = ws;
const isDefault = name === data.defaultWorkspace;
console.log(`${isDefault ? '* ' : ' '}${name} (${workspace.mode})`);
console.log(` Home: ${workspace.home}`);
for (const [repoName, repo] of Object.entries(workspace.repos)) {
const exists = existsSync(repo.path);
const hasGit = exists && existsSync(resolve(repo.path, '.git'));
const status = !exists ? 'MISSING' : !hasGit ? 'NO GIT' : 'OK';
console.log(` ${repoName}: ${status} — ${repo.path}`);
}
console.log('');
}
}
/**
* Switch active workspace
*/
async switchWorkspace(name) {
const data = await this.load();
if (!data.workspaces[name]) {
const available = Object.keys(data.workspaces).join(', ') || '(none)';
throw new Error(`Workspace "${name}" not found. Available: ${available}`);
}
data.defaultWorkspace = name;
await this.save(data);
console.log(`Active workspace: ${name}`);
}
/**
* List all registered workspaces
*/
async listWorkspaces() {
const data = await this.load();
if (Object.keys(data.workspaces).length === 0) {
console.log('No ops workspaces registered.');
return;
}
console.log('Registered workspaces:\n');
for (const [name, ws] of Object.entries(data.workspaces)) {
const isDefault = name === data.defaultWorkspace;
const repoCount = Object.keys(ws.repos).length;
console.log(` ${isDefault ? '*' : ' '} ${name} — ${ws.mode}, ${repoCount} repo(s), ${ws.home}`);
}
}
/**
* Adopt an existing local clone as a repo entry under a workspace.
*
* Detects the git remote, seeds OpsInventory.yaml only if missing
* (never overwrites an existing inventory), and registers the repo.
* Refuses to register a path nested inside another registered repo.
*/
async adoptRepo(repoPath, opts = {}) {
const absPath = resolve(repoPath);
if (!existsSync(absPath)) {
throw new Error(`Path does not exist: ${absPath}`);
}
if (!statSync(absPath).isDirectory()) {
throw new Error(`Path is not a directory: ${absPath}`);
}
const data = await this.load();
// Refuse if this path is nested inside another registered repo
for (const ws of Object.values(data.workspaces)) {
for (const repo of Object.values(ws.repos)) {
const registered = resolve(repo.path);
if (registered !== absPath && absPath.startsWith(registered + sep)) {
throw new Error(`Refusing to adopt: ${absPath} is nested inside registered repo ${registered}.`);
}
}
}
const workspaceName = opts.workspace ?? 'default';
if (!data.workspaces[workspaceName]) {
data.workspaces[workspaceName] = {
home: dirname(absPath),
mode: 'multi-repo',
repos: {},
};
}
const ws = data.workspaces[workspaceName];
const remote = readGitRemote(absPath);
let repoName = opts.name ?? basename(absPath);
let suffix = 2;
while (ws.repos[repoName]) {
// Same path already registered? Treat as idempotent.
if (resolve(ws.repos[repoName].path) === absPath) {
if (!opts.silent)
console.log(` Already registered: ${repoName} -> ${absPath}`);
return { workspace: workspaceName, repoName };
}
repoName = `${opts.name ?? basename(absPath)}-${suffix++}`;
}
// Seed OpsInventory.yaml only if missing — never overwrite existing.
const inventoryPath = resolve(absPath, 'OpsInventory.yaml');
if (!existsSync(inventoryPath)) {
const ext = (opts.extensions && opts.extensions[0]) ?? 'sys';
await seedInventory(absPath, repoName, ext);
}
ws.repos[repoName] = {
path: absPath,
remote,
extensions: opts.extensions ?? [],
};
if (Object.keys(data.workspaces).length === 1) {
data.defaultWorkspace = workspaceName;
}
await this.save(data);
if (!opts.silent) {
console.log(`Adopted ${repoName} at ${absPath} into workspace "${workspaceName}".`);
if (remote)
console.log(` Remote: ${remote}`);
}
return { workspace: workspaceName, repoName };
}
/**
* Walk the given roots looking for ops-workspace candidates.
*
* A candidate is any directory containing `OpsInventory.yaml`. Skips
* `node_modules`, `.git`, and other obvious noise. Candidates nested
* inside another candidate are dropped (siblings-only by design — see
* #935).
*/
async discoverWorkspaces(opts = {}) {
const roots = (opts.roots && opts.roots.length > 0 ? opts.roots : [homedir()]).map((r) => resolve(r));
const maxDepth = opts.maxDepth ?? 3;
const data = await this.load();
const registeredPaths = new Set();
for (const ws of Object.values(data.workspaces)) {
for (const repo of Object.values(ws.repos)) {
registeredPaths.add(resolve(repo.path));
}
}
const found = [];
const seen = new Set();
const skipNames = new Set([
'node_modules',
'.git',
'.aiwg', // walk past, never into
'.cache',
'.npm',
'.yarn',
'.pnpm-store',
'.venv',
'venv',
'dist',
'build',
'target',
]);
const walk = async (dir, depth) => {
if (depth > maxDepth)
return;
if (seen.has(dir))
return;
seen.add(dir);
// Marker check at this level
if (existsSync(resolve(dir, 'OpsInventory.yaml'))) {
found.push({
path: dir,
name: basename(dir),
remote: readGitRemote(dir),
alreadyRegistered: registeredPaths.has(dir),
marker: 'OpsInventory.yaml',
});
// Don't descend further — workspaces shouldn't nest.
return;
}
let entries;
try {
entries = await readdir(dir);
}
catch {
return;
}
for (const entry of entries) {
if (entry.startsWith('.') && entry !== '.aiwg') {
// Allow hidden roots only in narrow cases; skip the rest.
if (skipNames.has(entry))
continue;
if (entry === '.git')
continue;
}
if (skipNames.has(entry))
continue;
const child = resolve(dir, entry);
let isDir = false;
try {
isDir = statSync(child).isDirectory();
}
catch {
continue;
}
if (!isDir)
continue;
await walk(child, depth + 1);
}
};
for (const root of roots) {
if (!existsSync(root))
continue;
await walk(root, 0);
}
// Drop nested candidates (deeper paths whose ancestor is also a candidate)
found.sort((a, b) => a.path.length - b.path.length);
const kept = [];
for (const cand of found) {
const nested = kept.some((k) => cand.path !== k.path && cand.path.startsWith(k.path + sep));
if (!nested)
kept.push(cand);
}
return kept;
}
/**
* Register discovered candidates as a workspace in the registry.
*
* Creates a single multi-repo workspace and adds each candidate as a
* repo entry. Skips candidates whose path is already registered.
*/
async registerDiscovered(workspaceName, candidates) {
if (candidates.length === 0)
return { added: 0, skipped: 0 };
const data = await this.load();
if (!data.workspaces[workspaceName]) {
data.workspaces[workspaceName] = {
home: dirname(candidates[0].path),
mode: 'multi-repo',
repos: {},
};
}
const ws = data.workspaces[workspaceName];
let added = 0;
let skipped = 0;
for (const cand of candidates) {
if (cand.alreadyRegistered) {
skipped++;
continue;
}
// Avoid clobbering an existing repo entry of the same name
let repoName = cand.name;
let suffix = 2;
while (ws.repos[repoName]) {
repoName = `${cand.name}-${suffix++}`;
}
ws.repos[repoName] = {
path: cand.path,
remote: cand.remote,
extensions: [],
};
added++;
}
if (Object.keys(data.workspaces).length === 1) {
data.defaultWorkspace = workspaceName;
}
await this.save(data);
return { added, skipped };
}
/**
* Push workspace repos to remote (always private)
*/
async pushWorkspace(workspaceName) {
const data = await this.load();
const name = workspaceName || data.defaultWorkspace;
const workspace = data.workspaces[name];
if (!workspace) {
throw new Error(`Workspace "${name}" not found`);
}
for (const [repoName, repo] of Object.entries(workspace.repos)) {
if (!existsSync(repo.path)) {
console.log(` Skipping ${repoName} — path does not exist`);
continue;
}
if (repo.remote) {
console.log(` Pushing ${repoName} to ${repo.remote}...`);
try {
execSync(`git push origin main`, { cwd: repo.path, stdio: 'pipe' });
console.log(` ${repoName}: pushed`);
}
catch (err) {
console.log(` ${repoName}: push failed — ${err instanceof Error ? err.message : String(err)}`);
}
}
else {
console.log(` ${repoName}: no remote configured`);
}
}
}
}
/**
* Provision a repo at `repoPath`. Order of preference:
* 1. Pre-existing `.git` at the path → adopt (read remote, no init/clone).
* 2. `--from <url>` provided and target is empty → `git clone`.
* 3. Fallback → `mkdir` + `git init`.
*
* Returns the action taken and the resolved remote URL (if any).
*/
async function provisionRepo(repoPath, fromUrl, silent) {
// Case 1: existing .git at the path → adopt in place.
if (existsSync(resolve(repoPath, '.git'))) {
return { action: 'Adopted', remote: readGitRemote(repoPath) };
}
// Case 2: --from URL → clone (only if target is empty or doesn't exist).
if (fromUrl) {
const exists = existsSync(repoPath);
if (exists) {
// Refuse to clone over a non-empty existing dir to avoid surprise overwrites.
let entries = [];
try {
entries = readdirSync(repoPath);
}
catch {
// ignore
}
if (entries.filter((e) => !e.startsWith('.')).length > 0) {
throw new Error(`Refusing to clone into non-empty directory: ${repoPath}. Move or remove it first.`);
}
}
await mkdir(dirname(repoPath), { recursive: true });
execSync(`git clone ${shellEscape(fromUrl)} ${shellEscape(repoPath)}`, { stdio: silent ? 'pipe' : 'inherit' });
return { action: 'Cloned', remote: readGitRemote(repoPath) };
}
// Case 3: fresh init.
await mkdir(repoPath, { recursive: true });
execSync('git init', { cwd: repoPath, stdio: 'pipe' });
return { action: 'Created', remote: undefined };
}
/**
* Minimal shell escaping for git URLs and paths passed via execSync.
* URLs are URL-encoded already, but paths may contain spaces.
*/
function shellEscape(s) {
if (/^[A-Za-z0-9_@:./~+,=-]+$/.test(s))
return s;
return `'${s.replace(/'/g, "'\\''")}'`;
}
/**
* Return the `origin` remote URL for a git repo rooted at `dirPath`, or
* undefined if not a git repo or no origin configured.
*/
function readGitRemote(dirPath) {
if (!existsSync(resolve(dirPath, '.git')))
return undefined;
try {
const out = execSync('git config --get remote.origin.url', {
cwd: dirPath,
stdio: ['ignore', 'pipe', 'ignore'],
})
.toString()
.trim();
return out || undefined;
}
catch {
return undefined;
}
}
/**
* Walk up from targetPath's parent looking for an enclosing ops workspace.
* Returns the path of the enclosing workspace, or null if none found.
*
* Marker: OpsInventory.yaml (the file the ops scaffolder seeds at workspace
* root). `.aiwg/` is intentionally not used as a marker since AIWG project
* roots routinely contain `.aiwg/aiwg.config` without being ops workspaces.
*/
function findEnclosingOpsWorkspace(targetPath) {
const resolved = resolve(targetPath);
let current = dirname(resolved);
// Stop walking when dirname() no longer advances (filesystem root).
while (true) {
if (existsSync(resolve(current, 'OpsInventory.yaml'))) {
return current;
}
const parent = dirname(current);
if (parent === current)
return null;
current = parent;
}
}
/**
* Seed an OpsInventory.yaml stub in a repo or subdirectory
*/
async function seedInventory(dirPath, name, extension) {
const inventoryPath = resolve(dirPath, 'OpsInventory.yaml');
if (existsSync(inventoryPath))
return;
const fullName = EXTENSION_NAMES[extension] || extension;
const content = `apiVersion: aiwg.io/v1
kind: OpsInventory
metadata:
name: ${name}
domain: ${fullName}
created: ${new Date().toISOString().split('T')[0]}
# Add hosts, services, and resources below
inventory: []
`;
await writeFile(inventoryPath, content, 'utf-8');
}
//# sourceMappingURL=registry.js.map