The business file accepts a kind "library" project entry, and each consumer that assumed a project root skips it. An organization write whose projects differ from the file's is refused (M9). The bare mirror is created at the projectRepo path with no remote. library.file and library.status are human only and refuse an agent session with human-required. Candidate herb/row215-on197dde3445abona36523713, Filbert APPROVE rev 2924.
62 lines
3.0 KiB
JavaScript
62 lines
3.0 KiB
JavaScript
import { lstatSync } from 'node:fs';
|
|
import { dirname, isAbsolute, join, normalize, relative, sep } from 'node:path';
|
|
|
|
export class ProjectError extends Error {
|
|
constructor(code, message) { super(`${code}: ${message}`); this.code = code; }
|
|
}
|
|
export const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
export const checkedId = value => {
|
|
if (typeof value !== 'string' || !ID.test(value)) throw new ProjectError('project-id', 'invalid id');
|
|
return value;
|
|
};
|
|
// The files a person opens and saves in the Console (row 148): the spec, and Markdown directly
|
|
// under docs/. Reads and the human writer share this one definition.
|
|
export const documentPath = path => typeof path === 'string' &&
|
|
(path === 'spec/north-star.md' || /^docs\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.md$/.test(path));
|
|
export function absolute(path) {
|
|
if (typeof path !== 'string' || !isAbsolute(path) || normalize(path) !== path || path.includes('\0'))
|
|
throw new ProjectError('project-path', 'expected normalized absolute path');
|
|
return path;
|
|
}
|
|
export function noLinks(path) {
|
|
for (let part = path; ; part = dirname(part)) {
|
|
try {
|
|
const st = lstatSync(part);
|
|
if (!st.isDirectory() || st.isSymbolicLink()) throw new ProjectError('project-path', 'path ancestor must be a directory, never a link');
|
|
} catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
if (part === dirname(part)) break;
|
|
}
|
|
}
|
|
const contains = (base, child) => {
|
|
const rel = relative(base, child);
|
|
return rel === '' || (rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel));
|
|
};
|
|
export function projectRepo({ business, project, dataRoot }, { allowLibrary = false } = {}) {
|
|
checkedId(business?.id); checkedId(project);
|
|
if (!Object.hasOwn(business?.projects ?? {}, project))
|
|
throw new ProjectError('project-not-declared', `business does not declare ${project}`);
|
|
absolute(dataRoot);
|
|
const entry = business.projects[project];
|
|
if (entry.kind === 'library' && !allowLibrary)
|
|
throw new ProjectError('project-invalid', 'library projects are not record repositories');
|
|
const repo = absolute(entry.repo ?? join(dataRoot, 'projects', business.id, project));
|
|
for (const project of Object.values(business.projects)) {
|
|
if (project.kind === 'library') continue;
|
|
const code = absolute(project.root);
|
|
if (contains(code, repo) || contains(repo, code))
|
|
throw new ProjectError('project-path', 'record repository and code checkout overlap');
|
|
}
|
|
noLinks(repo);
|
|
return repo;
|
|
}
|
|
// Row 175 (#1673, mos-0872 Q2): the tester initializes only a repository in the data root's
|
|
// project area, <dataRoot>/projects/<business id>/. It writes nothing; projectRepo only lstats the path.
|
|
// The area itself is refused too: a repository there would hold every other project's default one.
|
|
export function testerProjectArea(context) {
|
|
const repo = projectRepo(context);
|
|
const area = join(context.dataRoot, 'projects', context.business.id);
|
|
if (relative(area, repo) === '' || !contains(area, repo))
|
|
throw new ProjectError('tester-project-area', `the tester initializes only a repository under ${area}`);
|
|
return repo;
|
|
}
|