@springbrand/space 0.1.0-alpha.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.
@@ -0,0 +1,141 @@
1
+ // ─── Inspector wrapper module ──────────────────────────────────────────────
2
+ //
3
+ // The DB-viewer tab needs to read SQL from the user's App Durable Object,
4
+ // but the Facet's storage is fully isolated from the supervisor (SpaceDO).
5
+ // The only way to read it is via methods on the App class itself.
6
+ //
7
+ // Rather than asking the LLM to paste ~60 lines of inspector boilerplate
8
+ // into every app, we inject a small wrapper module into the dynamic
9
+ // worker. The wrapper:
10
+ // 1. imports the user's App class,
11
+ // 2. re-exports every other named export unchanged,
12
+ // 3. exports a subclass `App` (shadowing the user's) that extends the
13
+ // user's App and adds `__vibeInspectListTables` / `__vibeInspectRead`.
14
+ //
15
+ // SpaceDO loads this wrapper as the main module and `getDurableObjectClass
16
+ // ("App")` returns the subclass. The subclass shares the user's `ctx`,
17
+ // `ctx.storage`, and any state — so the inspector reads the same SQLite
18
+ // database the user's App writes to.
19
+
20
+ export const VIBE_APP_MODULE = "__vibe_app__.js"
21
+
22
+ /**
23
+ * Build the wrapper source. `userMainModule` is the key in `modules`
24
+ * holding the LLM's compiled main (which must `export class App
25
+ * extends DurableObject`).
26
+ */
27
+ export function buildInspectorWrapperSource(userMainModule: string): string {
28
+ return `import * as __vibeUserModule from ${JSON.stringify(userMainModule)}
29
+
30
+ // Re-export everything from the user's module first. We then shadow the
31
+ // \`App\` export below with the subclass.
32
+ export * from ${JSON.stringify(userMainModule)}
33
+
34
+ const __VIBE_UserApp = __vibeUserModule.App
35
+ if (typeof __VIBE_UserApp !== "function") {
36
+ throw new Error(
37
+ "Your main module must export a class named \`App\` extending DurableObject. " +
38
+ "See the cloudflare-bundler-apps skill for the required app structure."
39
+ )
40
+ }
41
+
42
+ const __VIBE_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
43
+ const __VIBE_MAX_PAGE_SIZE = 200
44
+
45
+ function __vibeIsSafeIdentifier(name) {
46
+ return typeof name === "string" && __VIBE_IDENT_RE.test(name)
47
+ }
48
+
49
+ function __vibeQuote(name) {
50
+ return '"' + String(name).replace(/"/g, '""') + '"'
51
+ }
52
+
53
+ export class App extends __VIBE_UserApp {
54
+ async __vibeInspectListTables() {
55
+ const sql = this.ctx.storage.sql
56
+ const tableRows = sql.exec(
57
+ "SELECT name FROM sqlite_master " +
58
+ "WHERE type = 'table' " +
59
+ " AND name NOT LIKE 'sqlite_%' " +
60
+ " AND name NOT LIKE '_cf_%' " +
61
+ "ORDER BY name"
62
+ ).toArray()
63
+ const out = []
64
+ for (const row of tableRows) {
65
+ const name = row.name
66
+ if (!__vibeIsSafeIdentifier(name)) continue
67
+ const columns = sql.exec(
68
+ "PRAGMA table_info(" + __vibeQuote(name) + ")"
69
+ ).toArray().map((c) => ({
70
+ name: c.name, type: c.type, notnull: c.notnull, pk: c.pk,
71
+ }))
72
+ let rowCount = 0
73
+ try {
74
+ const cnt = sql.exec(
75
+ "SELECT COUNT(*) AS c FROM " + __vibeQuote(name)
76
+ ).one()
77
+ rowCount = Number(cnt.c)
78
+ } catch {
79
+ rowCount = 0
80
+ }
81
+ out.push({ name, rowCount, columns })
82
+ }
83
+ return out
84
+ }
85
+
86
+ async __vibeInspectRead(table, opts) {
87
+ opts = opts || {}
88
+ if (!__vibeIsSafeIdentifier(table)) {
89
+ throw new Error("Invalid table name: " + table)
90
+ }
91
+ const sql = this.ctx.storage.sql
92
+ const exists = sql.exec(
93
+ "SELECT COUNT(*) AS c FROM sqlite_master WHERE type='table' AND name = ?",
94
+ table
95
+ ).one().c
96
+ if (!exists) throw new Error("Unknown table: " + table)
97
+ const columns = sql.exec(
98
+ "PRAGMA table_info(" + __vibeQuote(table) + ")"
99
+ ).toArray().map((c) => c.name)
100
+ const limit = Math.max(1, Math.min(__VIBE_MAX_PAGE_SIZE, opts.limit || 50))
101
+ const offset = Math.max(0, opts.offset || 0)
102
+ let orderClause = ""
103
+ if (opts.orderBy) {
104
+ if (!columns.includes(opts.orderBy)) {
105
+ throw new Error("Unknown column: " + opts.orderBy)
106
+ }
107
+ const dir = opts.orderDir === "desc" ? "DESC" : "ASC"
108
+ orderClause = " ORDER BY " + __vibeQuote(opts.orderBy) + " " + dir
109
+ }
110
+ const totalCount = Number(sql.exec(
111
+ "SELECT COUNT(*) AS c FROM " + __vibeQuote(table)
112
+ ).one().c)
113
+ const rows = sql.exec(
114
+ "SELECT * FROM " + __vibeQuote(table) + orderClause + " LIMIT ? OFFSET ?",
115
+ limit, offset
116
+ ).toArray()
117
+ return { columns, rows, totalCount }
118
+ }
119
+
120
+ async __vibeWipe() {
121
+ // Drop every user table. The next request recreates whatever
122
+ // CREATE TABLE IF NOT EXISTS the App runs on startup.
123
+ const sql = this.ctx.storage.sql
124
+ const tableRows = sql.exec(
125
+ "SELECT name FROM sqlite_master " +
126
+ "WHERE type = 'table' " +
127
+ " AND name NOT LIKE 'sqlite_%' " +
128
+ " AND name NOT LIKE '_cf_%'"
129
+ ).toArray()
130
+ this.ctx.storage.transactionSync(() => {
131
+ for (const row of tableRows) {
132
+ const name = row.name
133
+ if (!__vibeIsSafeIdentifier(name)) continue
134
+ sql.exec("DROP TABLE IF EXISTS " + __vibeQuote(name))
135
+ }
136
+ })
137
+ return { ok: true }
138
+ }
139
+ }
140
+ `
141
+ }
@@ -0,0 +1,38 @@
1
+ // Preview response security.
2
+ //
3
+ // All Think previews share one browser origin (path-based on the preview
4
+ // domain). A generated app must not be able to emit response headers that grant
5
+ // it authority over that shared origin. Most importantly Service-Worker-Allowed
6
+ // lets a service worker widen its scope to "/" - allowing one app's SW to
7
+ // intercept every other user's previews. We also drop Clear-Site-Data (could
8
+ // wipe other users' preview cookies) and Service-Worker-Navigation-Preload.
9
+
10
+ export const STRIPPED_PREVIEW_HEADERS = [
11
+ "Service-Worker-Allowed",
12
+ "Service-Worker-Navigation-Preload",
13
+ "Clear-Site-Data",
14
+ ] as const
15
+
16
+ export function stripPreviewSecurityHeaders(response: Response): Response {
17
+ // WebSocket upgrade responses cannot be reconstructed (no body, immutable
18
+ // headers); they never carry these headers, so pass them through untouched.
19
+ if (response.status === 101 || response.headers.get("upgrade")?.toLowerCase() === "websocket") {
20
+ return response
21
+ }
22
+
23
+ const headers = new Headers(response.headers)
24
+ let changed = false
25
+ for (const name of STRIPPED_PREVIEW_HEADERS) {
26
+ if (headers.has(name)) {
27
+ headers.delete(name)
28
+ changed = true
29
+ }
30
+ }
31
+ if (!changed) return response
32
+
33
+ return new Response(response.body, {
34
+ status: response.status,
35
+ statusText: response.statusText,
36
+ headers,
37
+ })
38
+ }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * The production Workspace contract a Space must satisfy.
3
+ *
4
+ * Upstream SpaceDO shipped the file surface VibeSDK's Think tools needed:
5
+ * text reads that throw, string-only writes and unpaginated listings. A complete
6
+ * Agent Runtime needs binary writes for XLSX and images, append,
7
+ * conditional writes for Sandbox publishing, quota, and the full set of file
8
+ * operations — so this module states the contract once and the Durable Object
9
+ * implements it verbatim.
10
+ *
11
+ * The names and semantics deliberately match `WorkspacePort` in
12
+ * `@springbrand/agent-runtime`: one file capability, not two competing ones.
13
+ */
14
+
15
+ /** Metadata for a single Space directory entry. */
16
+ export interface SpaceFileInfo {
17
+ path: string;
18
+ name: string;
19
+ type: "file" | "directory" | "symlink";
20
+ mimeType: string;
21
+ size: number;
22
+ createdAt: number;
23
+ updatedAt: number;
24
+ target?: string;
25
+ }
26
+
27
+ /**
28
+ * A file's identity at a point in time.
29
+ *
30
+ * Size plus modification time, because that is what both the Space backends and
31
+ * the Sandbox adapter can observe without hashing every published byte.
32
+ */
33
+ export interface SpaceFileVersion {
34
+ updatedAt: number;
35
+ size: number;
36
+ }
37
+
38
+ export type SpaceConditionalWriteResult =
39
+ | { written: true; version: SpaceFileVersion }
40
+ | { written: false; reason: "conflict"; current: SpaceFileVersion | null };
41
+
42
+ export interface SpaceUsage {
43
+ fileCount: number;
44
+ directoryCount: number;
45
+ totalBytes: number;
46
+ }
47
+
48
+ export interface SpaceQuota {
49
+ maxFiles: number | null;
50
+ maxTotalBytes: number | null;
51
+ maxFileBytes: number | null;
52
+ }
53
+
54
+ /** One Space Git commit, as the host's revision history reads it. */
55
+ export interface SpaceCommit {
56
+ revision: string;
57
+ treeHash: string;
58
+ reason: string;
59
+ createdAt: number;
60
+ }
61
+
62
+ export interface SpaceCommitManifest {
63
+ revision: string;
64
+ treeHash: string;
65
+ files: readonly { path: string; size: number }[];
66
+ }
67
+
68
+ /**
69
+ * Paths a Space owns and callers may never write.
70
+ *
71
+ * `.git` and `.afs` are the Git object store and the Artifacts overlay's
72
+ * bookkeeping; a caller that could write them could rewrite history.
73
+ */
74
+ export const SPACE_RESERVED_PREFIXES = ["/.git", "/.afs"] as const;
75
+
76
+ /**
77
+ * Paths that persist in the Space but never enter a Git commit.
78
+ *
79
+ * A Space holds working data as well as deliverables: scratch files, installed
80
+ * dependencies and hydrated attachments are large, regenerable and worthless in
81
+ * history. Committing them would put multi-megabyte blobs into every revision.
82
+ */
83
+ export const SPACE_UNVERSIONED_PREFIXES = [
84
+ "/.git",
85
+ "/.afs",
86
+ "/tmp",
87
+ "/node_modules",
88
+ ] as const;
89
+
90
+ const DEFAULT_QUOTA: SpaceQuota = Object.freeze({
91
+ maxFiles: 20_000,
92
+ maxTotalBytes: 2 * 1024 * 1024 * 1024,
93
+ maxFileBytes: 256 * 1024 * 1024,
94
+ });
95
+
96
+ export function defaultSpaceQuota(): SpaceQuota {
97
+ return { ...DEFAULT_QUOTA };
98
+ }
99
+
100
+ export function isReservedSpacePath(path: string): boolean {
101
+ return SPACE_RESERVED_PREFIXES.some(
102
+ (prefix) => path === prefix || path.startsWith(`${prefix}/`),
103
+ );
104
+ }
105
+
106
+ export function isUnversionedSpacePath(path: string): boolean {
107
+ return SPACE_UNVERSIONED_PREFIXES.some(
108
+ (prefix) => path === prefix || path.startsWith(`${prefix}/`),
109
+ );
110
+ }
111
+
112
+ /**
113
+ * Reduce a caller-supplied path to a canonical absolute Space path.
114
+ *
115
+ * Traversal is rejected rather than resolved: a caller writing `../` means
116
+ * either a bug or an escape attempt, and silently clamping it to the root turns
117
+ * both into a file quietly landing somewhere the caller did not name.
118
+ */
119
+ export function normalizeSpacePath(path: string): string {
120
+ if (typeof path !== "string") throw new Error("Space path must be a string");
121
+ const value = path.trim().replaceAll("\\", "/");
122
+ const parts = value.split("/").filter(Boolean);
123
+ if (parts.some((part) => part === "." || part === "..")) {
124
+ throw new Error("Space path traversal is not allowed");
125
+ }
126
+ return `/${parts.join("/")}`;
127
+ }
128
+
129
+ /** As `normalizeSpacePath`, and refuses paths the Space owns. */
130
+ export function normalizeWritableSpacePath(path: string): string {
131
+ const normalized = normalizeSpacePath(path);
132
+ if (isReservedSpacePath(normalized)) {
133
+ throw new Error("Space metadata is not writable");
134
+ }
135
+ if (normalized === "/") throw new Error("The Space root is not a file");
136
+ return normalized;
137
+ }
138
+
139
+ /**
140
+ * Normalize a glob pattern without destroying its wildcards.
141
+ *
142
+ * `normalizeSpacePath` would reject `**` never, but it also collapses the empty
143
+ * segments a caller may rely on; patterns are therefore only anchored, and the
144
+ * traversal check is kept.
145
+ */
146
+ export function normalizeSpacePattern(pattern: string): string {
147
+ if (typeof pattern !== "string") throw new Error("Space pattern must be a string");
148
+ const value = pattern.trim().replaceAll("\\", "/");
149
+ const parts = value.split("/").filter(Boolean);
150
+ if (parts.some((part) => part === "." || part === "..")) {
151
+ throw new Error("Space path traversal is not allowed");
152
+ }
153
+ return `/${parts.join("/")}`;
154
+ }
155
+
156
+ export function toBytes(data: Uint8Array | ArrayBuffer): Uint8Array {
157
+ return data instanceof Uint8Array ? data : new Uint8Array(data);
158
+ }
159
+
160
+ export function versionOf(info: SpaceFileInfo | null): SpaceFileVersion | null {
161
+ return info?.type === "file" ? { updatedAt: info.updatedAt, size: info.size } : null;
162
+ }
163
+
164
+ /**
165
+ * The file capability the Agent Runtime consumes, method-for-method.
166
+ *
167
+ * Declared here so a compile error — not a production 500 — is what happens
168
+ * when the Durable Object and the runtime contract drift apart.
169
+ */
170
+ export interface SpaceWorkspacePort {
171
+ readFile(path: string): Promise<string | null>;
172
+ readFileBytes(path: string): Promise<Uint8Array | null>;
173
+ writeFile(path: string, content: string, mimeType?: string): Promise<void>;
174
+ writeFileBytes(
175
+ path: string,
176
+ data: Uint8Array | ArrayBuffer,
177
+ mimeType?: string,
178
+ ): Promise<void>;
179
+ appendFile(path: string, content: string, mimeType?: string): Promise<void>;
180
+ exists(path: string): Promise<boolean>;
181
+ stat(path: string): Promise<SpaceFileInfo | null>;
182
+ lstat(path: string): Promise<SpaceFileInfo | null>;
183
+ mkdir(path: string, opts?: { recursive?: boolean }): Promise<void>;
184
+ readDir(
185
+ dir?: string,
186
+ opts?: { limit?: number; offset?: number },
187
+ ): Promise<SpaceFileInfo[]>;
188
+ rm(path: string, opts?: { recursive?: boolean; force?: boolean }): Promise<void>;
189
+ cp(src: string, dest: string, opts?: { recursive?: boolean }): Promise<void>;
190
+ mv(src: string, dest: string, opts?: { recursive?: boolean }): Promise<void>;
191
+ symlink(target: string, linkPath: string): Promise<void>;
192
+ readlink(path: string): Promise<string>;
193
+ glob(pattern: string): Promise<SpaceFileInfo[]>;
194
+ }
195
+
196
+ /**
197
+ * Everything a Space does that is not a file operation.
198
+ *
199
+ * Git, deployment, preview and the App database live here rather than beside
200
+ * the file methods so a caller holding a Workspace cannot reach them by
201
+ * accident, and so the runtime's contract stays exactly the file surface.
202
+ */
203
+ export interface SpaceControlPort {
204
+ writeFileBytesIfUnchanged(
205
+ path: string,
206
+ data: Uint8Array,
207
+ mimeType: string,
208
+ expected: SpaceFileVersion | null,
209
+ ): Promise<SpaceConditionalWriteResult>;
210
+ getUsage(): Promise<SpaceUsage>;
211
+ getQuota(): Promise<SpaceQuota>;
212
+ commit(reason: string, prefix?: string): Promise<{
213
+ revision: string | null;
214
+ treeHash: string | null;
215
+ changed: boolean;
216
+ }>;
217
+ listCommits(prefix: string, limit?: number): Promise<readonly SpaceCommit[]>;
218
+ currentCommit(prefix: string): Promise<SpaceCommit | null>;
219
+ commitManifest(prefix: string, revision: string): Promise<SpaceCommitManifest>;
220
+ readCommitFile(prefix: string, revision: string, path: string): Promise<Uint8Array>;
221
+ restorePathsFromCommit(
222
+ prefix: string,
223
+ revision: string,
224
+ reason: string,
225
+ ): Promise<{ revision: string; treeHash: string }>;
226
+ destroySpace(): Promise<void>;
227
+ }
228
+
229
+ /**
230
+ * Turning a Space into a running application.
231
+ *
232
+ * Separate from the file and history surface because most Spaces never use it:
233
+ * a Space holding a spreadsheet and a script is complete without any of this,
234
+ * and only an explicit deploy brings an app into existence.
235
+ */
236
+ export interface SpaceAppPort {
237
+ deploy(branch: string, appRoot?: string): Promise<unknown>;
238
+ undeploy(branch: string): Promise<unknown>;
239
+ listDeployments(): Promise<unknown>;
240
+ getDeployment(branch: string): Promise<unknown>;
241
+ servePreview(branch: string, request: Request): Promise<Response>;
242
+ listAppTables(branch: string): Promise<unknown>;
243
+ queryAppTable(branch: string, table: string, opts?: unknown): Promise<unknown>;
244
+ wipeAppDatabase(branch: string): Promise<{ ok: true }>;
245
+ }
@@ -0,0 +1,188 @@
1
+ // ─── Wrangler Config Parser ──────────────────────────────────────────────────
2
+ // Parses the child project's wrangler.toml / wrangler.json / wrangler.jsonc
3
+ // to extract deployment-relevant configuration.
4
+
5
+ export interface ParsedDurableObjectBinding {
6
+ name: string
7
+ className: string
8
+ }
9
+
10
+ export interface ParsedWranglerConfig {
11
+ main?: string
12
+ compatibilityDate?: string
13
+ compatibilityFlags?: string[]
14
+ assets?: {
15
+ directory?: string
16
+ binding?: string
17
+ htmlHandling?: "auto-trailing-slash" | "force-trailing-slash" | "drop-trailing-slash" | "none"
18
+ notFoundHandling?: "single-page-application" | "404-page" | "none"
19
+ }
20
+ /**
21
+ * Durable Object bindings declared by the child project (Tier-2 apps).
22
+ * Each binding must reference a SQLite-backed class exported from `main`.
23
+ * `script_name`-style externals and KV-backed (`new_classes`) DOs are
24
+ * rejected upstream of the parser so we keep this shape minimal.
25
+ */
26
+ durableObjects?: ParsedDurableObjectBinding[]
27
+ }
28
+
29
+ export class WranglerConfigError extends Error {
30
+ constructor(message: string) {
31
+ super(message)
32
+ this.name = "WranglerConfigError"
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Try to parse wrangler config from a set of project files.
38
+ * Checks wrangler.json, wrangler.jsonc, then wrangler.toml.
39
+ */
40
+ export function parseWranglerConfig(files: Record<string, string>): ParsedWranglerConfig {
41
+ const jsonContent = files["wrangler.json"] ?? files["wrangler.jsonc"]
42
+ if (jsonContent) return parseJsonConfig(jsonContent)
43
+
44
+ const tomlContent = files["wrangler.toml"]
45
+ if (tomlContent) return parseTomlConfig(tomlContent)
46
+
47
+ return {}
48
+ }
49
+
50
+ function parseJsonConfig(content: string): ParsedWranglerConfig {
51
+ // Strip single-line comments for jsonc support
52
+ const stripped = content.replace(/^\s*\/\/.*$/gm, "")
53
+ const raw = JSON.parse(stripped)
54
+
55
+ const cfg: ParsedWranglerConfig = {}
56
+ if (typeof raw.main === "string") cfg.main = raw.main
57
+ if (typeof raw.compatibility_date === "string") cfg.compatibilityDate = raw.compatibility_date
58
+ if (Array.isArray(raw.compatibility_flags)) cfg.compatibilityFlags = raw.compatibility_flags
59
+
60
+ if (raw.assets && typeof raw.assets === "object") {
61
+ cfg.assets = {}
62
+ if (typeof raw.assets.directory === "string") cfg.assets.directory = raw.assets.directory
63
+ if (typeof raw.assets.binding === "string") cfg.assets.binding = raw.assets.binding
64
+ if (typeof raw.assets.html_handling === "string") cfg.assets.htmlHandling = raw.assets.html_handling
65
+ if (typeof raw.assets.not_found_handling === "string") cfg.assets.notFoundHandling = raw.assets.not_found_handling
66
+ }
67
+
68
+ // ── Durable Objects (Tier 2) ───────────────────────────────────
69
+ const dos = parseDurableObjectsJson(raw)
70
+ if (dos && dos.length > 0) cfg.durableObjects = dos
71
+
72
+ return cfg
73
+ }
74
+
75
+ function parseDurableObjectsJson(raw: any): ParsedDurableObjectBinding[] | undefined {
76
+ const block = raw?.durable_objects
77
+ if (!block || typeof block !== "object") return undefined
78
+ const bindings = block.bindings
79
+ if (!Array.isArray(bindings) || bindings.length === 0) return undefined
80
+
81
+ // Collect the set of class names allowed by migrations.
82
+ const sqliteClasses = new Set<string>()
83
+ const nonSqliteClasses = new Set<string>()
84
+ const migrations = Array.isArray(raw.migrations) ? raw.migrations : []
85
+ for (const m of migrations) {
86
+ if (!m || typeof m !== "object") continue
87
+ if (Array.isArray(m.new_sqlite_classes)) {
88
+ for (const c of m.new_sqlite_classes) {
89
+ if (typeof c === "string") sqliteClasses.add(c)
90
+ }
91
+ }
92
+ if (Array.isArray(m.new_classes)) {
93
+ for (const c of m.new_classes) {
94
+ if (typeof c === "string") nonSqliteClasses.add(c)
95
+ }
96
+ }
97
+ }
98
+
99
+ const result: ParsedDurableObjectBinding[] = []
100
+ for (const b of bindings) {
101
+ if (!b || typeof b !== "object") continue
102
+ if (typeof b.script_name === "string") {
103
+ throw new WranglerConfigError(
104
+ `Durable Object binding "${b.name ?? "?"}" uses script_name. ` +
105
+ `Generated apps cannot reference Durable Objects from other scripts.`,
106
+ )
107
+ }
108
+ const name = typeof b.name === "string" ? b.name : null
109
+ const className = typeof b.class_name === "string" ? b.class_name : null
110
+ if (!name || !className) {
111
+ throw new WranglerConfigError(
112
+ `Durable Object binding must have both "name" and "class_name".`,
113
+ )
114
+ }
115
+ if (nonSqliteClasses.has(className)) {
116
+ throw new WranglerConfigError(
117
+ `Durable Object class "${className}" uses new_classes (KV storage). ` +
118
+ `Generated apps must declare it under new_sqlite_classes instead.`,
119
+ )
120
+ }
121
+ if (!sqliteClasses.has(className)) {
122
+ throw new WranglerConfigError(
123
+ `Durable Object class "${className}" is not declared in migrations.new_sqlite_classes. ` +
124
+ `Add a migration tag with new_sqlite_classes: ["${className}"].`,
125
+ )
126
+ }
127
+ result.push({ name, className })
128
+ }
129
+
130
+ return result
131
+ }
132
+
133
+ function parseTomlConfig(content: string): ParsedWranglerConfig {
134
+ const cfg: ParsedWranglerConfig = {}
135
+
136
+ // Top-level fields (before any [section])
137
+ cfg.main = extractTomlString(content, "main", true)
138
+ cfg.compatibilityDate = extractTomlString(content, "compatibility_date", true)
139
+
140
+ const flags = extractTomlArray(content, "compatibility_flags", true)
141
+ if (flags) cfg.compatibilityFlags = flags
142
+
143
+ // [assets] section
144
+ const assetsSection = extractTomlSection(content, "assets")
145
+ if (assetsSection) {
146
+ cfg.assets = {}
147
+ cfg.assets.directory = extractTomlString(assetsSection, "directory")
148
+ cfg.assets.binding = extractTomlString(assetsSection, "binding")
149
+ cfg.assets.htmlHandling = extractTomlString(assetsSection, "html_handling") as ParsedWranglerConfig["assets"] extends { htmlHandling?: infer T } ? T : never
150
+ cfg.assets.notFoundHandling = extractTomlString(assetsSection, "not_found_handling") as ParsedWranglerConfig["assets"] extends { notFoundHandling?: infer T } ? T : never
151
+ }
152
+
153
+ // TOML DO parsing is intentionally not supported; the space skill
154
+ // tells the LLM to use wrangler.json (the file the bundler emits). If
155
+ // a TOML config declares DOs, we surface a clear error so the user
156
+ // knows to switch to JSON.
157
+ if (/\[\[durable_objects\.bindings\]\]/.test(content)) {
158
+ throw new WranglerConfigError(
159
+ "Durable Object bindings in wrangler.toml are not supported. " +
160
+ "Use wrangler.json instead.",
161
+ )
162
+ }
163
+
164
+ return cfg
165
+ }
166
+
167
+ // ─── TOML Helpers (minimal, field-specific) ──────────────────────────────────
168
+
169
+ function extractTomlSection(content: string, name: string): string | undefined {
170
+ const pattern = new RegExp(`^\\[${name}\\]\\s*\\n((?:(?!^\\[)[^\\n]*\\n?)*)`, "m")
171
+ const match = content.match(pattern)
172
+ return match?.[1]
173
+ }
174
+
175
+ function extractTomlString(content: string, key: string, topLevelOnly?: boolean): string | undefined {
176
+ // If topLevelOnly, only match before the first [section]
177
+ const scope = topLevelOnly ? content.split(/^\[/m)[0] : content
178
+ const pattern = new RegExp(`^${key}\\s*=\\s*"([^"]*)"`, "m")
179
+ return scope.match(pattern)?.[1]
180
+ }
181
+
182
+ function extractTomlArray(content: string, key: string, topLevelOnly?: boolean): string[] | undefined {
183
+ const scope = topLevelOnly ? content.split(/^\[/m)[0] : content
184
+ const pattern = new RegExp(`^${key}\\s*=\\s*\\[([^\\]]*)\\]`, "m")
185
+ const match = scope.match(pattern)?.[1]
186
+ if (!match) return undefined
187
+ return match.split(",").map(s => s.trim().replace(/^"|"$/g, "")).filter(Boolean)
188
+ }