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
480 lines (426 loc) • 15.6 kB
JavaScript
/**
* Channel Manager
*
* Manages the distribution channel (stable vs edge) for AIWG.
* - Stable: Uses the npm-installed package
* - Edge: Uses a git clone of the main branch for bleeding-edge updates
*
* @module src/channel/manager
* @version 2024.12.0
*/
import fs from 'fs/promises';
import { readFileSync, existsSync } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { execSync, spawn } from 'child_process';
import os from 'os';
/**
* Run a command with inherited stdio, applying a wall-clock timeout so a
* slow or hung git fetch cannot wedge the CLI for minutes. On timeout the
* child is SIGTERM'd and escalated to SIGKILL after 5s if it does not exit.
* Rejects with an Error carrying `.code = 'ETIMEDOUT'` on timeout or the
* original exit code otherwise.
*
* Replaces execSync() sites that had no timeout. Overridable via
* AIWG_GIT_TIMEOUT_MS (default 60s — fetch/pull on slow networks can take
* a while but must eventually terminate).
*/
async function runWithTimeout(cmd, args, options = {}) {
const timeoutMs = (() => {
const raw = process.env['AIWG_GIT_TIMEOUT_MS'];
const n = raw ? parseInt(raw, 10) : NaN;
return Number.isFinite(n) && n > 0 ? n : 60_000;
})();
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, {
stdio: 'inherit',
...options,
});
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
try { child.kill('SIGTERM'); } catch { /* already exited */ }
const kill9 = setTimeout(() => {
try { child.kill('SIGKILL'); } catch { /* already exited */ }
}, 5_000);
kill9.unref?.();
settled = true;
const err = new Error(`\`${cmd} ${args.join(' ')}\` timed out after ${timeoutMs}ms`);
err.code = 'ETIMEDOUT';
reject(err);
}, timeoutMs);
timer.unref?.();
child.on('close', (code) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (code === 0) resolve();
else {
const err = new Error(`\`${cmd} ${args.join(' ')}\` exited with code ${code}`);
err.code = code ?? 1;
reject(err);
}
});
child.on('error', (err) => {
if (settled) return;
settled = true;
clearTimeout(timer);
reject(err);
});
});
}
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Configuration paths
const CONFIG_DIR = path.join(os.homedir(), '.aiwg');
const CONFIG_FILE = path.join(CONFIG_DIR, 'channel.json');
const EDGE_INSTALL_PATH = path.join(os.homedir(), '.local', 'share', 'ai-writing-guide');
const REPO_URL = 'https://github.com/jmagly/aiwg.git';
/**
* Default channel configuration
*/
const DEFAULT_CONFIG = {
channel: 'stable',
edgePath: EDGE_INSTALL_PATH,
lastUpdateCheck: null,
updateCheckInterval: 86400000, // 24 hours in ms
};
/**
* Get the package root directory.
*
* Walks up from this file's directory looking for the nearest package.json
* that names "aiwg". This works whether this module runs from its source
* location (`src/channel/manager.mjs`) or from its compiled-build copy
* (`dist/src/channel/manager.mjs`), both of which have package.json at
* the repo root.
*
* The walk is bounded to 10 levels as a safety cap.
*
* @returns {string} Path to package root
*/
export function getPackageRoot() {
let dir = __dirname;
for (let i = 0; i < 10; i++) {
const pkg = path.join(dir, 'package.json');
if (existsSync(pkg)) {
try {
const content = JSON.parse(readFileSync(pkg, 'utf8'));
if (content.name === 'aiwg') return dir;
} catch {
// keep walking
}
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
// Fallback to the legacy hardcoded two-up resolution.
return path.resolve(__dirname, '..', '..');
}
/**
* Load channel configuration
* @returns {Promise<object>} Channel configuration
*/
export async function loadConfig() {
try {
const data = await fs.readFile(CONFIG_FILE, 'utf8');
return { ...DEFAULT_CONFIG, ...JSON.parse(data) };
} catch {
return { ...DEFAULT_CONFIG };
}
}
/**
* Save channel configuration
* @param {object} config - Configuration to save
*/
export async function saveConfig(config) {
await fs.mkdir(CONFIG_DIR, { recursive: true });
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
}
/**
* Get the current channel
* @returns {Promise<string>} 'stable' or 'edge'
*/
export async function getChannel() {
const config = await loadConfig();
return config.channel;
}
/**
* Get the framework root path based on current channel
* @returns {Promise<string>} Path to framework root
*/
export async function getFrameworkRoot() {
const config = await loadConfig();
if (config.channel === 'edge') {
// Check if edge installation exists
try {
await fs.access(config.edgePath);
return config.edgePath;
} catch {
// Edge path doesn't exist, fall back to stable
console.warn('Edge installation not found, using stable channel');
return getPackageRoot();
}
}
return getPackageRoot();
}
/**
* Switch to edge (bleeding edge) channel
* Clones or updates the git repository for latest main branch
*/
export async function switchToEdge() {
const config = await loadConfig();
console.log('Switching to edge channel (bleeding edge from main branch)...');
console.log('');
// Check if edge installation exists
const edgeExists = await fs.access(config.edgePath).then(() => true).catch(() => false);
if (edgeExists) {
// Update existing installation
console.log(`Updating edge installation at ${config.edgePath}...`);
try {
await runWithTimeout('git', ['fetch', '--all'], { cwd: config.edgePath });
await runWithTimeout('git', ['checkout', 'main'], { cwd: config.edgePath });
await runWithTimeout('git', ['pull', '--ff-only'], { cwd: config.edgePath });
// Ensure sparse checkout excludes .aiwg/ on existing installs
try {
const sparseConfig = path.join(config.edgePath, '.git', 'info', 'sparse-checkout');
const hasSparse = await fs.access(sparseConfig).then(() => true).catch(() => false);
if (!hasSparse) {
execSync('git sparse-checkout init --cone', { cwd: config.edgePath, stdio: 'pipe' });
execSync('git sparse-checkout set --no-cone "/*" "!/.aiwg"', { cwd: config.edgePath, stdio: 'pipe' });
}
} catch {
// Sparse checkout is optional — older git may not support it
}
console.log('Edge installation updated successfully.');
} catch (error) {
console.error('Failed to update edge installation:', error.message);
console.log('Try removing the directory and running again:');
console.log(` rm -rf ${config.edgePath}`);
console.log(' aiwg --use-main');
process.exit(1);
}
} else {
// Clone fresh
console.log(`Cloning repository to ${config.edgePath}...`);
await fs.mkdir(path.dirname(config.edgePath), { recursive: true });
try {
execSync(`git clone --branch main ${REPO_URL} "${config.edgePath}"`, { stdio: 'inherit' });
// Exclude dogfooding artifacts from edge clones — users want framework source only
try {
execSync('git sparse-checkout init --cone', { cwd: config.edgePath, stdio: 'pipe' });
execSync('git sparse-checkout set --no-cone "/*" "!/.aiwg"', { cwd: config.edgePath, stdio: 'pipe' });
} catch {
// Sparse checkout is optional — older git versions may not support it
console.log('Note: sparse checkout not available, .aiwg/ will be present in edge install.');
}
console.log('Edge installation created successfully.');
} catch (error) {
console.error('Failed to clone repository:', error.message);
process.exit(1);
}
}
// Update config
config.channel = 'edge';
await saveConfig(config);
console.log('');
console.log('Switched to edge channel.');
console.log('You are now using the latest code from the main branch.');
console.log('');
console.log('To update edge installation: aiwg -update');
console.log('To switch back to stable: aiwg --use-stable');
}
/**
* Switch to dev mode (use local repo as both framework source and CLI)
*
* In dev mode, the CLI entry point delegates to the dev repo's facade,
* so all code (including `aiwg index`, `aiwg use`, etc.) runs from the
* local build rather than the npm-installed package.
*
* @param {string} devPath - Path to the local development repository
*/
export async function switchToDev(devPath) {
const config = await loadConfig();
const resolvedPath = path.resolve(devPath);
console.log('Switching to dev mode (local repository source)...');
console.log('');
// Verify the path looks like an AIWG repo
try {
await fs.access(path.join(resolvedPath, 'agentic', 'code', 'frameworks'));
} catch {
console.error(`Error: ${resolvedPath} does not appear to be an AIWG repository.`);
console.error('Expected to find agentic/code/frameworks/ directory.');
process.exit(1);
}
// Verify compiled router exists (dev-mode dispatch imports from dist/).
// Accept repos that have never been built by emitting a clear build hint
// rather than hard-failing here — the first `aiwg` invocation will re-check
// and print the same hint. This lets `aiwg --use-dev /path/to/fresh/clone`
// succeed followed by `npm run build`.
const distRouter = path.join(resolvedPath, 'dist', 'src', 'cli', 'router.js');
const distExists = await fs.access(distRouter).then(() => true).catch(() => false);
config.channel = 'edge';
config.edgePath = resolvedPath;
config.devMode = true;
await saveConfig(config);
console.log('Switched to dev mode.');
console.log(`Dev repo: ${resolvedPath}`);
console.log(`Router: ${distRouter}`);
if (!distExists) {
console.log('');
console.log('NOTE: dist/ is not built yet. Before running aiwg commands:');
console.log(` (cd ${resolvedPath} && npm run build)`);
}
console.log('');
console.log('The aiwg command now runs the compiled router from your local repo.');
console.log('After making changes, run: npm run build');
console.log('');
console.log('Commands:');
console.log(' aiwg use all Deploy from local source');
console.log(' aiwg version Verify dev mode active');
console.log(' aiwg --use-stable Switch back to npm package');
}
/**
* Switch to next (alpha/beta/RC) channel
* Installs the latest pre-release from the `next` dist-tag
*/
export async function switchToNext() {
const config = await loadConfig();
console.log('Switching to next channel (alpha/beta/RC — latest pre-release)...');
console.log('');
try {
execSync('npm install -g aiwg@next', { stdio: 'inherit' });
} catch (error) {
console.error('Failed to install aiwg@next:', error.message);
console.error('Check that npm is available and you have write access to the global prefix.');
process.exit(1);
}
config.channel = 'next';
config.devMode = false;
await saveConfig(config);
console.log('');
console.log('Switched to next channel.');
console.log('You are now running the latest alpha/beta/RC release.');
console.log('');
console.log('To see what version you installed: aiwg version');
console.log('To switch back to stable: aiwg sync --channel latest');
}
/**
* Switch to nightly channel
* Installs the latest nightly snapshot from the `nightly` dist-tag
*/
export async function switchToNightly() {
const config = await loadConfig();
console.log('Switching to nightly channel (latest automated snapshot)...');
console.log('');
try {
execSync('npm install -g aiwg@nightly', { stdio: 'inherit' });
} catch (error) {
console.error('Failed to install aiwg@nightly:', error.message);
console.error('Check that npm is available and you have write access to the global prefix.');
process.exit(1);
}
config.channel = 'nightly';
config.devMode = false;
await saveConfig(config);
console.log('');
console.log('Switched to nightly channel.');
console.log('You are now running the latest automated nightly snapshot.');
console.log('');
console.log('To see what version you installed: aiwg version');
console.log('To switch back to stable: aiwg sync --channel latest');
}
/**
* Switch to stable (npm) channel
*/
export async function switchToStable() {
const config = await loadConfig();
console.log('Switching to stable channel (npm package)...');
console.log('');
config.channel = 'stable';
config.devMode = false;
await saveConfig(config);
console.log('Switched to stable channel.');
console.log('You are now using the npm-installed package.');
console.log('');
console.log('To update: npm update -g aiwg');
console.log('To switch to edge: aiwg --use-main');
}
/**
* Get version information based on current channel
* @returns {Promise<object>} Version info
*/
export async function getVersionInfo() {
const config = await loadConfig();
const packageRoot = getPackageRoot();
// Read package.json version
const packageJsonPath = path.join(packageRoot, 'package.json');
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
// Detect pre-release channel from version string when channel.json says 'stable'
// This handles the case where a user ran `npm install -g aiwg@next` directly —
// npm updates the binary but channel.json stays as 'stable'.
let channel = config.channel;
const version = packageJson.version;
if (!config.devMode && channel === 'stable') {
if (version.includes('-rc.')) {
channel = 'rc';
} else if (version.includes('-beta.')) {
channel = 'beta';
} else if (version.includes('-alpha.')) {
channel = 'alpha';
} else if (version.includes('-nightly.')) {
channel = 'nightly';
}
}
const info = {
version,
channel,
packageRoot,
devMode: config.devMode || false,
};
if (config.channel === 'edge') {
// Get git info for edge channel
try {
const gitHash = execSync('git rev-parse --short HEAD', {
cwd: config.edgePath,
encoding: 'utf8',
}).trim();
const gitBranch = execSync('git branch --show-current', {
cwd: config.edgePath,
encoding: 'utf8',
}).trim();
info.edgePath = config.edgePath;
info.gitHash = gitHash;
info.gitBranch = gitBranch;
} catch {
// Git info not available
}
}
return info;
}
/**
* Update the edge installation from git
*/
export async function updateEdge() {
const config = await loadConfig();
if (config.channel !== 'edge') {
console.log('Not in edge channel. Use npm update -g aiwg for stable channel.');
return;
}
console.log('Updating edge installation...');
try {
await runWithTimeout('git', ['fetch', '--all'], { cwd: config.edgePath });
await runWithTimeout('git', ['pull', '--ff-only'], { cwd: config.edgePath });
console.log('');
console.log('Edge installation updated successfully.');
} catch (error) {
console.error('Update failed:', error.message);
console.log('');
console.log('If you have local changes, try:');
console.log(` cd ${config.edgePath}`);
console.log(' git stash');
console.log(' git pull');
console.log(' git stash pop');
process.exit(1);
}
}