Merge pull request #21 from cognovis/feat/linux-support

feat: add Linux/macOS support for pathing, registry, and project scope
This commit is contained in:
zenchantlive 2026-03-24 19:01:16 -05:00 committed by GitHub
commit 23e15bf61b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 326 additions and 177 deletions

View file

@ -1,5 +1,8 @@
import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
const IS_WINDOWS = os.platform() === 'win32';
function normalizeDriveLetter(input: string): string { function normalizeDriveLetter(input: string): string {
if (/^[a-z]:/.test(input)) { if (/^[a-z]:/.test(input)) {
return `${input[0].toUpperCase()}${input.slice(1)}`; return `${input[0].toUpperCase()}${input.slice(1)}`;
@ -9,7 +12,11 @@ function normalizeDriveLetter(input: string): string {
} }
function trimTrailingSeparator(input: string): string { function trimTrailingSeparator(input: string): string {
if (/^[A-Za-z]:\\$/.test(input)) { if (IS_WINDOWS && /^[A-Za-z]:\\$/.test(input)) {
return input;
}
if (!IS_WINDOWS && input === '/') {
return input; return input;
} }
@ -17,6 +24,10 @@ function trimTrailingSeparator(input: string): string {
} }
export function canonicalizeWindowsPath(input: string): string { export function canonicalizeWindowsPath(input: string): string {
if (!IS_WINDOWS) {
return trimTrailingSeparator(path.resolve(input));
}
const withBackslashes = input.replaceAll('/', '\\'); const withBackslashes = input.replaceAll('/', '\\');
const normalized = path.win32.normalize(withBackslashes); const normalized = path.win32.normalize(withBackslashes);
const withDriveCase = normalizeDriveLetter(normalized); const withDriveCase = normalizeDriveLetter(normalized);
@ -24,10 +35,18 @@ export function canonicalizeWindowsPath(input: string): string {
} }
export function windowsPathKey(input: string): string { export function windowsPathKey(input: string): string {
if (!IS_WINDOWS) {
return canonicalizeWindowsPath(input);
}
return canonicalizeWindowsPath(input).toLowerCase(); return canonicalizeWindowsPath(input).toLowerCase();
} }
export function toDisplayPath(input: string): string { export function toDisplayPath(input: string): string {
if (!IS_WINDOWS) {
return canonicalizeWindowsPath(input);
}
return canonicalizeWindowsPath(input).replaceAll('\\', '/'); return canonicalizeWindowsPath(input).replaceAll('\\', '/');
} }

View file

@ -29,17 +29,17 @@ export function registryFilePath(): string {
return path.join(userProfileRoot(), '.beadboard', 'projects.json'); return path.join(userProfileRoot(), '.beadboard', 'projects.json');
} }
function ensureWindowsAbsolutePath(input: string): string { function ensureAbsolutePath(input: string): string {
const normalized = canonicalizeWindowsPath(input.trim()); const trimmed = input.trim();
if (!/^[A-Za-z]:\\/.test(normalized)) { if (!path.isAbsolute(trimmed)) {
throw new RegistryValidationError('Project path must be a Windows absolute path (e.g. C:\\Repos\\Project).'); throw new RegistryValidationError(`Project path must be absolute: ${input}`);
} }
return normalized; return canonicalizeWindowsPath(trimmed);
} }
function normalizeProject(input: string): RegistryProject { function normalizeProject(input: string): RegistryProject {
const normalized = ensureWindowsAbsolutePath(input); const normalized = ensureAbsolutePath(input);
return { return {
path: toDisplayPath(normalized), path: toDisplayPath(normalized),
key: windowsPathKey(normalized), key: windowsPathKey(normalized),

View file

@ -1,5 +1,6 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import os from 'node:os';
import { import {
canonicalizeWindowsPath, canonicalizeWindowsPath,
@ -8,23 +9,58 @@ import {
sameWindowsPath, sameWindowsPath,
} from '../../src/lib/pathing'; } from '../../src/lib/pathing';
test('canonicalizeWindowsPath normalizes separators and drive casing', () => { const IS_WINDOWS = os.platform() === 'win32';
const input = 'c:/Users/test/project/beadboard/';
const result = canonicalizeWindowsPath(input);
assert.equal(result, 'C:\\Users\\test\\project\\beadboard');
});
test('windowsPathKey is case-insensitive stable key', () => { if (IS_WINDOWS) {
const a = windowsPathKey('C:/Users/test/project/beadboard'); test('canonicalizeWindowsPath normalizes separators and drive casing', () => {
const b = windowsPathKey('c:\\users\\test\\project\\beadboard\\'); const input = 'c:/Users/test/project/beadboard/';
assert.equal(a, b); const result = canonicalizeWindowsPath(input);
}); assert.equal(result, 'C:\\Users\\test\\project\\beadboard');
});
test('toDisplayPath renders forward slashes for UI readability', () => { test('windowsPathKey is case-insensitive stable key', () => {
const display = toDisplayPath('C:\\Users\\test\\project\\beadboard'); const a = windowsPathKey('C:/Users/test/project/beadboard');
assert.equal(display, 'C:/Users/test/project/beadboard'); const b = windowsPathKey('c:\\users\\test\\project\\beadboard\\');
}); assert.equal(a, b);
});
test('sameWindowsPath handles case/separator differences', () => { test('toDisplayPath renders forward slashes for UI readability', () => {
assert.equal(sameWindowsPath('D:/Repos/One', 'd:\\repos\\one\\'), true); const display = toDisplayPath('C:\\Users\\test\\project\\beadboard');
}); assert.equal(display, 'C:/Users/test/project/beadboard');
});
test('sameWindowsPath handles case/separator differences', () => {
assert.equal(sameWindowsPath('D:/Repos/One', 'd:\\repos\\one\\'), true);
});
} else {
test('canonicalizeWindowsPath resolves to absolute path on POSIX', () => {
const result = canonicalizeWindowsPath('/tmp/project/beadboard');
assert.equal(result, '/tmp/project/beadboard');
});
test('canonicalizeWindowsPath strips trailing slash on POSIX', () => {
const result = canonicalizeWindowsPath('/tmp/project/');
assert.equal(result, '/tmp/project');
});
test('canonicalizeWindowsPath preserves root slash', () => {
const result = canonicalizeWindowsPath('/');
assert.equal(result, '/');
});
test('windowsPathKey preserves case on POSIX (case-sensitive FS)', () => {
const a = windowsPathKey('/tmp/Project');
const b = windowsPathKey('/tmp/project');
assert.notEqual(a, b);
});
test('toDisplayPath returns resolved path on POSIX', () => {
const display = toDisplayPath('/opt/beadboard-projects/mira');
assert.equal(display, '/opt/beadboard-projects/mira');
});
test('sameWindowsPath matches identical POSIX paths', () => {
assert.equal(sameWindowsPath('/tmp/one', '/tmp/one'), true);
assert.equal(sameWindowsPath('/tmp/one', '/tmp/two'), false);
});
}

View file

@ -1,87 +1,148 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import os from 'node:os';
import test from 'node:test'; import test from 'node:test';
import { resolveProjectScope, type ProjectScopeRegistryEntry } from '../../src/lib/project-scope'; import { resolveProjectScope, type ProjectScopeRegistryEntry } from '../../src/lib/project-scope';
const REGISTRY: ProjectScopeRegistryEntry[] = [ const IS_WINDOWS = os.platform() === 'win32';
{ path: 'D:/Repos/Alpha' },
{ path: 'D:/Repos/Beta' },
];
test('resolveProjectScope defaults to local when query key is missing', () => { if (IS_WINDOWS) {
const scope = resolveProjectScope({ const REGISTRY: ProjectScopeRegistryEntry[] = [
currentProjectRoot: 'C:/Users/test/project/beadboard', { path: 'D:/Repos/Alpha' },
registryProjects: REGISTRY, { path: 'D:/Repos/Beta' },
];
test('resolveProjectScope defaults to local when query key is missing', () => {
const scope = resolveProjectScope({
currentProjectRoot: 'C:/Users/test/project/beadboard',
registryProjects: REGISTRY,
});
assert.equal(scope.mode, 'single');
assert.equal(scope.selected.source, 'local');
assert.equal(scope.selected.root, 'C:\\Users\\test\\project\\beadboard');
assert.equal(scope.selected.key, 'local');
assert.deepEqual(scope.readRoots, ['C:\\Users\\test\\project\\beadboard']);
assert.equal(scope.options[0].key, 'local');
assert.equal(scope.options.length, 3);
}); });
assert.equal(scope.mode, 'single'); test('resolveProjectScope selects registry project when key matches', () => {
assert.equal(scope.selected.source, 'local'); const scope = resolveProjectScope({
assert.equal(scope.selected.root, 'C:\\Users\\test\\project\\beadboard'); currentProjectRoot: 'C:/Users/test/project/beadboard',
assert.equal(scope.selected.key, 'local'); registryProjects: REGISTRY,
assert.deepEqual(scope.readRoots, ['C:\\Users\\test\\project\\beadboard']); requestedProjectKey: 'd:\\repos\\beta',
assert.equal(scope.options[0].key, 'local'); });
assert.equal(scope.options.length, 3);
});
test('resolveProjectScope selects registry project when key matches', () => { assert.equal(scope.selected.source, 'registry');
const scope = resolveProjectScope({ assert.equal(scope.selected.root, 'D:\\Repos\\Beta');
currentProjectRoot: 'C:/Users/test/project/beadboard', assert.equal(scope.selected.key, 'd:\\repos\\beta');
registryProjects: REGISTRY, assert.deepEqual(scope.readRoots, ['D:\\Repos\\Beta']);
requestedProjectKey: 'd:\\repos\\beta',
}); });
assert.equal(scope.selected.source, 'registry'); test('resolveProjectScope deduplicates registry entries by normalized key', () => {
assert.equal(scope.selected.root, 'D:\\Repos\\Beta'); const scope = resolveProjectScope({
assert.equal(scope.selected.key, 'd:\\repos\\beta'); currentProjectRoot: 'C:/Users/test/project/beadboard',
assert.deepEqual(scope.readRoots, ['D:\\Repos\\Beta']); registryProjects: [{ path: 'D:/Repos/Alpha/' }, { path: 'd:\\repos\\alpha' }],
}); });
assert.equal(scope.options.length, 2);
assert.equal(scope.options.filter((option) => option.source === 'registry').length, 1);
});
test('resolveProjectScope supports aggregate mode and reads all roots', () => {
const scope = resolveProjectScope({
currentProjectRoot: 'C:/Users/test/project/beadboard',
registryProjects: REGISTRY,
requestedProjectKey: 'd:\\repos\\alpha',
requestedMode: 'aggregate',
});
assert.equal(scope.mode, 'aggregate');
assert.equal(scope.selected.key, 'd:\\repos\\alpha');
assert.deepEqual(scope.readRoots, [
'C:\\Users\\test\\project\\beadboard',
'D:\\Repos\\Alpha',
'D:\\Repos\\Beta',
]);
});
} else {
const REGISTRY: ProjectScopeRegistryEntry[] = [
{ path: '/opt/repos/alpha' },
{ path: '/opt/repos/beta' },
];
test('resolveProjectScope defaults to local when query key is missing', () => {
const scope = resolveProjectScope({
currentProjectRoot: '/opt/beadboard',
registryProjects: REGISTRY,
});
assert.equal(scope.mode, 'single');
assert.equal(scope.selected.source, 'local');
assert.equal(scope.selected.root, '/opt/beadboard');
assert.equal(scope.selected.key, 'local');
assert.deepEqual(scope.readRoots, ['/opt/beadboard']);
assert.equal(scope.options[0].key, 'local');
assert.equal(scope.options.length, 3);
});
test('resolveProjectScope selects registry project when key matches', () => {
const scope = resolveProjectScope({
currentProjectRoot: '/opt/beadboard',
registryProjects: REGISTRY,
requestedProjectKey: '/opt/repos/beta',
});
assert.equal(scope.selected.source, 'registry');
assert.equal(scope.selected.root, '/opt/repos/beta');
assert.deepEqual(scope.readRoots, ['/opt/repos/beta']);
});
test('resolveProjectScope deduplicates registry entries', () => {
const scope = resolveProjectScope({
currentProjectRoot: '/opt/beadboard',
registryProjects: [{ path: '/opt/repos/alpha/' }, { path: '/opt/repos/alpha' }],
});
assert.equal(scope.options.length, 2);
assert.equal(scope.options.filter((option) => option.source === 'registry').length, 1);
});
test('resolveProjectScope supports aggregate mode and reads all roots', () => {
const scope = resolveProjectScope({
currentProjectRoot: '/opt/beadboard',
registryProjects: REGISTRY,
requestedProjectKey: '/opt/repos/alpha',
requestedMode: 'aggregate',
});
assert.equal(scope.mode, 'aggregate');
assert.deepEqual(scope.readRoots, [
'/opt/beadboard',
'/opt/repos/alpha',
'/opt/repos/beta',
]);
});
}
test('resolveProjectScope falls back to local when query key is unknown', () => { test('resolveProjectScope falls back to local when query key is unknown', () => {
const scope = resolveProjectScope({ const scope = resolveProjectScope({
currentProjectRoot: 'C:/Users/test/project/beadboard', currentProjectRoot: IS_WINDOWS ? 'C:/Users/test/project' : '/opt/beadboard',
registryProjects: REGISTRY, registryProjects: [],
requestedProjectKey: 'd:\\repos\\missing', requestedProjectKey: '/nonexistent',
}); });
assert.equal(scope.selected.source, 'local'); assert.equal(scope.selected.source, 'local');
assert.equal(scope.selected.key, 'local'); assert.equal(scope.selected.key, 'local');
assert.deepEqual(scope.readRoots, ['C:\\Users\\test\\project\\beadboard']);
});
test('resolveProjectScope deduplicates registry entries by normalized key', () => {
const scope = resolveProjectScope({
currentProjectRoot: 'C:/Users/test/project/beadboard',
registryProjects: [{ path: 'D:/Repos/Alpha/' }, { path: 'd:\\repos\\alpha' }],
});
assert.equal(scope.options.length, 2);
assert.equal(scope.options.filter((option) => option.source === 'registry').length, 1);
});
test('resolveProjectScope supports aggregate mode and reads all roots', () => {
const scope = resolveProjectScope({
currentProjectRoot: 'C:/Users/test/project/beadboard',
registryProjects: REGISTRY,
requestedProjectKey: 'd:\\repos\\alpha',
requestedMode: 'aggregate',
});
assert.equal(scope.mode, 'aggregate');
assert.equal(scope.selected.key, 'd:\\repos\\alpha');
assert.deepEqual(scope.readRoots, [
'C:\\Users\\test\\project\\beadboard',
'D:\\Repos\\Alpha',
'D:\\Repos\\Beta',
]);
}); });
test('resolveProjectScope falls back to single mode for unknown mode values', () => { test('resolveProjectScope falls back to single mode for unknown mode values', () => {
const scope = resolveProjectScope({ const scope = resolveProjectScope({
currentProjectRoot: 'C:/Users/test/project/beadboard', currentProjectRoot: IS_WINDOWS ? 'C:/Users/test/project' : '/opt/beadboard',
registryProjects: REGISTRY, registryProjects: [],
requestedMode: 'invalid-mode', requestedMode: 'invalid-mode',
}); });
assert.equal(scope.mode, 'single'); assert.equal(scope.mode, 'single');
assert.deepEqual(scope.readRoots, ['C:\\Users\\test\\project\\beadboard']);
}); });

View file

@ -12,6 +12,8 @@ import {
type RegistryProject, type RegistryProject,
} from '../../src/lib/registry'; } from '../../src/lib/registry';
const IS_WINDOWS = os.platform() === 'win32';
async function withTempUserProfile(run: (userProfile: string) => Promise<void>): Promise<void> { async function withTempUserProfile(run: (userProfile: string) => Promise<void>): Promise<void> {
const previous = process.env.USERPROFILE; const previous = process.env.USERPROFILE;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-registry-')); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-registry-'));
@ -30,7 +32,7 @@ async function withTempUserProfile(run: (userProfile: string) => Promise<void>):
} }
} }
test('registryFilePath resolves under %USERPROFILE%/.beadboard/projects.json', async () => { test('registryFilePath resolves under user home/.beadboard/projects.json', async () => {
await withTempUserProfile(async (userProfile) => { await withTempUserProfile(async (userProfile) => {
const result = registryFilePath(); const result = registryFilePath();
assert.equal(result, path.join(userProfile, '.beadboard', 'projects.json')); assert.equal(result, path.join(userProfile, '.beadboard', 'projects.json'));
@ -44,43 +46,74 @@ test('listProjects returns empty when registry does not exist', async () => {
}); });
}); });
test('addProject persists normalized path and deduplicates case/separators', async () => { if (IS_WINDOWS) {
test('addProject persists normalized path and deduplicates case/separators', async () => {
await withTempUserProfile(async () => {
const first = await addProject('c:/Work/Alpha/');
assert.equal(first.added, true);
const second = await addProject('C:\\work\\alpha');
assert.equal(second.added, false);
const listed = await listProjects();
assert.equal(listed.length, 1);
assert.equal(listed[0].path, 'C:/Work/Alpha');
const file = await fs.readFile(registryFilePath(), 'utf8');
const parsed = JSON.parse(file) as { projects: RegistryProject[] };
assert.equal(parsed.projects.length, 1);
});
});
test('removeProject removes matching normalized path', async () => {
await withTempUserProfile(async () => {
await addProject('D:/Repos/One');
await addProject('D:/Repos/Two');
const removed = await removeProject('d:\\repos\\one\\');
assert.equal(removed.removed, true);
const listed = await listProjects();
assert.deepEqual(
listed.map((project) => project.path),
['D:/Repos/Two'],
);
});
});
} else {
test('addProject persists and deduplicates POSIX paths', async () => {
await withTempUserProfile(async () => {
const first = await addProject('/opt/projects/alpha');
assert.equal(first.added, true);
const second = await addProject('/opt/projects/alpha/');
assert.equal(second.added, false);
const listed = await listProjects();
assert.equal(listed.length, 1);
assert.equal(listed[0].path, '/opt/projects/alpha');
});
});
test('removeProject removes matching POSIX path', async () => {
await withTempUserProfile(async () => {
await addProject('/opt/repos/one');
await addProject('/opt/repos/two');
const removed = await removeProject('/opt/repos/one');
assert.equal(removed.removed, true);
const listed = await listProjects();
assert.deepEqual(
listed.map((project) => project.path),
['/opt/repos/two'],
);
});
});
}
test('addProject rejects relative paths', async () => {
await withTempUserProfile(async () => { await withTempUserProfile(async () => {
const first = await addProject('c:/Work/Alpha/'); await assert.rejects(() => addProject('relative/path'), /absolute/i);
assert.equal(first.added, true);
const second = await addProject('C:\\work\\alpha');
assert.equal(second.added, false);
const listed = await listProjects();
assert.equal(listed.length, 1);
assert.equal(listed[0].path, 'C:/Work/Alpha');
const file = await fs.readFile(registryFilePath(), 'utf8');
const parsed = JSON.parse(file) as { projects: RegistryProject[] };
assert.equal(parsed.projects.length, 1);
});
});
test('removeProject removes matching normalized path', async () => {
await withTempUserProfile(async () => {
await addProject('D:/Repos/One');
await addProject('D:/Repos/Two');
const removed = await removeProject('d:\\repos\\one\\');
assert.equal(removed.removed, true);
const listed = await listProjects();
assert.deepEqual(
listed.map((project) => project.path),
['D:/Repos/Two'],
);
});
});
test('addProject rejects non-Windows absolute paths', async () => {
await withTempUserProfile(async () => {
await assert.rejects(() => addProject('/tmp/project'), /Windows absolute path/i);
await assert.rejects(() => addProject('relative/path'), /Windows absolute path/i);
}); });
}); });