@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.
Files changed (84) hide show
  1. package/dist/core/core-ports.d.ts +7 -0
  2. package/dist/core/core-ports.d.ts.map +1 -1
  3. package/dist/core/core-ports.js.map +1 -1
  4. package/dist/core/create-core-server.d.ts.map +1 -1
  5. package/dist/core/create-core-server.js +24 -11
  6. package/dist/core/create-core-server.js.map +1 -1
  7. package/dist/core/create-core-services.d.ts +7 -2
  8. package/dist/core/create-core-services.d.ts.map +1 -1
  9. package/dist/core/create-core-services.js +39 -18
  10. package/dist/core/create-core-services.js.map +1 -1
  11. package/dist/modules/access/access.routes.d.ts +3 -1
  12. package/dist/modules/access/access.routes.d.ts.map +1 -1
  13. package/dist/modules/access/access.routes.js +4 -2
  14. package/dist/modules/access/access.routes.js.map +1 -1
  15. package/dist/modules/access/render-roles-yaml.d.ts +22 -0
  16. package/dist/modules/access/render-roles-yaml.d.ts.map +1 -0
  17. package/dist/modules/access/render-roles-yaml.js +56 -0
  18. package/dist/modules/access/render-roles-yaml.js.map +1 -0
  19. package/dist/modules/access/roles-admin.service.d.ts +15 -1
  20. package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
  21. package/dist/modules/access/roles-admin.service.js +30 -15
  22. package/dist/modules/access/roles-admin.service.js.map +1 -1
  23. package/dist/modules/settings/setup.routes.d.ts +10 -1
  24. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  25. package/dist/modules/settings/setup.routes.js +111 -6
  26. package/dist/modules/settings/setup.routes.js.map +1 -1
  27. package/dist/modules/workspace/startup/kb-git.d.ts +23 -0
  28. package/dist/modules/workspace/startup/kb-git.d.ts.map +1 -0
  29. package/dist/modules/workspace/startup/kb-git.js +86 -0
  30. package/dist/modules/workspace/startup/kb-git.js.map +1 -0
  31. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +74 -0
  32. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -0
  33. package/dist/modules/workspace/startup/kb-startup-runner.js +528 -0
  34. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -0
  35. package/dist/modules/workspace/startup/on-server-start.d.ts +105 -0
  36. package/dist/modules/workspace/startup/on-server-start.d.ts.map +1 -0
  37. package/dist/modules/workspace/startup/on-server-start.js +21 -0
  38. package/dist/modules/workspace/startup/on-server-start.js.map +1 -0
  39. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.d.ts +46 -0
  40. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.d.ts.map +1 -0
  41. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js +492 -0
  42. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js.map +1 -0
  43. package/dist/modules/workspace/startup/steps/roles-yaml.step.d.ts +23 -0
  44. package/dist/modules/workspace/startup/steps/roles-yaml.step.d.ts.map +1 -0
  45. package/dist/modules/workspace/startup/steps/roles-yaml.step.js +69 -0
  46. package/dist/modules/workspace/startup/steps/roles-yaml.step.js.map +1 -0
  47. package/dist/modules/workspace/startup/steps/seed-tree.d.ts +17 -0
  48. package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -0
  49. package/dist/modules/workspace/startup/steps/seed-tree.js +109 -0
  50. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -0
  51. package/dist/modules/workspace/startup/steps/template-files.step.d.ts +103 -0
  52. package/dist/modules/workspace/startup/steps/template-files.step.d.ts.map +1 -0
  53. package/dist/modules/workspace/startup/steps/template-files.step.js +337 -0
  54. package/dist/modules/workspace/startup/steps/template-files.step.js.map +1 -0
  55. package/dist/modules/workspace/workspace.service.d.ts +0 -35
  56. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  57. package/dist/modules/workspace/workspace.service.js +2 -101
  58. package/dist/modules/workspace/workspace.service.js.map +1 -1
  59. package/kb-template/gitignore.template +16 -0
  60. package/package.json +3 -3
  61. package/src/core/core-ports.ts +7 -0
  62. package/src/core/create-core-server.ts +30 -11
  63. package/src/core/create-core-services.ts +43 -22
  64. package/src/modules/access/__tests__/roles-admin.service.test.ts +24 -2
  65. package/src/modules/access/access.routes.ts +3 -0
  66. package/src/modules/access/render-roles-yaml.ts +65 -0
  67. package/src/modules/access/roles-admin.service.ts +32 -14
  68. package/src/modules/settings/__tests__/setup.routes.test.ts +124 -3
  69. package/src/modules/settings/setup.routes.ts +112 -5
  70. package/src/modules/workspace/__tests__/workspace.service.test.ts +5 -94
  71. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +495 -0
  72. package/src/modules/workspace/startup/kb-git.ts +94 -0
  73. package/src/modules/workspace/startup/kb-startup-runner.ts +597 -0
  74. package/src/modules/workspace/startup/on-server-start.ts +97 -0
  75. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +636 -0
  76. package/src/modules/workspace/{plugins-migration.ts → startup/steps/groups-to-plugins.step.ts} +342 -260
  77. package/src/modules/workspace/startup/steps/roles-yaml.step.ts +71 -0
  78. package/src/modules/workspace/startup/steps/seed-tree.ts +115 -0
  79. package/src/modules/workspace/startup/steps/template-files.step.ts +360 -0
  80. package/src/modules/workspace/workspace.service.ts +2 -106
  81. package/src/modules/workspace/__tests__/kb-seed.service.test.ts +0 -512
  82. package/src/modules/workspace/__tests__/plugins-migration.test.ts +0 -427
  83. package/src/modules/workspace/kb-seed.interface.ts +0 -36
  84. package/src/modules/workspace/kb-seed.service.ts +0 -584
