diff --git a/CHANGELOG.md b/CHANGELOG.md index a53bd86..9ba5790 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.20.4] - 2026-03-05 + +Overview: Added allow-read-config command to gsd-oc-tools.cjs for managing external_directory permissions. Introduced automated GSD config folder access configuration with comprehensive test coverage and workflow integration. + +### Added + +- allow-read-config.cjs command for adding external_directory permission to read GSD config folder in `gsd-opencode/get-shit-done/bin/gsd-oc-commands/allow-read-config.cjs` +- Comprehensive test suite for allow-read-config command with 5 tests covering permission creation, idempotency, dry-run mode, backup creation, and verbose output in `gsd-opencode/get-shit-done/bin/test/allow-read-config.test.cjs` +- GSD config read permission step in oc-set-profile.md workflow to ensure access to `~/.config/opencode/get-shit-done/` in `gsd-opencode/get-shit-done/workflows/oc-set-profile.md` + +### Changed + +- Updated gsd-oc-tools.cjs help text and command routing to include allow-read-config command in `gsd-opencode/get-shit-done/bin/gsd-oc-tools.cjs` +- Updated opencode.json with external_directory permission for `/Users/roki/.config/opencode/get-shit-done/**` in `opencode.json` + ## [1.20.3] - 2026-03-03 Overview: Major CLI tools release introducing gsd-oc-tools.cjs with comprehensive profile management, validation commands, and atomic transaction support. Added separate oc_config.json for profile configuration, pre-flight model validation, and vitest testing infrastructure. diff --git a/gsd-opencode/get-shit-done/bin/gsd-oc-commands/allow-read-config.cjs b/gsd-opencode/get-shit-done/bin/gsd-oc-commands/allow-read-config.cjs new file mode 100644 index 0000000..45c4bd5 --- /dev/null +++ b/gsd-opencode/get-shit-done/bin/gsd-oc-commands/allow-read-config.cjs @@ -0,0 +1,235 @@ +/** + * allow-read-config.cjs — Add external_directory permission to read GSD config folder + * + * Creates or updates local opencode.json with permission to access: + * ~/.config/opencode/get-shit-done/ + * + * This allows gsd-opencode commands to read workflow files, templates, and + * configuration from the global GSD installation directory. + * + * Usage: + * node allow-read-config.cjs # Add read permission + * node allow-read-config.cjs --dry-run # Preview changes + * node allow-read-config.cjs --verbose # Verbose output + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { output, error, createBackup } = require('../gsd-oc-lib/oc-core.cjs'); + +/** + * Error codes for allow-read-config operations + */ +const ERROR_CODES = { + WRITE_FAILED: 'WRITE_FAILED', + APPLY_FAILED: 'APPLY_FAILED', + ROLLBACK_FAILED: 'ROLLBACK_FAILED', + INVALID_ARGS: 'INVALID_ARGS' +}; + +/** + * Get the GSD config directory path + * Uses environment variable if set, otherwise defaults to ~/.config/opencode/get-shit-done + * + * @returns {string} GSD config directory path + */ +function getGsdConfigDir() { + const envDir = process.env.OPENCODE_CONFIG_DIR; + if (envDir) { + return envDir; + } + + const homeDir = os.homedir(); + return path.join(homeDir, '.config', 'opencode', 'get-shit-done'); +} + +/** + * Build the external_directory permission pattern + * + * @param {string} gsdDir - GSD config directory + * @returns {string} Permission pattern with wildcard + */ +function buildPermissionPattern(gsdDir) { + // Use ** for recursive matching (all subdirectories and files) + return `${gsdDir}/**`; +} + +/** + * Check if permission already exists in opencode.json + * + * @param {Object} opencodeData - Parsed opencode.json content + * @param {string} pattern - Permission pattern to check + * @returns {boolean} True if permission exists + */ +function permissionExists(opencodeData, pattern) { + const permissions = opencodeData.permission; + + if (!permissions) { + return false; + } + + const externalDirPerms = permissions.external_directory; + if (!externalDirPerms || typeof externalDirPerms !== 'object') { + return false; + } + + // Check if the pattern exists and is set to "allow" + return externalDirPerms[pattern] === 'allow'; +} + +/** + * Main command function + * + * @param {string} cwd - Current working directory + * @param {string[]} args - Command line arguments + */ +function allowReadConfig(cwd, args) { + const verbose = args.includes('--verbose'); + const dryRun = args.includes('--dry-run'); + const raw = args.includes('--raw'); + + const log = verbose ? (...args) => console.error('[allow-read-config]', ...args) : () => {}; + + const opencodePath = path.join(cwd, 'opencode.json'); + const backupsDir = path.join(cwd, '.planning', 'backups'); + const gsdConfigDir = getGsdConfigDir(); + const permissionPattern = buildPermissionPattern(gsdConfigDir); + + log('Starting allow-read-config command'); + log(`GSD config directory: ${gsdConfigDir}`); + log(`Permission pattern: ${permissionPattern}`); + + // Check for invalid arguments + const validFlags = ['--verbose', '--dry-run', '--raw']; + const invalidArgs = args.filter(arg => + arg.startsWith('--') && !validFlags.includes(arg) + ); + + if (invalidArgs.length > 0) { + error(`Unknown arguments: ${invalidArgs.join(', ')}`, 'INVALID_ARGS'); + } + + // Load or create opencode.json + let opencodeData; + let fileExisted = false; + + if (fs.existsSync(opencodePath)) { + try { + const content = fs.readFileSync(opencodePath, 'utf8'); + opencodeData = JSON.parse(content); + fileExisted = true; + log('Loaded existing opencode.json'); + } catch (err) { + error(`Failed to parse opencode.json: ${err.message}`, 'INVALID_JSON'); + } + } else { + // Create initial opencode.json structure + opencodeData = { + "$schema": "https://opencode.ai/config.json" + }; + log('Creating new opencode.json'); + } + + // Check if permission already exists + const exists = permissionExists(opencodeData, permissionPattern); + + if (exists) { + log('Permission already exists'); + output({ + success: true, + data: { + dryRun: dryRun, + action: 'permission_exists', + pattern: permissionPattern, + message: 'Permission already configured' + } + }); + process.exit(0); + } + + // Dry-run mode - preview changes + if (dryRun) { + log('Dry-run mode - no changes will be made'); + + const changes = []; + if (!fileExisted) { + changes.push('Create opencode.json'); + } + changes.push(`Add external_directory permission: ${permissionPattern}`); + + output({ + success: true, + data: { + dryRun: true, + action: 'add_permission', + pattern: permissionPattern, + gsdConfigDir: gsdConfigDir, + changes: changes, + message: fileExisted ? 'Would update opencode.json' : 'Would create opencode.json' + } + }); + process.exit(0); + } + + // Create backup if file exists + let backupPath = null; + if (fileExisted) { + // Ensure backup directory exists + if (!fs.existsSync(backupsDir)) { + fs.mkdirSync(backupsDir, { recursive: true }); + } + + backupPath = createBackup(opencodePath, backupsDir); + log(`Backup created: ${backupPath}`); + } + + // Initialize permission structure if needed + if (!opencodeData.permission) { + opencodeData.permission = {}; + } + + if (!opencodeData.permission.external_directory) { + opencodeData.permission.external_directory = {}; + } + + // Add the permission + opencodeData.permission.external_directory[permissionPattern] = 'allow'; + + log('Permission added to opencode.json'); + + // Write updated opencode.json + try { + fs.writeFileSync(opencodePath, JSON.stringify(opencodeData, null, 2) + '\n', 'utf8'); + log('Updated opencode.json'); + } catch (err) { + // Rollback if backup exists + if (backupPath) { + try { + fs.copyFileSync(backupPath, opencodePath); + } catch (rollbackErr) { + error( + `Failed to write opencode.json AND failed to rollback: ${rollbackErr.message}`, + 'ROLLBACK_FAILED' + ); + } + } + error(`Failed to write opencode.json: ${err.message}`, 'WRITE_FAILED'); + } + + output({ + success: true, + data: { + action: 'add_permission', + pattern: permissionPattern, + gsdConfigDir: gsdConfigDir, + opencodePath: opencodePath, + backup: backupPath, + created: !fileExisted, + message: fileExisted ? 'opencode.json updated' : 'opencode.json created' + } + }); + process.exit(0); +} + +module.exports = allowReadConfig; diff --git a/gsd-opencode/get-shit-done/bin/gsd-oc-tools.cjs b/gsd-opencode/get-shit-done/bin/gsd-oc-tools.cjs index 0afbceb..8c45724 100755 --- a/gsd-opencode/get-shit-done/bin/gsd-oc-tools.cjs +++ b/gsd-opencode/get-shit-done/bin/gsd-oc-tools.cjs @@ -16,6 +16,7 @@ * validate-models Validate model IDs against opencode catalog * set-profile Switch profile with interactive model selection * get-profile Get current profile or specific profile from oc_config.json + * allow-read-config Add external_directory permission to read GSD config folder * help Show this help message */ @@ -50,12 +51,13 @@ Available Commands: validate-models Validate one or more model IDs against opencode catalog set-profile Switch profile with interactive model selection wizard get-profile Get current profile or specific profile from oc_config.json + allow-read-config Add external_directory permission to read ~/.config/opencode/get-shit-done/** help Show this help message Options: --verbose Enable verbose output (stderr) - --raw Output raw values instead of JSON envelope - --dry-run Preview changes without applying (update-opencode-json) + --raw Output raw value instead of JSON envelope + --dry-run Preview changes without applying (update-opencode-json, allow-read-config) Examples: node gsd-oc-tools.cjs check-opencode-json @@ -66,6 +68,8 @@ Examples: node gsd-oc-tools.cjs get-profile node gsd-oc-tools.cjs get-profile genius node gsd-oc-tools.cjs get-profile --raw + node gsd-oc-tools.cjs allow-read-config + node gsd-oc-tools.cjs allow-read-config --dry-run `.trim(); console.log(helpText); @@ -121,9 +125,11 @@ switch (command) { break; } - - - + case 'allow-read-config': { + const allowReadConfig = require('./gsd-oc-commands/allow-read-config.cjs'); + allowReadConfig(cwd, flags); + break; + } default: error(`Unknown command: ${command}\nRun 'node gsd-oc-tools.cjs help' for available commands.`); diff --git a/gsd-opencode/get-shit-done/bin/test/allow-read-config.test.cjs b/gsd-opencode/get-shit-done/bin/test/allow-read-config.test.cjs new file mode 100644 index 0000000..76427e0 --- /dev/null +++ b/gsd-opencode/get-shit-done/bin/test/allow-read-config.test.cjs @@ -0,0 +1,262 @@ +/** + * allow-read-config.test.cjs — Tests for allow-read-config command + * + * Tests the allow-read-config command functionality: + * - Permission creation + * - Idempotency (detecting existing permission) + * - Dry-run mode + * - Backup creation + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { execSync } = require('child_process'); + +const CLI_PATH = path.join(__dirname, '../gsd-oc-commands/allow-read-config.cjs'); +const TOOLS_PATH = path.join(__dirname, '../gsd-oc-tools.cjs'); + +/** + * Create a temporary test directory + */ +function createTestDir() { + const testDir = path.join(os.tmpdir(), `gsd-oc-test-${Date.now()}`); + fs.mkdirSync(testDir, { recursive: true }); + return testDir; +} + +/** + * Clean up test directory + */ +function cleanupTestDir(testDir) { + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } +} + +/** + * Run CLI command and parse JSON output + */ +function runCLI(testDir, args) { + const cmd = `node ${TOOLS_PATH} allow-read-config ${args.join(' ')}`; + const output = execSync(cmd, { cwd: testDir, encoding: 'utf8' }); + return JSON.parse(output); +} + +/** + * Test: Create new opencode.json with permission + */ +function testCreatePermission() { + console.log('Test: Create new opencode.json with permission...'); + + const testDir = createTestDir(); + + try { + const result = runCLI(testDir, []); + + if (!result.success) { + throw new Error(`Expected success, got: ${JSON.stringify(result)}`); + } + + if (result.data.action !== 'add_permission') { + throw new Error(`Expected action 'add_permission', got: ${result.data.action}`); + } + + if (result.data.created !== true) { + throw new Error(`Expected created=true, got: ${result.data.created}`); + } + + // Verify opencode.json was created + const opencodePath = path.join(testDir, 'opencode.json'); + if (!fs.existsSync(opencodePath)) { + throw new Error('opencode.json was not created'); + } + + const content = JSON.parse(fs.readFileSync(opencodePath, 'utf8')); + if (!content.permission?.external_directory) { + throw new Error('Permission not added to opencode.json'); + } + + console.log('✓ PASS: Create permission\n'); + return true; + } catch (err) { + console.error('✗ FAIL:', err.message, '\n'); + return false; + } finally { + cleanupTestDir(testDir); + } +} + +/** + * Test: Idempotency - detect existing permission + */ +function testIdempotency() { + console.log('Test: Idempotency (detect existing permission)...'); + + const testDir = createTestDir(); + + try { + // First call - create permission + runCLI(testDir, []); + + // Second call - should detect existing + const result = runCLI(testDir, []); + + if (!result.success) { + throw new Error(`Expected success, got: ${JSON.stringify(result)}`); + } + + if (result.data.action !== 'permission_exists') { + throw new Error(`Expected action 'permission_exists', got: ${result.data.action}`); + } + + console.log('✓ PASS: Idempotency\n'); + return true; + } catch (err) { + console.error('✗ FAIL:', err.message, '\n'); + return false; + } finally { + cleanupTestDir(testDir); + } +} + +/** + * Test: Dry-run mode + */ +function testDryRun() { + console.log('Test: Dry-run mode...'); + + const testDir = createTestDir(); + + try { + const result = runCLI(testDir, ['--dry-run']); + + if (!result.success) { + throw new Error(`Expected success, got: ${JSON.stringify(result)}`); + } + + if (result.data.dryRun !== true) { + throw new Error(`Expected dryRun=true, got: ${result.data.dryRun}`); + } + + // Verify opencode.json was NOT created + const opencodePath = path.join(testDir, 'opencode.json'); + if (fs.existsSync(opencodePath)) { + throw new Error('opencode.json should not be created in dry-run mode'); + } + + console.log('✓ PASS: Dry-run mode\n'); + return true; + } catch (err) { + console.error('✗ FAIL:', err.message, '\n'); + return false; + } finally { + cleanupTestDir(testDir); + } +} + +/** + * Test: Backup creation on update + */ +function testBackupCreation() { + console.log('Test: Backup creation on update...'); + + const testDir = createTestDir(); + + try { + // Create initial opencode.json + const opencodePath = path.join(testDir, 'opencode.json'); + const initialContent = { + "$schema": "https://opencode.ai/config.json", + "model": "test/model" + }; + fs.writeFileSync(opencodePath, JSON.stringify(initialContent, null, 2) + '\n'); + + // Run allow-read-config + const result = runCLI(testDir, []); + + if (!result.success) { + throw new Error(`Expected success, got: ${JSON.stringify(result)}`); + } + + if (!result.data.backup) { + throw new Error('Expected backup path, got none'); + } + + if (!fs.existsSync(result.data.backup)) { + throw new Error(`Backup file does not exist: ${result.data.backup}`); + } + + // Verify backup content matches original + const backupContent = JSON.parse(fs.readFileSync(result.data.backup, 'utf8')); + if (JSON.stringify(backupContent) !== JSON.stringify(initialContent)) { + throw new Error('Backup content does not match original'); + } + + console.log('✓ PASS: Backup creation\n'); + return true; + } catch (err) { + console.error('✗ FAIL:', err.message, '\n'); + return false; + } finally { + cleanupTestDir(testDir); + } +} + +/** + * Test: Verbose output + */ +function testVerbose() { + console.log('Test: Verbose output...'); + + const testDir = createTestDir(); + + try { + const cmd = `node ${TOOLS_PATH} allow-read-config --verbose`; + const output = execSync(cmd, { cwd: testDir, encoding: 'utf8', stdio: 'pipe' }); + + // Verbose output should contain log messages to stderr + // We just verify it doesn't crash + console.log('✓ PASS: Verbose output\n'); + return true; + } catch (err) { + console.error('✗ FAIL:', err.message, '\n'); + return false; + } finally { + cleanupTestDir(testDir); + } +} + +/** + * Run all tests + */ +function runTests() { + console.log('Running allow-read-config tests...\n'); + console.log('=' .repeat(50)); + console.log(); + + const results = [ + testCreatePermission(), + testIdempotency(), + testDryRun(), + testBackupCreation(), + testVerbose() + ]; + + const passed = results.filter(r => r).length; + const total = results.length; + + console.log('=' .repeat(50)); + console.log(`Results: ${passed}/${total} tests passed`); + + if (passed === total) { + console.log('✓ All tests passed!\n'); + process.exit(0); + } else { + console.error(`✗ ${total - passed} test(s) failed\n`); + process.exit(1); + } +} + +// Run tests +runTests(); diff --git a/gsd-opencode/get-shit-done/workflows/oc-set-profile.md b/gsd-opencode/get-shit-done/workflows/oc-set-profile.md index 0ce9e39..d241dd4 100644 --- a/gsd-opencode/get-shit-done/workflows/oc-set-profile.md +++ b/gsd-opencode/get-shit-done/workflows/oc-set-profile.md @@ -5,6 +5,7 @@ You are executing the `/gsd-set-profile` command. Switch the project's active mo This command reads/writes: - `.planning/oc_config.json` — source of truth for profile state (profile_type, stage-to-model mapping) - `opencode.json` — agent model assignments (derived from profile; updated automatically by CLI) +- `opencode.json` — external_directory permissions for reading GSD config folder (added automatically) Do NOT modify agent .md files. Profile switching only updates these two JSON files. @@ -48,6 +49,27 @@ Active profile: **{profile_name}** +## Step 0: Ensure GSD config read permission + +Before any profile operations, ensure opencode.json has permission to read the GSD config folder: + +```bash +node ~/.config/opencode/get-shit-done/bin/gsd-oc-tools.cjs allow-read-config --dry-run +``` + +Parse the response: +- **`success: true` with `action: "permission_exists"`** — Permission already configured. Continue to Step 1. +- **`success: true` with `action: "add_permission"`** — Permission would be added. Execute without `--dry-run`: + +Attempt to switch to the saved profile: +```bash +node ~/.config/opencode/get-shit-done/bin/gsd-oc-tools.cjs allow-read-config +``` + +- **`success: false`** — Handle error appropriately. + +This ensures gsd-opencode can access workflow files, templates, and configuration from `~/.config/opencode/get-shit-done/`. + ## Step 1: Load current profile Run `get-profile` to read the current state from `.planning/oc_config.json`: @@ -148,6 +170,8 @@ Done! Updated {profile_name} profile: We just updated the `./opencode.json` file. Apply the agent settings you need to **restart your opencode**. +Note: GSD config read permission has been configured to allow access to `~/.config/opencode/get-shit-done/`. + diff --git a/gsd-opencode/package.json b/gsd-opencode/package.json index 5e70cc7..f4ef548 100644 --- a/gsd-opencode/package.json +++ b/gsd-opencode/package.json @@ -1,6 +1,6 @@ { "name": "gsd-opencode", - "version": "1.10.0", + "version": "1.20.4", "description": "GSD-OpenCode distribution manager - install, verify, and maintain your GSD-OpenCode installation", "type": "module", "main": "bin/gsd.js", diff --git a/opencode.json b/opencode.json index 8c93d3c..442bb50 100644 --- a/opencode.json +++ b/opencode.json @@ -34,5 +34,10 @@ "gsd-integration-checker": { "model": "bailian-coding-plan/MiniMax-M2.5" } + }, + "permission": { + "external_directory": { + "~/.config/opencode/get-shit-done/**": "allow" + } } }