lopata 0.20.0 → 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/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/artifacts-git-http.ts +249 -0
- package/src/bindings/artifacts.ts +453 -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/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 +68 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite'
|
|
2
|
+
|
|
3
|
+
export type FlagType = 'boolean' | 'string' | 'number' | 'object'
|
|
4
|
+
|
|
5
|
+
export interface FlagDetails<T> {
|
|
6
|
+
value: T
|
|
7
|
+
variant?: string
|
|
8
|
+
reason: 'STATIC' | 'DEFAULT' | 'ERROR' | 'TARGETING_MATCH'
|
|
9
|
+
errorCode?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type EvaluationContext = Record<string, unknown> | undefined
|
|
13
|
+
|
|
14
|
+
interface FlagRow {
|
|
15
|
+
type: string
|
|
16
|
+
value: string
|
|
17
|
+
variant: string | null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Local implementation of the Cloudflare Flagship feature-flag binding.
|
|
22
|
+
* Flag values are stored in SQLite (`flagship_flags` table). When a flag is
|
|
23
|
+
* not found, the caller's `defaultValue` is returned with reason `DEFAULT`.
|
|
24
|
+
* Local dev does not implement targeting rules — the `context` argument is
|
|
25
|
+
* accepted for API compatibility but ignored during evaluation.
|
|
26
|
+
*/
|
|
27
|
+
export class FlagshipBinding {
|
|
28
|
+
private db: Database
|
|
29
|
+
private appId: string
|
|
30
|
+
|
|
31
|
+
constructor(db: Database, appId: string) {
|
|
32
|
+
this.db = db
|
|
33
|
+
this.appId = appId
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async getBooleanValue(key: string, defaultValue: boolean, context?: EvaluationContext): Promise<boolean> {
|
|
37
|
+
return (await this.getBooleanValueDetails(key, defaultValue, context)).value
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async getStringValue(key: string, defaultValue: string, context?: EvaluationContext): Promise<string> {
|
|
41
|
+
return (await this.getStringValueDetails(key, defaultValue, context)).value
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async getNumberValue(key: string, defaultValue: number, context?: EvaluationContext): Promise<number> {
|
|
45
|
+
return (await this.getNumberValueDetails(key, defaultValue, context)).value
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async getObjectValue<T>(key: string, defaultValue: T, context?: EvaluationContext): Promise<T> {
|
|
49
|
+
return (await this.getObjectValueDetails(key, defaultValue, context)).value
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async getBooleanValueDetails(key: string, defaultValue: boolean, _context?: EvaluationContext): Promise<FlagDetails<boolean>> {
|
|
53
|
+
return this.evaluate('boolean', key, defaultValue, parseBoolean)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async getStringValueDetails(key: string, defaultValue: string, _context?: EvaluationContext): Promise<FlagDetails<string>> {
|
|
57
|
+
return this.evaluate('string', key, defaultValue, (v) => v)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async getNumberValueDetails(key: string, defaultValue: number, _context?: EvaluationContext): Promise<FlagDetails<number>> {
|
|
61
|
+
return this.evaluate('number', key, defaultValue, (v) => Number(v))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async getObjectValueDetails<T>(key: string, defaultValue: T, _context?: EvaluationContext): Promise<FlagDetails<T>> {
|
|
65
|
+
return this.evaluate('object', key, defaultValue, (v) => JSON.parse(v) as T)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private evaluate<T>(expectedType: FlagType, key: string, defaultValue: T, parse: (raw: string) => T): FlagDetails<T> {
|
|
69
|
+
const row = this.db
|
|
70
|
+
.query<FlagRow, [string, string]>('SELECT type, value, variant FROM flagship_flags WHERE app_id = ? AND flag_key = ?')
|
|
71
|
+
.get(this.appId, key)
|
|
72
|
+
if (!row) {
|
|
73
|
+
return { value: defaultValue, reason: 'DEFAULT' }
|
|
74
|
+
}
|
|
75
|
+
if (row.type !== expectedType) {
|
|
76
|
+
return { value: defaultValue, reason: 'ERROR', errorCode: 'TYPE_MISMATCH' }
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
const parsed = parse(row.value)
|
|
80
|
+
return { value: parsed, variant: row.variant ?? undefined, reason: 'STATIC' }
|
|
81
|
+
} catch {
|
|
82
|
+
return { value: defaultValue, reason: 'ERROR', errorCode: 'PARSE_ERROR' }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseBoolean(raw: string): boolean {
|
|
88
|
+
const v = raw.toLowerCase()
|
|
89
|
+
if (v === 'true' || v === '1') return true
|
|
90
|
+
if (v === 'false' || v === '0') return false
|
|
91
|
+
throw new Error(`invalid boolean flag value: ${raw}`)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Programmatic helper to seed / override a flag value in the local store.
|
|
96
|
+
* Used by tests and (later) the dashboard.
|
|
97
|
+
*/
|
|
98
|
+
export function setFlagValue(
|
|
99
|
+
db: Database,
|
|
100
|
+
appId: string,
|
|
101
|
+
key: string,
|
|
102
|
+
type: FlagType,
|
|
103
|
+
value: unknown,
|
|
104
|
+
variant?: string,
|
|
105
|
+
): void {
|
|
106
|
+
const raw = type === 'object' ? JSON.stringify(value) : String(value)
|
|
107
|
+
db.run(
|
|
108
|
+
`INSERT INTO flagship_flags (app_id, flag_key, type, value, variant, updated_at)
|
|
109
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
110
|
+
ON CONFLICT(app_id, flag_key) DO UPDATE SET type = excluded.type, value = excluded.value, variant = excluded.variant, updated_at = excluded.updated_at`,
|
|
111
|
+
[appId, key, type, raw, variant ?? null, Date.now()],
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function deleteFlag(db: Database, appId: string, key: string): void {
|
|
116
|
+
db.run('DELETE FROM flagship_flags WHERE app_id = ? AND flag_key = ?', [appId, key])
|
|
117
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local implementation of the Cloudflare VPC Networks binding (`vpc_networks`).
|
|
3
|
+
*
|
|
4
|
+
* On Cloudflare, this binding exposes a Fetcher-like `fetch()` method that
|
|
5
|
+
* routes the request through the bound tunnel or Cloudflare Mesh. The URL
|
|
6
|
+
* passed to `fetch()` determines the destination host/port.
|
|
7
|
+
*
|
|
8
|
+
* In local dev there is no tunnel overlay network, so we pass the request
|
|
9
|
+
* straight to the host system's stack — the caller is expected to arrange
|
|
10
|
+
* for the destination to be reachable locally (e.g. a dev service on a
|
|
11
|
+
* known port, or a VPN/WireGuard tunnel on the host).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface VpcNetworkConfig {
|
|
15
|
+
networkId: string
|
|
16
|
+
bindingName: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class VpcNetworkBinding {
|
|
20
|
+
private config: VpcNetworkConfig
|
|
21
|
+
|
|
22
|
+
constructor(config: VpcNetworkConfig) {
|
|
23
|
+
this.config = config
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
get networkId(): string {
|
|
27
|
+
return this.config.networkId
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async fetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {
|
|
31
|
+
const request = input instanceof Request ? new Request(input, init) : new Request(input.toString(), init)
|
|
32
|
+
const url = new URL(request.url)
|
|
33
|
+
if (!url.host) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`VPC Network binding "${this.config.bindingName}" requires an absolute URL (with host), got: ${request.url}`,
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
return fetch(request)
|
|
39
|
+
}
|
|
40
|
+
}
|