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.
Files changed (38) hide show
  1. package/dist/types/bindings/ai-search.d.ts +44 -0
  2. package/dist/types/bindings/analytics-engine-sql.d.ts +107 -0
  3. package/dist/types/bindings/artifacts-git-http.d.ts +19 -0
  4. package/dist/types/bindings/artifacts.d.ts +108 -0
  5. package/dist/types/bindings/flagship.d.ts +37 -0
  6. package/dist/types/bindings/vpc-network.d.ts +22 -0
  7. package/dist/types/bindings/worker-loader-entry.d.ts +71 -0
  8. package/dist/types/bindings/worker-loader.d.ts +81 -0
  9. package/dist/types/config.d.ts +23 -0
  10. package/dist/types/env.d.ts +3 -1
  11. package/dist/types/generation-manager.d.ts +6 -0
  12. package/dist/types/tsconfig.tsbuildinfo +1 -1
  13. package/dist/types/vite-plugin/dev-server-plugin.d.ts +8 -0
  14. package/dist/types/worker-thread/executor.d.ts +2 -0
  15. package/dist/types/worker-thread/protocol.d.ts +2 -0
  16. package/dist/types/worker-thread/thread-env.d.ts +3 -1
  17. package/package.json +1 -1
  18. package/src/bindings/ai-search.ts +185 -0
  19. package/src/bindings/analytics-engine-sql.ts +1138 -0
  20. package/src/bindings/artifacts-git-http.ts +249 -0
  21. package/src/bindings/artifacts.ts +453 -0
  22. package/src/bindings/do-worker-env.ts +4 -0
  23. package/src/bindings/flagship.ts +117 -0
  24. package/src/bindings/vpc-network.ts +40 -0
  25. package/src/bindings/worker-loader-entry.ts +143 -0
  26. package/src/bindings/worker-loader.ts +325 -0
  27. package/src/cli/dev.ts +25 -3
  28. package/src/config.ts +20 -0
  29. package/src/db.ts +54 -0
  30. package/src/env.ts +71 -0
  31. package/src/generation-manager.ts +5 -1
  32. package/src/generation.ts +20 -3
  33. package/src/plugin.ts +37 -0
  34. package/src/vite-plugin/dev-server-plugin.ts +18 -2
  35. package/src/worker-thread/entry.ts +1 -0
  36. package/src/worker-thread/executor.ts +3 -0
  37. package/src/worker-thread/protocol.ts +2 -0
  38. 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