diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 6ac0c7399..045b14eaf 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -602,10 +602,14 @@ Never migrate the live file from an agent session. separate local record repository, with layout version 1, package-validated JSON templates, Markdown spec/docs and its first commit authored as the business's human. Init refuses an agent environment, an undeclared project or a non-empty -directory. No bus or remote is involved. +directory. Failed init cleans up its own layout and allows retry, preserving a +pre-existing empty target and unrelated files. A changed owned path refuses +`project-init-cleanup` for inspection. Records may not overlap any declared +project's code root. No bus or remote is involved. `mosaic project check ` validates project metadata, every record, filename/ID agreement and the fixed layout, naming each problem. It +refuses `project-no-commit` if the layout has no readable HEAD commit. It exits 0 when valid, 2 for project/record problems (including an undeclared project), 3 for configuration loading problems and 4 for usage. The optional business project `repo` is a normalized absolute record-repository path; the default is diff --git a/packages/business/tests/project-repo.test.mjs b/packages/business/tests/project-repo.test.mjs index bfd57004a..e440dfd05 100644 --- a/packages/business/tests/project-repo.test.mjs +++ b/packages/business/tests/project-repo.test.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { join } from 'node:path'; import { businessDoc, REPO_ROLES, tmp } from './helpers.mjs'; import { validateBusinessDocument } from '../src/business.mjs'; +import { projectRepo } from '../../project/src/index.mjs'; test('optional project repo is preserved and must be a normalized absolute path', () => { const root = tmp(), doc = businessDoc(root), file = join(root, 'businesses/acme.json'); const check = () => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }); @@ -14,3 +15,15 @@ test('optional project repo is preserved and must be a normalized absolute path' assert.throws(check, /repo.*normalized absolute path/); } }); + +test('loaded business project repo cannot be nested in any declared code checkout', () => { + const root = tmp(), doc = businessDoc(root), file = join(root, 'businesses/acme.json'); + doc.projects.other = { root: join(root, 'other-code') }; + doc.projects.stack.repo = join(doc.projects.other.root, 'records'); + const business = validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }); + const context = { business, project: 'stack', dataRoot: join(root, 'data') }; + assert.throws(() => projectRepo(context), { code: 'project-path' }); + doc.projects.stack.repo = join(root, 'safe-records'); + const safe = validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }); + assert.equal(projectRepo({ ...context, business: safe }), doc.projects.stack.repo); +}); diff --git a/packages/project/README.md b/packages/project/README.md index 6c3561af7..a23a43c63 100644 --- a/packages/project/README.md +++ b/packages/project/README.md @@ -11,19 +11,29 @@ Set an optional normalized absolute `repo` in the business project entry: ``` Without `repo`, records live at -`/projects///`. The code and records paths must not -overlap. Record paths and their existing ancestors must not be symlinks. +`/projects///`. The records path must not overlap any declared project's code root, +including another project's root (in either direction). Record paths and their existing ancestors must not be symlinks. ```sh mosaic project init acme stack mosaic project check acme stack ``` +Init failure removes its own files and Git directory, allowing retry. A target +that existed empty is preserved; newly created empty parent directories are +removed. Unrelated files at the root or inside a layout directory are preserved, +so a concurrent human save can leave a non-empty target that needs inspection +before retry. Changed ownership of an initializer path refuses +`project-init-cleanup` and preserves that path for reconciliation. The private +`.git` created by this initializer is removed as a unit; external tools must not +modify Git metadata during initialization. + Both commands use the existing system and business config loaders. Init requires an outside-agent CLI environment, a declared project and an empty directory. It creates `main` and the first commit as the business's human, with a local `@mosaic.invalid` email. Check is read-only; it exits 2 and lists all -problems found. Usage exits 4; configuration problems exit 3. No bus is needed. +problems found. An otherwise valid layout with no readable HEAD commit refuses +`project-no-commit` (CLI exit 2), rather than reporting success. Usage exits 4; configuration problems exit 3. No bus is needed. ## Layout @@ -84,7 +94,8 @@ local commit authored and committed as `.` with a local validated here; row 138's broker must derive it from the bound role. This package neither authenticates an agent nor grants it authority. -Every agent path under `spec/` refuses with `project-spec-human-only`. Agents +An author with definition `human` refuses `project-author`; this agent writer +provides no human record write path. Every agent path under `spec/` refuses with `project-spec-human-only`. Agents cannot write project.json, templates or git metadata through this API. A valid but unchanged record refuses `project-record-unchanged` without an empty commit. diff --git a/packages/project/src/index.mjs b/packages/project/src/index.mjs index ea2618688..6523ca8a3 100644 --- a/packages/project/src/index.mjs +++ b/packages/project/src/index.mjs @@ -1,6 +1,6 @@ import { - closeSync, copyFileSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, - renameSync, unlinkSync, writeFileSync, fsyncSync, + closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, + renameSync, unlinkSync, writeFileSync, fsyncSync, rmSync, rmdirSync, } from 'node:fs'; import { dirname, join, normalize } from 'node:path'; import { randomUUID } from 'node:crypto'; @@ -28,7 +28,7 @@ function jsonProblems(kind, text, relative, problems) { return value; } catch (e) { problems.push(`${relative}: invalid JSON (${e.message})`); return null; } } -export async function checkProject(context) { +async function checkLayout(context) { const repo = projectRepo(context), problems = []; const problem = (path, message) => problems.push(`${path}: ${message}`); try { @@ -83,28 +83,74 @@ export async function checkProject(context) { return { ok: problems.length === 0, repo, problems }; } +export async function checkProject(context) { + const result = await checkLayout(context); + if (result.ok) { + try { await git(result.repo, ['rev-parse', '--verify', 'HEAD^{commit}']); } + catch { throw new ProjectError('project-no-commit', 'project repository has no readable commit'); } + } + return result; +} + export async function initProject(context) { const repo = projectRepo(context), human = checkedId(context.business.human); if (existsSync(repo) && readdirSync(repo).length) throw new ProjectError('project-not-empty', 'refusing existing non-empty directory'); + const parents = []; + for (let path = repo; !existsSync(path); path = dirname(path)) parents.unshift(path); mkdirSync(repo, { recursive: true, mode: 0o700 }); - // Exclusive directory creation prevents two initializers from racing past the empty check. - try { mkdirSync(join(repo, '.git'), { mode: 0o700 }); } - catch { throw new ProjectError('project-not-empty', 'another initializer already owns this directory'); } - await git(repo, ['init', '--initial-branch=main', '--template=']); - for (const directory of DIRECTORIES) mkdirSync(join(repo, directory), { mode: 0o700 }); - for (const name of TEMPLATES) - copyFileSync(new URL(`../templates/${name}`, import.meta.url), join(repo, 'templates', name)); - copyFileSync(new URL('../templates/north-star.md', import.meta.url), join(repo, 'spec/north-star.md')); - writeFileSync(join(repo, 'docs/README.md'), '# Project documentation\n\nKeep project Markdown documents here.\n'); - for (const directory of ['requirements', 'decisions']) writeFileSync(join(repo, directory, '.gitkeep'), ''); - const metadata = { layoutVersion: 1, business: context.business.id, project: context.project, - name: context.project, createdAt: new Date().toISOString() }; - writeFileSync(join(repo, 'project.json'), JSON.stringify(metadata, null, 2) + '\n'); - const result = await checkProject(context); - if (!result.ok) throw new ProjectError('project-invalid', result.problems.join('\n')); - await git(repo, ['add', '--', 'project.json', ...DIRECTORIES]); - await git(repo, ['commit', '-m', 'Initialize project layout v1'], identity(human)); - return { repo, commit: await git(repo, ['rev-parse', 'HEAD']) }; + const created = []; + const remember = (path, recursive = false) => { + const stat = lstatSync(path); + created.push({ path, ino: stat.ino, dev: stat.dev, recursive }); + }; + const createFile = (path, bytes) => { + const fd = openSync(path, 'wx', 0o600); + try { remember(path); writeFileSync(fd, bytes); } + finally { closeSync(fd); } + }; + try { + // Only the initializer that owns this exclusive directory may clean it up. + try { mkdirSync(join(repo, '.git'), { mode: 0o700 }); } + catch { throw new ProjectError('project-not-empty', 'another initializer already owns this directory'); } + remember(join(repo, '.git'), true); + await git(repo, ['init', '--initial-branch=main', '--template=']); + for (const directory of DIRECTORIES) { + const path = join(repo, directory); + mkdirSync(path, { mode: 0o700 }); + remember(path); + } + for (const name of TEMPLATES) + createFile(join(repo, 'templates', name), readFileSync(new URL(`../templates/${name}`, import.meta.url))); + createFile(join(repo, 'spec/north-star.md'), readFileSync(new URL('../templates/north-star.md', import.meta.url))); + createFile(join(repo, 'docs/README.md'), '# Project documentation\n\nKeep project Markdown documents here.\n'); + for (const directory of ['requirements', 'decisions']) createFile(join(repo, directory, '.gitkeep'), ''); + const metadata = { layoutVersion: 1, business: context.business.id, project: context.project, + name: context.project, createdAt: new Date().toISOString() }; + createFile(join(repo, 'project.json'), JSON.stringify(metadata, null, 2) + '\n'); + const result = await checkLayout(context); + if (!result.ok) throw new ProjectError('project-invalid', result.problems.join('\n')); + await git(repo, ['add', '--', 'project.json', ...DIRECTORIES]); + await git(repo, ['commit', '-m', 'Initialize project layout v1'], identity(human)); + return { repo, commit: await git(repo, ['rev-parse', 'HEAD']) }; + } catch (error) { + for (const entry of created.reverse()) { + let stat; + try { stat = lstatSync(entry.path); } + catch (e) { if (e.code === 'ENOENT') continue; throw e; } + if (stat.ino !== entry.ino || stat.dev !== entry.dev) + throw new ProjectError('project-init-cleanup', 'owned path changed; preserve it for reconciliation'); + if (stat.isDirectory() && !entry.recursive) { + try { rmdirSync(entry.path); } + catch (e) { if (!['ENOTEMPTY', 'EEXIST'].includes(e.code)) throw e; } + } else rmSync(entry.path, { recursive: entry.recursive, force: true }); + } + // rmdir never removes unrelated files or a pre-existing empty directory. + for (const path of parents.reverse()) { + try { rmdirSync(path); } + catch (e) { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(e.code)) throw e; } + } + throw error; + } } function recordBytes(path, record) { diff --git a/packages/project/src/paths.mjs b/packages/project/src/paths.mjs index 2b9ec5407..7f0676a59 100644 --- a/packages/project/src/paths.mjs +++ b/packages/project/src/paths.mjs @@ -28,14 +28,17 @@ export function projectRepo({ business, project, dataRoot }) { if (!Object.hasOwn(business?.projects ?? {}, project)) throw new ProjectError('project-not-declared', `business does not declare ${project}`); absolute(dataRoot); - const entry = business.projects[project], root = absolute(entry.root); + const entry = business.projects[project]; const repo = absolute(entry.repo ?? join(dataRoot, 'projects', business.id, project)); const contains = (base, child) => { const rel = relative(base, child); return rel === '' || (rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel)); }; - if (contains(root, repo) || contains(repo, root)) - throw new ProjectError('project-path', 'record repository and code checkout overlap'); + for (const project of Object.values(business.projects)) { + 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; } diff --git a/packages/project/tests/init-failure.test.mjs b/packages/project/tests/init-failure.test.mjs new file mode 100644 index 000000000..978e47d30 --- /dev/null +++ b/packages/project/tests/init-failure.test.mjs @@ -0,0 +1,91 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, rmSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { checkProject, initProject, projectRepo, writeRecord } from '../src/index.mjs'; + +function fixture(t) { + const root = mkdtempSync(join(tmpdir(), 'project-init-failure-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + return { root, dataRoot: join(root, 'data'), project: 'stack', business: { + id: 'acme', human: 'jason', projects: { stack: { root: join(root, 'code'), repo: join(root, 'records') } }, + } }; +} +async function failingCommit(c, operation, sideEffect = ':') { + const realGit = execFileSync('sh', ['-c', 'command -v git'], { encoding: 'utf8' }).trim(); + const bin = join(c.root, 'bin'); + mkdirSync(bin); + writeFileSync(join(bin, 'git'), `#!/bin/sh\nfor arg do if [ "$arg" = commit ]; then ${sideEffect}; exit 71; fi; done\nexec '${realGit}' "$@"\n`); + chmodSync(join(bin, 'git'), 0o755); + const saved = process.env.PATH; + try { process.env.PATH = bin + ':' + saved; await operation(); } + finally { process.env.PATH = saved; } +} + +for (const existing of [false, true]) test(`failed init cleans its layout and permits retry (pre-existing=${existing})`, async t => { + const c = fixture(t), repo = projectRepo(c); + if (existing) mkdirSync(repo); + const inode = existing ? lstatSync(repo).ino : null; + await failingCommit(c, () => assert.rejects(initProject(c), { code: 'project-git' })); + if (existing) { + assert.equal(lstatSync(repo).ino, inode); + assert.deepEqual(readdirSync(repo), []); + } else assert.equal(existsSync(repo), false); + const result = await initProject(c); + assert.match(result.commit, /^[0-9a-f]{40}$/); + assert.equal((await checkProject(c)).ok, true); +}); + +test('project check distinguishes a complete but uncommitted layout', async t => { + const c = fixture(t), { repo } = await initProject(c); + execFileSync('git', ['-C', repo, 'update-ref', '-d', 'refs/heads/main']); + await assert.rejects(checkProject(c), { code: 'project-no-commit' }); +}); + +test('record repo refuses every declared code root including a sibling project', async t => { + const c = fixture(t); + c.business.projects.other = { root: join(c.root, 'other-code') }; + c.business.projects.stack.repo = join(c.business.projects.other.root, 'records'); + assert.throws(() => projectRepo(c), { code: 'project-path' }); + await assert.rejects(initProject(c), { code: 'project-path' }); + assert.equal(existsSync(c.business.projects.other.root), false); + c.business.projects.stack.repo = c.business.projects.other.root; + assert.throws(() => projectRepo(c), { code: 'project-path' }); + c.business.projects.stack.repo = c.root; + assert.throws(() => projectRepo(c), { code: 'project-path' }); +}); + +test('agent record writer refuses a human definition without changing history or files', async t => { + const c = fixture(t), { repo, commit } = await initProject(c); + await assert.rejects(writeRecord({ ...c, path: 'docs/human.md', record: '# Human', + author: { definition: 'human', instance: c.business.human } }), { code: 'project-author' }); + assert.equal(existsSync(join(repo, 'docs/human.md')), false); + assert.equal(execFileSync('git', ['-C', repo, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), commit); + assert.equal(execFileSync('git', ['-C', repo, 'status', '--porcelain'], { encoding: 'utf8' }), ''); +}); + +test('failed init preserves an unrelated file created while its commit runs', async t => { + const c = fixture(t), repo = projectRepo(c), keep = join(repo, 'human-note.txt'); + await failingCommit(c, () => assert.rejects(initProject(c), { code: 'project-git' }), + `printf keep > '${keep}'`); + assert.deepEqual(readdirSync(repo), ['human-note.txt']); + assert.equal(readFileSync(keep, 'utf8'), 'keep'); +}); + +test('failed init preserves an unrelated file inside a newly created layout directory', async t => { + const c = fixture(t), repo = projectRepo(c), keep = join(repo, 'docs/human-note.md'); + await failingCommit(c, () => assert.rejects(initProject(c), { code: 'project-git' }), + `printf keep > '${keep}'`); + assert.deepEqual(readdirSync(repo), ['docs']); + assert.deepEqual(readdirSync(join(repo, 'docs')), ['human-note.md']); + assert.equal(readFileSync(keep, 'utf8'), 'keep'); +}); + +test('failed init refuses to delete a replacement of its owned metadata file', async t => { + const c = fixture(t), repo = projectRepo(c), file = join(repo, 'project.json'); + await failingCommit(c, () => assert.rejects(initProject(c), { code: 'project-init-cleanup' }), + `mv '${file}' '${join(c.root, 'original.json')}' && printf human > '${file}'`); + assert.equal(readFileSync(file, 'utf8'), 'human'); +});