decap-server 1.4.0-beta.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.
@@ -0,0 +1,463 @@
1
+ import path from 'path';
2
+ import { promises as fs } from 'fs';
3
+ import {
4
+ branchFromContentKey,
5
+ generateContentKey,
6
+ contentKeyFromBranch,
7
+ CMS_BRANCH_PREFIX,
8
+ statusToLabel,
9
+ labelToStatus,
10
+ parseContentKey,
11
+ } from 'decap-cms-lib-util/src/APIUtils';
12
+ import { parse } from 'what-the-diff';
13
+ // eslint-disable-next-line import/no-named-as-default
14
+ import simpleGit from 'simple-git';
15
+ import { Mutex, withTimeout } from 'async-mutex';
16
+
17
+ import { defaultSchema, joi } from '../joi';
18
+ import { pathTraversal } from '../joi/customValidators';
19
+ import { listRepoFiles, writeFile, move, deleteFile, getUpdateDate } from '../utils/fs';
20
+ import { entriesFromFiles, readMediaFile } from '../utils/entries';
21
+
22
+ import type {
23
+ EntriesByFolderParams,
24
+ EntriesByFilesParams,
25
+ GetEntryParams,
26
+ DefaultParams,
27
+ UnpublishedEntryParams,
28
+ PersistEntryParams,
29
+ GetMediaParams,
30
+ Asset,
31
+ PublishUnpublishedEntryParams,
32
+ PersistMediaParams,
33
+ DeleteFileParams,
34
+ UpdateUnpublishedEntryStatusParams,
35
+ DataFile,
36
+ GetMediaFileParams,
37
+ DeleteEntryParams,
38
+ DeleteFilesParams,
39
+ UnpublishedEntryDataFileParams,
40
+ UnpublishedEntryMediaFileParams,
41
+ } from '../types';
42
+ import type express from 'express';
43
+ import type winston from 'winston';
44
+ import type { SimpleGit } from 'simple-git';
45
+
46
+ async function commit(git: SimpleGit, commitMessage: string) {
47
+ await git.add('.');
48
+ await git.commit(commitMessage, undefined, {
49
+ // setting the value to a string passes name=value
50
+ // any other value passes just the key
51
+ '--no-verify': null,
52
+ '--no-gpg-sign': null,
53
+ });
54
+ }
55
+
56
+ async function getCurrentBranch(git: SimpleGit) {
57
+ const currentBranch = await git.branchLocal().then(summary => summary.current);
58
+ return currentBranch;
59
+ }
60
+
61
+ async function runOnBranch<T>(git: SimpleGit, branch: string, func: () => Promise<T>) {
62
+ const currentBranch = await getCurrentBranch(git);
63
+ try {
64
+ if (currentBranch !== branch) {
65
+ await git.checkout(branch);
66
+ }
67
+ const result = await func();
68
+ return result;
69
+ } finally {
70
+ await git.checkout(currentBranch);
71
+ }
72
+ }
73
+
74
+ function branchDescription(branch: string) {
75
+ return `branch.${branch}.description`;
76
+ }
77
+
78
+ type GitOptions = {
79
+ repoPath: string;
80
+ logger: winston.Logger;
81
+ };
82
+
83
+ async function commitEntry(
84
+ git: SimpleGit,
85
+ repoPath: string,
86
+ dataFiles: DataFile[],
87
+ assets: Asset[],
88
+ commitMessage: string,
89
+ ) {
90
+ // save entry content
91
+ await Promise.all(
92
+ dataFiles.map(dataFile => writeFile(path.join(repoPath, dataFile.path), dataFile.raw)),
93
+ );
94
+ // save assets
95
+ await Promise.all(
96
+ assets.map(a => writeFile(path.join(repoPath, a.path), Buffer.from(a.content, a.encoding))),
97
+ );
98
+ if (dataFiles.every(dataFile => dataFile.newPath)) {
99
+ dataFiles.forEach(async dataFile => {
100
+ await move(path.join(repoPath, dataFile.path), path.join(repoPath, dataFile.newPath!));
101
+ });
102
+ }
103
+
104
+ // commits files
105
+ await commit(git, commitMessage);
106
+ }
107
+
108
+ async function rebase(git: SimpleGit, branch: string) {
109
+ const gpgSign = await git.raw(['config', 'commit.gpgsign']);
110
+ try {
111
+ if (gpgSign === 'true') {
112
+ await git.addConfig('commit.gpgsign', 'false');
113
+ }
114
+ await git.rebase([branch, '--no-verify']);
115
+ } finally {
116
+ if (gpgSign === 'true') {
117
+ await git.addConfig('commit.gpgsign', gpgSign);
118
+ }
119
+ }
120
+ }
121
+
122
+ async function merge(git: SimpleGit, from: string, to: string) {
123
+ const gpgSign = await git.raw(['config', 'commit.gpgsign']);
124
+ try {
125
+ if (gpgSign === 'true') {
126
+ await git.addConfig('commit.gpgsign', 'false');
127
+ }
128
+ await git.mergeFromTo(from, to);
129
+ } finally {
130
+ if (gpgSign === 'true') {
131
+ await git.addConfig('commit.gpgsign', gpgSign);
132
+ }
133
+ }
134
+ }
135
+
136
+ async function isBranchExists(git: SimpleGit, branch: string) {
137
+ const branchExists = await git.branchLocal().then(({ all }) => all.includes(branch));
138
+ return branchExists;
139
+ }
140
+
141
+ async function getDiffs(git: SimpleGit, source: string, dest: string) {
142
+ const rawDiff = await git.diff([source, dest]);
143
+ const diffs = parse(rawDiff).map(d => {
144
+ const oldPath = d.oldPath?.replace(/b\//, '') || '';
145
+ const newPath = d.newPath?.replace(/b\//, '') || '';
146
+ const path = newPath || (oldPath as string);
147
+ return {
148
+ oldPath,
149
+ newPath,
150
+ status: d.status,
151
+ newFile: d.status === 'added',
152
+ path,
153
+ id: path,
154
+ binary: d.binary || /.svg$/.test(path),
155
+ };
156
+ });
157
+ return diffs;
158
+ }
159
+
160
+ export async function validateRepo({ repoPath }: { repoPath: string }) {
161
+ const git = simpleGit(repoPath);
162
+ const isRepo = await git.checkIsRepo();
163
+ if (!isRepo) {
164
+ throw Error(`${repoPath} is not a valid git repository`);
165
+ }
166
+ }
167
+
168
+ export function getSchema({ repoPath }: { repoPath: string }) {
169
+ const schema = defaultSchema({ path: pathTraversal(repoPath) });
170
+ return schema;
171
+ }
172
+
173
+ export function localGitMiddleware({ repoPath, logger }: GitOptions) {
174
+ const git = simpleGit(repoPath);
175
+
176
+ // we can only perform a single git operation at any given time
177
+ const mutex = withTimeout(new Mutex(), 3000, new Error('Request timed out'));
178
+
179
+ return async function (req: express.Request, res: express.Response) {
180
+ let release;
181
+ try {
182
+ release = await mutex.acquire();
183
+ const { body } = req;
184
+ if (body.action === 'info') {
185
+ res.json({
186
+ repo: path.basename(repoPath),
187
+ publish_modes: ['simple', 'editorial_workflow'],
188
+ type: 'local_git',
189
+ });
190
+ return;
191
+ }
192
+ const { branch } = body.params as DefaultParams;
193
+
194
+ const branchExists = await isBranchExists(git, branch);
195
+ if (!branchExists) {
196
+ const message = `Default branch '${branch}' doesn't exist`;
197
+ res.status(422).json({ error: message });
198
+ return;
199
+ }
200
+
201
+ switch (body.action) {
202
+ case 'entriesByFolder': {
203
+ const payload = body.params as EntriesByFolderParams;
204
+ const { folder, extension, depth } = payload;
205
+ const entries = await runOnBranch(git, branch, () =>
206
+ listRepoFiles(repoPath, folder, extension, depth).then(files =>
207
+ entriesFromFiles(
208
+ repoPath,
209
+ files.map(file => ({ path: file })),
210
+ ),
211
+ ),
212
+ );
213
+ res.json(entries);
214
+ break;
215
+ }
216
+ case 'entriesByFiles': {
217
+ const payload = body.params as EntriesByFilesParams;
218
+ const entries = await runOnBranch(git, branch, () =>
219
+ entriesFromFiles(repoPath, payload.files),
220
+ );
221
+ res.json(entries);
222
+ break;
223
+ }
224
+ case 'getEntry': {
225
+ const payload = body.params as GetEntryParams;
226
+ const [entry] = await runOnBranch(git, branch, () =>
227
+ entriesFromFiles(repoPath, [{ path: payload.path }]),
228
+ );
229
+ res.json(entry);
230
+ break;
231
+ }
232
+ case 'unpublishedEntries': {
233
+ const cmsBranches = await git
234
+ .branchLocal()
235
+ .then(result => result.all.filter(b => b.startsWith(`${CMS_BRANCH_PREFIX}/`)));
236
+ res.json(cmsBranches.map(contentKeyFromBranch));
237
+ break;
238
+ }
239
+ case 'unpublishedEntry': {
240
+ let { id, collection, slug, cmsLabelPrefix } = body.params as UnpublishedEntryParams;
241
+ if (id) {
242
+ ({ collection, slug } = parseContentKey(id));
243
+ }
244
+ const contentKey = generateContentKey(collection as string, slug as string);
245
+ const cmsBranch = branchFromContentKey(contentKey);
246
+ const branchExists = await isBranchExists(git, cmsBranch);
247
+ if (branchExists) {
248
+ const diffs = await getDiffs(git, branch, cmsBranch);
249
+ const label = await git.raw(['config', branchDescription(cmsBranch)]);
250
+ const status = label && labelToStatus(label.trim(), cmsLabelPrefix || '');
251
+ const updatedAt =
252
+ diffs.length >= 0
253
+ ? await runOnBranch(git, cmsBranch, async () => {
254
+ const dates = await Promise.all(
255
+ diffs.map(({ newPath }) => getUpdateDate(repoPath, newPath)),
256
+ );
257
+ return dates.reduce((a, b) => {
258
+ return a > b ? a : b;
259
+ });
260
+ })
261
+ : new Date();
262
+ const unpublishedEntry = {
263
+ collection,
264
+ slug,
265
+ status,
266
+ diffs,
267
+ updatedAt,
268
+ };
269
+ res.json(unpublishedEntry);
270
+ } else {
271
+ return res.status(404).json({ message: 'Not Found' });
272
+ }
273
+ break;
274
+ }
275
+ case 'unpublishedEntryDataFile': {
276
+ const { path, collection, slug } = body.params as UnpublishedEntryDataFileParams;
277
+ const contentKey = generateContentKey(collection as string, slug as string);
278
+ const cmsBranch = branchFromContentKey(contentKey);
279
+ const [entry] = await runOnBranch(git, cmsBranch, () =>
280
+ entriesFromFiles(repoPath, [{ path }]),
281
+ );
282
+ res.json({ data: entry.data });
283
+ break;
284
+ }
285
+ case 'unpublishedEntryMediaFile': {
286
+ const { path, collection, slug } = body.params as UnpublishedEntryMediaFileParams;
287
+ const contentKey = generateContentKey(collection as string, slug as string);
288
+ const cmsBranch = branchFromContentKey(contentKey);
289
+ const file = await runOnBranch(git, cmsBranch, () => readMediaFile(repoPath, path));
290
+ res.json(file);
291
+ break;
292
+ }
293
+ case 'deleteUnpublishedEntry': {
294
+ const { collection, slug } = body.params as DeleteEntryParams;
295
+ const contentKey = generateContentKey(collection, slug);
296
+ const cmsBranch = branchFromContentKey(contentKey);
297
+ const currentBranch = await getCurrentBranch(git);
298
+ if (currentBranch === cmsBranch) {
299
+ await git.checkoutLocalBranch(branch);
300
+ }
301
+ await git.branch(['-D', cmsBranch]);
302
+ res.json({ message: `deleted branch: ${cmsBranch}` });
303
+ break;
304
+ }
305
+ case 'persistEntry': {
306
+ const {
307
+ cmsLabelPrefix,
308
+ entry,
309
+ dataFiles = [entry as DataFile],
310
+ assets,
311
+ options,
312
+ } = body.params as PersistEntryParams;
313
+
314
+ if (!options.useWorkflow) {
315
+ await runOnBranch(git, branch, async () => {
316
+ await commitEntry(git, repoPath, dataFiles, assets, options.commitMessage);
317
+ });
318
+ } else {
319
+ const slug = dataFiles[0].slug;
320
+ const collection = options.collectionName as string;
321
+ const contentKey = generateContentKey(collection, slug);
322
+ const cmsBranch = branchFromContentKey(contentKey);
323
+ await runOnBranch(git, branch, async () => {
324
+ const branchExists = await isBranchExists(git, cmsBranch);
325
+ if (branchExists) {
326
+ await git.checkout(cmsBranch);
327
+ } else {
328
+ await git.checkoutLocalBranch(cmsBranch);
329
+ }
330
+ await rebase(git, branch);
331
+ const diffs = await getDiffs(git, branch, cmsBranch);
332
+ // delete media files that have been removed from the entry
333
+ const toDelete = diffs.filter(
334
+ d => d.binary && !assets.map(a => a.path).includes(d.path),
335
+ );
336
+ await Promise.all(toDelete.map(f => fs.unlink(path.join(repoPath, f.path))));
337
+ await commitEntry(git, repoPath, dataFiles, assets, options.commitMessage);
338
+
339
+ // add status for new entries
340
+ if (!branchExists) {
341
+ const description = statusToLabel(options.status, cmsLabelPrefix || '');
342
+ await git.addConfig(branchDescription(cmsBranch), description);
343
+ }
344
+ });
345
+ }
346
+ res.json({ message: 'entry persisted' });
347
+ break;
348
+ }
349
+ case 'updateUnpublishedEntryStatus': {
350
+ const { collection, slug, newStatus, cmsLabelPrefix } =
351
+ body.params as UpdateUnpublishedEntryStatusParams;
352
+ const contentKey = generateContentKey(collection, slug);
353
+ const cmsBranch = branchFromContentKey(contentKey);
354
+ const description = statusToLabel(newStatus, cmsLabelPrefix || '');
355
+ await git.addConfig(branchDescription(cmsBranch), description);
356
+ res.json({ message: `${branch} description was updated to ${description}` });
357
+ break;
358
+ }
359
+ case 'publishUnpublishedEntry': {
360
+ const { collection, slug } = body.params as PublishUnpublishedEntryParams;
361
+ const contentKey = generateContentKey(collection, slug);
362
+ const cmsBranch = branchFromContentKey(contentKey);
363
+ await merge(git, cmsBranch, branch);
364
+ await git.deleteLocalBranch(cmsBranch);
365
+ res.json({ message: `branch ${cmsBranch} merged to ${branch}` });
366
+ break;
367
+ }
368
+ case 'getMedia': {
369
+ const { mediaFolder } = body.params as GetMediaParams;
370
+ const mediaFiles = await runOnBranch(git, branch, async () => {
371
+ const files = await listRepoFiles(repoPath, mediaFolder, '', 1);
372
+ const serializedFiles = await Promise.all(
373
+ files.map(file => readMediaFile(repoPath, file)),
374
+ );
375
+ return serializedFiles;
376
+ });
377
+ res.json(mediaFiles);
378
+ break;
379
+ }
380
+ case 'getMediaFile': {
381
+ const { path } = body.params as GetMediaFileParams;
382
+ const mediaFile = await runOnBranch(git, branch, () => {
383
+ return readMediaFile(repoPath, path);
384
+ });
385
+ res.json(mediaFile);
386
+ break;
387
+ }
388
+ case 'persistMedia': {
389
+ const {
390
+ asset,
391
+ options: { commitMessage },
392
+ } = body.params as PersistMediaParams;
393
+
394
+ const file = await runOnBranch(git, branch, async () => {
395
+ await writeFile(
396
+ path.join(repoPath, asset.path),
397
+ Buffer.from(asset.content, asset.encoding),
398
+ );
399
+ await commit(git, commitMessage);
400
+ return readMediaFile(repoPath, asset.path);
401
+ });
402
+ res.json(file);
403
+ break;
404
+ }
405
+ case 'deleteFile': {
406
+ const {
407
+ path: filePath,
408
+ options: { commitMessage },
409
+ } = body.params as DeleteFileParams;
410
+ await runOnBranch(git, branch, async () => {
411
+ await deleteFile(repoPath, filePath);
412
+ await commit(git, commitMessage);
413
+ });
414
+ res.json({ message: `deleted file ${filePath}` });
415
+ break;
416
+ }
417
+ case 'deleteFiles': {
418
+ const {
419
+ paths,
420
+ options: { commitMessage },
421
+ } = body.params as DeleteFilesParams;
422
+ await runOnBranch(git, branch, async () => {
423
+ await Promise.all(paths.map(filePath => deleteFile(repoPath, filePath)));
424
+ await commit(git, commitMessage);
425
+ });
426
+ res.json({ message: `deleted files ${paths.join(', ')}` });
427
+ break;
428
+ }
429
+ case 'getDeployPreview': {
430
+ res.json(null);
431
+ break;
432
+ }
433
+ default: {
434
+ const message = `Unknown action ${body.action}`;
435
+ res.status(422).json({ error: message });
436
+ break;
437
+ }
438
+ }
439
+ } catch (e) {
440
+ logger.error(
441
+ `Error handling ${JSON.stringify(req.body)}: ${
442
+ e instanceof Error ? e.message : 'Unknown error'
443
+ }`,
444
+ );
445
+ res.status(500).json({ error: 'Unknown error' });
446
+ } finally {
447
+ release && release();
448
+ }
449
+ };
450
+ }
451
+
452
+ type Options = {
453
+ logger: winston.Logger;
454
+ };
455
+
456
+ export async function registerMiddleware(app: express.Express, options: Options) {
457
+ const { logger } = options;
458
+ const repoPath = path.resolve(process.env.GIT_REPO_DIRECTORY || process.cwd());
459
+ await validateRepo({ repoPath });
460
+ app.post('/api/v1', joi(getSchema({ repoPath })));
461
+ app.post('/api/v1', localGitMiddleware({ repoPath, logger }));
462
+ logger.info(`Decap CMS Git Proxy Server configured with ${repoPath}`);
463
+ }
@@ -0,0 +1,101 @@
1
+ export type DefaultParams = {
2
+ branch: string;
3
+ };
4
+
5
+ export type EntriesByFolderParams = {
6
+ folder: string;
7
+ extension: string;
8
+ depth: 1;
9
+ };
10
+
11
+ export type EntriesByFilesParams = {
12
+ files: { path: string }[];
13
+ };
14
+
15
+ export type GetEntryParams = {
16
+ path: string;
17
+ };
18
+
19
+ export type UnpublishedEntryParams = {
20
+ id?: string;
21
+ collection?: string;
22
+ slug?: string;
23
+ cmsLabelPrefix?: string;
24
+ };
25
+
26
+ export type UnpublishedEntryDataFileParams = {
27
+ collection: string;
28
+ slug: string;
29
+ id: string;
30
+ path: string;
31
+ };
32
+
33
+ export type UnpublishedEntryMediaFileParams = {
34
+ collection: string;
35
+ slug: string;
36
+ id: string;
37
+ path: string;
38
+ };
39
+
40
+ export type DeleteEntryParams = {
41
+ collection: string;
42
+ slug: string;
43
+ };
44
+
45
+ export type UpdateUnpublishedEntryStatusParams = {
46
+ collection: string;
47
+ slug: string;
48
+ newStatus: string;
49
+ cmsLabelPrefix?: string;
50
+ };
51
+
52
+ export type PublishUnpublishedEntryParams = {
53
+ collection: string;
54
+ slug: string;
55
+ };
56
+
57
+ export type DataFile = { slug: string; path: string; raw: string; newPath?: string };
58
+
59
+ export type Asset = { path: string; content: string; encoding: 'base64' };
60
+
61
+ export type PersistEntryParams = {
62
+ cmsLabelPrefix?: string;
63
+ entry?: DataFile;
64
+ dataFiles?: DataFile[];
65
+ assets: Asset[];
66
+ options: {
67
+ collectionName?: string;
68
+ commitMessage: string;
69
+ useWorkflow: boolean;
70
+ status: string;
71
+ };
72
+ };
73
+
74
+ export type GetMediaParams = {
75
+ mediaFolder: string;
76
+ };
77
+
78
+ export type GetMediaFileParams = {
79
+ path: string;
80
+ };
81
+
82
+ export type PersistMediaParams = {
83
+ asset: Asset;
84
+ options: {
85
+ commitMessage: string;
86
+ };
87
+ };
88
+
89
+ export type DeleteFileParams = {
90
+ path: string;
91
+ options: {
92
+ commitMessage: string;
93
+ };
94
+ };
95
+
96
+ export type DeleteFilesParams = {
97
+ paths: string[];
98
+ options: {
99
+ commitMessage: string;
100
+ };
101
+ };
@@ -0,0 +1,48 @@
1
+ import crypto from 'crypto';
2
+ import path from 'path';
3
+ import { promises as fs } from 'fs';
4
+
5
+ function sha256(buffer: Buffer) {
6
+ return crypto.createHash('sha256').update(buffer).digest('hex');
7
+ }
8
+
9
+ // normalize windows os path format
10
+ function normalizePath(path: string) {
11
+ return path.replace(/\\/g, '/');
12
+ }
13
+
14
+ export async function entriesFromFiles(
15
+ repoPath: string,
16
+ files: { path: string; label?: string }[],
17
+ ) {
18
+ return Promise.all(
19
+ files.map(async file => {
20
+ try {
21
+ const content = await fs.readFile(path.join(repoPath, file.path));
22
+ return {
23
+ data: content.toString(),
24
+ file: { path: normalizePath(file.path), label: file.label, id: sha256(content) },
25
+ };
26
+ } catch (e) {
27
+ return {
28
+ data: null,
29
+ file: { path: normalizePath(file.path), label: file.label, id: null },
30
+ };
31
+ }
32
+ }),
33
+ );
34
+ }
35
+
36
+ export async function readMediaFile(repoPath: string, file: string) {
37
+ const encoding = 'base64';
38
+ const buffer = await fs.readFile(path.join(repoPath, file));
39
+ const id = sha256(buffer);
40
+
41
+ return {
42
+ id,
43
+ content: buffer.toString(encoding),
44
+ encoding,
45
+ path: normalizePath(file),
46
+ name: path.basename(file),
47
+ };
48
+ }
@@ -0,0 +1,65 @@
1
+ import path from 'path';
2
+ import { promises as fs } from 'fs';
3
+
4
+ async function listFiles(dir: string, extension: string, depth: number): Promise<string[]> {
5
+ if (depth <= 0) {
6
+ return [];
7
+ }
8
+
9
+ try {
10
+ const dirents = await fs.readdir(dir, { withFileTypes: true });
11
+ const files = await Promise.all(
12
+ dirents.map(dirent => {
13
+ const res = path.join(dir, dirent.name);
14
+ return dirent.isDirectory()
15
+ ? listFiles(res, extension, depth - 1)
16
+ : [res].filter(f => f.endsWith(extension));
17
+ }),
18
+ );
19
+ return ([] as string[]).concat(...files);
20
+ } catch (e) {
21
+ return [];
22
+ }
23
+ }
24
+
25
+ export async function listRepoFiles(
26
+ repoPath: string,
27
+ folder: string,
28
+ extension: string,
29
+ depth: number,
30
+ ) {
31
+ const files = await listFiles(path.join(repoPath, folder), extension, depth);
32
+ return files.map(f => f.slice(repoPath.length + 1));
33
+ }
34
+
35
+ export async function writeFile(filePath: string, content: Buffer | string) {
36
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
37
+ await fs.writeFile(filePath, content);
38
+ }
39
+
40
+ export async function deleteFile(repoPath: string, filePath: string) {
41
+ await fs.unlink(path.join(repoPath, filePath)).catch(() => undefined);
42
+ }
43
+
44
+ async function moveFile(from: string, to: string) {
45
+ await fs.mkdir(path.dirname(to), { recursive: true });
46
+ await fs.rename(from, to);
47
+ }
48
+
49
+ export async function move(from: string, to: string) {
50
+ // move file
51
+ await moveFile(from, to);
52
+
53
+ // move children
54
+ const sourceDir = path.dirname(from);
55
+ const destDir = path.dirname(to);
56
+ const allFiles = await listFiles(sourceDir, '', 100);
57
+ await Promise.all(allFiles.map(file => moveFile(file, file.replace(sourceDir, destDir))));
58
+ }
59
+
60
+ export async function getUpdateDate(repoPath: string, filePath: string) {
61
+ return fs
62
+ .stat(path.join(repoPath, filePath))
63
+ .then(stat => stat.mtime)
64
+ .catch(() => new Date());
65
+ }
@@ -0,0 +1,28 @@
1
+ import { registerCommonMiddlewares } from './middlewares/common';
2
+ import { registerMiddleware as localGit } from './middlewares/localGit';
3
+ import { registerMiddleware as localFs } from './middlewares/localFs';
4
+ import { createLogger } from './logger';
5
+
6
+ import type express from 'express';
7
+
8
+ type Options = {
9
+ logLevel?: string;
10
+ };
11
+
12
+ function createOptions(options: Options) {
13
+ return {
14
+ logger: createLogger({ level: options.logLevel || 'info' }),
15
+ };
16
+ }
17
+
18
+ export async function registerLocalGit(app: express.Express, options: Options = {}) {
19
+ const opts = createOptions(options);
20
+ registerCommonMiddlewares(app, opts);
21
+ await localGit(app, opts);
22
+ }
23
+
24
+ export async function registerLocalFs(app: express.Express, options: Options = {}) {
25
+ const opts = createOptions(options);
26
+ registerCommonMiddlewares(app, opts);
27
+ await localFs(app, opts);
28
+ }