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
16 changes: 8 additions & 8 deletions src/cli/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import path from 'path';
import { walkVault } from '../storage/vault-walker';
import { parseFrontmatter } from '../storage/frontmatter';
import { loadConfig } from './config';
import { printEmptyVaultOnboarding, validateVaultPath } from './onboarding';

interface DashboardTopic {
id: string;
Expand All @@ -22,13 +23,7 @@ interface DashboardTopic {
async function dashboardCommand(): Promise<void> {
try {
const config = loadConfig();

if (!config.vaultPath) {
console.error('Error: Vault path not configured. Run: palee config set-vault <path>');
process.exit(2);
}

const vaultPath = config.vaultPath;
const vaultPath = validateVaultPath(config.vaultPath);
const files = walkVault(vaultPath);
const now = new Date();

Expand Down Expand Up @@ -61,6 +56,11 @@ async function dashboardCommand(): Promise<void> {
console.log('╚════════════════════════════════════════════════════════════╝');
console.log();

if (topics.length === 0) {
printEmptyVaultOnboarding();
return;
}

// Stats
const total = topics.length;
const mastered = topics.filter(t => t.mastery >= 0.7).length;
Expand Down Expand Up @@ -115,7 +115,7 @@ async function dashboardCommand(): Promise<void> {
console.log('Run "palee next" to start reviewing');
console.log('Run "palee plan" to see today\'s learning plan');

process.exit(0);
return;

} catch (e: unknown) {
const err = e as Error;
Expand Down
20 changes: 11 additions & 9 deletions src/cli/next.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { loadConfig } from './config';
import { printEmptyVaultOnboarding, validateVaultPath } from './onboarding';
/**
* Next Command Handler
* Shows next topic(s) due for review
Expand All @@ -22,22 +23,18 @@ interface DueTopic {
async function nextCommand(options: NextOptions): Promise<void> {
try {
const config = loadConfig();

if (!config.vaultPath) {
console.error('Error: Vault path not configured. Run: palee config set-vault <path>');
process.exit(2);
}

const vaultPath = config.vaultPath;
const vaultPath = validateVaultPath(config.vaultPath);
const files = walkVault(vaultPath);
const dueTopics: DueTopic[] = [];
let totalTopics = 0;
const now = new Date();

for (const filePath of files) {
const content = fs.readFileSync(filePath, 'utf8');
const { frontmatter } = parseFrontmatter(content);

if (!frontmatter || !frontmatter.palee_id) continue;
totalTopics++;

let dueAt = frontmatter.due_at ? new Date(frontmatter.due_at as string) : null;
if (dueAt && Number.isNaN(dueAt.getTime())) {
Expand All @@ -57,9 +54,14 @@ async function nextCommand(options: NextOptions): Promise<void> {
}
}

if (totalTopics === 0) {
printEmptyVaultOnboarding();
return;
}

if (dueTopics.length === 0) {
console.log('No topics due for review.');
process.exit(0);
return;
}

// Sort by due date (null first, then oldest)
Expand Down Expand Up @@ -97,7 +99,7 @@ async function nextCommand(options: NextOptions): Promise<void> {
console.log(` Path: ${next.path}`);
}

process.exit(0);
return;

} catch (e: unknown) {
const err = e as Error;
Expand Down
57 changes: 57 additions & 0 deletions src/cli/onboarding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Onboarding Utilities
* Shared guidance, validation, and empty-state messaging for PALEE CLI
*/

import fs from 'fs';
import path from 'path';

/**
* Validates that the vault path is configured, exists, is a directory, and is readable.
* Exits with code 2 on any configuration/access failure.
*/
export function validateVaultPath(vaultPath?: string): string {
if (!vaultPath) {
console.error('Error: Vault path not configured. Run: palee config set-vault <path>');
process.exit(2);
}

const resolved = path.resolve(vaultPath);
if (!fs.existsSync(resolved)) {
console.error(`Error: Vault path not found: ${resolved}`);
process.exit(2);
}

try {
const stat = fs.statSync(resolved);
if (!stat.isDirectory()) {
console.error(`Error: Vault path is not a directory: ${resolved}`);
process.exit(2);
}
} catch (err: unknown) {
const error = err as Error;
console.error(`Error: Cannot access vault path: ${error.message}`);
process.exit(2);
}

try {
fs.accessSync(resolved, fs.constants.R_OK);
} catch {
console.error(`Error: Vault path is not readable (permission denied): ${resolved}`);
process.exit(2);
}

return resolved;
}

/**
* Prints standard onboarding guidance when an empty vault contains 0 topics.
*/
export function printEmptyVaultOnboarding(): void {
console.log('No topics found in vault.\n');
console.log('To get started:');
console.log(' • Adopt an existing note:');
console.log(' palee adopt "path/to/note.md"\n');
console.log(' • Import a curriculum roadmap:');
console.log(' palee roadmap --from <file.yaml>\n');
}
20 changes: 10 additions & 10 deletions src/cli/plan.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { loadConfig } from './config';
import { printEmptyVaultOnboarding, validateVaultPath } from './onboarding';
/**
* Plan Command Handler
* Shows learning plan for the day
Expand All @@ -22,13 +23,7 @@ interface PlanTopic extends TopicNode {
async function planCommand(): Promise<void> {
try {
const config = loadConfig();

if (!config.vaultPath) {
console.error('Error: Vault path not configured. Run: palee config set-vault <path>');
process.exit(2);
}

const vaultPath = config.vaultPath;
const vaultPath = validateVaultPath(config.vaultPath);
const files = walkVault(vaultPath);
const topics = new Map<string, PlanTopic>();
const now = new Date();
Expand Down Expand Up @@ -64,11 +59,16 @@ async function planCommand(): Promise<void> {
}
}

console.log('=== Today\'s Learning Plan ===\n');

if (topics.size === 0) {
printEmptyVaultOnboarding();
return;
}

// Get ready to learn (deps satisfied, not mastered)
const readyTopics = getReadyTopics(topics, 0.7) as PlanTopic[];

console.log('=== Today\'s Learning Plan ===\n');

// Section 1: Due for review
console.log(`Reviews Due: ${dueTopics.length}`);
if (dueTopics.length > 0) {
Expand Down Expand Up @@ -123,7 +123,7 @@ async function planCommand(): Promise<void> {
console.log(` Learning: ${learningCount}`);
console.log(` New: ${newCount}`);

process.exit(0);
return;

} catch (e: unknown) {
const err = e as Error;
Expand Down
17 changes: 9 additions & 8 deletions src/cli/progress.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { loadConfig } from './config';
import { printEmptyVaultOnboarding, validateVaultPath } from './onboarding';
/**
* Progress Command Handler
* Shows learning progress summary
Expand All @@ -25,13 +26,7 @@ interface ProgressTopic {
async function progressCommand(options: ProgressOptions): Promise<void> {
try {
const config = loadConfig();

if (!config.vaultPath) {
console.error('Error: Vault path not configured. Run: palee config set-vault <path>');
process.exit(2);
}

const vaultPath = config.vaultPath;
const vaultPath = validateVaultPath(config.vaultPath);
const files = walkVault(vaultPath);
const topics: ProgressTopic[] = [];

Expand All @@ -54,6 +49,12 @@ async function progressCommand(options: ProgressOptions): Promise<void> {
});
}

if (topics.length === 0 && !options.topic) {
console.log('=== Learning Progress ===\n');
printEmptyVaultOnboarding();
return;
}

if (options.topic) {
const match = topics.find(t =>
t.id === options.topic || t.id.includes(options.topic!) ||
Expand Down Expand Up @@ -117,7 +118,7 @@ async function progressCommand(options: ProgressOptions): Promise<void> {
}
}

process.exit(0);
return;

} catch (e: unknown) {
const err = e as Error;
Expand Down
13 changes: 13 additions & 0 deletions src/storage/vault-walker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,19 @@ function walkVault(vaultPath: string, options: WalkOptions = {}): string[] {
const visited = new Set<string>();
const resolvedVaultPath = path.resolve(vaultPath);

if (!fs.existsSync(resolvedVaultPath)) {
throw new Error(`Vault path does not exist: ${resolvedVaultPath}`);
}
const rootStat = fs.statSync(resolvedVaultPath);
if (!rootStat.isDirectory()) {
throw new Error(`Vault path is not a directory: ${resolvedVaultPath}`);
}
try {
fs.accessSync(resolvedVaultPath, fs.constants.R_OK);
} catch {
throw new Error(`Vault path is not readable (permission denied): ${resolvedVaultPath}`);
}

function walk(dir: string): void {
let realDir = dir;
if (followSymlinks) {
Expand Down
51 changes: 42 additions & 9 deletions test/cli-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,18 +172,51 @@ topics:
assert.doesNotMatch(result.stdout, /NaN/);
});

test('dashboard command handles empty vault with 0.0% and no NaN', () => {
test('commands display onboarding guidance on empty vault', () => {
const emptyVault = path.join(tempDir, 'empty-vault');
fs.mkdirSync(emptyVault);
fs.mkdirSync(emptyVault, { recursive: true });
runCLI(['config', 'set-vault', emptyVault]);

const result = runCLI(['dashboard']);
assert.strictEqual(result.status, 0);
assert.match(result.stdout, /Total Topics:\s+0/);
assert.match(result.stdout, /Mastered \(≥70%\):\s+0 \(0\.0%\)/);
assert.match(result.stdout, /Learning:\s+0 \(0\.0%\)/);
assert.match(result.stdout, /New:\s+0 \(0\.0%\)/);
assert.doesNotMatch(result.stdout, /NaN/);
// 1. dashboard
const dashResult = runCLI(['dashboard']);
assert.strictEqual(dashResult.status, 0);
assert.match(dashResult.stdout, /No topics found in vault/);
assert.match(dashResult.stdout, /palee adopt/);
assert.match(dashResult.stdout, /palee roadmap --from/);

// 2. plan
const planResult = runCLI(['plan']);
assert.strictEqual(planResult.status, 0);
assert.match(planResult.stdout, /No topics found in vault/);
assert.match(planResult.stdout, /palee adopt/);

// 3. progress
const progResult = runCLI(['progress']);
assert.strictEqual(progResult.status, 0);
assert.match(progResult.stdout, /No topics found in vault/);
assert.match(progResult.stdout, /palee adopt/);

// 4. next
const nextResult = runCLI(['next']);
assert.strictEqual(nextResult.status, 0);
assert.match(nextResult.stdout, /No topics found in vault/);
assert.match(nextResult.stdout, /palee adopt/);

// Restore vaultDir
runCLI(['config', 'set-vault', vaultDir]);
});

test('commands exit with code 2 on non-existent vault path', () => {
const missingVault = path.join(tempDir, 'missing-vault');
fs.mkdirSync(missingVault, { recursive: true });
runCLI(['config', 'set-vault', missingVault]);
fs.rmSync(missingVault, { recursive: true, force: true });

for (const cmd of ['dashboard', 'plan', 'progress', 'next']) {
const result = runCLI([cmd]);
assert.strictEqual(result.status, 2, `${cmd} should exit with code 2 on missing vault`);
assert.match(result.stderr, /Vault path not found/);
}

// Restore vaultDir
runCLI(['config', 'set-vault', vaultDir]);
Expand Down
15 changes: 15 additions & 0 deletions test/storage-walker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,19 @@ describe('Vault Walker', () => {
assert.ok(path.isAbsolute(file));
});
});

test('throws error when root vault path does not exist', () => {
const nonExistent = path.join(testVaultPath, 'does-not-exist');
assert.throws(() => {
walkVault(nonExistent);
}, /Vault path does not exist/);
});

test('throws error when root vault path is a file', () => {
const filePath = path.join(testVaultPath, 'root.md');
assert.throws(() => {
walkVault(filePath);
}, /Vault path is not a directory/);
});
});

Loading