@@ -1,584 +0,0 @@
1
- import fs from 'node:fs/promises';
2
- import os from 'node:os';
3
- import path from 'node:path';
4
- import { execFile } from 'node:child_process';
5
- import { promisify } from 'node:util';
6
- import { KNOWLEDGE_BASE_DIR, LEGACY_GROUPS_DIR, PLUGINS_DIR } from '@bevel-software/platform-shared';
7
- import { IGNORE_FILENAME } from './bevel-ignore.js';
8
- import { migrateGroupsToPlugins } from './plugins-migration.js';
9
- import type { IKbSeedService } from './kb-seed.interface.js';
10
-
11
- const execFileAsync = promisify(execFile);
12
-
13
- /**
14
- * Fallback committer identity for seed commits. Mirrors the workspace clone's
15
- * bot identity so seed commits attribute to the same bot in `git log`.
16
- */
17
- const BOT_NAME = 'Bevel Workflow';
18
- const BOT_EMAIL = 'bevel-workflow@bevel.software';
19
-
20
- /**
21
- * The **required scaffolding** — the minimum an operational KB needs. When a
22
- * branch is loaded, any of these that are missing are added to it; the sample
23
- * ontology is NOT (it only seeds a fully-empty repo).
24
- *
25
- * Two kinds:
26
- * - {@link REQUIRED_FILES}: repo-root files added when the file is missing.
27
- * - {@link CORE_REQUIRED_DIRS} (plus any `extraDirs`): well-known root dirs the
28
- * app expects to exist; when a dir is entirely absent it's created by adding
29
- * its `<dir>/.gitkeep`. Keyed on the *directory's* existence, not the
30
- * `.gitkeep` file — so a branch that already has content under
31
- * `KnowledgeBase/` never gets a pointless placeholder.
32
- *
33
- * `roles.yaml` is in neither, and is not part of the template at all: it is
34
- * generated from `ADMIN_EMAIL` (see {@link renderRolesYaml}) and written
35
- * directly, so a repo can't be seeded with a stale hard-coded Admin list.
36
- */
37
- const REQUIRED_FILES: readonly string[] = ['access.md', 'AGENTS.md', '.bevelignore', '.gitignore'];
38
-
39
- /**
40
- * The two roots CORE gives a knowledge base: the ontologies, and the plugins
41
- * that hold skills and tools.
42
- *
43
- * `Data/`, `Agents/` and `Pipelines/` are deliberately absent. They scaffold
44
- * the agentic execution layer, which is not part of this platform — a core
45
- * deployment that created them would be handing every operator three empty
46
- * folders it has no feature to fill. A distribution that DOES own that layer
47
- * passes them as `extraDirs` (and ships a template carrying their READMEs);
48
- * the names stay reserved in `kb-layout.ts` either way, so a KB that has them
49
- * still renders them as roots rather than folding them into Knowledge.
50
- */
51
- export const CORE_REQUIRED_DIRS: readonly string[] = [KNOWLEDGE_BASE_DIR, PLUGINS_DIR];
52
-
53
- /**
54
- * A reserved root must be ONE path segment — `Data`, not `Data/x`, `../x` or
55
- * `/x`. The name is joined onto the repo root, so anything else writes outside
56
- * the repo being seeded.
57
- *
58
- * Deliberately NOT a check against the reserved-root set in `kb-layout.ts`:
59
- * `Data`, `Agents` and `Pipelines` are all in that set, and they are precisely
60
- * what a distribution passes here. Being reserved is what makes a name worth
61
- * claiming — the file tree renders it as its own root instead of folding it
62
- * into Knowledge — so rejecting reserved names would reject the only real use.
63
- */
64
- function assertRootSegment(dir: string): void {
65
- if (
66
- !dir ||
67
- dir === '.' ||
68
- dir === '..' ||
69
- dir.includes('/') ||
70
- dir.includes('\\') ||
71
- path.isAbsolute(dir)
72
- ) {
73
- throw new Error(
74
- `Reserved KB root must be a single path segment (no separators, no ".."); got "${dir}"`,
75
- );
76
- }
77
- }
78
-
79
- /** Redact the token from any string that might surface in a log or error. */
80
- function redact(s: string): string {
81
- const token = process.env.GITHUB_TOKEN;
82
- return token ? s.replaceAll(token, '***') : s;
83
- }
84
-
85
- /** Generate `roles.yaml` granting Admin to each seed email. */
86
- function renderRolesYaml(adminEmails: readonly string[]): string {
87
- const lines = adminEmails.map((e) => ` - ${e}`).join('\n');
88
- return (
89
- '# Identity → role mapping for access control.\n' +
90
- '# Role names are case- and whitespace-insensitive. The `Admin` role is special:\n' +
91
- '# only Admins may edit this file, and at least one Admin must always exist.\n' +
92
- '#\n' +
93
- '# Generated by the Bevel platform at KB-seed time from ADMIN_EMAIL.\n' +
94
- 'roles:\n' +
95
- ' Admin:\n' +
96
- `${lines}\n`
97
- );
98
- }
99
-
100
- /**
101
- * Seeds/tops-up the KB *remote* so the rest of the app's assumption — that the
102
- * repo it clones already carries the protected branches and base scaffolding —
103
- * holds even for a brand-new or partially-populated GitHub repo. The template
104
- * ships inside the platform (`kb-template/`); this reads from it.
105
- *
106
- * Split into two moments so the file-level top-up rides along with the clone the
107
- * app already performs, rather than doing its own extra clones:
108
- * - {@link ensureRemoteSeeded} runs once before the first clone (an empty remote
109
- * can't be `clone -b`'d): it seeds an empty repo and makes sure every
110
- * protected branch *exists* as a ref.
111
- * - {@link topUpWorkspace} runs on the freshly-cloned workspace of whatever
112
- * branch a user loads: it fills in any missing base files on that branch.
113
- */
114
- export class KbSeedService implements IKbSeedService {
115
- /** Single-flight: the first caller runs the remote pass; the rest await its result. */
116
- private inFlight: Promise<void> | null = null;
117
-
118
- /**
119
- * @param kbRepoUrl Clone/push URL of the KB remote.
120
- * @param kbTemplateDir Filesystem path to the `kb-template/` seed source.
121
- * @param protectedBranches Branch names that must exist (e.g. current/target-company-state).
122
- * @param defaultBranch The branch new checkouts land on — used as the init
123
- * branch of the empty-seed commit and the preferred
124
- * base when creating a missing protected branch.
125
- * @param seedAdminEmails Admins written into the generated `roles.yaml`.
126
- * @param gitUsername Basic-auth username for git-over-HTTPS (provider-specific).
127
- * @param extraDirs Additional root folders this distribution reserves,
128
- * on top of {@link CORE_REQUIRED_DIRS}. Their
129
- * `.gitkeep` is written directly rather than copied,
130
- * so a distribution can claim a root without also
131
- * shipping a template entry for it.
132
- */
133
- constructor(
134
- kbRepoUrl: string | (() => string),
135
- private readonly kbTemplateDir: string,
136
- protectedBranches: readonly string[] | (() => readonly string[]),
137
- defaultBranch: string | (() => string),
138
- private readonly seedAdminEmails: readonly string[],
139
- gitUsername: string | (() => string) = 'x-access-token',
140
- extraDirs: readonly string[] = [],
141
- ) {
142
- // A getter is read per-operation, so a remote supplied through the setup
143
- // screen is seeded against without restarting; a string is still accepted.
144
- this.kbRepoUrl = typeof kbRepoUrl === 'function' ? kbRepoUrl : () => kbRepoUrl;
145
- // Read when seeding, not at construction: the branch model can be supplied
146
- // through the setup screen, which happens after this object exists.
147
- this.protectedBranches =
148
- typeof protectedBranches === 'function' ? protectedBranches : () => protectedBranches;
149
- this.defaultBranch = typeof defaultBranch === 'function' ? defaultBranch : () => defaultBranch;
150
- this.gitUsername = typeof gitUsername === 'function' ? gitUsername : () => gitUsername;
151
- // NOT a getter, unlike its neighbours above: the reserved roots are named
152
- // by the composition root in code, not collected on the setup screen, so
153
- // there is nothing to re-read. Validated once, here — every entry is joined
154
- // onto the repo root and onto `<dir>/.gitkeep`, so a separator or a `..`
155
- // would write outside the repo being seeded, and a bad value should fail at
156
- // boot beside the rest of the wiring rather than part-way through seeding
157
- // somebody's knowledge base. Same contract as `KB_DIR_NAME` in `CoreConfig`.
158
- for (const dir of extraDirs) {
159
- assertRootSegment(dir);
160
- // A root named after a required FILE is a typo with a silent outcome:
161
- // the file is laid down first in both seed paths, so `ensureRequiredDirs`
162
- // finds the path taken and skips it, and the directory the caller asked
163
- // for never appears with nothing said about why.
164
- if (REQUIRED_FILES.includes(dir)) {
165
- throw new Error(`Reserved KB root "${dir}" collides with a required file of the same name`);
166
- }
167
- }
168
- this.requiredDirs = [...CORE_REQUIRED_DIRS, ...extraDirs];
169
- }
170
-
171
- private readonly kbRepoUrl: () => string;
172
- private readonly protectedBranches: () => readonly string[];
173
- private readonly defaultBranch: () => string;
174
- private readonly gitUsername: () => string;
175
- /** Root folders this deployment guarantees — core's two plus any extras. */
176
- private readonly requiredDirs: readonly string[];
177
-
178
- ensureRemoteSeeded(): Promise<void> {
179
- // Cache the promise, not just a boolean, so concurrent first-callers share
180
- // one run. On failure we clear it so a later call can retry (a transient
181
- // network blip on ls-remote shouldn't wedge seeding for the whole process).
182
- if (!this.inFlight) {
183
- this.inFlight = this.run().catch((err) => {
184
- this.inFlight = null;
185
- throw err;
186
- });
187
- }
188
- return this.inFlight;
189
- }
190
-
191
- private async run(): Promise<void> {
192
- const heads = await this.lsRemoteHeads();
193
-
194
- if (heads.size === 0) {
195
- if (this.seedAdminEmails.length === 0) {
196
- throw new Error(
197
- `KB remote ${redact(this.kbRepoUrl())} is empty and cannot be seeded: ` +
198
- 'no initial Admin was supplied. A seeded KB with no Admin is unusable ' +
199
- '(access resolution requires at least one Admin). Unreachable in ' +
200
- 'normal operation — ADMIN_EMAIL is required at boot — so this guards ' +
201
- 'a caller that constructed the seeder with an empty list.',
202
- );
203
- }
204
- await this.seedEmptyRemote();
205
- return;
206
- }
207
-
208
- // Non-empty remote: make sure every protected branch exists as a ref so a
209
- // later `clone -b <branch>` succeeds. This creates the branch pointer only;
210
- // per-branch file top-up happens in `topUpWorkspace` when the branch loads.
211
- const base = this.pickBase(heads);
212
- for (const branch of this.protectedBranches()) {
213
- if (!heads.has(branch)) {
214
- await this.createBranchOnRemote(branch, base);
215
- }
216
- }
217
- }
218
-
219
- /**
220
- * Add any missing base scaffolding to an already-cloned workspace of `branch`,
221
- * then commit + push it. Called once per fresh clone (any branch a user loads),
222
- * so the check reuses the clone the app already made instead of a dedicated one.
223
- *
224
- * Best-effort: never throws. If the push is rejected (e.g. a concurrent update
225
- * to the branch), the local scaffolding commit is rolled back so the workspace
226
- * stays consistent with origin, and the top-up is retried on a future clone.
227
- */
228
- async topUpWorkspace(repoDir: string, branch: string): Promise<void> {
229
- // Scaffolding lands on PROTECTED branches only. A draft or suggestions
230
- // branch is somebody's change-in-waiting, and a seeder commit there
231
- // surfaces as noise in their change request's diff against the default
232
- // branch (a stray scaffolding file riding along in a skill proposal was
233
- // exactly this). Whatever the protected branches are missing, they get
234
- // when THEY load — and drafts fork from them.
235
- if (!this.protectedBranches().includes(branch)) return;
236
- try {
237
- const added: string[] = [];
238
- // BEFORE the dir top-up: `ensureRequiredDirs` would otherwise create an
239
- // empty `Plugins/` next to the `Plugins/` that is about to become it, and
240
- // the migration refuses a destination that already exists — so the
241
- // scaffolding would quietly block the very migration it precedes.
242
- const migration = await migrateGroupsToPlugins(repoDir);
243
- // Notes are logged unconditionally: an advisory note (a manual that
244
- // refuses to convert) is exactly the run where the operator needs to
245
- // hear about it, and such a run changes no files.
246
- for (const note of migration.notes) console.log(`[plugins-migration] ${note}`);
247
- // `migrated` means FILES CHANGED — a note-only run stages nothing, or
248
- // the commit below would fail empty on every boot with a warning about
249
- // a migration that did nothing.
250
- if (migration.migrated) {
251
- // Stage the legacy root ONLY when the rename happened this run: `git
252
- // add -A -- Groups Plugins` fails outright on a pathspec that matches
253
- // nothing, and a reorganisation inside an existing Plugins/ tree has
254
- // no Groups/ to stage.
255
- if (migration.renamed) added.push(LEGACY_GROUPS_DIR);
256
- added.push(PLUGINS_DIR);
257
- // The rename's companion edit to the repo-root ignore file — outside
258
- // the two roots, so it needs its own pathspec to land in the commit.
259
- if (migration.ignoreRewritten) added.push(IGNORE_FILENAME);
260
- }
261
- for (const rel of REQUIRED_FILES) {
262
- if (!(await this.exists(path.join(repoDir, rel)))) {
263
- await this.copyTemplateFile(rel, repoDir);
264
- added.push(rel);
265
- // Adding AGENTS.md to a knowledge base seeded before the rename
266
- // leaves it VISIBLE: that repo's `.bevelignore` lists CLAUDE.md and
267
- // knows nothing of the new name, so the conventions doc starts
268
- // showing up in the file tree and the agent view. We created the
269
- // mismatch by adding the file, so we close it here.
270
- if (rel === 'AGENTS.md') added.push(...(await this.mergeIgnorePattern(repoDir, rel)));
271
- }
272
- }
273
- // AGENTS.md is MANAGED, not merely seeded: the platform owns its
274
- // content, and a stale copy is replaced with the packaged template's on
275
- // every top-up (each fresh clone of a protected branch — in practice,
276
- // every server restart). The file's own header says so, which is what
277
- // makes overwriting edits a stated contract instead of a surprise.
278
- let agentsRefreshed = false;
279
- if (
280
- !added.includes('AGENTS.md') &&
281
- (await this.templateDiffers(repoDir, 'AGENTS.md'))
282
- ) {
283
- await this.copyTemplateFile('AGENTS.md', repoDir);
284
- added.push('AGENTS.md');
285
- agentsRefreshed = true;
286
- }
287
- added.push(...(await this.ensureRequiredDirs(repoDir)));
288
- if (!(await this.exists(path.join(repoDir, 'roles.yaml')))) {
289
- if (this.seedAdminEmails.length > 0) {
290
- await fs.writeFile(
291
- path.join(repoDir, 'roles.yaml'),
292
- renderRolesYaml(this.seedAdminEmails),
293
- 'utf8',
294
- );
295
- added.push('roles.yaml');
296
- } else {
297
- console.warn(
298
- `[kb-seed] Branch "${branch}" is missing roles.yaml and ADMIN_EMAIL is ` +
299
- 'unset — leaving it absent. Access resolution will fail until an ' +
300
- 'Admin roles.yaml exists; set ADMIN_EMAIL or add roles.yaml manually.',
301
- );
302
- }
303
- }
304
-
305
- if (added.length === 0) return; // already fully scaffolded → no-op
306
-
307
- // Stamp the bot identity so the commit has an author even if the clone
308
- // wasn't configured with one (the workspace clone already sets the same
309
- // values, so this is a harmless no-op there).
310
- await this.stampIdentity(repoDir);
311
- // `-A` so the migration's renames stage their DELETIONS as well; a plain
312
- // `add` would commit the new tree while leaving `Plugins/` in the index.
313
- await this.git(repoDir, ['add', '-A', '--', ...added]);
314
- const message = migration.migrated
315
- ? migration.renamed
316
- ? `Move ${LEGACY_GROUPS_DIR}/ to ${PLUGINS_DIR}/ (Agent Plugins layout)`
317
- : `Reorganise ${PLUGINS_DIR}/ to the Agent Plugins layout`
318
- : agentsRefreshed && added.length === 1
319
- ? 'Update AGENTS.md to the current platform template'
320
- : `Add missing KB scaffolding: ${added.join(', ')}`;
321
- await this.git(repoDir, ['commit', '-m', message]);
322
- try {
323
- await this.git(repoDir, ['push', 'origin', `HEAD:refs/heads/${branch}`]);
324
- console.log(`[kb-seed] Topped up "${branch}" with: ${added.join(', ')}`);
325
- } catch (err) {
326
- // Roll the local branch back to origin so the workspace never sits ahead
327
- // of the remote (which would confuse the workflow git layer). The files
328
- // simply aren't present this session; a later fresh clone retries.
329
- console.warn(
330
- `[kb-seed] Could not push scaffolding to "${branch}", rolling back local commit:`,
331
- err instanceof Error ? redact(err.message) : String(err),
332
- );
333
- await this.git(repoDir, ['reset', '--hard', `origin/${branch}`]).catch(() => {});
334
- }
335
- } catch (err) {
336
- // Top-up is best-effort infrastructure — a failure here must not block the
337
- // user from loading the branch.
338
- console.warn(
339
- `[kb-seed] Scaffolding top-up for "${branch}" failed (non-fatal):`,
340
- err instanceof Error ? redact(err.message) : String(err),
341
- );
342
- }
343
- }
344
-
345
- /** Prefer the default branch as a base, else the first protected branch present, else any head. */
346
- private pickBase(heads: Set<string>): string {
347
- if (heads.has(this.defaultBranch())) return this.defaultBranch();
348
- const protectedPresent = this.protectedBranches().find((b) => heads.has(b));
349
- if (protectedPresent) return protectedPresent;
350
- // Deterministic pick so repeated runs behave identically.
351
- return [...heads].sort()[0];
352
- }
353
-
354
- /** `-c` args that inject credentials + longpaths, mirroring the workspace clone. */
355
- private credArgs(): string[] {
356
- const args = ['-c', 'core.longpaths=true'];
357
- const token = process.env.GITHUB_TOKEN;
358
- if (token) {
359
- // The helper reads GITHUB_TOKEN at runtime, so the value never appears in argv.
360
- // Username is provider-specific; the token is always the Basic-auth password.
361
- args.push(
362
- '-c',
363
- `credential.helper=!f() { echo "username=${this.gitUsername()}"; echo "password=$GITHUB_TOKEN"; }; f`,
364
- );
365
- }
366
- return args;
367
- }
368
-
369
- private async git(cwd: string | null, args: string[]): Promise<string> {
370
- try {
371
- const { stdout } = await execFileAsync('git', args, {
372
- cwd: cwd ?? undefined,
373
- env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
374
- maxBuffer: 32 * 1024 * 1024,
375
- });
376
- return stdout.toString();
377
- } catch (err) {
378
- const msg = err instanceof Error ? err.message : String(err);
379
- let i = 0;
380
- while (i < args.length && args[i] === '-c') i += 2;
381
- throw new Error(`git ${args[i] ?? args[0]} failed: ${redact(msg)}`);
382
- }
383
- }
384
-
385
- /** Set of branch names on the remote (empty ⇒ uninitialised repo). */
386
- private async lsRemoteHeads(): Promise<Set<string>> {
387
- const out = await this.git(null, [...this.credArgs(), 'ls-remote', '--heads', this.kbRepoUrl()]);
388
- const heads = new Set<string>();
389
- for (const line of out.split('\n')) {
390
- const m = line.match(/\srefs\/heads\/(.+)$/);
391
- if (m) heads.add(m[1].trim());
392
- }
393
- return heads;
394
- }
395
-
396
- /** Run a block against a throwaway temp dir, always cleaned up. */
397
- private async withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
398
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'kb-seed-'));
399
- try {
400
- return await fn(dir);
401
- } finally {
402
- await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
403
- }
404
- }
405
-
406
- private async stampIdentity(repo: string): Promise<void> {
407
- await this.git(repo, ['config', 'user.name', BOT_NAME]);
408
- await this.git(repo, ['config', 'user.email', BOT_EMAIL]);
409
- }
410
-
411
- /** Copy the entire template tree into `dest` (roles.yaml isn't in it — it's generated). */
412
- private async copyTemplateTree(dest: string): Promise<void> {
413
- const walk = async (relDir: string): Promise<void> => {
414
- const abs = path.join(this.kbTemplateDir, relDir);
415
- const entries = await fs.readdir(abs, { withFileTypes: true });
416
- for (const entry of entries) {
417
- const rel = relDir ? path.join(relDir, entry.name) : entry.name;
418
- if (entry.isDirectory()) {
419
- await walk(rel);
420
- } else {
421
- await this.copyTemplateFile(rel, dest);
422
- }
423
- }
424
- };
425
- await walk('');
426
- }
427
-
428
- /** Copy one template file (by repo-relative path) into `dest`, creating parents. */
429
- /**
430
- * Whether the repo's copy of `relPath` differs from the template's, modulo
431
- * line endings — a CRLF checkout of identical content must read as "same",
432
- * or the managed-file refresh would commit churn on every boot forever.
433
- */
434
- private async templateDiffers(repoDir: string, relPath: string): Promise<boolean> {
435
- const norm = (text: string) => text.replace(/\r\n?/g, '\n');
436
- const [current, template] = await Promise.all([
437
- fs.readFile(path.join(repoDir, relPath), 'utf8'),
438
- fs.readFile(path.join(this.kbTemplateDir, relPath), 'utf8'),
439
- ]);
440
- return norm(current) !== norm(template);
441
- }
442
-
443
- private async copyTemplateFile(relPath: string, dest: string): Promise<void> {
444
- const from = path.join(this.kbTemplateDir, relPath);
445
- const to = path.join(dest, relPath);
446
- await fs.mkdir(path.dirname(to), { recursive: true });
447
- await fs.copyFile(from, to);
448
- }
449
-
450
- /**
451
- * Ensure `.bevelignore` carries `pattern`, appending it when absent. Returns
452
- * the paths changed, for the commit.
453
- *
454
- * APPENDS — never rewrites. The file is the operator's, and every rule
455
- * already in it is theirs to keep; this adds one line under a comment saying
456
- * where it came from. Absent file, or a file that already lists the pattern,
457
- * is a no-op, so running it on every clone changes nothing after the first.
458
- *
459
- * Matched line-wise rather than by substring: a rule for `Plugins/AGENTS.md`
460
- * is not a rule for the root `AGENTS.md`, and treating it as one would leave
461
- * the mismatch this exists to close.
462
- */
463
- private async mergeIgnorePattern(repoDir: string, pattern: string): Promise<string[]> {
464
- const ignorePath = path.join(repoDir, IGNORE_FILENAME);
465
- let current: string;
466
- try {
467
- current = await fs.readFile(ignorePath, 'utf8');
468
- } catch {
469
- return []; // No ignore file — the template's copy arrives with the pattern in it.
470
- }
471
- const lines = current.split('\n').map((l) => l.trim());
472
- if (lines.includes(pattern)) return [];
473
- const separator = current.endsWith('\n') ? '' : '\n';
474
- await fs.appendFile(
475
- ignorePath,
476
- `${separator}\n# Added by the platform: the conventions doc is not node content.\n${pattern}\n`,
477
- 'utf8',
478
- );
479
- return [IGNORE_FILENAME];
480
- }
481
-
482
- /**
483
- * Create any reserved root folder this repo is missing, as an empty
484
- * `<dir>/.gitkeep`. Returns the paths added, for the commit message.
485
- *
486
- * WRITTEN, not copied from the template. A `.gitkeep` is empty by definition,
487
- * and requiring a template entry per root would mean a distribution could not
488
- * reserve one without forking the packaged template.
489
- *
490
- * Keyed on the DIRECTORY's existence, not the `.gitkeep` file — a branch that
491
- * already has content under `KnowledgeBase/` never gets a pointless
492
- * placeholder alongside it.
493
- */
494
- private async ensureRequiredDirs(repoDir: string): Promise<string[]> {
495
- const added: string[] = [];
496
- for (const rootDir of this.requiredDirs) {
497
- const abs = path.join(repoDir, rootDir);
498
- // `lstat`, not `exists`: `fs.access` answers "is there something here?",
499
- // which is true of a FILE named `Plugins` — and the old skip-if-present
500
- // check then did nothing and reported success, leaving a knowledge base
501
- // permanently missing a root it claims to guarantee. `lstat` rather than
502
- // `stat` so a SYMLINK is rejected too: a link named `Plugins` is not a KB
503
- // layout, and one pointing outside the repo would make every later write
504
- // into it land somewhere nobody asked for.
505
- const found = await this.lstatOrNull(abs);
506
- if (found) {
507
- if (found.isDirectory()) continue;
508
- throw new Error(
509
- `KB root "${rootDir}" exists but is not a directory ` +
510
- `(${found.isSymbolicLink() ? 'symlink' : 'file'}). Remove or rename it — ` +
511
- 'the platform requires this name to be a folder.',
512
- );
513
- }
514
- await fs.mkdir(abs, { recursive: true });
515
- await fs.writeFile(path.join(abs, '.gitkeep'), '', 'utf8');
516
- added.push(`${rootDir}/.gitkeep`);
517
- }
518
- return added;
519
- }
520
-
521
- /** `lstat` without the throw — null when nothing is at `p`. */
522
- private async lstatOrNull(p: string): Promise<import('node:fs').Stats | null> {
523
- try {
524
- return await fs.lstat(p);
525
- } catch {
526
- return null;
527
- }
528
- }
529
-
530
- /** `git init` a temp repo, lay down the full template + generated roles.yaml, commit. */
531
- private async buildSeedCommit(dir: string): Promise<void> {
532
- await this.git(dir, ['init', '-b', this.defaultBranch()]);
533
- await this.stampIdentity(dir);
534
- await this.copyTemplateTree(dir);
535
- // Reserved roots the template does not carry. Without this the seed commit
536
- // would hold only what the template has, and a distribution's own roots
537
- // would appear a step later, when the first clone gets topped up — the same
538
- // folders, arriving in a second commit for no reason.
539
- await this.ensureRequiredDirs(dir);
540
- await fs.writeFile(path.join(dir, 'roles.yaml'), renderRolesYaml(this.seedAdminEmails), 'utf8');
541
- await this.git(dir, ['add', '-A']);
542
- await this.git(dir, ['commit', '-m', 'Seed knowledge base from Bevel template']);
543
- }
544
-
545
- /** Empty remote → one seed commit pushed to every protected branch. */
546
- private async seedEmptyRemote(): Promise<void> {
547
- await this.withTempDir(async (dir) => {
548
- await this.buildSeedCommit(dir);
549
- // Point every protected branch at the seed commit. The init branch is
550
- // already `defaultBranch`; create the rest as refs to HEAD.
551
- for (const branch of this.protectedBranches()) {
552
- if (branch !== this.defaultBranch()) {
553
- await this.git(dir, ['branch', branch]);
554
- }
555
- }
556
- await this.git(dir, ['remote', 'add', 'origin', this.kbRepoUrl()]);
557
- // Push only the protected branches — never the stray init branch if it
558
- // isn't itself protected.
559
- await this.git(dir, [...this.credArgs(), 'push', '-u', 'origin', ...this.protectedBranches()]);
560
- console.log(
561
- `[kb-seed] Seeded empty KB remote with branches: ${this.protectedBranches().join(', ')}`,
562
- );
563
- });
564
- }
565
-
566
- /** Create a missing protected branch on the remote, pointed at `base`'s tip. */
567
- private async createBranchOnRemote(branch: string, base: string): Promise<void> {
568
- await this.withTempDir(async (dir) => {
569
- await this.git(dir, [...this.credArgs(), 'clone', '--depth', '1', '-b', base, this.kbRepoUrl(), dir]);
570
- // Push base's fetched tip up under the new branch name.
571
- await this.git(dir, [...this.credArgs(), 'push', 'origin', `HEAD:refs/heads/${branch}`]);
572
- console.log(`[kb-seed] Created missing protected branch "${branch}" from "${base}"`);
573
- });
574
- }
575
-
576
- private async exists(p: string): Promise<boolean> {
577
- try {
578
- await fs.access(p);
579
- return true;
580
- } catch {
581
- return false;
582
- }
583
- }
584
- }