Files
sp80/.opencode/get-shit-done/bin/gsd-oc-commands/set-profile.cjs
2026-03-13 15:46:10 +08:00

358 lines
10 KiB
JavaScript

/**
* set-profile.cjs — Switch profile in oc_config.json with three operation modes
*
* Command module for managing OpenCode profiles using .planning/oc_config.json:
* 1. Mode 1 (no profile name): Validate and apply current profile
* 2. Mode 2 (profile name): Switch to specified profile
* 3. Mode 3 (inline JSON): Create new profile from definition
*
* Features:
* - Pre-flight validation BEFORE any file modifications
* - Atomic transaction with rollback on failure
* - Dry-run mode for previewing changes
* - Structured JSON output
*
* Usage:
* node set-profile.cjs # Mode 1: validate current
* node set-profile.cjs genius # Mode 2: switch to profile
* node set-profile.cjs 'custom:{...}' # Mode 3: create profile
* node set-profile.cjs --dry-run genius # Preview changes
*/
const fs = require('fs');
const path = require('path');
const { output, error, createBackup } = require('../gsd-oc-lib/oc-core.cjs');
const { applyProfileWithValidation } = require('../gsd-oc-lib/oc-profile-config.cjs');
const { getModelCatalog } = require('../gsd-oc-lib/oc-models.cjs');
const { applyProfileToOpencode } = require('../gsd-oc-lib/oc-config.cjs');
/**
* Error codes for set-profile operations
*/
const ERROR_CODES = {
CONFIG_NOT_FOUND: 'CONFIG_NOT_FOUND',
INVALID_JSON: 'INVALID_JSON',
INVALID_SYNTAX: 'INVALID_SYNTAX',
PROFILE_NOT_FOUND: 'PROFILE_NOT_FOUND',
PROFILE_EXISTS: 'PROFILE_EXISTS',
INVALID_MODELS: 'INVALID_MODELS',
INCOMPLETE_PROFILE: 'INCOMPLETE_PROFILE',
WRITE_FAILED: 'WRITE_FAILED',
APPLY_FAILED: 'APPLY_FAILED',
ROLLBACK_FAILED: 'ROLLBACK_FAILED',
MISSING_CURRENT_PROFILE: 'MISSING_CURRENT_PROFILE',
INVALID_ARGS: 'INVALID_ARGS'
};
/**
* Parse inline profile definition from argument
* Expected format: profileName:{"planning":"...", "execution":"...", "verification":"..."}
*
* @param {string} arg - Argument string
* @returns {Object|null} {name, profile} or null if invalid
*/
function parseInlineProfile(arg) {
const match = arg.match(/^([^:]+):(.+)$/);
if (!match) {
return null;
}
const [, profileName, profileJson] = match;
try {
const profile = JSON.parse(profileJson);
return { name: profileName, profile };
} catch (err) {
return null;
}
}
/**
* Validate inline profile definition has all required keys
*
* @param {Object} profile - Profile object to validate
* @returns {Object} {valid: boolean, missingKeys: string[]}
*/
function validateInlineProfile(profile) {
const requiredKeys = ['planning', 'execution', 'verification'];
const missingKeys = requiredKeys.filter(key => !profile[key]);
return {
valid: missingKeys.length === 0,
missingKeys
};
}
/**
* Validate models against whitelist
*
* @param {Object} profile - Profile with planning/execution/verification
* @param {string[]} validModels - Array of valid model IDs
* @returns {Object} {valid: boolean, invalidModels: string[]}
*/
function validateProfileModels(profile, validModels) {
const modelsToCheck = [profile.planning, profile.execution, profile.verification].filter(Boolean);
const invalidModels = modelsToCheck.filter(model => !validModels.includes(model));
return {
valid: invalidModels.length === 0,
invalidModels
};
}
/**
* Main command function
*
* @param {string} cwd - Current working directory
* @param {string[]} args - Command line arguments
*/
function setProfilePhase16(cwd, args) {
const verbose = args.includes('--verbose');
const dryRun = args.includes('--dry-run');
const raw = args.includes('--raw');
const log = verbose ? (...args) => console.error('[set-profile]', ...args) : () => {};
const configPath = path.join(cwd, '.planning', 'oc_config.json');
const opencodePath = path.join(cwd, 'opencode.json');
const backupsDir = path.join(cwd, '.planning', 'backups');
log('Starting set-profile command');
// Filter flags to get profile argument
const profileArgs = args.filter(arg => !arg.startsWith('--'));
// Check for too many arguments
if (profileArgs.length > 1) {
error('Too many arguments. Usage: set-profile [profile-name | profileName:JSON] [--dry-run]', 'INVALID_ARGS');
}
const profileArg = profileArgs.length > 0 ? profileArgs[0] : null;
// ========== MODE 3: Inline profile definition ==========
if (profileArg && profileArg.includes(':')) {
const parsed = parseInlineProfile(profileArg);
if (!parsed) {
error(
'Invalid profile syntax. Use: profileName:{"planning":"...", "execution":"...", "verification":"..."}',
'INVALID_SYNTAX'
);
}
const { name: profileName, profile } = parsed;
log(`Mode 3: Creating inline profile "${profileName}"`);
// Validate complete profile definition
const validation = validateInlineProfile(profile);
if (!validation.valid) {
error(
`Profile definition missing required keys: ${validation.missingKeys.join(', ')}`,
'INCOMPLETE_PROFILE'
);
}
// Get model catalog for validation
const catalogResult = getModelCatalog();
if (!catalogResult.success) {
error(catalogResult.error.message, catalogResult.error.code);
}
// Validate models against whitelist
const modelValidation = validateProfileModels(profile, catalogResult.models);
if (!modelValidation.valid) {
error(
`Invalid models: ${modelValidation.invalidModels.join(', ')}`,
'INVALID_MODELS'
);
}
log('Inline profile validation passed');
// Dry-run mode
if (dryRun) {
output({
success: true,
data: {
dryRun: true,
action: 'create_profile',
profile: profileName,
models: profile
}
});
process.exit(0);
}
// Load or create oc_config.json
let config = {};
if (fs.existsSync(configPath)) {
try {
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
} catch (err) {
error(`Failed to parse oc_config.json: ${err.message}`, 'INVALID_JSON');
}
}
// Create backup
if (!fs.existsSync(backupsDir)) {
fs.mkdirSync(backupsDir, { recursive: true });
}
const backupPath = createBackup(configPath, backupsDir);
// Initialize structure if needed
if (!config.profiles) config.profiles = {};
if (!config.profiles.presets) config.profiles.presets = {};
// Add profile and set as current
config.profiles.presets[profileName] = profile;
config.current_oc_profile = profileName;
// write oc_config.json
try {
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
log('Updated oc_config.json');
} catch (err) {
error(`Failed to write oc_config.json: ${err.message}`, 'WRITE_FAILED');
}
// Apply to opencode.json
const applyResult = applyProfileToOpencode(opencodePath, configPath, profileName);
if (!applyResult.success) {
// Rollback
try {
if (backupPath) {
fs.copyFileSync(backupPath, configPath);
}
} catch (rollbackErr) {
error(
`Failed to apply profile AND failed to rollback: ${rollbackErr.message}`,
'ROLLBACK_FAILED'
);
}
error(`Failed to apply profile to opencode.json: ${applyResult.error.message}`, 'APPLY_FAILED');
}
output({
success: true,
data: {
profile: profileName,
models: profile,
backup: backupPath,
configPath
}
});
process.exit(0);
}
// ========== MODE 1 & 2: Use applyProfileWithValidation ==========
// Load oc_config.json first to determine mode
let config;
if (fs.existsSync(configPath)) {
try {
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
} catch (err) {
error(`Failed to parse oc_config.json: ${err.message}`, 'INVALID_JSON');
}
} else {
error('.planning/oc_config.json not found. Create it with an inline profile definition first.', 'CONFIG_NOT_FOUND');
}
const presets = config.profiles?.presets || {};
const currentProfile = config.current_oc_profile;
// ========== MODE 2: Profile name provided ==========
if (profileArg) {
log(`Mode 2: Switching to profile "${profileArg}"`);
// Check profile exists
if (!presets[profileArg]) {
const available = Object.keys(presets).join(', ') || 'none';
error(`Profile "${profileArg}" not found. Available profiles: ${available}`, 'PROFILE_NOT_FOUND');
}
// Use applyProfileWithValidation for Mode 2
const result = applyProfileWithValidation(cwd, profileArg, { dryRun, verbose });
if (!result.success) {
error(result.error.message, result.error.code || 'UNKNOWN_ERROR');
}
if (result.dryRun) {
output({
success: true,
data: {
dryRun: true,
action: 'switch_profile',
profile: profileArg,
models: result.preview.models,
changes: result.preview.changes
}
});
} else {
output({
success: true,
data: {
profile: profileArg,
models: result.data.models,
backup: result.data.backup,
updated: result.data.updated,
configPath: result.data.configPath
}
});
}
process.exit(0);
}
// ========== MODE 1: No profile name - validate current profile ==========
log('Mode 1: Validating current profile');
if (!currentProfile) {
const available = Object.keys(presets).join(', ') || 'none';
error(
`current_oc_profile not set. Available profiles: ${available}`,
'MISSING_CURRENT_PROFILE'
);
}
if (!presets[currentProfile]) {
error(
`Current profile "${currentProfile}" not found in profiles.presets`,
'PROFILE_NOT_FOUND'
);
}
// Use applyProfileWithValidation for Mode 1
const result = applyProfileWithValidation(cwd, currentProfile, { dryRun, verbose });
if (!result.success) {
error(result.error.message, result.error.code || 'UNKNOWN_ERROR');
}
if (result.dryRun) {
output({
success: true,
data: {
dryRun: true,
action: 'validate_current',
profile: currentProfile,
models: result.preview.models,
changes: result.preview.changes
}
});
} else {
output({
success: true,
data: {
profile: currentProfile,
models: result.data.models,
backup: result.data.backup,
updated: result.data.updated,
configPath: result.data.configPath
}
});
}
process.exit(0);
}
module.exports = setProfilePhase16;