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,13 +35,21 @@ 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('\\', '/');
} }
export function sameWindowsPath(a: string, b: string): boolean { export function sameWindowsPath(a: string, b: string): boolean {
return windowsPathKey(a) === windowsPathKey(b); return windowsPathKey(a) === windowsPathKey(b);
} }

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 test from 'node:test'; import os from 'node:os';
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[] = [
{ path: 'D:/Repos/Alpha' }, const IS_WINDOWS = os.platform() === 'win32';
{ path: 'D:/Repos/Beta' },
]; if (IS_WINDOWS) {
const REGISTRY: ProjectScopeRegistryEntry[] = [
test('resolveProjectScope defaults to local when query key is missing', () => { { path: 'D:/Repos/Alpha' },
const scope = resolveProjectScope({ { path: 'D:/Repos/Beta' },
currentProjectRoot: 'C:/Users/test/project/beadboard', ];
registryProjects: REGISTRY,
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

@ -1,86 +1,119 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { import {
addProject, addProject,
listProjects, listProjects,
removeProject, removeProject,
registryFilePath, registryFilePath,
type RegistryProject, type RegistryProject,
} from '../../src/lib/registry'; } from '../../src/lib/registry';
async function withTempUserProfile(run: (userProfile: string) => Promise<void>): Promise<void> { const IS_WINDOWS = os.platform() === 'win32';
const previous = process.env.USERPROFILE;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-registry-')); async function withTempUserProfile(run: (userProfile: string) => Promise<void>): Promise<void> {
process.env.USERPROFILE = tempDir; const previous = process.env.USERPROFILE;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-registry-'));
try { process.env.USERPROFILE = tempDir;
await run(tempDir);
} finally { try {
if (previous === undefined) { await run(tempDir);
delete process.env.USERPROFILE; } finally {
} else { if (previous === undefined) {
process.env.USERPROFILE = previous; delete process.env.USERPROFILE;
} } else {
process.env.USERPROFILE = previous;
await fs.rm(tempDir, { recursive: true, force: true }); }
}
} await fs.rm(tempDir, { recursive: true, force: true });
}
test('registryFilePath resolves under %USERPROFILE%/.beadboard/projects.json', async () => { }
await withTempUserProfile(async (userProfile) => {
const result = registryFilePath(); test('registryFilePath resolves under user home/.beadboard/projects.json', async () => {
assert.equal(result, path.join(userProfile, '.beadboard', 'projects.json')); await withTempUserProfile(async (userProfile) => {
}); const result = registryFilePath();
}); assert.equal(result, path.join(userProfile, '.beadboard', 'projects.json'));
});
test('listProjects returns empty when registry does not exist', async () => { });
await withTempUserProfile(async () => {
const result = await listProjects(); test('listProjects returns empty when registry does not exist', async () => {
assert.deepEqual(result, []); await withTempUserProfile(async () => {
}); const result = await listProjects();
}); assert.deepEqual(result, []);
});
test('addProject persists normalized path and deduplicates case/separators', async () => { });
await withTempUserProfile(async () => {
const first = await addProject('c:/Work/Alpha/'); if (IS_WINDOWS) {
assert.equal(first.added, true); test('addProject persists normalized path and deduplicates case/separators', async () => {
await withTempUserProfile(async () => {
const second = await addProject('C:\\work\\alpha'); const first = await addProject('c:/Work/Alpha/');
assert.equal(second.added, false); assert.equal(first.added, true);
const listed = await listProjects(); const second = await addProject('C:\\work\\alpha');
assert.equal(listed.length, 1); assert.equal(second.added, false);
assert.equal(listed[0].path, 'C:/Work/Alpha');
const listed = await listProjects();
const file = await fs.readFile(registryFilePath(), 'utf8'); assert.equal(listed.length, 1);
const parsed = JSON.parse(file) as { projects: RegistryProject[] }; assert.equal(listed[0].path, 'C:/Work/Alpha');
assert.equal(parsed.projects.length, 1);
}); 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'); test('removeProject removes matching normalized path', async () => {
await withTempUserProfile(async () => {
const removed = await removeProject('d:\\repos\\one\\'); await addProject('D:/Repos/One');
assert.equal(removed.removed, true); await addProject('D:/Repos/Two');
const listed = await listProjects(); const removed = await removeProject('d:\\repos\\one\\');
assert.deepEqual( assert.equal(removed.removed, true);
listed.map((project) => project.path),
['D:/Repos/Two'], 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); } else {
await assert.rejects(() => addProject('relative/path'), /Windows absolute path/i); 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 assert.rejects(() => addProject('relative/path'), /absolute/i);
});
});