mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-03-30 13:43:26 +08:00
feat: orchestration harness, selective install, observer improvements
This commit is contained in:
211
scripts/ci/validate-install-manifests.js
Normal file
211
scripts/ci/validate-install-manifests.js
Normal file
@@ -0,0 +1,211 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Validate selective-install manifests and profile/module relationships.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const Ajv = require('ajv');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '../..');
|
||||
const MODULES_MANIFEST_PATH = path.join(REPO_ROOT, 'manifests/install-modules.json');
|
||||
const PROFILES_MANIFEST_PATH = path.join(REPO_ROOT, 'manifests/install-profiles.json');
|
||||
const COMPONENTS_MANIFEST_PATH = path.join(REPO_ROOT, 'manifests/install-components.json');
|
||||
const MODULES_SCHEMA_PATH = path.join(REPO_ROOT, 'schemas/install-modules.schema.json');
|
||||
const PROFILES_SCHEMA_PATH = path.join(REPO_ROOT, 'schemas/install-profiles.schema.json');
|
||||
const COMPONENTS_SCHEMA_PATH = path.join(REPO_ROOT, 'schemas/install-components.schema.json');
|
||||
const COMPONENT_FAMILY_PREFIXES = {
|
||||
baseline: 'baseline:',
|
||||
language: 'lang:',
|
||||
framework: 'framework:',
|
||||
capability: 'capability:',
|
||||
};
|
||||
|
||||
function readJson(filePath, label) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid JSON in ${label}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRelativePath(relativePath) {
|
||||
return String(relativePath).replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function validateSchema(ajv, schemaPath, data, label) {
|
||||
const schema = readJson(schemaPath, `${label} schema`);
|
||||
const validate = ajv.compile(schema);
|
||||
const valid = validate(data);
|
||||
|
||||
if (!valid) {
|
||||
for (const error of validate.errors) {
|
||||
console.error(
|
||||
`ERROR: ${label} schema: ${error.instancePath || '/'} ${error.message}`
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function validateInstallManifests() {
|
||||
if (!fs.existsSync(MODULES_MANIFEST_PATH) || !fs.existsSync(PROFILES_MANIFEST_PATH)) {
|
||||
console.log('Install manifests not found, skipping validation');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let hasErrors = false;
|
||||
let modulesData;
|
||||
let profilesData;
|
||||
let componentsData = { version: null, components: [] };
|
||||
|
||||
try {
|
||||
modulesData = readJson(MODULES_MANIFEST_PATH, 'install-modules.json');
|
||||
profilesData = readJson(PROFILES_MANIFEST_PATH, 'install-profiles.json');
|
||||
if (fs.existsSync(COMPONENTS_MANIFEST_PATH)) {
|
||||
componentsData = readJson(COMPONENTS_MANIFEST_PATH, 'install-components.json');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`ERROR: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const ajv = new Ajv({ allErrors: true });
|
||||
hasErrors = validateSchema(ajv, MODULES_SCHEMA_PATH, modulesData, 'install-modules.json') || hasErrors;
|
||||
hasErrors = validateSchema(ajv, PROFILES_SCHEMA_PATH, profilesData, 'install-profiles.json') || hasErrors;
|
||||
if (fs.existsSync(COMPONENTS_MANIFEST_PATH)) {
|
||||
hasErrors = validateSchema(ajv, COMPONENTS_SCHEMA_PATH, componentsData, 'install-components.json') || hasErrors;
|
||||
}
|
||||
|
||||
if (hasErrors) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const modules = Array.isArray(modulesData.modules) ? modulesData.modules : [];
|
||||
const moduleIds = new Set();
|
||||
const claimedPaths = new Map();
|
||||
|
||||
for (const module of modules) {
|
||||
if (moduleIds.has(module.id)) {
|
||||
console.error(`ERROR: Duplicate install module id: ${module.id}`);
|
||||
hasErrors = true;
|
||||
}
|
||||
moduleIds.add(module.id);
|
||||
|
||||
for (const dependency of module.dependencies) {
|
||||
if (!moduleIds.has(dependency) && !modules.some(candidate => candidate.id === dependency)) {
|
||||
console.error(`ERROR: Module ${module.id} depends on unknown module ${dependency}`);
|
||||
hasErrors = true;
|
||||
}
|
||||
if (dependency === module.id) {
|
||||
console.error(`ERROR: Module ${module.id} cannot depend on itself`);
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const relativePath of module.paths) {
|
||||
const normalizedPath = normalizeRelativePath(relativePath);
|
||||
const absolutePath = path.join(REPO_ROOT, normalizedPath);
|
||||
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
console.error(
|
||||
`ERROR: Module ${module.id} references missing path: ${normalizedPath}`
|
||||
);
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
if (claimedPaths.has(normalizedPath)) {
|
||||
console.error(
|
||||
`ERROR: Install path ${normalizedPath} is claimed by both ${claimedPaths.get(normalizedPath)} and ${module.id}`
|
||||
);
|
||||
hasErrors = true;
|
||||
} else {
|
||||
claimedPaths.set(normalizedPath, module.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const profiles = profilesData.profiles || {};
|
||||
const components = Array.isArray(componentsData.components) ? componentsData.components : [];
|
||||
const expectedProfileIds = ['core', 'developer', 'security', 'research', 'full'];
|
||||
|
||||
for (const profileId of expectedProfileIds) {
|
||||
if (!profiles[profileId]) {
|
||||
console.error(`ERROR: Missing required install profile: ${profileId}`);
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [profileId, profile] of Object.entries(profiles)) {
|
||||
const seenModules = new Set();
|
||||
for (const moduleId of profile.modules) {
|
||||
if (!moduleIds.has(moduleId)) {
|
||||
console.error(
|
||||
`ERROR: Profile ${profileId} references unknown module ${moduleId}`
|
||||
);
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
if (seenModules.has(moduleId)) {
|
||||
console.error(
|
||||
`ERROR: Profile ${profileId} contains duplicate module ${moduleId}`
|
||||
);
|
||||
hasErrors = true;
|
||||
}
|
||||
seenModules.add(moduleId);
|
||||
}
|
||||
}
|
||||
|
||||
if (profiles.full) {
|
||||
const fullModules = new Set(profiles.full.modules);
|
||||
for (const moduleId of moduleIds) {
|
||||
if (!fullModules.has(moduleId)) {
|
||||
console.error(`ERROR: full profile is missing module ${moduleId}`);
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const componentIds = new Set();
|
||||
for (const component of components) {
|
||||
if (componentIds.has(component.id)) {
|
||||
console.error(`ERROR: Duplicate install component id: ${component.id}`);
|
||||
hasErrors = true;
|
||||
}
|
||||
componentIds.add(component.id);
|
||||
|
||||
const expectedPrefix = COMPONENT_FAMILY_PREFIXES[component.family];
|
||||
if (expectedPrefix && !component.id.startsWith(expectedPrefix)) {
|
||||
console.error(
|
||||
`ERROR: Component ${component.id} does not match expected ${component.family} prefix ${expectedPrefix}`
|
||||
);
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
const seenModules = new Set();
|
||||
for (const moduleId of component.modules) {
|
||||
if (!moduleIds.has(moduleId)) {
|
||||
console.error(`ERROR: Component ${component.id} references unknown module ${moduleId}`);
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
if (seenModules.has(moduleId)) {
|
||||
console.error(`ERROR: Component ${component.id} contains duplicate module ${moduleId}`);
|
||||
hasErrors = true;
|
||||
}
|
||||
seenModules.add(moduleId);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasErrors) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Validated ${modules.length} install modules, ${components.length} install components, and ${Object.keys(profiles).length} profiles`
|
||||
);
|
||||
}
|
||||
|
||||
validateInstallManifests();
|
||||
110
scripts/doctor.js
Normal file
110
scripts/doctor.js
Normal file
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { buildDoctorReport } = require('./lib/install-lifecycle');
|
||||
const { SUPPORTED_INSTALL_TARGETS } = require('./lib/install-manifests');
|
||||
|
||||
function showHelp(exitCode = 0) {
|
||||
console.log(`
|
||||
Usage: node scripts/doctor.js [--target <${SUPPORTED_INSTALL_TARGETS.join('|')}>] [--json]
|
||||
|
||||
Diagnose drift and missing managed files for ECC install-state in the current context.
|
||||
`);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = argv.slice(2);
|
||||
const parsed = {
|
||||
targets: [],
|
||||
json: false,
|
||||
help: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
|
||||
if (arg === '--target') {
|
||||
parsed.targets.push(args[index + 1] || null);
|
||||
index += 1;
|
||||
} else if (arg === '--json') {
|
||||
parsed.json = true;
|
||||
} else if (arg === '--help' || arg === '-h') {
|
||||
parsed.help = true;
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function statusLabel(status) {
|
||||
if (status === 'ok') {
|
||||
return 'OK';
|
||||
}
|
||||
|
||||
if (status === 'warning') {
|
||||
return 'WARNING';
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return 'ERROR';
|
||||
}
|
||||
|
||||
return status.toUpperCase();
|
||||
}
|
||||
|
||||
function printHuman(report) {
|
||||
if (report.results.length === 0) {
|
||||
console.log('No ECC install-state files found for the current home/project context.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Doctor report:\n');
|
||||
for (const result of report.results) {
|
||||
console.log(`- ${result.adapter.id}`);
|
||||
console.log(` Status: ${statusLabel(result.status)}`);
|
||||
console.log(` Install-state: ${result.installStatePath}`);
|
||||
|
||||
if (result.issues.length === 0) {
|
||||
console.log(' Issues: none');
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const issue of result.issues) {
|
||||
console.log(` - [${issue.severity}] ${issue.code}: ${issue.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nSummary: checked=${report.summary.checkedCount}, ok=${report.summary.okCount}, warnings=${report.summary.warningCount}, errors=${report.summary.errorCount}`);
|
||||
}
|
||||
|
||||
function main() {
|
||||
try {
|
||||
const options = parseArgs(process.argv);
|
||||
if (options.help) {
|
||||
showHelp(0);
|
||||
}
|
||||
|
||||
const report = buildDoctorReport({
|
||||
repoRoot: require('path').join(__dirname, '..'),
|
||||
homeDir: process.env.HOME,
|
||||
projectRoot: process.cwd(),
|
||||
targets: options.targets,
|
||||
});
|
||||
const hasIssues = report.summary.errorCount > 0 || report.summary.warningCount > 0;
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
} else {
|
||||
printHuman(report);
|
||||
}
|
||||
|
||||
process.exitCode = hasIssues ? 1 : 0;
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
194
scripts/ecc.js
Normal file
194
scripts/ecc.js
Normal file
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { spawnSync } = require('child_process');
|
||||
const path = require('path');
|
||||
const { listAvailableLanguages } = require('./lib/install-executor');
|
||||
|
||||
const COMMANDS = {
|
||||
install: {
|
||||
script: 'install-apply.js',
|
||||
description: 'Install ECC content into a supported target',
|
||||
},
|
||||
plan: {
|
||||
script: 'install-plan.js',
|
||||
description: 'Inspect selective-install manifests and resolved plans',
|
||||
},
|
||||
'install-plan': {
|
||||
script: 'install-plan.js',
|
||||
description: 'Alias for plan',
|
||||
},
|
||||
'list-installed': {
|
||||
script: 'list-installed.js',
|
||||
description: 'Inspect install-state files for the current context',
|
||||
},
|
||||
doctor: {
|
||||
script: 'doctor.js',
|
||||
description: 'Diagnose missing or drifted ECC-managed files',
|
||||
},
|
||||
repair: {
|
||||
script: 'repair.js',
|
||||
description: 'Restore drifted or missing ECC-managed files',
|
||||
},
|
||||
'session-inspect': {
|
||||
script: 'session-inspect.js',
|
||||
description: 'Emit canonical ECC session snapshots from dmux or Claude history targets',
|
||||
},
|
||||
uninstall: {
|
||||
script: 'uninstall.js',
|
||||
description: 'Remove ECC-managed files recorded in install-state',
|
||||
},
|
||||
};
|
||||
|
||||
const PRIMARY_COMMANDS = [
|
||||
'install',
|
||||
'plan',
|
||||
'list-installed',
|
||||
'doctor',
|
||||
'repair',
|
||||
'session-inspect',
|
||||
'uninstall',
|
||||
];
|
||||
|
||||
function showHelp(exitCode = 0) {
|
||||
console.log(`
|
||||
ECC selective-install CLI
|
||||
|
||||
Usage:
|
||||
ecc <command> [args...]
|
||||
ecc [install args...]
|
||||
|
||||
Commands:
|
||||
${PRIMARY_COMMANDS.map(command => ` ${command.padEnd(15)} ${COMMANDS[command].description}`).join('\n')}
|
||||
|
||||
Compatibility:
|
||||
ecc-install Legacy install entrypoint retained for existing flows
|
||||
ecc [args...] Without a command, args are routed to "install"
|
||||
ecc help <command> Show help for a specific command
|
||||
|
||||
Examples:
|
||||
ecc typescript
|
||||
ecc install --profile developer --target claude
|
||||
ecc plan --profile core --target cursor
|
||||
ecc list-installed --json
|
||||
ecc doctor --target cursor
|
||||
ecc repair --dry-run
|
||||
ecc session-inspect claude:latest
|
||||
ecc uninstall --target antigravity --dry-run
|
||||
`);
|
||||
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
function resolveCommand(argv) {
|
||||
const args = argv.slice(2);
|
||||
|
||||
if (args.length === 0) {
|
||||
return { mode: 'help' };
|
||||
}
|
||||
|
||||
const [firstArg, ...restArgs] = args;
|
||||
|
||||
if (firstArg === '--help' || firstArg === '-h') {
|
||||
return { mode: 'help' };
|
||||
}
|
||||
|
||||
if (firstArg === 'help') {
|
||||
return {
|
||||
mode: 'help-command',
|
||||
command: restArgs[0] || null,
|
||||
};
|
||||
}
|
||||
|
||||
if (COMMANDS[firstArg]) {
|
||||
return {
|
||||
mode: 'command',
|
||||
command: firstArg,
|
||||
args: restArgs,
|
||||
};
|
||||
}
|
||||
|
||||
const knownLegacyLanguages = listAvailableLanguages();
|
||||
const shouldTreatAsImplicitInstall = (
|
||||
firstArg.startsWith('-')
|
||||
|| knownLegacyLanguages.includes(firstArg)
|
||||
);
|
||||
|
||||
if (!shouldTreatAsImplicitInstall) {
|
||||
throw new Error(`Unknown command: ${firstArg}`);
|
||||
}
|
||||
|
||||
return {
|
||||
mode: 'command',
|
||||
command: 'install',
|
||||
args,
|
||||
};
|
||||
}
|
||||
|
||||
function runCommand(commandName, args) {
|
||||
const command = COMMANDS[commandName];
|
||||
if (!command) {
|
||||
throw new Error(`Unknown command: ${commandName}`);
|
||||
}
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[path.join(__dirname, command.script), ...args],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
encoding: 'utf8',
|
||||
}
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
if (result.stdout) {
|
||||
process.stdout.write(result.stdout);
|
||||
}
|
||||
|
||||
if (result.stderr) {
|
||||
process.stderr.write(result.stderr);
|
||||
}
|
||||
|
||||
if (typeof result.status === 'number') {
|
||||
return result.status;
|
||||
}
|
||||
|
||||
if (result.signal) {
|
||||
throw new Error(`Command "${commandName}" terminated by signal ${result.signal}`);
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
function main() {
|
||||
try {
|
||||
const resolution = resolveCommand(process.argv);
|
||||
|
||||
if (resolution.mode === 'help') {
|
||||
showHelp(0);
|
||||
}
|
||||
|
||||
if (resolution.mode === 'help-command') {
|
||||
if (!resolution.command) {
|
||||
showHelp(0);
|
||||
}
|
||||
|
||||
if (!COMMANDS[resolution.command]) {
|
||||
throw new Error(`Unknown command: ${resolution.command}`);
|
||||
}
|
||||
|
||||
process.exitCode = runCommand(resolution.command, ['--help']);
|
||||
return;
|
||||
}
|
||||
|
||||
process.exitCode = runCommand(resolution.command, resolution.args);
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
137
scripts/install-apply.js
Normal file
137
scripts/install-apply.js
Normal file
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Refactored ECC installer runtime.
|
||||
*
|
||||
* Keeps the legacy language-based install entrypoint intact while moving
|
||||
* target-specific mutation logic into testable Node code.
|
||||
*/
|
||||
|
||||
const {
|
||||
SUPPORTED_INSTALL_TARGETS,
|
||||
listAvailableLanguages,
|
||||
} = require('./lib/install-executor');
|
||||
const {
|
||||
LEGACY_INSTALL_TARGETS,
|
||||
normalizeInstallRequest,
|
||||
parseInstallArgs,
|
||||
} = require('./lib/install/request');
|
||||
const { loadInstallConfig } = require('./lib/install/config');
|
||||
const { applyInstallPlan } = require('./lib/install/apply');
|
||||
const { createInstallPlanFromRequest } = require('./lib/install/runtime');
|
||||
|
||||
function showHelp(exitCode = 0) {
|
||||
const languages = listAvailableLanguages();
|
||||
|
||||
console.log(`
|
||||
Usage: install.sh [--target <${LEGACY_INSTALL_TARGETS.join('|')}>] [--dry-run] [--json] <language> [<language> ...]
|
||||
install.sh [--target <${SUPPORTED_INSTALL_TARGETS.join('|')}>] [--dry-run] [--json] --profile <name> [--with <component>]... [--without <component>]...
|
||||
install.sh [--target <${SUPPORTED_INSTALL_TARGETS.join('|')}>] [--dry-run] [--json] --modules <id,id,...> [--with <component>]... [--without <component>]...
|
||||
install.sh [--dry-run] [--json] --config <path>
|
||||
|
||||
Targets:
|
||||
claude (default) - Install rules to ~/.claude/rules/
|
||||
cursor - Install rules, hooks, and bundled Cursor configs to ./.cursor/
|
||||
antigravity - Install rules, workflows, skills, and agents to ./.agent/
|
||||
|
||||
Options:
|
||||
--profile <name> Resolve and install a manifest profile
|
||||
--modules <ids> Resolve and install explicit module IDs
|
||||
--with <component> Include a user-facing install component
|
||||
--without <component>
|
||||
Exclude a user-facing install component
|
||||
--config <path> Load install intent from ecc-install.json
|
||||
--dry-run Show the install plan without copying files
|
||||
--json Emit machine-readable plan/result JSON
|
||||
--help Show this help text
|
||||
|
||||
Available languages:
|
||||
${languages.map(language => ` - ${language}`).join('\n')}
|
||||
`);
|
||||
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
function printHumanPlan(plan, dryRun) {
|
||||
console.log(`${dryRun ? 'Dry-run install plan' : 'Applying install plan'}:\n`);
|
||||
console.log(`Mode: ${plan.mode}`);
|
||||
console.log(`Target: ${plan.target}`);
|
||||
console.log(`Adapter: ${plan.adapter.id}`);
|
||||
console.log(`Install root: ${plan.installRoot}`);
|
||||
console.log(`Install-state: ${plan.installStatePath}`);
|
||||
if (plan.mode === 'legacy') {
|
||||
console.log(`Languages: ${plan.languages.join(', ')}`);
|
||||
} else {
|
||||
console.log(`Profile: ${plan.profileId || '(custom modules)'}`);
|
||||
console.log(`Included components: ${plan.includedComponentIds.join(', ') || '(none)'}`);
|
||||
console.log(`Excluded components: ${plan.excludedComponentIds.join(', ') || '(none)'}`);
|
||||
console.log(`Requested modules: ${plan.requestedModuleIds.join(', ') || '(none)'}`);
|
||||
console.log(`Selected modules: ${plan.selectedModuleIds.join(', ') || '(none)'}`);
|
||||
if (plan.skippedModuleIds.length > 0) {
|
||||
console.log(`Skipped modules: ${plan.skippedModuleIds.join(', ')}`);
|
||||
}
|
||||
if (plan.excludedModuleIds.length > 0) {
|
||||
console.log(`Excluded modules: ${plan.excludedModuleIds.join(', ')}`);
|
||||
}
|
||||
}
|
||||
console.log(`Operations: ${plan.operations.length}`);
|
||||
|
||||
if (plan.warnings.length > 0) {
|
||||
console.log('\nWarnings:');
|
||||
for (const warning of plan.warnings) {
|
||||
console.log(`- ${warning}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nPlanned file operations:');
|
||||
for (const operation of plan.operations) {
|
||||
console.log(`- ${operation.sourceRelativePath} -> ${operation.destinationPath}`);
|
||||
}
|
||||
|
||||
if (!dryRun) {
|
||||
console.log(`\nDone. Install-state written to ${plan.installStatePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
try {
|
||||
const options = parseInstallArgs(process.argv);
|
||||
|
||||
if (options.help) {
|
||||
showHelp(0);
|
||||
}
|
||||
|
||||
const config = options.configPath
|
||||
? loadInstallConfig(options.configPath, { cwd: process.cwd() })
|
||||
: null;
|
||||
const request = normalizeInstallRequest({
|
||||
...options,
|
||||
config,
|
||||
});
|
||||
const plan = createInstallPlanFromRequest(request, {
|
||||
projectRoot: process.cwd(),
|
||||
homeDir: process.env.HOME,
|
||||
claudeRulesDir: process.env.CLAUDE_RULES_DIR || null,
|
||||
});
|
||||
|
||||
if (options.dryRun) {
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ dryRun: true, plan }, null, 2));
|
||||
} else {
|
||||
printHumanPlan(plan, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const result = applyInstallPlan(plan);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ dryRun: false, result }, null, 2));
|
||||
} else {
|
||||
printHumanPlan(result, false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
254
scripts/install-plan.js
Normal file
254
scripts/install-plan.js
Normal file
@@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Inspect selective-install profiles and module plans without mutating targets.
|
||||
*/
|
||||
|
||||
const {
|
||||
listInstallComponents,
|
||||
listInstallModules,
|
||||
listInstallProfiles,
|
||||
resolveInstallPlan,
|
||||
} = require('./lib/install-manifests');
|
||||
const { loadInstallConfig } = require('./lib/install/config');
|
||||
const { normalizeInstallRequest } = require('./lib/install/request');
|
||||
|
||||
function showHelp() {
|
||||
console.log(`
|
||||
Inspect ECC selective-install manifests
|
||||
|
||||
Usage:
|
||||
node scripts/install-plan.js --list-profiles
|
||||
node scripts/install-plan.js --list-modules
|
||||
node scripts/install-plan.js --list-components [--family <family>] [--target <target>] [--json]
|
||||
node scripts/install-plan.js --profile <name> [--with <component>]... [--without <component>]... [--target <target>] [--json]
|
||||
node scripts/install-plan.js --modules <id,id,...> [--with <component>]... [--without <component>]... [--target <target>] [--json]
|
||||
node scripts/install-plan.js --config <path> [--json]
|
||||
|
||||
Options:
|
||||
--list-profiles List available install profiles
|
||||
--list-modules List install modules
|
||||
--list-components List user-facing install components
|
||||
--family <family> Filter listed components by family
|
||||
--profile <name> Resolve an install profile
|
||||
--modules <ids> Resolve explicit module IDs (comma-separated)
|
||||
--with <component> Include a user-facing install component
|
||||
--without <component>
|
||||
Exclude a user-facing install component
|
||||
--config <path> Load install intent from ecc-install.json
|
||||
--target <target> Filter plan for a specific target
|
||||
--json Emit machine-readable JSON
|
||||
--help Show this help text
|
||||
`);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = argv.slice(2);
|
||||
const parsed = {
|
||||
json: false,
|
||||
help: false,
|
||||
profileId: null,
|
||||
moduleIds: [],
|
||||
includeComponentIds: [],
|
||||
excludeComponentIds: [],
|
||||
configPath: null,
|
||||
target: null,
|
||||
family: null,
|
||||
listProfiles: false,
|
||||
listModules: false,
|
||||
listComponents: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
parsed.help = true;
|
||||
} else if (arg === '--json') {
|
||||
parsed.json = true;
|
||||
} else if (arg === '--list-profiles') {
|
||||
parsed.listProfiles = true;
|
||||
} else if (arg === '--list-modules') {
|
||||
parsed.listModules = true;
|
||||
} else if (arg === '--list-components') {
|
||||
parsed.listComponents = true;
|
||||
} else if (arg === '--family') {
|
||||
parsed.family = args[index + 1] || null;
|
||||
index += 1;
|
||||
} else if (arg === '--profile') {
|
||||
parsed.profileId = args[index + 1] || null;
|
||||
index += 1;
|
||||
} else if (arg === '--modules') {
|
||||
const raw = args[index + 1] || '';
|
||||
parsed.moduleIds = raw.split(',').map(value => value.trim()).filter(Boolean);
|
||||
index += 1;
|
||||
} else if (arg === '--with') {
|
||||
const componentId = args[index + 1] || '';
|
||||
if (componentId.trim()) {
|
||||
parsed.includeComponentIds.push(componentId.trim());
|
||||
}
|
||||
index += 1;
|
||||
} else if (arg === '--without') {
|
||||
const componentId = args[index + 1] || '';
|
||||
if (componentId.trim()) {
|
||||
parsed.excludeComponentIds.push(componentId.trim());
|
||||
}
|
||||
index += 1;
|
||||
} else if (arg === '--config') {
|
||||
parsed.configPath = args[index + 1] || null;
|
||||
index += 1;
|
||||
} else if (arg === '--target') {
|
||||
parsed.target = args[index + 1] || null;
|
||||
index += 1;
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function printProfiles(profiles) {
|
||||
console.log('Install profiles:\n');
|
||||
for (const profile of profiles) {
|
||||
console.log(`- ${profile.id} (${profile.moduleCount} modules)`);
|
||||
console.log(` ${profile.description}`);
|
||||
}
|
||||
}
|
||||
|
||||
function printModules(modules) {
|
||||
console.log('Install modules:\n');
|
||||
for (const module of modules) {
|
||||
console.log(`- ${module.id} [${module.kind}]`);
|
||||
console.log(
|
||||
` targets=${module.targets.join(', ')} default=${module.defaultInstall} cost=${module.cost} stability=${module.stability}`
|
||||
);
|
||||
console.log(` ${module.description}`);
|
||||
}
|
||||
}
|
||||
|
||||
function printComponents(components) {
|
||||
console.log('Install components:\n');
|
||||
for (const component of components) {
|
||||
console.log(`- ${component.id} [${component.family}]`);
|
||||
console.log(` targets=${component.targets.join(', ')} modules=${component.moduleIds.join(', ')}`);
|
||||
console.log(` ${component.description}`);
|
||||
}
|
||||
}
|
||||
|
||||
function printPlan(plan) {
|
||||
console.log('Install plan:\n');
|
||||
console.log(
|
||||
'Note: target filtering and operation output currently reflect scaffold-level adapter planning, not a byte-for-byte mirror of legacy install.sh copy paths.\n'
|
||||
);
|
||||
console.log(`Profile: ${plan.profileId || '(custom modules)'}`);
|
||||
console.log(`Target: ${plan.target || '(all targets)'}`);
|
||||
console.log(`Included components: ${plan.includedComponentIds.join(', ') || '(none)'}`);
|
||||
console.log(`Excluded components: ${plan.excludedComponentIds.join(', ') || '(none)'}`);
|
||||
console.log(`Requested: ${plan.requestedModuleIds.join(', ')}`);
|
||||
if (plan.targetAdapterId) {
|
||||
console.log(`Adapter: ${plan.targetAdapterId}`);
|
||||
console.log(`Target root: ${plan.targetRoot}`);
|
||||
console.log(`Install-state: ${plan.installStatePath}`);
|
||||
}
|
||||
console.log('');
|
||||
console.log(`Selected modules (${plan.selectedModuleIds.length}):`);
|
||||
for (const module of plan.selectedModules) {
|
||||
console.log(`- ${module.id} [${module.kind}]`);
|
||||
}
|
||||
|
||||
if (plan.skippedModuleIds.length > 0) {
|
||||
console.log('');
|
||||
console.log(`Skipped for target ${plan.target} (${plan.skippedModuleIds.length}):`);
|
||||
for (const module of plan.skippedModules) {
|
||||
console.log(`- ${module.id} [${module.kind}]`);
|
||||
}
|
||||
}
|
||||
|
||||
if (plan.excludedModuleIds.length > 0) {
|
||||
console.log('');
|
||||
console.log(`Excluded by selection (${plan.excludedModuleIds.length}):`);
|
||||
for (const module of plan.excludedModules) {
|
||||
console.log(`- ${module.id} [${module.kind}]`);
|
||||
}
|
||||
}
|
||||
|
||||
if (plan.operations.length > 0) {
|
||||
console.log('');
|
||||
console.log(`Operation plan (${plan.operations.length}):`);
|
||||
for (const operation of plan.operations) {
|
||||
console.log(
|
||||
`- ${operation.moduleId}: ${operation.sourceRelativePath} -> ${operation.destinationPath} [${operation.strategy}]`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
try {
|
||||
const options = parseArgs(process.argv);
|
||||
|
||||
if (options.help || process.argv.length <= 2) {
|
||||
showHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (options.listProfiles) {
|
||||
const profiles = listInstallProfiles();
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ profiles }, null, 2));
|
||||
} else {
|
||||
printProfiles(profiles);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.listModules) {
|
||||
const modules = listInstallModules();
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ modules }, null, 2));
|
||||
} else {
|
||||
printModules(modules);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.listComponents) {
|
||||
const components = listInstallComponents({
|
||||
family: options.family,
|
||||
target: options.target,
|
||||
});
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ components }, null, 2));
|
||||
} else {
|
||||
printComponents(components);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const config = options.configPath
|
||||
? loadInstallConfig(options.configPath, { cwd: process.cwd() })
|
||||
: null;
|
||||
const request = normalizeInstallRequest({
|
||||
...options,
|
||||
languages: [],
|
||||
config,
|
||||
});
|
||||
const plan = resolveInstallPlan({
|
||||
profileId: request.profileId,
|
||||
moduleIds: request.moduleIds,
|
||||
includeComponentIds: request.includeComponentIds,
|
||||
excludeComponentIds: request.excludeComponentIds,
|
||||
target: request.target,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(plan, null, 2));
|
||||
} else {
|
||||
printPlan(plan);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
605
scripts/lib/install-executor.js
Normal file
605
scripts/lib/install-executor.js
Normal file
@@ -0,0 +1,605 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const { applyInstallPlan } = require('./install/apply');
|
||||
const { LEGACY_INSTALL_TARGETS, parseInstallArgs } = require('./install/request');
|
||||
const {
|
||||
SUPPORTED_INSTALL_TARGETS,
|
||||
resolveInstallPlan,
|
||||
} = require('./install-manifests');
|
||||
const { getInstallTargetAdapter } = require('./install-targets/registry');
|
||||
const { createInstallState } = require('./install-state');
|
||||
|
||||
const LANGUAGE_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
||||
const EXCLUDED_GENERATED_SOURCE_SUFFIXES = [
|
||||
'/ecc-install-state.json',
|
||||
'/ecc/install-state.json',
|
||||
];
|
||||
|
||||
function getSourceRoot() {
|
||||
return path.join(__dirname, '../..');
|
||||
}
|
||||
|
||||
function getPackageVersion(sourceRoot) {
|
||||
try {
|
||||
const packageJson = JSON.parse(
|
||||
fs.readFileSync(path.join(sourceRoot, 'package.json'), 'utf8')
|
||||
);
|
||||
return packageJson.version || null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getManifestVersion(sourceRoot) {
|
||||
try {
|
||||
const modulesManifest = JSON.parse(
|
||||
fs.readFileSync(path.join(sourceRoot, 'manifests', 'install-modules.json'), 'utf8')
|
||||
);
|
||||
return modulesManifest.version || 1;
|
||||
} catch (_error) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
function getRepoCommit(sourceRoot) {
|
||||
try {
|
||||
return execFileSync('git', ['rev-parse', 'HEAD'], {
|
||||
cwd: sourceRoot,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
timeout: 5000,
|
||||
}).trim();
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readDirectoryNames(dirPath) {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs.readdirSync(dirPath, { withFileTypes: true })
|
||||
.filter(entry => entry.isDirectory())
|
||||
.map(entry => entry.name)
|
||||
.sort();
|
||||
}
|
||||
|
||||
function listAvailableLanguages(sourceRoot = getSourceRoot()) {
|
||||
return readDirectoryNames(path.join(sourceRoot, 'rules'))
|
||||
.filter(name => name !== 'common');
|
||||
}
|
||||
|
||||
function validateLegacyTarget(target) {
|
||||
if (!LEGACY_INSTALL_TARGETS.includes(target)) {
|
||||
throw new Error(
|
||||
`Unknown install target: ${target}. Expected one of ${LEGACY_INSTALL_TARGETS.join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function listFilesRecursive(dirPath) {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = [];
|
||||
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const absolutePath = path.join(dirPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const childFiles = listFilesRecursive(absolutePath);
|
||||
for (const childFile of childFiles) {
|
||||
files.push(path.join(entry.name, childFile));
|
||||
}
|
||||
} else if (entry.isFile()) {
|
||||
files.push(entry.name);
|
||||
}
|
||||
}
|
||||
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
function isGeneratedRuntimeSourcePath(sourceRelativePath) {
|
||||
const normalizedPath = String(sourceRelativePath || '').replace(/\\/g, '/');
|
||||
return EXCLUDED_GENERATED_SOURCE_SUFFIXES.some(suffix => normalizedPath.endsWith(suffix));
|
||||
}
|
||||
|
||||
function buildCopyFileOperation({ moduleId, sourcePath, sourceRelativePath, destinationPath, strategy }) {
|
||||
return {
|
||||
kind: 'copy-file',
|
||||
moduleId,
|
||||
sourcePath,
|
||||
sourceRelativePath,
|
||||
destinationPath,
|
||||
strategy,
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
};
|
||||
}
|
||||
|
||||
function addRecursiveCopyOperations(operations, options) {
|
||||
const sourceDir = path.join(options.sourceRoot, options.sourceRelativeDir);
|
||||
if (!fs.existsSync(sourceDir)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const relativeFiles = listFilesRecursive(sourceDir);
|
||||
|
||||
for (const relativeFile of relativeFiles) {
|
||||
const sourceRelativePath = path.join(options.sourceRelativeDir, relativeFile);
|
||||
const sourcePath = path.join(options.sourceRoot, sourceRelativePath);
|
||||
const destinationPath = path.join(options.destinationDir, relativeFile);
|
||||
operations.push(buildCopyFileOperation({
|
||||
moduleId: options.moduleId,
|
||||
sourcePath,
|
||||
sourceRelativePath,
|
||||
destinationPath,
|
||||
strategy: options.strategy || 'preserve-relative-path',
|
||||
}));
|
||||
}
|
||||
|
||||
return relativeFiles.length;
|
||||
}
|
||||
|
||||
function addFileCopyOperation(operations, options) {
|
||||
const sourcePath = path.join(options.sourceRoot, options.sourceRelativePath);
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
operations.push(buildCopyFileOperation({
|
||||
moduleId: options.moduleId,
|
||||
sourcePath,
|
||||
sourceRelativePath: options.sourceRelativePath,
|
||||
destinationPath: options.destinationPath,
|
||||
strategy: options.strategy || 'preserve-relative-path',
|
||||
}));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function addMatchingRuleOperations(operations, options) {
|
||||
const sourceDir = path.join(options.sourceRoot, options.sourceRelativeDir);
|
||||
if (!fs.existsSync(sourceDir)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(sourceDir, { withFileTypes: true })
|
||||
.filter(entry => entry.isFile() && options.matcher(entry.name))
|
||||
.map(entry => entry.name)
|
||||
.sort();
|
||||
|
||||
for (const fileName of files) {
|
||||
const sourceRelativePath = path.join(options.sourceRelativeDir, fileName);
|
||||
const sourcePath = path.join(options.sourceRoot, sourceRelativePath);
|
||||
const destinationPath = path.join(
|
||||
options.destinationDir,
|
||||
options.rename ? options.rename(fileName) : fileName
|
||||
);
|
||||
|
||||
operations.push(buildCopyFileOperation({
|
||||
moduleId: options.moduleId,
|
||||
sourcePath,
|
||||
sourceRelativePath,
|
||||
destinationPath,
|
||||
strategy: options.strategy || 'flatten-copy',
|
||||
}));
|
||||
}
|
||||
|
||||
return files.length;
|
||||
}
|
||||
|
||||
function isDirectoryNonEmpty(dirPath) {
|
||||
return fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory() && fs.readdirSync(dirPath).length > 0;
|
||||
}
|
||||
|
||||
function planClaudeLegacyInstall(context) {
|
||||
const adapter = getInstallTargetAdapter('claude');
|
||||
const targetRoot = adapter.resolveRoot({ homeDir: context.homeDir });
|
||||
const rulesDir = context.claudeRulesDir || path.join(targetRoot, 'rules');
|
||||
const installStatePath = adapter.getInstallStatePath({ homeDir: context.homeDir });
|
||||
const operations = [];
|
||||
const warnings = [];
|
||||
|
||||
if (isDirectoryNonEmpty(rulesDir)) {
|
||||
warnings.push(
|
||||
`Destination ${rulesDir}/ already exists and files may be overwritten`
|
||||
);
|
||||
}
|
||||
|
||||
addRecursiveCopyOperations(operations, {
|
||||
moduleId: 'legacy-claude-rules',
|
||||
sourceRoot: context.sourceRoot,
|
||||
sourceRelativeDir: path.join('rules', 'common'),
|
||||
destinationDir: path.join(rulesDir, 'common'),
|
||||
});
|
||||
|
||||
for (const language of context.languages) {
|
||||
if (!LANGUAGE_NAME_PATTERN.test(language)) {
|
||||
warnings.push(
|
||||
`Invalid language name '${language}'. Only alphanumeric, dash, and underscore are allowed`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceDir = path.join(context.sourceRoot, 'rules', language);
|
||||
if (!fs.existsSync(sourceDir)) {
|
||||
warnings.push(`rules/${language}/ does not exist, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
addRecursiveCopyOperations(operations, {
|
||||
moduleId: 'legacy-claude-rules',
|
||||
sourceRoot: context.sourceRoot,
|
||||
sourceRelativeDir: path.join('rules', language),
|
||||
destinationDir: path.join(rulesDir, language),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
mode: 'legacy',
|
||||
adapter,
|
||||
target: 'claude',
|
||||
targetRoot,
|
||||
installRoot: rulesDir,
|
||||
installStatePath,
|
||||
operations,
|
||||
warnings,
|
||||
selectedModules: ['legacy-claude-rules'],
|
||||
};
|
||||
}
|
||||
|
||||
function planCursorLegacyInstall(context) {
|
||||
const adapter = getInstallTargetAdapter('cursor');
|
||||
const targetRoot = adapter.resolveRoot({ repoRoot: context.projectRoot });
|
||||
const installStatePath = adapter.getInstallStatePath({ repoRoot: context.projectRoot });
|
||||
const operations = [];
|
||||
const warnings = [];
|
||||
|
||||
addMatchingRuleOperations(operations, {
|
||||
moduleId: 'legacy-cursor-install',
|
||||
sourceRoot: context.sourceRoot,
|
||||
sourceRelativeDir: path.join('.cursor', 'rules'),
|
||||
destinationDir: path.join(targetRoot, 'rules'),
|
||||
matcher: fileName => /^common-.*\.md$/.test(fileName),
|
||||
});
|
||||
|
||||
for (const language of context.languages) {
|
||||
if (!LANGUAGE_NAME_PATTERN.test(language)) {
|
||||
warnings.push(
|
||||
`Invalid language name '${language}'. Only alphanumeric, dash, and underscore are allowed`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const matches = addMatchingRuleOperations(operations, {
|
||||
moduleId: 'legacy-cursor-install',
|
||||
sourceRoot: context.sourceRoot,
|
||||
sourceRelativeDir: path.join('.cursor', 'rules'),
|
||||
destinationDir: path.join(targetRoot, 'rules'),
|
||||
matcher: fileName => fileName.startsWith(`${language}-`) && fileName.endsWith('.md'),
|
||||
});
|
||||
|
||||
if (matches === 0) {
|
||||
warnings.push(`No Cursor rules for '${language}' found, skipping`);
|
||||
}
|
||||
}
|
||||
|
||||
addRecursiveCopyOperations(operations, {
|
||||
moduleId: 'legacy-cursor-install',
|
||||
sourceRoot: context.sourceRoot,
|
||||
sourceRelativeDir: path.join('.cursor', 'agents'),
|
||||
destinationDir: path.join(targetRoot, 'agents'),
|
||||
});
|
||||
addRecursiveCopyOperations(operations, {
|
||||
moduleId: 'legacy-cursor-install',
|
||||
sourceRoot: context.sourceRoot,
|
||||
sourceRelativeDir: path.join('.cursor', 'skills'),
|
||||
destinationDir: path.join(targetRoot, 'skills'),
|
||||
});
|
||||
addRecursiveCopyOperations(operations, {
|
||||
moduleId: 'legacy-cursor-install',
|
||||
sourceRoot: context.sourceRoot,
|
||||
sourceRelativeDir: path.join('.cursor', 'commands'),
|
||||
destinationDir: path.join(targetRoot, 'commands'),
|
||||
});
|
||||
addRecursiveCopyOperations(operations, {
|
||||
moduleId: 'legacy-cursor-install',
|
||||
sourceRoot: context.sourceRoot,
|
||||
sourceRelativeDir: path.join('.cursor', 'hooks'),
|
||||
destinationDir: path.join(targetRoot, 'hooks'),
|
||||
});
|
||||
|
||||
addFileCopyOperation(operations, {
|
||||
moduleId: 'legacy-cursor-install',
|
||||
sourceRoot: context.sourceRoot,
|
||||
sourceRelativePath: path.join('.cursor', 'hooks.json'),
|
||||
destinationPath: path.join(targetRoot, 'hooks.json'),
|
||||
});
|
||||
addFileCopyOperation(operations, {
|
||||
moduleId: 'legacy-cursor-install',
|
||||
sourceRoot: context.sourceRoot,
|
||||
sourceRelativePath: path.join('.cursor', 'mcp.json'),
|
||||
destinationPath: path.join(targetRoot, 'mcp.json'),
|
||||
});
|
||||
|
||||
return {
|
||||
mode: 'legacy',
|
||||
adapter,
|
||||
target: 'cursor',
|
||||
targetRoot,
|
||||
installRoot: targetRoot,
|
||||
installStatePath,
|
||||
operations,
|
||||
warnings,
|
||||
selectedModules: ['legacy-cursor-install'],
|
||||
};
|
||||
}
|
||||
|
||||
function planAntigravityLegacyInstall(context) {
|
||||
const adapter = getInstallTargetAdapter('antigravity');
|
||||
const targetRoot = adapter.resolveRoot({ repoRoot: context.projectRoot });
|
||||
const installStatePath = adapter.getInstallStatePath({ repoRoot: context.projectRoot });
|
||||
const operations = [];
|
||||
const warnings = [];
|
||||
|
||||
if (isDirectoryNonEmpty(path.join(targetRoot, 'rules'))) {
|
||||
warnings.push(
|
||||
`Destination ${path.join(targetRoot, 'rules')}/ already exists and files may be overwritten`
|
||||
);
|
||||
}
|
||||
|
||||
addMatchingRuleOperations(operations, {
|
||||
moduleId: 'legacy-antigravity-install',
|
||||
sourceRoot: context.sourceRoot,
|
||||
sourceRelativeDir: path.join('rules', 'common'),
|
||||
destinationDir: path.join(targetRoot, 'rules'),
|
||||
matcher: fileName => fileName.endsWith('.md'),
|
||||
rename: fileName => `common-${fileName}`,
|
||||
});
|
||||
|
||||
for (const language of context.languages) {
|
||||
if (!LANGUAGE_NAME_PATTERN.test(language)) {
|
||||
warnings.push(
|
||||
`Invalid language name '${language}'. Only alphanumeric, dash, and underscore are allowed`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceDir = path.join(context.sourceRoot, 'rules', language);
|
||||
if (!fs.existsSync(sourceDir)) {
|
||||
warnings.push(`rules/${language}/ does not exist, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
addMatchingRuleOperations(operations, {
|
||||
moduleId: 'legacy-antigravity-install',
|
||||
sourceRoot: context.sourceRoot,
|
||||
sourceRelativeDir: path.join('rules', language),
|
||||
destinationDir: path.join(targetRoot, 'rules'),
|
||||
matcher: fileName => fileName.endsWith('.md'),
|
||||
rename: fileName => `${language}-${fileName}`,
|
||||
});
|
||||
}
|
||||
|
||||
addRecursiveCopyOperations(operations, {
|
||||
moduleId: 'legacy-antigravity-install',
|
||||
sourceRoot: context.sourceRoot,
|
||||
sourceRelativeDir: 'commands',
|
||||
destinationDir: path.join(targetRoot, 'workflows'),
|
||||
});
|
||||
addRecursiveCopyOperations(operations, {
|
||||
moduleId: 'legacy-antigravity-install',
|
||||
sourceRoot: context.sourceRoot,
|
||||
sourceRelativeDir: 'agents',
|
||||
destinationDir: path.join(targetRoot, 'skills'),
|
||||
});
|
||||
addRecursiveCopyOperations(operations, {
|
||||
moduleId: 'legacy-antigravity-install',
|
||||
sourceRoot: context.sourceRoot,
|
||||
sourceRelativeDir: 'skills',
|
||||
destinationDir: path.join(targetRoot, 'skills'),
|
||||
});
|
||||
|
||||
return {
|
||||
mode: 'legacy',
|
||||
adapter,
|
||||
target: 'antigravity',
|
||||
targetRoot,
|
||||
installRoot: targetRoot,
|
||||
installStatePath,
|
||||
operations,
|
||||
warnings,
|
||||
selectedModules: ['legacy-antigravity-install'],
|
||||
};
|
||||
}
|
||||
|
||||
function createLegacyInstallPlan(options = {}) {
|
||||
const sourceRoot = options.sourceRoot || getSourceRoot();
|
||||
const projectRoot = options.projectRoot || process.cwd();
|
||||
const homeDir = options.homeDir || process.env.HOME;
|
||||
const target = options.target || 'claude';
|
||||
|
||||
validateLegacyTarget(target);
|
||||
|
||||
const context = {
|
||||
sourceRoot,
|
||||
projectRoot,
|
||||
homeDir,
|
||||
languages: Array.isArray(options.languages) ? options.languages : [],
|
||||
claudeRulesDir: options.claudeRulesDir || process.env.CLAUDE_RULES_DIR || null,
|
||||
};
|
||||
|
||||
let plan;
|
||||
if (target === 'claude') {
|
||||
plan = planClaudeLegacyInstall(context);
|
||||
} else if (target === 'cursor') {
|
||||
plan = planCursorLegacyInstall(context);
|
||||
} else {
|
||||
plan = planAntigravityLegacyInstall(context);
|
||||
}
|
||||
|
||||
const source = {
|
||||
repoVersion: getPackageVersion(sourceRoot),
|
||||
repoCommit: getRepoCommit(sourceRoot),
|
||||
manifestVersion: getManifestVersion(sourceRoot),
|
||||
};
|
||||
|
||||
const statePreview = createInstallState({
|
||||
adapter: plan.adapter,
|
||||
targetRoot: plan.targetRoot,
|
||||
installStatePath: plan.installStatePath,
|
||||
request: {
|
||||
profile: null,
|
||||
modules: [],
|
||||
legacyLanguages: context.languages,
|
||||
legacyMode: true,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: plan.selectedModules,
|
||||
skippedModules: [],
|
||||
},
|
||||
operations: plan.operations,
|
||||
source,
|
||||
});
|
||||
|
||||
return {
|
||||
mode: 'legacy',
|
||||
target: plan.target,
|
||||
adapter: {
|
||||
id: plan.adapter.id,
|
||||
target: plan.adapter.target,
|
||||
kind: plan.adapter.kind,
|
||||
},
|
||||
targetRoot: plan.targetRoot,
|
||||
installRoot: plan.installRoot,
|
||||
installStatePath: plan.installStatePath,
|
||||
warnings: plan.warnings,
|
||||
languages: context.languages,
|
||||
operations: plan.operations,
|
||||
statePreview,
|
||||
};
|
||||
}
|
||||
|
||||
function materializeScaffoldOperation(sourceRoot, operation) {
|
||||
const sourcePath = path.join(sourceRoot, operation.sourceRelativePath);
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (isGeneratedRuntimeSourcePath(operation.sourceRelativePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const stat = fs.statSync(sourcePath);
|
||||
if (stat.isFile()) {
|
||||
return [buildCopyFileOperation({
|
||||
moduleId: operation.moduleId,
|
||||
sourcePath,
|
||||
sourceRelativePath: operation.sourceRelativePath,
|
||||
destinationPath: operation.destinationPath,
|
||||
strategy: operation.strategy,
|
||||
})];
|
||||
}
|
||||
|
||||
const relativeFiles = listFilesRecursive(sourcePath).filter(relativeFile => {
|
||||
const sourceRelativePath = path.join(operation.sourceRelativePath, relativeFile);
|
||||
return !isGeneratedRuntimeSourcePath(sourceRelativePath);
|
||||
});
|
||||
return relativeFiles.map(relativeFile => {
|
||||
const sourceRelativePath = path.join(operation.sourceRelativePath, relativeFile);
|
||||
return buildCopyFileOperation({
|
||||
moduleId: operation.moduleId,
|
||||
sourcePath: path.join(sourcePath, relativeFile),
|
||||
sourceRelativePath,
|
||||
destinationPath: path.join(operation.destinationPath, relativeFile),
|
||||
strategy: operation.strategy,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createManifestInstallPlan(options = {}) {
|
||||
const sourceRoot = options.sourceRoot || getSourceRoot();
|
||||
const projectRoot = options.projectRoot || process.cwd();
|
||||
const target = options.target || 'claude';
|
||||
const plan = resolveInstallPlan({
|
||||
repoRoot: sourceRoot,
|
||||
projectRoot,
|
||||
homeDir: options.homeDir,
|
||||
profileId: options.profileId || null,
|
||||
moduleIds: options.moduleIds || [],
|
||||
includeComponentIds: options.includeComponentIds || [],
|
||||
excludeComponentIds: options.excludeComponentIds || [],
|
||||
target,
|
||||
});
|
||||
const adapter = getInstallTargetAdapter(target);
|
||||
const operations = plan.operations.flatMap(operation => materializeScaffoldOperation(sourceRoot, operation));
|
||||
const source = {
|
||||
repoVersion: getPackageVersion(sourceRoot),
|
||||
repoCommit: getRepoCommit(sourceRoot),
|
||||
manifestVersion: getManifestVersion(sourceRoot),
|
||||
};
|
||||
const statePreview = createInstallState({
|
||||
adapter,
|
||||
targetRoot: plan.targetRoot,
|
||||
installStatePath: plan.installStatePath,
|
||||
request: {
|
||||
profile: plan.profileId,
|
||||
modules: Array.isArray(options.moduleIds) ? [...options.moduleIds] : [],
|
||||
includeComponents: Array.isArray(options.includeComponentIds)
|
||||
? [...options.includeComponentIds]
|
||||
: [],
|
||||
excludeComponents: Array.isArray(options.excludeComponentIds)
|
||||
? [...options.excludeComponentIds]
|
||||
: [],
|
||||
legacyLanguages: [],
|
||||
legacyMode: false,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: plan.selectedModuleIds,
|
||||
skippedModules: plan.skippedModuleIds,
|
||||
},
|
||||
operations,
|
||||
source,
|
||||
});
|
||||
|
||||
return {
|
||||
mode: 'manifest',
|
||||
target,
|
||||
adapter: {
|
||||
id: adapter.id,
|
||||
target: adapter.target,
|
||||
kind: adapter.kind,
|
||||
},
|
||||
targetRoot: plan.targetRoot,
|
||||
installRoot: plan.targetRoot,
|
||||
installStatePath: plan.installStatePath,
|
||||
warnings: [],
|
||||
languages: [],
|
||||
profileId: plan.profileId,
|
||||
requestedModuleIds: plan.requestedModuleIds,
|
||||
explicitModuleIds: plan.explicitModuleIds,
|
||||
includedComponentIds: plan.includedComponentIds,
|
||||
excludedComponentIds: plan.excludedComponentIds,
|
||||
selectedModuleIds: plan.selectedModuleIds,
|
||||
skippedModuleIds: plan.skippedModuleIds,
|
||||
excludedModuleIds: plan.excludedModuleIds,
|
||||
operations,
|
||||
statePreview,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SUPPORTED_INSTALL_TARGETS,
|
||||
LEGACY_INSTALL_TARGETS,
|
||||
applyInstallPlan,
|
||||
createManifestInstallPlan,
|
||||
createLegacyInstallPlan,
|
||||
getSourceRoot,
|
||||
listAvailableLanguages,
|
||||
parseInstallArgs,
|
||||
};
|
||||
763
scripts/lib/install-lifecycle.js
Normal file
763
scripts/lib/install-lifecycle.js
Normal file
@@ -0,0 +1,763 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { resolveInstallPlan, loadInstallManifests } = require('./install-manifests');
|
||||
const { readInstallState, writeInstallState } = require('./install-state');
|
||||
const {
|
||||
applyInstallPlan,
|
||||
createLegacyInstallPlan,
|
||||
createManifestInstallPlan,
|
||||
} = require('./install-executor');
|
||||
const {
|
||||
getInstallTargetAdapter,
|
||||
listInstallTargetAdapters,
|
||||
} = require('./install-targets/registry');
|
||||
|
||||
const DEFAULT_REPO_ROOT = path.join(__dirname, '../..');
|
||||
|
||||
function readPackageVersion(repoRoot) {
|
||||
try {
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
|
||||
return packageJson.version || null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTargets(targets) {
|
||||
if (!Array.isArray(targets) || targets.length === 0) {
|
||||
return listInstallTargetAdapters().map(adapter => adapter.target);
|
||||
}
|
||||
|
||||
const normalizedTargets = [];
|
||||
for (const target of targets) {
|
||||
const adapter = getInstallTargetAdapter(target);
|
||||
if (!normalizedTargets.includes(adapter.target)) {
|
||||
normalizedTargets.push(adapter.target);
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedTargets;
|
||||
}
|
||||
|
||||
function compareStringArrays(left, right) {
|
||||
const leftValues = Array.isArray(left) ? left : [];
|
||||
const rightValues = Array.isArray(right) ? right : [];
|
||||
|
||||
if (leftValues.length !== rightValues.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return leftValues.every((value, index) => value === rightValues[index]);
|
||||
}
|
||||
|
||||
function getManagedOperations(state) {
|
||||
return Array.isArray(state && state.operations)
|
||||
? state.operations.filter(operation => operation.ownership === 'managed')
|
||||
: [];
|
||||
}
|
||||
|
||||
function resolveOperationSourcePath(repoRoot, operation) {
|
||||
if (operation.sourceRelativePath) {
|
||||
return path.join(repoRoot, operation.sourceRelativePath);
|
||||
}
|
||||
|
||||
return operation.sourcePath || null;
|
||||
}
|
||||
|
||||
function areFilesEqual(leftPath, rightPath) {
|
||||
try {
|
||||
const leftStat = fs.statSync(leftPath);
|
||||
const rightStat = fs.statSync(rightPath);
|
||||
if (!leftStat.isFile() || !rightStat.isFile()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return fs.readFileSync(leftPath).equals(fs.readFileSync(rightPath));
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function inspectManagedOperation(repoRoot, operation) {
|
||||
const destinationPath = operation.destinationPath;
|
||||
if (!destinationPath) {
|
||||
return {
|
||||
status: 'invalid-destination',
|
||||
operation,
|
||||
};
|
||||
}
|
||||
|
||||
if (!fs.existsSync(destinationPath)) {
|
||||
return {
|
||||
status: 'missing',
|
||||
operation,
|
||||
destinationPath,
|
||||
};
|
||||
}
|
||||
|
||||
if (operation.kind !== 'copy-file') {
|
||||
return {
|
||||
status: 'unverified',
|
||||
operation,
|
||||
destinationPath,
|
||||
};
|
||||
}
|
||||
|
||||
const sourcePath = resolveOperationSourcePath(repoRoot, operation);
|
||||
if (!sourcePath || !fs.existsSync(sourcePath)) {
|
||||
return {
|
||||
status: 'missing-source',
|
||||
operation,
|
||||
destinationPath,
|
||||
sourcePath,
|
||||
};
|
||||
}
|
||||
|
||||
if (!areFilesEqual(sourcePath, destinationPath)) {
|
||||
return {
|
||||
status: 'drifted',
|
||||
operation,
|
||||
destinationPath,
|
||||
sourcePath,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
operation,
|
||||
destinationPath,
|
||||
sourcePath,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeManagedOperationHealth(repoRoot, operations) {
|
||||
return operations.reduce((summary, operation) => {
|
||||
const inspection = inspectManagedOperation(repoRoot, operation);
|
||||
if (inspection.status === 'missing') {
|
||||
summary.missing.push(inspection);
|
||||
} else if (inspection.status === 'drifted') {
|
||||
summary.drifted.push(inspection);
|
||||
} else if (inspection.status === 'missing-source') {
|
||||
summary.missingSource.push(inspection);
|
||||
} else if (inspection.status === 'unverified' || inspection.status === 'invalid-destination') {
|
||||
summary.unverified.push(inspection);
|
||||
}
|
||||
return summary;
|
||||
}, {
|
||||
missing: [],
|
||||
drifted: [],
|
||||
missingSource: [],
|
||||
unverified: [],
|
||||
});
|
||||
}
|
||||
|
||||
function buildDiscoveryRecord(adapter, context) {
|
||||
const installTargetInput = {
|
||||
homeDir: context.homeDir,
|
||||
projectRoot: context.projectRoot,
|
||||
repoRoot: context.projectRoot,
|
||||
};
|
||||
const targetRoot = adapter.resolveRoot(installTargetInput);
|
||||
const installStatePath = adapter.getInstallStatePath(installTargetInput);
|
||||
const exists = fs.existsSync(installStatePath);
|
||||
|
||||
if (!exists) {
|
||||
return {
|
||||
adapter: {
|
||||
id: adapter.id,
|
||||
target: adapter.target,
|
||||
kind: adapter.kind,
|
||||
},
|
||||
targetRoot,
|
||||
installStatePath,
|
||||
exists: false,
|
||||
state: null,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const state = readInstallState(installStatePath);
|
||||
return {
|
||||
adapter: {
|
||||
id: adapter.id,
|
||||
target: adapter.target,
|
||||
kind: adapter.kind,
|
||||
},
|
||||
targetRoot,
|
||||
installStatePath,
|
||||
exists: true,
|
||||
state,
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
adapter: {
|
||||
id: adapter.id,
|
||||
target: adapter.target,
|
||||
kind: adapter.kind,
|
||||
},
|
||||
targetRoot,
|
||||
installStatePath,
|
||||
exists: true,
|
||||
state: null,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function discoverInstalledStates(options = {}) {
|
||||
const context = {
|
||||
homeDir: options.homeDir || process.env.HOME,
|
||||
projectRoot: options.projectRoot || process.cwd(),
|
||||
};
|
||||
const targets = normalizeTargets(options.targets);
|
||||
|
||||
return targets.map(target => {
|
||||
const adapter = getInstallTargetAdapter(target);
|
||||
return buildDiscoveryRecord(adapter, context);
|
||||
});
|
||||
}
|
||||
|
||||
function buildIssue(severity, code, message, extra = {}) {
|
||||
return {
|
||||
severity,
|
||||
code,
|
||||
message,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function determineStatus(issues) {
|
||||
if (issues.some(issue => issue.severity === 'error')) {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
if (issues.some(issue => issue.severity === 'warning')) {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
function analyzeRecord(record, context) {
|
||||
const issues = [];
|
||||
|
||||
if (record.error) {
|
||||
issues.push(buildIssue('error', 'invalid-install-state', record.error));
|
||||
return {
|
||||
...record,
|
||||
status: determineStatus(issues),
|
||||
issues,
|
||||
};
|
||||
}
|
||||
|
||||
const state = record.state;
|
||||
if (!state) {
|
||||
return {
|
||||
...record,
|
||||
status: 'missing',
|
||||
issues,
|
||||
};
|
||||
}
|
||||
|
||||
if (!fs.existsSync(state.target.root)) {
|
||||
issues.push(buildIssue(
|
||||
'error',
|
||||
'missing-target-root',
|
||||
`Target root does not exist: ${state.target.root}`
|
||||
));
|
||||
}
|
||||
|
||||
if (state.target.root !== record.targetRoot) {
|
||||
issues.push(buildIssue(
|
||||
'warning',
|
||||
'target-root-mismatch',
|
||||
`Recorded target root differs from current target root (${record.targetRoot})`,
|
||||
{
|
||||
recordedTargetRoot: state.target.root,
|
||||
currentTargetRoot: record.targetRoot,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
if (state.target.installStatePath !== record.installStatePath) {
|
||||
issues.push(buildIssue(
|
||||
'warning',
|
||||
'install-state-path-mismatch',
|
||||
`Recorded install-state path differs from current path (${record.installStatePath})`,
|
||||
{
|
||||
recordedInstallStatePath: state.target.installStatePath,
|
||||
currentInstallStatePath: record.installStatePath,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
const managedOperations = getManagedOperations(state);
|
||||
const operationHealth = summarizeManagedOperationHealth(context.repoRoot, managedOperations);
|
||||
const missingManagedOperations = operationHealth.missing;
|
||||
|
||||
if (missingManagedOperations.length > 0) {
|
||||
issues.push(buildIssue(
|
||||
'error',
|
||||
'missing-managed-files',
|
||||
`${missingManagedOperations.length} managed file(s) are missing`,
|
||||
{
|
||||
paths: missingManagedOperations.map(entry => entry.destinationPath),
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
if (operationHealth.drifted.length > 0) {
|
||||
issues.push(buildIssue(
|
||||
'warning',
|
||||
'drifted-managed-files',
|
||||
`${operationHealth.drifted.length} managed file(s) differ from the source repo`,
|
||||
{
|
||||
paths: operationHealth.drifted.map(entry => entry.destinationPath),
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
if (operationHealth.missingSource.length > 0) {
|
||||
issues.push(buildIssue(
|
||||
'error',
|
||||
'missing-source-files',
|
||||
`${operationHealth.missingSource.length} source file(s) referenced by install-state are missing`,
|
||||
{
|
||||
paths: operationHealth.missingSource.map(entry => entry.sourcePath).filter(Boolean),
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
if (operationHealth.unverified.length > 0) {
|
||||
issues.push(buildIssue(
|
||||
'warning',
|
||||
'unverified-managed-operations',
|
||||
`${operationHealth.unverified.length} managed operation(s) could not be content-verified`,
|
||||
{
|
||||
paths: operationHealth.unverified.map(entry => entry.destinationPath).filter(Boolean),
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
if (state.source.manifestVersion !== context.manifestVersion) {
|
||||
issues.push(buildIssue(
|
||||
'warning',
|
||||
'manifest-version-mismatch',
|
||||
`Recorded manifest version ${state.source.manifestVersion} differs from current manifest version ${context.manifestVersion}`
|
||||
));
|
||||
}
|
||||
|
||||
if (
|
||||
context.packageVersion
|
||||
&& state.source.repoVersion
|
||||
&& state.source.repoVersion !== context.packageVersion
|
||||
) {
|
||||
issues.push(buildIssue(
|
||||
'warning',
|
||||
'repo-version-mismatch',
|
||||
`Recorded repo version ${state.source.repoVersion} differs from current repo version ${context.packageVersion}`
|
||||
));
|
||||
}
|
||||
|
||||
if (!state.request.legacyMode) {
|
||||
try {
|
||||
const desiredPlan = resolveInstallPlan({
|
||||
repoRoot: context.repoRoot,
|
||||
projectRoot: context.projectRoot,
|
||||
homeDir: context.homeDir,
|
||||
target: record.adapter.target,
|
||||
profileId: state.request.profile || null,
|
||||
moduleIds: state.request.modules || [],
|
||||
includeComponentIds: state.request.includeComponents || [],
|
||||
excludeComponentIds: state.request.excludeComponents || [],
|
||||
});
|
||||
|
||||
if (
|
||||
!compareStringArrays(desiredPlan.selectedModuleIds, state.resolution.selectedModules)
|
||||
|| !compareStringArrays(desiredPlan.skippedModuleIds, state.resolution.skippedModules)
|
||||
) {
|
||||
issues.push(buildIssue(
|
||||
'warning',
|
||||
'resolution-drift',
|
||||
'Current manifest resolution differs from recorded install-state',
|
||||
{
|
||||
expectedSelectedModules: desiredPlan.selectedModuleIds,
|
||||
recordedSelectedModules: state.resolution.selectedModules,
|
||||
expectedSkippedModules: desiredPlan.skippedModuleIds,
|
||||
recordedSkippedModules: state.resolution.skippedModules,
|
||||
}
|
||||
));
|
||||
}
|
||||
} catch (error) {
|
||||
issues.push(buildIssue(
|
||||
'error',
|
||||
'resolution-unavailable',
|
||||
error.message
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...record,
|
||||
status: determineStatus(issues),
|
||||
issues,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDoctorReport(options = {}) {
|
||||
const repoRoot = options.repoRoot || DEFAULT_REPO_ROOT;
|
||||
const manifests = loadInstallManifests({ repoRoot });
|
||||
const records = discoverInstalledStates({
|
||||
homeDir: options.homeDir,
|
||||
projectRoot: options.projectRoot,
|
||||
targets: options.targets,
|
||||
}).filter(record => record.exists);
|
||||
const context = {
|
||||
repoRoot,
|
||||
homeDir: options.homeDir || process.env.HOME,
|
||||
projectRoot: options.projectRoot || process.cwd(),
|
||||
manifestVersion: manifests.modulesVersion,
|
||||
packageVersion: readPackageVersion(repoRoot),
|
||||
};
|
||||
const results = records.map(record => analyzeRecord(record, context));
|
||||
const summary = results.reduce((accumulator, result) => {
|
||||
const errorCount = result.issues.filter(issue => issue.severity === 'error').length;
|
||||
const warningCount = result.issues.filter(issue => issue.severity === 'warning').length;
|
||||
|
||||
return {
|
||||
checkedCount: accumulator.checkedCount + 1,
|
||||
okCount: accumulator.okCount + (result.status === 'ok' ? 1 : 0),
|
||||
errorCount: accumulator.errorCount + errorCount,
|
||||
warningCount: accumulator.warningCount + warningCount,
|
||||
};
|
||||
}, {
|
||||
checkedCount: 0,
|
||||
okCount: 0,
|
||||
errorCount: 0,
|
||||
warningCount: 0,
|
||||
});
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
packageVersion: context.packageVersion,
|
||||
manifestVersion: context.manifestVersion,
|
||||
results,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
function createRepairPlanFromRecord(record, context) {
|
||||
const state = record.state;
|
||||
if (!state) {
|
||||
throw new Error('No install-state available for repair');
|
||||
}
|
||||
|
||||
if (state.request.legacyMode) {
|
||||
const operations = getManagedOperations(state).map(operation => ({
|
||||
...operation,
|
||||
sourcePath: resolveOperationSourcePath(context.repoRoot, operation),
|
||||
}));
|
||||
|
||||
const statePreview = {
|
||||
...state,
|
||||
operations: operations.map(operation => ({ ...operation })),
|
||||
source: {
|
||||
...state.source,
|
||||
repoVersion: context.packageVersion,
|
||||
manifestVersion: context.manifestVersion,
|
||||
},
|
||||
lastValidatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return {
|
||||
mode: 'legacy',
|
||||
target: record.adapter.target,
|
||||
adapter: record.adapter,
|
||||
targetRoot: state.target.root,
|
||||
installRoot: state.target.root,
|
||||
installStatePath: state.target.installStatePath,
|
||||
warnings: [],
|
||||
languages: Array.isArray(state.request.legacyLanguages)
|
||||
? [...state.request.legacyLanguages]
|
||||
: [],
|
||||
operations,
|
||||
statePreview,
|
||||
};
|
||||
}
|
||||
|
||||
const desiredPlan = createManifestInstallPlan({
|
||||
sourceRoot: context.repoRoot,
|
||||
target: record.adapter.target,
|
||||
profileId: state.request.profile || null,
|
||||
moduleIds: state.request.modules || [],
|
||||
includeComponentIds: state.request.includeComponents || [],
|
||||
excludeComponentIds: state.request.excludeComponents || [],
|
||||
projectRoot: context.projectRoot,
|
||||
homeDir: context.homeDir,
|
||||
});
|
||||
|
||||
return {
|
||||
...desiredPlan,
|
||||
statePreview: {
|
||||
...desiredPlan.statePreview,
|
||||
installedAt: state.installedAt,
|
||||
lastValidatedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function repairInstalledStates(options = {}) {
|
||||
const repoRoot = options.repoRoot || DEFAULT_REPO_ROOT;
|
||||
const manifests = loadInstallManifests({ repoRoot });
|
||||
const context = {
|
||||
repoRoot,
|
||||
homeDir: options.homeDir || process.env.HOME,
|
||||
projectRoot: options.projectRoot || process.cwd(),
|
||||
manifestVersion: manifests.modulesVersion,
|
||||
packageVersion: readPackageVersion(repoRoot),
|
||||
};
|
||||
const records = discoverInstalledStates({
|
||||
homeDir: context.homeDir,
|
||||
projectRoot: context.projectRoot,
|
||||
targets: options.targets,
|
||||
}).filter(record => record.exists);
|
||||
|
||||
const results = records.map(record => {
|
||||
if (record.error) {
|
||||
return {
|
||||
adapter: record.adapter,
|
||||
status: 'error',
|
||||
installStatePath: record.installStatePath,
|
||||
repairedPaths: [],
|
||||
plannedRepairs: [],
|
||||
error: record.error,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const desiredPlan = createRepairPlanFromRecord(record, context);
|
||||
const operationHealth = summarizeManagedOperationHealth(context.repoRoot, desiredPlan.operations);
|
||||
|
||||
if (operationHealth.missingSource.length > 0) {
|
||||
return {
|
||||
adapter: record.adapter,
|
||||
status: 'error',
|
||||
installStatePath: record.installStatePath,
|
||||
repairedPaths: [],
|
||||
plannedRepairs: [],
|
||||
error: `Missing source file(s): ${operationHealth.missingSource.map(entry => entry.sourcePath).join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
const repairOperations = [
|
||||
...operationHealth.missing.map(entry => ({ ...entry.operation })),
|
||||
...operationHealth.drifted.map(entry => ({ ...entry.operation })),
|
||||
];
|
||||
const plannedRepairs = repairOperations.map(operation => operation.destinationPath);
|
||||
|
||||
if (options.dryRun) {
|
||||
return {
|
||||
adapter: record.adapter,
|
||||
status: plannedRepairs.length > 0 ? 'planned' : 'ok',
|
||||
installStatePath: record.installStatePath,
|
||||
repairedPaths: [],
|
||||
plannedRepairs,
|
||||
stateRefreshed: plannedRepairs.length === 0,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (repairOperations.length > 0) {
|
||||
applyInstallPlan({
|
||||
...desiredPlan,
|
||||
operations: repairOperations,
|
||||
statePreview: desiredPlan.statePreview,
|
||||
});
|
||||
} else {
|
||||
writeInstallState(desiredPlan.installStatePath, desiredPlan.statePreview);
|
||||
}
|
||||
|
||||
return {
|
||||
adapter: record.adapter,
|
||||
status: repairOperations.length > 0 ? 'repaired' : 'ok',
|
||||
installStatePath: record.installStatePath,
|
||||
repairedPaths: plannedRepairs,
|
||||
plannedRepairs: [],
|
||||
stateRefreshed: true,
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
adapter: record.adapter,
|
||||
status: 'error',
|
||||
installStatePath: record.installStatePath,
|
||||
repairedPaths: [],
|
||||
plannedRepairs: [],
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const summary = results.reduce((accumulator, result) => ({
|
||||
checkedCount: accumulator.checkedCount + 1,
|
||||
repairedCount: accumulator.repairedCount + (result.status === 'repaired' ? 1 : 0),
|
||||
plannedRepairCount: accumulator.plannedRepairCount + (result.status === 'planned' ? 1 : 0),
|
||||
errorCount: accumulator.errorCount + (result.status === 'error' ? 1 : 0),
|
||||
}), {
|
||||
checkedCount: 0,
|
||||
repairedCount: 0,
|
||||
plannedRepairCount: 0,
|
||||
errorCount: 0,
|
||||
});
|
||||
|
||||
return {
|
||||
dryRun: Boolean(options.dryRun),
|
||||
generatedAt: new Date().toISOString(),
|
||||
results,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
function cleanupEmptyParentDirs(filePath, stopAt) {
|
||||
let currentPath = path.dirname(filePath);
|
||||
const normalizedStopAt = path.resolve(stopAt);
|
||||
|
||||
while (
|
||||
currentPath
|
||||
&& path.resolve(currentPath).startsWith(normalizedStopAt)
|
||||
&& path.resolve(currentPath) !== normalizedStopAt
|
||||
) {
|
||||
if (!fs.existsSync(currentPath)) {
|
||||
currentPath = path.dirname(currentPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
const stat = fs.lstatSync(currentPath);
|
||||
if (!stat.isDirectory() || fs.readdirSync(currentPath).length > 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
fs.rmdirSync(currentPath);
|
||||
currentPath = path.dirname(currentPath);
|
||||
}
|
||||
}
|
||||
|
||||
function uninstallInstalledStates(options = {}) {
|
||||
const records = discoverInstalledStates({
|
||||
homeDir: options.homeDir,
|
||||
projectRoot: options.projectRoot,
|
||||
targets: options.targets,
|
||||
}).filter(record => record.exists);
|
||||
|
||||
const results = records.map(record => {
|
||||
if (record.error || !record.state) {
|
||||
return {
|
||||
adapter: record.adapter,
|
||||
status: 'error',
|
||||
installStatePath: record.installStatePath,
|
||||
removedPaths: [],
|
||||
plannedRemovals: [],
|
||||
error: record.error || 'No valid install-state available',
|
||||
};
|
||||
}
|
||||
|
||||
const state = record.state;
|
||||
const plannedRemovals = Array.from(new Set([
|
||||
...getManagedOperations(state).map(operation => operation.destinationPath),
|
||||
state.target.installStatePath,
|
||||
]));
|
||||
|
||||
if (options.dryRun) {
|
||||
return {
|
||||
adapter: record.adapter,
|
||||
status: 'planned',
|
||||
installStatePath: record.installStatePath,
|
||||
removedPaths: [],
|
||||
plannedRemovals,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const removedPaths = [];
|
||||
const cleanupTargets = [];
|
||||
const filePaths = Array.from(new Set(
|
||||
getManagedOperations(state).map(operation => operation.destinationPath)
|
||||
)).sort((left, right) => right.length - left.length);
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const stat = fs.lstatSync(filePath);
|
||||
if (stat.isDirectory()) {
|
||||
throw new Error(`Refusing to remove managed directory path without explicit support: ${filePath}`);
|
||||
}
|
||||
|
||||
fs.rmSync(filePath, { force: true });
|
||||
removedPaths.push(filePath);
|
||||
cleanupTargets.push(filePath);
|
||||
}
|
||||
|
||||
if (fs.existsSync(state.target.installStatePath)) {
|
||||
fs.rmSync(state.target.installStatePath, { force: true });
|
||||
removedPaths.push(state.target.installStatePath);
|
||||
cleanupTargets.push(state.target.installStatePath);
|
||||
}
|
||||
|
||||
for (const cleanupTarget of cleanupTargets) {
|
||||
cleanupEmptyParentDirs(cleanupTarget, state.target.root);
|
||||
}
|
||||
|
||||
return {
|
||||
adapter: record.adapter,
|
||||
status: 'uninstalled',
|
||||
installStatePath: record.installStatePath,
|
||||
removedPaths,
|
||||
plannedRemovals: [],
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
adapter: record.adapter,
|
||||
status: 'error',
|
||||
installStatePath: record.installStatePath,
|
||||
removedPaths: [],
|
||||
plannedRemovals,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const summary = results.reduce((accumulator, result) => ({
|
||||
checkedCount: accumulator.checkedCount + 1,
|
||||
uninstalledCount: accumulator.uninstalledCount + (result.status === 'uninstalled' ? 1 : 0),
|
||||
plannedRemovalCount: accumulator.plannedRemovalCount + (result.status === 'planned' ? 1 : 0),
|
||||
errorCount: accumulator.errorCount + (result.status === 'error' ? 1 : 0),
|
||||
}), {
|
||||
checkedCount: 0,
|
||||
uninstalledCount: 0,
|
||||
plannedRemovalCount: 0,
|
||||
errorCount: 0,
|
||||
});
|
||||
|
||||
return {
|
||||
dryRun: Boolean(options.dryRun),
|
||||
generatedAt: new Date().toISOString(),
|
||||
results,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_REPO_ROOT,
|
||||
buildDoctorReport,
|
||||
discoverInstalledStates,
|
||||
normalizeTargets,
|
||||
repairInstalledStates,
|
||||
uninstallInstalledStates,
|
||||
};
|
||||
305
scripts/lib/install-manifests.js
Normal file
305
scripts/lib/install-manifests.js
Normal file
@@ -0,0 +1,305 @@
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { planInstallTargetScaffold } = require('./install-targets/registry');
|
||||
|
||||
const DEFAULT_REPO_ROOT = path.join(__dirname, '../..');
|
||||
const SUPPORTED_INSTALL_TARGETS = ['claude', 'cursor', 'antigravity', 'codex', 'opencode'];
|
||||
const COMPONENT_FAMILY_PREFIXES = {
|
||||
baseline: 'baseline:',
|
||||
language: 'lang:',
|
||||
framework: 'framework:',
|
||||
capability: 'capability:',
|
||||
};
|
||||
|
||||
function readJson(filePath, label) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to read ${label}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function dedupeStrings(values) {
|
||||
return [...new Set((Array.isArray(values) ? values : []).map(value => String(value).trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function intersectTargets(modules) {
|
||||
if (!Array.isArray(modules) || modules.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return SUPPORTED_INSTALL_TARGETS.filter(target => (
|
||||
modules.every(module => Array.isArray(module.targets) && module.targets.includes(target))
|
||||
));
|
||||
}
|
||||
|
||||
function getManifestPaths(repoRoot = DEFAULT_REPO_ROOT) {
|
||||
return {
|
||||
modulesPath: path.join(repoRoot, 'manifests', 'install-modules.json'),
|
||||
profilesPath: path.join(repoRoot, 'manifests', 'install-profiles.json'),
|
||||
componentsPath: path.join(repoRoot, 'manifests', 'install-components.json'),
|
||||
};
|
||||
}
|
||||
|
||||
function loadInstallManifests(options = {}) {
|
||||
const repoRoot = options.repoRoot || DEFAULT_REPO_ROOT;
|
||||
const { modulesPath, profilesPath, componentsPath } = getManifestPaths(repoRoot);
|
||||
|
||||
if (!fs.existsSync(modulesPath) || !fs.existsSync(profilesPath)) {
|
||||
throw new Error(`Install manifests not found under ${repoRoot}`);
|
||||
}
|
||||
|
||||
const modulesData = readJson(modulesPath, 'install-modules.json');
|
||||
const profilesData = readJson(profilesPath, 'install-profiles.json');
|
||||
const componentsData = fs.existsSync(componentsPath)
|
||||
? readJson(componentsPath, 'install-components.json')
|
||||
: { version: null, components: [] };
|
||||
const modules = Array.isArray(modulesData.modules) ? modulesData.modules : [];
|
||||
const profiles = profilesData && typeof profilesData.profiles === 'object'
|
||||
? profilesData.profiles
|
||||
: {};
|
||||
const components = Array.isArray(componentsData.components) ? componentsData.components : [];
|
||||
const modulesById = new Map(modules.map(module => [module.id, module]));
|
||||
const componentsById = new Map(components.map(component => [component.id, component]));
|
||||
|
||||
return {
|
||||
repoRoot,
|
||||
modulesPath,
|
||||
profilesPath,
|
||||
componentsPath,
|
||||
modules,
|
||||
profiles,
|
||||
components,
|
||||
modulesById,
|
||||
componentsById,
|
||||
modulesVersion: modulesData.version,
|
||||
profilesVersion: profilesData.version,
|
||||
componentsVersion: componentsData.version,
|
||||
};
|
||||
}
|
||||
|
||||
function listInstallProfiles(options = {}) {
|
||||
const manifests = loadInstallManifests(options);
|
||||
return Object.entries(manifests.profiles).map(([id, profile]) => ({
|
||||
id,
|
||||
description: profile.description,
|
||||
moduleCount: Array.isArray(profile.modules) ? profile.modules.length : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
function listInstallModules(options = {}) {
|
||||
const manifests = loadInstallManifests(options);
|
||||
return manifests.modules.map(module => ({
|
||||
id: module.id,
|
||||
kind: module.kind,
|
||||
description: module.description,
|
||||
targets: module.targets,
|
||||
defaultInstall: module.defaultInstall,
|
||||
cost: module.cost,
|
||||
stability: module.stability,
|
||||
dependencyCount: Array.isArray(module.dependencies) ? module.dependencies.length : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
function listInstallComponents(options = {}) {
|
||||
const manifests = loadInstallManifests(options);
|
||||
const family = options.family || null;
|
||||
const target = options.target || null;
|
||||
|
||||
if (family && !Object.hasOwn(COMPONENT_FAMILY_PREFIXES, family)) {
|
||||
throw new Error(
|
||||
`Unknown component family: ${family}. Expected one of ${Object.keys(COMPONENT_FAMILY_PREFIXES).join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
if (target && !SUPPORTED_INSTALL_TARGETS.includes(target)) {
|
||||
throw new Error(
|
||||
`Unknown install target: ${target}. Expected one of ${SUPPORTED_INSTALL_TARGETS.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
return manifests.components
|
||||
.filter(component => !family || component.family === family)
|
||||
.map(component => {
|
||||
const moduleIds = dedupeStrings(component.modules);
|
||||
const modules = moduleIds
|
||||
.map(moduleId => manifests.modulesById.get(moduleId))
|
||||
.filter(Boolean);
|
||||
const targets = intersectTargets(modules);
|
||||
|
||||
return {
|
||||
id: component.id,
|
||||
family: component.family,
|
||||
description: component.description,
|
||||
moduleIds,
|
||||
moduleCount: moduleIds.length,
|
||||
targets,
|
||||
};
|
||||
})
|
||||
.filter(component => !target || component.targets.includes(target));
|
||||
}
|
||||
|
||||
function expandComponentIdsToModuleIds(componentIds, manifests) {
|
||||
const expandedModuleIds = [];
|
||||
|
||||
for (const componentId of dedupeStrings(componentIds)) {
|
||||
const component = manifests.componentsById.get(componentId);
|
||||
if (!component) {
|
||||
throw new Error(`Unknown install component: ${componentId}`);
|
||||
}
|
||||
expandedModuleIds.push(...component.modules);
|
||||
}
|
||||
|
||||
return dedupeStrings(expandedModuleIds);
|
||||
}
|
||||
|
||||
function resolveInstallPlan(options = {}) {
|
||||
const manifests = loadInstallManifests(options);
|
||||
const profileId = options.profileId || null;
|
||||
const explicitModuleIds = dedupeStrings(options.moduleIds);
|
||||
const includedComponentIds = dedupeStrings(options.includeComponentIds);
|
||||
const excludedComponentIds = dedupeStrings(options.excludeComponentIds);
|
||||
const requestedModuleIds = [];
|
||||
|
||||
if (profileId) {
|
||||
const profile = manifests.profiles[profileId];
|
||||
if (!profile) {
|
||||
throw new Error(`Unknown install profile: ${profileId}`);
|
||||
}
|
||||
requestedModuleIds.push(...profile.modules);
|
||||
}
|
||||
|
||||
requestedModuleIds.push(...explicitModuleIds);
|
||||
requestedModuleIds.push(...expandComponentIdsToModuleIds(includedComponentIds, manifests));
|
||||
|
||||
const excludedModuleIds = expandComponentIdsToModuleIds(excludedComponentIds, manifests);
|
||||
const excludedModuleOwners = new Map();
|
||||
for (const componentId of excludedComponentIds) {
|
||||
const component = manifests.componentsById.get(componentId);
|
||||
if (!component) {
|
||||
throw new Error(`Unknown install component: ${componentId}`);
|
||||
}
|
||||
for (const moduleId of component.modules) {
|
||||
const owners = excludedModuleOwners.get(moduleId) || [];
|
||||
owners.push(componentId);
|
||||
excludedModuleOwners.set(moduleId, owners);
|
||||
}
|
||||
}
|
||||
|
||||
const target = options.target || null;
|
||||
if (target && !SUPPORTED_INSTALL_TARGETS.includes(target)) {
|
||||
throw new Error(
|
||||
`Unknown install target: ${target}. Expected one of ${SUPPORTED_INSTALL_TARGETS.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
const effectiveRequestedIds = dedupeStrings(
|
||||
requestedModuleIds.filter(moduleId => !excludedModuleOwners.has(moduleId))
|
||||
);
|
||||
|
||||
if (requestedModuleIds.length === 0) {
|
||||
throw new Error('No install profile, module IDs, or included component IDs were provided');
|
||||
}
|
||||
|
||||
if (effectiveRequestedIds.length === 0) {
|
||||
throw new Error('Selection excludes every requested install module');
|
||||
}
|
||||
|
||||
const selectedIds = new Set();
|
||||
const skippedTargetIds = new Set();
|
||||
const excludedIds = new Set(excludedModuleIds);
|
||||
const visitingIds = new Set();
|
||||
const resolvedIds = new Set();
|
||||
|
||||
function resolveModule(moduleId, dependencyOf) {
|
||||
const module = manifests.modulesById.get(moduleId);
|
||||
if (!module) {
|
||||
throw new Error(`Unknown install module: ${moduleId}`);
|
||||
}
|
||||
|
||||
if (excludedModuleOwners.has(moduleId)) {
|
||||
if (dependencyOf) {
|
||||
const owners = excludedModuleOwners.get(moduleId) || [];
|
||||
throw new Error(
|
||||
`Module ${dependencyOf} depends on excluded module ${moduleId}${owners.length > 0 ? ` (excluded by ${owners.join(', ')})` : ''}`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (target && !module.targets.includes(target)) {
|
||||
if (dependencyOf) {
|
||||
throw new Error(
|
||||
`Module ${dependencyOf} depends on ${moduleId}, which does not support target ${target}`
|
||||
);
|
||||
}
|
||||
skippedTargetIds.add(moduleId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (resolvedIds.has(moduleId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (visitingIds.has(moduleId)) {
|
||||
throw new Error(`Circular install dependency detected at ${moduleId}`);
|
||||
}
|
||||
|
||||
visitingIds.add(moduleId);
|
||||
for (const dependencyId of module.dependencies) {
|
||||
resolveModule(dependencyId, moduleId);
|
||||
}
|
||||
visitingIds.delete(moduleId);
|
||||
resolvedIds.add(moduleId);
|
||||
selectedIds.add(moduleId);
|
||||
}
|
||||
|
||||
for (const moduleId of effectiveRequestedIds) {
|
||||
resolveModule(moduleId, null);
|
||||
}
|
||||
|
||||
const selectedModules = manifests.modules.filter(module => selectedIds.has(module.id));
|
||||
const skippedModules = manifests.modules.filter(module => skippedTargetIds.has(module.id));
|
||||
const excludedModules = manifests.modules.filter(module => excludedIds.has(module.id));
|
||||
const scaffoldPlan = target
|
||||
? planInstallTargetScaffold({
|
||||
target,
|
||||
repoRoot: manifests.repoRoot,
|
||||
projectRoot: options.projectRoot || manifests.repoRoot,
|
||||
homeDir: options.homeDir || os.homedir(),
|
||||
modules: selectedModules,
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
repoRoot: manifests.repoRoot,
|
||||
profileId,
|
||||
target,
|
||||
requestedModuleIds: effectiveRequestedIds,
|
||||
explicitModuleIds,
|
||||
includedComponentIds,
|
||||
excludedComponentIds,
|
||||
selectedModuleIds: selectedModules.map(module => module.id),
|
||||
skippedModuleIds: skippedModules.map(module => module.id),
|
||||
excludedModuleIds: excludedModules.map(module => module.id),
|
||||
selectedModules,
|
||||
skippedModules,
|
||||
excludedModules,
|
||||
targetAdapterId: scaffoldPlan ? scaffoldPlan.adapter.id : null,
|
||||
targetRoot: scaffoldPlan ? scaffoldPlan.targetRoot : null,
|
||||
installStatePath: scaffoldPlan ? scaffoldPlan.installStatePath : null,
|
||||
operations: scaffoldPlan ? scaffoldPlan.operations : [],
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_REPO_ROOT,
|
||||
SUPPORTED_INSTALL_TARGETS,
|
||||
getManifestPaths,
|
||||
loadInstallManifests,
|
||||
listInstallComponents,
|
||||
listInstallModules,
|
||||
listInstallProfiles,
|
||||
resolveInstallPlan,
|
||||
};
|
||||
120
scripts/lib/install-state.js
Normal file
120
scripts/lib/install-state.js
Normal file
@@ -0,0 +1,120 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const Ajv = require('ajv');
|
||||
|
||||
const SCHEMA_PATH = path.join(__dirname, '..', '..', 'schemas', 'install-state.schema.json');
|
||||
|
||||
let cachedValidator = null;
|
||||
|
||||
function readJson(filePath, label) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to read ${label}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getValidator() {
|
||||
if (cachedValidator) {
|
||||
return cachedValidator;
|
||||
}
|
||||
|
||||
const schema = readJson(SCHEMA_PATH, 'install-state schema');
|
||||
const ajv = new Ajv({ allErrors: true });
|
||||
cachedValidator = ajv.compile(schema);
|
||||
return cachedValidator;
|
||||
}
|
||||
|
||||
function formatValidationErrors(errors = []) {
|
||||
return errors
|
||||
.map(error => `${error.instancePath || '/'} ${error.message}`)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
function validateInstallState(state) {
|
||||
const validator = getValidator();
|
||||
const valid = validator(state);
|
||||
return {
|
||||
valid,
|
||||
errors: validator.errors || [],
|
||||
};
|
||||
}
|
||||
|
||||
function assertValidInstallState(state, label) {
|
||||
const result = validateInstallState(state);
|
||||
if (!result.valid) {
|
||||
throw new Error(`Invalid install-state${label ? ` (${label})` : ''}: ${formatValidationErrors(result.errors)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function createInstallState(options) {
|
||||
const installedAt = options.installedAt || new Date().toISOString();
|
||||
const state = {
|
||||
schemaVersion: 'ecc.install.v1',
|
||||
installedAt,
|
||||
target: {
|
||||
id: options.adapter.id,
|
||||
target: options.adapter.target || undefined,
|
||||
kind: options.adapter.kind || undefined,
|
||||
root: options.targetRoot,
|
||||
installStatePath: options.installStatePath,
|
||||
},
|
||||
request: {
|
||||
profile: options.request.profile || null,
|
||||
modules: Array.isArray(options.request.modules) ? [...options.request.modules] : [],
|
||||
includeComponents: Array.isArray(options.request.includeComponents)
|
||||
? [...options.request.includeComponents]
|
||||
: [],
|
||||
excludeComponents: Array.isArray(options.request.excludeComponents)
|
||||
? [...options.request.excludeComponents]
|
||||
: [],
|
||||
legacyLanguages: Array.isArray(options.request.legacyLanguages)
|
||||
? [...options.request.legacyLanguages]
|
||||
: [],
|
||||
legacyMode: Boolean(options.request.legacyMode),
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: Array.isArray(options.resolution.selectedModules)
|
||||
? [...options.resolution.selectedModules]
|
||||
: [],
|
||||
skippedModules: Array.isArray(options.resolution.skippedModules)
|
||||
? [...options.resolution.skippedModules]
|
||||
: [],
|
||||
},
|
||||
source: {
|
||||
repoVersion: options.source.repoVersion || null,
|
||||
repoCommit: options.source.repoCommit || null,
|
||||
manifestVersion: options.source.manifestVersion,
|
||||
},
|
||||
operations: Array.isArray(options.operations)
|
||||
? options.operations.map(operation => ({ ...operation }))
|
||||
: [],
|
||||
};
|
||||
|
||||
if (options.lastValidatedAt) {
|
||||
state.lastValidatedAt = options.lastValidatedAt;
|
||||
}
|
||||
|
||||
assertValidInstallState(state, 'create');
|
||||
return state;
|
||||
}
|
||||
|
||||
function readInstallState(filePath) {
|
||||
const state = readJson(filePath, 'install-state');
|
||||
assertValidInstallState(state, filePath);
|
||||
return state;
|
||||
}
|
||||
|
||||
function writeInstallState(filePath, state) {
|
||||
assertValidInstallState(state, filePath);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(state, null, 2)}\n`);
|
||||
return state;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createInstallState,
|
||||
readInstallState,
|
||||
validateInstallState,
|
||||
writeInstallState,
|
||||
};
|
||||
9
scripts/lib/install-targets/antigravity-project.js
Normal file
9
scripts/lib/install-targets/antigravity-project.js
Normal file
@@ -0,0 +1,9 @@
|
||||
const { createInstallTargetAdapter } = require('./helpers');
|
||||
|
||||
module.exports = createInstallTargetAdapter({
|
||||
id: 'antigravity-project',
|
||||
target: 'antigravity',
|
||||
kind: 'project',
|
||||
rootSegments: ['.agent'],
|
||||
installStatePathSegments: ['ecc-install-state.json'],
|
||||
});
|
||||
10
scripts/lib/install-targets/claude-home.js
Normal file
10
scripts/lib/install-targets/claude-home.js
Normal file
@@ -0,0 +1,10 @@
|
||||
const { createInstallTargetAdapter } = require('./helpers');
|
||||
|
||||
module.exports = createInstallTargetAdapter({
|
||||
id: 'claude-home',
|
||||
target: 'claude',
|
||||
kind: 'home',
|
||||
rootSegments: ['.claude'],
|
||||
installStatePathSegments: ['ecc', 'install-state.json'],
|
||||
nativeRootRelativePath: '.claude-plugin',
|
||||
});
|
||||
10
scripts/lib/install-targets/codex-home.js
Normal file
10
scripts/lib/install-targets/codex-home.js
Normal file
@@ -0,0 +1,10 @@
|
||||
const { createInstallTargetAdapter } = require('./helpers');
|
||||
|
||||
module.exports = createInstallTargetAdapter({
|
||||
id: 'codex-home',
|
||||
target: 'codex',
|
||||
kind: 'home',
|
||||
rootSegments: ['.codex'],
|
||||
installStatePathSegments: ['ecc-install-state.json'],
|
||||
nativeRootRelativePath: '.codex',
|
||||
});
|
||||
10
scripts/lib/install-targets/cursor-project.js
Normal file
10
scripts/lib/install-targets/cursor-project.js
Normal file
@@ -0,0 +1,10 @@
|
||||
const { createInstallTargetAdapter } = require('./helpers');
|
||||
|
||||
module.exports = createInstallTargetAdapter({
|
||||
id: 'cursor-project',
|
||||
target: 'cursor',
|
||||
kind: 'project',
|
||||
rootSegments: ['.cursor'],
|
||||
installStatePathSegments: ['ecc-install-state.json'],
|
||||
nativeRootRelativePath: '.cursor',
|
||||
});
|
||||
89
scripts/lib/install-targets/helpers.js
Normal file
89
scripts/lib/install-targets/helpers.js
Normal file
@@ -0,0 +1,89 @@
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
function normalizeRelativePath(relativePath) {
|
||||
return String(relativePath || '')
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\.\/+/, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function resolveBaseRoot(scope, input = {}) {
|
||||
if (scope === 'home') {
|
||||
return input.homeDir || os.homedir();
|
||||
}
|
||||
|
||||
if (scope === 'project') {
|
||||
const projectRoot = input.projectRoot || input.repoRoot;
|
||||
if (!projectRoot) {
|
||||
throw new Error('projectRoot or repoRoot is required for project install targets');
|
||||
}
|
||||
return projectRoot;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported install target scope: ${scope}`);
|
||||
}
|
||||
|
||||
function createInstallTargetAdapter(config) {
|
||||
const adapter = {
|
||||
id: config.id,
|
||||
target: config.target,
|
||||
kind: config.kind,
|
||||
nativeRootRelativePath: config.nativeRootRelativePath || null,
|
||||
supports(target) {
|
||||
return target === config.target || target === config.id;
|
||||
},
|
||||
resolveRoot(input = {}) {
|
||||
const baseRoot = resolveBaseRoot(config.kind, input);
|
||||
return path.join(baseRoot, ...config.rootSegments);
|
||||
},
|
||||
getInstallStatePath(input = {}) {
|
||||
const root = adapter.resolveRoot(input);
|
||||
return path.join(root, ...config.installStatePathSegments);
|
||||
},
|
||||
resolveDestinationPath(sourceRelativePath, input = {}) {
|
||||
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
|
||||
const targetRoot = adapter.resolveRoot(input);
|
||||
|
||||
if (
|
||||
config.nativeRootRelativePath
|
||||
&& normalizedSourcePath === normalizeRelativePath(config.nativeRootRelativePath)
|
||||
) {
|
||||
return targetRoot;
|
||||
}
|
||||
|
||||
return path.join(targetRoot, normalizedSourcePath);
|
||||
},
|
||||
determineStrategy(sourceRelativePath) {
|
||||
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
|
||||
|
||||
if (
|
||||
config.nativeRootRelativePath
|
||||
&& normalizedSourcePath === normalizeRelativePath(config.nativeRootRelativePath)
|
||||
) {
|
||||
return 'sync-root-children';
|
||||
}
|
||||
|
||||
return 'preserve-relative-path';
|
||||
},
|
||||
createScaffoldOperation(moduleId, sourceRelativePath, input = {}) {
|
||||
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
|
||||
return {
|
||||
kind: 'copy-path',
|
||||
moduleId,
|
||||
sourceRelativePath: normalizedSourcePath,
|
||||
destinationPath: adapter.resolveDestinationPath(normalizedSourcePath, input),
|
||||
strategy: adapter.determineStrategy(normalizedSourcePath),
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: true,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return Object.freeze(adapter);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createInstallTargetAdapter,
|
||||
normalizeRelativePath,
|
||||
};
|
||||
10
scripts/lib/install-targets/opencode-home.js
Normal file
10
scripts/lib/install-targets/opencode-home.js
Normal file
@@ -0,0 +1,10 @@
|
||||
const { createInstallTargetAdapter } = require('./helpers');
|
||||
|
||||
module.exports = createInstallTargetAdapter({
|
||||
id: 'opencode-home',
|
||||
target: 'opencode',
|
||||
kind: 'home',
|
||||
rootSegments: ['.opencode'],
|
||||
installStatePathSegments: ['ecc-install-state.json'],
|
||||
nativeRootRelativePath: '.opencode',
|
||||
});
|
||||
64
scripts/lib/install-targets/registry.js
Normal file
64
scripts/lib/install-targets/registry.js
Normal file
@@ -0,0 +1,64 @@
|
||||
const antigravityProject = require('./antigravity-project');
|
||||
const claudeHome = require('./claude-home');
|
||||
const codexHome = require('./codex-home');
|
||||
const cursorProject = require('./cursor-project');
|
||||
const opencodeHome = require('./opencode-home');
|
||||
|
||||
const ADAPTERS = Object.freeze([
|
||||
claudeHome,
|
||||
cursorProject,
|
||||
antigravityProject,
|
||||
codexHome,
|
||||
opencodeHome,
|
||||
]);
|
||||
|
||||
function listInstallTargetAdapters() {
|
||||
return ADAPTERS.slice();
|
||||
}
|
||||
|
||||
function getInstallTargetAdapter(targetOrAdapterId) {
|
||||
const adapter = ADAPTERS.find(candidate => candidate.supports(targetOrAdapterId));
|
||||
|
||||
if (!adapter) {
|
||||
throw new Error(`Unknown install target adapter: ${targetOrAdapterId}`);
|
||||
}
|
||||
|
||||
return adapter;
|
||||
}
|
||||
|
||||
function planInstallTargetScaffold(options = {}) {
|
||||
const adapter = getInstallTargetAdapter(options.target);
|
||||
const modules = Array.isArray(options.modules) ? options.modules : [];
|
||||
const planningInput = {
|
||||
repoRoot: options.repoRoot,
|
||||
projectRoot: options.projectRoot || options.repoRoot,
|
||||
homeDir: options.homeDir,
|
||||
};
|
||||
const targetRoot = adapter.resolveRoot(planningInput);
|
||||
const installStatePath = adapter.getInstallStatePath(planningInput);
|
||||
const operations = modules.flatMap(module => {
|
||||
const paths = Array.isArray(module.paths) ? module.paths : [];
|
||||
return paths.map(sourceRelativePath => adapter.createScaffoldOperation(
|
||||
module.id,
|
||||
sourceRelativePath,
|
||||
planningInput
|
||||
));
|
||||
});
|
||||
|
||||
return {
|
||||
adapter: {
|
||||
id: adapter.id,
|
||||
target: adapter.target,
|
||||
kind: adapter.kind,
|
||||
},
|
||||
targetRoot,
|
||||
installStatePath,
|
||||
operations,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getInstallTargetAdapter,
|
||||
listInstallTargetAdapters,
|
||||
planInstallTargetScaffold,
|
||||
};
|
||||
23
scripts/lib/install/apply.js
Normal file
23
scripts/lib/install/apply.js
Normal file
@@ -0,0 +1,23 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
const { writeInstallState } = require('../install-state');
|
||||
|
||||
function applyInstallPlan(plan) {
|
||||
for (const operation of plan.operations) {
|
||||
fs.mkdirSync(require('path').dirname(operation.destinationPath), { recursive: true });
|
||||
fs.copyFileSync(operation.sourcePath, operation.destinationPath);
|
||||
}
|
||||
|
||||
writeInstallState(plan.installStatePath, plan.statePreview);
|
||||
|
||||
return {
|
||||
...plan,
|
||||
applied: true,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
applyInstallPlan,
|
||||
};
|
||||
82
scripts/lib/install/config.js
Normal file
82
scripts/lib/install/config.js
Normal file
@@ -0,0 +1,82 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const Ajv = require('ajv');
|
||||
|
||||
const DEFAULT_INSTALL_CONFIG = 'ecc-install.json';
|
||||
const CONFIG_SCHEMA_PATH = path.join(__dirname, '..', '..', '..', 'schemas', 'ecc-install-config.schema.json');
|
||||
|
||||
let cachedValidator = null;
|
||||
|
||||
function readJson(filePath, label) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid JSON in ${label}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getValidator() {
|
||||
if (cachedValidator) {
|
||||
return cachedValidator;
|
||||
}
|
||||
|
||||
const schema = readJson(CONFIG_SCHEMA_PATH, 'ecc-install-config.schema.json');
|
||||
const ajv = new Ajv({ allErrors: true });
|
||||
cachedValidator = ajv.compile(schema);
|
||||
return cachedValidator;
|
||||
}
|
||||
|
||||
function dedupeStrings(values) {
|
||||
return [...new Set((Array.isArray(values) ? values : []).map(value => String(value).trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function formatValidationErrors(errors = []) {
|
||||
return errors.map(error => `${error.instancePath || '/'} ${error.message}`).join('; ');
|
||||
}
|
||||
|
||||
function resolveInstallConfigPath(configPath, options = {}) {
|
||||
if (!configPath) {
|
||||
throw new Error('An install config path is required');
|
||||
}
|
||||
|
||||
const cwd = options.cwd || process.cwd();
|
||||
return path.isAbsolute(configPath)
|
||||
? configPath
|
||||
: path.resolve(cwd, configPath);
|
||||
}
|
||||
|
||||
function loadInstallConfig(configPath, options = {}) {
|
||||
const resolvedPath = resolveInstallConfigPath(configPath, options);
|
||||
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
throw new Error(`Install config not found: ${resolvedPath}`);
|
||||
}
|
||||
|
||||
const raw = readJson(resolvedPath, path.basename(resolvedPath));
|
||||
const validator = getValidator();
|
||||
|
||||
if (!validator(raw)) {
|
||||
throw new Error(
|
||||
`Invalid install config ${resolvedPath}: ${formatValidationErrors(validator.errors)}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
path: resolvedPath,
|
||||
version: raw.version,
|
||||
target: raw.target || null,
|
||||
profileId: raw.profile || null,
|
||||
moduleIds: dedupeStrings(raw.modules),
|
||||
includeComponentIds: dedupeStrings(raw.include),
|
||||
excludeComponentIds: dedupeStrings(raw.exclude),
|
||||
options: raw.options && typeof raw.options === 'object' ? { ...raw.options } : {},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_INSTALL_CONFIG,
|
||||
loadInstallConfig,
|
||||
resolveInstallConfigPath,
|
||||
};
|
||||
113
scripts/lib/install/request.js
Normal file
113
scripts/lib/install/request.js
Normal file
@@ -0,0 +1,113 @@
|
||||
'use strict';
|
||||
|
||||
const LEGACY_INSTALL_TARGETS = ['claude', 'cursor', 'antigravity'];
|
||||
|
||||
function dedupeStrings(values) {
|
||||
return [...new Set((Array.isArray(values) ? values : []).map(value => String(value).trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function parseInstallArgs(argv) {
|
||||
const args = argv.slice(2);
|
||||
const parsed = {
|
||||
target: null,
|
||||
dryRun: false,
|
||||
json: false,
|
||||
help: false,
|
||||
configPath: null,
|
||||
profileId: null,
|
||||
moduleIds: [],
|
||||
includeComponentIds: [],
|
||||
excludeComponentIds: [],
|
||||
languages: [],
|
||||
};
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
|
||||
if (arg === '--target') {
|
||||
parsed.target = args[index + 1] || null;
|
||||
index += 1;
|
||||
} else if (arg === '--config') {
|
||||
parsed.configPath = args[index + 1] || null;
|
||||
index += 1;
|
||||
} else if (arg === '--profile') {
|
||||
parsed.profileId = args[index + 1] || null;
|
||||
index += 1;
|
||||
} else if (arg === '--modules') {
|
||||
const raw = args[index + 1] || '';
|
||||
parsed.moduleIds = raw.split(',').map(value => value.trim()).filter(Boolean);
|
||||
index += 1;
|
||||
} else if (arg === '--with') {
|
||||
const componentId = args[index + 1] || '';
|
||||
if (componentId.trim()) {
|
||||
parsed.includeComponentIds.push(componentId.trim());
|
||||
}
|
||||
index += 1;
|
||||
} else if (arg === '--without') {
|
||||
const componentId = args[index + 1] || '';
|
||||
if (componentId.trim()) {
|
||||
parsed.excludeComponentIds.push(componentId.trim());
|
||||
}
|
||||
index += 1;
|
||||
} else if (arg === '--dry-run') {
|
||||
parsed.dryRun = true;
|
||||
} else if (arg === '--json') {
|
||||
parsed.json = true;
|
||||
} else if (arg === '--help' || arg === '-h') {
|
||||
parsed.help = true;
|
||||
} else if (arg.startsWith('--')) {
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
} else {
|
||||
parsed.languages.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizeInstallRequest(options = {}) {
|
||||
const config = options.config && typeof options.config === 'object'
|
||||
? options.config
|
||||
: null;
|
||||
const profileId = options.profileId || config?.profileId || null;
|
||||
const moduleIds = dedupeStrings([...(config?.moduleIds || []), ...(options.moduleIds || [])]);
|
||||
const includeComponentIds = dedupeStrings([
|
||||
...(config?.includeComponentIds || []),
|
||||
...(options.includeComponentIds || []),
|
||||
]);
|
||||
const excludeComponentIds = dedupeStrings([
|
||||
...(config?.excludeComponentIds || []),
|
||||
...(options.excludeComponentIds || []),
|
||||
]);
|
||||
const languages = Array.isArray(options.languages) ? [...options.languages] : [];
|
||||
const target = options.target || config?.target || 'claude';
|
||||
const hasManifestBaseSelection = Boolean(profileId) || moduleIds.length > 0 || includeComponentIds.length > 0;
|
||||
const usingManifestMode = hasManifestBaseSelection || excludeComponentIds.length > 0;
|
||||
|
||||
if (usingManifestMode && languages.length > 0) {
|
||||
throw new Error(
|
||||
'Legacy language arguments cannot be combined with --profile, --modules, --with, --without, or manifest config selections'
|
||||
);
|
||||
}
|
||||
|
||||
if (!options.help && !hasManifestBaseSelection && languages.length === 0) {
|
||||
throw new Error('No install profile, module IDs, included components, or legacy languages were provided');
|
||||
}
|
||||
|
||||
return {
|
||||
mode: usingManifestMode ? 'manifest' : 'legacy',
|
||||
target,
|
||||
profileId,
|
||||
moduleIds,
|
||||
includeComponentIds,
|
||||
excludeComponentIds,
|
||||
languages,
|
||||
configPath: config?.path || options.configPath || null,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LEGACY_INSTALL_TARGETS,
|
||||
normalizeInstallRequest,
|
||||
parseInstallArgs,
|
||||
};
|
||||
42
scripts/lib/install/runtime.js
Normal file
42
scripts/lib/install/runtime.js
Normal file
@@ -0,0 +1,42 @@
|
||||
'use strict';
|
||||
|
||||
const {
|
||||
createLegacyInstallPlan,
|
||||
createManifestInstallPlan,
|
||||
} = require('../install-executor');
|
||||
|
||||
function createInstallPlanFromRequest(request, options = {}) {
|
||||
if (!request || typeof request !== 'object') {
|
||||
throw new Error('A normalized install request is required');
|
||||
}
|
||||
|
||||
if (request.mode === 'manifest') {
|
||||
return createManifestInstallPlan({
|
||||
target: request.target,
|
||||
profileId: request.profileId,
|
||||
moduleIds: request.moduleIds,
|
||||
includeComponentIds: request.includeComponentIds,
|
||||
excludeComponentIds: request.excludeComponentIds,
|
||||
projectRoot: options.projectRoot,
|
||||
homeDir: options.homeDir,
|
||||
sourceRoot: options.sourceRoot,
|
||||
});
|
||||
}
|
||||
|
||||
if (request.mode === 'legacy') {
|
||||
return createLegacyInstallPlan({
|
||||
target: request.target,
|
||||
languages: request.languages,
|
||||
projectRoot: options.projectRoot,
|
||||
homeDir: options.homeDir,
|
||||
claudeRulesDir: options.claudeRulesDir,
|
||||
sourceRoot: options.sourceRoot,
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported install request mode: ${request.mode}`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createInstallPlanFromRequest,
|
||||
};
|
||||
@@ -154,7 +154,8 @@ function loadWorkerSnapshots(coordinationDir) {
|
||||
});
|
||||
}
|
||||
|
||||
function listTmuxPanes(sessionName) {
|
||||
function listTmuxPanes(sessionName, options = {}) {
|
||||
const { spawnSyncImpl = spawnSync } = options;
|
||||
const format = [
|
||||
'#{pane_id}',
|
||||
'#{window_index}',
|
||||
@@ -167,12 +168,15 @@ function listTmuxPanes(sessionName) {
|
||||
'#{pane_pid}'
|
||||
].join('\t');
|
||||
|
||||
const result = spawnSync('tmux', ['list-panes', '-t', sessionName, '-F', format], {
|
||||
const result = spawnSyncImpl('tmux', ['list-panes', '-t', sessionName, '-F', format], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
if (result.error.code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
|
||||
138
scripts/lib/session-adapters/canonical-session.js
Normal file
138
scripts/lib/session-adapters/canonical-session.js
Normal file
@@ -0,0 +1,138 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
|
||||
const SESSION_SCHEMA_VERSION = 'ecc.session.v1';
|
||||
|
||||
function buildAggregates(workers) {
|
||||
const states = workers.reduce((accumulator, worker) => {
|
||||
const state = worker.state || 'unknown';
|
||||
accumulator[state] = (accumulator[state] || 0) + 1;
|
||||
return accumulator;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
workerCount: workers.length,
|
||||
states
|
||||
};
|
||||
}
|
||||
|
||||
function deriveDmuxSessionState(snapshot) {
|
||||
if (snapshot.sessionActive) {
|
||||
return 'active';
|
||||
}
|
||||
|
||||
if (snapshot.workerCount > 0) {
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
return 'missing';
|
||||
}
|
||||
|
||||
function normalizeDmuxSnapshot(snapshot, sourceTarget) {
|
||||
const workers = (snapshot.workers || []).map(worker => ({
|
||||
id: worker.workerSlug,
|
||||
label: worker.workerSlug,
|
||||
state: worker.status.state || 'unknown',
|
||||
branch: worker.status.branch || null,
|
||||
worktree: worker.status.worktree || null,
|
||||
runtime: {
|
||||
kind: 'tmux-pane',
|
||||
command: worker.pane ? worker.pane.currentCommand || null : null,
|
||||
pid: worker.pane ? worker.pane.pid || null : null,
|
||||
active: worker.pane ? Boolean(worker.pane.active) : false,
|
||||
dead: worker.pane ? Boolean(worker.pane.dead) : false,
|
||||
},
|
||||
intent: {
|
||||
objective: worker.task.objective || '',
|
||||
seedPaths: Array.isArray(worker.task.seedPaths) ? worker.task.seedPaths : []
|
||||
},
|
||||
outputs: {
|
||||
summary: Array.isArray(worker.handoff.summary) ? worker.handoff.summary : [],
|
||||
validation: Array.isArray(worker.handoff.validation) ? worker.handoff.validation : [],
|
||||
remainingRisks: Array.isArray(worker.handoff.remainingRisks) ? worker.handoff.remainingRisks : []
|
||||
},
|
||||
artifacts: {
|
||||
statusFile: worker.files.status,
|
||||
taskFile: worker.files.task,
|
||||
handoffFile: worker.files.handoff
|
||||
}
|
||||
}));
|
||||
|
||||
return {
|
||||
schemaVersion: SESSION_SCHEMA_VERSION,
|
||||
adapterId: 'dmux-tmux',
|
||||
session: {
|
||||
id: snapshot.sessionName,
|
||||
kind: 'orchestrated',
|
||||
state: deriveDmuxSessionState(snapshot),
|
||||
repoRoot: snapshot.repoRoot || null,
|
||||
sourceTarget
|
||||
},
|
||||
workers,
|
||||
aggregates: buildAggregates(workers)
|
||||
};
|
||||
}
|
||||
|
||||
function deriveClaudeWorkerId(session) {
|
||||
if (session.shortId && session.shortId !== 'no-id') {
|
||||
return session.shortId;
|
||||
}
|
||||
|
||||
return path.basename(session.filename || session.sessionPath || 'session', '.tmp');
|
||||
}
|
||||
|
||||
function normalizeClaudeHistorySession(session, sourceTarget) {
|
||||
const metadata = session.metadata || {};
|
||||
const workerId = deriveClaudeWorkerId(session);
|
||||
const worker = {
|
||||
id: workerId,
|
||||
label: metadata.title || session.filename || workerId,
|
||||
state: 'recorded',
|
||||
branch: metadata.branch || null,
|
||||
worktree: metadata.worktree || null,
|
||||
runtime: {
|
||||
kind: 'claude-session',
|
||||
command: 'claude',
|
||||
pid: null,
|
||||
active: false,
|
||||
dead: true,
|
||||
},
|
||||
intent: {
|
||||
objective: metadata.inProgress && metadata.inProgress.length > 0
|
||||
? metadata.inProgress[0]
|
||||
: (metadata.title || ''),
|
||||
seedPaths: []
|
||||
},
|
||||
outputs: {
|
||||
summary: Array.isArray(metadata.completed) ? metadata.completed : [],
|
||||
validation: [],
|
||||
remainingRisks: metadata.notes ? [metadata.notes] : []
|
||||
},
|
||||
artifacts: {
|
||||
sessionFile: session.sessionPath,
|
||||
context: metadata.context || null
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
schemaVersion: SESSION_SCHEMA_VERSION,
|
||||
adapterId: 'claude-history',
|
||||
session: {
|
||||
id: workerId,
|
||||
kind: 'history',
|
||||
state: 'recorded',
|
||||
repoRoot: metadata.worktree || null,
|
||||
sourceTarget
|
||||
},
|
||||
workers: [worker],
|
||||
aggregates: buildAggregates([worker])
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SESSION_SCHEMA_VERSION,
|
||||
buildAggregates,
|
||||
normalizeClaudeHistorySession,
|
||||
normalizeDmuxSnapshot
|
||||
};
|
||||
147
scripts/lib/session-adapters/claude-history.js
Normal file
147
scripts/lib/session-adapters/claude-history.js
Normal file
@@ -0,0 +1,147 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const sessionManager = require('../session-manager');
|
||||
const sessionAliases = require('../session-aliases');
|
||||
const { normalizeClaudeHistorySession } = require('./canonical-session');
|
||||
|
||||
function parseClaudeTarget(target) {
|
||||
if (typeof target !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const prefix of ['claude-history:', 'claude:', 'history:']) {
|
||||
if (target.startsWith(prefix)) {
|
||||
return target.slice(prefix.length).trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isSessionFileTarget(target, cwd) {
|
||||
if (typeof target !== 'string' || target.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const absoluteTarget = path.resolve(cwd, target);
|
||||
return fs.existsSync(absoluteTarget)
|
||||
&& fs.statSync(absoluteTarget).isFile()
|
||||
&& absoluteTarget.endsWith('.tmp');
|
||||
}
|
||||
|
||||
function hydrateSessionFromPath(sessionPath) {
|
||||
const filename = path.basename(sessionPath);
|
||||
const parsed = sessionManager.parseSessionFilename(filename);
|
||||
if (!parsed) {
|
||||
throw new Error(`Unsupported session file: ${sessionPath}`);
|
||||
}
|
||||
|
||||
const content = sessionManager.getSessionContent(sessionPath);
|
||||
const stats = fs.statSync(sessionPath);
|
||||
|
||||
return {
|
||||
...parsed,
|
||||
sessionPath,
|
||||
content,
|
||||
metadata: sessionManager.parseSessionMetadata(content),
|
||||
stats: sessionManager.getSessionStats(content || ''),
|
||||
size: stats.size,
|
||||
modifiedTime: stats.mtime,
|
||||
createdTime: stats.birthtime || stats.ctime
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSessionRecord(target, cwd) {
|
||||
const explicitTarget = parseClaudeTarget(target);
|
||||
|
||||
if (explicitTarget) {
|
||||
if (explicitTarget === 'latest') {
|
||||
const [latest] = sessionManager.getAllSessions({ limit: 1 }).sessions;
|
||||
if (!latest) {
|
||||
throw new Error('No Claude session history found');
|
||||
}
|
||||
|
||||
return {
|
||||
session: sessionManager.getSessionById(latest.filename, true),
|
||||
sourceTarget: {
|
||||
type: 'claude-history',
|
||||
value: 'latest'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const alias = sessionAliases.resolveAlias(explicitTarget);
|
||||
if (alias) {
|
||||
return {
|
||||
session: hydrateSessionFromPath(alias.sessionPath),
|
||||
sourceTarget: {
|
||||
type: 'claude-alias',
|
||||
value: explicitTarget
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const session = sessionManager.getSessionById(explicitTarget, true);
|
||||
if (!session) {
|
||||
throw new Error(`Claude session not found: ${explicitTarget}`);
|
||||
}
|
||||
|
||||
return {
|
||||
session,
|
||||
sourceTarget: {
|
||||
type: 'claude-history',
|
||||
value: explicitTarget
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (isSessionFileTarget(target, cwd)) {
|
||||
return {
|
||||
session: hydrateSessionFromPath(path.resolve(cwd, target)),
|
||||
sourceTarget: {
|
||||
type: 'session-file',
|
||||
value: path.resolve(cwd, target)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported Claude session target: ${target}`);
|
||||
}
|
||||
|
||||
function createClaudeHistoryAdapter() {
|
||||
return {
|
||||
id: 'claude-history',
|
||||
canOpen(target, context = {}) {
|
||||
if (context.adapterId && context.adapterId !== 'claude-history') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (context.adapterId === 'claude-history') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const cwd = context.cwd || process.cwd();
|
||||
return parseClaudeTarget(target) !== null || isSessionFileTarget(target, cwd);
|
||||
},
|
||||
open(target, context = {}) {
|
||||
const cwd = context.cwd || process.cwd();
|
||||
|
||||
return {
|
||||
adapterId: 'claude-history',
|
||||
getSnapshot() {
|
||||
const { session, sourceTarget } = resolveSessionRecord(target, cwd);
|
||||
return normalizeClaudeHistorySession(session, sourceTarget);
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createClaudeHistoryAdapter,
|
||||
isSessionFileTarget,
|
||||
parseClaudeTarget
|
||||
};
|
||||
78
scripts/lib/session-adapters/dmux-tmux.js
Normal file
78
scripts/lib/session-adapters/dmux-tmux.js
Normal file
@@ -0,0 +1,78 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { collectSessionSnapshot } = require('../orchestration-session');
|
||||
const { normalizeDmuxSnapshot } = require('./canonical-session');
|
||||
|
||||
function isPlanFileTarget(target, cwd) {
|
||||
if (typeof target !== 'string' || target.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const absoluteTarget = path.resolve(cwd, target);
|
||||
return fs.existsSync(absoluteTarget)
|
||||
&& fs.statSync(absoluteTarget).isFile()
|
||||
&& path.extname(absoluteTarget) === '.json';
|
||||
}
|
||||
|
||||
function isSessionNameTarget(target, cwd) {
|
||||
if (typeof target !== 'string' || target.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const coordinationDir = path.resolve(cwd, '.claude', 'orchestration', target);
|
||||
return fs.existsSync(coordinationDir) && fs.statSync(coordinationDir).isDirectory();
|
||||
}
|
||||
|
||||
function buildSourceTarget(target, cwd) {
|
||||
if (isPlanFileTarget(target, cwd)) {
|
||||
return {
|
||||
type: 'plan',
|
||||
value: path.resolve(cwd, target)
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'session',
|
||||
value: target
|
||||
};
|
||||
}
|
||||
|
||||
function createDmuxTmuxAdapter(options = {}) {
|
||||
const collectSessionSnapshotImpl = options.collectSessionSnapshotImpl || collectSessionSnapshot;
|
||||
|
||||
return {
|
||||
id: 'dmux-tmux',
|
||||
canOpen(target, context = {}) {
|
||||
if (context.adapterId && context.adapterId !== 'dmux-tmux') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (context.adapterId === 'dmux-tmux') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const cwd = context.cwd || process.cwd();
|
||||
return isPlanFileTarget(target, cwd) || isSessionNameTarget(target, cwd);
|
||||
},
|
||||
open(target, context = {}) {
|
||||
const cwd = context.cwd || process.cwd();
|
||||
|
||||
return {
|
||||
adapterId: 'dmux-tmux',
|
||||
getSnapshot() {
|
||||
const snapshot = collectSessionSnapshotImpl(target, cwd);
|
||||
return normalizeDmuxSnapshot(snapshot, buildSourceTarget(target, cwd));
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createDmuxTmuxAdapter,
|
||||
isPlanFileTarget,
|
||||
isSessionNameTarget
|
||||
};
|
||||
42
scripts/lib/session-adapters/registry.js
Normal file
42
scripts/lib/session-adapters/registry.js
Normal file
@@ -0,0 +1,42 @@
|
||||
'use strict';
|
||||
|
||||
const { createClaudeHistoryAdapter } = require('./claude-history');
|
||||
const { createDmuxTmuxAdapter } = require('./dmux-tmux');
|
||||
|
||||
function createDefaultAdapters() {
|
||||
return [
|
||||
createClaudeHistoryAdapter(),
|
||||
createDmuxTmuxAdapter()
|
||||
];
|
||||
}
|
||||
|
||||
function createAdapterRegistry(options = {}) {
|
||||
const adapters = options.adapters || createDefaultAdapters();
|
||||
|
||||
return {
|
||||
adapters,
|
||||
select(target, context = {}) {
|
||||
const adapter = adapters.find(candidate => candidate.canOpen(target, context));
|
||||
if (!adapter) {
|
||||
throw new Error(`No session adapter matched target: ${target}`);
|
||||
}
|
||||
|
||||
return adapter;
|
||||
},
|
||||
open(target, context = {}) {
|
||||
const adapter = this.select(target, context);
|
||||
return adapter.open(target, context);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function inspectSessionTarget(target, options = {}) {
|
||||
const registry = createAdapterRegistry(options);
|
||||
return registry.open(target, options).getSnapshot();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createAdapterRegistry,
|
||||
createDefaultAdapters,
|
||||
inspectSessionTarget
|
||||
};
|
||||
90
scripts/list-installed.js
Normal file
90
scripts/list-installed.js
Normal file
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { discoverInstalledStates } = require('./lib/install-lifecycle');
|
||||
const { SUPPORTED_INSTALL_TARGETS } = require('./lib/install-manifests');
|
||||
|
||||
function showHelp(exitCode = 0) {
|
||||
console.log(`
|
||||
Usage: node scripts/list-installed.js [--target <${SUPPORTED_INSTALL_TARGETS.join('|')}>] [--json]
|
||||
|
||||
Inspect ECC install-state files for the current home/project context.
|
||||
`);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = argv.slice(2);
|
||||
const parsed = {
|
||||
targets: [],
|
||||
json: false,
|
||||
help: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
|
||||
if (arg === '--target') {
|
||||
parsed.targets.push(args[index + 1] || null);
|
||||
index += 1;
|
||||
} else if (arg === '--json') {
|
||||
parsed.json = true;
|
||||
} else if (arg === '--help' || arg === '-h') {
|
||||
parsed.help = true;
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function printHuman(records) {
|
||||
if (records.length === 0) {
|
||||
console.log('No ECC install-state files found for the current home/project context.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Installed ECC targets:\n');
|
||||
for (const record of records) {
|
||||
if (record.error) {
|
||||
console.log(`- ${record.adapter.id}: INVALID (${record.error})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const state = record.state;
|
||||
console.log(`- ${record.adapter.id}`);
|
||||
console.log(` Root: ${state.target.root}`);
|
||||
console.log(` Installed: ${state.installedAt}`);
|
||||
console.log(` Profile: ${state.request.profile || '(legacy/custom)'}`);
|
||||
console.log(` Modules: ${(state.resolution.selectedModules || []).join(', ') || '(none)'}`);
|
||||
console.log(` Legacy languages: ${(state.request.legacyLanguages || []).join(', ') || '(none)'}`);
|
||||
console.log(` Source version: ${state.source.repoVersion || '(unknown)'}`);
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
try {
|
||||
const options = parseArgs(process.argv);
|
||||
if (options.help) {
|
||||
showHelp(0);
|
||||
}
|
||||
|
||||
const records = discoverInstalledStates({
|
||||
homeDir: process.env.HOME,
|
||||
projectRoot: process.cwd(),
|
||||
targets: options.targets,
|
||||
}).filter(record => record.exists);
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ records }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
printHuman(records);
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -4,7 +4,7 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { collectSessionSnapshot } = require('./lib/orchestration-session');
|
||||
const { inspectSessionTarget } = require('./lib/session-adapters/registry');
|
||||
|
||||
function usage() {
|
||||
console.log([
|
||||
@@ -35,7 +35,10 @@ function main() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const snapshot = collectSessionSnapshot(target, process.cwd());
|
||||
const snapshot = inspectSessionTarget(target, {
|
||||
cwd: process.cwd(),
|
||||
adapterId: 'dmux-tmux'
|
||||
});
|
||||
const json = JSON.stringify(snapshot, null, 2);
|
||||
|
||||
if (writePath) {
|
||||
|
||||
97
scripts/repair.js
Normal file
97
scripts/repair.js
Normal file
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { repairInstalledStates } = require('./lib/install-lifecycle');
|
||||
const { SUPPORTED_INSTALL_TARGETS } = require('./lib/install-manifests');
|
||||
|
||||
function showHelp(exitCode = 0) {
|
||||
console.log(`
|
||||
Usage: node scripts/repair.js [--target <${SUPPORTED_INSTALL_TARGETS.join('|')}>] [--dry-run] [--json]
|
||||
|
||||
Rebuild ECC-managed files recorded in install-state for the current context.
|
||||
`);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = argv.slice(2);
|
||||
const parsed = {
|
||||
targets: [],
|
||||
dryRun: false,
|
||||
json: false,
|
||||
help: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
|
||||
if (arg === '--target') {
|
||||
parsed.targets.push(args[index + 1] || null);
|
||||
index += 1;
|
||||
} else if (arg === '--dry-run') {
|
||||
parsed.dryRun = true;
|
||||
} else if (arg === '--json') {
|
||||
parsed.json = true;
|
||||
} else if (arg === '--help' || arg === '-h') {
|
||||
parsed.help = true;
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function printHuman(result) {
|
||||
if (result.results.length === 0) {
|
||||
console.log('No ECC install-state files found for the current home/project context.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Repair summary:\n');
|
||||
for (const entry of result.results) {
|
||||
console.log(`- ${entry.adapter.id}`);
|
||||
console.log(` Status: ${entry.status.toUpperCase()}`);
|
||||
console.log(` Install-state: ${entry.installStatePath}`);
|
||||
|
||||
if (entry.error) {
|
||||
console.log(` Error: ${entry.error}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const paths = result.dryRun ? entry.plannedRepairs : entry.repairedPaths;
|
||||
console.log(` ${result.dryRun ? 'Planned repairs' : 'Repaired paths'}: ${paths.length}`);
|
||||
}
|
||||
|
||||
console.log(`\nSummary: checked=${result.summary.checkedCount}, ${result.dryRun ? 'planned' : 'repaired'}=${result.dryRun ? result.summary.plannedRepairCount : result.summary.repairedCount}, errors=${result.summary.errorCount}`);
|
||||
}
|
||||
|
||||
function main() {
|
||||
try {
|
||||
const options = parseArgs(process.argv);
|
||||
if (options.help) {
|
||||
showHelp(0);
|
||||
}
|
||||
|
||||
const result = repairInstalledStates({
|
||||
repoRoot: require('path').join(__dirname, '..'),
|
||||
homeDir: process.env.HOME,
|
||||
projectRoot: process.cwd(),
|
||||
targets: options.targets,
|
||||
dryRun: options.dryRun,
|
||||
});
|
||||
const hasErrors = result.summary.errorCount > 0;
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
printHuman(result);
|
||||
}
|
||||
|
||||
process.exitCode = hasErrors ? 1 : 0;
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
77
scripts/session-inspect.js
Normal file
77
scripts/session-inspect.js
Normal file
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { inspectSessionTarget } = require('./lib/session-adapters/registry');
|
||||
|
||||
function usage() {
|
||||
console.log([
|
||||
'Usage:',
|
||||
' node scripts/session-inspect.js <target> [--adapter <id>] [--write <output.json>]',
|
||||
'',
|
||||
'Targets:',
|
||||
' <plan.json> Dmux/orchestration plan file',
|
||||
' <session-name> Dmux session name when the coordination directory exists',
|
||||
' claude:latest Most recent Claude session history entry',
|
||||
' claude:<id|alias> Specific Claude session or alias',
|
||||
' <session.tmp> Direct path to a Claude session file',
|
||||
'',
|
||||
'Examples:',
|
||||
' node scripts/session-inspect.js .claude/plan/workflow.json',
|
||||
' node scripts/session-inspect.js workflow-visual-proof',
|
||||
' node scripts/session-inspect.js claude:latest',
|
||||
' node scripts/session-inspect.js claude:a1b2c3d4 --write /tmp/session.json'
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = argv.slice(2);
|
||||
const target = args.find(argument => !argument.startsWith('--'));
|
||||
|
||||
const adapterIndex = args.indexOf('--adapter');
|
||||
const adapterId = adapterIndex >= 0 ? args[adapterIndex + 1] : null;
|
||||
|
||||
const writeIndex = args.indexOf('--write');
|
||||
const writePath = writeIndex >= 0 ? args[writeIndex + 1] : null;
|
||||
|
||||
return { target, adapterId, writePath };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { target, adapterId, writePath } = parseArgs(process.argv);
|
||||
|
||||
if (!target) {
|
||||
usage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const snapshot = inspectSessionTarget(target, {
|
||||
cwd: process.cwd(),
|
||||
adapterId
|
||||
});
|
||||
const payload = JSON.stringify(snapshot, null, 2);
|
||||
|
||||
if (writePath) {
|
||||
const absoluteWritePath = path.resolve(writePath);
|
||||
fs.mkdirSync(path.dirname(absoluteWritePath), { recursive: true });
|
||||
fs.writeFileSync(absoluteWritePath, payload + '\n', 'utf8');
|
||||
}
|
||||
|
||||
console.log(payload);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(`[session-inspect] ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
main,
|
||||
parseArgs
|
||||
};
|
||||
96
scripts/uninstall.js
Normal file
96
scripts/uninstall.js
Normal file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { uninstallInstalledStates } = require('./lib/install-lifecycle');
|
||||
const { SUPPORTED_INSTALL_TARGETS } = require('./lib/install-manifests');
|
||||
|
||||
function showHelp(exitCode = 0) {
|
||||
console.log(`
|
||||
Usage: node scripts/uninstall.js [--target <${SUPPORTED_INSTALL_TARGETS.join('|')}>] [--dry-run] [--json]
|
||||
|
||||
Remove ECC-managed files recorded in install-state for the current context.
|
||||
`);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = argv.slice(2);
|
||||
const parsed = {
|
||||
targets: [],
|
||||
dryRun: false,
|
||||
json: false,
|
||||
help: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
|
||||
if (arg === '--target') {
|
||||
parsed.targets.push(args[index + 1] || null);
|
||||
index += 1;
|
||||
} else if (arg === '--dry-run') {
|
||||
parsed.dryRun = true;
|
||||
} else if (arg === '--json') {
|
||||
parsed.json = true;
|
||||
} else if (arg === '--help' || arg === '-h') {
|
||||
parsed.help = true;
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function printHuman(result) {
|
||||
if (result.results.length === 0) {
|
||||
console.log('No ECC install-state files found for the current home/project context.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Uninstall summary:\n');
|
||||
for (const entry of result.results) {
|
||||
console.log(`- ${entry.adapter.id}`);
|
||||
console.log(` Status: ${entry.status.toUpperCase()}`);
|
||||
console.log(` Install-state: ${entry.installStatePath}`);
|
||||
|
||||
if (entry.error) {
|
||||
console.log(` Error: ${entry.error}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const paths = result.dryRun ? entry.plannedRemovals : entry.removedPaths;
|
||||
console.log(` ${result.dryRun ? 'Planned removals' : 'Removed paths'}: ${paths.length}`);
|
||||
}
|
||||
|
||||
console.log(`\nSummary: checked=${result.summary.checkedCount}, ${result.dryRun ? 'planned' : 'uninstalled'}=${result.dryRun ? result.summary.plannedRemovalCount : result.summary.uninstalledCount}, errors=${result.summary.errorCount}`);
|
||||
}
|
||||
|
||||
function main() {
|
||||
try {
|
||||
const options = parseArgs(process.argv);
|
||||
if (options.help) {
|
||||
showHelp(0);
|
||||
}
|
||||
|
||||
const result = uninstallInstalledStates({
|
||||
homeDir: process.env.HOME,
|
||||
projectRoot: process.cwd(),
|
||||
targets: options.targets,
|
||||
dryRun: options.dryRun,
|
||||
});
|
||||
const hasErrors = result.summary.errorCount > 0;
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
printHuman(result);
|
||||
}
|
||||
|
||||
process.exitCode = hasErrors ? 1 : 0;
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user