Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed
- **Sudo trust prompt** — Managed settings now shows a clear explanation, a copy-pasteable verification prompt, and an explicit fallback option before any password prompt

### Added
- **Managed settings test coverage** — Unit tests for `installManagedSettings` two-stage write logic

---

## [1.3.2] - 2026-03-08
Expand Down
112 changes: 111 additions & 1 deletion tests/init-logic.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import * as path from 'path';
import * as os from 'os';
Expand All @@ -18,6 +18,7 @@ import {
hasMemoryHooks,
} from '../src/cli/commands/init.js';
import { getManagedSettingsPath } from '../src/cli/utils/paths.js';
import { installManagedSettings } from '../src/cli/utils/post-install.js';
import { installViaFileCopy, type Spinner } from '../src/cli/utils/installer.js';
import { DEVFLOW_PLUGINS, buildAssetMaps } from '../src/cli/plugins.js';

Expand Down Expand Up @@ -353,6 +354,115 @@ describe('memory hook re-exports from init', () => {
});
});

describe('installManagedSettings', () => {
let tmpDir: string;
let managedDir: string;
let managedPath: string;
let templateDir: string;

const denyEntries = ['Bash(rm -rf /*)', 'Bash(sudo *)'];
const templateContent = JSON.stringify({ permissions: { deny: denyEntries } }, null, 2);

beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'devflow-managed-test-'));
managedDir = path.join(tmpDir, 'managed');
managedPath = path.join(managedDir, 'managed-settings.json');
templateDir = path.join(tmpDir, 'root');

// Create template file at expected location
await fs.mkdir(path.join(templateDir, 'src', 'templates'), { recursive: true });
await fs.writeFile(
path.join(templateDir, 'src', 'templates', 'managed-settings.json'),
templateContent,
'utf-8',
);
});

afterEach(async () => {
vi.restoreAllMocks();
await fs.rm(tmpDir, { recursive: true, force: true });
});

it('returns false when getManagedSettingsPath throws (unsupported platform)', async () => {
vi.spyOn(await import('../src/cli/utils/paths.js'), 'getManagedSettingsPath').mockImplementation(() => {
throw new Error('Unsupported platform');
});

const result = await installManagedSettings(templateDir, false);
expect(result).toBe(false);
});

it('returns false when template file cannot be read', async () => {
vi.spyOn(await import('../src/cli/utils/paths.js'), 'getManagedSettingsPath').mockReturnValue(managedPath);

// Use a rootDir with no template file
const emptyRoot = path.join(tmpDir, 'empty-root');
await fs.mkdir(emptyRoot, { recursive: true });

const result = await installManagedSettings(emptyRoot, true);
expect(result).toBe(false);
});

it('writes managed settings via direct write when directory is writable', async () => {
vi.spyOn(await import('../src/cli/utils/paths.js'), 'getManagedSettingsPath').mockReturnValue(managedPath);

const result = await installManagedSettings(templateDir, false);

expect(result).toBe(true);
const written = JSON.parse(await fs.readFile(managedPath, 'utf-8'));
expect(written.permissions.deny).toEqual(denyEntries);
});

it('merges with existing managed settings (preserves existing entries)', async () => {
vi.spyOn(await import('../src/cli/utils/paths.js'), 'getManagedSettingsPath').mockReturnValue(managedPath);

// Pre-populate existing managed settings with an extra entry
await fs.mkdir(managedDir, { recursive: true });
const existing = { permissions: { deny: ['Bash(eval *)'] } };
await fs.writeFile(managedPath, JSON.stringify(existing), 'utf-8');

const result = await installManagedSettings(templateDir, false);

expect(result).toBe(true);
const written = JSON.parse(await fs.readFile(managedPath, 'utf-8'));
// Should contain both the existing entry and new entries, deduplicated
expect(written.permissions.deny).toContain('Bash(eval *)');
expect(written.permissions.deny).toContain('Bash(rm -rf /*)');
expect(written.permissions.deny).toContain('Bash(sudo *)');
});

it('returns false on EACCES when not in TTY', async () => {
vi.spyOn(await import('../src/cli/utils/paths.js'), 'getManagedSettingsPath').mockReturnValue(managedPath);

// Make the parent dir exist but not writable
await fs.mkdir(managedDir, { recursive: true });
await fs.chmod(managedDir, 0o444);

// Mock process.stdin.isTTY as falsy
const origTTY = process.stdin.isTTY;
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });

try {
const result = await installManagedSettings(templateDir, false);
expect(result).toBe(false);
} finally {
Object.defineProperty(process.stdin, 'isTTY', { value: origTTY, configurable: true });
// Restore permissions for cleanup
await fs.chmod(managedDir, 0o755);
}
});

it('returns false on non-EACCES write errors', async () => {
vi.spyOn(await import('../src/cli/utils/paths.js'), 'getManagedSettingsPath').mockReturnValue(
// Point to a path inside a file (not a directory) to trigger ENOTDIR
path.join(templateDir, 'src', 'templates', 'managed-settings.json', 'impossible', 'managed-settings.json'),
);

const result = await installManagedSettings(templateDir, false);
expect(result).toBe(false);
});
});

describe('installViaFileCopy cleanup (isPartialInstall)', () => {
let tmpDir: string;
let claudeDir: string;
Expand Down