@bevel-software/platform-core-backend 0.7.5 → 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/LICENSE +202 -202
- package/THIRD-PARTY-NOTICES.md +428 -454
- package/dist/core/core-ports.d.ts +8 -1
- 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 +75 -18
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +12 -5
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +69 -36
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +8 -7
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +1 -1
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +6 -6
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access-declarations.d.ts +5 -5
- package/dist/modules/access/access-declarations.js +3 -3
- package/dist/modules/access/access-mutation.service.d.ts +3 -3
- package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
- package/dist/modules/access/access-mutation.service.js +3 -3
- package/dist/modules/access/access-mutation.service.js.map +1 -1
- package/dist/modules/access/access-splice.js +4 -4
- package/dist/modules/access/access-splice.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 +22 -20
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/creator-access.d.ts +2 -2
- package/dist/modules/access/creator-access.js +5 -5
- package/dist/modules/access/creator-access.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 +33 -5
- package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
- package/dist/modules/access/roles-admin.service.js +44 -19
- package/dist/modules/access/roles-admin.service.js.map +1 -1
- package/dist/modules/code-mode/code-mode-names.d.ts +5 -13
- package/dist/modules/code-mode/code-mode-names.d.ts.map +1 -1
- package/dist/modules/code-mode/code-mode-names.js +5 -27
- package/dist/modules/code-mode/code-mode-names.js.map +1 -1
- package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
- package/dist/modules/code-mode/code-mode.tool.js +29 -7
- package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +11 -52
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +33 -395
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/plugins/index.d.ts +7 -0
- package/dist/modules/plugins/index.d.ts.map +1 -0
- package/dist/modules/plugins/index.js +6 -0
- package/dist/modules/plugins/index.js.map +1 -0
- package/dist/modules/plugins/join-proposals.d.ts +53 -0
- package/dist/modules/plugins/join-proposals.d.ts.map +1 -0
- package/dist/modules/plugins/join-proposals.js +67 -0
- package/dist/modules/plugins/join-proposals.js.map +1 -0
- package/dist/modules/plugins/join-requests.service.d.ts +81 -0
- package/dist/modules/plugins/join-requests.service.d.ts.map +1 -0
- package/dist/modules/plugins/join-requests.service.js +135 -0
- package/dist/modules/plugins/join-requests.service.js.map +1 -0
- package/dist/modules/plugins/plugin-provision.service.d.ts +134 -0
- package/dist/modules/plugins/plugin-provision.service.d.ts.map +1 -0
- package/dist/modules/plugins/plugin-provision.service.js +344 -0
- package/dist/modules/plugins/plugin-provision.service.js.map +1 -0
- package/dist/modules/plugins/plugins.contract.d.ts +106 -0
- package/dist/modules/plugins/plugins.contract.d.ts.map +1 -0
- package/dist/modules/plugins/plugins.contract.js +36 -0
- package/dist/modules/plugins/plugins.contract.js.map +1 -0
- package/dist/modules/plugins/plugins.routes.d.ts +42 -0
- package/dist/modules/plugins/plugins.routes.d.ts.map +1 -0
- package/dist/modules/plugins/plugins.routes.js +379 -0
- package/dist/modules/plugins/plugins.routes.js.map +1 -0
- package/dist/modules/plugins/plugins.service.d.ts +60 -0
- package/dist/modules/plugins/plugins.service.d.ts.map +1 -0
- package/dist/modules/plugins/plugins.service.js +172 -0
- package/dist/modules/plugins/plugins.service.js.map +1 -0
- 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/skills/pending-skills.service.d.ts +2 -2
- package/dist/modules/skills/pending-skills.service.js +7 -7
- package/dist/modules/skills/pending-skills.service.js.map +1 -1
- package/dist/modules/skills/skills.contract.d.ts +4 -4
- package/dist/modules/skills/skills.contract.d.ts.map +1 -1
- package/dist/modules/skills/skills.contract.js +1 -1
- package/dist/modules/skills/skills.service.js +5 -5
- package/dist/modules/skills/skills.service.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts +65 -0
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -0
- package/dist/modules/tool-manuals/mcp-json-discovery.js +276 -0
- package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -0
- package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts +92 -0
- package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -0
- package/dist/modules/tool-manuals/mcp-server-edit.service.js +328 -0
- package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -0
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +38 -12
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.js +1 -1
- package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +13 -2
- package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.routes.js +233 -2
- package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +74 -37
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +6 -3
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/workflow/git/git.service.js +2 -2
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workspace/kb-seed.service.d.ts +2 -2
- package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
- package/dist/modules/workspace/kb-seed.service.js +43 -10
- package/dist/modules/workspace/kb-seed.service.js.map +1 -1
- package/dist/modules/workspace/plugins-migration.d.ts +50 -0
- package/dist/modules/workspace/plugins-migration.d.ts.map +1 -0
- package/dist/modules/workspace/plugins-migration.js +379 -0
- package/dist/modules/workspace/plugins-migration.js.map +1 -0
- 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.routes.js +3 -3
- package/dist/modules/workspace/workspace.routes.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +0 -9
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +2 -30
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/shared/kb-layout.test.js +3 -3
- package/dist/shared/kb-layout.test.js.map +1 -1
- package/dist/shared/utcp-namespace.d.ts +6 -27
- package/dist/shared/utcp-namespace.d.ts.map +1 -1
- package/dist/shared/utcp-namespace.js +6 -63
- package/dist/shared/utcp-namespace.js.map +1 -1
- package/dist/shared/variable-refs.d.ts +42 -0
- package/dist/shared/variable-refs.d.ts.map +1 -0
- package/dist/shared/variable-refs.js +60 -0
- package/dist/shared/variable-refs.js.map +1 -0
- package/kb-template/.bevelignore +1 -1
- package/kb-template/AGENTS.md +88 -35
- package/kb-template/KnowledgeBase/How to get started.md +10 -10
- package/kb-template/access.md +36 -36
- package/kb-template/gitignore.template +16 -0
- package/migrations/meta/0000_snapshot.json +1479 -1479
- package/package.json +5 -4
- package/src/assets.ts +25 -25
- package/src/core/core-ports.ts +113 -106
- package/src/core/create-core-server.ts +85 -20
- package/src/core/create-core-services.ts +83 -42
- package/src/index.ts +69 -69
- package/src/modules/access/__tests__/access-control.atref-batch.test.ts +98 -98
- package/src/modules/access/__tests__/access-declarations.test.ts +28 -28
- package/src/modules/access/__tests__/access-md-format.test.ts +18 -18
- package/src/modules/access/__tests__/access-mutation.service.test.ts +5 -5
- package/src/modules/access/__tests__/access-splice.test.ts +2 -2
- package/src/modules/access/__tests__/access.routes.overrides.test.ts +16 -16
- package/src/modules/access/__tests__/grant-sources.test.ts +12 -12
- package/src/modules/access/__tests__/roles-admin.service.test.ts +36 -2
- package/src/modules/access/access-control.interface.ts +8 -7
- package/src/modules/access/access-control.service.ts +7 -7
- package/src/modules/access/access-declarations.ts +5 -5
- package/src/modules/access/access-mutation.service.ts +3 -3
- package/src/modules/access/access-splice.ts +4 -4
- package/src/modules/access/access.routes.ts +23 -20
- package/src/modules/access/creator-access.ts +5 -5
- package/src/modules/access/render-roles-yaml.ts +65 -0
- package/src/modules/access/roles-admin.service.ts +45 -16
- package/src/modules/admin/admin-access.routes.ts +29 -29
- package/src/modules/auth/__tests__/auth.routes.test.ts +91 -91
- package/src/modules/auth/__tests__/rate-limit.test.ts +36 -36
- package/src/modules/auth/rate-limit.ts +45 -45
- package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +67 -0
- package/src/modules/code-mode/code-mode-names.ts +10 -36
- package/src/modules/code-mode/code-mode.tool.ts +27 -7
- package/src/modules/database/connection.ts +15 -15
- package/src/modules/database/schema.ts +11 -11
- package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +150 -150
- package/src/modules/mcp/mcp.service.ts +57 -435
- package/src/modules/{groups → plugins}/__tests__/join-proposals.test.ts +1 -1
- package/src/modules/{groups → plugins}/__tests__/join-requests.service.test.ts +7 -7
- package/src/modules/{groups/__tests__/group-index.service.test.ts → plugins/__tests__/plugin-index.service.test.ts} +41 -41
- package/src/modules/plugins/__tests__/plugin-provision.service.test.ts +312 -0
- package/src/modules/{groups/__tests__/groups.routes.test.ts → plugins/__tests__/plugins.routes.test.ts} +100 -100
- package/src/modules/plugins/index.ts +17 -0
- package/src/modules/{groups → plugins}/join-proposals.ts +2 -2
- package/src/modules/{groups → plugins}/join-requests.service.ts +8 -8
- package/src/modules/{groups/group-provision.service.ts → plugins/plugin-provision.service.ts} +139 -69
- package/src/modules/{groups/groups.contract.ts → plugins/plugins.contract.ts} +26 -26
- package/src/modules/{groups/groups.routes.ts → plugins/plugins.routes.ts} +102 -102
- package/src/modules/{groups/groups.service.ts → plugins/plugins.service.ts} +43 -43
- package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +143 -143
- package/src/modules/settings/__tests__/setup.routes.test.ts +124 -3
- package/src/modules/settings/setup.routes.ts +112 -5
- package/src/modules/skills/__tests__/pending-skills.service.test.ts +14 -14
- package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
- package/src/modules/skills/pending-skills.service.ts +7 -7
- package/src/modules/skills/skills.contract.ts +4 -4
- package/src/modules/skills/skills.service.ts +5 -5
- package/src/modules/tool-auth/llm-usage-meter.ts +19 -19
- package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +198 -0
- package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +346 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.archive.route.test.ts +101 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +3 -3
- package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +2 -2
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +27 -27
- package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +3 -3
- package/src/modules/tool-manuals/mcp-json-discovery.ts +328 -0
- package/src/modules/tool-manuals/mcp-server-edit.service.ts +434 -0
- package/src/modules/tool-manuals/tool-manuals.contract.ts +35 -12
- package/src/modules/tool-manuals/tool-manuals.routes.ts +222 -1
- package/src/modules/tool-manuals/tool-manuals.service.ts +82 -42
- package/src/modules/tool-manuals/tool-manuals.tools.ts +6 -3
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +1 -1
- package/src/modules/workflow/git/__tests__/branch-name.test.ts +3 -3
- package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +3 -3
- package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +13 -8
- package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +1 -1
- package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +1 -1
- package/src/modules/workflow/git/git.service.ts +2 -2
- package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +1 -1
- package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +1 -1
- package/src/modules/workflow/workflow-hooks.ts +101 -101
- package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +237 -237
- package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +236 -236
- package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +179 -179
- package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +320 -320
- package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +337 -337
- package/src/modules/workspace/__tests__/workspace.service.test.ts +27 -0
- package/src/modules/workspace/bevel-ignore.ts +66 -66
- package/src/modules/workspace/session-sink.ts +25 -25
- 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/startup/steps/groups-to-plugins.step.ts +561 -0
- 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.routes.ts +3 -3
- package/src/modules/workspace/workspace.service.ts +2 -35
- package/src/modules/workspace/workspace.tools.ts +922 -922
- package/src/shared/__tests__/join-request.test.ts +13 -13
- package/src/shared/__tests__/kb-layout.plugin.test.ts +45 -0
- package/src/shared/kb-layout.test.ts +3 -3
- package/src/shared/utcp-namespace.ts +10 -68
- package/src/shared/variable-refs.ts +64 -0
- package/src/modules/groups/__tests__/group-provision.service.test.ts +0 -247
- package/src/modules/groups/index.ts +0 -17
- package/src/modules/workspace/__tests__/kb-seed.service.test.ts +0 -444
- package/src/modules/workspace/kb-seed.interface.ts +0 -36
- package/src/modules/workspace/kb-seed.service.ts +0 -555
- package/src/shared/__tests__/kb-layout.group.test.ts +0 -45
- /package/kb-template/{Groups → Plugins}/.gitkeep +0 -0
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { KbStartupRunner } from '../kb-startup-runner.js';
|
|
8
|
+
import { redactSecret } from '../kb-git.js';
|
|
9
|
+
import type { OnServerStart, ServerStartContext, StepResult } from '../on-server-start.js';
|
|
10
|
+
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
|
|
13
|
+
const PROTECTED = ['current-company-state', 'target-company-state'];
|
|
14
|
+
const DEFAULT_BRANCH = 'current-company-state';
|
|
15
|
+
|
|
16
|
+
async function git(cwd: string, args: string[]): Promise<string> {
|
|
17
|
+
const { stdout } = await execFileAsync('git', args, {
|
|
18
|
+
cwd,
|
|
19
|
+
env: {
|
|
20
|
+
...process.env,
|
|
21
|
+
GIT_AUTHOR_NAME: 'Test', GIT_AUTHOR_EMAIL: 't@x.com',
|
|
22
|
+
GIT_COMMITTER_NAME: 'Test', GIT_COMMITTER_EMAIL: 't@x.com',
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
return stdout.toString();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let root: string;
|
|
29
|
+
let upstream: string;
|
|
30
|
+
let workspacesRoot: string;
|
|
31
|
+
|
|
32
|
+
beforeEach(async () => {
|
|
33
|
+
root = await fs.mkdtemp(path.join(os.tmpdir(), 'kb-startup-'));
|
|
34
|
+
workspacesRoot = path.join(root, 'workspaces');
|
|
35
|
+
await fs.mkdir(workspacesRoot, { recursive: true });
|
|
36
|
+
upstream = path.join(root, 'upstream.git');
|
|
37
|
+
await git(root, ['init', '--bare', '-b', DEFAULT_BRANCH, upstream]);
|
|
38
|
+
delete process.env.KB_SAFE_BOOT;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(async () => {
|
|
42
|
+
delete process.env.KB_SAFE_BOOT;
|
|
43
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** A populated upstream: one commit on the default branch, both protected refs. */
|
|
47
|
+
async function populatedUpstream(): Promise<void> {
|
|
48
|
+
const seed = path.join(root, '.seed');
|
|
49
|
+
await fs.mkdir(seed);
|
|
50
|
+
await git(seed, ['init', '-b', DEFAULT_BRANCH]);
|
|
51
|
+
await fs.writeFile(path.join(seed, 'marker.txt'), 'seeded', 'utf8');
|
|
52
|
+
await git(seed, ['add', '-A']);
|
|
53
|
+
await git(seed, ['commit', '-m', 'init']);
|
|
54
|
+
await git(seed, ['branch', PROTECTED[1]!]);
|
|
55
|
+
await git(seed, ['remote', 'add', 'origin', upstream]);
|
|
56
|
+
await git(seed, ['push', 'origin', ...PROTECTED]);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function makeRunner(steps: OnServerStart[], overrides: Partial<Parameters<typeof runnerOpts>[1]> = {}) {
|
|
60
|
+
return new KbStartupRunner(runnerOpts(steps, overrides));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function runnerOpts(steps: OnServerStart[], overrides: Record<string, unknown> = {}) {
|
|
64
|
+
return {
|
|
65
|
+
kbRepoUrl: () => upstream,
|
|
66
|
+
gitUsername: () => 'x-access-token',
|
|
67
|
+
workspacesRoot,
|
|
68
|
+
kbDirName: 'knowledge-base',
|
|
69
|
+
templateDir: path.join(root, 'template'),
|
|
70
|
+
defaultBranch: () => DEFAULT_BRANCH,
|
|
71
|
+
protectedBranches: () => PROTECTED,
|
|
72
|
+
seedAdminEmails: ['admin@example.com'],
|
|
73
|
+
steps,
|
|
74
|
+
buildSeedTree: async (dir: string) => {
|
|
75
|
+
await fs.writeFile(path.join(dir, 'seeded.md'), 'from template', 'utf8');
|
|
76
|
+
return [];
|
|
77
|
+
},
|
|
78
|
+
...overrides,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function step(name: string, run: (ctx: ServerStartContext) => Promise<StepResult>): OnServerStart {
|
|
83
|
+
return { name, run };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function checkout(branch: string): Promise<string> {
|
|
87
|
+
const dir = path.join(root, `checkout-${branch}-${Math.random().toString(36).slice(2)}`);
|
|
88
|
+
await git(root, ['clone', '-b', branch, upstream, dir]);
|
|
89
|
+
return dir;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
describe('KbStartupRunner', () => {
|
|
93
|
+
it('seeds an empty remote with every protected branch before any step runs', async () => {
|
|
94
|
+
const seen: string[] = [];
|
|
95
|
+
await makeRunner([
|
|
96
|
+
step('probe', async (ctx) => {
|
|
97
|
+
for (const b of await ctx.protectedBranches()) seen.push(b.name);
|
|
98
|
+
return { outcome: 'ok' };
|
|
99
|
+
}),
|
|
100
|
+
]).runAll();
|
|
101
|
+
expect(seen.sort()).toEqual([...PROTECTED].sort());
|
|
102
|
+
for (const b of PROTECTED) {
|
|
103
|
+
const dir = await checkout(b);
|
|
104
|
+
expect(await fs.readFile(path.join(dir, 'seeded.md'), 'utf8')).toBe('from template');
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('rejects a repository URL with embedded credentials — operator error, fail-closed', async () => {
|
|
109
|
+
let stepRan = false;
|
|
110
|
+
await expect(
|
|
111
|
+
makeRunner(
|
|
112
|
+
[step('never', async () => { stepRan = true; return { outcome: 'ok' }; })],
|
|
113
|
+
{ kbRepoUrl: () => 'https://alice:hunter2@example.com/kb.git' },
|
|
114
|
+
).runAll(),
|
|
115
|
+
).rejects.toThrow(/embeds credentials.*process listings/s);
|
|
116
|
+
expect(stepRan).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('KB_SAFE_BOOT boots over a persisted credentials-bearing URL — the rescue never invokes git', async () => {
|
|
120
|
+
process.env.KB_SAFE_BOOT = '1';
|
|
121
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
122
|
+
try {
|
|
123
|
+
await makeRunner(
|
|
124
|
+
[step('never', async () => ({ outcome: 'ok' }))],
|
|
125
|
+
{ kbRepoUrl: () => 'https://alice:hunter2@example.com/kb.git' },
|
|
126
|
+
).runAll(); // resolves — the server boots unmaintained
|
|
127
|
+
} finally {
|
|
128
|
+
vi.restoreAllMocks();
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('accepts a concurrent replica\'s seed when the empty-remote seed push loses the race', async () => {
|
|
133
|
+
// The WINNER lands its seed between this replica's empty ls-remote and
|
|
134
|
+
// its push — buildSeedTree runs exactly in that window, which makes the
|
|
135
|
+
// race deterministic with real git.
|
|
136
|
+
const seen: string[] = [];
|
|
137
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
138
|
+
try {
|
|
139
|
+
await makeRunner(
|
|
140
|
+
[
|
|
141
|
+
step('probe', async (ctx) => {
|
|
142
|
+
for (const b of await ctx.protectedBranches()) seen.push(b.name);
|
|
143
|
+
return { outcome: 'ok' };
|
|
144
|
+
}),
|
|
145
|
+
],
|
|
146
|
+
{
|
|
147
|
+
buildSeedTree: async (dir: string) => {
|
|
148
|
+
const winner = path.join(root, 'winner-seed');
|
|
149
|
+
await fs.mkdir(winner);
|
|
150
|
+
await git(winner, ['init', '-b', DEFAULT_BRANCH]);
|
|
151
|
+
await fs.writeFile(path.join(winner, 'seeded.md'), 'winner content', 'utf8');
|
|
152
|
+
await git(winner, ['add', '-A']);
|
|
153
|
+
await git(winner, ['commit', '-m', 'winner seed']);
|
|
154
|
+
await git(winner, ['branch', PROTECTED[1]!]);
|
|
155
|
+
await git(winner, ['remote', 'add', 'origin', upstream]);
|
|
156
|
+
await git(winner, ['push', 'origin', ...PROTECTED]);
|
|
157
|
+
await fs.writeFile(path.join(dir, 'seeded.md'), 'loser content', 'utf8');
|
|
158
|
+
return [];
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
).runAll(); // resolves — the loser accepts the winner's identical work
|
|
162
|
+
} finally {
|
|
163
|
+
vi.restoreAllMocks();
|
|
164
|
+
}
|
|
165
|
+
expect(seen.sort()).toEqual([...PROTECTED].sort());
|
|
166
|
+
// The remote carries the WINNER's seed; the loser's never landed.
|
|
167
|
+
for (const b of PROTECTED) {
|
|
168
|
+
const dir = await checkout(b);
|
|
169
|
+
expect(await fs.readFile(path.join(dir, 'seeded.md'), 'utf8')).toBe('winner content');
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('applies a step\'s buffered ops before the next step runs, and commits once per branch with the notes', async () => {
|
|
174
|
+
await populatedUpstream();
|
|
175
|
+
const secondSaw: string[] = [];
|
|
176
|
+
await makeRunner([
|
|
177
|
+
step('first', async (ctx) => {
|
|
178
|
+
const b = await ctx.defaultBranch();
|
|
179
|
+
b.write('a/one.md', 'one');
|
|
180
|
+
b.note('Add one.md');
|
|
181
|
+
return { outcome: 'ok' };
|
|
182
|
+
}),
|
|
183
|
+
step('second', async (ctx) => {
|
|
184
|
+
const b = await ctx.defaultBranch();
|
|
185
|
+
// The previous step's op is REAL on disk by now.
|
|
186
|
+
secondSaw.push(await fs.readFile(path.join(await b.repoDir(), 'a/one.md'), 'utf8'));
|
|
187
|
+
b.write('b/two.md', 'two');
|
|
188
|
+
b.note('Add two.md');
|
|
189
|
+
return { outcome: 'ok' };
|
|
190
|
+
}),
|
|
191
|
+
]).runAll();
|
|
192
|
+
expect(secondSaw).toEqual(['one']);
|
|
193
|
+
const dir = await checkout(DEFAULT_BRANCH);
|
|
194
|
+
expect(await fs.readFile(path.join(dir, 'a/one.md'), 'utf8')).toBe('one');
|
|
195
|
+
expect(await fs.readFile(path.join(dir, 'b/two.md'), 'utf8')).toBe('two');
|
|
196
|
+
const log = await git(dir, ['log', '--format=%s%n%b', '-1']);
|
|
197
|
+
expect(log).toContain('Add one.md');
|
|
198
|
+
expect(log).toContain('Add two.md');
|
|
199
|
+
// ONE commit carried both steps' changes.
|
|
200
|
+
const count = (await git(dir, ['rev-list', '--count', 'HEAD'])).trim();
|
|
201
|
+
expect(count).toBe('2'); // init + the one startup commit
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('discards a skipped step\'s ops unapplied — the tree is untouched by construction', async () => {
|
|
205
|
+
await populatedUpstream();
|
|
206
|
+
await makeRunner([
|
|
207
|
+
step('flaky', async (ctx) => {
|
|
208
|
+
(await ctx.defaultBranch()).write('never.md', 'x');
|
|
209
|
+
return { outcome: 'skipped', reason: 'precondition unmet' };
|
|
210
|
+
}),
|
|
211
|
+
step('after', async (ctx) => {
|
|
212
|
+
const repo = await (await ctx.defaultBranch()).repoDir();
|
|
213
|
+
expect(await fs.access(path.join(repo, 'never.md')).then(() => true, () => false)).toBe(false);
|
|
214
|
+
return { outcome: 'ok' };
|
|
215
|
+
}),
|
|
216
|
+
]).runAll();
|
|
217
|
+
const dir = await checkout(DEFAULT_BRANCH);
|
|
218
|
+
await expect(fs.access(path.join(dir, 'never.md'))).rejects.toThrow();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('an unhandled step throw stops the boot with the step named; nothing commits', async () => {
|
|
222
|
+
await populatedUpstream();
|
|
223
|
+
await expect(
|
|
224
|
+
makeRunner([
|
|
225
|
+
step('good', async (ctx) => {
|
|
226
|
+
const b = await ctx.defaultBranch();
|
|
227
|
+
b.write('good.md', 'ok');
|
|
228
|
+
return { outcome: 'ok' };
|
|
229
|
+
}),
|
|
230
|
+
step('broken', async () => {
|
|
231
|
+
throw new Error('ECONNREFUSED somewhere');
|
|
232
|
+
}),
|
|
233
|
+
]).runAll(),
|
|
234
|
+
).rejects.toThrow(/step "broken" failed: .*ECONNREFUSED/);
|
|
235
|
+
// The good step's APPLIED but uncommitted work never reached the remote.
|
|
236
|
+
const dir = await checkout(DEFAULT_BRANCH);
|
|
237
|
+
await expect(fs.access(path.join(dir, 'good.md'))).rejects.toThrow();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('stopBoot is the deliberate kill switch and carries the message', async () => {
|
|
241
|
+
await populatedUpstream();
|
|
242
|
+
await expect(
|
|
243
|
+
makeRunner([
|
|
244
|
+
step('guard', async () => ({ outcome: 'stopBoot', message: 'KB layout is newer than this build' })),
|
|
245
|
+
]).runAll(),
|
|
246
|
+
).rejects.toThrow(/stopped the boot: KB layout is newer/);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('KB_SAFE_BOOT=1 abandons the phase on failure, resets uncommitted work, and boots', async () => {
|
|
250
|
+
await populatedUpstream();
|
|
251
|
+
const originSha = (await git(path.join(root, '.seed'), ['rev-parse', 'HEAD'])).trim();
|
|
252
|
+
process.env.KB_SAFE_BOOT = '1';
|
|
253
|
+
await makeRunner([
|
|
254
|
+
step('good', async (ctx) => {
|
|
255
|
+
(await ctx.defaultBranch()).write('good.md', 'ok');
|
|
256
|
+
return { outcome: 'ok' };
|
|
257
|
+
}),
|
|
258
|
+
step('broken', async () => {
|
|
259
|
+
throw new Error('boom');
|
|
260
|
+
}),
|
|
261
|
+
]).runAll(); // resolves — the server boots
|
|
262
|
+
// The applied-but-uncommitted change was reset; the remote never saw it.
|
|
263
|
+
const local = path.join(workspacesRoot, DEFAULT_BRANCH, 'knowledge-base');
|
|
264
|
+
await expect(fs.access(path.join(local, 'good.md'))).rejects.toThrow();
|
|
265
|
+
// The rollback lands EXACTLY on the pre-phase sha recorded at clone time.
|
|
266
|
+
expect((await git(local, ['rev-parse', 'HEAD'])).trim()).toBe(originSha);
|
|
267
|
+
const dir = await checkout(DEFAULT_BRANCH);
|
|
268
|
+
await expect(fs.access(path.join(dir, 'good.md'))).rejects.toThrow();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('KB_SAFE_BOOT rollback undoes even a created-but-unpushed finalize commit (reset to the pre-phase sha)', async () => {
|
|
272
|
+
await populatedUpstream();
|
|
273
|
+
const originSha = (await git(path.join(root, '.seed'), ['rev-parse', 'HEAD'])).trim();
|
|
274
|
+
// A pre-receive hook that refuses every push: a POLICY refusal, not the
|
|
275
|
+
// concurrent-replica carve-out — finalize rethrows AFTER committing, and
|
|
276
|
+
// safe boot must roll that commit back or it strands locally forever.
|
|
277
|
+
const hookPath = path.join(upstream, 'hooks', 'pre-receive');
|
|
278
|
+
await fs.mkdir(path.dirname(hookPath), { recursive: true });
|
|
279
|
+
await fs.writeFile(hookPath, '#!/bin/sh\necho "policy says no" >&2\nexit 1\n', { mode: 0o755 });
|
|
280
|
+
process.env.KB_SAFE_BOOT = '1';
|
|
281
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
282
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
283
|
+
try {
|
|
284
|
+
await makeRunner([
|
|
285
|
+
step('write', async (ctx) => {
|
|
286
|
+
const b = await ctx.defaultBranch();
|
|
287
|
+
b.write('managed.md', 'phase');
|
|
288
|
+
b.note('Add managed.md');
|
|
289
|
+
return { outcome: 'ok' };
|
|
290
|
+
}),
|
|
291
|
+
]).runAll(); // resolves — safe boot abandons after the push refusal
|
|
292
|
+
} finally {
|
|
293
|
+
vi.restoreAllMocks();
|
|
294
|
+
}
|
|
295
|
+
const local = path.join(workspacesRoot, DEFAULT_BRANCH, 'knowledge-base');
|
|
296
|
+
expect((await git(local, ['rev-parse', 'HEAD'])).trim()).toBe(originSha);
|
|
297
|
+
await expect(fs.access(path.join(local, 'managed.md'))).rejects.toThrow();
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("finalize stages only the phase's paths — pre-existing dirt in a surviving clone stays out of the commit and in the tree", async () => {
|
|
301
|
+
await populatedUpstream();
|
|
302
|
+
// Materialize the surviving clone, then drop operator dirt into it.
|
|
303
|
+
await makeRunner([
|
|
304
|
+
step('touch', async (ctx) => {
|
|
305
|
+
await (await ctx.defaultBranch()).repoDir();
|
|
306
|
+
return { outcome: 'ok' };
|
|
307
|
+
}),
|
|
308
|
+
]).runAll();
|
|
309
|
+
const local = path.join(workspacesRoot, DEFAULT_BRANCH, 'knowledge-base');
|
|
310
|
+
await fs.writeFile(path.join(local, 'stray.md'), 'operator dirt', 'utf8');
|
|
311
|
+
// A crashed tool even STAGED it — the pre-existing index state must be
|
|
312
|
+
// dropped, not adopted into the phase's commit.
|
|
313
|
+
await git(local, ['add', '--', 'stray.md']);
|
|
314
|
+
|
|
315
|
+
await makeRunner([
|
|
316
|
+
step('write', async (ctx) => {
|
|
317
|
+
const b = await ctx.defaultBranch();
|
|
318
|
+
b.write('managed.md', 'phase');
|
|
319
|
+
b.note('Add managed.md');
|
|
320
|
+
return { outcome: 'ok' };
|
|
321
|
+
}),
|
|
322
|
+
]).runAll();
|
|
323
|
+
|
|
324
|
+
// The commit carries ONLY the phase's path.
|
|
325
|
+
const committed = (await git(local, ['show', '--name-only', '--format=', 'HEAD'])).trim();
|
|
326
|
+
expect(committed.split('\n')).toEqual(['managed.md']);
|
|
327
|
+
const dir = await checkout(DEFAULT_BRANCH);
|
|
328
|
+
expect(await fs.readFile(path.join(dir, 'managed.md'), 'utf8')).toBe('phase');
|
|
329
|
+
await expect(fs.access(path.join(dir, 'stray.md'))).rejects.toThrow();
|
|
330
|
+
// The dirt remains in the working tree, uncommitted — not this phase's to touch.
|
|
331
|
+
expect(await fs.readFile(path.join(local, 'stray.md'), 'utf8')).toBe('operator dirt');
|
|
332
|
+
expect((await git(local, ['status', '--porcelain', '--', 'stray.md'])).trim()).toBe('?? stray.md');
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('draft branches are reachable AND writable through allBranches()', async () => {
|
|
336
|
+
await populatedUpstream();
|
|
337
|
+
const seed = path.join(root, '.seed');
|
|
338
|
+
await git(seed, ['checkout', '-b', 'alice/draft']);
|
|
339
|
+
await fs.writeFile(path.join(seed, 'draft.md'), 'draft', 'utf8');
|
|
340
|
+
await git(seed, ['add', '-A']);
|
|
341
|
+
await git(seed, ['commit', '-m', 'draft work']);
|
|
342
|
+
await git(seed, ['push', 'origin', 'alice/draft']);
|
|
343
|
+
|
|
344
|
+
await makeRunner([
|
|
345
|
+
step('uniform', async (ctx) => {
|
|
346
|
+
for (const b of await ctx.allBranches()) {
|
|
347
|
+
expect(typeof b.isProtected).toBe('boolean');
|
|
348
|
+
b.write('uniform.md', `maintained ${b.name}`);
|
|
349
|
+
b.note(`Maintain ${b.name}`);
|
|
350
|
+
}
|
|
351
|
+
return { outcome: 'ok' };
|
|
352
|
+
}),
|
|
353
|
+
]).runAll();
|
|
354
|
+
|
|
355
|
+
const draft = await checkout('alice/draft');
|
|
356
|
+
expect(await fs.readFile(path.join(draft, 'uniform.md'), 'utf8')).toBe('maintained alice/draft');
|
|
357
|
+
expect(await fs.readFile(path.join(draft, 'draft.md'), 'utf8')).toBe('draft');
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('refuses op paths that escape, target .git, or traverse a symlink', async () => {
|
|
361
|
+
await populatedUpstream();
|
|
362
|
+
for (const bad of ['../outside.md', '.git/hooks/x', '']) {
|
|
363
|
+
await expect(
|
|
364
|
+
makeRunner([
|
|
365
|
+
step('hostile', async (ctx) => {
|
|
366
|
+
(await ctx.defaultBranch()).write(bad, 'x');
|
|
367
|
+
return { outcome: 'ok' };
|
|
368
|
+
}),
|
|
369
|
+
]).runAll(),
|
|
370
|
+
).rejects.toThrow(/op path/);
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('self-skips when the branch model is set but the repository URL is not', async () => {
|
|
375
|
+
let stepRan = false;
|
|
376
|
+
// A partially set-up deployment (branch model saved, repo URL not yet):
|
|
377
|
+
// running would `ls-remote ''` and fail forever, with the setup screen
|
|
378
|
+
// that could fix it unreachable behind the failed boot.
|
|
379
|
+
await makeRunner(
|
|
380
|
+
[step('never', async () => { stepRan = true; return { outcome: 'ok' }; })],
|
|
381
|
+
{ kbRepoUrl: () => '' },
|
|
382
|
+
).runAll(); // resolves — nothing to maintain yet
|
|
383
|
+
expect(stepRan).toBe(false);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("discards a skipped step's notes with its ops — a later commit never carries them", async () => {
|
|
387
|
+
await populatedUpstream();
|
|
388
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
389
|
+
try {
|
|
390
|
+
await makeRunner([
|
|
391
|
+
step('flaky', async (ctx) => {
|
|
392
|
+
const b = await ctx.defaultBranch();
|
|
393
|
+
b.write('never.md', 'x');
|
|
394
|
+
b.note('Never say this');
|
|
395
|
+
return { outcome: 'skipped', reason: 'precondition unmet' };
|
|
396
|
+
}),
|
|
397
|
+
step('after', async (ctx) => {
|
|
398
|
+
const b = await ctx.defaultBranch();
|
|
399
|
+
b.write('after.md', 'y');
|
|
400
|
+
b.note('Add after.md');
|
|
401
|
+
return { outcome: 'ok' };
|
|
402
|
+
}),
|
|
403
|
+
]).runAll();
|
|
404
|
+
} finally {
|
|
405
|
+
vi.restoreAllMocks();
|
|
406
|
+
}
|
|
407
|
+
const dir = await checkout(DEFAULT_BRANCH);
|
|
408
|
+
const log = await git(dir, ['log', '--format=%s%n%b', '-1']);
|
|
409
|
+
expect(log).toContain('Add after.md');
|
|
410
|
+
expect(log).not.toContain('Never say this');
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it('the push-race rollback undoes exactly the startup commit — a pre-existing AHEAD commit survives', async () => {
|
|
414
|
+
await populatedUpstream();
|
|
415
|
+
// Materialize the clone, then give it a committed-but-unpushed (AHEAD)
|
|
416
|
+
// commit — the crash-before-push state ensureClone deliberately preserves.
|
|
417
|
+
await makeRunner([
|
|
418
|
+
step('touch', async (ctx) => {
|
|
419
|
+
await (await ctx.defaultBranch()).repoDir();
|
|
420
|
+
return { outcome: 'ok' };
|
|
421
|
+
}),
|
|
422
|
+
]).runAll();
|
|
423
|
+
const local = path.join(workspacesRoot, DEFAULT_BRANCH, 'knowledge-base');
|
|
424
|
+
await fs.writeFile(path.join(local, 'unpushed.md'), 'precious', 'utf8');
|
|
425
|
+
await git(local, ['add', '-A']);
|
|
426
|
+
await git(local, ['commit', '-m', 'committed but unpushed']);
|
|
427
|
+
|
|
428
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
429
|
+
try {
|
|
430
|
+
await makeRunner([
|
|
431
|
+
step('dirty-and-race', async (ctx) => {
|
|
432
|
+
const b = await ctx.defaultBranch();
|
|
433
|
+
b.write('startup.md', 'maintenance');
|
|
434
|
+
b.note('Add startup.md');
|
|
435
|
+
// A concurrent replica lands its own commit upstream AFTER this
|
|
436
|
+
// runner's fetch, so the finalize push is rejected non-fast-forward.
|
|
437
|
+
const other = await checkout(DEFAULT_BRANCH);
|
|
438
|
+
await fs.writeFile(path.join(other, 'winner.md'), 'replica won', 'utf8');
|
|
439
|
+
await git(other, ['add', '-A']);
|
|
440
|
+
await git(other, ['commit', '-m', 'replica commit']);
|
|
441
|
+
await git(other, ['push', 'origin', DEFAULT_BRANCH]);
|
|
442
|
+
return { outcome: 'ok' };
|
|
443
|
+
}),
|
|
444
|
+
]).runAll(); // resolves — the carve-out rolls back and continues
|
|
445
|
+
} finally {
|
|
446
|
+
vi.restoreAllMocks();
|
|
447
|
+
}
|
|
448
|
+
// The rollback undid the startup commit and ONLY it: the AHEAD commit is
|
|
449
|
+
// still HEAD (a reset to origin/<branch> would have discarded it too).
|
|
450
|
+
expect(await fs.readFile(path.join(local, 'unpushed.md'), 'utf8')).toBe('precious');
|
|
451
|
+
expect((await git(local, ['log', '--format=%s', '-1'])).trim()).toBe('committed but unpushed');
|
|
452
|
+
expect(await fs.access(path.join(local, 'startup.md')).then(() => true, () => false)).toBe(false);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('a surviving clone that is AHEAD keeps its unpushed commit — not this phase\'s to discard', async () => {
|
|
456
|
+
await populatedUpstream();
|
|
457
|
+
// First pass touches the branch so the clone exists (handles are lazy —
|
|
458
|
+
// a pass that never asks for repoDir never clones).
|
|
459
|
+
await makeRunner([
|
|
460
|
+
step('touch', async (ctx) => {
|
|
461
|
+
await (await ctx.defaultBranch()).repoDir();
|
|
462
|
+
return { outcome: 'ok' };
|
|
463
|
+
}),
|
|
464
|
+
]).runAll();
|
|
465
|
+
const local = path.join(workspacesRoot, DEFAULT_BRANCH, 'knowledge-base');
|
|
466
|
+
// A crash-before-push scenario: a committed local change origin never got.
|
|
467
|
+
await fs.writeFile(path.join(local, 'unpushed.md'), 'precious', 'utf8');
|
|
468
|
+
await git(local, ['add', '-A']);
|
|
469
|
+
await git(local, ['commit', '-m', 'committed but unpushed']);
|
|
470
|
+
|
|
471
|
+
await makeRunner([step('noop', async () => ({ outcome: 'ok' }))]).runAll();
|
|
472
|
+
expect(await fs.readFile(path.join(local, 'unpushed.md'), 'utf8')).toBe('precious');
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
describe('redactSecret', () => {
|
|
477
|
+
it('scrubs the configured token wherever it appears', () => {
|
|
478
|
+
const prev = process.env.GITHUB_TOKEN;
|
|
479
|
+
process.env.GITHUB_TOKEN = 'ghp_supersecret';
|
|
480
|
+
try {
|
|
481
|
+
expect(redactSecret('fatal: ghp_supersecret was rejected')).toBe('fatal: *** was rejected');
|
|
482
|
+
} finally {
|
|
483
|
+
if (prev === undefined) delete process.env.GITHUB_TOKEN;
|
|
484
|
+
else process.env.GITHUB_TOKEN = prev;
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it('scrubs URL userinfo — a user:pass@ remote must not leak the password', () => {
|
|
489
|
+
expect(redactSecret("fetch of 'https://alice:hunter2@example.com/kb.git' failed")).toBe(
|
|
490
|
+
"fetch of 'https://***@example.com/kb.git' failed",
|
|
491
|
+
);
|
|
492
|
+
// A plain URL is untouched.
|
|
493
|
+
expect(redactSecret('https://example.com/kb.git')).toBe('https://example.com/kb.git');
|
|
494
|
+
});
|
|
495
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
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
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The startup phase's own git plumbing — the runner owns every remote and
|
|
11
|
+
* repository operation of the phase (steps only declare buffered ops), so
|
|
12
|
+
* this is deliberately small and self-contained rather than borrowed from
|
|
13
|
+
* the runtime workspace service.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Fallback committer identity; workflow commits override with `--author`. */
|
|
17
|
+
export const BOT_NAME = 'Bevel Workflow';
|
|
18
|
+
export const BOT_EMAIL = 'bevel-workflow@bevel.software';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Scrub credentials from anything that reaches a log or an error message:
|
|
22
|
+
* the configured token wherever it appears, and URL userinfo — a remote
|
|
23
|
+
* spelled `https://user:pass@host` would otherwise leak `pass` verbatim
|
|
24
|
+
* through every git failure that quotes the URL back.
|
|
25
|
+
*/
|
|
26
|
+
export function redactSecret(text: string): string {
|
|
27
|
+
const token = process.env.GITHUB_TOKEN;
|
|
28
|
+
const scrubbed = token ? text.replaceAll(token, '***') : text;
|
|
29
|
+
return scrubbed.replace(/:\/\/[^/@\s]+@/g, '://***@');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Per-invocation `-c` config. Long paths always (Windows checkouts of deep
|
|
34
|
+
* KB trees); when a token is present, an inline credential helper that reads
|
|
35
|
+
* it from the environment at call time — the secret never appears in argv.
|
|
36
|
+
*/
|
|
37
|
+
function credArgs(gitUsername: string): string[] {
|
|
38
|
+
const args = ['-c', 'core.longpaths=true'];
|
|
39
|
+
if (process.env.GITHUB_TOKEN) {
|
|
40
|
+
args.push(
|
|
41
|
+
'-c',
|
|
42
|
+
`credential.helper=!f() { echo "username=${gitUsername}"; echo "password=$GITHUB_TOKEN"; }; f`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
return args;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function git(cwd: string, gitUsername: string, args: string[]): Promise<string> {
|
|
49
|
+
try {
|
|
50
|
+
const { stdout } = await execFileAsync('git', [...credArgs(gitUsername), ...args], {
|
|
51
|
+
cwd,
|
|
52
|
+
// A stalled remote must FAIL the phase, not hang the boot forever —
|
|
53
|
+
// fail-closed (and KB_SAFE_BOOT's demotion) can only engage on an error
|
|
54
|
+
// that actually arrives. 10 minutes is generous for the largest clone.
|
|
55
|
+
timeout: 600_000,
|
|
56
|
+
// The 1MiB default truncates `ls-remote --heads` on remotes with very
|
|
57
|
+
// many branches, which would silently drop heads from the phase's view.
|
|
58
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
59
|
+
// A credential prompt must fail the phase, not hang the boot forever
|
|
60
|
+
// waiting on a terminal nobody is watching.
|
|
61
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
62
|
+
});
|
|
63
|
+
return stdout.toString();
|
|
64
|
+
} catch (err) {
|
|
65
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
66
|
+
throw new Error(redactSecret(`git ${args[0]} failed: ${msg}`));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function stampIdentity(repo: string, gitUsername: string): Promise<void> {
|
|
71
|
+
await git(repo, gitUsername, ['config', 'user.name', BOT_NAME]);
|
|
72
|
+
await git(repo, gitUsername, ['config', 'user.email', BOT_EMAIL]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Branch names present on the remote, from `ls-remote --heads`. */
|
|
76
|
+
export async function lsRemoteHeads(repoUrl: string, gitUsername: string): Promise<Set<string>> {
|
|
77
|
+
const out = await git(os.tmpdir(), gitUsername, ['ls-remote', '--heads', repoUrl]);
|
|
78
|
+
const heads = new Set<string>();
|
|
79
|
+
for (const line of out.split('\n')) {
|
|
80
|
+
const m = /\srefs\/heads\/(.+)$/.exec(line.trim());
|
|
81
|
+
if (m) heads.add(m[1]!);
|
|
82
|
+
}
|
|
83
|
+
return heads;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A temp dir that is always removed, success or failure. */
|
|
87
|
+
export async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|
88
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'kb-startup-'));
|
|
89
|
+
try {
|
|
90
|
+
return await fn(dir);
|
|
91
|
+
} finally {
|
|
92
|
+
await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
93
|
+
}
|
|
94
|
+
}
|