diff --git a/packages/typegen/CLAUDE.md b/packages/typegen/CLAUDE.md index b95eac8..5584c27 100644 --- a/packages/typegen/CLAUDE.md +++ b/packages/typegen/CLAUDE.md @@ -39,13 +39,17 @@ lives in supabase-js. - `src/languages/dart.ts` -- the first out-of-process entry: `dart run supabase_typegen --output -`. No `--schema` flag: the document on stdin already holds the schemas the consumer introspected. -- `src/languages/swift.ts` -- `supabase-typegen --access-control `, a - prebuilt binary from supabase-swift's releases (SDK-2199). It downloads the - release the nearest `Package.resolved` pins into the user cache, then falls - back to PATH; `SUPABASE_TYPEGEN` overrides both. The only entry that touches - the network or the file system; `createSwift` takes `fetch` and the platform - so tests stay offline. `swift-access-control` maps to `--access-control` and - accepts only `internal` and `public`. Exit `65` is a rejected document. +- `src/languages/swift.ts` -- `supabase-typegen --access-control `, built + from source (SDK-3043) at the supabase-swift commit the nearest + `Package.resolved` pins, with the user's `git` and `swift`, and cached per + commit; then falls back to PATH; `SUPABASE_TYPEGEN` overrides both. Before + fetching it proves the commit is upstream's (tag for a version pin, ancestry + of the branch otherwise), since GitHub serves fork commits by SHA. The build + steps run through `host.spawn` with a temporary folder as `cwd`. The only + entry that touches the network or the file system; `createSwift` takes the + platform, the temporary directory and the progress `log` so tests stay + hermetic. `swift-access-control` maps to `--access-control` and accepts only + `internal` and `public`. Exit `65` is a rejected document. - `src/languages/index.ts` -- the three in-process entries and the `languages` list. `typescript` has no user options; `detect-one-to-one-relationships` (default true; the CLI's deprecated `--postgrest-v9-compat` sets it false), @@ -107,5 +111,6 @@ with `parseGeneratorMetadata`. `test/in-process.test.ts` asserts each bundled language's output equals the direct generator call on sorted input (the byte-identity acceptance criterion, without Docker). `test/dart.test.ts` and `test/swift.test.ts` cover the handoff and every error classification with the -fake host from `test/helpers.ts`; `test/node-host.test.ts` runs a fake `dart` -shell script on PATH. +fake host from `test/helpers.ts`. `test/swift.test.ts` also runs the source +build through `createNodeHost` against fake `git` and `swift` shell scripts on +PATH; `test/node-host.test.ts` runs a fake `dart` shell script on PATH. diff --git a/packages/typegen/README.md b/packages/typegen/README.md index 942f3f2..7b68a46 100644 --- a/packages/typegen/README.md +++ b/packages/typegen/README.md @@ -62,7 +62,7 @@ not. | `typescript` | in-process | `generateTypescript` from `@supabase/postgrest-typegen` | consumer: `detect-one-to-one-relationships`, `postgrest-version`, `default-schema` | | `go` | in-process | `generateGo` from `@supabase/postgrest-typegen` | | | `python` | in-process | `generatePython` from `@supabase/postgrest-typegen` | | -| `swift` | out-of-process | `supabase-typegen --access-control `, fetched | `--swift-access-control internal\|public` | +| `swift` | out-of-process | `supabase-typegen --access-control `, built | `--swift-access-control internal\|public` | | `dart` | out-of-process | `dart run supabase_typegen --output -` in the project | | The three in-process entries are a transition: as each generator relocates to @@ -70,19 +70,55 @@ its SDK repository (SDK-1641), its entry here changes to an out-of-process command, or for TypeScript to an import of the new npm package, and consumers notice nothing but a dependency bump. -Swift moved first. `supabase-typegen` is a prebuilt executable from the -supabase-swift releases, not a package dependency: as a SwiftPM product its +Swift moved first. `supabase-typegen` is an executable in supabase-swift's +`tools/supabase-typegen`, not a package dependency: as a SwiftPM product its swift-format dependency would pin every app's swift-syntax, and an Xcode project without a `Package.swift` has nothing to `swift run` from. -The output targets one version's `@Table` macro, so the binary must match the -project's supabase-swift. The entry reads that version from the nearest -`Package.resolved` (in `cwd` or a parent; SwiftPM's, or Xcode's inside the -`.xcodeproj` or `.xcworkspace`), downloads that release's asset once into -`$XDG_CACHE_HOME/supabase/typegen//` (default `~/Library/Caches` on -macOS, `~/.cache` elsewhere) and runs it. Without a version pin, or on a -platform with no asset (anything but macOS and Linux x86_64), it runs -`supabase-typegen` from PATH. `SUPABASE_TYPEGEN=` overrides both. +The output targets one commit's `@Table` macro, so the generator must match the +project's supabase-swift. The entry reads the supabase-swift pin from the +nearest `Package.resolved` (in `cwd` or a parent; SwiftPM's, or Xcode's inside +the `.xcodeproj` or `.xcworkspace`) and builds the generator from source at the +pin's `revision`, so version, branch and revision pins all work: + +1. The revision must be a 40-character lowercase hex SHA, a version must be a + semantic version and a branch a plain branch name; nothing else reaches + `git`. Only regular files the user owns count as `Package.resolved`, so + another user's file in a shared parent such as `/tmp` cannot pick the commit, + and a malformed one counts as no pin. +2. A binary already in `$XDG_CACHE_HOME/supabase/typegen//` (default + `~/Library/Caches` on macOS, `~/.cache` elsewhere; an empty or relative + `XDG_CACHE_HOME` is ignored) runs with no build. +3. Otherwise it checks that `git` and `swift` (Swift 6.2 / Xcode 26 or newer) + are on PATH and that the commit is upstream's. GitHub serves every commit of + the fork network through the upstream URL, pull request heads included, so a + version pin's tag (`` or `v`, per `git ls-remote`) must + point at the revision, and a branch pin's revision must be an ancestor of + that upstream branch (a bare revision pin: the default branch), checked in a + commits-only clone (`--filter=tree:0`, about 1.5 MB and a second). It then + fetches the single commit into a temporary folder, checks that `HEAD` is the + revision, and runs + `swift build -c release --package-path tools/supabase-typegen --disable-automatic-resolution`. + The flag builds the generator's committed `Package.resolved` exactly and + fails rather than resolve other dependency versions. Linux adds + `--static-swift-stdlib`, so the cached binary keeps working after the + toolchain that built it is removed or upgraded. Git's repository variables + (`GIT_DIR`, `GIT_INDEX_FILE` and the rest of + `git rev-parse --local-env-vars`) are removed from these steps, so running + inside a git hook cannot touch the user's repository. +4. For a version pin, `supabase-typegen --version` must print that version. +5. The binary is copied beside its cache path and renamed into place, so + concurrent runs are safe. The temporary folder is removed in every case. + +The first build prints a line to stderr and takes a few minutes, mostly +compiling swift-syntax: about 5.5 minutes on an Apple M3 with Swift 6.4, and +about the same on Linux aarch64 with Swift 6.2 in Docker on that machine. It +trusts nothing the app does not already run: the supabase-swift source at the +pinned commit, the generator's pinned dependencies and the user's toolchain. +supabase-swift 2.x has no `tools/supabase-typegen` and fails with a request to +update to 3.0.0-alpha.1 or newer. Without a supabase-swift pin, or on Windows, +the entry runs `supabase-typegen` from PATH. `SUPABASE_TYPEGEN=` overrides +both. `--swift-access-control` lost `private` and `package`, which the new generator does not accept. diff --git a/packages/typegen/src/contract.ts b/packages/typegen/src/contract.ts index 4cd8eb5..c82c625 100644 --- a/packages/typegen/src/contract.ts +++ b/packages/typegen/src/contract.ts @@ -8,7 +8,10 @@ export interface SpawnRequest { /** Executable name, resolved against `PATH` by the host (for example `dart`). */ readonly command: string; readonly args: readonly string[]; - /** Directory to run in; always the host's `cwd`, the user's project. */ + /** + * Directory to run in: the host's `cwd`, the user's project, for a + * generator, or a directory the language entry chose for a setup step. + */ readonly cwd: string; readonly env: Readonly>; /** Complete document to write to the child's stdin before closing it. */ diff --git a/packages/typegen/src/errors.ts b/packages/typegen/src/errors.ts index a1cd673..06f6ea0 100644 --- a/packages/typegen/src/errors.ts +++ b/packages/typegen/src/errors.ts @@ -61,7 +61,10 @@ export class SpawnUnavailableError extends TypegenError { } } -/** An out-of-process generator exited unsuccessfully. */ +/** + * An out-of-process generator, or a step that builds one, exited + * unsuccessfully. + */ export class ToolFailedError extends TypegenError { readonly command: readonly string[]; readonly exitCode: number | null; diff --git a/packages/typegen/src/languages/external.ts b/packages/typegen/src/languages/external.ts index 961b395..4c8a3f9 100644 --- a/packages/typegen/src/languages/external.ts +++ b/packages/typegen/src/languages/external.ts @@ -44,7 +44,8 @@ export interface ExternalTool { classify?(result: SpawnResult): ToolFailure | undefined; } -const isCommandNotFound = (error: unknown): boolean => +/** Whether `Host.spawn` rejected because the command is not on PATH. */ +export const isCommandNotFound = (error: unknown): boolean => typeof error === "object" && error !== null && "code" in error && diff --git a/packages/typegen/src/languages/swift.ts b/packages/typegen/src/languages/swift.ts index 4d556b6..dcef7c2 100644 --- a/packages/typegen/src/languages/swift.ts +++ b/packages/typegen/src/languages/swift.ts @@ -1,23 +1,45 @@ import { existsSync } from "node:fs"; import { chmod, + copyFile, mkdir, + mkdtemp, readdir, readFile, rename, - writeFile, + rm, + stat, } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { gunzipSync } from "node:zlib"; -import type { ChoiceOptionSpec, Host, TypegenLanguage } from "../contract.ts"; -import { ToolNotInstalledError, TypegenError } from "../errors.ts"; +import { homedir, tmpdir } from "node:os"; +import { basename, dirname, isAbsolute, join } from "node:path"; +import type { + ChoiceOptionSpec, + Host, + SpawnRequest, + SpawnResult, + TypegenLanguage, +} from "../contract.ts"; +import { + ToolFailedError, + ToolNotInstalledError, + TypegenError, +} from "../errors.ts"; import { resolveOptions } from "../options.ts"; -import { externalLanguage } from "./external.ts"; +import { externalLanguage, isCommandNotFound } from "./external.ts"; const SWIFT_ACCESS_CONTROL = "swift-access-control"; const BINARY = "supabase-typegen"; -const RELEASES = "https://github.com/supabase/supabase-swift/releases"; +const SOURCE = "https://github.com/supabase/supabase-swift"; const REPOSITORY = /github\.com[/:]supabase\/supabase-swift(\.git)?\/?$/i; +const PACKAGE_PATH = "tools/supabase-typegen"; +const TOOLCHAIN = "Swift 6.2 / Xcode 26"; +const FIRST_RELEASE = "3.0.0-alpha.1"; +/** The only revision form ever passed to git, so a pin cannot smuggle in an option. */ +const COMMIT = /^[0-9a-f]{40}$/; +/** Semantic versions only: a pinned version reaches git as a tag name and the terminal as text. */ +const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; +/** Branch names git accepts, minus anything that could read as an option or a refspec. */ +const BRANCH = /^(?!-)(?!.*\.\.)[A-Za-z0-9._/-]+$/; /** * The flag `supabase gen types` already exposes, narrowed to the two levels @@ -33,19 +55,21 @@ const swiftAccessControl = { help: "Access control for Swift generated types.", } satisfies ChoiceOptionSpec; -const installHint = `Set SUPABASE_TYPEGEN to a \`${BINARY}\` binary, or put one on PATH. Download it from the supabase-swift release your project depends on (${RELEASES}).`; - -/** Release asset names, keyed by `process.platform` and `process.arch`. */ -const assets: Readonly> = { - "darwin-arm64": "supabase-typegen-macos-universal.tar.gz", - "darwin-x64": "supabase-typegen-macos-universal.tar.gz", - "linux-x64": "supabase-typegen-linux-x86_64.tar.gz", -}; +const installHint = `Install git and ${TOOLCHAIN} or newer so \`${BINARY}\` can be built from the supabase-swift commit your project pins, or set SUPABASE_TYPEGEN to a \`${BINARY}\` binary, or put one on PATH.`; export interface SwiftDependencies { + /** `process.platform`. Windows skips the source build. */ readonly platform: string; - readonly arch: string; - readonly fetch: (url: string, init?: RequestInit) => Promise; + /** Where the source build makes its scratch checkout. */ + readonly temporaryDirectory: () => string; + /** Progress the user should see while the generator builds. */ + readonly log: (message: string) => void; + /** + * `process.getuid()`. A `Package.resolved` owned by anyone else is ignored, + * so another user's file in a shared parent such as `/tmp` cannot pick the + * commit that gets built. `undefined` skips the check. + */ + readonly userId: () => number | undefined; } /** @@ -53,24 +77,32 @@ export interface SwiftDependencies { * on stdin and writes one file of `@Table` structs to stdout. * * The generated code targets the `@Table` macro of one supabase-swift - * version, so the binary must come from the same release as the project's - * dependency. The entry reads that version from the nearest - * `Package.resolved` (SwiftPM, or Xcode's copy inside the `.xcodeproj` or - * `.xcworkspace`), downloads the release's binary once into the user's cache - * and runs it. Without a version pin, or on a platform with no release asset, - * it runs `supabase-typegen` from PATH. `SUPABASE_TYPEGEN` overrides both. + * commit, so the generator must come from the same commit as the project's + * dependency. The entry reads that commit from the nearest `Package.resolved` + * (SwiftPM, or Xcode's copy inside the `.xcodeproj` or `.xcworkspace`), + * builds the generator there from source once with the user's own `git` and + * `swift`, caches the binary per commit and runs it. Without a supabase-swift + * pin, or on Windows, it runs `supabase-typegen` from PATH. + * `SUPABASE_TYPEGEN` overrides both. * - * It is a binary, not a package dependency, because its swift-format - * dependency would pin every app's swift-syntax, and because an Xcode project - * without a `Package.swift` has nothing to `swift run` from. No `--schema` is - * passed: the document already holds exactly the schemas the consumer - * introspected, and the tool generates all of them. + * A source build trusts nothing beyond what the app already runs: the + * supabase-swift source at the pinned commit, the generator's committed + * `Package.resolved`, and the user's toolchain. GitHub serves every commit of + * the fork network, pull request heads included, through the upstream URL, so + * the commit must first be shown to be upstream's: a version pin's tag must + * point at it, and any other pin must be on its upstream branch. It is a separate executable, + * not a package dependency, because its swift-format dependency would pin + * every app's swift-syntax, and because an Xcode project without a + * `Package.swift` has nothing to `swift run` from. No `--schema` is passed: + * the document already holds exactly the schemas the consumer introspected, + * and the tool generates all of them. */ export function createSwift( dependencies: SwiftDependencies = { platform: process.platform, - arch: process.arch, - fetch: (url, init) => fetch(url, init), + temporaryDirectory: tmpdir, + log: (message) => process.stderr.write(`${message}\n`), + userId: () => process.getuid?.(), }, ): TypegenLanguage { const options = [swiftAccessControl]; @@ -99,77 +131,333 @@ export const swift = createSwift(); async function resolveBinary( host: Host, - { platform, arch, fetch }: SwiftDependencies, + dependencies: SwiftDependencies, ): Promise { const override = host.env.SUPABASE_TYPEGEN; if (override) { return override; } - const asset = assets[`${platform}-${arch}`]; - const version = asset ? await pinnedVersion(host.cwd) : undefined; - if (!asset || !version) { + const { spawn } = host; + // Without spawn, externalLanguage reports SpawnUnavailableError for the generator itself. + if (dependencies.platform === "win32" || spawn === undefined) { return BINARY; } - const path = join(cacheDirectory(host.env, platform), version, BINARY); - if (existsSync(path)) { - return path; + const pin = await pinnedState(host.cwd, dependencies.userId()); + if (pin?.revision === undefined) { + return BINARY; } - const url = `${RELEASES}/download/v${version}/${asset}`; - const response = await fetch(url, host.signal ? { signal: host.signal } : {}); - if (response.status === 404) { - throw new ToolNotInstalledError({ + const { revision, version, branch } = pin; + const invalid = (field: string, value: string, expected: string) => + new TypegenError({ language: "swift", - tool: BINARY, - installHint, - message: `supabase-swift ${version}, which ${host.cwd} depends on, has no \`${BINARY}\` release asset (${url}). Update supabase-swift to a release that ships it, or: ${installHint}`, + message: `The supabase-swift pin in ${host.cwd}'s Package.resolved has ${field} ${JSON.stringify(value)}, which is not ${expected}. Resolve the package again, or: ${installHint}`, }); + if (!COMMIT.test(revision)) { + throw invalid( + "revision", + revision, + "a 40-character lowercase hex commit SHA", + ); } - if (!response.ok) { - throw new TypegenError({ - language: "swift", - message: `Downloading ${url} failed with HTTP ${response.status}. ${installHint}`, - }); + if (version !== undefined && !VERSION.test(version)) { + throw invalid("version", version, "a semantic version"); } - const binary = extract( - gunzipSync(new Uint8Array(await response.arrayBuffer())), + if (version === undefined && branch !== undefined && !BRANCH.test(branch)) { + throw invalid("branch", branch, "a plain branch name"); + } + const path = join( + cacheDirectory(host.env, dependencies.platform), + revision, BINARY, ); - if (!binary) { - throw new TypegenError({ - language: "swift", - message: `${url} does not contain \`${BINARY}\`. ${installHint}`, + if (!existsSync(path)) { + await build({ + host, + spawn, + dependencies, + pin: { revision, version, branch }, + path, }); } - // Written beside the target and renamed, so a concurrent run never sees a partial file. - await mkdir(dirname(path), { recursive: true }); - const partial = `${path}.${process.pid}.partial`; - await writeFile(partial, binary); - await chmod(partial, 0o755); - await rename(partial, path); return path; } +interface Build { + readonly host: Host; + readonly spawn: NonNullable; + readonly dependencies: SwiftDependencies; + /** A validated pin: the revision is a SHA, the version a semantic version, the branch plain. */ + readonly pin: { readonly revision: string } & PinState; + /** Where the finished binary goes in the cache. */ + readonly path: string; +} + +/** + * Checks that `pin.revision` is upstream's, checks it out into a scratch + * folder, builds the generator with its committed dependency versions and + * moves the binary to `path`. The scratch folder is removed whatever happens, + * and the cache only ever receives a binary that built and, for a version pin, + * reports that version. + */ +async function build({ + host, + spawn, + dependencies, + pin, + path, +}: Build): Promise { + const { revision, version, branch } = pin; + const label = version ?? revision; + const exec = async ( + command: string, + args: readonly string[], + cwd: string, + ): Promise => { + const request: SpawnRequest = { + command, + args, + cwd, + env: buildEnvironment(host.env), + stdin: "", + signal: host.signal, + }; + try { + return await spawn(request); + } catch (error) { + if (isCommandNotFound(error)) { + throw new ToolNotInstalledError({ + language: "swift", + tool: command, + installHint, + message: `Building \`${BINARY}\` ${label} from source needs \`${command}\` and ${TOOLCHAIN} or newer, and \`${command}\` was not found on PATH. ${installHint}`, + cause: error, + }); + } + throw error; + } + }; + const run = async ( + command: string, + args: readonly string[], + cwd: string, + ): Promise => { + const result = await exec(command, args, cwd); + if (result.exitCode !== 0) { + const commandLine = [command, ...args]; + const output = [result.stdout.trim(), result.stderr.trim()] + .filter(Boolean) + .join("\n"); + throw new ToolFailedError({ + language: "swift", + command: commandLine, + exitCode: result.exitCode, + stderr: result.stderr.trim(), + message: `Building \`${BINARY}\` ${label} from source failed: \`${commandLine.join(" ")}\` ${ + result.exitCode === null + ? "was terminated by a signal" + : `exited with code ${result.exitCode}` + }.${output ? `\n${output}` : ""}`, + }); + } + return result; + }; + + await run("git", ["--version"], host.cwd); + await run("swift", ["--version"], host.cwd); + dependencies.log( + `Building ${BINARY} ${label} from source (supabase-swift ${revision}). This takes a few minutes; later runs use the cached build.`, + ); + const scratch = await mkdtemp( + join(dependencies.temporaryDirectory(), `${BINARY}-`), + ); + // Named after the scratch folder, which is unique, so concurrent runs never share one. + const partial = `${path}.${process.pid}.${basename(scratch)}.partial`; + const checkout = join(scratch, "src"); + try { + if (version !== undefined) { + // A tag can only come from upstream's maintainers; annotated tags list the commit as `^{}`. + const tags = [version, `v${version}`].map((name) => `refs/tags/${name}`); + const listed = await run( + "git", + ["ls-remote", SOURCE, ...tags.flatMap((tag) => [tag, `${tag}^{}`])], + scratch, + ); + const commits = new Map( + listed.stdout + .split("\n") + .map((line) => line.split("\t")) + .map(([sha, ref]) => [ref?.trim(), sha?.trim()]), + ); + if ( + !tags.some( + (tag) => (commits.get(`${tag}^{}`) ?? commits.get(tag)) === revision, + ) + ) { + throw new TypegenError({ + language: "swift", + message: `supabase-swift's ${version} tag does not point at ${revision}, the revision Package.resolved pins. Resolve the package again; a revision from a fork or a pull request is not built.`, + }); + } + } else { + // Only the commits of one branch, about 1.5 MB, to show the revision is on it. + const branchArgs = branch === undefined ? [] : ["--branch", branch]; + await run( + "git", + [ + "clone", + "--bare", + "--filter=tree:0", + "--no-tags", + "--single-branch", + ...branchArgs, + SOURCE, + "history.git", + ], + scratch, + ); + const ancestry = await exec( + "git", + ["merge-base", "--is-ancestor", revision, "HEAD"], + join(scratch, "history.git"), + ); + if (ancestry.exitCode !== 0) { + throw new TypegenError({ + language: "swift", + message: `${revision} is not on supabase-swift's ${branch ?? "default"} branch. Resolve the package again; a revision from a fork or a pull request is not built.`, + }); + } + } + await mkdir(checkout); + await run("git", ["init"], checkout); + await run("git", ["fetch", "--depth", "1", SOURCE, revision], checkout); + await run("git", ["checkout", "--detach", "FETCH_HEAD"], checkout); + const head = (await run("git", ["rev-parse", "HEAD"], checkout)).stdout; + if (head.trim() !== revision) { + throw new TypegenError({ + language: "swift", + message: `Fetching supabase-swift ${revision} checked out ${head.trim()} instead.`, + }); + } + if (!existsSync(join(checkout, PACKAGE_PATH, "Package.swift"))) { + throw new ToolNotInstalledError({ + language: "swift", + tool: BINARY, + installHint: `Update supabase-swift to ${FIRST_RELEASE} or newer.`, + message: `supabase-swift ${label} has no ${BINARY}; update to ${FIRST_RELEASE} or newer.`, + }); + } + // --disable-automatic-resolution builds the committed Package.resolved exactly, or fails. + // On Linux the binary would otherwise load the Swift runtime from the toolchain that + // built it, and break for good once that toolchain is removed or upgraded. + await run( + "swift", + [ + "build", + "-c", + "release", + "--package-path", + PACKAGE_PATH, + "--disable-automatic-resolution", + ...(dependencies.platform === "linux" ? ["--static-swift-stdlib"] : []), + ], + checkout, + ); + const built = join(checkout, PACKAGE_PATH, ".build", "release", BINARY); + if (version !== undefined) { + const reported = ( + await run(built, ["--version"], checkout) + ).stdout.trim(); + if (reported !== version) { + throw new TypegenError({ + language: "swift", + message: `The \`${BINARY}\` built from supabase-swift ${revision} reports version ${reported}, but Package.resolved pins ${version}.`, + }); + } + } + // Copied beside the target and renamed, so a concurrent run never sees a partial file. + await mkdir(dirname(path), { recursive: true }); + await copyFile(built, partial); + await chmod(partial, 0o755); + await rename(partial, path); + } finally { + await rm(partial, { force: true }); + await rm(scratch, { recursive: true, force: true }); + } +} + +/** + * What git exports to hooks, from `git rev-parse --local-env-vars`. Inherited, + * they would point the scratch checkout, and SwiftPM's own git calls, at the + * user's repository. `undefined` removes a variable even from a host that + * merges `process.env` in, since Node and Bun skip undefined values. + */ +const GIT_REPOSITORY_VARIABLES = [ + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_COMMON_DIR", + "GIT_CONFIG", + "GIT_CONFIG_COUNT", + "GIT_CONFIG_PARAMETERS", + "GIT_DIR", + "GIT_GRAFT_FILE", + "GIT_IMPLICIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_NO_REPLACE_OBJECTS", + "GIT_OBJECT_DIRECTORY", + "GIT_PREFIX", + "GIT_REPLACE_REF_BASE", + "GIT_SHALLOW_FILE", + "GIT_WORK_TREE", +]; + +function buildEnvironment( + env: Readonly>, +): Record { + return { + ...env, + ...Object.fromEntries( + GIT_REPOSITORY_VARIABLES.map((name) => [name, undefined]), + ), + // A credential prompt would hang the CLI; the repository is public. + GIT_TERMINAL_PROMPT: "0", + }; +} + +/** Relative or empty values are ignored, as the XDG base directory spec asks. */ function cacheDirectory( env: Readonly>, platform: string, ): string { + const absolute = (path: string | undefined) => + path && isAbsolute(path) ? path : undefined; const base = - env.XDG_CACHE_HOME ?? - join(env.HOME ?? "", platform === "darwin" ? "Library/Caches" : ".cache"); + absolute(env.XDG_CACHE_HOME) ?? + join( + absolute(env.HOME) ?? homedir(), + platform === "darwin" ? "Library/Caches" : ".cache", + ); return join(base, "supabase", "typegen"); } +interface PinState { + readonly version?: string; + readonly branch?: string; + readonly revision?: string; +} + /** - * The supabase-swift version pinned by the nearest `Package.resolved`, looking - * in `cwd` and then each parent. Returns `undefined` when nothing pins it to a - * version, for example a branch or revision pin. + * The supabase-swift pin of the nearest `Package.resolved` that has one, + * looking in `cwd` and then each parent. Only regular files that `userId` + * owns count. */ -async function pinnedVersion(cwd: string): Promise { +async function pinnedState( + cwd: string, + userId: number | undefined, +): Promise { for (let directory = cwd; ; directory = dirname(directory)) { - for (const file of await resolvedFiles(directory)) { - const version = versionIn(await readFile(file, "utf8")); - if (version) { - return version; + for (const file of await resolvedFiles(directory, userId)) { + const state = stateIn(await readFile(file, "utf8")); + if (state) { + return state; } } if (dirname(directory) === directory) { @@ -178,9 +466,12 @@ async function pinnedVersion(cwd: string): Promise { } } -async function resolvedFiles(directory: string): Promise { +async function resolvedFiles( + directory: string, + userId: number | undefined, +): Promise { const entries = await readdir(directory).catch(() => []); - return [ + const candidates = [ join(directory, "Package.resolved"), ...entries .filter((entry) => entry.endsWith(".xcworkspace")) @@ -196,54 +487,55 @@ async function resolvedFiles(directory: string): Promise { "project.xcworkspace/xcshareddata/swiftpm/Package.resolved", ), ), - ].filter((file) => existsSync(file)); + ]; + const usable = await Promise.all( + candidates.map(async (file) => { + const status = await stat(file).catch(() => undefined); + // A FIFO would block the read forever; a file of another user is not the project's. + return ( + status?.isFile() === true && + (userId === undefined || status.uid === userId) + ); + }), + ); + return candidates.filter((_file, index) => usable[index]); } -interface Pin { - readonly location?: string; - readonly repositoryURL?: string; - readonly state?: { readonly version?: string }; -} +const record = (value: unknown): Record | undefined => + typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; -/** Reads every `Package.resolved` format: version 1 nests the pins under `object`. */ -function versionIn(contents: string): string | undefined { - let parsed: { pins?: Pin[]; object?: { pins?: Pin[] } }; +const text = (value: unknown): string | undefined => + typeof value === "string" ? value : undefined; + +/** + * Reads every `Package.resolved` format (version 1 nests the pins under + * `object`). Anything that is not the expected shape counts as no pin. + */ +function stateIn(contents: string): PinState | undefined { + let parsed: unknown; try { parsed = JSON.parse(contents); } catch { return undefined; } - const pins = parsed.pins ?? parsed.object?.pins ?? []; - return pins.find((pin) => - REPOSITORY.test(pin.location ?? pin.repositoryURL ?? ""), - )?.state?.version; -} - -/** - * The regular file called `name` in an uncompressed tar archive. Enough of the - * format for the release assets: 512-byte headers, the name at offset 0, the - * size in octal at 124, the type at 156, data padded to 512 bytes. - */ -function extract(archive: Uint8Array, name: string): Uint8Array | undefined { - const text = new TextDecoder(); - for (let offset = 0; offset + 512 <= archive.length;) { - const header = archive.subarray(offset, offset + 512); - const entry = text.decode(header.subarray(0, 100)).replace(/\0.*$/s, ""); - if (entry === "") { - return undefined; - } - const size = Number.parseInt( - text.decode(header.subarray(124, 136)).replace(/\0.*$/s, "").trim() || - "0", - 8, - ); - const type = header[156]; - const start = offset + 512; - // Type "0" or NUL is a regular file; pax ("x", "g") and others are skipped. - if ((type === 0x30 || type === 0) && entry.replace(/^\.\//, "") === name) { - return archive.slice(start, start + size); + const root = record(parsed); + const pins = root?.pins ?? record(root?.object)?.pins; + if (!Array.isArray(pins)) { + return undefined; + } + for (const entry of pins) { + const pin = record(entry); + const location = text(pin?.location) ?? text(pin?.repositoryURL) ?? ""; + if (REPOSITORY.test(location)) { + const state = record(pin?.state); + return { + version: text(state?.version), + branch: text(state?.branch), + revision: text(state?.revision), + }; } - offset = start + Math.ceil(size / 512) * 512; } return undefined; } diff --git a/packages/typegen/test/swift.test.ts b/packages/typegen/test/swift.test.ts index 09ba2ac..948e44b 100644 --- a/packages/typegen/test/swift.test.ts +++ b/packages/typegen/test/swift.test.ts @@ -1,9 +1,13 @@ -import { beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { execFileSync } from "node:child_process"; import { + existsSync, mkdirSync, mkdtempSync, + readdirSync, readFileSync, + realpathSync, + rmSync, statSync, writeFileSync, } from "node:fs"; @@ -14,6 +18,7 @@ import { sortGeneratorMetadata, } from "@supabase/postgrest-typegen"; import { + createNodeHost, InvalidOptionError, MetadataRejectedError, swift, @@ -76,9 +81,7 @@ describe("swift", () => { expect(error).toBeInstanceOf(ToolNotInstalledError); const notInstalled = error as ToolNotInstalledError; expect(notInstalled.tool).toBe("supabase-typegen"); - expect(notInstalled.installHint).toContain( - "https://github.com/supabase/supabase-swift/releases", - ); + expect(notInstalled.installHint).toContain("SUPABASE_TYPEGEN"); }); test("reports exit 65 as a rejected document", async () => { @@ -109,105 +112,310 @@ describe("swift", () => { }); }); -describe("swift binary resolution", () => { - const binary = "#!/bin/sh\necho generated\n"; - let tarball: Uint8Array; +const REVISION = "0123456789abcdef0123456789abcdef01234567"; + +/** + * Stand-ins for `git` and `swift`, put alone on PATH. Each appends its command + * line to `$FAKE_LOG`. `git` fakes a fetch of the revision it is given, and + * checking it out creates `tools/supabase-typegen` unless `FAKE_NO_TYPEGEN` is + * set. `swift build` writes a `supabase-typegen` that prints + * `$FAKE_TYPEGEN_VERSION` for `--version` and `generated` otherwise. The + * `FAKE_*_FAIL` variables make a step fail the way the real tool does. Both + * fail if a git repository variable reaches them. They + * call coreutils by absolute path because PATH holds only the fakes. + */ +const FAKE_GIT = `#!/bin/sh +echo "git $*" >> "$FAKE_LOG" +if [ -n "$GIT_DIR$GIT_WORK_TREE$GIT_INDEX_FILE$GIT_OBJECT_DIRECTORY" ]; then + echo "a repository variable leaked in" >&2 + exit 3 +fi +case "$1" in + --version) echo "git version 2.50.0" ;; + ls-remote) + if [ -n "$FAKE_TAGS" ]; then printf '%b' "$FAKE_TAGS" + else printf '%s\\trefs/tags/v3.0.0-alpha.1\\n' ${REVISION}; fi ;; + clone) + for argument; do directory=$argument; done + /bin/mkdir "$directory" ;; + merge-base) if [ -n "$FAKE_NOT_ON_BRANCH" ]; then exit 1; fi ;; + init) /bin/mkdir .git ;; + fetch) + if [ -n "$FAKE_FETCH_FAIL" ]; then + echo "fatal: remote error: upload-pack: not our ref $5" >&2 + exit 128 + fi + for argument; do revision=$argument; done + echo "$revision" > .git/FAKE_FETCH_HEAD ;; + checkout) + /bin/cp .git/FAKE_FETCH_HEAD .git/FAKE_HEAD + if [ -z "$FAKE_NO_TYPEGEN" ]; then + /bin/mkdir -p tools/supabase-typegen + echo "// swift-tools-version:6.2" > tools/supabase-typegen/Package.swift + fi ;; + rev-parse) if [ -n "$FAKE_HEAD" ]; then echo "$FAKE_HEAD"; else /bin/cat .git/FAKE_HEAD; fi ;; + *) echo "unexpected git $*" >&2; exit 2 ;; +esac +`; + +const FAKE_SWIFT = `#!/bin/sh +echo "swift $*" >> "$FAKE_LOG" +if [ -n "$GIT_DIR$GIT_WORK_TREE$GIT_INDEX_FILE$GIT_OBJECT_DIRECTORY" ]; then + echo "a repository variable leaked in" >&2 + exit 3 +fi +case "$1" in + --version) echo "Swift version 6.2" ;; + build) + if [ -n "$FAKE_BUILD_FAIL" ]; then + echo "Sources/SupabaseTypegen/Typegen.swift:1:1: error: cannot find 'x' in scope" + echo "error: fatalError" >&2 + exit 1 + fi + /bin/mkdir -p tools/supabase-typegen/.build/release + binary=tools/supabase-typegen/.build/release/supabase-typegen + printf '#!/bin/sh\\necho "typegen $*" >> "$FAKE_LOG"\\nif [ "$1" = --version ]; then echo %s; exit 0; fi\\n/bin/cat > /dev/null\\necho generated\\n' "\${FAKE_TYPEGEN_VERSION:-3.0.0-alpha.1}" > "$binary" + /bin/chmod 755 "$binary" ;; + *) echo "unexpected swift $*" >&2; exit 2 ;; +esac +`; + +describe("swift source build", () => { let root: string; + let bin: string; + let scratch: string; + let logFile: string; + let messages: string[]; - beforeAll(() => { - const source = mkdtempSync(join(tmpdir(), "typegen-asset-")); - writeFileSync(join(source, "supabase-typegen"), binary, { mode: 0o755 }); - // The system tar, as the supabase-swift release job uses: bsdtar on macOS adds pax headers. - execFileSync( - "tar", - ["-czf", "asset.tar.gz", "-C", source, "supabase-typegen"], - { - cwd: source, - }, - ); - tarball = new Uint8Array(readFileSync(join(source, "asset.tar.gz"))); + beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), "typegen-swift-"))); + bin = join(root, "bin"); + scratch = join(root, "tmp"); + logFile = join(root, "commands.log"); + messages = []; + mkdirSync(bin); + mkdirSync(scratch); + writeFileSync(logFile, ""); + writeFileSync(join(bin, "git"), FAKE_GIT, { mode: 0o755 }); + writeFileSync(join(bin, "swift"), FAKE_SWIFT, { mode: 0o755 }); }); - beforeEach(() => { - root = mkdtempSync(join(tmpdir(), "typegen-swift-")); + afterEach(() => { + rmSync(root, { recursive: true, force: true }); }); - const resolvedV2 = (version: string) => + const pin = (state: Record) => JSON.stringify({ pins: [ { identity: "swift-crypto", location: "https://github.com/apple/swift-crypto.git", - state: { version: "3.0.0" }, + state: { version: "3.0.0", revision: "f".repeat(40) }, }, { identity: "supabase-swift", location: "https://github.com/supabase/supabase-swift.git", - state: { version, revision: "abc" }, + state, }, ], version: 2, }); + const versionPin = (version = "3.0.0-alpha.1") => + pin({ version, revision: REVISION }); + const project = (files: Record) => { const cwd = join(root, "app"); + mkdirSync(cwd, { recursive: true }); for (const [path, contents] of Object.entries(files)) { mkdirSync(dirname(join(cwd, path)), { recursive: true }); writeFileSync(join(cwd, path), contents); } - mkdirSync(cwd, { recursive: true }); return cwd; }; - const setup = ( + const make = (platform = "darwin", userId = process.getuid?.()) => + createSwift({ + platform, + temporaryDirectory: () => scratch, + log: (message) => messages.push(message), + userId: () => userId, + }); + + const run = ( cwd: string, - respond: (url: string) => Response = () => - new Response(tarball, { status: 200 }), env: Record = {}, - ) => { - const urls: string[] = []; - const language = createSwift({ - platform: "darwin", - arch: "arm64", - fetch: async (url) => { - urls.push(String(url)); - return respond(String(url)); - }, + platform = "darwin", + ) => + make(platform).generate( + unsortedMetadata, + {}, + createNodeHost({ + cwd, + env: { + PATH: bin, + HOME: join(root, "home"), + FAKE_LOG: logFile, + ...env, + }, + }), + ); + + const ran = (prefix: string) => + commands().some((line) => line.startsWith(prefix)); + const commands = () => readFileSync(logFile, "utf8").split("\n").slice(0, -1); + const cached = (cache = "home/Library/Caches") => + join(root, cache, "supabase/typegen", REVISION, "supabase-typegen"); + const leftovers = () => [ + ...readdirSync(scratch), + ...(existsSync(dirname(cached())) ? readdirSync(dirname(cached())) : []), + ]; + + test("builds the pinned commit from source, caches the binary and runs it", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + + expect(await run(cwd)).toBe("generated\n"); + + expect(commands()).toEqual([ + "git --version", + "swift --version", + "git ls-remote https://github.com/supabase/supabase-swift refs/tags/3.0.0-alpha.1 refs/tags/3.0.0-alpha.1^{} refs/tags/v3.0.0-alpha.1 refs/tags/v3.0.0-alpha.1^{}", + "git init", + `git fetch --depth 1 https://github.com/supabase/supabase-swift ${REVISION}`, + "git checkout --detach FETCH_HEAD", + "git rev-parse HEAD", + "swift build -c release --package-path tools/supabase-typegen --disable-automatic-resolution", + "typegen --version", + "typegen --access-control internal", + ]); + expect(statSync(cached()).mode & 0o777).toBe(0o755); + expect(readdirSync(dirname(cached()))).toEqual(["supabase-typegen"]); + expect(readdirSync(scratch)).toEqual([]); + expect(messages).toHaveLength(1); + expect(messages[0]).toContain( + "Building supabase-typegen 3.0.0-alpha.1 from source", + ); + expect(messages[0]).toContain("a few minutes"); + }); + + test("runs the cached binary without git or swift", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + await run(cwd); + writeFileSync(logFile, ""); + messages = []; + + expect(await run(cwd)).toBe("generated\n"); + + expect(commands()).toEqual(["typegen --access-control internal"]); + expect(messages).toEqual([]); + }); + + test("builds a branch pin once its revision is on that upstream branch", async () => { + const cwd = project({ + "Package.resolved": pin({ branch: "release/v3", revision: REVISION }), }); - const host = createFakeHost(() => success("code\n"), { - cwd, - env: { HOME: join(root, "home"), ...env }, + + expect(await run(cwd)).toBe("generated\n"); + + expect(commands().slice(2, 5)).toEqual([ + "git clone --bare --filter=tree:0 --no-tags --single-branch --branch release/v3 https://github.com/supabase/supabase-swift history.git", + `git merge-base --is-ancestor ${REVISION} HEAD`, + "git init", + ]); + expect(ran("git ls-remote")).toBe(false); + expect(commands()).not.toContain("typegen --version"); + expect(existsSync(cached())).toBe(true); + }); + + test("checks a bare revision pin against the default branch", async () => { + const cwd = project({ "Package.resolved": pin({ revision: REVISION }) }); + await run(cwd); + expect(commands()).toContain( + "git clone --bare --filter=tree:0 --no-tags --single-branch https://github.com/supabase/supabase-swift history.git", + ); + }); + + test("refuses a revision that is not on its upstream branch, such as a fork's commit", async () => { + const cwd = project({ + "Package.resolved": pin({ branch: "main", revision: REVISION }), }); - return { language, host, urls }; - }; + const error = await rejection(run(cwd, { FAKE_NOT_ON_BRANCH: "1" })); + expect(error).toBeInstanceOf(TypegenError); + expect((error as TypegenError).message).toContain( + `${REVISION} is not on supabase-swift's main branch`, + ); + expect(ran("git fetch")).toBe(false); + expect(leftovers()).toEqual([]); + }); - test("downloads the release the project's Package.resolved pins and runs it", async () => { - const cwd = project({ "Package.resolved": resolvedV2("2.56.0") }); - const { language, host, urls } = setup(cwd); + test("accepts an annotated tag through its peeled commit", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + const tag = "f".repeat(40); + expect( + await run(cwd, { + FAKE_TAGS: `${tag}\\trefs/tags/3.0.0-alpha.1\\n${REVISION}\\trefs/tags/3.0.0-alpha.1^{}\\n`, + }), + ).toBe("generated\n"); + }); - expect(await language.generate(unsortedMetadata, {}, host)).toBe("code\n"); + test("refuses a version pin whose tag points at another commit", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + for (const tags of [ + `${"e".repeat(40)}\\trefs/tags/v3.0.0-alpha.1\\n`, + "", + ]) { + writeFileSync(logFile, ""); + const error = await rejection(run(cwd, { FAKE_TAGS: tags || "\\c" })); + expect(error).toBeInstanceOf(TypegenError); + expect((error as TypegenError).message).toContain( + `supabase-swift's 3.0.0-alpha.1 tag does not point at ${REVISION}`, + ); + expect(ran("git fetch")).toBe(false); + expect(leftovers()).toEqual([]); + } + }); - expect(urls).toEqual([ - "https://github.com/supabase/supabase-swift/releases/download/v2.56.0/supabase-typegen-macos-universal.tar.gz", - ]); - const command = host.requests[0]!.command; - expect(command).toBe( - join( - root, - "home/Library/Caches/supabase/typegen/2.56.0/supabase-typegen", + test("refuses a malformed version or branch before running git", async () => { + const states: Record[] = [ + { version: "3.0.0\u001b]0;x\u0007", revision: REVISION }, + { version: "--upload-pack=x", revision: REVISION }, + { branch: "--upload-pack=x", revision: REVISION }, + { branch: "main:refs/heads/x", revision: REVISION }, + ]; + for (const state of states) { + const cwd = project({ "Package.resolved": pin(state) }); + const error = await rejection(run(cwd)); + expect(error).toBeInstanceOf(TypegenError); + expect((error as TypegenError).message).not.toContain("\u001b"); + expect(commands()).toEqual([]); + } + }); + + test("skips a Package.resolved owned by another user", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + const error = await rejection( + make("darwin", (process.getuid?.() ?? 0) + 1).generate( + unsortedMetadata, + {}, + createNodeHost({ cwd, env: { PATH: bin, FAKE_LOG: logFile } }), ), ); - expect(readFileSync(command, "utf8")).toBe(binary); - expect(statSync(command).mode & 0o777).toBe(0o755); + expect(error).toBeInstanceOf(ToolNotInstalledError); + expect((error as ToolNotInstalledError).tool).toBe("supabase-typegen"); + expect(ran("git")).toBe(false); }); - test("reuses the cached binary", async () => { - const cwd = project({ "Package.resolved": resolvedV2("2.56.0") }); - const { language, host, urls } = setup(cwd); - await language.generate(unsortedMetadata, {}, host); - await language.generate(unsortedMetadata, {}, host); - expect(urls).toHaveLength(1); - expect(host.requests).toHaveLength(2); + test("skips malformed and non-regular Package.resolved files", async () => { + for (const contents of ["null", '{"pins":{}}', '{"pins":[null]}', "[1]"]) { + const cwd = project({ "Package.resolved": contents }); + const error = await rejection(run(cwd)); + expect(error).toBeInstanceOf(ToolNotInstalledError); + } + const cwd = project({}); + rmSync(join(cwd, "Package.resolved"), { force: true }); + execFileSync("/usr/bin/mkfifo", [join(cwd, "Package.resolved")]); + expect(await rejection(run(cwd))).toBeInstanceOf(ToolNotInstalledError); + expect(commands()).toEqual([]); }); test("reads an Xcode project's Package.resolved in the version 1 format", async () => { @@ -219,135 +427,201 @@ describe("swift binary resolution", () => { { package: "Supabase", repositoryURL: "https://github.com/supabase/supabase-swift", - state: { version: "2.40.0" }, + state: { version: "3.0.0-alpha.1", revision: REVISION }, }, ], }, version: 1, }), }); - const { language, host, urls } = setup(cwd); - await language.generate(unsortedMetadata, {}, host); - expect(urls[0]).toContain("/download/v2.40.0/"); + await run(cwd); + expect(existsSync(cached())).toBe(true); }); test("finds Package.resolved in a parent directory", async () => { - const cwd = project({ "Package.resolved": resolvedV2("2.56.0") }); + const cwd = project({ "Package.resolved": versionPin() }); mkdirSync(join(cwd, "Sources/App"), { recursive: true }); - const { language, host, urls } = setup(join(cwd, "Sources/App")); - await language.generate(unsortedMetadata, {}, host); - expect(urls[0]).toContain("/download/v2.56.0/"); - }); - - test("picks the Linux asset on Linux x86_64", async () => { - const cwd = project({ "Package.resolved": resolvedV2("2.56.0") }); - const urls: string[] = []; - const language = createSwift({ - platform: "linux", - arch: "x64", - fetch: async (url) => { - urls.push(String(url)); - return new Response(tarball); - }, - }); - const host = createFakeHost(() => success(""), { - cwd, - env: { HOME: join(root, "home"), XDG_CACHE_HOME: join(root, "xdg") }, - }); - await language.generate(unsortedMetadata, {}, host); - expect(urls[0]).toEndWith("/supabase-typegen-linux-x86_64.tar.gz"); - expect(host.requests[0]!.command).toBe( - join(root, "xdg/supabase/typegen/2.56.0/supabase-typegen"), + await run(join(cwd, "Sources/App")); + expect(existsSync(cached())).toBe(true); + }); + + test("links the Swift runtime statically on Linux", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + await run(cwd, {}, "linux"); + expect(commands()).toContain( + "swift build -c release --package-path tools/supabase-typegen --disable-automatic-resolution --static-swift-stdlib", ); }); - test("falls back to PATH without a version pin", async () => { - const branchPin = JSON.stringify({ - pins: [ - { - identity: "supabase-swift", - location: "https://github.com/supabase/supabase-swift", - state: { branch: "main", revision: "abc" }, - }, - ], - version: 3, - }); - for (const cwd of [ - project({}), - project({ "Package.resolved": branchPin }), - ]) { - const { language, host, urls } = setup(cwd); - await language.generate(unsortedMetadata, {}, host); - expect(urls).toEqual([]); - expect(host.requests.at(-1)!.command).toBe("supabase-typegen"); + test("uses XDG_CACHE_HOME and ~/.cache outside macOS", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + await run(cwd, {}, "linux"); + expect(existsSync(cached("home/.cache"))).toBe(true); + await run(cwd, { XDG_CACHE_HOME: join(root, "xdg") }, "linux"); + expect(existsSync(cached("xdg"))).toBe(true); + }); + + test("ignores an empty or relative XDG_CACHE_HOME", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + for (const XDG_CACHE_HOME of ["", "relative/cache"]) { + await run(cwd, { XDG_CACHE_HOME }, "linux"); + expect(existsSync(cached("home/.cache"))).toBe(true); } }); - test("falls back to PATH on a platform without a release asset", async () => { - const cwd = project({ "Package.resolved": resolvedV2("2.56.0") }); - const language = createSwift({ - platform: "win32", - arch: "x64", - fetch: async () => fail("must not download"), - }); + test("keeps the user's git repository variables away from the build", async () => { + // Git exports these to hooks, for example when gen types runs in pre-commit. + const cwd = project({ "Package.resolved": versionPin() }); + const repository = join(root, "user-repo/.git"); + expect( + await run(cwd, { + GIT_DIR: repository, + GIT_WORK_TREE: dirname(repository), + GIT_INDEX_FILE: join(repository, "index"), + GIT_OBJECT_DIRECTORY: join(repository, "objects"), + }), + ).toBe("generated\n"); + }); + + test("two concurrent first runs both succeed", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + expect(await Promise.all([run(cwd), run(cwd)])).toEqual([ + "generated\n", + "generated\n", + ]); + expect(readdirSync(dirname(cached()))).toEqual(["supabase-typegen"]); + expect(readdirSync(scratch)).toEqual([]); + }); + + test("falls back to PATH without a supabase-swift pin", async () => { + const cwd = project({}); + writeFileSync( + join(bin, "supabase-typegen"), + '#!/bin/sh\necho "typegen $*" >> "$FAKE_LOG"\necho from-path\n', + { mode: 0o755 }, + ); + expect(await run(cwd)).toBe("from-path\n"); + expect(commands()).toEqual(["typegen --access-control internal"]); + }); + + test("falls back to PATH on Windows", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + const language = make("win32"); const host = createFakeHost(() => success(""), { cwd }); await language.generate(unsortedMetadata, {}, host); - expect(host.requests[0]!.command).toBe("supabase-typegen"); + expect(host.requests.map((request) => request.command)).toEqual([ + "supabase-typegen", + ]); }); - test("SUPABASE_TYPEGEN overrides the download", async () => { - const cwd = project({ "Package.resolved": resolvedV2("2.56.0") }); - const { language, host, urls } = setup(cwd, undefined, { - SUPABASE_TYPEGEN: "/opt/dev/supabase-typegen", + test("SUPABASE_TYPEGEN overrides the source build", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + const host = createFakeHost(() => success(""), { + cwd, + env: { SUPABASE_TYPEGEN: "/opt/dev/supabase-typegen" }, }); - await language.generate(unsortedMetadata, {}, host); - expect(urls).toEqual([]); - expect(host.requests[0]!.command).toBe("/opt/dev/supabase-typegen"); + await make().generate(unsortedMetadata, {}, host); + expect(host.requests.map((request) => request.command)).toEqual([ + "/opt/dev/supabase-typegen", + ]); }); - test("reports a release without the binary as not installed", async () => { - const cwd = project({ "Package.resolved": resolvedV2("2.10.0") }); - const { language, host } = setup( - cwd, - () => new Response("Not Found", { status: 404 }), - ); + test("refuses a revision that is not a full commit SHA without running git", async () => { + for (const revision of ["abc", "--upload-pack=touch", "A".repeat(40)]) { + const cwd = project({ + "Package.resolved": pin({ version: "3.0.0-alpha.1", revision }), + }); + const error = await rejection(run(cwd)); + expect(error).toBeInstanceOf(TypegenError); + expect((error as TypegenError).message).toContain("40-character"); + expect(commands()).toEqual([]); + } + }); + + for (const tool of ["git", "swift"]) { + test(`names a missing ${tool} and the minimum toolchain before fetching`, async () => { + rmSync(join(bin, tool)); + const cwd = project({ "Package.resolved": versionPin() }); + const error = await rejection(run(cwd)); + expect(error).toBeInstanceOf(ToolNotInstalledError); + expect((error as ToolNotInstalledError).tool).toBe(tool); + expect((error as ToolNotInstalledError).message).toContain( + "Swift 6.2 / Xcode 26", + ); + expect(ran("git fetch")).toBe(false); + expect(leftovers()).toEqual([]); + }); + } + + test("reports a supabase-swift without supabase-typegen as not installed", async () => { + const cwd = project({ "Package.resolved": versionPin("2.56.0") }); const error = await rejection( - language.generate(unsortedMetadata, {}, host), + run(cwd, { + FAKE_NO_TYPEGEN: "1", + FAKE_TAGS: `${REVISION}\\trefs/tags/2.56.0\\n`, + }), ); expect(error).toBeInstanceOf(ToolNotInstalledError); - expect((error as ToolNotInstalledError).message).toContain("2.10.0"); - expect(host.requests).toHaveLength(0); + expect((error as ToolNotInstalledError).message).toContain( + "supabase-swift 2.56.0 has no supabase-typegen; update to 3.0.0-alpha.1 or newer", + ); + expect(ran("swift build")).toBe(false); + expect(leftovers()).toEqual([]); }); - test("reports any other download failure", async () => { - const cwd = project({ "Package.resolved": resolvedV2("2.56.0") }); - const { language, host } = setup( - cwd, - () => new Response("", { status: 503 }), + test("reports a failed fetch with git's error", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + const error = await rejection(run(cwd, { FAKE_FETCH_FAIL: "1" })); + expect(error).toBeInstanceOf(TypegenError); + expect((error as TypegenError).message).toContain( + "upload-pack: not our ref", ); + expect(leftovers()).toEqual([]); + }); + + test("refuses a checkout whose HEAD is not the pinned revision", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + const error = await rejection(run(cwd, { FAKE_HEAD: "e".repeat(40) })); + expect(error).toBeInstanceOf(TypegenError); + expect((error as TypegenError).message).toContain(REVISION); + expect(ran("swift build")).toBe(false); + expect(leftovers()).toEqual([]); + }); + + test("forwards the swift build errors", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + const error = await rejection(run(cwd, { FAKE_BUILD_FAIL: "1" })); + expect(error).toBeInstanceOf(ToolFailedError); + const { message, exitCode, stderr } = error as ToolFailedError; + expect(exitCode).toBe(1); + expect(stderr).toBe("error: fatalError"); + expect(message).toContain("swift build"); + expect(message).toContain("error: cannot find 'x' in scope"); + expect(message).toContain("error: fatalError"); + expect(leftovers()).toEqual([]); + }); + + test("refuses a binary whose --version differs from the pin, and does not cache it", async () => { + const cwd = project({ "Package.resolved": versionPin() }); const error = await rejection( - language.generate(unsortedMetadata, {}, host), + run(cwd, { FAKE_TYPEGEN_VERSION: "3.0.0-alpha.2" }), ); expect(error).toBeInstanceOf(TypegenError); - expect((error as TypegenError).message).toContain("503"); + expect((error as TypegenError).message).toContain("3.0.0-alpha.2"); + expect(commands()).not.toContain("typegen --access-control internal"); + expect(leftovers()).toEqual([]); }); - test("validates options before downloading", async () => { - const cwd = project({ "Package.resolved": resolvedV2("2.56.0") }); - const { language, host, urls } = setup(cwd); - expect( - await rejection( - language.generate( - unsortedMetadata, - { "swift-access-control": "open" }, - host, - ), + test("validates options before building", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + const error = await rejection( + make().generate( + unsortedMetadata, + { "swift-access-control": "open" }, + createNodeHost({ cwd, env: { PATH: bin, FAKE_LOG: logFile } }), ), - ).toBeInstanceOf(InvalidOptionError); - expect(urls).toEqual([]); + ); + expect(error).toBeInstanceOf(InvalidOptionError); + expect(commands()).toEqual([]); }); }); - -const fail = (message: string): never => { - throw new Error(message); -};