@bevel-software/platform-core-backend 0.21.0 → 0.22.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/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +32 -2
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +13 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +51 -5
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access-requests.contract.d.ts +75 -0
- package/dist/modules/access/access-requests.contract.d.ts.map +1 -0
- package/dist/modules/access/access-requests.contract.js +20 -0
- package/dist/modules/access/access-requests.contract.js.map +1 -0
- package/dist/modules/access/access-requests.routes.d.ts +35 -0
- package/dist/modules/access/access-requests.routes.d.ts.map +1 -0
- package/dist/modules/access/access-requests.routes.js +237 -0
- package/dist/modules/access/access-requests.routes.js.map +1 -0
- package/dist/modules/access/access-requests.service.d.ts +123 -0
- package/dist/modules/access/access-requests.service.d.ts.map +1 -0
- package/dist/modules/access/access-requests.service.js +337 -0
- package/dist/modules/access/access-requests.service.js.map +1 -0
- package/dist/modules/access-model/access-grammar.d.ts +12 -0
- package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
- package/dist/modules/access-model/access-grammar.js +24 -0
- package/dist/modules/access-model/access-grammar.js.map +1 -1
- package/dist/modules/auth/auth.service.d.ts +26 -1
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +11 -2
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/github-app/github-app.client.d.ts +101 -0
- package/dist/modules/github-app/github-app.client.d.ts.map +1 -0
- package/dist/modules/github-app/github-app.client.js +222 -0
- package/dist/modules/github-app/github-app.client.js.map +1 -0
- package/dist/modules/github-app/github-app.connection.d.ts +102 -0
- package/dist/modules/github-app/github-app.connection.d.ts.map +1 -0
- package/dist/modules/github-app/github-app.connection.js +185 -0
- package/dist/modules/github-app/github-app.connection.js.map +1 -0
- package/dist/modules/github-app/github-app.routes.d.ts +59 -0
- package/dist/modules/github-app/github-app.routes.d.ts.map +1 -0
- package/dist/modules/github-app/github-app.routes.js +323 -0
- package/dist/modules/github-app/github-app.routes.js.map +1 -0
- package/dist/modules/github-app/index.d.ts +4 -0
- package/dist/modules/github-app/index.d.ts.map +1 -0
- package/dist/modules/github-app/index.js +4 -0
- package/dist/modules/github-app/index.js.map +1 -0
- package/dist/modules/kb-fs/remote-url.d.ts +22 -0
- package/dist/modules/kb-fs/remote-url.d.ts.map +1 -0
- package/dist/modules/kb-fs/remote-url.js +35 -0
- package/dist/modules/kb-fs/remote-url.js.map +1 -0
- package/dist/modules/plugins/join-proposals.d.ts +17 -5
- package/dist/modules/plugins/join-proposals.d.ts.map +1 -1
- package/dist/modules/plugins/join-proposals.js +76 -22
- package/dist/modules/plugins/join-proposals.js.map +1 -1
- package/dist/modules/plugins/join-requests.service.d.ts +111 -18
- package/dist/modules/plugins/join-requests.service.d.ts.map +1 -1
- package/dist/modules/plugins/join-requests.service.js +173 -29
- package/dist/modules/plugins/join-requests.service.js.map +1 -1
- package/dist/modules/plugins/plugins.routes.d.ts.map +1 -1
- package/dist/modules/plugins/plugins.routes.js +3 -2
- package/dist/modules/plugins/plugins.routes.js.map +1 -1
- package/dist/modules/settings/deployment-settings.service.d.ts +22 -1
- package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
- package/dist/modules/settings/deployment-settings.service.js +93 -4
- package/dist/modules/settings/deployment-settings.service.js.map +1 -1
- package/dist/modules/settings/managed-repository.d.ts +43 -0
- package/dist/modules/settings/managed-repository.d.ts.map +1 -0
- package/dist/modules/settings/managed-repository.js +60 -0
- package/dist/modules/settings/managed-repository.js.map +1 -0
- package/dist/modules/settings/repository-source.d.ts +128 -0
- package/dist/modules/settings/repository-source.d.ts.map +1 -0
- package/dist/modules/settings/repository-source.js +150 -0
- package/dist/modules/settings/repository-source.js.map +1 -0
- package/dist/modules/settings/setup.routes.d.ts +27 -4
- package/dist/modules/settings/setup.routes.d.ts.map +1 -1
- package/dist/modules/settings/setup.routes.js +187 -13
- package/dist/modules/settings/setup.routes.js.map +1 -1
- package/dist/modules/skills/skill-access-requests.routes.d.ts +6 -8
- package/dist/modules/skills/skill-access-requests.routes.d.ts.map +1 -1
- package/dist/modules/skills/skill-access-requests.routes.js +24 -63
- package/dist/modules/skills/skill-access-requests.routes.js.map +1 -1
- package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-context.js +15 -0
- package/dist/modules/tool-helpers/tool-context.js.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +38 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +3 -1
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +17 -4
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/git/node-git-runner.d.ts.map +1 -1
- package/dist/modules/workflow/git/node-git-runner.js +5 -0
- package/dist/modules/workflow/git/node-git-runner.js.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.d.ts +45 -0
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.js +99 -0
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +3 -1
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +34 -7
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +148 -48
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts +66 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.js +148 -0
- package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +17 -1
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +28 -2
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/domain-errors.d.ts +39 -0
- package/dist/shared/domain-errors.d.ts.map +1 -1
- package/dist/shared/domain-errors.js +55 -0
- package/dist/shared/domain-errors.js.map +1 -1
- package/dist/shared/git.contract.d.ts +10 -0
- package/dist/shared/git.contract.d.ts.map +1 -1
- package/dist/shared/git.contract.js.map +1 -1
- package/package.json +3 -3
- package/src/core/create-core-server.ts +37 -1
- package/src/core/create-core-services.ts +65 -8
- package/src/modules/access/__tests__/access-requests.recut.test.ts +134 -0
- package/src/modules/access/__tests__/access-requests.routes.test.ts +610 -0
- package/src/modules/access/access-requests.contract.ts +93 -0
- package/src/modules/access/access-requests.routes.ts +286 -0
- package/src/modules/access/access-requests.service.ts +420 -0
- package/src/modules/access-model/access-grammar.ts +22 -0
- package/src/modules/auth/__tests__/auth.service.test.ts +38 -0
- package/src/modules/auth/auth.service.ts +28 -1
- package/src/modules/github-app/__tests__/github-app.test.ts +848 -0
- package/src/modules/github-app/github-app.client.ts +268 -0
- package/src/modules/github-app/github-app.connection.ts +204 -0
- package/src/modules/github-app/github-app.routes.ts +359 -0
- package/src/modules/github-app/index.ts +19 -0
- package/src/modules/kb-fs/__tests__/remote-url.test.ts +38 -0
- package/src/modules/kb-fs/remote-url.ts +35 -0
- package/src/modules/plugins/__tests__/join-proposals.test.ts +100 -19
- package/src/modules/plugins/__tests__/join-requests.service.test.ts +27 -11
- package/src/modules/plugins/__tests__/join-requests.settlement.test.ts +371 -0
- package/src/modules/plugins/__tests__/plugins.routes.test.ts +1 -1
- package/src/modules/plugins/join-proposals.ts +87 -19
- package/src/modules/plugins/join-requests.service.ts +199 -39
- package/src/modules/plugins/plugins.routes.ts +3 -2
- package/src/modules/settings/__tests__/repository-source.test.ts +191 -0
- package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +353 -0
- package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +282 -0
- package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +233 -0
- package/src/modules/settings/deployment-settings.service.ts +109 -3
- package/src/modules/settings/managed-repository.ts +68 -0
- package/src/modules/settings/repository-source.ts +197 -0
- package/src/modules/settings/setup.routes.ts +214 -10
- package/src/modules/skills/__tests__/skill-access-requests.routes.test.ts +5 -1
- package/src/modules/skills/skill-access-requests.routes.ts +25 -75
- package/src/modules/tool-helpers/tool-context.ts +15 -0
- package/src/modules/workflow/__tests__/apply-failure.test.ts +9 -1
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +159 -8
- package/src/modules/workflow/__tests__/workflow.service.update-from-target.test.ts +146 -40
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +141 -1
- package/src/modules/workflow/agent-tools/workflow.tools.ts +49 -3
- package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +121 -0
- package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +145 -2
- package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-delete.test.ts +166 -0
- package/src/modules/workflow/git/git.service.ts +25 -4
- package/src/modules/workflow/git/node-git-runner.ts +6 -0
- package/src/modules/workflow/git/pull-request.service.ts +119 -0
- package/src/modules/workflow/workflow.routes.ts +3 -1
- package/src/modules/workflow/workflow.service.ts +170 -52
- package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +43 -0
- package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +233 -7
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +35 -3
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +211 -0
- package/src/modules/workspace/startup/kb-startup-runner.ts +159 -0
- package/src/modules/workspace/workspace.service.ts +17 -1
- package/src/modules/workspace/workspace.tools.ts +35 -0
- package/src/shared/domain-errors.ts +58 -0
- package/src/shared/git.contract.ts +10 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import type { Server as HttpServer } from 'node:http';
|
|
4
|
+
import type { AddressInfo } from 'node:net';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
import express from 'express';
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
10
|
+
import { testKbContext } from '../../../__tests__/kb-context.js';
|
|
11
|
+
import { createSetupRoutes } from '../setup.routes.js';
|
|
12
|
+
import { DeploymentSettingsService } from '../deployment-settings.service.js';
|
|
13
|
+
import { ManagedRepository } from '../managed-repository.js';
|
|
14
|
+
import { RepositorySource } from '../repository-source.js';
|
|
15
|
+
import { KbStartupRunner } from '../../workspace/startup/kb-startup-runner.js';
|
|
16
|
+
import { NodeGitRunner } from '../../workflow/git/node-git-runner.js';
|
|
17
|
+
import { TemplateFilesStep } from '../../workspace/startup/steps/template-files.step.js';
|
|
18
|
+
import { NodeFs } from '../../kb-fs/node-fs.js';
|
|
19
|
+
import { defaultKbTemplateDir } from '../../../assets.js';
|
|
20
|
+
import type { Database } from '../../database/connection.js';
|
|
21
|
+
import type { IAdminAccessService } from '../../admin/admin.interface.js';
|
|
22
|
+
import type { KbContext } from '../../../shared/kb-context.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A repository the deployment keeps, and the REAL startup phase, wired the
|
|
26
|
+
* way the composition root wires them: the phase asks the repository source
|
|
27
|
+
* where the repository is, and git runs with what the source presents.
|
|
28
|
+
*
|
|
29
|
+
* A fake phase proves the routes chose the right address. It cannot see
|
|
30
|
+
* whether a bare repository on a disk is something the phase can seed,
|
|
31
|
+
* clone and push to, or what happens to the working copies of a repository
|
|
32
|
+
* the deployment has moved away from. This looks at the disk afterwards.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const execFileAsync = promisify(execFile);
|
|
36
|
+
const ENC_KEY = 'kToAi8FXWDpDn3A6yQ/60O39bv05N7XzVOIu/0CJrFc=';
|
|
37
|
+
const KB_ENV = ['KB_REPO_URL', 'GIT_TOKEN', 'GIT_USERNAME', 'GIT_MODE', 'GITHUB_TOKEN', 'DEFAULT_BRANCH', 'PROTECTED_BRANCHES'] as const;
|
|
38
|
+
|
|
39
|
+
const gitEnv = {
|
|
40
|
+
...process.env,
|
|
41
|
+
GIT_AUTHOR_NAME: 'Test',
|
|
42
|
+
GIT_AUTHOR_EMAIL: 't@x.com',
|
|
43
|
+
GIT_COMMITTER_NAME: 'Test',
|
|
44
|
+
GIT_COMMITTER_EMAIL: 't@x.com',
|
|
45
|
+
};
|
|
46
|
+
const git = async (cwd: string, args: string[]) =>
|
|
47
|
+
(await execFileAsync('git', args, { cwd, env: gitEnv })).stdout.toString().trim();
|
|
48
|
+
|
|
49
|
+
let root: string;
|
|
50
|
+
let server: HttpServer | null = null;
|
|
51
|
+
let savedEnv: Partial<Record<(typeof KB_ENV)[number], string | undefined>> = {};
|
|
52
|
+
|
|
53
|
+
beforeEach(async () => {
|
|
54
|
+
savedEnv = {};
|
|
55
|
+
for (const k of KB_ENV) {
|
|
56
|
+
savedEnv[k] = process.env[k];
|
|
57
|
+
delete process.env[k];
|
|
58
|
+
}
|
|
59
|
+
root = await fs.mkdtemp(path.join(os.tmpdir(), 'setup-managed-phase-'));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
afterEach(async () => {
|
|
63
|
+
server?.close();
|
|
64
|
+
server = null;
|
|
65
|
+
for (const k of KB_ENV) {
|
|
66
|
+
const original = savedEnv[k];
|
|
67
|
+
if (original === undefined) delete process.env[k];
|
|
68
|
+
else process.env[k] = original;
|
|
69
|
+
}
|
|
70
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/** A deployment: its settings, its repository source, the real phase, and the setup routes over them. */
|
|
74
|
+
function boot(kb: KbContext) {
|
|
75
|
+
const db = {
|
|
76
|
+
select: () => ({ from: () => Promise.resolve([]) }),
|
|
77
|
+
insert: () => ({ values: () => ({ onConflictDoUpdate: () => Promise.resolve() }) }),
|
|
78
|
+
delete: () => ({ where: () => Promise.resolve() }),
|
|
79
|
+
} as unknown as Database;
|
|
80
|
+
const settings = new DeploymentSettingsService(db, ENC_KEY);
|
|
81
|
+
const managed = new ManagedRepository(path.join(root, 'backups', 'managed-repository'));
|
|
82
|
+
// A deployment reaching a repository by its address has one on a host; here
|
|
83
|
+
// it is a folder, which the source hands over as it would hand a URL.
|
|
84
|
+
const hosted = path.join(root, 'hosted.git');
|
|
85
|
+
const source = new RepositorySource({
|
|
86
|
+
read: (key) => (key === 'kbRepoUrl' && settings.resolve('kbRepoUrl') ? hosted : settings.resolve(key)),
|
|
87
|
+
managed,
|
|
88
|
+
});
|
|
89
|
+
const gitRunner = new NodeGitRunner(60_000, source.credentials);
|
|
90
|
+
const workspacesRoot = path.join(root, 'workspaces');
|
|
91
|
+
const setAsideRoot = path.join(root, 'backups', 'replaced-working-copies');
|
|
92
|
+
const runner = new KbStartupRunner({
|
|
93
|
+
gitRunner,
|
|
94
|
+
kbRepoUrl: () => source.url(),
|
|
95
|
+
workspacesRoot,
|
|
96
|
+
setAsideRoot,
|
|
97
|
+
kbDirName: 'knowledge-base',
|
|
98
|
+
templateDir: defaultKbTemplateDir(),
|
|
99
|
+
defaultBranch: () => kb.defaultBranch,
|
|
100
|
+
protectedBranches: () => [...kb.protectedBranches],
|
|
101
|
+
seedAdminEmails: ['admin@example.com'],
|
|
102
|
+
// A step that works on every branch, so the phase makes each its working copy.
|
|
103
|
+
steps: [new TemplateFilesStep(new NodeFs(), kb)],
|
|
104
|
+
buildSeedTree: async (dir) => {
|
|
105
|
+
await fs.writeFile(path.join(dir, 'WELCOME.md'), 'seeded\n', 'utf8');
|
|
106
|
+
return ['WELCOME.md'];
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
const app = express();
|
|
110
|
+
app.use(express.json());
|
|
111
|
+
app.use((req, _res, next) => {
|
|
112
|
+
req.userEmail = 'root@example.com';
|
|
113
|
+
req.userId = 'user-1';
|
|
114
|
+
next();
|
|
115
|
+
});
|
|
116
|
+
app.use(
|
|
117
|
+
'/api',
|
|
118
|
+
createSetupRoutes(
|
|
119
|
+
settings,
|
|
120
|
+
{ isAdmin: async () => true } as IAdminAccessService,
|
|
121
|
+
runner,
|
|
122
|
+
kb,
|
|
123
|
+
undefined,
|
|
124
|
+
async () => ({ outcome: 'connected', branches: ['main'], defaultBranch: 'main', empty: false }),
|
|
125
|
+
undefined,
|
|
126
|
+
undefined,
|
|
127
|
+
undefined,
|
|
128
|
+
undefined,
|
|
129
|
+
{ source, ensureManaged: (branch) => managed.ensure(gitRunner, branch) },
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
server = app.listen(0);
|
|
133
|
+
const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
|
134
|
+
const save = async (entries: Record<string, string>) => {
|
|
135
|
+
const res = await fetch(`${base}/api/setup/settings`, {
|
|
136
|
+
method: 'POST',
|
|
137
|
+
headers: { 'content-type': 'application/json' },
|
|
138
|
+
body: JSON.stringify({ settings: entries }),
|
|
139
|
+
});
|
|
140
|
+
return { status: res.status, body: (await res.json()) as Record<string, unknown> };
|
|
141
|
+
};
|
|
142
|
+
const workingCopy = (branch: string) => path.join(workspacesRoot, encodeURIComponent(branch), 'knowledge-base');
|
|
143
|
+
return { save, managed, hosted, runner, source, workingCopy, setAsideRoot };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
describe('the save that chooses a repository the deployment keeps', () => {
|
|
147
|
+
it('creates it, seeds it and clones from it, asking for nothing else', async () => {
|
|
148
|
+
const { save, managed, workingCopy } = boot(testKbContext({ branchModel: null }));
|
|
149
|
+
const { status, body } = await save({ gitMode: 'managed' });
|
|
150
|
+
expect(status, JSON.stringify(body)).toBe(200);
|
|
151
|
+
expect(body).toMatchObject({ complete: true, restartRequired: false });
|
|
152
|
+
|
|
153
|
+
// The repository is where the source says, bare, and holds the seed on the branch named for it.
|
|
154
|
+
expect(await git(managed.path, ['rev-parse', '--is-bare-repository'])).toBe('true');
|
|
155
|
+
expect(await git(managed.path, ['for-each-ref', '--format=%(refname)'])).toBe('refs/heads/main');
|
|
156
|
+
expect(await git(managed.path, ['ls-tree', '-r', '--name-only', 'main'])).toContain('WELCOME.md');
|
|
157
|
+
|
|
158
|
+
// The working copy is a clone of it, and carries no credential helper: there is no credential.
|
|
159
|
+
const copy = workingCopy('main');
|
|
160
|
+
expect(await git(copy, ['config', '--get', 'remote.origin.url'])).toBe(managed.path);
|
|
161
|
+
expect((await fs.readFile(path.join(copy, 'WELCOME.md'), 'utf8')).replace(/\r\n/g, '\n')).toBe('seeded\n');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('takes a push from a working copy, like any remote', async () => {
|
|
165
|
+
const { save, managed, workingCopy } = boot(testKbContext({ branchModel: null }));
|
|
166
|
+
await save({ gitMode: 'managed' });
|
|
167
|
+
const copy = workingCopy('main');
|
|
168
|
+
await fs.writeFile(path.join(copy, 'note.md'), 'a change\n', 'utf8');
|
|
169
|
+
await git(copy, ['add', '-A']);
|
|
170
|
+
await git(copy, ['commit', '-m', 'a change']);
|
|
171
|
+
await git(copy, ['push', 'origin', 'main']);
|
|
172
|
+
expect(await git(managed.path, ['ls-tree', '-r', '--name-only', 'main'])).toContain('note.md');
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('a deployment that moves to a repository it keeps', () => {
|
|
177
|
+
it('leaves the repository it had untouched and sets its working copies aside, unpushed work included', async () => {
|
|
178
|
+
const { save, managed, hosted, runner, source, workingCopy, setAsideRoot } = boot(
|
|
179
|
+
testKbContext({ branchModel: { defaultBranch: 'main', protectedBranches: ['main'] } }),
|
|
180
|
+
);
|
|
181
|
+
// A deployment on a repository of its own, set up and serving. The
|
|
182
|
+
// repository has a history of ITS OWN, written here and not by the
|
|
183
|
+
// startup phase: two empty repositories the phase seeds within the
|
|
184
|
+
// same second get the same first commit (same tree, same author, same
|
|
185
|
+
// time), and the phase would then read them, rightly, as one repository
|
|
186
|
+
// at two addresses, and keep the working copy instead of setting it
|
|
187
|
+
// aside. A fast machine did exactly that.
|
|
188
|
+
await git(root, ['init', '--bare', '-b', 'main', hosted]);
|
|
189
|
+
const theirs = path.join(root, 'theirs');
|
|
190
|
+
await fs.mkdir(theirs);
|
|
191
|
+
await git(theirs, ['init', '-b', 'main']);
|
|
192
|
+
await fs.writeFile(path.join(theirs, 'HANDBOOK.md'), 'what this organisation had before\n', 'utf8');
|
|
193
|
+
await git(theirs, ['add', '-A']);
|
|
194
|
+
await git(theirs, ['commit', '-m', 'the handbook']);
|
|
195
|
+
await git(theirs, ['push', hosted, 'main']);
|
|
196
|
+
const first = await save({ kbRepoUrl: 'https://git.example.com/acme/kb.git', gitToken: 'a-token', defaultBranch: 'main', protectedBranches: 'main' });
|
|
197
|
+
expect(first.status, JSON.stringify(first.body)).toBe(200);
|
|
198
|
+
const copy = workingCopy('main');
|
|
199
|
+
expect(await git(copy, ['config', '--get', 'remote.origin.url'])).toBe(hosted);
|
|
200
|
+
// Work that was committed and never pushed.
|
|
201
|
+
await fs.writeFile(path.join(copy, 'unpushed.md'), 'only here\n', 'utf8');
|
|
202
|
+
await git(copy, ['add', '-A']);
|
|
203
|
+
await git(copy, ['commit', '-m', 'never pushed']);
|
|
204
|
+
const hostedBefore = await git(hosted, ['for-each-ref']);
|
|
205
|
+
|
|
206
|
+
const moved = await save({ gitMode: 'managed' });
|
|
207
|
+
expect(moved.body).toMatchObject({ ok: true, restartRequired: true });
|
|
208
|
+
// Until the restart the working copy is where it was, and so is the
|
|
209
|
+
// deployment: the repository it has is the one it goes on talking to.
|
|
210
|
+
expect(await git(copy, ['config', '--get', 'remote.origin.url'])).toBe(hosted);
|
|
211
|
+
expect(source.url()).toBe(hosted);
|
|
212
|
+
|
|
213
|
+
// The restart: the deployment is built on the mode chosen, and the
|
|
214
|
+
// phase brings the working copies into line.
|
|
215
|
+
source.takeEffect();
|
|
216
|
+
await runner.runAll();
|
|
217
|
+
|
|
218
|
+
expect(await git(hosted, ['for-each-ref'])).toBe(hostedBefore);
|
|
219
|
+
expect(await git(copy, ['config', '--get', 'remote.origin.url'])).toBe(managed.path);
|
|
220
|
+
expect(await fs.access(path.join(copy, 'unpushed.md')).then(() => true, () => false)).toBe(false);
|
|
221
|
+
const managedHolds = await git(managed.path, ['ls-tree', '-r', '--name-only', 'main']);
|
|
222
|
+
expect(managedHolds).toContain('WELCOME.md');
|
|
223
|
+
// Nothing of one repository was pushed into the other.
|
|
224
|
+
expect(managedHolds).not.toContain('HANDBOOK.md');
|
|
225
|
+
expect(managedHolds).not.toContain('unpushed.md');
|
|
226
|
+
|
|
227
|
+
// What was set aside is whole: the commit nobody pushed is in it.
|
|
228
|
+
const [stamp] = await fs.readdir(setAsideRoot);
|
|
229
|
+
const kept = path.join(setAsideRoot, stamp!, encodeURIComponent('main'));
|
|
230
|
+
expect(await git(kept, ['log', '-1', '--format=%s'])).toBe('never pushed');
|
|
231
|
+
expect((await fs.readFile(path.join(kept, 'unpushed.md'), 'utf8')).replace(/\r\n/g, '\n')).toBe('only here\n');
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -17,6 +17,7 @@ import { TokenCrypto } from '../../shared/token-crypto.js';
|
|
|
17
17
|
import { assertKbDirNameFree } from '../kb-fs/repo-path.js';
|
|
18
18
|
import { parseRetentionWindow } from '../audit/audit.contract.js';
|
|
19
19
|
import { normalizeIssuerUrl } from './oidc-check.js';
|
|
20
|
+
import { GIT_MODES, isGitMode } from './repository-source.js';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* A setting an admin may set from the setup screen instead of the environment.
|
|
@@ -69,6 +70,17 @@ export interface SettingDef {
|
|
|
69
70
|
* restart for a change that never happened.
|
|
70
71
|
*/
|
|
71
72
|
unsetMeans?: string;
|
|
73
|
+
/**
|
|
74
|
+
* Written by the deployment itself, at the end of a flow that PROVED the
|
|
75
|
+
* value, and never taken from a save. What a GitHub App was given when it
|
|
76
|
+
* was registered, and which installation of it the admin was shown to
|
|
77
|
+
* hold, are facts somebody established; accepted from a form they would
|
|
78
|
+
* be claims, and the claim "installation 42 is mine" is one that reaches
|
|
79
|
+
* another organisation's repositories. A save that names one is refused,
|
|
80
|
+
* the setup screen is not told about it, and its value is read like any
|
|
81
|
+
* other's (the environment first), so an operator can still pin it.
|
|
82
|
+
*/
|
|
83
|
+
internal?: boolean;
|
|
72
84
|
}
|
|
73
85
|
|
|
74
86
|
/**
|
|
@@ -99,6 +111,25 @@ export const validateHttpsRemote = (value: string): string | null => {
|
|
|
99
111
|
* The core catalogue. Order is the order the setup screen renders them in.
|
|
100
112
|
*/
|
|
101
113
|
export const CORE_SETTINGS: SettingDef[] = [
|
|
114
|
+
{
|
|
115
|
+
/**
|
|
116
|
+
* Which way the deployment is given its repository (see
|
|
117
|
+
* `RepositorySource`). Unset on a deployment configured before there was
|
|
118
|
+
* a choice, which is read as `token` when it has an address or a token,
|
|
119
|
+
* and as nothing chosen when it has neither.
|
|
120
|
+
*
|
|
121
|
+
* Restart-to-apply on a deployment that is running: its working copies
|
|
122
|
+
* were cloned from the repository it had, and they are brought into line
|
|
123
|
+
* with another one by the startup phase, at a moment when nobody is
|
|
124
|
+
* using them. The save that completes first-run setup runs that phase
|
|
125
|
+
* itself, and owes no restart.
|
|
126
|
+
*/
|
|
127
|
+
key: 'gitMode',
|
|
128
|
+
envVar: 'GIT_MODE',
|
|
129
|
+
section: 'knowledge-base',
|
|
130
|
+
validate: (v) => (isGitMode(v) ? null : `Choose one of: ${GIT_MODES.join(', ')}.`),
|
|
131
|
+
restartToApply: true,
|
|
132
|
+
},
|
|
102
133
|
{
|
|
103
134
|
key: 'kbRepoUrl',
|
|
104
135
|
envVar: 'KB_REPO_URL',
|
|
@@ -121,6 +152,59 @@ export const CORE_SETTINGS: SettingDef[] = [
|
|
|
121
152
|
validate: (v) =>
|
|
122
153
|
/^[A-Za-z0-9._-]+$/.test(v) ? null : 'Use only letters, digits, dot, underscore or hyphen.',
|
|
123
154
|
},
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* A repository on GitHub, reached through a GitHub App (see
|
|
158
|
+
* `modules/github-app/`).
|
|
159
|
+
*
|
|
160
|
+
* THE REPOSITORY is the admin's to choose, among those the installation
|
|
161
|
+
* reaches, and is saved like any setting: a name the installation does not
|
|
162
|
+
* reach fails the connection check, since the only token presented to
|
|
163
|
+
* GitHub is the installation's.
|
|
164
|
+
*
|
|
165
|
+
* EVERYTHING ELSE is internal: the app's identity and keys, as GitHub
|
|
166
|
+
* issued them when the app was registered, and the installation, as
|
|
167
|
+
* GitHub confirmed the admin holds it. Each has a variable, so whoever
|
|
168
|
+
* operates the deployment can supply an app they registered themselves,
|
|
169
|
+
* and a host serving many deployments can supply one app for all of them.
|
|
170
|
+
*/
|
|
171
|
+
{
|
|
172
|
+
key: 'githubRepository',
|
|
173
|
+
envVar: 'GITHUB_APP_REPOSITORY',
|
|
174
|
+
section: 'knowledge-base',
|
|
175
|
+
validate: (v) =>
|
|
176
|
+
/^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\/[A-Za-z0-9._-]+$/.test(v) && !v.endsWith('.git')
|
|
177
|
+
? null
|
|
178
|
+
: 'Name the repository as owner/name.',
|
|
179
|
+
},
|
|
180
|
+
{ key: 'githubAppId', envVar: 'GITHUB_APP_ID', section: 'knowledge-base', internal: true, validate: (v) => (/^\d+$/.test(v) ? null : 'The app id is a number.') },
|
|
181
|
+
{ key: 'githubAppSlug', envVar: 'GITHUB_APP_SLUG', section: 'knowledge-base', internal: true, validate: (v) => (/^[A-Za-z0-9-]+$/.test(v) ? null : 'The app slug is letters, digits and hyphens.') },
|
|
182
|
+
{ key: 'githubAppPrivateKey', envVar: 'GITHUB_APP_PRIVATE_KEY', section: 'knowledge-base', internal: true, secret: true },
|
|
183
|
+
{ key: 'githubAppClientId', envVar: 'GITHUB_APP_CLIENT_ID', section: 'knowledge-base', internal: true },
|
|
184
|
+
{ key: 'githubAppClientSecret', envVar: 'GITHUB_APP_CLIENT_SECRET', section: 'knowledge-base', internal: true, secret: true },
|
|
185
|
+
{
|
|
186
|
+
/**
|
|
187
|
+
* Put in front of the state every round trip to GitHub carries, by a
|
|
188
|
+
* host that serves several deployments behind ONE app: GitHub sends the
|
|
189
|
+
* browser back to the one address registered for the app, and the host
|
|
190
|
+
* reads this to know which deployment to hand it to. Never stored.
|
|
191
|
+
*/
|
|
192
|
+
key: 'githubAppStateTag',
|
|
193
|
+
envVar: 'GITHUB_APP_STATE_TAG',
|
|
194
|
+
section: 'knowledge-base',
|
|
195
|
+
internal: true,
|
|
196
|
+
validate: (v) => (/^[a-z0-9-]{1,63}$/.test(v) ? null : 'Lowercase letters, digits and hyphens.'),
|
|
197
|
+
},
|
|
198
|
+
{ key: 'githubInstallationId', envVar: 'GITHUB_APP_INSTALLATION_ID', section: 'knowledge-base', internal: true, validate: (v) => (/^\d+$/.test(v) ? null : 'The installation id is a number.') },
|
|
199
|
+
/** Whose account the installation is on, as GitHub names it: shown, never trusted. */
|
|
200
|
+
{ key: 'githubInstallationAccount', section: 'knowledge-base', internal: true },
|
|
201
|
+
/**
|
|
202
|
+
* The repositories the person who connected the installation could PUSH
|
|
203
|
+
* to with their own GitHub account, one `owner/name` a line, as GitHub
|
|
204
|
+
* listed them when they connected. The deployment may be pointed at one
|
|
205
|
+
* of these and at nothing else the installation reaches.
|
|
206
|
+
*/
|
|
207
|
+
{ key: 'githubRepositoriesPermitted', section: 'knowledge-base', internal: true },
|
|
124
208
|
{
|
|
125
209
|
key: 'kbDirName',
|
|
126
210
|
envVar: 'KB_DIR_NAME',
|
|
@@ -583,7 +667,7 @@ export class DeploymentSettingsService {
|
|
|
583
667
|
* replace it, which is all anyone needs to finish setup.
|
|
584
668
|
*/
|
|
585
669
|
describe(): ResolvedSetting[] {
|
|
586
|
-
return this.definitions.map((def) => {
|
|
670
|
+
return this.definitions.filter((def) => !def.internal).map((def) => {
|
|
587
671
|
const source = this.sourceOf(def.key);
|
|
588
672
|
const base = {
|
|
589
673
|
key: def.key,
|
|
@@ -616,8 +700,10 @@ export class DeploymentSettingsService {
|
|
|
616
700
|
async save(
|
|
617
701
|
entries: Record<string, string>,
|
|
618
702
|
updatedBy: string | null,
|
|
703
|
+
/** Set by {@link record} alone: the batch is the deployment's own, and may name internal settings. */
|
|
704
|
+
by: 'admin' | 'deployment' = 'admin',
|
|
619
705
|
): Promise<{ restartRequired: boolean; restartKeys: string[] }> {
|
|
620
|
-
const { toWrite, toClear } = this.plan(entries);
|
|
706
|
+
const { toWrite, toClear } = this.plan(entries, by);
|
|
621
707
|
|
|
622
708
|
/** The settings this save changed that a running server cannot pick up. */
|
|
623
709
|
const restartKeys: string[] = [];
|
|
@@ -662,8 +748,22 @@ export class DeploymentSettingsService {
|
|
|
662
748
|
toClear.includes(key) ? '' : (toWrite.find((w) => w.key === key)?.value ?? this.resolve(key));
|
|
663
749
|
}
|
|
664
750
|
|
|
751
|
+
/**
|
|
752
|
+
* Store what the DEPLOYMENT established, internal settings included: the
|
|
753
|
+
* end of a flow that proved each value (see `SettingDef.internal`). The
|
|
754
|
+
* same validation, the same encryption, the same refusal of a setting the
|
|
755
|
+
* environment pins. Never reachable from a request body: the setup routes
|
|
756
|
+
* call {@link save}.
|
|
757
|
+
*/
|
|
758
|
+
async record(entries: Record<string, string>, updatedBy: string | null): Promise<void> {
|
|
759
|
+
await this.save(entries, updatedBy, 'deployment');
|
|
760
|
+
}
|
|
761
|
+
|
|
665
762
|
/** Validate a batch and return the writes (and the clears) it amounts to; throws on any problem. */
|
|
666
|
-
private plan(
|
|
763
|
+
private plan(
|
|
764
|
+
entries: Record<string, string>,
|
|
765
|
+
by: 'admin' | 'deployment' = 'admin',
|
|
766
|
+
): {
|
|
667
767
|
toWrite: { key: string; value: string; def: SettingDef }[];
|
|
668
768
|
toClear: string[];
|
|
669
769
|
} {
|
|
@@ -677,6 +777,12 @@ export class DeploymentSettingsService {
|
|
|
677
777
|
problems[key] = 'Unknown setting.';
|
|
678
778
|
continue;
|
|
679
779
|
}
|
|
780
|
+
if (def.internal && by !== 'deployment') {
|
|
781
|
+
// Said the way an unknown setting is: what the deployment keeps for
|
|
782
|
+
// itself is not the form's to name, or to learn the names of.
|
|
783
|
+
problems[key] = 'Unknown setting.';
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
680
786
|
if (this.sourceOf(key) === 'env') {
|
|
681
787
|
problems[key] = `Set by the ${def.envVar} environment variable — change it there.`;
|
|
682
788
|
continue;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import type { IGitRunner } from '../../shared/git.contract.js';
|
|
4
|
+
|
|
5
|
+
/** The folder the managed repository is kept in, under the root it is given. */
|
|
6
|
+
export const MANAGED_REPOSITORY_DIR = 'knowledge-base.git';
|
|
7
|
+
|
|
8
|
+
/** The branch a managed repository starts on when the deployment names none. */
|
|
9
|
+
export const MANAGED_DEFAULT_BRANCH = 'main';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The repository a deployment keeps FOR ITSELF, when its admin chose not to
|
|
13
|
+
* bring one: a bare git repository on the deployment's own disk.
|
|
14
|
+
*
|
|
15
|
+
* Nothing else about the deployment changes. Working copies are cloned from
|
|
16
|
+
* it, pushed to it and fetched from it exactly as they are from a
|
|
17
|
+
* repository on a host; it happens to be reached by a path, and to need no
|
|
18
|
+
* credential. An empty one is seeded by the startup phase like any empty
|
|
19
|
+
* remote.
|
|
20
|
+
*
|
|
21
|
+
* WHERE IT LIVES IS THE WHOLE DECISION. It is the only copy of the
|
|
22
|
+
* knowledge base that is not a working copy, so it has to be somewhere that
|
|
23
|
+
* survives the container and that nothing tidies up:
|
|
24
|
+
*
|
|
25
|
+
* - not under the workspaces root, where the workspace service's orphan
|
|
26
|
+
* sweep removes every folder that is not a known branch's;
|
|
27
|
+
* - not in a new folder beside it, which every shipped compose file would
|
|
28
|
+
* have to grow a volume for, and a deployment started from an older file
|
|
29
|
+
* would lose on its next redeploy without a word.
|
|
30
|
+
*
|
|
31
|
+
* The composition root puts it under the BACKUPS root: a volume of its own
|
|
32
|
+
* in every compose file that has ever shipped, and one nothing sweeps.
|
|
33
|
+
*/
|
|
34
|
+
export class ManagedRepository {
|
|
35
|
+
/** The repository's folder: what git is given as the remote. */
|
|
36
|
+
readonly path: string;
|
|
37
|
+
|
|
38
|
+
constructor(private readonly root: string) {
|
|
39
|
+
this.path = path.join(root, MANAGED_REPOSITORY_DIR);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Whether the repository is there. A folder that is not a repository does not count. */
|
|
43
|
+
async exists(): Promise<boolean> {
|
|
44
|
+
return fs.access(path.join(this.path, 'HEAD')).then(
|
|
45
|
+
() => true,
|
|
46
|
+
() => false,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Make sure the repository exists, creating it empty when it does not.
|
|
52
|
+
* One that exists is left exactly as it is: this never re-initialises,
|
|
53
|
+
* and never touches a ref.
|
|
54
|
+
*/
|
|
55
|
+
async ensure(runner: IGitRunner, initialBranch: string = MANAGED_DEFAULT_BRANCH): Promise<void> {
|
|
56
|
+
if (await this.exists()) return;
|
|
57
|
+
await fs.mkdir(this.root, { recursive: true });
|
|
58
|
+
// A folder that is there without being a repository is somebody's, and
|
|
59
|
+
// initialising over it would mix a repository into their files.
|
|
60
|
+
const entries = await fs.readdir(this.path).catch(() => null);
|
|
61
|
+
if (entries && entries.length > 0) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`${this.path} exists and is not a git repository. Move it away, or empty it, before choosing a managed repository.`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
await runner.run(this.root, ['init', '--bare', `--initial-branch=${initialBranch}`, this.path]);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { DEFAULT_GIT_USERNAME, type GitCredentials } from '../../shared/git.contract.js';
|
|
2
|
+
import type { ManagedRepository } from './managed-repository.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The ways a deployment can be given the repository its knowledge base
|
|
6
|
+
* lives in, in the order the setup screen offers them.
|
|
7
|
+
*
|
|
8
|
+
* - `managed`: the deployment keeps the repository itself.
|
|
9
|
+
* - `github-app`: a repository on GitHub, reached through a GitHub App the
|
|
10
|
+
* admin installed on it.
|
|
11
|
+
* - `token`: any git host, by its address and an access token.
|
|
12
|
+
*/
|
|
13
|
+
export const GIT_MODES = ['managed', 'github-app', 'token'] as const;
|
|
14
|
+
export type GitMode = (typeof GIT_MODES)[number];
|
|
15
|
+
|
|
16
|
+
export function isGitMode(value: unknown): value is GitMode {
|
|
17
|
+
return typeof value === 'string' && (GIT_MODES as readonly string[]).includes(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Reads one setting as it is in effect, or as it would be after a save. */
|
|
21
|
+
export type SettingReader = (key: string) => string;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A repository reached through a GitHub App: which one, and the short-lived
|
|
25
|
+
* token the app is given for it. Implemented by the GitHub App module; the
|
|
26
|
+
* source only asks.
|
|
27
|
+
*/
|
|
28
|
+
export interface GitHubAppRepository {
|
|
29
|
+
/** `https://github.com/<owner>/<name>.git`, or empty while none is chosen. */
|
|
30
|
+
url(read: SettingReader): string;
|
|
31
|
+
/** The installation token in hand, or null when there is none to give. */
|
|
32
|
+
token(): string | null;
|
|
33
|
+
/**
|
|
34
|
+
* Make sure {@link token} answers with one that is still good. `asked`
|
|
35
|
+
* is for a caller waiting on the answer itself, who is told a failure.
|
|
36
|
+
*/
|
|
37
|
+
prepare(opts?: { asked?: boolean }): Promise<void>;
|
|
38
|
+
/** Whether the deployment may be pointed at this repository (`owner/name`). */
|
|
39
|
+
permits(repository: string, read: SettingReader): boolean;
|
|
40
|
+
/** Whether the app, its installation and a repository are all there. */
|
|
41
|
+
answered(read: SettingReader): boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface RepositorySourceOptions {
|
|
45
|
+
/** The deployment's settings, as they are in effect. */
|
|
46
|
+
read: SettingReader;
|
|
47
|
+
/**
|
|
48
|
+
* What the environment gives the token mode beyond the settings
|
|
49
|
+
* catalogue: the legacy spellings of the token variable, and the username
|
|
50
|
+
* a deployment configured there.
|
|
51
|
+
*/
|
|
52
|
+
fallback?: { username?: string; token?: string };
|
|
53
|
+
managed: ManagedRepository;
|
|
54
|
+
githubApp?: GitHubAppRepository;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* WHERE THE KNOWLEDGE BASE'S REPOSITORY IS, AND WHAT GIT PRESENTS TO IT —
|
|
59
|
+
* the one place that knows there is more than one answer.
|
|
60
|
+
*
|
|
61
|
+
* Everything that runs git asks for two things: an address to clone from,
|
|
62
|
+
* and a credential. Both used to be read straight from two settings, which
|
|
63
|
+
* is one way of having a repository. With three ways, each reader deciding
|
|
64
|
+
* for itself would be three places to disagree, so they all ask here, and
|
|
65
|
+
* what they get back is the same two things as before.
|
|
66
|
+
*
|
|
67
|
+
* TWO MODES, AND THEY ARE NOT ALWAYS THE SAME ONE.
|
|
68
|
+
*
|
|
69
|
+
* - The mode IN EFFECT is the one this process is running on. Its working
|
|
70
|
+
* copies are clones of that mode's repository, and every address and
|
|
71
|
+
* credential this class hands out is that mode's. It is taken when the
|
|
72
|
+
* source is built and changes only in {@link takeEffect}.
|
|
73
|
+
* - The mode CHOSEN is what the settings say. A save changes it at once.
|
|
74
|
+
*
|
|
75
|
+
* They differ between the save that moves a serving deployment and the
|
|
76
|
+
* restart that save owes. Reading the mode live would move the process the
|
|
77
|
+
* moment the setting was stored: the new mode's credential presented to the
|
|
78
|
+
* old mode's repository, which every working copy still points at, and new
|
|
79
|
+
* branches cloned from a repository the startup phase has not prepared. So
|
|
80
|
+
* the process stays where it is until it is started again, which is what
|
|
81
|
+
* the setup screen tells the admin.
|
|
82
|
+
*
|
|
83
|
+
* WITHIN a mode the values are read live, as they always were: a rotated
|
|
84
|
+
* token is the one the next push carries.
|
|
85
|
+
*
|
|
86
|
+
* THE MODE IS INFERRED WHEN NOBODY CHOSE ONE. A deployment configured before
|
|
87
|
+
* there was a choice has an address and a token and no mode; it is in
|
|
88
|
+
* `token` mode, because that is what it has. One with nothing configured has
|
|
89
|
+
* no mode, and the setup screen offers the first.
|
|
90
|
+
*/
|
|
91
|
+
export class RepositorySource {
|
|
92
|
+
/** What git authenticates with, read on every call. */
|
|
93
|
+
readonly credentials: GitCredentials;
|
|
94
|
+
private inEffect: GitMode | null;
|
|
95
|
+
|
|
96
|
+
constructor(private readonly opts: RepositorySourceOptions) {
|
|
97
|
+
this.inEffect = this.chosen();
|
|
98
|
+
this.credentials = {
|
|
99
|
+
username: () => this.username(),
|
|
100
|
+
token: () => this.token(),
|
|
101
|
+
prepare: () => this.prepare(),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Without a reader, the mode IN EFFECT. With one, the mode a save of that
|
|
107
|
+
* reader's values would CHOOSE: the answer validation wants, about values
|
|
108
|
+
* that are not stored yet. Null: nothing is configured.
|
|
109
|
+
*/
|
|
110
|
+
mode(read?: SettingReader): GitMode | null {
|
|
111
|
+
return read ? this.chosen(read) : this.inEffect;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The mode the settings say, which a restart would put in effect. */
|
|
115
|
+
chosen(read: SettingReader = this.opts.read): GitMode | null {
|
|
116
|
+
const named = read('gitMode').trim();
|
|
117
|
+
if (isGitMode(named)) return named;
|
|
118
|
+
return read('kbRepoUrl') || this.tokenIn(read) ? 'token' : null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Put the mode chosen in effect. Called where nothing is using the mode
|
|
123
|
+
* that was: by a save made before the deployment was serving, which the
|
|
124
|
+
* startup phase follows. A process that is serving is never moved; it is
|
|
125
|
+
* restarted, and built on the mode chosen.
|
|
126
|
+
*/
|
|
127
|
+
takeEffect(): void {
|
|
128
|
+
this.inEffect = this.chosen();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The address git clones from and pushes to. Empty while there is none. Asked as {@link mode} is. */
|
|
132
|
+
url(read?: SettingReader): string {
|
|
133
|
+
const from = read ?? this.opts.read;
|
|
134
|
+
switch (this.mode(read)) {
|
|
135
|
+
case 'managed':
|
|
136
|
+
return this.opts.managed.path;
|
|
137
|
+
case 'github-app':
|
|
138
|
+
return this.opts.githubApp?.url(from) ?? '';
|
|
139
|
+
case 'token':
|
|
140
|
+
return from('kbRepoUrl');
|
|
141
|
+
default:
|
|
142
|
+
return '';
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Whether the repository half of setup is answered: there is somewhere to
|
|
148
|
+
* clone from and, where one is needed, something to present. Asked as
|
|
149
|
+
* {@link mode} is.
|
|
150
|
+
*/
|
|
151
|
+
answered(read?: SettingReader): boolean {
|
|
152
|
+
const from = read ?? this.opts.read;
|
|
153
|
+
switch (this.mode(read)) {
|
|
154
|
+
case 'managed':
|
|
155
|
+
return true;
|
|
156
|
+
case 'github-app':
|
|
157
|
+
return this.opts.githubApp?.answered(from) ?? false;
|
|
158
|
+
case 'token':
|
|
159
|
+
return Boolean(from('kbRepoUrl') && this.tokenIn(from));
|
|
160
|
+
default:
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private username(): string {
|
|
166
|
+
// Only a host that was given a token is given a name to go with it.
|
|
167
|
+
if (this.inEffect !== 'token') return DEFAULT_GIT_USERNAME;
|
|
168
|
+
return this.opts.read('gitUsername') || this.opts.fallback?.username || DEFAULT_GIT_USERNAME;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* One mode's credential is never presented to another mode's repository:
|
|
173
|
+
* a deployment that moved to a managed repository still has its old
|
|
174
|
+
* token stored, and a path on its own disk has no use for it. The mode is
|
|
175
|
+
* the one IN EFFECT, because that is the repository git is talking to.
|
|
176
|
+
*/
|
|
177
|
+
private token(): string | null {
|
|
178
|
+
switch (this.inEffect) {
|
|
179
|
+
case 'token':
|
|
180
|
+
return this.tokenIn(this.opts.read) || null;
|
|
181
|
+
case 'github-app':
|
|
182
|
+
return this.opts.githubApp?.token() ?? null;
|
|
183
|
+
default:
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private async prepare(): Promise<void> {
|
|
189
|
+
if (this.inEffect === 'github-app') await this.opts.githubApp?.prepare();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private tokenIn(read: SettingReader): string {
|
|
193
|
+
// The fallback is the environment's, so it is in effect whatever a save brings.
|
|
194
|
+
return read('gitToken') || this.opts.fallback?.token || '';
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|