genesis-compiler 1.7.5 → 1.7.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/templates.md
CHANGED
|
@@ -23,8 +23,8 @@ components or authored project contracts when no catalogue technology matches.
|
|
|
23
23
|
The exact bootstrap allowlist is `GENESIS_BOOTSTRAP_PATHS` in
|
|
24
24
|
`src/index/project-inspection.js`: the four core Markdown documents and version,
|
|
25
25
|
the two derived Cities, Genesis's Codex/OpenCode integration files, its managed
|
|
26
|
-
skills manifest, and the SKILL.md/openai.yaml
|
|
27
|
-
workflow skills. Whole `.agents/`, `.codex/`, or `genesis/` directories are not
|
|
26
|
+
skills manifest, the relative Claude discovery link, and the SKILL.md/openai.yaml
|
|
27
|
+
pair for each of the three Genesis workflow skills. Whole `.agents/`, `.codex/`, or `genesis/` directories are not
|
|
28
28
|
exempt. A README, `.gitignore`, application manifest, unfamiliar source file, or
|
|
29
29
|
custom agent tool counts as existing content. Ignored files also count, except
|
|
30
30
|
installed `node_modules` dependencies. Source recognition does not depend on a
|
|
@@ -56,8 +56,14 @@ Each branch is a complete, current Genesis project with actual application
|
|
|
56
56
|
source, dependency lockfile, setup/run contracts and focused checks.
|
|
57
57
|
|
|
58
58
|
Applying fetches the explicitly named branch once, resolves its commit and reads
|
|
59
|
-
its ordinary Git blobs into a temporary project. Genesis rejects links,
|
|
59
|
+
its ordinary Git blobs into a temporary project. Genesis rejects arbitrary links,
|
|
60
60
|
submodules, unsafe paths, dependencies and private dotenv files in the template.
|
|
61
|
+
The only link exception is the exact generated Claude skill discovery link
|
|
62
|
+
`.claude/skills/<name>` → `../../.agents/skills/<name>`. Incoming discovery links
|
|
63
|
+
are omitted and regenerated from the canonical skill directories by initialization.
|
|
64
|
+
Existing exact generated links are preserved and checked again before copying;
|
|
65
|
+
links with another target remain rejected. A bare destination receives the same
|
|
66
|
+
generated links without following links supplied by a template.
|
|
61
67
|
It validates the prepared project before changing the destination. A project
|
|
62
68
|
lock serializes competing imports; the source and authored bootstrap files are
|
|
63
69
|
checked again immediately before the copy. An ordinary copy failure rolls back
|
package/package.json
CHANGED
|
@@ -53,6 +53,11 @@ async function renderedSettings(projectRoot) {
|
|
|
53
53
|
|
|
54
54
|
// Claude discovers .claude/skills; the canonical files remain in .agents/skills.
|
|
55
55
|
// Only exact generated links are owned here. User-owned Claude skills win.
|
|
56
|
+
export function claudeSkillLinkTarget(relative) {
|
|
57
|
+
const name = /^\.claude\/skills\/([^/\\]+)$/u.exec(relative)?.[1];
|
|
58
|
+
return name && name !== '.' && name !== '..' ? `../../.agents/skills/${name}` : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
56
61
|
export async function claudeSkillLinks(root, write = false) {
|
|
57
62
|
const [canonical, installed] = await Promise.all(['.agents/skills', '.claude/skills'].map(async (directory) => {
|
|
58
63
|
try { return await readdir(path.join(root, directory), { withFileTypes: true }); }
|
|
@@ -65,7 +70,7 @@ export async function claudeSkillLinks(root, write = false) {
|
|
|
65
70
|
for (const name of new Set([...names, ...existing.keys()])) {
|
|
66
71
|
const relative = `.claude/skills/${name}`;
|
|
67
72
|
const location = path.join(root, relative);
|
|
68
|
-
const target =
|
|
73
|
+
const target = claudeSkillLinkTarget(relative);
|
|
69
74
|
const entry = existing.get(name);
|
|
70
75
|
const owned = entry?.isSymbolicLink() && await readlink(location) === target;
|
|
71
76
|
if (!names.has(name)) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { lstat, mkdir, mkdtemp, readFile, readdir, rm, rmdir, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { lstat, mkdir, mkdtemp, readFile, readdir, readlink, rm, rmdir, symlink, writeFile } from 'node:fs/promises';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { parseBlueprintSource } from './blueprint.js';
|
|
@@ -13,6 +13,7 @@ import { listTemplates, resolveTemplate } from './template-catalog.js';
|
|
|
13
13
|
import { readGitSnapshot } from './template-source.js';
|
|
14
14
|
import { readStack } from './stack.js';
|
|
15
15
|
import { writeFileAtomic } from './utils.js';
|
|
16
|
+
import { claudeSkillLinkTarget } from './claude-hooks.js';
|
|
16
17
|
|
|
17
18
|
function sections(source) {
|
|
18
19
|
const result = new Map();
|
|
@@ -43,8 +44,12 @@ async function existingFile(root, name) {
|
|
|
43
44
|
const location = path.join(root, name);
|
|
44
45
|
try {
|
|
45
46
|
const state = await lstat(location);
|
|
47
|
+
if (state.isSymbolicLink()) {
|
|
48
|
+
const linkTarget = claudeSkillLinkTarget(name);
|
|
49
|
+
if (linkTarget && await readlink(location) === linkTarget) return { contents: Buffer.from(linkTarget), linkTarget };
|
|
50
|
+
}
|
|
46
51
|
if (!state.isFile()) throw new GenesisError('TEMPLATE_DESTINATION_CONFLICT', `Template destination is not an ordinary file: ${name}.`);
|
|
47
|
-
return await readFile(location);
|
|
52
|
+
return { contents: await readFile(location), linkTarget: null };
|
|
48
53
|
} catch (error) {
|
|
49
54
|
if (error.code === 'ENOENT') return null;
|
|
50
55
|
throw error;
|
|
@@ -57,7 +62,7 @@ async function filesBelow(root, relative = '') {
|
|
|
57
62
|
if (!relative && entry.name === '.git') continue;
|
|
58
63
|
const name = relative ? `${relative}/${entry.name}` : entry.name;
|
|
59
64
|
if (entry.isDirectory()) files.push(...await filesBelow(root, name));
|
|
60
|
-
else if (entry.isFile()) files.push({ path: name,
|
|
65
|
+
else if (entry.isFile() || entry.isSymbolicLink()) files.push({ path: name, ...await existingFile(root, name), mode: (await lstat(path.join(root, name))).mode & 0o111 ? 0o777 : 0o666 });
|
|
61
66
|
else throw new GenesisError('TEMPLATE_TREE_INVALID', `Template contains a non-ordinary file: ${name}.`);
|
|
62
67
|
}
|
|
63
68
|
return files;
|
|
@@ -96,28 +101,34 @@ export async function applyTemplate({ projectRoot, templateId, stackPackages = [
|
|
|
96
101
|
await mkdir(path.dirname(path.join(stagingRoot, file.path)), { recursive: true });
|
|
97
102
|
await writeFile(path.join(stagingRoot, file.path), file.contents, { flag: 'wx', mode: file.mode });
|
|
98
103
|
}
|
|
99
|
-
const
|
|
100
|
-
if (!
|
|
104
|
+
const stackFile = await existingFile(stagingRoot, 'genesis/stack.md');
|
|
105
|
+
if (!stackFile) throw new GenesisError('TEMPLATE_STACK_REQUIRED', 'A template must contain its complete genesis/stack.md.');
|
|
101
106
|
const seedStack = await readStack(stagingRoot, { stackPackages });
|
|
102
107
|
if (!seedStack.components.some(({ id }) => id === template.technology)) {
|
|
103
108
|
throw new GenesisError('TEMPLATE_STACK_MISMATCH', 'The template Stack does not contain its advertised technology.');
|
|
104
109
|
}
|
|
105
|
-
for (const [name,
|
|
106
|
-
if (
|
|
110
|
+
for (const [name, previous] of preserved) {
|
|
111
|
+
if (previous === null || name.startsWith('.genesis/')) continue;
|
|
112
|
+
const { contents, linkTarget } = previous;
|
|
107
113
|
if (name === 'genesis/blueprint.md' && !parseBlueprintSource(contents.toString('utf8')).description) continue;
|
|
108
114
|
let value = contents;
|
|
109
|
-
if (name === 'genesis/stack.md') value = mergeTemplateStack(
|
|
110
|
-
|
|
111
|
-
await
|
|
115
|
+
if (name === 'genesis/stack.md') value = mergeTemplateStack(stackFile.contents.toString('utf8'), contents.toString('utf8'));
|
|
116
|
+
const location = path.join(stagingRoot, name);
|
|
117
|
+
await mkdir(path.dirname(location), { recursive: true });
|
|
118
|
+
if (linkTarget) {
|
|
119
|
+
await rm(location, { force: true });
|
|
120
|
+
await symlink(linkTarget, location, 'dir');
|
|
121
|
+
} else await writeFile(location, value);
|
|
112
122
|
}
|
|
113
123
|
await initializeProject({ projectRoot: stagingRoot, stackPackages });
|
|
114
124
|
await buildProjectIndex({ projectRoot: stagingRoot, stackPackages });
|
|
115
125
|
const prepared = await inspectProject({ projectRoot: stagingRoot, stackPackages });
|
|
116
126
|
if (prepared.state !== 'ready') throw new GenesisError('TEMPLATE_PROJECT_INVALID', 'The prepared template does not contain a usable Genesis project.', { inspection: prepared });
|
|
117
127
|
await assertSeedable(root, stackPackages);
|
|
118
|
-
for (const [name,
|
|
128
|
+
for (const [name, previous] of preserved) {
|
|
119
129
|
const current = await existingFile(root, name);
|
|
120
|
-
if ((
|
|
130
|
+
if ((previous === null) !== (current === null) || previous &&
|
|
131
|
+
(previous.linkTarget !== current.linkTarget || !previous.contents.equals(current.contents))) {
|
|
121
132
|
throw new GenesisError('TEMPLATE_DESTINATION_CHANGED', 'Project context changed while the template was being prepared. Nothing was applied.');
|
|
122
133
|
}
|
|
123
134
|
}
|
|
@@ -126,16 +137,19 @@ export async function applyTemplate({ projectRoot, templateId, stackPackages = [
|
|
|
126
137
|
try {
|
|
127
138
|
for (const file of files) {
|
|
128
139
|
const previous = await existingFile(root, file.path);
|
|
129
|
-
if (previous && previous.equals(file.contents)) continue;
|
|
140
|
+
if (previous && previous.linkTarget === file.linkTarget && previous.contents.equals(file.contents)) continue;
|
|
130
141
|
if (previous && !preserved.has(file.path)) throw new GenesisError('TEMPLATE_DESTINATION_CHANGED', `Existing project content was preserved: ${file.path}.`);
|
|
131
142
|
await mkdir(path.dirname(path.join(root, file.path)), { recursive: true });
|
|
132
|
-
if (
|
|
143
|
+
if (file.linkTarget || previous?.linkTarget) {
|
|
144
|
+
if (previous) throw new GenesisError('TEMPLATE_DESTINATION_CHANGED', `Existing project link was preserved: ${file.path}.`);
|
|
145
|
+
await symlink(file.linkTarget, path.join(root, file.path), 'dir');
|
|
146
|
+
} else if (previous) await writeFileAtomic(path.join(root, file.path), file.contents);
|
|
133
147
|
else await writeFile(path.join(root, file.path), file.contents, { flag: 'wx', mode: file.mode });
|
|
134
148
|
applied.push({ path: file.path, previous });
|
|
135
149
|
}
|
|
136
150
|
} catch (error) {
|
|
137
151
|
for (const file of applied.reverse()) {
|
|
138
|
-
if (file.previous) await writeFileAtomic(path.join(root, file.path), file.previous);
|
|
152
|
+
if (file.previous) await writeFileAtomic(path.join(root, file.path), file.previous.contents);
|
|
139
153
|
else await rm(path.join(root, file.path));
|
|
140
154
|
}
|
|
141
155
|
throw error;
|
|
@@ -3,6 +3,7 @@ import os from 'node:os';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { GenesisError } from './errors.js';
|
|
5
5
|
import { runGit, runGitText } from './process.js';
|
|
6
|
+
import { claudeSkillLinkTarget } from './claude-hooks.js';
|
|
6
7
|
|
|
7
8
|
function validateRepository(repository) {
|
|
8
9
|
if (typeof repository !== 'string' || !repository || repository.startsWith('-')) {
|
|
@@ -35,11 +36,19 @@ export async function readGitSnapshot({ repository, branch }) {
|
|
|
35
36
|
const separator = item.indexOf('\t');
|
|
36
37
|
const [mode, type, object] = item.slice(0, separator).split(' ');
|
|
37
38
|
const name = item.slice(separator + 1);
|
|
38
|
-
if (!['100644', '100755'].includes(mode) || type !== 'blob' || path.isAbsolute(name)
|
|
39
|
+
if (!['100644', '100755', '120000'].includes(mode) || type !== 'blob' || path.isAbsolute(name)
|
|
39
40
|
|| name.includes('\\') || name.split('/').some((part) => !part || ['.', '..', '.git'].includes(part.toLowerCase()))) {
|
|
40
41
|
throw new GenesisError('TEMPLATE_TREE_INVALID', `Template contains an unsupported path or file: ${name}.`);
|
|
41
42
|
}
|
|
42
43
|
const blob = await runGit(temporaryRoot, ['cat-file', 'blob', object]);
|
|
44
|
+
if (mode === '120000') {
|
|
45
|
+
const target = claudeSkillLinkTarget(name);
|
|
46
|
+
if (!target || !blob.stdout.equals(Buffer.from(target))) {
|
|
47
|
+
throw new GenesisError('TEMPLATE_TREE_INVALID', `Template contains an unsupported link: ${name}.`);
|
|
48
|
+
}
|
|
49
|
+
// Regenerate these through initialization from the canonical skill files.
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
43
52
|
bytes += blob.stdout.length;
|
|
44
53
|
if (bytes > 64 * 1024 * 1024 || files.length >= 10_000) {
|
|
45
54
|
throw new GenesisError('TEMPLATE_TREE_TOO_LARGE', 'Template exceeds the source size limit.');
|