@bevel-software/platform-core-backend 0.8.0 → 0.9.0
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/dist/core/core-ports.d.ts +7 -0
- package/dist/core/core-ports.d.ts.map +1 -1
- package/dist/core/core-ports.js.map +1 -1
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +24 -11
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +7 -2
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +39 -18
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts +3 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +4 -2
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/render-roles-yaml.d.ts +22 -0
- package/dist/modules/access/render-roles-yaml.d.ts.map +1 -0
- package/dist/modules/access/render-roles-yaml.js +56 -0
- package/dist/modules/access/render-roles-yaml.js.map +1 -0
- package/dist/modules/access/roles-admin.service.d.ts +15 -1
- package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
- package/dist/modules/access/roles-admin.service.js +30 -15
- package/dist/modules/access/roles-admin.service.js.map +1 -1
- package/dist/modules/settings/setup.routes.d.ts +10 -1
- package/dist/modules/settings/setup.routes.d.ts.map +1 -1
- package/dist/modules/settings/setup.routes.js +111 -6
- package/dist/modules/settings/setup.routes.js.map +1 -1
- package/dist/modules/workspace/startup/kb-git.d.ts +23 -0
- package/dist/modules/workspace/startup/kb-git.d.ts.map +1 -0
- package/dist/modules/workspace/startup/kb-git.js +86 -0
- package/dist/modules/workspace/startup/kb-git.js.map +1 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts +74 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -0
- package/dist/modules/workspace/startup/kb-startup-runner.js +528 -0
- package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -0
- package/dist/modules/workspace/startup/on-server-start.d.ts +105 -0
- package/dist/modules/workspace/startup/on-server-start.d.ts.map +1 -0
- package/dist/modules/workspace/startup/on-server-start.js +21 -0
- package/dist/modules/workspace/startup/on-server-start.js.map +1 -0
- package/dist/modules/workspace/startup/steps/groups-to-plugins.step.d.ts +46 -0
- package/dist/modules/workspace/startup/steps/groups-to-plugins.step.d.ts.map +1 -0
- package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js +492 -0
- package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js.map +1 -0
- package/dist/modules/workspace/startup/steps/roles-yaml.step.d.ts +23 -0
- package/dist/modules/workspace/startup/steps/roles-yaml.step.d.ts.map +1 -0
- package/dist/modules/workspace/startup/steps/roles-yaml.step.js +69 -0
- package/dist/modules/workspace/startup/steps/roles-yaml.step.js.map +1 -0
- package/dist/modules/workspace/startup/steps/seed-tree.d.ts +17 -0
- package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -0
- package/dist/modules/workspace/startup/steps/seed-tree.js +109 -0
- package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -0
- package/dist/modules/workspace/startup/steps/template-files.step.d.ts +103 -0
- package/dist/modules/workspace/startup/steps/template-files.step.d.ts.map +1 -0
- package/dist/modules/workspace/startup/steps/template-files.step.js +337 -0
- package/dist/modules/workspace/startup/steps/template-files.step.js.map +1 -0
- package/dist/modules/workspace/workspace.service.d.ts +0 -35
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +2 -101
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/kb-template/gitignore.template +16 -0
- package/package.json +3 -3
- package/src/core/core-ports.ts +7 -0
- package/src/core/create-core-server.ts +30 -11
- package/src/core/create-core-services.ts +43 -22
- package/src/modules/access/__tests__/roles-admin.service.test.ts +24 -2
- package/src/modules/access/access.routes.ts +3 -0
- package/src/modules/access/render-roles-yaml.ts +65 -0
- package/src/modules/access/roles-admin.service.ts +32 -14
- package/src/modules/settings/__tests__/setup.routes.test.ts +124 -3
- package/src/modules/settings/setup.routes.ts +112 -5
- package/src/modules/workspace/__tests__/workspace.service.test.ts +5 -94
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +495 -0
- package/src/modules/workspace/startup/kb-git.ts +94 -0
- package/src/modules/workspace/startup/kb-startup-runner.ts +597 -0
- package/src/modules/workspace/startup/on-server-start.ts +97 -0
- package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +636 -0
- package/src/modules/workspace/{plugins-migration.ts → startup/steps/groups-to-plugins.step.ts} +342 -260
- package/src/modules/workspace/startup/steps/roles-yaml.step.ts +71 -0
- package/src/modules/workspace/startup/steps/seed-tree.ts +115 -0
- package/src/modules/workspace/startup/steps/template-files.step.ts +360 -0
- package/src/modules/workspace/workspace.service.ts +2 -106
- package/src/modules/workspace/__tests__/kb-seed.service.test.ts +0 -512
- package/src/modules/workspace/__tests__/plugins-migration.test.ts +0 -427
- package/src/modules/workspace/kb-seed.interface.ts +0 -36
- package/src/modules/workspace/kb-seed.service.ts +0 -584
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { renderRolesYaml } from '../../../access/render-roles-yaml.js';
|
|
4
|
+
import type { OnServerStart, ServerStartContext, StepResult } from '../on-server-start.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Write `roles.yaml` on any protected branch missing it, generated from the
|
|
8
|
+
* configured seed admins (`ADMIN_EMAIL`) via the shared validated renderer
|
|
9
|
+
* (../../../access/render-roles-yaml.ts — a malformed email stops the boot
|
|
10
|
+
* with a message naming the fix). Never part of the template: a template copy
|
|
11
|
+
* would seed repos with a stale hard-coded Admin list.
|
|
12
|
+
*
|
|
13
|
+
* An existing regular file — whatever it says — is the operator's and is left
|
|
14
|
+
* alone.
|
|
15
|
+
*
|
|
16
|
+
* A missing file with NO admins configured is a declared `skipped`: the lazy
|
|
17
|
+
* top-up warned and left the file absent, and under the startup contract that
|
|
18
|
+
* survivable-but-incomplete state is the step's to declare, not to bury in a
|
|
19
|
+
* log. Access resolution will fail until an Admin roles.yaml exists.
|
|
20
|
+
*/
|
|
21
|
+
export class RolesYamlStep implements OnServerStart {
|
|
22
|
+
readonly name = 'roles-yaml';
|
|
23
|
+
|
|
24
|
+
constructor(private readonly seedAdminEmails: readonly string[]) {}
|
|
25
|
+
|
|
26
|
+
async run(ctx: ServerStartContext): Promise<StepResult> {
|
|
27
|
+
const adminless: string[] = [];
|
|
28
|
+
for (const branch of await ctx.protectedBranches()) {
|
|
29
|
+
const repoDir = await branch.repoDir();
|
|
30
|
+
// `lstat`, not `exists`: a DIRECTORY or SYMLINK squatting the name would
|
|
31
|
+
// read as "present" and be skipped over — reporting success over a
|
|
32
|
+
// knowledge base whose access roster cannot be read. Fail closed, same
|
|
33
|
+
// as template-files' squatter checks: this is a state a human must fix.
|
|
34
|
+
const found = await lstatOrNull(path.join(repoDir, 'roles.yaml'));
|
|
35
|
+
if (found) {
|
|
36
|
+
if (found.isFile()) continue; // the operator's file — leave it alone
|
|
37
|
+
throw new Error(
|
|
38
|
+
`"roles.yaml" on branch "${branch.name}" exists but is not a regular file ` +
|
|
39
|
+
`(${found.isSymbolicLink() ? 'symlink' : found.isDirectory() ? 'directory' : 'special file'}). ` +
|
|
40
|
+
'Remove or rename it — access loading requires this name to be a readable file at the repository root.',
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
if (this.seedAdminEmails.length === 0) {
|
|
44
|
+
adminless.push(branch.name);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
branch.write('roles.yaml', renderRolesYaml(this.seedAdminEmails));
|
|
48
|
+
branch.note('Add roles.yaml granting Admin to the configured seed admins');
|
|
49
|
+
}
|
|
50
|
+
if (adminless.length > 0) {
|
|
51
|
+
// With an empty admin list nothing was declared for ANY branch, so the
|
|
52
|
+
// skip discards nothing another branch needed.
|
|
53
|
+
return {
|
|
54
|
+
outcome: 'skipped',
|
|
55
|
+
reason:
|
|
56
|
+
`roles.yaml is missing on ${adminless.join(', ')} and ADMIN_EMAIL is unset — leaving it absent. ` +
|
|
57
|
+
'Access resolution will fail until an Admin roles.yaml exists; set ADMIN_EMAIL or add roles.yaml manually.',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return { outcome: 'ok' };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** `lstat` without the throw — null when nothing is at `p`. */
|
|
65
|
+
async function lstatOrNull(p: string): Promise<import('node:fs').Stats | null> {
|
|
66
|
+
try {
|
|
67
|
+
return await fs.lstat(p);
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { renderRolesYaml } from '../../../access/render-roles-yaml.js';
|
|
4
|
+
import { TEMPLATE_SOURCE_FALLBACKS, reservedRootDirs, templateSource } from './template-files.step.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The empty-remote seed builder the runner takes as `buildSeedTree`: the full
|
|
8
|
+
* template tree, every reserved root, and a generated roles.yaml. DIRECT fs
|
|
9
|
+
* writes are correct here — the target is a temp directory the runner inits,
|
|
10
|
+
* commits and pushes itself, not a branch handle with buffered ops.
|
|
11
|
+
*
|
|
12
|
+
* Resolves to the repo-relative paths it GENERATED (roles.yaml plus each
|
|
13
|
+
* reserved root's .gitkeep): a template `.gitignore` rule could match any of
|
|
14
|
+
* them, and the runner force-adds them after `git add -A` so a required seed
|
|
15
|
+
* file can never be silently dropped from the seed commit.
|
|
16
|
+
*
|
|
17
|
+
* `extraRootDirs` is validated eagerly, at composition time: a bad value
|
|
18
|
+
* should fail at boot beside the rest of the wiring, not mid-seed of
|
|
19
|
+
* somebody's knowledge base.
|
|
20
|
+
*/
|
|
21
|
+
export function buildSeedTree(
|
|
22
|
+
templateDir: string,
|
|
23
|
+
extraRootDirs: readonly string[],
|
|
24
|
+
seedAdminEmails: readonly string[],
|
|
25
|
+
): (dir: string) => Promise<string[]> {
|
|
26
|
+
const requiredDirs = reservedRootDirs(extraRootDirs);
|
|
27
|
+
return async (dir) => {
|
|
28
|
+
const generated: string[] = [];
|
|
29
|
+
await copyTemplateTree(templateDir, dir);
|
|
30
|
+
// Reserved roots the template does not carry. Without this the seed commit
|
|
31
|
+
// would hold only what the template has, and a distribution's own roots
|
|
32
|
+
// would appear a step later, when the first startup phase tops them up —
|
|
33
|
+
// the same folders, arriving in a second commit for no reason. Keyed on
|
|
34
|
+
// the DIRECTORY's existence: a template already carrying content under a
|
|
35
|
+
// root never gets a pointless placeholder beside it.
|
|
36
|
+
for (const rootDir of requiredDirs) {
|
|
37
|
+
const abs = path.join(dir, rootDir);
|
|
38
|
+
const found = await lstatOrNull(abs);
|
|
39
|
+
if (found) {
|
|
40
|
+
if (found.isDirectory()) continue;
|
|
41
|
+
// Only a template shipping a FILE under a reserved name reaches this —
|
|
42
|
+
// a broken build, not a broken knowledge base.
|
|
43
|
+
throw new Error(`KB root "${rootDir}" exists in the template but is not a directory.`);
|
|
44
|
+
}
|
|
45
|
+
await fs.mkdir(abs, { recursive: true });
|
|
46
|
+
await fs.writeFile(path.join(abs, '.gitkeep'), '', 'utf8');
|
|
47
|
+
generated.push(`${rootDir}/.gitkeep`);
|
|
48
|
+
}
|
|
49
|
+
// Generated, never templated — see roles-yaml.step.ts. The runner refuses
|
|
50
|
+
// to seed an empty remote with no admins, so the list is non-empty here.
|
|
51
|
+
await fs.writeFile(path.join(dir, 'roles.yaml'), renderRolesYaml(seedAdminEmails), 'utf8');
|
|
52
|
+
generated.push('roles.yaml');
|
|
53
|
+
return generated;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Copy the entire template tree into `dest` (roles.yaml isn't in it — it's generated). */
|
|
58
|
+
async function copyTemplateTree(templateDir: string, dest: string): Promise<void> {
|
|
59
|
+
const packableToReal = new Map(
|
|
60
|
+
Object.entries(TEMPLATE_SOURCE_FALLBACKS).map(([real, packable]) => [packable, real]),
|
|
61
|
+
);
|
|
62
|
+
const walk = async (relDir: string): Promise<void> => {
|
|
63
|
+
const abs = path.join(templateDir, relDir);
|
|
64
|
+
const entries = await fs.readdir(abs, { withFileTypes: true });
|
|
65
|
+
for (const entry of entries) {
|
|
66
|
+
// Never copy a git dir: a KB_TEMPLATE_DIR that is itself a working tree
|
|
67
|
+
// (this repo in a Docker build) must not seed its history into the KB.
|
|
68
|
+
if (entry.name === '.git') continue;
|
|
69
|
+
const rel = relDir ? path.join(relDir, entry.name) : entry.name;
|
|
70
|
+
if (entry.isDirectory()) {
|
|
71
|
+
await walk(rel);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
// A packable spelling at the template root seeds under its REAL name
|
|
75
|
+
// — unless the template also carries the literal file (a
|
|
76
|
+
// distribution's own template), which wins and is copied by its own
|
|
77
|
+
// walk entry; copying the packable twin too would clobber it.
|
|
78
|
+
const realName = relDir === '' ? packableToReal.get(entry.name) : undefined;
|
|
79
|
+
if (realName !== undefined) {
|
|
80
|
+
if (!(await exists(path.join(templateDir, realName)))) {
|
|
81
|
+
await copyTemplateFile(templateDir, realName, dest);
|
|
82
|
+
}
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
await copyTemplateFile(templateDir, rel, dest);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
await walk('');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Copy one template file (by repo-relative path) into `dest`, creating parents. */
|
|
92
|
+
async function copyTemplateFile(templateDir: string, relPath: string, dest: string): Promise<void> {
|
|
93
|
+
const from = await templateSource(templateDir, relPath);
|
|
94
|
+
const to = path.join(dest, relPath);
|
|
95
|
+
await fs.mkdir(path.dirname(to), { recursive: true });
|
|
96
|
+
await fs.copyFile(from, to);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function exists(p: string): Promise<boolean> {
|
|
100
|
+
try {
|
|
101
|
+
await fs.access(p);
|
|
102
|
+
return true;
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** `lstat` without the throw — null when nothing is at `p`. */
|
|
109
|
+
async function lstatOrNull(p: string): Promise<import('node:fs').Stats | null> {
|
|
110
|
+
try {
|
|
111
|
+
return await fs.lstat(p);
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { KNOWLEDGE_BASE_DIR, PLUGINS_DIR } from '@bevel-software/platform-shared';
|
|
4
|
+
import { IGNORE_FILENAME } from '../../bevel-ignore.js';
|
|
5
|
+
import type { KbBranch, OnServerStart, ServerStartContext, StepResult } from '../on-server-start.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The **required scaffolding** — the minimum an operational KB needs. Any of
|
|
9
|
+
* these missing from a protected branch are added at the startup phase; the
|
|
10
|
+
* sample ontology is NOT (it only seeds a fully-empty repo, see seed-tree.ts).
|
|
11
|
+
*
|
|
12
|
+
* Two kinds:
|
|
13
|
+
* - {@link REQUIRED_FILES}: repo-root files added when the file is missing.
|
|
14
|
+
* - Reserved root dirs (core's two plus a distribution's `extraRootDirs`):
|
|
15
|
+
* when a dir is entirely absent it's created by adding its `<dir>/.gitkeep`.
|
|
16
|
+
* Keyed on the *directory's* existence, not the `.gitkeep` file — so a
|
|
17
|
+
* branch that already has content under `KnowledgeBase/` never gets a
|
|
18
|
+
* pointless placeholder.
|
|
19
|
+
*
|
|
20
|
+
* `roles.yaml` is in neither, and is not part of the template at all: it is
|
|
21
|
+
* generated from `ADMIN_EMAIL` (see roles-yaml.step.ts), so a repo can't be
|
|
22
|
+
* seeded with a stale hard-coded Admin list.
|
|
23
|
+
*/
|
|
24
|
+
export const REQUIRED_FILES: readonly string[] = ['access.md', 'AGENTS.md', '.bevelignore', '.gitignore'];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Repo-root files the startup phase GENERATES rather than copies — today just
|
|
28
|
+
* `roles.yaml`, rendered from `ADMIN_EMAIL` (see roles-yaml.step.ts and
|
|
29
|
+
* seed-tree.ts). Reserved-root validation must treat these exactly like
|
|
30
|
+
* {@link REQUIRED_FILES}: a root claiming a generated name is the same silent
|
|
31
|
+
* typo with the same silent outcome.
|
|
32
|
+
*/
|
|
33
|
+
export const GENERATED_FILES: readonly string[] = ['roles.yaml'];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Destination name → the packable spelling the template may carry instead.
|
|
37
|
+
* npm strips every file named `.gitignore` from a published tarball, so the
|
|
38
|
+
* packaged template cannot ship one under its real name (see
|
|
39
|
+
* {@link templateSource}).
|
|
40
|
+
*/
|
|
41
|
+
export const TEMPLATE_SOURCE_FALLBACKS: Readonly<Record<string, string>> = {
|
|
42
|
+
'.gitignore': 'gitignore.template',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The two roots CORE gives a knowledge base: the ontologies, and the plugins
|
|
47
|
+
* that hold skills and tools.
|
|
48
|
+
*
|
|
49
|
+
* `Data/`, `Agents/` and `Pipelines/` are deliberately absent. They scaffold
|
|
50
|
+
* the agentic execution layer, which is not part of this platform — a core
|
|
51
|
+
* deployment that created them would be handing every operator three empty
|
|
52
|
+
* folders it has no feature to fill. A distribution that DOES own that layer
|
|
53
|
+
* passes them as `extraRootDirs` (and ships a template carrying their
|
|
54
|
+
* READMEs); the names stay reserved in `kb-layout.ts` either way, so a KB
|
|
55
|
+
* that has them still renders them as roots rather than folding them into
|
|
56
|
+
* Knowledge.
|
|
57
|
+
*/
|
|
58
|
+
const CORE_REQUIRED_DIRS: readonly string[] = [KNOWLEDGE_BASE_DIR, PLUGINS_DIR];
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A reserved root must be ONE path segment — `Data`, not `Data/x`, `../x` or
|
|
62
|
+
* `/x`. The name is joined onto the repo root, so anything else writes outside
|
|
63
|
+
* the repo being maintained.
|
|
64
|
+
*
|
|
65
|
+
* Deliberately NOT a check against the reserved-root set in `kb-layout.ts`:
|
|
66
|
+
* `Data`, `Agents` and `Pipelines` are all in that set, and they are precisely
|
|
67
|
+
* what a distribution passes here. Being reserved is what makes a name worth
|
|
68
|
+
* claiming — the file tree renders it as its own root instead of folding it
|
|
69
|
+
* into Knowledge — so rejecting reserved names would reject the only real use.
|
|
70
|
+
*/
|
|
71
|
+
function assertRootSegment(dir: string): void {
|
|
72
|
+
if (!dir || dir === '.' || dir === '..' || dir.includes('/') || dir.includes('\\') || path.isAbsolute(dir)) {
|
|
73
|
+
throw new Error(`Reserved KB root must be a single path segment (no separators, no ".."); got "${dir}"`);
|
|
74
|
+
}
|
|
75
|
+
// `.git` can never be a KB root: writing `<dir>/.gitkeep` under it would
|
|
76
|
+
// corrupt the clone's own metadata. Any case — Windows filesystems treat
|
|
77
|
+
// `.GIT` as the same directory.
|
|
78
|
+
if (dir.toLowerCase() === '.git') {
|
|
79
|
+
throw new Error(`Reserved KB root must not be ".git" (any case); got "${dir}"`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Core's guaranteed roots plus a distribution's extras, validated once at
|
|
85
|
+
* composition time: every entry is joined onto the repo root and onto
|
|
86
|
+
* `<dir>/.gitkeep`, so a separator or a `..` would write outside the repo
|
|
87
|
+
* being maintained, and a bad value should fail at boot beside the rest of
|
|
88
|
+
* the wiring rather than part-way through maintaining somebody's knowledge
|
|
89
|
+
* base. Shared with the empty-remote seed builder (seed-tree.ts) so the two
|
|
90
|
+
* paths can never disagree about what a deployment guarantees.
|
|
91
|
+
*/
|
|
92
|
+
export function reservedRootDirs(extraRootDirs: readonly string[]): readonly string[] {
|
|
93
|
+
for (const dir of extraRootDirs) {
|
|
94
|
+
assertRootSegment(dir);
|
|
95
|
+
// A root named after a required OR generated FILE is a typo with a silent
|
|
96
|
+
// outcome: the file is laid down first, so the dir check finds the path
|
|
97
|
+
// taken and skips it, and the directory the caller asked for never appears
|
|
98
|
+
// with nothing said about why.
|
|
99
|
+
if (REQUIRED_FILES.includes(dir) || GENERATED_FILES.includes(dir)) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`Reserved KB root "${dir}" collides with a required or generated file of the same name`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return [...CORE_REQUIRED_DIRS, ...extraRootDirs];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Where `relPath`'s template content actually lives. npm refuses to pack
|
|
110
|
+
* files named `.gitignore` — every such file is silently stripped from the
|
|
111
|
+
* published tarball — so the packaged template ships the KB's gitignore
|
|
112
|
+
* under a packable name and the seeder writes it to its real one. A
|
|
113
|
+
* template carrying the literal file (a distribution's own
|
|
114
|
+
* KB_TEMPLATE_DIR, or this repo's tree in a Docker build) wins outright:
|
|
115
|
+
* the mapping is a fallback, never a rename.
|
|
116
|
+
*/
|
|
117
|
+
export async function templateSource(templateDir: string, relPath: string): Promise<string> {
|
|
118
|
+
const direct = path.join(templateDir, relPath);
|
|
119
|
+
if (await exists(direct)) return direct;
|
|
120
|
+
const packable = TEMPLATE_SOURCE_FALLBACKS[relPath];
|
|
121
|
+
if (packable !== undefined) {
|
|
122
|
+
const fallback = path.join(templateDir, packable);
|
|
123
|
+
if (await exists(fallback)) return fallback;
|
|
124
|
+
}
|
|
125
|
+
return direct; // let the ENOENT surface under the name the caller asked for
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function exists(p: string): Promise<boolean> {
|
|
129
|
+
try {
|
|
130
|
+
await fs.access(p);
|
|
131
|
+
return true;
|
|
132
|
+
} catch {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** `lstat` without the throw — null when nothing is at `p`. */
|
|
138
|
+
async function lstatOrNull(p: string): Promise<import('node:fs').Stats | null> {
|
|
139
|
+
try {
|
|
140
|
+
return await fs.lstat(p);
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The template top-up as an {@link OnServerStart} step: add any missing base
|
|
148
|
+
* scaffolding to every PROTECTED branch, and keep the managed AGENTS.md
|
|
149
|
+
* current. Drafts are deliberately out of scope — whatever the protected
|
|
150
|
+
* branches gain, drafts fork from; a scaffolding addition on a draft would
|
|
151
|
+
* surface as noise in its change request's diff. (Unlike the Groups→Plugins
|
|
152
|
+
* rename, a missing file diffs as one file, not the whole tree — so the
|
|
153
|
+
* uniform-application argument does not bite here.)
|
|
154
|
+
*
|
|
155
|
+
* Everything is DECLARED on the branch handle; reads go against the pre-step
|
|
156
|
+
* tree via `repoDir()`. Fail-open behavior from the lazy top-up (best-effort,
|
|
157
|
+
* never throws) is deliberately gone: an unexpected state — a file squatting
|
|
158
|
+
* a reserved root name — now throws and stops the boot, which is the phase's
|
|
159
|
+
* contract for states a human must look at.
|
|
160
|
+
*/
|
|
161
|
+
export class TemplateFilesStep implements OnServerStart {
|
|
162
|
+
readonly name = 'template-files';
|
|
163
|
+
|
|
164
|
+
private readonly requiredDirs: readonly string[];
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* @param extraRootDirs Additional root folders this distribution reserves,
|
|
168
|
+
* on top of core's two. Their `.gitkeep` is written
|
|
169
|
+
* directly rather than copied, so a distribution can
|
|
170
|
+
* claim a root without also shipping a template entry
|
|
171
|
+
* for it.
|
|
172
|
+
*/
|
|
173
|
+
constructor(extraRootDirs: readonly string[] = []) {
|
|
174
|
+
this.requiredDirs = reservedRootDirs(extraRootDirs);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async run(ctx: ServerStartContext): Promise<StepResult> {
|
|
178
|
+
for (const branch of await ctx.protectedBranches()) {
|
|
179
|
+
await this.topUp(ctx.templateDir, branch);
|
|
180
|
+
}
|
|
181
|
+
return { outcome: 'ok' };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private async topUp(templateDir: string, branch: KbBranch): Promise<void> {
|
|
185
|
+
const repoDir = await branch.repoDir();
|
|
186
|
+
const added: string[] = [];
|
|
187
|
+
|
|
188
|
+
for (const rel of REQUIRED_FILES) {
|
|
189
|
+
// `lstat`, not `exists`: a DIRECTORY or SYMLINK squatting a required
|
|
190
|
+
// file's name would read as "present", and a skip-if-present check
|
|
191
|
+
// would then report success over a knowledge base whose root access
|
|
192
|
+
// policy (say) cannot be read. Fail-closed, same as the reserved-root
|
|
193
|
+
// squatting check below: this is a state a human must fix.
|
|
194
|
+
const found = await lstatOrNull(path.join(repoDir, rel));
|
|
195
|
+
if (found) {
|
|
196
|
+
if (found.isFile()) continue;
|
|
197
|
+
throw new Error(
|
|
198
|
+
`Required KB file "${rel}" on branch "${branch.name}" exists but is not a regular file ` +
|
|
199
|
+
`(${found.isSymbolicLink() ? 'symlink' : found.isDirectory() ? 'directory' : 'special file'}). ` +
|
|
200
|
+
'Remove or rename it — the platform requires this name to be a readable file.',
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
let content: Uint8Array | string = await readTemplate(templateDir, rel);
|
|
204
|
+
// The on-disk merge below only runs against an EXISTING ignore file; a
|
|
205
|
+
// freshly-declared one was merely assumed to carry the AGENTS.md rule —
|
|
206
|
+
// true of the packaged template, not necessarily of a distribution's
|
|
207
|
+
// custom one. Make it true here, so the managed conventions doc is
|
|
208
|
+
// hidden from the file tree from the first boot either way.
|
|
209
|
+
if (rel === IGNORE_FILENAME) {
|
|
210
|
+
content = withIgnorePattern(new TextDecoder().decode(content), 'AGENTS.md');
|
|
211
|
+
}
|
|
212
|
+
branch.write(rel, content);
|
|
213
|
+
added.push(rel);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// AGENTS.md left VISIBLE by a stale `.bevelignore` is closed here — and
|
|
217
|
+
// UNCONDITIONALLY, not only when the file was just added: a KB whose
|
|
218
|
+
// AGENTS.md predates the CLAUDE.md→AGENTS.md rename has an ignore file
|
|
219
|
+
// that lists the old name and knows nothing of the new one, so the
|
|
220
|
+
// conventions doc shows up in the file tree and the agent view.
|
|
221
|
+
// Idempotent: an ignore file already carrying the rule — or absent, in
|
|
222
|
+
// which case the template's copy declared above arrives with the rule in
|
|
223
|
+
// it — changes nothing and produces no note. Deliberately checked by
|
|
224
|
+
// LINE PRESENCE, not effective outcome: a later `!AGENTS.md` negation is
|
|
225
|
+
// the operator explicitly choosing to SHOW the file, and hiding it is a
|
|
226
|
+
// default this step provides, not a mandate it re-imposes every boot.
|
|
227
|
+
added.push(...(await mergeIgnorePattern(repoDir, branch, 'AGENTS.md')));
|
|
228
|
+
|
|
229
|
+
// AGENTS.md is MANAGED, not merely seeded: the platform owns its content,
|
|
230
|
+
// and a stale copy is replaced with the packaged template's every startup
|
|
231
|
+
// phase. The file's own header says so, which is what makes overwriting
|
|
232
|
+
// edits a stated contract instead of a surprise.
|
|
233
|
+
let agentsRefreshed = false;
|
|
234
|
+
if (!added.includes('AGENTS.md') && (await templateDiffers(templateDir, repoDir, 'AGENTS.md'))) {
|
|
235
|
+
branch.write('AGENTS.md', await readTemplate(templateDir, 'AGENTS.md'));
|
|
236
|
+
added.push('AGENTS.md');
|
|
237
|
+
agentsRefreshed = true;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
added.push(...this.ensureRequiredDirs(repoDir, branch, await this.missingDirs(repoDir)));
|
|
241
|
+
|
|
242
|
+
if (added.length === 0) return;
|
|
243
|
+
// One honest line; it becomes the commit subject when this step is the
|
|
244
|
+
// first to dirty the branch.
|
|
245
|
+
branch.note(
|
|
246
|
+
agentsRefreshed && added.length === 1
|
|
247
|
+
? 'Update AGENTS.md to the current platform template'
|
|
248
|
+
: `Add missing KB scaffolding: ${added.join(', ')}`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Which reserved roots are absent — and which are SQUATTED. `lstat`, not
|
|
254
|
+
* `exists`: `fs.access` answers "is there something here?", which is true of
|
|
255
|
+
* a FILE named `Plugins` — and a skip-if-present check would then do nothing
|
|
256
|
+
* and report success, leaving a knowledge base permanently missing a root it
|
|
257
|
+
* claims to guarantee. `lstat` rather than `stat` so a SYMLINK is rejected
|
|
258
|
+
* too: a link named `Plugins` is not a KB layout, and one pointing outside
|
|
259
|
+
* the repo would make every later write into it land somewhere nobody asked
|
|
260
|
+
* for. A squatter THROWS — under this phase's fail-closed contract that
|
|
261
|
+
* stops the boot, which such a state deserves.
|
|
262
|
+
*/
|
|
263
|
+
private async missingDirs(repoDir: string): Promise<string[]> {
|
|
264
|
+
const missing: string[] = [];
|
|
265
|
+
for (const rootDir of this.requiredDirs) {
|
|
266
|
+
const found = await lstatOrNull(path.join(repoDir, rootDir));
|
|
267
|
+
if (found) {
|
|
268
|
+
if (found.isDirectory()) continue;
|
|
269
|
+
throw new Error(
|
|
270
|
+
`KB root "${rootDir}" exists but is not a directory ` +
|
|
271
|
+
`(${found.isSymbolicLink() ? 'symlink' : 'file'}). Remove or rename it — ` +
|
|
272
|
+
'the platform requires this name to be a folder.',
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
missing.push(rootDir);
|
|
276
|
+
}
|
|
277
|
+
return missing;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Declare each missing reserved root as an empty `<dir>/.gitkeep`.
|
|
282
|
+
* WRITTEN, not copied from the template: a `.gitkeep` is empty by
|
|
283
|
+
* definition, and requiring a template entry per root would mean a
|
|
284
|
+
* distribution could not reserve one without forking the packaged template.
|
|
285
|
+
*/
|
|
286
|
+
private ensureRequiredDirs(repoDir: string, branch: KbBranch, missing: readonly string[]): string[] {
|
|
287
|
+
const added: string[] = [];
|
|
288
|
+
for (const rootDir of missing) {
|
|
289
|
+
branch.write(`${rootDir}/.gitkeep`, '');
|
|
290
|
+
added.push(`${rootDir}/.gitkeep`);
|
|
291
|
+
}
|
|
292
|
+
return added;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** The template's content for `relPath`, bytes as shipped. */
|
|
297
|
+
async function readTemplate(templateDir: string, relPath: string): Promise<Uint8Array> {
|
|
298
|
+
return fs.readFile(await templateSource(templateDir, relPath));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Whether the repo's copy of `relPath` differs from the template's, modulo
|
|
303
|
+
* line endings — a CRLF checkout of identical content must read as "same",
|
|
304
|
+
* or the managed-file refresh would commit churn on every boot forever.
|
|
305
|
+
*/
|
|
306
|
+
async function templateDiffers(templateDir: string, repoDir: string, relPath: string): Promise<boolean> {
|
|
307
|
+
const norm = (text: string) => text.replace(/\r\n?/g, '\n');
|
|
308
|
+
const [current, template] = await Promise.all([
|
|
309
|
+
fs.readFile(path.join(repoDir, relPath), 'utf8'),
|
|
310
|
+
templateSource(templateDir, relPath).then((from) => fs.readFile(from, 'utf8')),
|
|
311
|
+
]);
|
|
312
|
+
return norm(current) !== norm(template);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Ensure `.bevelignore` carries `pattern`, declaring the appended content when
|
|
317
|
+
* absent. Returns the paths changed, for the note.
|
|
318
|
+
*
|
|
319
|
+
* APPENDS — never rewrites. The file is the operator's, and every rule
|
|
320
|
+
* already in it is theirs to keep; this adds one line under a comment saying
|
|
321
|
+
* where it came from. Absent file, or a file that already lists the pattern,
|
|
322
|
+
* is a no-op — an absent file means the template's copy (declared in the same
|
|
323
|
+
* step) arrives with the pattern in it.
|
|
324
|
+
*
|
|
325
|
+
* Matched line-wise rather than by substring: a rule for `Plugins/AGENTS.md`
|
|
326
|
+
* is not a rule for the root `AGENTS.md`, and treating it as one would leave
|
|
327
|
+
* the mismatch this exists to close.
|
|
328
|
+
*/
|
|
329
|
+
async function mergeIgnorePattern(repoDir: string, branch: KbBranch, pattern: string): Promise<string[]> {
|
|
330
|
+
let current: string;
|
|
331
|
+
try {
|
|
332
|
+
current = await fs.readFile(path.join(repoDir, IGNORE_FILENAME), 'utf8');
|
|
333
|
+
} catch {
|
|
334
|
+
// No ignore file — the copy declared from the template arrives with the
|
|
335
|
+
// pattern in it (guaranteed at declaration time, see the required-files
|
|
336
|
+
// loop above).
|
|
337
|
+
return [];
|
|
338
|
+
}
|
|
339
|
+
const merged = withIgnorePattern(current, pattern);
|
|
340
|
+
if (merged === current) return [];
|
|
341
|
+
branch.write(IGNORE_FILENAME, merged);
|
|
342
|
+
return [IGNORE_FILENAME];
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* `text` with `pattern` guaranteed present as a LINE — appended under a
|
|
347
|
+
* comment naming its origin when absent, returned unchanged when present.
|
|
348
|
+
* Line-wise match, same rationale as {@link mergeIgnorePattern}.
|
|
349
|
+
*
|
|
350
|
+
* An explicit `!pattern` line also returns the text unchanged: that is the
|
|
351
|
+
* operator choosing to SHOW the file, and ordered matching means a positive
|
|
352
|
+
* line appended after it would win and silently defeat the choice. Hiding
|
|
353
|
+
* the conventions doc is a default this provides, never a mandate.
|
|
354
|
+
*/
|
|
355
|
+
function withIgnorePattern(text: string, pattern: string): string {
|
|
356
|
+
const lines = text.split('\n').map((l) => l.trim());
|
|
357
|
+
if (lines.includes(pattern) || lines.includes(`!${pattern}`)) return text;
|
|
358
|
+
const separator = text.endsWith('\n') ? '' : '\n';
|
|
359
|
+
return `${text}${separator}\n# Added by the platform: the conventions doc is not node content.\n${pattern}\n`;
|
|
360
|
+
}
|