diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d718ef0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,24 @@ +# bool-sdk — agent notes + +## This repo is PUBLIC (and published to npm) + +Everything here ships to the world: source, comments, README, CHANGELOG, +commit messages, PR titles/bodies. Write accordingly. + +## Voice + +Describe the SDK on its own terms — what an API does and why it's shaped that +way. Keep comments, docs, the README, the CHANGELOG, commit messages, and PRs +self-contained: no references to other products or frameworks as the +explanation for a design (a reader shouldn't need outside context to +understand ours). + +## Working here + +- `bun install`, `bun test` (hermetic — fetch/fs stubbed or temp dirs), + `bun run typecheck`, `bun run build` (emits `dist/`, ESM + `.d.ts`). +- Add tests in the same change as the code. +- The gateway wire paths (`/_bool/v1/*`) are append-only; keep this SDK in + sync with the gateway routes in the Bool platform repo (`lib/gateway/`). +- Semver discipline is load-bearing: generated apps install from a caret + range on every sandbox boot, so a breaking change requires a major bump. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8887de2..2a290c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,80 @@ # Changelog +## 0.2.0-next.16 + +- `bool create` no longer requires a name — a bare `bool create` generates a + friendly one (e.g. `swift-otter-42`) and scaffolds into a matching folder. + Pass a name to override. Combined with the default API URL (or `BOOL_API_URL`), + `bool create` alone stands up a new todo app + project. + +## 0.2.0-next.15 + +- `bool create` now aborts (exit 1) if the entity push fails, instead of + deploying an app whose data model was never created. It prints how to finish + (`bool entities push` + `bool deploy`) once the cause is fixed. +- The scaffolded todo app shows the real error message instead of + "[object Object]" — bool-sdk throws the raw (often non-Error) error, so the + template now extracts `.message` from it. + +## 0.2.0-next.14 + +- Fix `bool create`: the scaffolded app now lists `@supabase/supabase-js` + (a bool-sdk peer dependency) in its `package.json`, so the deploy/cloud build + can resolve it — previously `vite build` failed with "Rollup failed to resolve + import @supabase/supabase-js". Verified with a real `npm install && vite build`. + + Note: `bool create` / `bool entities push` also need the platform's + `POST /api/projects/[id]/entities` endpoint (added in codehs/bool#488). Without + it the entity push returns HTTP 405. + +## 0.2.0-next.13 + +- New `bool create [--path ] [--deploy]` — scaffold a new Bool + project and a working todo-list app in one command. Creates the project + (`POST /api/projects`), writes a self-contained Vite + React todo app wired to + the project through `bool-sdk`, links it (`bool.config.json` + `.env.bool` + + types), and declares a public `todos` entity so the deployed app works with no + sign-in. `--deploy` publishes it immediately. + +## 0.2.0-next.12 + +- CLI: fail with a clear message instead of crashing when the API returns a + non-JSON `2xx` response. This happens when `--api-url` points at a host that + serves the HTML app shell (e.g. the Bool API isn't deployed there yet) — the + `link`, `entities`, and `entities pull` commands previously threw an + unhandled `TypeError` (`Cannot read properties of null`). They now report + `expected a JSON response … — check --api-url` and exit 1. + +## 0.2.0-next.11 + +Local development: use a Bool project as a managed backend from your own +machine, and publish back to Bool — without leaving your editor. + +- `createBoolClient({ ..., apiKey })` — a Bool data API key (`boolsk_` project + admin key, or a `boolk_` end-user key) is sent as the `api_key` header on + every gateway call (db, users, ai), so the client now works from anywhere: + Node scripts, a local Vite app, CI. Without `apiKey`, behavior is unchanged. +- New CLI (`npx bool-sdk `, zero dependencies): + - `link --project ` — connects a local folder to a Bool project. Writes + `bool.config.json` (public connection config), puts the project's admin + data key in `.env.bool` (gitignored; owner only), and pulls entity types. + - `types` — regenerates `bool/types.d.ts` from the project's entity schemas, + so `bool.entities.` is fully typed locally. + - `entities` — prints the project's declared entities + fields. + - `entities pull` / `entities push` — round-trip the entity schema files + (`bool/entities/*.jsonc`) between the project and disk: pull writes them + verbatim, push declares every local file on the project (additive + migrations server-side; per-file results and warnings reported). + - `deploy` — zips the app source (node_modules/.git/env files excluded) and + publishes it on Bool via the drop pipeline: Bool builds in the cloud and + the project URL stays stable. + - Platform calls authenticate with a personal access token (`--token` or + `BOOL_TOKEN`). + +Requires the local-dev endpoints in the Bool platform repo +(`/api/projects/[id]/connection`, `/api/projects/[id]/entities/types`, +`POST /api/drops`). + ## 0.2.0-next.10 Adds `bool.ai` — the AI battery. A deployed app can call a model with NO API key @@ -40,7 +115,7 @@ already-created app on the stable `^0.1.0` range too. ## 0.2.0-next.8 -Adds per-user API keys (Base44 convention): the gateway's `/users/me` lazily +Adds per-user API keys: the gateway's `/users/me` lazily mints and returns a personal `api_key` for the signed-in end user. - `BoolUser.apiKey?: string` — typed access to the key. @@ -53,7 +128,7 @@ change that accepts `api_key` and stamps `sub` accordingly. ## 0.2.0-next.7 -- **Entities pagination cap raised 1000 → 5000, matching Base44.** `list` and +- **Entities pagination cap raised 1000 → 5000.** `list` and `filter` still page (50 rows by default) but now allow up to 5000 rows per call. A `limit` above the cap **throws** instead of silently truncating, so over-large reads fail loudly rather than returning a partial result the caller @@ -72,7 +147,7 @@ app renders its sign-in screen rather than a blank page. Adds a regression test. ## 0.2.0 -Adds the **entities data layer** — a Base44-parity data API over the gateway so +Adds the **entities data layer** — a high-level data API over the gateway so apps read/write data without touching Supabase, SQL, or credentials directly: ```ts @@ -82,7 +157,7 @@ await bool.entities.todos.update(one.id, { done: true }); await bool.entities.todos.filter({ status: "active", count: { $gte: 10 } }); ``` -`bool.entities.` mirrors Base44's entity surface one-to-one: +`bool.entities.
` exposes the full entity surface: - **Reads:** `list`, `filter`, `get` — with `sort` (`-col`), `limit`, `skip`, and `fields` (column selection). - **Writes:** `create`, `bulkCreate`, `update`, `bulkUpdate`, `delete`. @@ -96,7 +171,7 @@ await bool.entities.todos.filter({ status: "active", count: { $gte: 10 } }); Methods return row data directly and throw on error. Additive and backward-compatible — `bool.db` / `supabase` still work. -Known gaps vs. Base44 (documented, follow-ups): `updateMany` with +Known gaps (documented, follow-ups): `updateMany` with `$inc/$mul/$push/$pull` is read-modify-write (not atomic under concurrent writers — a Postgres RPC would make it atomic); `$size` (filter by array length) isn't expressible over PostgREST and is omitted. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/README.md b/README.md index 2ff47e8..a729fe7 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ tested, and upgradable independently of any one app. ## What it does - **Entities data API.** `client.entities.
` is the recommended way to - read/write data — a one-to-one mirror of Base44's entity surface: `list`, + read/write data — a simple, high-level entity surface: `list`, `filter`, `get`, `create`, `bulkCreate`, `update`, `bulkUpdate`, `updateMany`, `delete`, `deleteMany`, `importEntities`, `subscribe`. It hides Supabase/SQL entirely; methods return rows directly and throw on error: @@ -68,6 +68,78 @@ tested, and upgradable independently of any one app. `useBoolAuth()`, ``, and the headless `useSignInForm()` state machine that login forms bind to. +## Local Development (Your Own Machine) + +Build an app on your computer, use a Bool project as your backend, then +publish to `https://.bool.so`. This is the one case where you install +the SDK yourself. + +### Quick Start + +```bash +npm install bool-sdk +export BOOL_TOKEN=bool_live_xxxxx # from Bool → Settings → Access tokens + +npx bool link --project # connect to a Bool project +npx bool entities push --dir bool/entities # push schema changes +npx bool deploy # publish when ready +``` + +**Three new files after `link`:** +- `bool.config.json` — project metadata (commit this) +- `.env.bool` — admin key (gitignore, keep secret) +- `bool/types.d.ts` — TypeScript types (auto-updated) + +**Then in your app:** + +```ts +import { createBoolClient } from "bool-sdk"; +import config from "./bool.config.json"; + +export const bool = createBoolClient({ + supabaseUrl: config.supabaseUrl, + supabaseAnonKey: config.supabaseAnonKey, + schema: config.schema, + appOrigin: config.appOrigin, + slug: config.slug, + apiKey: process.env.BOOL_API_KEY, // from .env.bool +}); + +// Now use your data +const todos = await bool.entities.todos.list(); +``` + +### Documentation + +Complete guides and API reference at **[bool.com/docs](https://bool.com/docs)**: + +- **[Local Development](https://bool.com/docs/local-development)** — complete + walkthrough with use cases, workflows, and tips +- **[CLI Reference](https://bool.com/docs/cli)** — command-line tools +- **[SDK Reference](https://bool.com/docs/sdk-reference)** — API documentation +- **[Data Design](https://bool.com/docs/database)** — schema patterns and + privacy + +### Admin Key Gotcha + +When using the admin key (`apiKey`), on a **private** entity (one with +`user_id` owner field), you must set `user_id` explicitly: + +```ts +// ❌ Fails on private entity (NOT NULL constraint) +await bool.entities.tasks.create({ title: "Task" }); + +// ✅ Works +await bool.entities.tasks.create({ title: "Task", user_id: userId }); +``` + +The admin key has no user identity, so it can't default `user_id`. End-user +clients and `boolk_` keys carry the user and default automatically. + +Coding agents can do all of the above through Bool's MCP server instead +(`list_entities`, `define_entity`, `list_records`, `get_entity_types`, +`get_project_connection`, …) — see the platform docs. + ## Usage ```ts diff --git a/package.json b/package.json index 863d544..c1604a4 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,13 @@ { "name": "bool-sdk", - "version": "0.2.0-next.10", - "description": "Client SDK for apps built on Bool — gateway data access, end-user auth, the AI battery, and the React auth layer.", + "version": "0.2.0-next.16", + "description": "Client SDK for apps built on Bool — gateway data access, end-user auth, the AI battery, the React auth layer, and the local-dev CLI (link, types, deploy).", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", + "bin": { + "bool": "./dist/cli-entry.js" + }, "exports": { ".": { "types": "./dist/index.d.ts", diff --git a/src/cli-entry.ts b/src/cli-entry.ts new file mode 100644 index 0000000..a595fdb --- /dev/null +++ b/src/cli-entry.ts @@ -0,0 +1,6 @@ +#!/usr/bin/env node +// npm bin entry for the bool-sdk CLI (package.json "bin"). All logic lives in +// cli.ts so tests can drive it with stubbed deps. +import { runCli } from "./cli.js"; + +process.exit(await runCli(process.argv.slice(2))); diff --git a/src/cli.test.ts b/src/cli.test.ts new file mode 100644 index 0000000..2a48418 --- /dev/null +++ b/src/cli.test.ts @@ -0,0 +1,446 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { CONFIG_FILE, ENV_FILE, collectDeployEntries, isDeployExcluded, parseArgs, runCli } from "./cli.js"; + +const CONNECTION = { + projectId: "p1", + name: "My App", + slug: "my-app", + schema: "app_abc123", + appOrigin: "https://bool.so", + appUrl: "https://my-app.bool.so", + supabaseUrl: "https://apps.supabase.test", + supabaseAnonKey: "anon-key", +}; + +type Call = { url: string; init?: RequestInit }; + +function makeDeps(cwd: string, routes: Record Response>) { + const calls: Call[] = []; + const logs: string[] = []; + const errors: string[] = []; + const deps = { + cwd, + env: { BOOL_TOKEN: "bool_live_test" } as Record, + log: (m: string) => logs.push(m), + error: (m: string) => errors.push(m), + sleep: async () => {}, + fetch: async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + calls.push({ url, init }); + const path = url.startsWith("http") ? new URL(url).pathname : url; + const handler = routes[path]; + if (!handler) return new Response(JSON.stringify({ error: "no route " + path }), { status: 404 }); + return handler(init); + }, + }; + return { deps, calls, logs, errors }; +} + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +let cwd: string; +beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), "bool-cli-")); +}); + +describe("parseArgs", () => { + test("parses command and flag forms", () => { + expect(parseArgs(["link", "--project", "p1", "--api-url=https://x", "--verbose"])).toEqual({ + command: "link", + positionals: [], + flags: { project: "p1", "api-url": "https://x", verbose: true }, + }); + }); + test("captures subcommand positionals", () => { + expect(parseArgs(["entities", "push", "--dir", "x"])).toEqual({ + command: "entities", + positionals: ["push"], + flags: { dir: "x" }, + }); + }); +}); + +describe("create", () => { + function createRoutes() { + return { + "/api/projects": () => + json({ id: "new1", name: "My Todo" }, 201), + "/api/projects/new1/connection": () => + json({ ...CONNECTION, projectId: "new1", name: "My Todo" }), + "/api/projects/new1/api-key": () => json({ apiKey: "boolsk_secret" }), + "/api/projects/new1/entities": () => + json({ ok: true, entity: "todos", changed: true, warnings: [] }), + "/api/projects/new1/entities/types": () => new Response("// todo types"), + }; + } + + test("creates a project, scaffolds a todo app, links, pushes the entity", async () => { + const { deps, calls, logs } = makeDeps(cwd, createRoutes()); + const code = await runCli(["create", "my-todo"], deps); + expect(code).toBe(0); + + const dir = join(cwd, "my-todo"); + // Scaffolded app files. + expect(existsSync(join(dir, "package.json"))).toBe(true); + expect(readFileSync(join(dir, "index.html"), "utf8")).toContain("my-todo"); + expect(readFileSync(join(dir, "src/App.tsx"), "utf8")).toContain("bool.entities.todos"); + const entity = readFileSync(join(dir, "bool/entities/todos.jsonc"), "utf8"); + expect(entity).toContain('"x-bool-access": "public"'); + + // Linked into the new dir (config + secret key), not the cwd. + const config = JSON.parse(readFileSync(join(dir, CONFIG_FILE), "utf8")); + expect(config.projectId).toBe("new1"); + expect(existsSync(join(cwd, CONFIG_FILE))).toBe(false); + expect(readFileSync(join(dir, ENV_FILE), "utf8")).toBe("BOOL_API_KEY=boolsk_secret\n"); + + // Created the project and declared the entity. + expect(calls.some((c) => c.url.endsWith("/api/projects") && c.init?.method === "POST")).toBe(true); + expect(calls.some((c) => c.url.endsWith("/api/projects/new1/entities") && c.init?.method === "POST")).toBe(true); + // No deploy without --deploy. + expect(calls.some((c) => c.url.includes("/api/drops"))).toBe(false); + expect(logs.join("\n")).toContain('Created project "My Todo"'); + }); + + test("aborts before deploy when the entity push fails", async () => { + const routes = createRoutes(); + routes["/api/projects/new1/entities"] = () => + new Response(JSON.stringify({ error: "Method Not Allowed" }), { status: 405 }); + const { deps, calls, errors } = makeDeps(cwd, routes); + expect(await runCli(["create", "my-todo", "--deploy"], deps)).toBe(1); + // Never deployed a broken app. + expect(calls.some((c) => c.url.includes("/api/drops"))).toBe(false); + expect(errors.join("\n")).toContain("bool entities push"); + }); + + test("refuses a non-empty target directory", async () => { + const dir = join(cwd, "taken"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "keep.txt"), "x"); + const { deps, errors, calls } = makeDeps(cwd, createRoutes()); + expect(await runCli(["create", "taken"], deps)).toBe(1); + expect(errors.join("\n")).toContain("isn't empty"); + // Bailed before creating anything server-side. + expect(calls.length).toBe(0); + }); + + test("generates a name when none is given (bare `bool create`)", async () => { + const { deps, logs } = makeDeps(cwd, createRoutes()); + expect(await runCli(["create"], deps)).toBe(0); + // A project was created and something got scaffolded under a generated name. + expect(logs.join("\n")).toMatch(/Created project ".+"/); + expect(logs.join("\n")).toMatch(/Scaffolded a todo app in [a-z]+-[a-z]+-\d+\//); + }); +}); + +describe("link", () => { + test("writes config + env + gitignore and pulls types", async () => { + const { deps, calls, logs } = makeDeps(cwd, { + "/api/projects/p1/connection": () => json(CONNECTION), + "/api/projects/p1/api-key": () => json({ apiKey: "boolsk_secret" }), + "/api/projects/p1/entities/types": () => new Response("// types here"), + }); + const code = await runCli(["link", "--project", "p1", "--api-url", "https://bool.test"], deps); + expect(code).toBe(0); + + const config = JSON.parse(readFileSync(join(cwd, CONFIG_FILE), "utf8")); + expect(config).toMatchObject({ + projectId: "p1", + slug: "my-app", + apiUrl: "https://bool.test", + schema: "app_abc123", + supabaseAnonKey: "anon-key", + typesPath: "bool/types.d.ts", + }); + // The secret goes to .env.bool (gitignored), never into the config. + expect(JSON.stringify(config)).not.toContain("boolsk_secret"); + expect(readFileSync(join(cwd, ENV_FILE), "utf8")).toBe("BOOL_API_KEY=boolsk_secret\n"); + expect(readFileSync(join(cwd, ".gitignore"), "utf8")).toContain(ENV_FILE); + expect(readFileSync(join(cwd, "bool/types.d.ts"), "utf8")).toBe("// types here"); + + // Platform calls carry the PAT. + for (const c of calls) { + expect(new Headers(c.init?.headers).get("authorization")).toBe("Bearer bool_live_test"); + } + expect(logs.join("\n")).toContain("Linked to \"My App\""); + }); + + test("still links when the api key is owner-only (404)", async () => { + const { deps, logs } = makeDeps(cwd, { + "/api/projects/p1/connection": () => json(CONNECTION), + "/api/projects/p1/api-key": () => json({ error: "Not found" }, 404), + "/api/projects/p1/entities/types": () => new Response("// t"), + }); + expect(await runCli(["link", "--project", "p1"], deps)).toBe(0); + expect(existsSync(join(cwd, ENV_FILE))).toBe(false); + expect(logs.join("\n")).toContain("Skipped the admin data key"); + }); + + test("fails without a token", async () => { + const { deps, errors } = makeDeps(cwd, {}); + deps.env = {}; + expect(await runCli(["link", "--project", "p1"], deps)).toBe(1); + expect(errors.join("\n")).toContain("BOOL_TOKEN"); + }); + + test("surfaces a connection error (v1 project)", async () => { + const { deps, errors } = makeDeps(cwd, { + "/api/projects/p1/connection": () => + json({ error: "not_gateway_project", message: "This project runs on the v1 runtime" }, 409), + }); + expect(await runCli(["link", "--project", "p1"], deps)).toBe(1); + expect(errors.join("\n")).toContain("v1 runtime"); + }); + + // Regression: a 200 with a non-JSON body (e.g. --api-url points at a host + // that serves the HTML app shell because the Bool API isn't deployed there) + // must fail cleanly, not crash on `conn.projectId`. + test("fails cleanly when the API returns a non-JSON 200 (wrong --api-url)", async () => { + const { deps, errors } = makeDeps(cwd, { + "/api/projects/p1/connection": () => + new Response("app", { + status: 200, + headers: { "content-type": "text/html" }, + }), + }); + expect(await runCli(["link", "--project", "p1", "--api-url", "https://not-the-api.test"], deps)).toBe(1); + expect(errors.join("\n")).toContain("--api-url"); + expect(errors.join("\n")).not.toContain("projectId"); + }); +}); + +describe("types", () => { + test("refreshes the types file from config", async () => { + writeFileSync( + join(cwd, CONFIG_FILE), + JSON.stringify({ ...CONNECTION, apiUrl: "https://bool.test", typesPath: "bool/types.d.ts" }), + ); + const { deps } = makeDeps(cwd, { + "/api/projects/p1/entities/types": () => new Response("// fresh"), + }); + expect(await runCli(["types", "--out", "custom/entities.d.ts"], deps)).toBe(0); + expect(readFileSync(join(cwd, "custom/entities.d.ts"), "utf8")).toBe("// fresh"); + }); + + test("requires a link first", async () => { + const { deps, errors } = makeDeps(cwd, {}); + expect(await runCli(["types"], deps)).toBe(1); + expect(errors.join("\n")).toContain("bool link"); + }); +}); + +describe("deploy exclusions", () => { + test("excludes deps, VCS, build output, env files, and the link config", () => { + for (const p of [ + "node_modules/react/index.js", + ".git/HEAD", + "dist/bundle.js", + "build/x", + ".next/y", + ".env", + ".env.bool", + ".env.local", + "bool.config.json", + "src/.DS_Store", + ]) { + expect(isDeployExcluded(p)).toBe(true); + } + for (const p of ["index.html", "src/main.ts", "package.json", "bool/types.d.ts", "public/env-info.txt"]) { + expect(isDeployExcluded(p)).toBe(false); + } + }); + + test("collectDeployEntries walks the tree with exclusions applied", () => { + writeFileSync(join(cwd, "index.html"), ""); + mkdirSync(join(cwd, "src"), { recursive: true }); + writeFileSync(join(cwd, "src/main.ts"), "code"); + mkdirSync(join(cwd, "node_modules/x"), { recursive: true }); + writeFileSync(join(cwd, "node_modules/x/i.js"), "dep"); + writeFileSync(join(cwd, ".env.bool"), "BOOL_API_KEY=secret"); + writeFileSync(join(cwd, CONFIG_FILE), "{}"); + const paths = collectDeployEntries(cwd).map((e) => e.path); + expect(paths).toEqual(["index.html", "src/main.ts"]); + }); +}); + +describe("deploy", () => { + test("zips, uploads, polls to ready", async () => { + writeFileSync(join(cwd, CONFIG_FILE), JSON.stringify({ ...CONNECTION, apiUrl: "https://bool.test" })); + writeFileSync(join(cwd, "index.html"), ""); + let polls = 0; + const { deps, calls, logs } = makeDeps(cwd, { + "/api/drops": (init) => { + expect(JSON.parse(String(init?.body))).toEqual({ project_id: "p1" }); + return json({ + drop_id: "d1", + upload_url: "https://storage.test/upload/d1", + upload_headers: { "Content-Type": "application/zip" }, + status_url: "https://bool.test/api/drops/d1/status?sig=x", + max_upload_size_bytes: 10_000_000, + }); + }, + "/upload/d1": () => new Response(null, { status: 200 }), + "/api/drops/d1/status": () => + ++polls < 3 + ? json({ status: "building", url: null, error: null }) + : json({ status: "ready", url: "https://my-app.bool.so", error: null }), + }); + expect(await runCli(["deploy"], deps)).toBe(0); + expect(polls).toBe(3); + // The upload PUT carried zip bytes. + const put = calls.find((c) => c.url.includes("/upload/d1"))!; + expect(put.init?.method).toBe("PUT"); + expect(logs.join("\n")).toContain("Live at https://my-app.bool.so"); + }); + + test("reports a failed build", async () => { + writeFileSync(join(cwd, CONFIG_FILE), JSON.stringify({ ...CONNECTION, apiUrl: "https://bool.test" })); + writeFileSync(join(cwd, "index.html"), ""); + const { deps, errors } = makeDeps(cwd, { + "/api/drops": () => + json({ + drop_id: "d1", + upload_url: "https://storage.test/upload/d1", + upload_headers: {}, + status_url: "https://bool.test/api/drops/d1/status", + max_upload_size_bytes: 10_000_000, + }), + "/upload/d1": () => new Response(null, { status: 200 }), + "/api/drops/d1/status": () => + json({ status: "failed", url: null, error: { code: "BUILD_FAILED", message: "vite exited 1" } }), + }); + expect(await runCli(["deploy"], deps)).toBe(1); + expect(errors.join("\n")).toContain("vite exited 1"); + }); + + test("refuses a directory without index.html", async () => { + writeFileSync(join(cwd, CONFIG_FILE), JSON.stringify({ ...CONNECTION, apiUrl: "https://bool.test" })); + const { deps, errors } = makeDeps(cwd, {}); + expect(await runCli(["deploy"], deps)).toBe(1); + expect(errors.join("\n")).toContain("index.html"); + }); +}); + +describe("entities", () => { + test("prints the entity reference", async () => { + writeFileSync(join(cwd, CONFIG_FILE), JSON.stringify({ ...CONNECTION, apiUrl: "https://bool.test" })); + const { deps, logs } = makeDeps(cwd, { + "/api/projects/p1/entities": () => + json({ + entities: [ + { + name: "todos", + access: "private", + fields: [ + { name: "id", type: "string", required: true }, + { name: "title", type: "string", required: true }, + ], + }, + ], + }), + }); + expect(await runCli(["entities"], deps)).toBe(0); + const out = logs.join("\n"); + expect(out).toContain("todos (private)"); + expect(out).toContain("title: string (required)"); + }); +}); + +describe("entities push / pull", () => { + const BANNER = "// Data model managed by Bool.\n"; + + test("push declares every local schema and refreshes types", async () => { + writeFileSync(join(cwd, CONFIG_FILE), JSON.stringify({ ...CONNECTION, apiUrl: "https://bool.test", typesPath: "bool/types.d.ts" })); + mkdirSync(join(cwd, "bool/entities"), { recursive: true }); + writeFileSync( + join(cwd, "bool/entities/todos.jsonc"), + BANNER + + JSON.stringify({ + name: "todos", + type: "object", + properties: { title: { type: "string" } }, + required: ["title"], + "x-bool-access": "private", + }), + ); + writeFileSync( + join(cwd, "bool/entities/games.jsonc"), + BANNER + JSON.stringify({ name: "games", type: "object", properties: { score: { type: "number" } }, "x-bool-access": "public" }), + ); + const pushed: unknown[] = []; + const { deps, logs } = makeDeps(cwd, { + "/api/projects/p1/entities": (init) => { + const body = JSON.parse(String(init?.body)); + pushed.push(body); + return json({ ok: true, entity: body.name, changed: body.name === "todos", warnings: body.name === "todos" ? ["heads up"] : [] }); + }, + "/api/projects/p1/entities/types": () => new Response("// regenerated"), + }); + expect(await runCli(["entities", "push"], deps)).toBe(0); + // Alphabetical file order; access carried through from x-bool-access. + expect(pushed.map((p) => (p as { name: string }).name)).toEqual(["games", "todos"]); + expect((pushed[0] as { access: string }).access).toBe("public"); + expect((pushed[1] as { access: string; required: string[] }).required).toEqual(["title"]); + const out = logs.join("\n"); + expect(out).toContain("✓ todos: migrated"); + expect(out).toContain("✓ games: already up to date"); + expect(out).toContain("⚠ heads up"); + expect(readFileSync(join(cwd, "bool/types.d.ts"), "utf8")).toBe("// regenerated"); + }); + + test("push reports per-entity failures and exits 1", async () => { + writeFileSync(join(cwd, CONFIG_FILE), JSON.stringify({ ...CONNECTION, apiUrl: "https://bool.test" })); + mkdirSync(join(cwd, "bool/entities"), { recursive: true }); + writeFileSync(join(cwd, "bool/entities/bad.jsonc"), "{ not json"); + writeFileSync( + join(cwd, "bool/entities/ok.jsonc"), + JSON.stringify({ name: "ok", properties: { a: { type: "string" } } }), + ); + const { deps, errors } = makeDeps(cwd, { + "/api/projects/p1/entities": () => json({ ok: true, entity: "ok", changed: false, warnings: [] }), + "/api/projects/p1/entities/types": () => new Response("// t"), + }); + expect(await runCli(["entities", "push"], deps)).toBe(1); + expect(errors.join("\n")).toContain("✗ bad.jsonc"); + expect(errors.join("\n")).toContain("1 of 2 entities failed"); + }); + + test("pull writes the raw schema files and refreshes types", async () => { + writeFileSync(join(cwd, CONFIG_FILE), JSON.stringify({ ...CONNECTION, apiUrl: "https://bool.test", typesPath: "bool/types.d.ts" })); + const { deps, errors } = makeDeps(cwd, { + "/api/projects/p1/entities/schemas": () => + json({ + schemas: [ + { path: "bool/entities/todos.jsonc", content: '// banner\n{"name":"todos"}' }, + { path: "../pull-escape-evil.txt", content: "evil" }, + ], + }), + "/api/projects/p1/entities/types": () => new Response("// t"), + }); + expect(await runCli(["entities", "pull"], deps)).toBe(0); + expect(readFileSync(join(cwd, "bool/entities/todos.jsonc"), "utf8")).toBe('// banner\n{"name":"todos"}'); + // A hostile path from the network is skipped, never written. + expect(errors.join("\n")).toContain("Skipping unexpected path"); + expect(existsSync(join(cwd, "../pull-escape-evil.txt"))).toBe(false); + }); +}); + +describe("help / unknown", () => { + test("no command prints usage and exits 1", async () => { + const { deps, logs } = makeDeps(cwd, {}); + expect(await runCli([], deps)).toBe(1); + expect(logs.join("\n")).toContain("Usage:"); + }); + test("unknown command errors", async () => { + const { deps, errors } = makeDeps(cwd, {}); + expect(await runCli(["frobnicate"], deps)).toBe(1); + expect(errors.join("\n")).toContain('Unknown command "frobnicate"'); + }); +}); diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..8de01db --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,757 @@ +// The Bool CLI (`bool`, shipped in the bool-sdk package): develop a LOCAL app +// against a Bool project as its managed backend, then publish it back to Bool. +// Run via `npx bool-sdk ` / `bunx bool-sdk`, or just `bool ` +// once installed. Zero dependencies — plain fetch + node:fs. +// +// bool create scaffold a new Bool todo app + project here, +// then link it (add --deploy to publish it too) +// bool link --project connect this folder to a Bool project: +// writes bool.config.json (public config), +// .env.bool (the secret BOOL_API_KEY), and +// pulls the generated entity types +// bool types refresh bool/types.d.ts from the project's +// entity schemas +// bool entities list the project's entities + fields +// bool entities pull write the project's schema files to bool/entities/ +// bool entities push declare local bool/entities/*.jsonc on the +// project (additive migrations, server-side) +// bool deploy [--dir .] zip the app source and publish it on Bool +// (Bool builds in the cloud; the URL is stable) +// +// Auth: platform API calls (link/types/entities/deploy) use a personal access +// token — pass --token or set BOOL_TOKEN (create one in Bool → Settings → +// Access tokens). The app's DATA access uses the project api key `link` puts +// in .env.bool, which the app passes to createBoolClient as `apiKey`. + +import { + appendFileSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + statSync, + writeFileSync, +} from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { createZip, type ZipEntry } from "./zip.js"; +import { todoTemplate } from "./templates.js"; + +export const CONFIG_FILE = "bool.config.json"; +export const ENV_FILE = ".env.bool"; +const DEFAULT_API_URL = "https://bool.com"; +const DEFAULT_TYPES_PATH = "bool/types.d.ts"; + +/** The public, committable half of a link — everything createBoolClient needs + * except the secret api key (that lives in .env.bool / BOOL_API_KEY). */ +export type BoolConfig = { + projectId: string; + slug: string; + apiUrl: string; + appOrigin: string; + appUrl: string; + schema: string; + supabaseUrl: string; + supabaseAnonKey: string; + typesPath: string; +}; + +/** Injectable effects so tests run hermetically (stub fetch, pin cwd/env). + * `fetch` is a plain fetch-shaped function (not Bun's `typeof fetch`, which + * demands a `preconnect` property a stub doesn't have — same cast client.ts + * documents). */ +export type CliDeps = { + fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise; + cwd: string; + env: Record; + log: (msg: string) => void; + error: (msg: string) => void; + sleep: (ms: number) => Promise; +}; + +function defaults(): CliDeps { + return { + fetch: (...args) => fetch(...args), + cwd: process.cwd(), + env: process.env, + log: (m) => console.log(m), + error: (m) => console.error(m), + sleep: (ms) => new Promise((r) => setTimeout(r, ms)), + }; +} + +/** Tiny arg parser: `--key value` / `--key=value` / bare `--flag`, plus bare + * positionals (subcommands like `entities push`). */ +export function parseArgs(argv: string[]): { + command: string | undefined; + positionals: string[]; + flags: Record; +} { + const [command, ...rest] = argv; + const flags: Record = {}; + const positionals: string[] = []; + for (let i = 0; i < rest.length; i++) { + const arg = rest[i]!; + if (!arg.startsWith("--")) { + positionals.push(arg); + continue; + } + const eq = arg.indexOf("="); + if (eq !== -1) { + flags[arg.slice(2, eq)] = arg.slice(eq + 1); + } else if (i + 1 < rest.length && !rest[i + 1]!.startsWith("--")) { + flags[arg.slice(2)] = rest[++i]!; + } else { + flags[arg.slice(2)] = true; + } + } + return { command, positionals, flags }; +} + +class CliError extends Error {} + +function str(v: string | boolean | undefined): string | undefined { + return typeof v === "string" && v !== "" ? v : undefined; +} + +function token(flags: Record, deps: CliDeps): string { + const t = str(flags.token) ?? deps.env.BOOL_TOKEN; + if (!t) { + throw new CliError( + "No access token. Pass --token or set BOOL_TOKEN (create one in Bool → Settings → Access tokens).", + ); + } + return t; +} + +async function api( + deps: CliDeps, + tok: string, + base: string, + path: string, +): Promise { + const res = await deps.fetch(`${base.replace(/\/$/, "")}${path}`, { + headers: { authorization: `Bearer ${tok}` }, + }); + return res; +} + +async function apiJson( + deps: CliDeps, + tok: string, + base: string, + path: string, +): Promise { + const res = await api(deps, tok, base, path); + const body = await res.json().catch(() => null); + if (!res.ok) { + const msg = + (body as { error?: string; message?: string } | null)?.message ?? + (body as { error?: string } | null)?.error ?? + `HTTP ${res.status}`; + throw new CliError(`${path} failed: ${msg}`); + } + // A 2xx with a body we couldn't parse as a JSON object means we didn't reach + // the Bool API — most often --api-url points at a host that serves an HTML + // page (e.g. the app shell when the endpoint isn't deployed there yet, or a + // login/redirect page). Fail with a clear message instead of returning null + // and letting the caller crash on `body.projectId`. + if (body === null || typeof body !== "object") { + throw new CliError( + `${path}: expected a JSON response from ${base} but got something else — check --api-url (is the Bool API deployed there?).`, + ); + } + return body as T; +} + +async function apiPost( + deps: CliDeps, + tok: string, + base: string, + path: string, + payload: unknown, +): Promise { + const res = await deps.fetch(`${base.replace(/\/$/, "")}${path}`, { + method: "POST", + headers: { + authorization: `Bearer ${tok}`, + "content-type": "application/json", + }, + body: JSON.stringify(payload), + }); + const body = await res.json().catch(() => null); + if (!res.ok) { + const msg = + (body as { error?: string; message?: string } | null)?.message ?? + (body as { error?: string } | null)?.error ?? + `HTTP ${res.status}`; + throw new CliError(`${path} failed: ${msg}`); + } + if (body === null || typeof body !== "object") { + throw new CliError( + `${path}: expected a JSON response from ${base} but got something else — check --api-url (is the Bool API deployed there?).`, + ); + } + return body as T; +} + +export function readConfig(cwd: string): BoolConfig { + const path = join(cwd, CONFIG_FILE); + if (!existsSync(path)) { + throw new CliError( + `No ${CONFIG_FILE} here — run \`bool link --project \` first.`, + ); + } + return JSON.parse(readFileSync(path, "utf8")) as BoolConfig; +} + +/** Idempotently set KEY=value in .env.bool and make sure .gitignore hides it. */ +function writeEnvKey(cwd: string, key: string, value: string): void { + const envPath = join(cwd, ENV_FILE); + const line = `${key}=${value}`; + if (existsSync(envPath)) { + const lines = readFileSync(envPath, "utf8").split("\n"); + const i = lines.findIndex((l) => l.startsWith(`${key}=`)); + if (i !== -1) lines[i] = line; + else lines.push(line); + writeFileSync(envPath, lines.filter((l, j) => l || j < lines.length - 1).join("\n") + "\n"); + } else { + writeFileSync(envPath, line + "\n"); + } + const gitignore = join(cwd, ".gitignore"); + const existing = existsSync(gitignore) ? readFileSync(gitignore, "utf8") : ""; + if (!existing.split("\n").some((l) => l.trim() === ENV_FILE)) { + appendFileSync( + gitignore, + (existing && !existing.endsWith("\n") ? "\n" : "") + ENV_FILE + "\n", + ); + } +} + +type Connection = { + projectId: string; + name: string; + slug: string; + schema: string; + appOrigin: string; + appUrl: string; + supabaseUrl: string; + supabaseAnonKey: string; +}; + +// Fetch a project's connection descriptor, write bool.config.json, and (for the +// owner) the admin data key to .env.bool — all into deps.cwd. Returns the config +// so the caller can pull types / push entities against it. Shared by `link` and +// `create`. +async function writeConfigAndKey( + projectId: string, + apiUrl: string, + tok: string, + typesPath: string, + deps: CliDeps, +): Promise<{ config: BoolConfig; name: string }> { + const conn = await apiJson( + deps, + tok, + apiUrl, + `/api/projects/${projectId}/connection`, + ); + + const config: BoolConfig = { + projectId: conn.projectId, + slug: conn.slug, + apiUrl, + appOrigin: conn.appOrigin, + appUrl: conn.appUrl, + schema: conn.schema, + supabaseUrl: conn.supabaseUrl, + supabaseAnonKey: conn.supabaseAnonKey, + typesPath, + }; + writeFileSync(join(deps.cwd, CONFIG_FILE), JSON.stringify(config, null, 2) + "\n"); + + // The admin data key is owner-only; a non-owner link still works for + // types/entities/deploy, they just bring their own key. + const keyRes = await api(deps, tok, apiUrl, `/api/projects/${projectId}/api-key`); + if (keyRes.ok) { + const { apiKey } = (await keyRes.json()) as { apiKey: string }; + writeEnvKey(deps.cwd, "BOOL_API_KEY", apiKey); + deps.log(`Wrote the project's admin data key to ${ENV_FILE} (gitignored — keep it secret).`); + } else { + deps.log( + `Skipped the admin data key (${keyRes.status === 404 ? "owner-only" : `HTTP ${keyRes.status}`}) — set BOOL_API_KEY yourself to read/write data locally.`, + ); + } + + return { config, name: conn.name }; +} + +async function cmdLink( + flags: Record, + deps: CliDeps, +): Promise { + const projectId = str(flags.project); + if (!projectId) throw new CliError("Usage: bool link --project [--api-url ] [--token ]"); + const apiUrl = str(flags["api-url"]) ?? deps.env.BOOL_API_URL ?? DEFAULT_API_URL; + const tok = token(flags, deps); + + const typesPath = str(flags.types) ?? DEFAULT_TYPES_PATH; + const { config, name } = await writeConfigAndKey(projectId, apiUrl, tok, typesPath, deps); + deps.log(`Linked to "${name}" (${config.projectId}) — wrote ${CONFIG_FILE}.`); + + await pullTypes(config, tok, deps); + + deps.log(` +Next steps: + 1. Load ${ENV_FILE} into your env (or copy BOOL_API_KEY into your own .env). + 2. Create the client: + + import { createBoolClient } from "bool-sdk"; + import config from "./${CONFIG_FILE}"; + + export const bool = createBoolClient({ + supabaseUrl: config.supabaseUrl, + supabaseAnonKey: config.supabaseAnonKey, + schema: config.schema, + appOrigin: config.appOrigin, + slug: config.slug, + apiKey: process.env.BOOL_API_KEY, // import.meta.env.VITE_BOOL_API_KEY in Vite + }); + + 3. Use your data: await bool.entities..list() + (admin-key note: creates on a PRIVATE entity must set owner_id explicitly — + the admin key has no end-user identity to default it from) + 4. Publish anytime: bool deploy`); + return 0; +} + +// A friendly default project name for bare `bool create` (adjective-noun-NN). +function generateProjectName(): string { + const adjectives = ["swift", "cozy", "bright", "calm", "bold", "keen", "lucky", "sunny", "brave", "clever"]; + const nouns = ["otter", "maple", "harbor", "meadow", "comet", "pixel", "willow", "ember", "cedar", "finch"]; + const pick = (a: T[]) => a[Math.floor(Math.random() * a.length)]; + return `${pick(adjectives)}-${pick(nouns)}-${Math.floor(Math.random() * 90 + 10)}`; +} + +async function cmdCreate( + positionals: string[], + flags: Record, + deps: CliDeps, +): Promise { + // Name is optional — bare `bool create` picks a friendly one so it just works. + const name = positionals[0] ?? str(flags.name) ?? generateProjectName(); + if (!/^[a-zA-Z0-9][a-zA-Z0-9 _-]*$/.test(name)) { + throw new CliError( + `Invalid project name "${name}" — use letters, numbers, spaces, dashes, underscores.`, + ); + } + const apiUrl = str(flags["api-url"]) ?? deps.env.BOOL_API_URL ?? DEFAULT_API_URL; + const tok = token(flags, deps); + const dir = resolve(deps.cwd, str(flags.path) ?? name); + + if (existsSync(dir) && readdirSync(dir).length > 0) { + throw new CliError( + `${relative(deps.cwd, dir) || dir} already exists and isn't empty — pick another name or --path.`, + ); + } + + // 1. Create the project. template stays vite-react so Bool cloud-builds the + // app we deploy; the server also provisions the project's schema. + const project = await apiPost<{ id: string; name?: string }>( + deps, + tok, + apiUrl, + "/api/projects", + { name, template: "vite-react" }, + ); + deps.log(`Created project "${project.name ?? name}" (${project.id}).`); + + // 2. Scaffold the todo app into dir. + mkdirSync(dir, { recursive: true }); + const files = todoTemplate(name); + for (const [rel, content] of Object.entries(files)) { + const out = join(dir, rel); + mkdirSync(dirname(out), { recursive: true }); + writeFileSync(out, content); + } + deps.log( + `Scaffolded a todo app in ${relative(deps.cwd, dir) || "."}/ (${Object.keys(files).length} files).`, + ); + + // Everything below runs inside the new project dir. + const sub: CliDeps = { ...deps, cwd: dir }; + + // 3. Write bool.config.json + .env.bool into the project dir. + const { config } = await writeConfigAndKey(project.id, apiUrl, tok, DEFAULT_TYPES_PATH, sub); + deps.log(`Linked ${CONFIG_FILE} to project ${project.id}.`); + + // 4. Declare the todos entity so the table exists, then refresh types. + // If this fails the app has no data — don't ship a broken deploy; tell the + // user to re-push once the cause is fixed. + const pushCode = await cmdEntitiesPush(flags, sub); + if (pushCode !== 0) { + throw new CliError( + `The todos entity didn't get created, so the app would have no data. Once the cause above is resolved, finish with:\n cd ${relative(deps.cwd, dir) || "."}\n bool entities push\n bool deploy`, + ); + } + await pullTypes(config, tok, sub); + + // 5. Optionally publish. + if (flags.deploy) { + await cmdDeploy(flags, sub); + } else { + const rel = relative(deps.cwd, dir) || "."; + deps.log(` +Next: + cd ${rel} + npm install + npm run dev # develop locally (data goes to your Bool project) + bool deploy # publish to Bool hosting`); + } + return 0; +} + +async function pullTypes(config: BoolConfig, tok: string, deps: CliDeps): Promise { + const res = await api( + deps, + tok, + config.apiUrl, + `/api/projects/${config.projectId}/entities/types`, + ); + if (!res.ok) throw new CliError(`Fetching entity types failed: HTTP ${res.status}`); + const body = await res.text(); + const out = resolve(deps.cwd, config.typesPath || DEFAULT_TYPES_PATH); + mkdirSync(dirname(out), { recursive: true }); + writeFileSync(out, body); + deps.log(`Wrote entity types to ${relative(deps.cwd, out)}.`); +} + +async function cmdTypes( + flags: Record, + deps: CliDeps, +): Promise { + const config = readConfig(deps.cwd); + if (str(flags.out)) config.typesPath = str(flags.out)!; + await pullTypes(config, token(flags, deps), deps); + return 0; +} + +/** Where local entity schema files live — the same layout the platform uses + * inside a project, so pull/push round-trip byte-for-byte. */ +export const ENTITIES_DIR = "bool/entities"; + +/** Parse one local entity `.jsonc` file (JSON with whole-line `//` comments — + * the platform's banner format). Mirrors the server's parser. */ +export function parseEntitySchemaFile(content: string): { + name: string; + properties: Record; + required?: string[]; + access: "private" | "public"; +} | null { + const stripped = content + .split("\n") + .filter((line) => !line.trimStart().startsWith("//")) + .join("\n"); + let doc: unknown; + try { + doc = JSON.parse(stripped); + } catch { + return null; + } + if (!doc || typeof doc !== "object") return null; + const obj = doc as Record; + if (typeof obj.name !== "string" || !obj.properties || typeof obj.properties !== "object") { + return null; + } + return { + name: obj.name, + properties: obj.properties as Record, + required: Array.isArray(obj.required) + ? (obj.required.filter((r) => typeof r === "string") as string[]) + : undefined, + access: obj["x-bool-access"] === "public" ? "public" : "private", + }; +} + +/** `bool entities push`: declare every local bool/entities/*.jsonc on the + * project (additive-only server-side — it never drops columns), then refresh + * types. Continues past a bad file and reports it; exits 1 if any failed. */ +async function cmdEntitiesPush( + flags: Record, + deps: CliDeps, +): Promise { + const config = readConfig(deps.cwd); + const tok = token(flags, deps); + const dir = resolve(deps.cwd, str(flags.dir) ?? ENTITIES_DIR); + if (!existsSync(dir)) { + throw new CliError(`No ${relative(deps.cwd, dir)}/ directory — run \`bool entities pull\` first, or create .jsonc files there.`); + } + const files = readdirSync(dir) + .filter((f) => f.endsWith(".jsonc")) + .sort(); + if (files.length === 0) { + throw new CliError(`No .jsonc entity files in ${relative(deps.cwd, dir)}/.`); + } + + let failed = 0; + for (const file of files) { + const parsed = parseEntitySchemaFile(readFileSync(join(dir, file), "utf8")); + if (!parsed) { + deps.error(`✗ ${file}: not a valid entity schema (needs "name" + "properties")`); + failed++; + continue; + } + const res = await deps.fetch( + `${config.apiUrl.replace(/\/$/, "")}/api/projects/${config.projectId}/entities`, + { + method: "POST", + headers: { authorization: `Bearer ${tok}`, "content-type": "application/json" }, + body: JSON.stringify(parsed), + }, + ); + const body = (await res.json().catch(() => null)) as + | { ok?: boolean; changed?: boolean; warnings?: string[]; error?: string } + | null; + if (!res.ok || !body?.ok) { + deps.error(`✗ ${parsed.name}: ${body?.error ?? `HTTP ${res.status}`}`); + failed++; + continue; + } + deps.log(`✓ ${parsed.name}: ${body.changed ? "migrated" : "already up to date"}`); + for (const w of body.warnings ?? []) deps.log(` ⚠ ${w}`); + } + + await pullTypes(config, tok, deps); + if (failed > 0) { + deps.error(`${failed} of ${files.length} entities failed to push.`); + return 1; + } + return 0; +} + +/** `bool entities pull`: write the project's entity schema files verbatim into + * bool/entities/ (so they can be edited and pushed back), then refresh types. */ +async function cmdEntitiesPull( + flags: Record, + deps: CliDeps, +): Promise { + const config = readConfig(deps.cwd); + const tok = token(flags, deps); + const { schemas } = await apiJson<{ schemas: Array<{ path: string; content: string }> }>( + deps, + tok, + config.apiUrl, + `/api/projects/${config.projectId}/entities/schemas`, + ); + if (schemas.length === 0) { + deps.log("The project has no declared entities yet — nothing to pull."); + return 0; + } + for (const s of schemas) { + // Paths come from the platform (`bool/entities/.jsonc`), but never + // trust a path from the network with the filesystem: resolve and confine. + const out = resolve(deps.cwd, s.path); + if (!out.startsWith(resolve(deps.cwd, ENTITIES_DIR) + "/")) { + deps.error(`Skipping unexpected path from server: ${s.path}`); + continue; + } + mkdirSync(dirname(out), { recursive: true }); + writeFileSync(out, s.content); + deps.log(`✓ ${s.path}`); + } + await pullTypes(config, tok, deps); + return 0; +} + +async function cmdEntities( + flags: Record, + deps: CliDeps, +): Promise { + const config = readConfig(deps.cwd); + const { entities } = await apiJson<{ + entities: Array<{ + name: string; + access: string; + fields: Array<{ name: string; type: string; required: boolean }>; + }>; + }>(deps, token(flags, deps), config.apiUrl, `/api/projects/${config.projectId}/entities`); + if (entities.length === 0) { + deps.log("No entities declared yet — define one in the Bool editor (or via the MCP define_entity tool)."); + return 0; + } + for (const e of entities) { + deps.log(`${e.name} (${e.access})`); + for (const f of e.fields) { + deps.log(` ${f.name}: ${f.type}${f.required ? " (required)" : ""}`); + } + } + return 0; +} + +// Never ship these into a deploy archive: build output and deps are rebuilt in +// the cloud; env files and the local link config are machine-local (and the +// env files hold secrets). +const DEPLOY_EXCLUDE_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next"]); +export function isDeployExcluded(rel: string): boolean { + const top = rel.split("/")[0]!; + if (DEPLOY_EXCLUDE_DIRS.has(top)) return true; + const base = rel.split("/").pop()!; + if (base === CONFIG_FILE) return true; + if (base === ".env" || base.startsWith(".env.")) return true; + if (base === ".DS_Store") return true; + return false; +} + +export function collectDeployEntries(dir: string): ZipEntry[] { + const entries: ZipEntry[] = []; + const walk = (abs: string) => { + for (const name of readdirSync(abs).sort()) { + const child = join(abs, name); + const rel = relative(dir, child).split("\\").join("/"); + if (isDeployExcluded(rel)) continue; + const st = statSync(child); + if (st.isDirectory()) walk(child); + else if (st.isFile()) entries.push({ path: rel, data: new Uint8Array(readFileSync(child)) }); + } + }; + walk(dir); + return entries; +} + +type DropCreated = { + drop_id: string; + upload_url: string; + upload_headers: Record; + status_url: string; + max_upload_size_bytes: number; +}; +type DropStatus = { + status: string; + url: string | null; + error: { code?: string; message?: string } | null; +}; + +const DEPLOY_POLL_MS = 2500; +const DEPLOY_TIMEOUT_MS = 15 * 60 * 1000; + +async function cmdDeploy( + flags: Record, + deps: CliDeps, +): Promise { + const config = readConfig(deps.cwd); + const tok = token(flags, deps); + const dir = resolve(deps.cwd, str(flags.dir) ?? "."); + + const entries = collectDeployEntries(dir); + if (!entries.some((e) => e.path === "index.html")) { + throw new CliError( + `No index.html at the root of ${dir} — deploy from your app's root (or pass --dir).`, + ); + } + const archive = createZip(entries); + deps.log(`Packed ${entries.length} files (${(archive.length / 1024).toFixed(1)} KB). Creating drop…`); + + const createRes = await deps.fetch(`${config.apiUrl.replace(/\/$/, "")}/api/drops`, { + method: "POST", + headers: { authorization: `Bearer ${tok}`, "content-type": "application/json" }, + body: JSON.stringify({ project_id: config.projectId }), + }); + const created = (await createRes.json().catch(() => null)) as DropCreated | { error?: string } | null; + if (!createRes.ok || !created || !("upload_url" in created)) { + throw new CliError( + `Creating the drop failed: ${(created as { error?: string } | null)?.error ?? `HTTP ${createRes.status}`}`, + ); + } + if (archive.length > created.max_upload_size_bytes) { + throw new CliError( + `Archive is ${archive.length} bytes — over the ${created.max_upload_size_bytes}-byte limit.`, + ); + } + + const putRes = await deps.fetch(created.upload_url, { + method: "PUT", + headers: created.upload_headers ?? { "Content-Type": "application/zip" }, + // createZip returns an exact-sized view, so its backing buffer IS the + // archive; the cast bridges Uint8Array vs BodyInit typing. + body: archive.buffer as ArrayBuffer, + }); + if (!putRes.ok) throw new CliError(`Uploading the archive failed: HTTP ${putRes.status}`); + deps.log("Uploaded. Bool is building in the cloud…"); + + const deadline = Date.now() + DEPLOY_TIMEOUT_MS; + while (Date.now() < deadline) { + const statusRes = await deps.fetch(created.status_url); + const status = (await statusRes.json().catch(() => null)) as DropStatus | null; + if (!statusRes.ok || !status) throw new CliError(`Polling drop status failed: HTTP ${statusRes.status}`); + if (status.status === "ready") { + deps.log(`Live at ${status.url ?? config.appUrl}`); + return 0; + } + if (status.status === "failed") { + throw new CliError( + `Deploy failed: ${status.error?.message ?? status.error?.code ?? "unknown error"}`, + ); + } + await deps.sleep(DEPLOY_POLL_MS); + } + throw new CliError("Timed out waiting for the deploy — check the project on Bool."); +} + +const USAGE = `bool — develop locally against a Bool project, deploy to Bool + +Usage: + bool create [name] [--path ] [--deploy] [--token ] + scaffold a new Bool todo app + project + (name is optional — one is generated) + bool link --project [--api-url ] [--token ] [--types ] + bool types [--out ] [--token ] + bool entities [--token ] list the project's data models + bool entities pull [--token ] write schemas to ${ENTITIES_DIR}/ + bool entities push [--dir ] declare local schemas on the project + bool deploy [--dir ] [--token ] + +Auth: pass --token or set BOOL_TOKEN (Bool → Settings → Access tokens). +Data key: link writes BOOL_API_KEY to ${ENV_FILE} (owner only) — pass it to +createBoolClient as \`apiKey\`.`; + +export async function runCli(argv: string[], overrides?: Partial): Promise { + const deps: CliDeps = { ...defaults(), ...overrides }; + const { command, positionals, flags } = parseArgs(argv); + try { + switch (command) { + case "create": + return await cmdCreate(positionals, flags, deps); + case "link": + return await cmdLink(flags, deps); + case "types": + return await cmdTypes(flags, deps); + case "entities": + switch (positionals[0]) { + case undefined: + return await cmdEntities(flags, deps); + case "push": + return await cmdEntitiesPush(flags, deps); + case "pull": + return await cmdEntitiesPull(flags, deps); + default: + deps.error(`Unknown entities subcommand "${positionals[0]}".\n\n${USAGE}`); + return 1; + } + case "deploy": + return await cmdDeploy(flags, deps); + case undefined: + case "help": + case "--help": + deps.log(USAGE); + return command ? 0 : 1; + default: + deps.error(`Unknown command "${command}".\n\n${USAGE}`); + return 1; + } + } catch (err) { + if (err instanceof CliError) { + deps.error(err.message); + return 1; + } + throw err; + } +} diff --git a/src/client.test.ts b/src/client.test.ts index b8cc44f..40498c8 100644 --- a/src/client.test.ts +++ b/src/client.test.ts @@ -287,6 +287,42 @@ describe("per-user API key", () => { }); }); +describe("local development (config.apiKey)", () => { + const LOCAL = { ...CONFIG, apiKey: "boolsk_admin123" }; + + test("db calls carry the api_key header", async () => { + const client = createBoolClient(LOCAL); + await client.db.from("todos").select("*"); + expect(headersOf(calls[0]!).get("api_key")).toBe("boolsk_admin123"); + }); + + test("users-plane calls carry the api_key header", async () => { + respond = () => + new Response(JSON.stringify({ user: { id: "u1" } }), { + headers: { "content-type": "application/json" }, + }); + const client = createBoolClient(LOCAL); + await client.auth.getUser(); + expect(headersOf(calls[0]!).get("api_key")).toBe("boolsk_admin123"); + }); + + test("ai-plane calls carry the api_key header", async () => { + respond = () => + new Response(JSON.stringify({ text: "hi" }), { + headers: { "content-type": "application/json" }, + }); + const client = createBoolClient(LOCAL); + await client.ai.generate("hello"); + expect(headersOf(calls[0]!).get("api_key")).toBe("boolsk_admin123"); + }); + + test("no api_key header without the config option", async () => { + const client = createBoolClient(CONFIG); + await client.db.from("todos").select("*"); + expect(headersOf(calls[0]!).get("api_key")).toBeNull(); + }); +}); + describe("bool.ai battery", () => { test("generate(prompt) POSTs to the ai plane and returns text", async () => { respond = () => diff --git a/src/client.ts b/src/client.ts index e045a6b..e56e603 100644 --- a/src/client.ts +++ b/src/client.ts @@ -51,6 +51,16 @@ export type BoolClientConfig = { * sandbox can't send the live-gate cookie). Empty when deployed — the * cookie is used then. (VITE_BOOL_VIEWER_TOKEN) */ viewerToken?: string; + /** LOCAL / external development: a Bool data API key sent as the `api_key` + * header on every gateway call, instead of the cookie/viewer-token identity a + * deployed app uses. Two kinds (both minted by the platform): + * - `boolsk_…` — the project's OWNER/ADMIN key: full access to ALL rows + * (bypasses per-user RLS). For your own scripts/backends. SECRET — load + * it from an env var (`bool link` writes .env.bool), never commit it. + * - `boolk_…` — one END USER's personal key: acts exactly as that user. + * With an apiKey set the client works from anywhere (Node, a local Vite app, + * CI) — this is what `bool link` wires up. */ + apiKey?: string; }; /** The signed-in end user (mirrors what the gateway returns from /me). Never @@ -187,6 +197,7 @@ export function createBoolClient(config: BoolClientConfig): BoolClient { appOrigin = "", slug = "", viewerToken = "", + apiKey = "", } = config; let euSessionToken = (() => { @@ -236,6 +247,7 @@ export function createBoolClient(config: BoolClientConfig): BoolClient { const headers = new Headers(init?.headers ?? {}); if (viewerToken) headers.set("x-bool-viewer", viewerToken); if (euSessionToken) headers.set("x-bool-eu-session", euSessionToken); + if (apiKey) headers.set("api_key", apiKey); // credentials:include so the live-gate identity cookie flows to the // gateway (same-origin or custom-domain); the viewer token covers the // cross-origin preview. @@ -269,6 +281,7 @@ export function createBoolClient(config: BoolClientConfig): BoolClient { }; if (viewerToken) headers["x-bool-viewer"] = viewerToken; if (euSessionToken) headers["x-bool-eu-session"] = euSessionToken; + if (apiKey) headers["api_key"] = apiKey; const res = await fetch(`${GATEWAY}/_bool/${GATEWAY_API}/users${path}`, { ...init, headers, @@ -493,6 +506,7 @@ export function createBoolClient(config: BoolClientConfig): BoolClient { const headers: Record = { "content-type": "application/json" }; if (viewerToken) headers["x-bool-viewer"] = viewerToken; if (euSessionToken) headers["x-bool-eu-session"] = euSessionToken; + if (apiKey) headers["api_key"] = apiKey; return headers; } const ai: BoolAi = { diff --git a/src/entities.test.ts b/src/entities.test.ts index 2d53b00..08a1e6f 100644 --- a/src/entities.test.ts +++ b/src/entities.test.ts @@ -81,7 +81,7 @@ describe("entities: read paths → PostgREST", () => { expect(q.get("archived_at")).toBe("is.null"); }); - test("filter() maps the richer Base44 operators", async () => { + test("filter() maps the richer Mongo-style operators", async () => { const bool = createBoolClient(CONFIG); await bool.entities.todos.filter({ a: { $ne: 1 }, diff --git a/src/entities.ts b/src/entities.ts index 89f3120..acab451 100644 --- a/src/entities.ts +++ b/src/entities.ts @@ -1,4 +1,4 @@ -// The Bool entities layer: a Base44-style data API over the gateway-routed +// The Bool entities layer: a high-level data API over the gateway-routed // Supabase client. App code (and the AI that writes it) works with // `bool.entities.
` instead of raw `supabase.from(...)`, so a generated // app never mentions Supabase, SQL, PostgREST, or credentials — the whole @@ -9,7 +9,7 @@ // await bool.entities.todos.update(one.id, { done: true }); // await bool.entities.todos.filter({ status: "active", count: { $gte: 10 } }); // -// The method surface and filter DSL mirror Base44's entities module one-to-one +// The method surface and filter DSL are deliberately Mongo-flavored // (MongoDB-style `$`-operators, `-col` sort, bulk + *Many ops), so there's no // need to drop down to raw SQL. Methods return row data directly and THROW on // error (unlike supabase-js's `{ data, error }`), so app code reads clean. @@ -19,7 +19,7 @@ import type { BoolChangePayload, BoolDb } from "./client.js"; * Entity tables always have `created_at`, so it's the default. */ export type SortSpec = string; -/** MongoDB-style comparison operators for a single field. Mirrors Base44. */ +/** MongoDB-style comparison operators for a single field. */ export type FilterOperators = Partial<{ $eq: unknown; $ne: unknown; @@ -84,7 +84,7 @@ export type ImportResult = { }; /** CRUD + realtime for one entity table. `T` defaults to `any` (untyped); - * apps can pass a row type for autocomplete. Mirrors Base44's EntityHandler. */ + * apps can pass a row type for autocomplete. */ export interface EntityHandler { /** Rows, newest first by default. `limit` defaults to 50, max 5000 — page * through larger result sets with `limit` + `skip` (an over-cap `limit` diff --git a/src/templates.ts b/src/templates.ts new file mode 100644 index 0000000..1361bf9 --- /dev/null +++ b/src/templates.ts @@ -0,0 +1,358 @@ +// Bundled starter apps for `bool create`. Each template is a path→content map +// written verbatim into the new project directory. Kept as plain string +// literals (zero deps) so the CLI can scaffold offline; the only network step +// in `create` is the project/entity API calls. +// +// The apps use bool-sdk's `createBoolClient`, fed the VITE_BOOL_*/VITE_SUPABASE_* +// vars Bool injects at build/deploy time (see runtimeEnv in the platform). The +// `todos` entity is PUBLIC (one shared list, no per-user isolation) so the +// deployed app works for any visitor with no sign-in. + +// Keep in sync with the CLI's own version so the scaffolded app pulls the +// matching client. Injected into package.json at scaffold time. +export const TEMPLATE_BOOL_SDK_VERSION = "0.2.0-next.16"; + +function packageJson(name: string): string { + return ( + JSON.stringify( + { + name, + private: true, + type: "module", + scripts: { + dev: "vite --host", + build: "vite build", + preview: "vite preview --host", + }, + dependencies: { + // bool-sdk declares @supabase/supabase-js as a peer, so the app must + // install it directly — else the deploy build fails to resolve it. + "@supabase/supabase-js": "^2.105.0", + "bool-sdk": TEMPLATE_BOOL_SDK_VERSION, + react: "^19.2.0", + "react-dom": "^19.2.0", + }, + devDependencies: { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + typescript: "^5.7.0", + vite: "^6.0.7", + }, + }, + null, + 2, + ) + "\n" + ); +} + +/** + * The default `bool create` app: a working todo list backed by a public `todos` + * entity. Returns a path→content map for the given project name. + */ +export function todoTemplate(name: string): Record { + return { + "package.json": packageJson(name), + + "vite.config.ts": `import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], +}); +`, + + "tsconfig.json": + JSON.stringify( + { + compilerOptions: { + target: "ES2020", + useDefineForClassFields: true, + lib: ["ES2020", "DOM", "DOM.Iterable"], + module: "ESNext", + skipLibCheck: true, + moduleResolution: "bundler", + jsx: "react-jsx", + strict: true, + noEmit: true, + types: ["vite/client"], + }, + include: ["src"], + }, + null, + 2, + ) + "\n", + + "index.html": ` + + + + + ${name} + + +
+ + + +`, + + "src/vite-env.d.ts": `/// +`, + + // Bool-provided gateway client. Bool injects the VITE_BOOL_*/VITE_SUPABASE_* + // values at build/deploy; the anon key alone has no data grants, so all data + // flows through the gateway. + "src/lib/bool.ts": `import { createBoolClient } from "bool-sdk"; + +export const bool = createBoolClient({ + supabaseUrl: import.meta.env.VITE_SUPABASE_URL!, + supabaseAnonKey: import.meta.env.VITE_SUPABASE_ANON_KEY!, + schema: import.meta.env.VITE_BOOL_DB_SCHEMA!, + appHost: import.meta.env.VITE_BOOL_APP_HOST, + appOrigin: import.meta.env.VITE_BOOL_APP_ORIGIN, + slug: import.meta.env.VITE_BOOL_SLUG, + // Preview only; empty when deployed (same-origin cookie is used then). + viewerToken: import.meta.env.VITE_BOOL_VIEWER_TOKEN, +}); +`, + + "src/main.tsx": `import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; +import "./index.css"; + +createRoot(document.getElementById("root")!).render( + + + , +); +`, + + "src/App.tsx": `import { useEffect, useState, type FormEvent } from "react"; +import { bool } from "./lib/bool"; + +type Todo = { + id: string; + title: string; + completed: boolean; + created_at: string; +}; + +// bool-sdk throws the raw error, which may be a plain object (e.g. a Postgres +// error), not an Error — so pull a message off it instead of String()'ing it +// into "[object Object]". +function errorMessage(e: unknown): string { + if (e instanceof Error) return e.message; + if (e && typeof e === "object" && "message" in e) { + return String((e as { message: unknown }).message); + } + return String(e); +} + +export default function App() { + const [todos, setTodos] = useState([]); + const [title, setTitle] = useState(""); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + async function load() { + try { + setTodos((await bool.entities.todos.list("-created_at")) as Todo[]); + setError(null); + } catch (e) { + setError(errorMessage(e)); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + }, []); + + async function add(e: FormEvent) { + e.preventDefault(); + const text = title.trim(); + if (!text) return; + setTitle(""); + await bool.entities.todos.create({ title: text, completed: false }); + load(); + } + + async function toggle(todo: Todo) { + await bool.entities.todos.update(todo.id, { completed: !todo.completed }); + load(); + } + + async function remove(todo: Todo) { + await bool.entities.todos.delete(todo.id); + load(); + } + + return ( +
+

