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';
const IS_WINDOWS = os.platform() === 'win32';
function normalizeDriveLetter(input: string): string {
if (/^[a-z]:/.test(input)) {
return `${input[0].toUpperCase()}${input.slice(1)}`;
@ -9,7 +12,11 @@ function normalizeDriveLetter(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;
}
@ -17,6 +24,10 @@ function trimTrailingSeparator(input: string): string {
}
export function canonicalizeWindowsPath(input: string): string {
if (!IS_WINDOWS) {
return trimTrailingSeparator(path.resolve(input));
}
const withBackslashes = input.replaceAll('/', '\\');
const normalized = path.win32.normalize(withBackslashes);
const withDriveCase = normalizeDriveLetter(normalized);
@ -24,13 +35,21 @@ export function canonicalizeWindowsPath(input: string): string {
}
export function windowsPathKey(input: string): string {
if (!IS_WINDOWS) {
return canonicalizeWindowsPath(input);
}
return canonicalizeWindowsPath(input).toLowerCase();
}
export function toDisplayPath(input: string): string {
if (!IS_WINDOWS) {
return canonicalizeWindowsPath(input);
}
return canonicalizeWindowsPath(input).replaceAll('\\', '/');
}
export function sameWindowsPath(a: string, b: string): boolean {
return windowsPathKey(a) === windowsPathKey(b);
}
}

View file

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

View file

@ -1,5 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import os from 'node:os';
import {
canonicalizeWindowsPath,
@ -8,23 +9,58 @@ import {
sameWindowsPath,
} from '../../src/lib/pathing';
test('canonicalizeWindowsPath normalizes separators and drive casing', () => {
const input = 'c:/Users/test/project/beadboard/';
const result = canonicalizeWindowsPath(input);
assert.equal(result, 'C:\\Users\\test\\project\\beadboard');
});
const IS_WINDOWS = os.platform() === 'win32';
test('windowsPathKey is case-insensitive stable key', () => {
const a = windowsPathKey('C:/Users/test/project/beadboard');
const b = windowsPathKey('c:\\users\\test\\project\\beadboard\\');
assert.equal(a, b);
});
if (IS_WINDOWS) {
test('canonicalizeWindowsPath normalizes separators and drive casing', () => {
const input = 'c:/Users/test/project/beadboard/';
const result = canonicalizeWindowsPath(input);
assert.equal(result, 'C:\\Users\\test\\project\\beadboard');
});
test('toDisplayPath renders forward slashes for UI readability', () => {
const display = toDisplayPath('C:\\Users\\test\\project\\beadboard');
assert.equal(display, 'C:/Users/test/project/beadboard');
});
test('windowsPathKey is case-insensitive stable key', () => {
const a = windowsPathKey('C:/Users/test/project/beadboard');
const b = windowsPathKey('c:\\users\\test\\project\\beadboard\\');
assert.equal(a, b);
});
test('sameWindowsPath handles case/separator differences', () => {
assert.equal(sameWindowsPath('D:/Repos/One', 'd:\\repos\\one\\'), true);
});
test('toDisplayPath renders forward slashes for UI readability', () => {
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 test from 'node:test';
import { resolveProjectScope, type ProjectScopeRegistryEntry } from '../../src/lib/project-scope';
const REGISTRY: ProjectScopeRegistryEntry[] = [
{ path: 'D:/Repos/Alpha' },
{ 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,
import assert from 'node:assert/strict';
import os from 'node:os';
import test from 'node:test';
import { resolveProjectScope, type ProjectScopeRegistryEntry } from '../../src/lib/project-scope';
const IS_WINDOWS = os.platform() === 'win32';
if (IS_WINDOWS) {
const REGISTRY: ProjectScopeRegistryEntry[] = [
{ path: 'D:/Repos/Alpha' },
{ 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');
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);
});
test('resolveProjectScope selects registry project when key matches', () => {
const scope = resolveProjectScope({
currentProjectRoot: 'C:/Users/test/project/beadboard',
registryProjects: REGISTRY,
requestedProjectKey: 'd:\\repos\\beta',
});
test('resolveProjectScope selects registry project when key matches', () => {
const scope = resolveProjectScope({
currentProjectRoot: 'C:/Users/test/project/beadboard',
registryProjects: REGISTRY,
requestedProjectKey: 'd:\\repos\\beta',
assert.equal(scope.selected.source, 'registry');
assert.equal(scope.selected.root, 'D:\\Repos\\Beta');
assert.equal(scope.selected.key, 'd:\\repos\\beta');
assert.deepEqual(scope.readRoots, ['D:\\Repos\\Beta']);
});
assert.equal(scope.selected.source, 'registry');
assert.equal(scope.selected.root, 'D:\\Repos\\Beta');
assert.equal(scope.selected.key, 'd:\\repos\\beta');
assert.deepEqual(scope.readRoots, ['D:\\Repos\\Beta']);
});
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',
]);
});
} 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', () => {
const scope = resolveProjectScope({
currentProjectRoot: 'C:/Users/test/project/beadboard',
registryProjects: REGISTRY,
requestedProjectKey: 'd:\\repos\\missing',
currentProjectRoot: IS_WINDOWS ? 'C:/Users/test/project' : '/opt/beadboard',
registryProjects: [],
requestedProjectKey: '/nonexistent',
});
assert.equal(scope.selected.source, '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', () => {
const scope = resolveProjectScope({
currentProjectRoot: 'C:/Users/test/project/beadboard',
registryProjects: REGISTRY,
currentProjectRoot: IS_WINDOWS ? 'C:/Users/test/project' : '/opt/beadboard',
registryProjects: [],
requestedMode: 'invalid-mode',
});
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 assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
addProject,
listProjects,
removeProject,
registryFilePath,
type RegistryProject,
} from '../../src/lib/registry';
async function withTempUserProfile(run: (userProfile: string) => Promise<void>): Promise<void> {
const previous = process.env.USERPROFILE;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-registry-'));
process.env.USERPROFILE = tempDir;
try {
await run(tempDir);
} finally {
if (previous === undefined) {
delete process.env.USERPROFILE;
} else {
process.env.USERPROFILE = previous;
}
await fs.rm(tempDir, { recursive: true, force: true });
}
}
test('registryFilePath resolves under %USERPROFILE%/.beadboard/projects.json', async () => {
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();
assert.deepEqual(result, []);
});
});
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'],
);
});
});
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);
});
});
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
addProject,
listProjects,
removeProject,
registryFilePath,
type RegistryProject,
} from '../../src/lib/registry';
const IS_WINDOWS = os.platform() === 'win32';
async function withTempUserProfile(run: (userProfile: string) => Promise<void>): Promise<void> {
const previous = process.env.USERPROFILE;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-registry-'));
process.env.USERPROFILE = tempDir;
try {
await run(tempDir);
} finally {
if (previous === undefined) {
delete process.env.USERPROFILE;
} else {
process.env.USERPROFILE = previous;
}
await fs.rm(tempDir, { recursive: true, force: true });
}
}
test('registryFilePath resolves under user home/.beadboard/projects.json', async () => {
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();
assert.deepEqual(result, []);
});
});
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 assert.rejects(() => addProject('relative/path'), /absolute/i);
});
});