@deckbuilderkit/server-mods 0.0.0-stage → 0.1.1
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/package.json +25 -4
- package/src/archive-worker.ts +13 -0
- package/src/archive.ts +205 -0
- package/src/backup.ts +139 -0
- package/src/bytes.ts +2 -0
- package/src/dialogs.ts +68 -0
- package/src/editors.ts +109 -0
- package/src/encoding.ts +87 -0
- package/src/git.ts +275 -0
- package/src/http.ts +443 -0
- package/src/index.ts +18 -0
- package/src/info.ts +30 -0
- package/src/platform-errors.ts +8 -0
- package/src/records.ts +294 -0
- package/src/rest-env.ts +51 -0
- package/src/rest-history.ts +92 -0
- package/src/rest-tabs.ts +83 -0
- package/src/rooted-fs.ts +977 -0
- package/src/settings.ts +82 -0
- package/src/sqlite.ts +45 -0
- package/src/theme-head.ts +36 -0
- package/src/update.ts +435 -0
- package/src/wasm.d.ts +4 -0
- package/README.md +0 -3
package/src/git.ts
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { runWithTimeout, type Runner } from '@deckbuilderkit/platform';
|
|
2
|
+
import {
|
|
3
|
+
AppError,
|
|
4
|
+
invalid,
|
|
5
|
+
notFound,
|
|
6
|
+
type GitCode,
|
|
7
|
+
type GitCommit,
|
|
8
|
+
type GitCommitFile,
|
|
9
|
+
type GitDiff,
|
|
10
|
+
type GitFile,
|
|
11
|
+
type GitFileContent,
|
|
12
|
+
type GitRepoInfo,
|
|
13
|
+
type GitStatus,
|
|
14
|
+
type Routes,
|
|
15
|
+
} from '@deckbuilderkit/server-core';
|
|
16
|
+
import { isBinary } from './bytes.ts';
|
|
17
|
+
import { badPath, segmentsOf, type FsRoot } from './rooted-fs.ts';
|
|
18
|
+
|
|
19
|
+
export interface GitRoutesOptions {
|
|
20
|
+
/** The registered roots; the same function the gallery passes to `rootedFs`. */
|
|
21
|
+
roots: () => Promise<FsRoot[]>;
|
|
22
|
+
/** Runs a command: `runWithTimeout` from platform, a fake in tests. */
|
|
23
|
+
run?: Runner;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const STATUS_TIMEOUT_MS = 10_000;
|
|
27
|
+
const QUICK_TIMEOUT_MS = 5_000;
|
|
28
|
+
const MAX_FILE_BYTES = 2_000_000;
|
|
29
|
+
const MAX_DIFF_CHARS = 1_000_000;
|
|
30
|
+
const LOG_PAGE = 50;
|
|
31
|
+
const MAX_LOG_PAGE = 200;
|
|
32
|
+
const HASH = /^[0-9a-f]{7,40}$/;
|
|
33
|
+
const LOG_FORMAT = ['%H', '%h', '%an', '%aI', '%D', '%s'].join('%x1f');
|
|
34
|
+
|
|
35
|
+
const gitMissing = () => new AppError('internal', 'gitMissing', 'Git was not found on PATH.');
|
|
36
|
+
const notARepo = () => invalid('notARepo', 'This folder is not in a Git repository.');
|
|
37
|
+
const gitTimeout = () => new AppError('internal', 'gitTimeout', 'Git took too long to answer.');
|
|
38
|
+
const gitFailed = (stderr: string) => {
|
|
39
|
+
if (/dubious ownership/i.test(stderr)) {
|
|
40
|
+
return new AppError('internal', 'gitDubiousOwnership', 'Git refuses this folder: its owner differs. Add it to safe.directory.', { detail: stderr.trim() });
|
|
41
|
+
}
|
|
42
|
+
return new AppError('internal', 'gitFailed', stderr.trim().split(/\r?\n/)[0] || 'Git failed.', { detail: stderr.trim() });
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export interface PorcelainStatus {
|
|
46
|
+
branch: string;
|
|
47
|
+
upstream?: string;
|
|
48
|
+
ahead: number;
|
|
49
|
+
behind: number;
|
|
50
|
+
files: GitFile[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Parses `git status --porcelain=v2 -z --branch` output; paths stay as git prints them (repo-relative). */
|
|
54
|
+
export function parsePorcelainV2(text: string): PorcelainStatus {
|
|
55
|
+
const out: PorcelainStatus = { branch: '', ahead: 0, behind: 0, files: [] };
|
|
56
|
+
const tokens = text.split('\0');
|
|
57
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
58
|
+
const t = tokens[i]!;
|
|
59
|
+
if (!t) continue;
|
|
60
|
+
if (t.startsWith('# ')) {
|
|
61
|
+
const space = t.indexOf(' ', 2);
|
|
62
|
+
const key = space < 0 ? t.slice(2) : t.slice(2, space);
|
|
63
|
+
const value = space < 0 ? '' : t.slice(space + 1);
|
|
64
|
+
if (key === 'branch.head') out.branch = value;
|
|
65
|
+
else if (key === 'branch.upstream') out.upstream = value;
|
|
66
|
+
else if (key === 'branch.ab') {
|
|
67
|
+
const m = /^\+(\d+) -(\d+)$/.exec(value);
|
|
68
|
+
if (m) {
|
|
69
|
+
out.ahead = Number(m[1]);
|
|
70
|
+
out.behind = Number(m[2]);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
} else if (t[0] === '1') {
|
|
74
|
+
const f = t.split(' ');
|
|
75
|
+
addEntry(out.files, f[1]!, t.split(' ').slice(8).join(' '));
|
|
76
|
+
} else if (t[0] === '2') {
|
|
77
|
+
const path = t.split(' ').slice(9).join(' ');
|
|
78
|
+
const origPath = tokens[++i] ?? '';
|
|
79
|
+
addEntry(out.files, t.split(' ')[1]!, path, origPath);
|
|
80
|
+
} else if (t[0] === 'u') {
|
|
81
|
+
const path = t.split(' ').slice(10).join(' ');
|
|
82
|
+
out.files.push({ path, index: 'U', worktree: 'U', group: 'conflict' });
|
|
83
|
+
} else if (t[0] === '?') {
|
|
84
|
+
out.files.push({ path: t.slice(2), index: '?', worktree: '?', group: 'untracked' });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Parses `git log -z --format=<LOG_FORMAT>` output. */
|
|
91
|
+
export function parseLog(text: string): GitCommit[] {
|
|
92
|
+
return text
|
|
93
|
+
.split('\0')
|
|
94
|
+
.map((record) => record.replace(/^\n/, ''))
|
|
95
|
+
.filter(Boolean)
|
|
96
|
+
.map((record) => {
|
|
97
|
+
const [hash = '', short = '', author = '', date = '', refs = '', ...subject] = record.split('\x1f');
|
|
98
|
+
return { hash, short, author, date, subject: subject.join('\x1f'), ...(refs && { refs }) };
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Parses `git diff-tree -r -z --name-status -M` output. */
|
|
103
|
+
export function parseNameStatus(text: string): GitCommitFile[] {
|
|
104
|
+
const tokens = text.split('\0');
|
|
105
|
+
const out: GitCommitFile[] = [];
|
|
106
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
107
|
+
const code = tokens[i]!.trim();
|
|
108
|
+
if (!code) continue;
|
|
109
|
+
const status = code[0] as GitCode;
|
|
110
|
+
if (status === 'R' || status === 'C') {
|
|
111
|
+
const origPath = tokens[++i] ?? '';
|
|
112
|
+
out.push({ path: tokens[++i] ?? '', origPath, status });
|
|
113
|
+
} else {
|
|
114
|
+
out.push({ path: tokens[++i] ?? '', status });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function addEntry(files: GitFile[], xy: string, path: string, origPath?: string): void {
|
|
121
|
+
const index = xy[0] as GitCode;
|
|
122
|
+
const worktree = xy[1] as GitCode;
|
|
123
|
+
const base = origPath ? { path, origPath } : { path };
|
|
124
|
+
if (index !== '.') files.push({ ...base, index, worktree, group: 'staged' });
|
|
125
|
+
if (worktree !== '.') files.push({ ...base, index, worktree, group: 'unstaged' });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let gitOnPath: boolean | undefined;
|
|
129
|
+
const defaultRun: Runner = (cmd, timeoutMs) => {
|
|
130
|
+
gitOnPath ??= Bun.which('git') !== null;
|
|
131
|
+
if (!gitOnPath) throw gitMissing();
|
|
132
|
+
return runWithTimeout(cmd, timeoutMs);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Read-only Git routes over the registered roots: `GET /api/git/repos|status|file|diff|log|commit`. The client
|
|
137
|
+
* sends a root id and a root-relative path; every call runs `git -C <root>` with the path after `--`
|
|
138
|
+
* (or inside a `HEAD:` / `:` revision), so no client string is ever an option.
|
|
139
|
+
*/
|
|
140
|
+
export function gitRoutes(opts: GitRoutesOptions): Routes {
|
|
141
|
+
const run = opts.run ?? defaultRun;
|
|
142
|
+
|
|
143
|
+
async function git(root: FsRoot, args: string[], timeoutMs = QUICK_TIMEOUT_MS) {
|
|
144
|
+
let result;
|
|
145
|
+
try {
|
|
146
|
+
// A folder's own .git/config must not run programs: no fsmonitor hook (status), external diff,
|
|
147
|
+
// textconv (diff) or signature check (log, through gpg.program).
|
|
148
|
+
result = await run(['git', '--no-optional-locks', '-c', 'core.quotepath=off', '-c', 'core.fsmonitor=false', '-C', root.path, ...args], timeoutMs);
|
|
149
|
+
} catch (e) {
|
|
150
|
+
throw e instanceof AppError ? e : gitMissing();
|
|
151
|
+
}
|
|
152
|
+
if (result.kind === 'timeout') throw gitTimeout();
|
|
153
|
+
return result;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Stdout of a call that must succeed. */
|
|
157
|
+
async function gitOut(root: FsRoot, args: string[], timeoutMs?: number): Promise<string> {
|
|
158
|
+
const r = await git(root, args, timeoutMs);
|
|
159
|
+
if (r.code !== 0) throw /not a git repository/i.test(r.stderr) ? notARepo() : gitFailed(r.stderr);
|
|
160
|
+
return r.stdout;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function findRoot(id: unknown): Promise<FsRoot> {
|
|
164
|
+
const root = typeof id === 'string' ? (await opts.roots()).find((r) => r.id === id) : undefined;
|
|
165
|
+
if (!root) throw notFound('rootNotFound', 'This folder is no longer registered or no longer exists.');
|
|
166
|
+
return root;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The folder's path inside its repo, `sub/dir/` or empty at the top. */
|
|
170
|
+
async function prefixOf(root: FsRoot): Promise<string> {
|
|
171
|
+
const out = await gitOut(root, ['rev-parse', '--show-toplevel', '--show-prefix']);
|
|
172
|
+
return (out.split(/\r?\n/)[1] ?? '').trim();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function commitOf(query: URLSearchParams): string {
|
|
176
|
+
const hash = query.get('commit') ?? '';
|
|
177
|
+
if (!HASH.test(hash)) throw invalid('badRequest', 'Invalid request.');
|
|
178
|
+
return hash;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** `diff-tree` arguments comparing a commit with its first parent, or with the empty tree for a root commit. */
|
|
182
|
+
async function rangeOf(root: FsRoot, hash: string): Promise<string[]> {
|
|
183
|
+
const parents = (await gitOut(root, ['rev-list', '--parents', '-n1', hash])).trim().split(' ');
|
|
184
|
+
return parents[1] ? [parents[1], hash] : ['--root', hash];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function filePath(query: URLSearchParams): string {
|
|
188
|
+
const segments = segmentsOf(query.get('path') ?? '');
|
|
189
|
+
if (!segments.length) throw badPath();
|
|
190
|
+
return segments.join('/');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function status(root: FsRoot, untracked: boolean): Promise<GitStatus> {
|
|
194
|
+
const args = ['status', '--porcelain=v2', '-z', '--branch', untracked ? '--untracked-files=all' : '--untracked-files=no', '--', '.'];
|
|
195
|
+
const parsed = parsePorcelainV2(await gitOut(root, args, STATUS_TIMEOUT_MS));
|
|
196
|
+
if (!untracked) return { ...parsed, files: [] };
|
|
197
|
+
const prefix = await prefixOf(root);
|
|
198
|
+
const strip = (p: string) => (prefix && p.startsWith(prefix) ? p.slice(prefix.length) : p);
|
|
199
|
+
const files = parsed.files.map((f) => ({ ...f, path: strip(f.path), ...(f.origPath !== undefined && { origPath: strip(f.origPath) }) }));
|
|
200
|
+
return { ...parsed, files };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
'GET /api/git/repos': async () => {
|
|
205
|
+
const roots = await opts.roots();
|
|
206
|
+
return Promise.all(
|
|
207
|
+
roots.map(async (root): Promise<GitRepoInfo> => {
|
|
208
|
+
try {
|
|
209
|
+
const { branch, upstream, ahead, behind } = await status(root, false);
|
|
210
|
+
return { root: root.id, repo: true, branch, ...(upstream !== undefined && { upstream }), ahead, behind };
|
|
211
|
+
} catch (e) {
|
|
212
|
+
if (e instanceof AppError && e.code === 'notARepo') return { root: root.id, repo: false };
|
|
213
|
+
return { root: root.id, repo: false, error: e instanceof Error ? e.message : String(e) };
|
|
214
|
+
}
|
|
215
|
+
}),
|
|
216
|
+
);
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
'GET /api/git/status': async ({ query }) => status(await findRoot(query.get('root')), true),
|
|
220
|
+
|
|
221
|
+
'GET /api/git/file': async ({ query }) => {
|
|
222
|
+
const root = await findRoot(query.get('root'));
|
|
223
|
+
const path = filePath(query);
|
|
224
|
+
const from = query.get('from');
|
|
225
|
+
const prefix = await prefixOf(root);
|
|
226
|
+
let tree: string;
|
|
227
|
+
if (from === 'head') tree = 'HEAD';
|
|
228
|
+
else if (from === 'index') tree = '';
|
|
229
|
+
else if (from === 'commit') tree = commitOf(query);
|
|
230
|
+
else if (from === 'parent') tree = `${commitOf(query)}^`;
|
|
231
|
+
else throw invalid('badRequest', 'Invalid request.');
|
|
232
|
+
const rev = `${tree}:${prefix}${path}`;
|
|
233
|
+
const size = await git(root, ['cat-file', '-s', rev]);
|
|
234
|
+
if (size.code !== 0) {
|
|
235
|
+
if (/not a git repository/i.test(size.stderr)) throw notARepo();
|
|
236
|
+
if (/dubious ownership/i.test(size.stderr)) throw gitFailed(size.stderr);
|
|
237
|
+
return { missing: true } satisfies GitFileContent;
|
|
238
|
+
}
|
|
239
|
+
if (Number(size.stdout.trim()) > MAX_FILE_BYTES) return { tooLarge: true } satisfies GitFileContent;
|
|
240
|
+
const text = await gitOut(root, ['show', rev]);
|
|
241
|
+
if (isBinary(new TextEncoder().encode(text.slice(0, 8192)))) return { binary: true } satisfies GitFileContent;
|
|
242
|
+
return { text } satisfies GitFileContent;
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
'GET /api/git/log': async ({ query }) => {
|
|
246
|
+
const root = await findRoot(query.get('root'));
|
|
247
|
+
const skip = Math.max(0, Math.floor(Number(query.get('skip')) || 0));
|
|
248
|
+
const limit = Math.min(MAX_LOG_PAGE, Math.max(1, Math.floor(Number(query.get('limit')) || LOG_PAGE)));
|
|
249
|
+
const r = await git(root, ['log', '-z', '--no-show-signature', `--format=${LOG_FORMAT}`, `--skip=${skip}`, `-n${limit}`, '--', '.'], STATUS_TIMEOUT_MS);
|
|
250
|
+
if (r.code !== 0) {
|
|
251
|
+
if (/does not have any commits/i.test(r.stderr)) return [] satisfies GitCommit[];
|
|
252
|
+
throw /not a git repository/i.test(r.stderr) ? notARepo() : gitFailed(r.stderr);
|
|
253
|
+
}
|
|
254
|
+
return parseLog(r.stdout);
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
'GET /api/git/commit': async ({ query }) => {
|
|
258
|
+
const root = await findRoot(query.get('root'));
|
|
259
|
+
const range = await rangeOf(root, commitOf(query));
|
|
260
|
+
const text = await gitOut(root, ['diff-tree', '-r', '-z', '--no-commit-id', '--name-status', '-M', '--relative', ...range, '--'], STATUS_TIMEOUT_MS);
|
|
261
|
+
return parseNameStatus(text);
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
'GET /api/git/diff': async ({ query }) => {
|
|
265
|
+
const root = await findRoot(query.get('root'));
|
|
266
|
+
const path = filePath(query);
|
|
267
|
+
const commit = query.get('commit');
|
|
268
|
+
const args = commit
|
|
269
|
+
? ['diff-tree', '-p', '--no-commit-id', '--no-color', '--no-ext-diff', '--no-textconv', '--relative', ...(await rangeOf(root, commitOf(query))), '--', path]
|
|
270
|
+
: ['diff', ...(query.get('staged') === '1' ? ['--cached'] : []), '--no-color', '--no-ext-diff', '--no-textconv', '--', path];
|
|
271
|
+
const text = await gitOut(root, args);
|
|
272
|
+
return (text.length > MAX_DIFF_CHARS ? { text: text.slice(0, MAX_DIFF_CHARS), truncated: true } : { text, truncated: false }) satisfies GitDiff;
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|