${name}

+
+ setTitle(e.target.value)} + placeholder="What needs doing?" + /> + + + + {error &&

{error}

} + + {loading ? ( +

Loading…

+ ) : ( +
    + {todos.map((t) => ( +
  • + + +
  • + ))} + {todos.length === 0 && ( +
  • Nothing yet — add your first task.
  • + )} +
+ )} + +
+ Built with Bool · scaffolded by bool create +
+
+ ); +} +`, + + "src/index.css": `:root { + color-scheme: light dark; + font-family: system-ui, -apple-system, sans-serif; +} +* { + box-sizing: border-box; +} +body { + margin: 0; + background: Canvas; + color: CanvasText; +} +.app { + max-width: 32rem; + margin: 4rem auto; + padding: 0 1.25rem; +} +h1 { + font-size: 1.75rem; + margin: 0 0 1.25rem; +} +.add { + display: flex; + gap: 0.5rem; + margin-bottom: 1.5rem; +} +.add input { + flex: 1; + padding: 0.6rem 0.75rem; + font-size: 1rem; + border: 1px solid color-mix(in oklab, CanvasText 25%, transparent); + border-radius: 0.5rem; + background: transparent; + color: inherit; +} +.add button { + padding: 0.6rem 1rem; + font-size: 1rem; + border: 0; + border-radius: 0.5rem; + background: CanvasText; + color: Canvas; + cursor: pointer; +} +.list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.25rem; +} +.list li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.6rem 0.25rem; + border-bottom: 1px solid color-mix(in oklab, CanvasText 12%, transparent); +} +.list label { + display: flex; + align-items: center; + gap: 0.6rem; + cursor: pointer; +} +.list li.done span { + text-decoration: line-through; + opacity: 0.55; +} +.del { + border: 0; + background: transparent; + color: inherit; + font-size: 1.25rem; + line-height: 1; + opacity: 0.5; + cursor: pointer; +} +.del:hover { + opacity: 1; +} +.empty { + justify-content: center; + border: 0; +} +.muted { + opacity: 0.6; +} +.error { + color: #d33; +} +footer { + margin-top: 2rem; + font-size: 0.85rem; + text-align: center; +} +`, + + ".gitignore": `node_modules +dist +.env.bool +`, + + // Public todos: one shared list, no per-user isolation, so the deployed app + // reads/writes with no sign-in. id/created_at are managed by Bool. + "bool/entities/todos.jsonc": `{ + "name": "todos", + "type": "object", + "properties": { + "title": { "type": "string", "description": "The task text" }, + "completed": { "type": "boolean", "default": false } + }, + "required": ["title"], + "x-bool-access": "public" +} +`, + }; +} diff --git a/src/zip.test.ts b/src/zip.test.ts new file mode 100644 index 0000000..cbcdb7a --- /dev/null +++ b/src/zip.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test"; +import { crc32, createZip } from "./zip.js"; + +const enc = new TextEncoder(); + +function u32At(buf: Uint8Array, off: number): number { + return (buf[off]! | (buf[off + 1]! << 8) | (buf[off + 2]! << 16) | (buf[off + 3]! << 24)) >>> 0; +} +function u16At(buf: Uint8Array, off: number): number { + return buf[off]! | (buf[off + 1]! << 8); +} + +describe("crc32", () => { + test("matches the standard check vector", () => { + // The canonical CRC-32 test vector: "123456789" → 0xCBF43926. + expect(crc32(enc.encode("123456789"))).toBe(0xcbf43926); + }); + test("empty input", () => { + expect(crc32(new Uint8Array())).toBe(0); + }); +}); + +describe("createZip", () => { + test("produces a structurally valid store-only archive", () => { + const entries = [ + { path: "index.html", data: enc.encode("

hi

") }, + { path: "src/main.ts", data: enc.encode("console.log(1)\n") }, + ]; + const zip = createZip(entries); + + // First local file header signature. + expect(u32At(zip, 0)).toBe(0x04034b50); + + // End-of-central-directory record: last 22 bytes (no comment). + const eocd = zip.length - 22; + expect(u32At(zip, eocd)).toBe(0x06054b50); + expect(u16At(zip, eocd + 8)).toBe(2); // total entries + const cdSize = u32At(zip, eocd + 12); + const cdOffset = u32At(zip, eocd + 16); + expect(cdOffset + cdSize).toBe(eocd); + + // First central directory header + its filename. + expect(u32At(zip, cdOffset)).toBe(0x02014b50); + const nameLen = u16At(zip, cdOffset + 28); + const name = new TextDecoder().decode( + zip.slice(cdOffset + 46, cdOffset + 46 + nameLen), + ); + expect(name).toBe("index.html"); + + // Stored (method 0) with the right CRC + sizes for entry 0. + expect(u16At(zip, cdOffset + 10)).toBe(0); // method + expect(u32At(zip, cdOffset + 16)).toBe(crc32(entries[0]!.data)); + expect(u32At(zip, cdOffset + 20)).toBe(entries[0]!.data.length); + + // The raw bytes are embedded verbatim right after the local header. + const localNameLen = u16At(zip, 26); + const dataStart = 30 + localNameLen; + expect(new TextDecoder().decode(zip.slice(dataStart, dataStart + 11))).toBe("

hi

"); + }); + + test("unzips with the system unzip when available (integration sanity)", async () => { + const which = Bun.spawnSync(["sh", "-c", "command -v unzip"]); + if (which.exitCode !== 0) return; // no unzip on this machine — skip silently + const dir = `${process.env.TMPDIR ?? "/tmp"}/bool-sdk-zip-test-${Date.now()}`; + const zipPath = `${dir}/a.zip`; + await Bun.write(zipPath, createZip([{ path: "hello.txt", data: enc.encode("hello world") }]).buffer as ArrayBuffer); + const res = Bun.spawnSync(["unzip", "-o", zipPath, "-d", dir]); + expect(res.exitCode).toBe(0); + expect(await Bun.file(`${dir}/hello.txt`).text()).toBe("hello world"); + }); +}); diff --git a/src/zip.ts b/src/zip.ts new file mode 100644 index 0000000..742b4c0 --- /dev/null +++ b/src/zip.ts @@ -0,0 +1,106 @@ +// Minimal ZIP writer for `bool deploy` — packs a file map into a valid, +// STORE-only (no compression) ZIP archive with zero dependencies. Bool's drop +// pipeline unpacks server-side, so wire size matters less than having no deps; +// source trees are small and the platform's max-archive cap still applies. +// +// Format: local file header + data per entry, then the central directory and +// end-of-central-directory record (the classic ZIP layout, no zip64 — fine for +// < 4 GB archives and < 65k files, both far beyond a project source tree). + +const textEncoder = new TextEncoder(); + +// Standard CRC-32 (the ZIP polynomial), table-driven. +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[n] = c >>> 0; + } + return table; +})(); + +export function crc32(data: Uint8Array): number { + let crc = 0xffffffff; + for (let i = 0; i < data.length; i++) { + crc = CRC_TABLE[(crc ^ data[i]!) & 0xff]! ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + +function u16(v: number): Uint8Array { + return new Uint8Array([v & 0xff, (v >>> 8) & 0xff]); +} +function u32(v: number): Uint8Array { + return new Uint8Array([v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff]); +} +function concat(parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((n, p) => n + p.length, 0); + const out = new Uint8Array(total); + let off = 0; + for (const p of parts) { + out.set(p, off); + off += p.length; + } + return out; +} + +export type ZipEntry = { path: string; data: Uint8Array }; + +/** Build a STORE-only ZIP archive from entries. Paths are archive-relative, + * forward-slashed (e.g. "index.html", "src/App.tsx"). */ +export function createZip(entries: ZipEntry[]): Uint8Array { + const locals: Uint8Array[] = []; + const centrals: Uint8Array[] = []; + let offset = 0; + + for (const entry of entries) { + const name = textEncoder.encode(entry.path); + const crc = crc32(entry.data); + const size = entry.data.length; + // Fixed DOS date/time (ZIP has no "unset"); the platform ignores mtimes. + const dosTime = u16(0); + const dosDate = u16(0x21); // 1980-01-01 + const common = concat([ + u16(20), // version needed + u16(0x0800), // flags: UTF-8 names + u16(0), // method: store + dosTime, + dosDate, + u32(crc), + u32(size), // compressed (= raw for store) + u32(size), // uncompressed + u16(name.length), + u16(0), // extra len + ]); + const local = concat([u32(0x04034b50), common, name, entry.data]); + locals.push(local); + centrals.push( + concat([ + u32(0x02014b50), + u16(20), // version made by + common, + u16(0), // comment len + u16(0), // disk start + u16(0), // internal attrs + u32(0), // external attrs + u32(offset), // local header offset + name, + ]), + ); + offset += local.length; + } + + const centralDir = concat(centrals); + const end = concat([ + u32(0x06054b50), + u16(0), // disk + u16(0), // central dir disk + u16(entries.length), + u16(entries.length), + u32(centralDir.length), + u32(offset), // central dir offset + u16(0), // comment len + ]); + return concat([...locals, centralDir, end]); +}