takos-runtime-service 1.0.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/package.json +29 -0
- package/src/__tests__/middleware/rate-limit.test.ts +33 -0
- package/src/__tests__/middleware/workspace-scope-extended.test.ts +163 -0
- package/src/__tests__/routes/actions-start-limits.test.ts +139 -0
- package/src/__tests__/routes/actions-step-warnings.test.ts +194 -0
- package/src/__tests__/routes/cli-proxy.test.ts +72 -0
- package/src/__tests__/routes/git-http.test.ts +218 -0
- package/src/__tests__/routes/git-lfs-policy.test.ts +112 -0
- package/src/__tests__/routes/sessions/store.test.ts +72 -0
- package/src/__tests__/routes/workspace-scope.test.ts +45 -0
- package/src/__tests__/runtime/action-registry.test.ts +208 -0
- package/src/__tests__/runtime/action-result-helpers.test.ts +129 -0
- package/src/__tests__/runtime/actions/executor.test.ts +131 -0
- package/src/__tests__/runtime/composite-expression.test.ts +294 -0
- package/src/__tests__/runtime/file-parsers.test.ts +129 -0
- package/src/__tests__/runtime/logging.test.ts +65 -0
- package/src/__tests__/runtime/paths.test.ts +236 -0
- package/src/__tests__/runtime/secrets.test.ts +247 -0
- package/src/__tests__/runtime/validation.test.ts +516 -0
- package/src/__tests__/setup.ts +126 -0
- package/src/__tests__/shared/errors.test.ts +117 -0
- package/src/__tests__/storage/r2.test.ts +106 -0
- package/src/__tests__/utils/audit-log.test.ts +163 -0
- package/src/__tests__/utils/error-message.test.ts +38 -0
- package/src/__tests__/utils/sandbox-env.test.ts +74 -0
- package/src/app.ts +245 -0
- package/src/index.ts +1 -0
- package/src/middleware/rate-limit.ts +91 -0
- package/src/middleware/space-scope.ts +95 -0
- package/src/routes/actions/action-types.ts +20 -0
- package/src/routes/actions/execution.ts +229 -0
- package/src/routes/actions/index.ts +17 -0
- package/src/routes/actions/job-lifecycle.ts +242 -0
- package/src/routes/actions/job-queries.ts +52 -0
- package/src/routes/cli/proxy.ts +105 -0
- package/src/routes/git/http.ts +565 -0
- package/src/routes/git/init.ts +88 -0
- package/src/routes/repos/branches.ts +160 -0
- package/src/routes/repos/content.ts +209 -0
- package/src/routes/repos/read.ts +130 -0
- package/src/routes/repos/repo-validation.ts +136 -0
- package/src/routes/repos/write.ts +274 -0
- package/src/routes/runtime/exec.ts +147 -0
- package/src/routes/runtime/tools.ts +113 -0
- package/src/routes/sessions/execution.ts +263 -0
- package/src/routes/sessions/files.ts +326 -0
- package/src/routes/sessions/session-routes.ts +241 -0
- package/src/routes/sessions/session-utils.ts +88 -0
- package/src/routes/sessions/snapshot.ts +208 -0
- package/src/routes/sessions/storage.ts +329 -0
- package/src/runtime/actions/action-registry.ts +450 -0
- package/src/runtime/actions/action-result-converter.ts +31 -0
- package/src/runtime/actions/builtin/artifacts.ts +292 -0
- package/src/runtime/actions/builtin/cache-operations.ts +358 -0
- package/src/runtime/actions/builtin/checkout.ts +58 -0
- package/src/runtime/actions/builtin/index.ts +5 -0
- package/src/runtime/actions/builtin/setup-node.ts +86 -0
- package/src/runtime/actions/builtin/tar-parser.ts +175 -0
- package/src/runtime/actions/composite-executor.ts +192 -0
- package/src/runtime/actions/composite-expression.ts +190 -0
- package/src/runtime/actions/executor.ts +578 -0
- package/src/runtime/actions/file-parsers.ts +51 -0
- package/src/runtime/actions/job-manager.ts +213 -0
- package/src/runtime/actions/process-spawner.ts +275 -0
- package/src/runtime/actions/secrets.ts +162 -0
- package/src/runtime/command.ts +120 -0
- package/src/runtime/exec-runner.ts +309 -0
- package/src/runtime/git-http-backend.ts +145 -0
- package/src/runtime/git.ts +98 -0
- package/src/runtime/heartbeat.ts +57 -0
- package/src/runtime/logging.ts +26 -0
- package/src/runtime/paths.ts +264 -0
- package/src/runtime/secure-fs.ts +82 -0
- package/src/runtime/tools/network.ts +161 -0
- package/src/runtime/tools/worker.ts +335 -0
- package/src/runtime/validation.ts +292 -0
- package/src/shared/config.ts +149 -0
- package/src/shared/errors.ts +65 -0
- package/src/shared/temp-id.ts +10 -0
- package/src/storage/r2.ts +287 -0
- package/src/types/hono.d.ts +23 -0
- package/src/utils/audit-log.ts +92 -0
- package/src/utils/process-kill.ts +18 -0
- package/src/utils/sandbox-env.ts +136 -0
- package/src/utils/temp-dir.ts +74 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { runGitCommand } from '../../runtime/git.js';
|
|
3
|
+
import { getErrorMessage } from 'takos-common/errors';
|
|
4
|
+
import {
|
|
5
|
+
getVerifiedRepoPath,
|
|
6
|
+
validateRef,
|
|
7
|
+
requireRepoParams,
|
|
8
|
+
} from './repo-validation.js';
|
|
9
|
+
import { badRequest, internalError, notFound } from 'takos-common/middleware/hono';
|
|
10
|
+
import { ErrorCodes } from 'takos-common/errors';
|
|
11
|
+
|
|
12
|
+
const app = new Hono();
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// branches: list, create, delete
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
app.get('/repos/:spaceId/:repoName/branches', async (c) => {
|
|
19
|
+
try {
|
|
20
|
+
const spaceId = c.req.param('spaceId');
|
|
21
|
+
const repoName = c.req.param('repoName');
|
|
22
|
+
|
|
23
|
+
const paramsErr = requireRepoParams(c, spaceId, repoName);
|
|
24
|
+
if (paramsErr) return paramsErr;
|
|
25
|
+
|
|
26
|
+
const repoResult = await getVerifiedRepoPath(c, spaceId, repoName);
|
|
27
|
+
if ('error' in repoResult) return repoResult.error;
|
|
28
|
+
const gitPath = repoResult.gitPath;
|
|
29
|
+
|
|
30
|
+
const { exitCode, output } = await runGitCommand(
|
|
31
|
+
['branch', '--list', '--format=%(refname:short)'],
|
|
32
|
+
gitPath
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
if (exitCode !== 0) {
|
|
36
|
+
return internalError(c, 'Failed to list branches', { output });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const branches = output
|
|
40
|
+
.split('\n')
|
|
41
|
+
.map((branch) => branch.trim())
|
|
42
|
+
.filter((branch) => branch.length > 0);
|
|
43
|
+
|
|
44
|
+
return c.json({ success: true, branches });
|
|
45
|
+
} catch (err) {
|
|
46
|
+
return internalError(c, getErrorMessage(err));
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
app.post('/repos/branch', async (c) => {
|
|
51
|
+
try {
|
|
52
|
+
const { spaceId, repoName, branchName, fromRef } = await c.req.json() as {
|
|
53
|
+
spaceId: string;
|
|
54
|
+
repoName: string;
|
|
55
|
+
branchName: string;
|
|
56
|
+
fromRef?: string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
if (!spaceId || !repoName || !branchName) {
|
|
60
|
+
return badRequest(c, 'spaceId, repoName, and branchName are required');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const refErr = validateRef(c, branchName);
|
|
64
|
+
if (refErr) return refErr;
|
|
65
|
+
if (fromRef) {
|
|
66
|
+
const fromRefErr = validateRef(c, fromRef);
|
|
67
|
+
if (fromRefErr) return fromRefErr;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const repoResult = await getVerifiedRepoPath(c, spaceId, repoName);
|
|
71
|
+
if ('error' in repoResult) return repoResult.error;
|
|
72
|
+
const gitPath = repoResult.gitPath;
|
|
73
|
+
|
|
74
|
+
const branchCheckResult = await runGitCommand(
|
|
75
|
+
['show-ref', '--verify', `refs/heads/${branchName}`],
|
|
76
|
+
gitPath
|
|
77
|
+
);
|
|
78
|
+
if (branchCheckResult.exitCode === 0) {
|
|
79
|
+
return c.json({ error: { code: ErrorCodes.CONFLICT, message: `Branch '${branchName}' already exists` } }, 409);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const sourceRef = fromRef || 'HEAD';
|
|
83
|
+
const resolveResult = await runGitCommand(['rev-parse', sourceRef], gitPath);
|
|
84
|
+
if (resolveResult.exitCode !== 0) {
|
|
85
|
+
return badRequest(c, `Invalid reference: ${sourceRef}`, { output: resolveResult.output });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const commitHash = resolveResult.output.trim();
|
|
89
|
+
const createResult = await runGitCommand(
|
|
90
|
+
['update-ref', `refs/heads/${branchName}`, commitHash],
|
|
91
|
+
gitPath
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
if (createResult.exitCode !== 0) {
|
|
95
|
+
return internalError(c, 'Failed to create branch', { output: createResult.output });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return c.json({
|
|
99
|
+
success: true,
|
|
100
|
+
branchName,
|
|
101
|
+
commitHash,
|
|
102
|
+
fromRef: sourceRef,
|
|
103
|
+
message: `Branch '${branchName}' created successfully`,
|
|
104
|
+
});
|
|
105
|
+
} catch (err) {
|
|
106
|
+
return internalError(c, getErrorMessage(err));
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
app.delete('/repos/branch', async (c) => {
|
|
111
|
+
try {
|
|
112
|
+
const { spaceId, repoName, branchName } = await c.req.json() as {
|
|
113
|
+
spaceId: string;
|
|
114
|
+
repoName: string;
|
|
115
|
+
branchName: string;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
if (!spaceId || !repoName || !branchName) {
|
|
119
|
+
return badRequest(c, 'spaceId, repoName, and branchName are required');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const refErr = validateRef(c, branchName);
|
|
123
|
+
if (refErr) return refErr;
|
|
124
|
+
|
|
125
|
+
if (branchName === 'main' || branchName === 'master') {
|
|
126
|
+
return badRequest(c, `Cannot delete protected branch: ${branchName}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const repoResult = await getVerifiedRepoPath(c, spaceId, repoName);
|
|
130
|
+
if ('error' in repoResult) return repoResult.error;
|
|
131
|
+
const gitPath = repoResult.gitPath;
|
|
132
|
+
|
|
133
|
+
const branchCheckResult = await runGitCommand(
|
|
134
|
+
['show-ref', '--verify', `refs/heads/${branchName}`],
|
|
135
|
+
gitPath
|
|
136
|
+
);
|
|
137
|
+
if (branchCheckResult.exitCode !== 0) {
|
|
138
|
+
return notFound(c, `Branch '${branchName}' not found`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const deleteResult = await runGitCommand(
|
|
142
|
+
['update-ref', '-d', `refs/heads/${branchName}`],
|
|
143
|
+
gitPath
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
if (deleteResult.exitCode !== 0) {
|
|
147
|
+
return internalError(c, 'Failed to delete branch', { output: deleteResult.output });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return c.json({
|
|
151
|
+
success: true,
|
|
152
|
+
branchName,
|
|
153
|
+
message: `Branch '${branchName}' deleted successfully`,
|
|
154
|
+
});
|
|
155
|
+
} catch (err) {
|
|
156
|
+
return internalError(c, getErrorMessage(err));
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
export default app;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { runGitCommand } from '../../runtime/git.js';
|
|
3
|
+
import { getErrorMessage } from 'takos-common/errors';
|
|
4
|
+
import {
|
|
5
|
+
getVerifiedRepoPath,
|
|
6
|
+
validateRef,
|
|
7
|
+
validatePathParam,
|
|
8
|
+
requireRepoParams,
|
|
9
|
+
} from './repo-validation.js';
|
|
10
|
+
import { badRequest, internalError, notFound } from 'takos-common/middleware/hono';
|
|
11
|
+
|
|
12
|
+
const app = new Hono();
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// tree: ls-tree + blob
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
app.get('/repos/:spaceId/:repoName/tree', async (c) => {
|
|
19
|
+
try {
|
|
20
|
+
const spaceId = c.req.param('spaceId');
|
|
21
|
+
const repoName = c.req.param('repoName');
|
|
22
|
+
const ref = c.req.query('ref') || 'HEAD';
|
|
23
|
+
const treePath = c.req.query('path') || '';
|
|
24
|
+
|
|
25
|
+
const paramsErr = requireRepoParams(c, spaceId, repoName);
|
|
26
|
+
if (paramsErr) return paramsErr;
|
|
27
|
+
const refErr = validateRef(c, ref);
|
|
28
|
+
if (refErr) return refErr;
|
|
29
|
+
if (treePath) {
|
|
30
|
+
const pathErr = validatePathParam(c, treePath);
|
|
31
|
+
if (pathErr) return pathErr;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const repoResult = await getVerifiedRepoPath(c, spaceId, repoName);
|
|
35
|
+
if ('error' in repoResult) return repoResult.error;
|
|
36
|
+
const gitPath = repoResult.gitPath;
|
|
37
|
+
|
|
38
|
+
const lsTreeArgs = ['ls-tree', '-l', ref];
|
|
39
|
+
if (treePath) {
|
|
40
|
+
lsTreeArgs.push('--', treePath);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const { exitCode, output } = await runGitCommand(lsTreeArgs, gitPath);
|
|
44
|
+
|
|
45
|
+
if (exitCode !== 0) {
|
|
46
|
+
if (output.includes('Not a valid object name') || output.includes('does not exist')) {
|
|
47
|
+
return notFound(c, `Reference not found: ${ref}`, { output });
|
|
48
|
+
}
|
|
49
|
+
return internalError(c, 'Failed to list tree', { output });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const entries = output
|
|
53
|
+
.split('\n')
|
|
54
|
+
.filter((line) => line.trim().length > 0)
|
|
55
|
+
.map((line) => {
|
|
56
|
+
const tabIndex = line.indexOf('\t');
|
|
57
|
+
if (tabIndex === -1) return null;
|
|
58
|
+
|
|
59
|
+
const metaParts = line.slice(0, tabIndex).split(/\s+/);
|
|
60
|
+
if (metaParts.length < 4) return null;
|
|
61
|
+
|
|
62
|
+
const [mode, type, hash, sizeStr] = metaParts;
|
|
63
|
+
const name = line.slice(tabIndex + 1);
|
|
64
|
+
const parsedSize = type === 'blob' ? parseInt(sizeStr, 10) : undefined;
|
|
65
|
+
const size = parsedSize !== undefined && Number.isFinite(parsedSize) ? parsedSize : undefined;
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
mode,
|
|
69
|
+
type: type as 'blob' | 'tree',
|
|
70
|
+
hash,
|
|
71
|
+
size,
|
|
72
|
+
name,
|
|
73
|
+
path: treePath ? `${treePath}/${name}` : name,
|
|
74
|
+
};
|
|
75
|
+
})
|
|
76
|
+
.filter((entry) => entry !== null);
|
|
77
|
+
|
|
78
|
+
return c.json({
|
|
79
|
+
success: true,
|
|
80
|
+
ref,
|
|
81
|
+
path: treePath || '/',
|
|
82
|
+
entries,
|
|
83
|
+
});
|
|
84
|
+
} catch (err) {
|
|
85
|
+
return internalError(c, getErrorMessage(err));
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
app.get('/repos/:spaceId/:repoName/blob', async (c) => {
|
|
90
|
+
try {
|
|
91
|
+
const spaceId = c.req.param('spaceId');
|
|
92
|
+
const repoName = c.req.param('repoName');
|
|
93
|
+
const ref = c.req.query('ref') || 'HEAD';
|
|
94
|
+
const filePath = c.req.query('path');
|
|
95
|
+
|
|
96
|
+
const paramsErr = requireRepoParams(c, spaceId, repoName);
|
|
97
|
+
if (paramsErr) return paramsErr;
|
|
98
|
+
|
|
99
|
+
if (!filePath) {
|
|
100
|
+
return badRequest(c, 'path query parameter is required');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const refErr = validateRef(c, ref);
|
|
104
|
+
if (refErr) return refErr;
|
|
105
|
+
const pathErr = validatePathParam(c, filePath);
|
|
106
|
+
if (pathErr) return pathErr;
|
|
107
|
+
|
|
108
|
+
const repoResult = await getVerifiedRepoPath(c, spaceId, repoName);
|
|
109
|
+
if ('error' in repoResult) return repoResult.error;
|
|
110
|
+
const gitPath = repoResult.gitPath;
|
|
111
|
+
|
|
112
|
+
const { exitCode, output } = await runGitCommand(['show', `${ref}:${filePath}`], gitPath);
|
|
113
|
+
|
|
114
|
+
if (exitCode !== 0) {
|
|
115
|
+
if (output.includes('does not exist') || output.includes('Not a valid object')) {
|
|
116
|
+
return notFound(c, `File not found: ${filePath} at ${ref}`, { output });
|
|
117
|
+
}
|
|
118
|
+
return internalError(c, 'Failed to get file content', { output });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const lsResult = await runGitCommand(['ls-tree', '-l', ref, '--', filePath], gitPath);
|
|
122
|
+
|
|
123
|
+
let size: number | undefined;
|
|
124
|
+
let mode: string | undefined;
|
|
125
|
+
if (lsResult.exitCode === 0 && lsResult.output.trim()) {
|
|
126
|
+
const parts = lsResult.output.trim().split(/\s+/);
|
|
127
|
+
if (parts.length >= 4) {
|
|
128
|
+
mode = parts[0];
|
|
129
|
+
const rawSize = parseInt(parts[3], 10);
|
|
130
|
+
size = Number.isFinite(rawSize) ? rawSize : undefined;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return c.json({
|
|
135
|
+
success: true,
|
|
136
|
+
ref,
|
|
137
|
+
path: filePath,
|
|
138
|
+
content: output,
|
|
139
|
+
size,
|
|
140
|
+
mode,
|
|
141
|
+
});
|
|
142
|
+
} catch (err) {
|
|
143
|
+
return internalError(c, getErrorMessage(err));
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// history: commit log
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
app.get('/repos/:spaceId/:repoName/commits', async (c) => {
|
|
152
|
+
try {
|
|
153
|
+
const spaceId = c.req.param('spaceId');
|
|
154
|
+
const repoName = c.req.param('repoName');
|
|
155
|
+
const limit = c.req.query('limit') || '20';
|
|
156
|
+
const branch = c.req.query('branch') || 'HEAD';
|
|
157
|
+
|
|
158
|
+
const paramsErr = requireRepoParams(c, spaceId, repoName);
|
|
159
|
+
if (paramsErr) return paramsErr;
|
|
160
|
+
|
|
161
|
+
const repoResult = await getVerifiedRepoPath(c, spaceId, repoName);
|
|
162
|
+
if ('error' in repoResult) return repoResult.error;
|
|
163
|
+
const gitPath = repoResult.gitPath;
|
|
164
|
+
|
|
165
|
+
const refErr = validateRef(c, branch);
|
|
166
|
+
if (refErr) return refErr;
|
|
167
|
+
|
|
168
|
+
const limitNum = Math.min(Math.max(parseInt(limit, 10) || 20, 1), 100);
|
|
169
|
+
const { exitCode, output } = await runGitCommand(
|
|
170
|
+
['log', branch, `-n${limitNum}`, '--format=%H|%s|%an|%ae|%aI'],
|
|
171
|
+
gitPath
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
if (exitCode !== 0) {
|
|
175
|
+
if (output.includes('unknown revision') || output.includes('does not have any commits')) {
|
|
176
|
+
return c.json({
|
|
177
|
+
success: true,
|
|
178
|
+
commits: [],
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
return internalError(c, 'Failed to get commit history', { output });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const commits = output
|
|
185
|
+
.split('\n')
|
|
186
|
+
.filter((line) => line.trim().length > 0)
|
|
187
|
+
.map((line) => {
|
|
188
|
+
const [hash, message, authorName, authorEmail, date] = line.split('|');
|
|
189
|
+
return {
|
|
190
|
+
hash,
|
|
191
|
+
message,
|
|
192
|
+
author: {
|
|
193
|
+
name: authorName,
|
|
194
|
+
email: authorEmail,
|
|
195
|
+
},
|
|
196
|
+
date,
|
|
197
|
+
};
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
return c.json({
|
|
201
|
+
success: true,
|
|
202
|
+
commits,
|
|
203
|
+
});
|
|
204
|
+
} catch (err) {
|
|
205
|
+
return internalError(c, getErrorMessage(err));
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
export default app;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import * as fs from 'fs/promises';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { REPOS_BASE_DIR, WORKDIR_BASE_DIR } from '../../shared/config.js';
|
|
5
|
+
import { runGitCommand } from '../../runtime/git.js';
|
|
6
|
+
import {
|
|
7
|
+
getRepoPath,
|
|
8
|
+
verifyNoSymlinkPathComponents,
|
|
9
|
+
verifyPathWithinAfterAccess,
|
|
10
|
+
} from '../../runtime/paths.js';
|
|
11
|
+
import { getErrorMessage } from 'takos-common/errors';
|
|
12
|
+
import {
|
|
13
|
+
validateRef,
|
|
14
|
+
validateTargetDir,
|
|
15
|
+
} from './repo-validation.js';
|
|
16
|
+
import { isBoundaryViolationError } from '../../shared/errors.js';
|
|
17
|
+
import { badRequest, forbidden, internalError, notFound } from 'takos-common/middleware/hono';
|
|
18
|
+
import { ErrorCodes } from 'takos-common/errors';
|
|
19
|
+
import branchRoutes from './branches.js';
|
|
20
|
+
import contentRoutes from './content.js';
|
|
21
|
+
|
|
22
|
+
const app = new Hono();
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// base: init + clone
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
app.post('/repos/init', async (c) => {
|
|
29
|
+
try {
|
|
30
|
+
const { spaceId, repoName = 'main' } = await c.req.json() as {
|
|
31
|
+
spaceId: string;
|
|
32
|
+
repoName?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
if (!spaceId) {
|
|
36
|
+
return badRequest(c, 'spaceId is required');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const gitPath = getRepoPath(spaceId, repoName);
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
await fs.access(gitPath);
|
|
43
|
+
return c.json({ error: { code: ErrorCodes.CONFLICT, message: 'Repository already exists', details: { gitPath } } }, 409);
|
|
44
|
+
} catch {
|
|
45
|
+
// Repository does not exist yet - proceed with creation.
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
await fs.mkdir(path.dirname(gitPath), { recursive: true });
|
|
49
|
+
|
|
50
|
+
const { exitCode, output } = await runGitCommand(['init', '--bare', gitPath], REPOS_BASE_DIR);
|
|
51
|
+
|
|
52
|
+
if (exitCode !== 0) {
|
|
53
|
+
return internalError(c, 'Failed to initialize repository', { output });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return c.json({
|
|
57
|
+
success: true,
|
|
58
|
+
gitPath,
|
|
59
|
+
message: 'Repository initialized successfully',
|
|
60
|
+
});
|
|
61
|
+
} catch (err) {
|
|
62
|
+
return internalError(c, getErrorMessage(err));
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
app.post('/repos/clone', async (c) => {
|
|
67
|
+
try {
|
|
68
|
+
const { spaceId, repoName, branch, targetDir } = await c.req.json() as {
|
|
69
|
+
spaceId: string;
|
|
70
|
+
repoName: string;
|
|
71
|
+
branch?: string;
|
|
72
|
+
targetDir: string;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
if (!spaceId || !repoName || !targetDir) {
|
|
76
|
+
return badRequest(c, 'spaceId, repoName, and targetDir are required');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const targetDirResult = await validateTargetDir(c, targetDir);
|
|
80
|
+
if ('error' in targetDirResult) return targetDirResult.error;
|
|
81
|
+
const resolvedTargetDir = targetDirResult.resolved;
|
|
82
|
+
|
|
83
|
+
const gitPath = getRepoPath(spaceId, repoName);
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
await fs.access(gitPath);
|
|
87
|
+
} catch {
|
|
88
|
+
return notFound(c, 'Repository not found', { gitPath });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const targetParentDir = path.dirname(resolvedTargetDir);
|
|
92
|
+
await fs.mkdir(targetParentDir, { recursive: true });
|
|
93
|
+
await verifyNoSymlinkPathComponents(WORKDIR_BASE_DIR, resolvedTargetDir, 'targetDir');
|
|
94
|
+
await verifyPathWithinAfterAccess(WORKDIR_BASE_DIR, targetParentDir, 'targetDir');
|
|
95
|
+
|
|
96
|
+
const cloneArgs = ['clone'];
|
|
97
|
+
if (branch) {
|
|
98
|
+
const refErr = validateRef(c, branch);
|
|
99
|
+
if (refErr) return refErr;
|
|
100
|
+
cloneArgs.push('--branch', branch);
|
|
101
|
+
}
|
|
102
|
+
cloneArgs.push(gitPath, resolvedTargetDir);
|
|
103
|
+
|
|
104
|
+
const { exitCode, output } = await runGitCommand(cloneArgs, '/');
|
|
105
|
+
|
|
106
|
+
if (exitCode !== 0) {
|
|
107
|
+
return internalError(c, 'Failed to clone repository', { output });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
await verifyPathWithinAfterAccess(WORKDIR_BASE_DIR, resolvedTargetDir, 'targetDir');
|
|
111
|
+
|
|
112
|
+
return c.json({
|
|
113
|
+
success: true,
|
|
114
|
+
targetDir: resolvedTargetDir,
|
|
115
|
+
branch: branch || 'default',
|
|
116
|
+
message: 'Repository cloned successfully',
|
|
117
|
+
});
|
|
118
|
+
} catch (err) {
|
|
119
|
+
if (isBoundaryViolationError(err)) {
|
|
120
|
+
return forbidden(c, 'Path escapes workdir boundary');
|
|
121
|
+
}
|
|
122
|
+
return internalError(c, getErrorMessage(err));
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Mount branch and content sub-routes
|
|
127
|
+
app.route('/', branchRoutes);
|
|
128
|
+
app.route('/', contentRoutes);
|
|
129
|
+
|
|
130
|
+
export default app;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { Context } from 'hono';
|
|
2
|
+
import * as fs from 'fs/promises';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { WORKDIR_BASE_DIR } from '../../shared/config.js';
|
|
5
|
+
import {
|
|
6
|
+
getRepoPath,
|
|
7
|
+
resolveWorkDirPath,
|
|
8
|
+
verifyNoSymlinkPathComponents,
|
|
9
|
+
verifyPathWithinBeforeCreate,
|
|
10
|
+
verifyPathWithinAfterAccess,
|
|
11
|
+
} from '../../runtime/paths.js';
|
|
12
|
+
import { validateGitRef, validateGitPath } from '../../runtime/validation.js';
|
|
13
|
+
import { getErrorMessage } from 'takos-common/errors';
|
|
14
|
+
import { isBoundaryViolationError } from '../../shared/errors.js';
|
|
15
|
+
import { badRequest, forbidden, notFound } from 'takos-common/middleware/hono';
|
|
16
|
+
|
|
17
|
+
// --- getVerifiedRepoPath ---
|
|
18
|
+
|
|
19
|
+
export async function getVerifiedRepoPath(
|
|
20
|
+
c: Context,
|
|
21
|
+
spaceId: string,
|
|
22
|
+
repoName: string,
|
|
23
|
+
): Promise<{ gitPath: string } | { error: Response }> {
|
|
24
|
+
const gitPath = getRepoPath(spaceId, repoName);
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
await fs.access(gitPath);
|
|
28
|
+
return { gitPath };
|
|
29
|
+
} catch {
|
|
30
|
+
return { error: notFound(c, 'Repository not found', { gitPath }) };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Validate that a git ref is well-formed.
|
|
36
|
+
* Returns null on success, or a Response on failure.
|
|
37
|
+
*/
|
|
38
|
+
export function validateRef(c: Context, ref: string): Response | null {
|
|
39
|
+
try {
|
|
40
|
+
validateGitRef(ref);
|
|
41
|
+
return null;
|
|
42
|
+
} catch (err) {
|
|
43
|
+
return badRequest(c, getErrorMessage(err));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Validate that a git path is well-formed.
|
|
49
|
+
* Returns null on success, or a Response on failure.
|
|
50
|
+
*/
|
|
51
|
+
export function validatePathParam(c: Context, filePath: string): Response | null {
|
|
52
|
+
try {
|
|
53
|
+
validateGitPath(filePath);
|
|
54
|
+
return null;
|
|
55
|
+
} catch (err) {
|
|
56
|
+
return badRequest(c, getErrorMessage(err));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Validate that spaceId and repoName are present.
|
|
62
|
+
* Returns null on success, or a Response on failure.
|
|
63
|
+
*/
|
|
64
|
+
export function requireRepoParams(c: Context, spaceId: string | undefined, repoName: string | undefined): Response | null {
|
|
65
|
+
if (!spaceId || !repoName) {
|
|
66
|
+
return badRequest(c, 'spaceId and repoName are required');
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolve and validate a target directory for repo operations (clone, init).
|
|
73
|
+
* Checks: path resolution, symlink safety, boundary containment before creation.
|
|
74
|
+
* Returns the resolved path on success, or an error Response.
|
|
75
|
+
*/
|
|
76
|
+
export async function validateTargetDir(c: Context, targetDir: string): Promise<{ resolved: string } | { error: Response }> {
|
|
77
|
+
let resolved: string;
|
|
78
|
+
try {
|
|
79
|
+
resolved = resolveWorkDirPath(targetDir, 'targetDir');
|
|
80
|
+
} catch {
|
|
81
|
+
return { error: badRequest(c, 'Invalid targetDir') };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
await verifyNoSymlinkPathComponents(WORKDIR_BASE_DIR, resolved, 'targetDir');
|
|
86
|
+
await verifyPathWithinBeforeCreate(WORKDIR_BASE_DIR, resolved, 'targetDir');
|
|
87
|
+
} catch (err) {
|
|
88
|
+
if (isBoundaryViolationError(err)) {
|
|
89
|
+
return { error: forbidden(c, 'Path escapes workdir boundary') };
|
|
90
|
+
}
|
|
91
|
+
return { error: badRequest(c, 'Invalid targetDir') };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const stats = await fs.lstat(resolved);
|
|
96
|
+
if (stats.isSymbolicLink()) {
|
|
97
|
+
return { error: forbidden(c, 'Path escapes workdir boundary') };
|
|
98
|
+
}
|
|
99
|
+
} catch (err) {
|
|
100
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { resolved };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Resolve and validate a working directory for git operations (commit, push).
|
|
108
|
+
* Checks: path resolution, symlink safety, boundary containment, .git presence.
|
|
109
|
+
* Returns the resolved path on success, or an error Response.
|
|
110
|
+
*/
|
|
111
|
+
export async function resolveAndValidateWorkDir(c: Context, workDir: string): Promise<{ resolved: string } | { error: Response }> {
|
|
112
|
+
let resolved: string;
|
|
113
|
+
try {
|
|
114
|
+
resolved = resolveWorkDirPath(workDir, 'workDir');
|
|
115
|
+
} catch (err) {
|
|
116
|
+
return { error: badRequest(c, getErrorMessage(err)) };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
await verifyNoSymlinkPathComponents(WORKDIR_BASE_DIR, resolved, 'workDir');
|
|
121
|
+
await verifyPathWithinAfterAccess(WORKDIR_BASE_DIR, resolved, 'workDir');
|
|
122
|
+
} catch (err) {
|
|
123
|
+
if (isBoundaryViolationError(err)) {
|
|
124
|
+
return { error: forbidden(c, 'Path escapes workdir boundary') };
|
|
125
|
+
}
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
await fs.access(path.join(resolved, '.git'));
|
|
131
|
+
} catch {
|
|
132
|
+
return { error: badRequest(c, 'Not a git repository', { workDir: resolved }) };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { resolved };
|
|
136
|
+
}
|