lopata 0.19.2 → 0.20.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/dist/types/bindings/ai-search.d.ts +44 -0
- package/dist/types/bindings/analytics-engine-sql.d.ts +107 -0
- package/dist/types/bindings/artifacts-git-http.d.ts +19 -0
- package/dist/types/bindings/artifacts.d.ts +108 -0
- package/dist/types/bindings/flagship.d.ts +37 -0
- package/dist/types/bindings/vpc-network.d.ts +22 -0
- package/dist/types/bindings/worker-loader-entry.d.ts +71 -0
- package/dist/types/bindings/worker-loader.d.ts +81 -0
- package/dist/types/config.d.ts +23 -0
- package/dist/types/env.d.ts +3 -1
- package/dist/types/generation-manager.d.ts +6 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/dist/types/vite-plugin/dev-server-plugin.d.ts +8 -0
- package/dist/types/worker-thread/executor.d.ts +2 -0
- package/dist/types/worker-thread/protocol.d.ts +2 -0
- package/dist/types/worker-thread/thread-env.d.ts +3 -1
- package/package.json +1 -1
- package/src/bindings/ai-search.ts +185 -0
- package/src/bindings/analytics-engine-sql.ts +1138 -0
- package/src/bindings/artifacts-git-http.ts +249 -0
- package/src/bindings/artifacts.ts +453 -0
- package/src/bindings/do-worker-env.ts +4 -0
- package/src/bindings/flagship.ts +117 -0
- package/src/bindings/vpc-network.ts +40 -0
- package/src/bindings/worker-loader-entry.ts +143 -0
- package/src/bindings/worker-loader.ts +325 -0
- package/src/cli/dev.ts +25 -3
- package/src/config.ts +20 -0
- package/src/db.ts +54 -0
- package/src/env.ts +71 -0
- package/src/generation-manager.ts +5 -1
- package/src/generation.ts +20 -3
- package/src/plugin.ts +37 -0
- package/src/vite-plugin/dev-server-plugin.ts +18 -2
- package/src/worker-thread/entry.ts +1 -0
- package/src/worker-thread/executor.ts +3 -0
- package/src/worker-thread/protocol.ts +2 -0
- package/src/worker-thread/thread-env.ts +72 -1
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git HTTP protocol handler for the Artifacts binding.
|
|
3
|
+
*
|
|
4
|
+
* Proxies `/__artifacts/git/<repo-id>.git/<path>` requests to the `git http-backend`
|
|
5
|
+
* CGI binary. Authentication is via HTTP Basic (username ignored, password = token
|
|
6
|
+
* from `artifacts_tokens`). Read operations (`git-upload-pack`) accept any non-revoked
|
|
7
|
+
* token; write operations (`git-receive-pack`) require a `write`-scoped token.
|
|
8
|
+
*
|
|
9
|
+
* Repos marked read-only (`read_only = 1`) reject write requests regardless of scope.
|
|
10
|
+
*/
|
|
11
|
+
import type { Database } from 'bun:sqlite'
|
|
12
|
+
import { existsSync } from 'node:fs'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
|
|
15
|
+
interface RepoRow {
|
|
16
|
+
id: string
|
|
17
|
+
read_only: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface TokenRow {
|
|
21
|
+
scope: string
|
|
22
|
+
expires_at: number | null
|
|
23
|
+
revoked_at: number | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const GIT_PATH_RE = /^\/__artifacts\/git\/([^/]+)\.git(\/.*)?$/
|
|
27
|
+
|
|
28
|
+
export interface ArtifactsGitHandlerOptions {
|
|
29
|
+
db: Database
|
|
30
|
+
artifactsDir: string
|
|
31
|
+
gitBinary?: string
|
|
32
|
+
/** When true (tests), skip token validation. */
|
|
33
|
+
skipAuth?: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function handleArtifactsGitRequest(
|
|
37
|
+
request: Request,
|
|
38
|
+
opts: ArtifactsGitHandlerOptions,
|
|
39
|
+
): Promise<Response | null> {
|
|
40
|
+
const url = new URL(request.url)
|
|
41
|
+
const match = url.pathname.match(GIT_PATH_RE)
|
|
42
|
+
if (!match) return null
|
|
43
|
+
|
|
44
|
+
const repoId = match[1]!
|
|
45
|
+
const subPath = match[2] ?? '/'
|
|
46
|
+
const repo = opts.db
|
|
47
|
+
.query<RepoRow, [string]>('SELECT id, read_only FROM artifacts_repos WHERE id = ?')
|
|
48
|
+
.get(repoId)
|
|
49
|
+
if (!repo) {
|
|
50
|
+
return new Response('Not found', { status: 404 })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const repoDir = join(opts.artifactsDir, `${repoId}.git`)
|
|
54
|
+
if (!existsSync(repoDir)) {
|
|
55
|
+
return new Response('Repo directory missing', { status: 500 })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Decide if this is a write request
|
|
59
|
+
const isWrite = isWriteRequest(request.method, subPath, url.searchParams.get('service'))
|
|
60
|
+
if (isWrite && repo.read_only) {
|
|
61
|
+
return new Response('Repo is read-only', { status: 403 })
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!opts.skipAuth) {
|
|
65
|
+
const authResult = authenticate(request, opts.db, repoId, isWrite)
|
|
66
|
+
if (!authResult.ok) {
|
|
67
|
+
return new Response(authResult.message, {
|
|
68
|
+
status: 401,
|
|
69
|
+
headers: {
|
|
70
|
+
'WWW-Authenticate': 'Basic realm="Artifacts"',
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return proxyToGitHttpBackend(request, subPath, repoDir, opts.gitBinary ?? 'git')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isWriteRequest(method: string, subPath: string, service: string | null): boolean {
|
|
80
|
+
if (method === 'POST' && subPath === '/git-receive-pack') return true
|
|
81
|
+
if (method === 'GET' && subPath === '/info/refs' && service === 'git-receive-pack') return true
|
|
82
|
+
return false
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface AuthResult {
|
|
86
|
+
ok: boolean
|
|
87
|
+
message: string
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function authenticate(request: Request, db: Database, repoId: string, writeNeeded: boolean): AuthResult {
|
|
91
|
+
const authHeader = request.headers.get('authorization')
|
|
92
|
+
if (!authHeader) {
|
|
93
|
+
return { ok: false, message: 'Authentication required' }
|
|
94
|
+
}
|
|
95
|
+
let token: string | null = null
|
|
96
|
+
if (authHeader.startsWith('Bearer ')) {
|
|
97
|
+
token = authHeader.slice(7).trim()
|
|
98
|
+
} else if (authHeader.startsWith('Basic ')) {
|
|
99
|
+
try {
|
|
100
|
+
const decoded = atob(authHeader.slice(6).trim())
|
|
101
|
+
const colonIdx = decoded.indexOf(':')
|
|
102
|
+
token = colonIdx === -1 ? decoded : decoded.slice(colonIdx + 1)
|
|
103
|
+
} catch {
|
|
104
|
+
return { ok: false, message: 'Invalid Basic auth encoding' }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (!token) {
|
|
108
|
+
return { ok: false, message: 'Missing token' }
|
|
109
|
+
}
|
|
110
|
+
const row = db
|
|
111
|
+
.query<TokenRow, [string, string]>(
|
|
112
|
+
'SELECT scope, expires_at, revoked_at FROM artifacts_tokens WHERE repo_id = ? AND plaintext = ?',
|
|
113
|
+
)
|
|
114
|
+
.get(repoId, token)
|
|
115
|
+
if (!row) {
|
|
116
|
+
return { ok: false, message: 'Invalid token' }
|
|
117
|
+
}
|
|
118
|
+
if (row.revoked_at != null) {
|
|
119
|
+
return { ok: false, message: 'Token revoked' }
|
|
120
|
+
}
|
|
121
|
+
if (row.expires_at != null && row.expires_at < Date.now()) {
|
|
122
|
+
return { ok: false, message: 'Token expired' }
|
|
123
|
+
}
|
|
124
|
+
if (writeNeeded && row.scope !== 'write') {
|
|
125
|
+
return { ok: false, message: 'Token lacks write scope' }
|
|
126
|
+
}
|
|
127
|
+
return { ok: true, message: 'ok' }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function proxyToGitHttpBackend(
|
|
131
|
+
request: Request,
|
|
132
|
+
subPath: string,
|
|
133
|
+
repoDir: string,
|
|
134
|
+
gitBinary: string,
|
|
135
|
+
): Promise<Response> {
|
|
136
|
+
const url = new URL(request.url)
|
|
137
|
+
|
|
138
|
+
// CGI env variables required by git http-backend
|
|
139
|
+
const env: Record<string, string> = {
|
|
140
|
+
// Point GIT_PROJECT_ROOT at the repo parent so PATH_INFO can be "/<id>.git/..."
|
|
141
|
+
// But easier: point directly and use PATH_TRANSLATED + GIT_HTTP_EXPORT_ALL.
|
|
142
|
+
// We use PATH_TRANSLATED which tells http-backend exactly which repo dir to serve.
|
|
143
|
+
PATH_TRANSLATED: `${repoDir}${subPath}`,
|
|
144
|
+
GIT_HTTP_EXPORT_ALL: '1',
|
|
145
|
+
REQUEST_METHOD: request.method,
|
|
146
|
+
QUERY_STRING: url.search.startsWith('?') ? url.search.slice(1) : url.search,
|
|
147
|
+
CONTENT_TYPE: request.headers.get('content-type') ?? '',
|
|
148
|
+
REMOTE_USER: 'artifacts',
|
|
149
|
+
REMOTE_ADDR: '127.0.0.1',
|
|
150
|
+
// Let git detect encoding
|
|
151
|
+
HTTP_CONTENT_ENCODING: request.headers.get('content-encoding') ?? '',
|
|
152
|
+
HTTP_USER_AGENT: request.headers.get('user-agent') ?? 'lopata-artifacts',
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const proc = Bun.spawn([gitBinary, 'http-backend'], {
|
|
156
|
+
env,
|
|
157
|
+
stdin: 'pipe',
|
|
158
|
+
stdout: 'pipe',
|
|
159
|
+
stderr: 'pipe',
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
// Pipe request body into the CGI's stdin (if any)
|
|
163
|
+
if (request.body) {
|
|
164
|
+
request.body.pipeTo(
|
|
165
|
+
new WritableStream({
|
|
166
|
+
async write(chunk) {
|
|
167
|
+
proc.stdin.write(chunk)
|
|
168
|
+
},
|
|
169
|
+
close() {
|
|
170
|
+
proc.stdin.end()
|
|
171
|
+
},
|
|
172
|
+
abort() {
|
|
173
|
+
proc.stdin.end()
|
|
174
|
+
},
|
|
175
|
+
}),
|
|
176
|
+
).catch(() => {})
|
|
177
|
+
} else {
|
|
178
|
+
proc.stdin.end()
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Read full stdout (git http-backend writes CGI-formatted response: headers + body)
|
|
182
|
+
const stdoutBytes = new Uint8Array(await new Response(proc.stdout).arrayBuffer())
|
|
183
|
+
const exitCode = await proc.exited
|
|
184
|
+
if (exitCode !== 0) {
|
|
185
|
+
const stderr = await new Response(proc.stderr).text()
|
|
186
|
+
return new Response(`git http-backend failed (${exitCode}): ${stderr}`, { status: 500 })
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return parseCgiResponse(stdoutBytes)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function parseCgiResponse(raw: Uint8Array): Response {
|
|
193
|
+
// Find header/body separator: \r\n\r\n or \n\n
|
|
194
|
+
let headerEnd = indexOfDouble(raw, 0x0d, 0x0a) // \r\n\r\n
|
|
195
|
+
let separatorLen = 4
|
|
196
|
+
if (headerEnd === -1) {
|
|
197
|
+
headerEnd = indexOfDouble(raw, 0x0a, 0x0a) // \n\n
|
|
198
|
+
separatorLen = 2
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let headerBytes: Uint8Array
|
|
202
|
+
let body: Uint8Array
|
|
203
|
+
if (headerEnd === -1) {
|
|
204
|
+
headerBytes = new Uint8Array(0)
|
|
205
|
+
body = raw
|
|
206
|
+
} else {
|
|
207
|
+
headerBytes = raw.slice(0, headerEnd)
|
|
208
|
+
body = raw.slice(headerEnd + separatorLen)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const headers = new Headers()
|
|
212
|
+
let status = 200
|
|
213
|
+
let statusText = 'OK'
|
|
214
|
+
const headerText = new TextDecoder().decode(headerBytes)
|
|
215
|
+
for (const line of headerText.split(/\r?\n/)) {
|
|
216
|
+
if (!line) continue
|
|
217
|
+
const colon = line.indexOf(':')
|
|
218
|
+
if (colon === -1) continue
|
|
219
|
+
const key = line.slice(0, colon).trim().toLowerCase()
|
|
220
|
+
const value = line.slice(colon + 1).trim()
|
|
221
|
+
if (key === 'status') {
|
|
222
|
+
const m = value.match(/^(\d+)\s*(.*)$/)
|
|
223
|
+
if (m) {
|
|
224
|
+
status = Number.parseInt(m[1]!, 10)
|
|
225
|
+
statusText = m[2] ?? ''
|
|
226
|
+
}
|
|
227
|
+
} else {
|
|
228
|
+
headers.append(key, value)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return new Response(body, { status, statusText, headers })
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function indexOfDouble(buf: Uint8Array, a: number, b: number): number {
|
|
236
|
+
// Find position of a,b,a,b (4-byte) or a,a (2-byte) pattern — caller chose pattern length.
|
|
237
|
+
// For \r\n\r\n: a=\r=0x0d, b=\n=0x0a, need 4 bytes
|
|
238
|
+
// For \n\n: a=\n=0x0a, b=\n=0x0a, need 2 bytes; simpler to special-case
|
|
239
|
+
if (a === b) {
|
|
240
|
+
for (let i = 0; i + 1 < buf.length; i++) {
|
|
241
|
+
if (buf[i] === a && buf[i + 1] === b) return i
|
|
242
|
+
}
|
|
243
|
+
return -1
|
|
244
|
+
}
|
|
245
|
+
for (let i = 0; i + 3 < buf.length; i++) {
|
|
246
|
+
if (buf[i] === a && buf[i + 1] === b && buf[i + 2] === a && buf[i + 3] === b) return i
|
|
247
|
+
}
|
|
248
|
+
return -1
|
|
249
|
+
}
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local implementation of the Cloudflare Artifacts binding.
|
|
3
|
+
*
|
|
4
|
+
* Artifacts on Cloudflare is a control-plane API (create/fork/delete repos,
|
|
5
|
+
* issue tokens) + a Git-over-HTTPS endpoint for content access. The binding
|
|
6
|
+
* only exposes the control plane; actual blob/tree/commit I/O happens via a
|
|
7
|
+
* regular Git client against the returned `remote` URL.
|
|
8
|
+
*
|
|
9
|
+
* In local dev we mirror this split:
|
|
10
|
+
* - Repo metadata and tokens live in SQLite (`artifacts_repos`, `artifacts_tokens`).
|
|
11
|
+
* - Each repo is a bare Git repository on disk at `.lopata/artifacts/<repo-id>.git`.
|
|
12
|
+
* - The lopata dev server exposes `/__artifacts/git/<repo-id>.git/*` backed by
|
|
13
|
+
* `git http-backend` (see `artifacts-git-http.ts`).
|
|
14
|
+
*/
|
|
15
|
+
import { randomUUIDv7 } from 'bun'
|
|
16
|
+
import type { Database } from 'bun:sqlite'
|
|
17
|
+
import { existsSync, mkdirSync, rmSync } from 'node:fs'
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
|
|
20
|
+
export type ArtifactsTokenScope = 'read' | 'write'
|
|
21
|
+
|
|
22
|
+
export interface CreateRepoOpts {
|
|
23
|
+
readOnly?: boolean
|
|
24
|
+
description?: string
|
|
25
|
+
setDefaultBranch?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface CreateRepoResult {
|
|
29
|
+
id: string
|
|
30
|
+
name: string
|
|
31
|
+
description: string | null
|
|
32
|
+
defaultBranch: string
|
|
33
|
+
remote: string
|
|
34
|
+
token: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface TokenResult {
|
|
38
|
+
id: string
|
|
39
|
+
plaintext: string
|
|
40
|
+
scope: ArtifactsTokenScope
|
|
41
|
+
expiresAt: string | null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ListReposOpts {
|
|
45
|
+
limit?: number
|
|
46
|
+
cursor?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface RepoListResult {
|
|
50
|
+
repos: { id: string; name: string; remote: string; defaultBranch: string; description: string | null }[]
|
|
51
|
+
cursor: string | null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface TokenListResult {
|
|
55
|
+
total: number
|
|
56
|
+
tokens: { id: string; scope: ArtifactsTokenScope; expiresAt: string | null; createdAt: string }[]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ImportParams {
|
|
60
|
+
source: { url: string; branch?: string; depth?: number }
|
|
61
|
+
target: { name: string; opts?: CreateRepoOpts }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ForkOpts {
|
|
65
|
+
description?: string
|
|
66
|
+
readOnly?: boolean
|
|
67
|
+
defaultBranchOnly?: boolean
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface RepoRow {
|
|
71
|
+
id: string
|
|
72
|
+
namespace: string
|
|
73
|
+
name: string
|
|
74
|
+
description: string | null
|
|
75
|
+
default_branch: string
|
|
76
|
+
read_only: number
|
|
77
|
+
forked_from: string | null
|
|
78
|
+
created_at: number
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface TokenRow {
|
|
82
|
+
id: string
|
|
83
|
+
repo_id: string
|
|
84
|
+
plaintext: string
|
|
85
|
+
scope: string
|
|
86
|
+
expires_at: number | null
|
|
87
|
+
created_at: number
|
|
88
|
+
revoked_at: number | null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const VALID_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/
|
|
92
|
+
|
|
93
|
+
function assertValidName(name: string) {
|
|
94
|
+
if (!VALID_NAME.test(name)) {
|
|
95
|
+
throw new Error(`Invalid repo name "${name}" — must match ${VALID_NAME}`)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function tsToIso(ts: number | null): string | null {
|
|
100
|
+
return ts == null ? null : new Date(ts).toISOString()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export class ArtifactsBinding {
|
|
104
|
+
private readonly db: Database
|
|
105
|
+
private readonly namespace: string
|
|
106
|
+
private readonly artifactsDir: string
|
|
107
|
+
private readonly remoteBase: string
|
|
108
|
+
private readonly gitBinary: string
|
|
109
|
+
|
|
110
|
+
constructor(
|
|
111
|
+
db: Database,
|
|
112
|
+
namespace: string,
|
|
113
|
+
artifactsDir: string,
|
|
114
|
+
remoteBase: string,
|
|
115
|
+
gitBinary = 'git',
|
|
116
|
+
) {
|
|
117
|
+
this.db = db
|
|
118
|
+
this.namespace = namespace
|
|
119
|
+
this.artifactsDir = artifactsDir
|
|
120
|
+
this.remoteBase = remoteBase.replace(/\/$/, '')
|
|
121
|
+
this.gitBinary = gitBinary
|
|
122
|
+
mkdirSync(this.artifactsDir, { recursive: true })
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private repoDir(id: string): string {
|
|
126
|
+
return join(this.artifactsDir, `${id}.git`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private buildRemote(id: string, token?: string): string {
|
|
130
|
+
const url = new URL(`${this.remoteBase}/${id}.git`)
|
|
131
|
+
if (token) {
|
|
132
|
+
url.username = 'artifacts'
|
|
133
|
+
url.password = token
|
|
134
|
+
}
|
|
135
|
+
return url.toString()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async create(name: string, opts: CreateRepoOpts = {}): Promise<CreateRepoResult> {
|
|
139
|
+
assertValidName(name)
|
|
140
|
+
const existing = this.db
|
|
141
|
+
.query<RepoRow, [string, string]>('SELECT * FROM artifacts_repos WHERE namespace = ? AND name = ?')
|
|
142
|
+
.get(this.namespace, name)
|
|
143
|
+
if (existing) {
|
|
144
|
+
throw new Error(`Artifacts repo "${name}" already exists in namespace "${this.namespace}"`)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const id = randomUUIDv7()
|
|
148
|
+
const defaultBranch = opts.setDefaultBranch ?? 'main'
|
|
149
|
+
const description = opts.description ?? null
|
|
150
|
+
const readOnly = opts.readOnly ? 1 : 0
|
|
151
|
+
|
|
152
|
+
const dir = this.repoDir(id)
|
|
153
|
+
await initBareRepo(this.gitBinary, dir, defaultBranch)
|
|
154
|
+
|
|
155
|
+
this.db.run(
|
|
156
|
+
`INSERT INTO artifacts_repos (id, namespace, name, description, default_branch, read_only, created_at)
|
|
157
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
158
|
+
[id, this.namespace, name, description, defaultBranch, readOnly, Date.now()],
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
const token = this.issueToken(id, 'write')
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
id,
|
|
165
|
+
name,
|
|
166
|
+
description,
|
|
167
|
+
defaultBranch,
|
|
168
|
+
remote: this.buildRemote(id),
|
|
169
|
+
token: token.plaintext,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async get(name: string): Promise<ArtifactsRepo> {
|
|
174
|
+
const row = this.db
|
|
175
|
+
.query<RepoRow, [string, string]>('SELECT * FROM artifacts_repos WHERE namespace = ? AND name = ?')
|
|
176
|
+
.get(this.namespace, name)
|
|
177
|
+
if (!row) {
|
|
178
|
+
throw new Error(`Artifacts repo "${name}" not found in namespace "${this.namespace}"`)
|
|
179
|
+
}
|
|
180
|
+
return new ArtifactsRepo(this, row, this.buildRemote(row.id))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async list(opts: ListReposOpts = {}): Promise<RepoListResult> {
|
|
184
|
+
const limit = Math.min(Math.max(opts.limit ?? 100, 1), 1000)
|
|
185
|
+
const cursor = opts.cursor ? Number.parseInt(opts.cursor, 10) : 0
|
|
186
|
+
const rows = this.db
|
|
187
|
+
.query<RepoRow, [string, number, number]>(
|
|
188
|
+
'SELECT * FROM artifacts_repos WHERE namespace = ? AND created_at >= ? ORDER BY created_at ASC LIMIT ?',
|
|
189
|
+
)
|
|
190
|
+
.all(this.namespace, cursor, limit + 1)
|
|
191
|
+
|
|
192
|
+
let nextCursor: string | null = null
|
|
193
|
+
const page = rows.length > limit ? rows.slice(0, limit) : rows
|
|
194
|
+
if (rows.length > limit) {
|
|
195
|
+
nextCursor = String(rows[limit]!.created_at)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
repos: page.map(r => ({
|
|
200
|
+
id: r.id,
|
|
201
|
+
name: r.name,
|
|
202
|
+
remote: this.buildRemote(r.id),
|
|
203
|
+
defaultBranch: r.default_branch,
|
|
204
|
+
description: r.description,
|
|
205
|
+
})),
|
|
206
|
+
cursor: nextCursor,
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async import(params: ImportParams): Promise<CreateRepoResult> {
|
|
211
|
+
const { source, target } = params
|
|
212
|
+
assertValidName(target.name)
|
|
213
|
+
if (!/^https?:\/\//.test(source.url) && !source.url.startsWith('git@')) {
|
|
214
|
+
throw new Error(`Artifacts import: unsupported source URL "${source.url}"`)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const existing = this.db
|
|
218
|
+
.query<RepoRow, [string, string]>('SELECT * FROM artifacts_repos WHERE namespace = ? AND name = ?')
|
|
219
|
+
.get(this.namespace, target.name)
|
|
220
|
+
if (existing) {
|
|
221
|
+
throw new Error(`Artifacts repo "${target.name}" already exists in namespace "${this.namespace}"`)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const id = randomUUIDv7()
|
|
225
|
+
const dir = this.repoDir(id)
|
|
226
|
+
|
|
227
|
+
const args = ['clone', '--bare']
|
|
228
|
+
if (source.depth) args.push('--depth', String(source.depth))
|
|
229
|
+
if (source.branch) args.push('--branch', source.branch, '--single-branch')
|
|
230
|
+
args.push(source.url, dir)
|
|
231
|
+
|
|
232
|
+
const proc = Bun.spawn([this.gitBinary, ...args], { stdout: 'pipe', stderr: 'pipe' })
|
|
233
|
+
const exit = await proc.exited
|
|
234
|
+
if (exit !== 0) {
|
|
235
|
+
const stderr = await new Response(proc.stderr).text()
|
|
236
|
+
try {
|
|
237
|
+
rmSync(dir, { recursive: true, force: true })
|
|
238
|
+
} catch {}
|
|
239
|
+
throw new Error(`git clone failed (${exit}): ${stderr}`)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Determine default branch from the cloned repo's HEAD
|
|
243
|
+
const defaultBranch = await readDefaultBranch(this.gitBinary, dir) ?? source.branch ?? 'main'
|
|
244
|
+
const description = target.opts?.description ?? null
|
|
245
|
+
const readOnly = target.opts?.readOnly ? 1 : 0
|
|
246
|
+
|
|
247
|
+
this.db.run(
|
|
248
|
+
`INSERT INTO artifacts_repos (id, namespace, name, description, default_branch, read_only, created_at)
|
|
249
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
250
|
+
[id, this.namespace, target.name, description, defaultBranch, readOnly, Date.now()],
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
const token = this.issueToken(id, 'write')
|
|
254
|
+
return {
|
|
255
|
+
id,
|
|
256
|
+
name: target.name,
|
|
257
|
+
description,
|
|
258
|
+
defaultBranch,
|
|
259
|
+
remote: this.buildRemote(id),
|
|
260
|
+
token: token.plaintext,
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async delete(name: string): Promise<boolean> {
|
|
265
|
+
const row = this.db
|
|
266
|
+
.query<RepoRow, [string, string]>('SELECT * FROM artifacts_repos WHERE namespace = ? AND name = ?')
|
|
267
|
+
.get(this.namespace, name)
|
|
268
|
+
if (!row) return false
|
|
269
|
+
this.db.run('DELETE FROM artifacts_tokens WHERE repo_id = ?', [row.id])
|
|
270
|
+
this.db.run('DELETE FROM artifacts_repos WHERE id = ?', [row.id])
|
|
271
|
+
const dir = this.repoDir(row.id)
|
|
272
|
+
if (existsSync(dir)) rmSync(dir, { recursive: true, force: true })
|
|
273
|
+
return true
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** @internal — also called by `ArtifactsRepo.fork()` */
|
|
277
|
+
async _forkRepo(source: RepoRow, newName: string, opts: ForkOpts = {}): Promise<CreateRepoResult> {
|
|
278
|
+
assertValidName(newName)
|
|
279
|
+
const existing = this.db
|
|
280
|
+
.query<RepoRow, [string, string]>('SELECT * FROM artifacts_repos WHERE namespace = ? AND name = ?')
|
|
281
|
+
.get(this.namespace, newName)
|
|
282
|
+
if (existing) {
|
|
283
|
+
throw new Error(`Artifacts repo "${newName}" already exists in namespace "${this.namespace}"`)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const id = randomUUIDv7()
|
|
287
|
+
const srcDir = this.repoDir(source.id)
|
|
288
|
+
const dstDir = this.repoDir(id)
|
|
289
|
+
|
|
290
|
+
const cloneArgs = ['clone', '--bare']
|
|
291
|
+
if (opts.defaultBranchOnly) cloneArgs.push('--single-branch', '--branch', source.default_branch)
|
|
292
|
+
cloneArgs.push(srcDir, dstDir)
|
|
293
|
+
|
|
294
|
+
const proc = Bun.spawn([this.gitBinary, ...cloneArgs], { stdout: 'pipe', stderr: 'pipe' })
|
|
295
|
+
const exit = await proc.exited
|
|
296
|
+
if (exit !== 0) {
|
|
297
|
+
const stderr = await new Response(proc.stderr).text()
|
|
298
|
+
try {
|
|
299
|
+
rmSync(dstDir, { recursive: true, force: true })
|
|
300
|
+
} catch {}
|
|
301
|
+
throw new Error(`git clone (fork) failed (${exit}): ${stderr}`)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const description = opts.description ?? null
|
|
305
|
+
const readOnly = opts.readOnly ? 1 : 0
|
|
306
|
+
this.db.run(
|
|
307
|
+
`INSERT INTO artifacts_repos (id, namespace, name, description, default_branch, read_only, forked_from, created_at)
|
|
308
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
309
|
+
[id, this.namespace, newName, description, source.default_branch, readOnly, source.id, Date.now()],
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
const token = this.issueToken(id, 'write')
|
|
313
|
+
return {
|
|
314
|
+
id,
|
|
315
|
+
name: newName,
|
|
316
|
+
description,
|
|
317
|
+
defaultBranch: source.default_branch,
|
|
318
|
+
remote: this.buildRemote(id),
|
|
319
|
+
token: token.plaintext,
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** @internal — called by `ArtifactsRepo.createToken()`, `ArtifactsBinding.create()`. */
|
|
324
|
+
issueToken(repoId: string, scope: ArtifactsTokenScope, ttlSeconds?: number): TokenResult {
|
|
325
|
+
const id = randomUUIDv7()
|
|
326
|
+
const plaintext = generateTokenString()
|
|
327
|
+
const createdAt = Date.now()
|
|
328
|
+
const expiresAt = ttlSeconds ? createdAt + ttlSeconds * 1000 : null
|
|
329
|
+
this.db.run(
|
|
330
|
+
`INSERT INTO artifacts_tokens (id, repo_id, plaintext, scope, expires_at, created_at)
|
|
331
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
332
|
+
[id, repoId, plaintext, scope, expiresAt, createdAt],
|
|
333
|
+
)
|
|
334
|
+
return {
|
|
335
|
+
id,
|
|
336
|
+
plaintext,
|
|
337
|
+
scope,
|
|
338
|
+
expiresAt: tsToIso(expiresAt),
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** @internal */
|
|
343
|
+
listTokens(repoId: string): TokenListResult {
|
|
344
|
+
const rows = this.db
|
|
345
|
+
.query<TokenRow, [string]>(
|
|
346
|
+
'SELECT * FROM artifacts_tokens WHERE repo_id = ? AND revoked_at IS NULL ORDER BY created_at ASC',
|
|
347
|
+
)
|
|
348
|
+
.all(repoId)
|
|
349
|
+
const tokens = rows.map(r => ({
|
|
350
|
+
id: r.id,
|
|
351
|
+
scope: r.scope as ArtifactsTokenScope,
|
|
352
|
+
expiresAt: tsToIso(r.expires_at),
|
|
353
|
+
createdAt: new Date(r.created_at).toISOString(),
|
|
354
|
+
}))
|
|
355
|
+
return { total: tokens.length, tokens }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** @internal */
|
|
359
|
+
revokeTokenById(repoId: string, tokenOrId: string): boolean {
|
|
360
|
+
// Accept either the plaintext token or the id
|
|
361
|
+
const now = Date.now()
|
|
362
|
+
const info = this.db.run(
|
|
363
|
+
'UPDATE artifacts_tokens SET revoked_at = ? WHERE repo_id = ? AND revoked_at IS NULL AND (id = ? OR plaintext = ?)',
|
|
364
|
+
[now, repoId, tokenOrId, tokenOrId],
|
|
365
|
+
)
|
|
366
|
+
return info.changes > 0
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export class ArtifactsRepo {
|
|
371
|
+
private readonly binding: ArtifactsBinding
|
|
372
|
+
private readonly row: RepoRow
|
|
373
|
+
readonly remote: string
|
|
374
|
+
|
|
375
|
+
constructor(binding: ArtifactsBinding, row: RepoRow, remote: string) {
|
|
376
|
+
this.binding = binding
|
|
377
|
+
this.row = row
|
|
378
|
+
this.remote = remote
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
get id(): string {
|
|
382
|
+
return this.row.id
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
get name(): string {
|
|
386
|
+
return this.row.name
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
get defaultBranch(): string {
|
|
390
|
+
return this.row.default_branch
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
get description(): string | null {
|
|
394
|
+
return this.row.description
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async createToken(scope: ArtifactsTokenScope = 'write', ttl?: number): Promise<TokenResult> {
|
|
398
|
+
return this.binding.issueToken(this.row.id, scope, ttl)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async listTokens(): Promise<TokenListResult> {
|
|
402
|
+
return this.binding.listTokens(this.row.id)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async revokeToken(tokenOrId: string): Promise<boolean> {
|
|
406
|
+
return this.binding.revokeTokenById(this.row.id, tokenOrId)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async fork(name: string, opts: ForkOpts = {}): Promise<CreateRepoResult> {
|
|
410
|
+
return this.binding._forkRepo(this.row, name, opts)
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ─── helpers ─────────────────────────────────────────────────────────
|
|
415
|
+
|
|
416
|
+
function generateTokenString(): string {
|
|
417
|
+
// 32 bytes → 64 hex chars
|
|
418
|
+
const bytes = crypto.getRandomValues(new Uint8Array(32))
|
|
419
|
+
return 'art_' + Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function initBareRepo(gitBinary: string, dir: string, defaultBranch: string): Promise<void> {
|
|
423
|
+
mkdirSync(dir, { recursive: true })
|
|
424
|
+
const proc = Bun.spawn(
|
|
425
|
+
[gitBinary, 'init', '--bare', '--initial-branch', defaultBranch, dir],
|
|
426
|
+
{ stdout: 'pipe', stderr: 'pipe' },
|
|
427
|
+
)
|
|
428
|
+
const exit = await proc.exited
|
|
429
|
+
if (exit !== 0) {
|
|
430
|
+
const stderr = await new Response(proc.stderr).text()
|
|
431
|
+
try {
|
|
432
|
+
rmSync(dir, { recursive: true, force: true })
|
|
433
|
+
} catch {}
|
|
434
|
+
throw new Error(`git init --bare failed (${exit}): ${stderr}`)
|
|
435
|
+
}
|
|
436
|
+
// Allow HTTP access without explicit "git config http.receivepack true"
|
|
437
|
+
const cfg = Bun.spawn(
|
|
438
|
+
[gitBinary, '-C', dir, 'config', 'http.receivepack', 'true'],
|
|
439
|
+
{ stdout: 'pipe', stderr: 'pipe' },
|
|
440
|
+
)
|
|
441
|
+
await cfg.exited
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function readDefaultBranch(gitBinary: string, dir: string): Promise<string | null> {
|
|
445
|
+
const proc = Bun.spawn(
|
|
446
|
+
[gitBinary, '-C', dir, 'symbolic-ref', '--short', 'HEAD'],
|
|
447
|
+
{ stdout: 'pipe', stderr: 'pipe' },
|
|
448
|
+
)
|
|
449
|
+
const exit = await proc.exited
|
|
450
|
+
if (exit !== 0) return null
|
|
451
|
+
const out = (await new Response(proc.stdout).text()).trim()
|
|
452
|
+
return out || null
|
|
453
|
+
}
|
|
@@ -161,6 +161,10 @@ export function buildWorkerEnv(
|
|
|
161
161
|
// SQLITE_BUSY instead of waiting.
|
|
162
162
|
db.run('PRAGMA busy_timeout=5000')
|
|
163
163
|
runMigrations(db)
|
|
164
|
+
// Expose this thread's DB handle so the fetch patch (plugin.ts) can serve the
|
|
165
|
+
// intercepted Analytics Engine SQL API from this DO worker's data dir.
|
|
166
|
+
const threadGlobals = globalThis as { __lopata_db?: Database }
|
|
167
|
+
threadGlobals.__lopata_db = db
|
|
164
168
|
|
|
165
169
|
const env: Record<string, unknown> = {}
|
|
166
170
|
|