diff --git a/packages/typegen/CLAUDE.md b/packages/typegen/CLAUDE.md index b95eac8..7968694 100644 --- a/packages/typegen/CLAUDE.md +++ b/packages/typegen/CLAUDE.md @@ -39,13 +39,26 @@ 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 a supabase-swift commit picked in this order + (SDK-3045): the `swift-typegen-source` option (a local checkout starting with + `./`, `../`, `/` or `~/`, built in place and never cached; a full SHA; a + version, resolved through its tag; a branch, resolved on every run, all with + `git ls-remote`), the supabase-swift pin of the nearest `Package.resolved` + that has one (`cwd` and parents, then up to two levels down, where the pins + must agree), or the latest 3.x release with a notice. Each run logs the commit + it uses. The binary is cached per commit. There is no PATH fallback and no + `SUPABASE_TYPEGEN`; Windows is refused. Before fetching a commit that did not + come from upstream's own refs 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 value parsing, `git ls-remote` parsing and semver + selection are pure exported functions (`parseTypegenSource`, `parseRefs`, + `compareVersions`, `latestRelease`) with direct tests. 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 +120,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..938e1df 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`, `--swift-typegen-source ` | | `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,102 @@ 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 builds it from source at a supabase-swift +commit, which it picks in this order: + +1. `--swift-typegen-source`, when given. The value is read in a fixed order, + without guessing: + - A value that starts with `./`, `../`, `/` or `~/` is a local checkout. + Relative paths must start with `./` or `../`, so a folder named `3.0.0` is + never read as a version, and `~` means the home folder. The entry checks + that the checkout has `tools/supabase-typegen`, runs + `swift build -c release --package-path /tools/supabase-typegen` + in the checkout's own `.build` folder, so later builds are incremental, and + runs the binary from there. It never caches it, since the working tree can + hold uncommitted changes. This is the form for working on the generator, or + for a fork. + - 40 hex characters are a commit SHA. An abbreviated SHA is refused. + - A semantic version, with or without a leading `v`, is the commit its tag + `v` (or ``) points at, per `git ls-remote`; an annotated + tag's commit wins. A version below 3 is refused, since supabase-swift 2.x + has no generator. + - Anything else is a branch, resolved to its commit with `git ls-remote` on + every run, so new commits are picked up. A branch name that starts with `-` + or holds whitespace, `..`, control characters or anything else git could + read as an option or a refspec is refused before git runs. + + A lookup that finds nothing names what it looked for, and for a branch name + that is also a folder in `cwd`, the `./` form to build it. + +2. The supabase-swift pin of the nearest `Package.resolved` that has one: in + `cwd` or a parent, SwiftPM's or Xcode's inside the `.xcodeproj` or + `.xcworkspace`. Lockfiles without a supabase-swift pin are skipped. For a + monorepo run from its root, when no parent has a pin, the entry also looks up + to two levels below `cwd`, skipping `.git`, `.build`, `node_modules`, `Pods` + and `DerivedData`; the pins found there must agree on the revision, or the + run fails and asks for `--swift-typegen-source`. The pin's `revision` is + built, so version, branch and revision pins all work. +3. The latest supabase-swift 3.x release from `git ls-remote --tags`: the + highest stable 3.x once there is one, the highest 3.x prerelease before that + (with semantic version precedence, so `alpha.10` follows `alpha.9`), and + never another major version. A notice on stderr says so and asks to run from + the app's directory, or to pass `--swift-typegen-source`, to match the pinned + version. + +Every run prints the commit it uses to stderr, for example +``Using branch `main` at `4f2a9c1…`.``. Except for a local checkout, the build +then goes: + +1. Only a 40-character lowercase hex SHA reaches `git` as a revision, a version + must be a semantic version and a branch a plain branch name. 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, for a commit that did not come from upstream's own tags or + branches, that it 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, or a SHA passed to + `--swift-typegen-source`: 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, from the pin, the flag or the latest release, + `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. A +pinned build 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. A version or the latest release trusts the tag at the time of the +run, and a branch anyone who can push to it, which is why a branch is never a +default. supabase-swift 2.x has no `tools/supabase-typegen` and fails with a +request to update to 3.0.0-alpha.1 or newer. The entry never runs a +`supabase-typegen` from PATH, and `SUPABASE_TYPEGEN` is gone: a stale binary +could generate code for the wrong version without a warning, and a local +checkout covers the development loop. Windows is not supported. `--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..3963e74 100644 --- a/packages/typegen/src/languages/swift.ts +++ b/packages/typegen/src/languages/swift.ts @@ -1,23 +1,69 @@ 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, + relative, + resolve, +} from "node:path"; +import type { + ChoiceOptionSpec, + Host, + SpawnRequest, + SpawnResult, + StringOptionSpec, + TypegenLanguage, +} from "../contract.ts"; +import { + InvalidOptionError, + 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 SWIFT_TYPEGEN_SOURCE = "swift-typegen-source"; 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 major version whose latest release is the default without a pin. */ +const MAJOR = 3; +/** The only revision form ever passed to git, so a pin cannot smuggle in an option. */ +const COMMIT = /^[0-9a-f]{40}$/; +/** Hex that could be an abbreviated commit; git abbreviates to 7 characters by default. */ +const SHORT_COMMIT = /^[0-9a-f]{7,39}$/i; +/** Semantic versions only: a 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._/-]+$/; +/** How far below `cwd` a monorepo's lockfiles are searched when no parent has one. */ +const SEARCH_DEPTH = 2; +/** Folders that hold dependencies, build output or history, never an app's lockfile. */ +const SKIPPED_FOLDERS = new Set([ + ".git", + ".build", + "node_modules", + "Pods", + "DerivedData", +]); /** * The flag `supabase gen types` already exposes, narrowed to the two levels @@ -33,19 +79,29 @@ 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}).`; +/** Which supabase-swift source `supabase-typegen` is built from. */ +const swiftTypegenSource = { + name: SWIFT_TYPEGEN_SOURCE, + audience: "user", + kind: "string", + help: "Where to build supabase-typegen from: a supabase-swift version (3.0.0-alpha.1), a branch (main), a full commit SHA, or a local checkout (./supabase-swift). Default: the supabase-swift pin in the nearest Package.resolved, else the latest 3.x release.", +} satisfies StringOptionSpec; -/** 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, which build \`${BINARY}\` from supabase-swift's source. Pass --${SWIFT_TYPEGEN_SOURCE} to choose that source: a version, a branch, a full commit SHA or a local checkout.`; export interface SwiftDependencies { + /** `process.platform`. Windows is not supported. */ readonly platform: string; - readonly arch: string; - readonly fetch: (url: string, init?: RequestInit) => Promise; + /** Where the source build makes its scratch checkout and runs its lookups. */ + readonly temporaryDirectory: () => string; + /** Progress the user should see while the generator is chosen and built. */ + 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,39 +109,53 @@ 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 is built from source at a supabase-swift commit + * with the user's own `git` and `swift`, and cached per commit. The commit + * is, in this order: the one `swift-typegen-source` names (a version, a + * branch, a full SHA, or a local checkout built in place and never cached); + * the supabase-swift pin of the nearest `Package.resolved` (SwiftPM, or + * Xcode's copy inside the `.xcodeproj` or `.xcworkspace`), in `cwd`, a parent, + * or up to two levels below `cwd`; or the latest 3.x release. * - * 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 pinned 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 + * a commit that did not come from upstream's own tags or branches must first + * be shown to be upstream's: a version pin's tag must point at it, and any + * other pin, or a SHA passed to `swift-typegen-source`, 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]; + const options = [swiftAccessControl, swiftTypegenSource]; return { name: "swift", inProcess: false, options, async generate(metadata, values, host) { - resolveOptions("swift", options, values); - const command = await resolveBinary(host, dependencies); + const resolved = resolveOptions("swift", options, values); + const command = await resolveBinary( + host, + dependencies, + resolved[SWIFT_TYPEGEN_SOURCE] as string | undefined, + ); return externalLanguage("swift", options, { command, - args: (_metadata, resolved) => [ + args: (_metadata, resolvedOptions) => [ "--access-control", - resolved[SWIFT_ACCESS_CONTROL] as string, + resolvedOptions[SWIFT_ACCESS_CONTROL] as string, ], installHint, classify: (result) => @@ -97,90 +167,791 @@ export function createSwift( export const swift = createSwift(); +/** A value of `swift-typegen-source`, parsed without looking anything up. */ +export type TypegenSource = + | { readonly kind: "path"; readonly path: string } + | { readonly kind: "commit"; readonly revision: string } + | { readonly kind: "version"; readonly version: string } + | { readonly kind: "branch"; readonly branch: string }; + +/** + * Reads a `swift-typegen-source` value in a fixed order, without guessing: a + * path starts with `./`, `../`, `/` or `~/` (so a folder named `3.0.0` is + * never read as a version), a commit is 40 hex characters, a version is a + * semantic version with or without a leading `v`, and anything else that is a + * plain branch name is a branch. Throws an `InvalidOptionError` for an + * abbreviated SHA, any other path form and anything git could read as an + * option or a refspec. + */ +export function parseTypegenSource( + value: string, + base: { readonly cwd: string; readonly home: string }, +): TypegenSource { + const invalid = (reason: string) => + new InvalidOptionError({ + language: "swift", + option: SWIFT_TYPEGEN_SOURCE, + message: `--${SWIFT_TYPEGEN_SOURCE} ${printable(value)} ${reason}`, + }); + if (/^[./~]/.test(value)) { + if (value === "~" || value.startsWith("~/")) { + return { kind: "path", path: join(base.home, value.slice(1)) }; + } + if ( + value.startsWith("/") || + value === "." || + value === ".." || + value.startsWith("./") || + value.startsWith("../") + ) { + return { kind: "path", path: resolve(base.cwd, value) }; + } + throw invalid( + "is not a path this option reads. Start a relative path with ./ or ../, and a path in your home folder with ~/.", + ); + } + if (/^[0-9a-f]{40}$/i.test(value)) { + return { kind: "commit", revision: value.toLowerCase() }; + } + if (SHORT_COMMIT.test(value)) { + throw invalid( + "looks like an abbreviated commit SHA. Pass the full 40-character SHA.", + ); + } + const version = value.replace(/^v(?=\d)/, ""); + if (VERSION.test(version)) { + return { kind: "version", version }; + } + if (!BRANCH.test(value)) { + throw invalid( + "is not a version, a full commit SHA, a path starting with ./, ../, / or ~/, or a valid branch name.", + ); + } + return { kind: "branch", branch: value }; +} + +/** + * `git ls-remote` output as ref name to commit. An annotated tag lists its + * own object and, as `^{}`, the commit it points at; the commit wins. + * Lines whose object is not a full SHA are dropped. + */ +export function parseRefs(listing: string): Map { + const refs = new Map(); + const peeled = new Map(); + for (const line of listing.split("\n")) { + const [sha, ref] = line.split("\t").map((field) => field.trim()); + if (sha === undefined || ref === undefined || !COMMIT.test(sha)) { + continue; + } + if (ref.endsWith("^{}")) { + peeled.set(ref.slice(0, -3), sha); + } else { + refs.set(ref, sha); + } + } + for (const [ref, sha] of peeled) { + refs.set(ref, sha); + } + return refs; +} + +interface SemanticVersion { + readonly core: readonly [number, number, number]; + readonly prerelease: readonly string[]; +} + +const parseVersion = (version: string): SemanticVersion | undefined => { + const match = + /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec( + version, + ); + if (!match) { + return undefined; + } + return { + core: [Number(match[1]), Number(match[2]), Number(match[3])], + prerelease: match[4]?.split(".") ?? [], + }; +}; + +const compareIdentifiers = (a: string, b: string): number => { + const numeric = /^\d+$/; + if (numeric.test(a) && numeric.test(b)) { + return Number(a) - Number(b); + } + if (numeric.test(a) !== numeric.test(b)) { + return numeric.test(a) ? -1 : 1; + } + return a < b ? -1 : a > b ? 1 : 0; +}; + +/** + * Semantic version precedence: negative when `a` comes before `b`. A + * prerelease comes before its release, and prerelease identifiers compare + * numerically when both are numbers, so `alpha.10` follows `alpha.9`. Build + * metadata is ignored. Both arguments must be semantic versions. + */ +export function compareVersions(a: string, b: string): number { + const left = parseVersion(a); + const right = parseVersion(b); + if (!left || !right) { + throw new RangeError(`Not semantic versions: ${a}, ${b}`); + } + for (const index of [0, 1, 2] as const) { + const difference = left.core[index] - right.core[index]; + if (difference !== 0) { + return difference; + } + } + if (left.prerelease.length === 0 || right.prerelease.length === 0) { + return right.prerelease.length - left.prerelease.length; + } + const shared = Math.min(left.prerelease.length, right.prerelease.length); + for (let index = 0; index < shared; index++) { + const difference = compareIdentifiers( + left.prerelease[index] ?? "", + right.prerelease[index] ?? "", + ); + if (difference !== 0) { + return difference; + } + } + return left.prerelease.length - right.prerelease.length; +} + +/** + * The tag of the highest `major`.x release among `tags` (names with or + * without a leading `v`): the highest stable release once there is one, the + * highest prerelease before that. Never another major version. + */ +export function latestRelease( + tags: readonly string[], + major: number, +): { readonly tag: string; readonly version: string } | undefined { + const releases = tags.flatMap((tag) => { + const version = tag.replace(/^v(?=\d)/, ""); + const parsed = parseVersion(version); + return parsed?.core[0] === major ? [{ tag, version, parsed }] : []; + }); + const stable = releases.filter( + (release) => release.parsed.prerelease.length === 0, + ); + const candidates = stable.length > 0 ? stable : releases; + let latest: (typeof candidates)[number] | undefined; + for (const candidate of candidates) { + if ( + latest === undefined || + compareVersions(candidate.version, latest.version) > 0 + ) { + latest = candidate; + } + } + return latest && { tag: latest.tag, version: latest.version }; +} + +/** The supabase-swift commit to build, already a 40-character lowercase hex SHA. */ +interface Target { + readonly revision: string; + /** Set when a version was asked for; the built binary must report it. */ + readonly version?: string; + /** + * How to show the revision is upstream's before fetching it: the version + * tag must point at it, or it must be on the branch (`undefined`: the + * default branch). Absent when the revision came from upstream's own refs. + */ + readonly provenance?: + | { readonly kind: "tag"; readonly version: string } + | { readonly kind: "branch"; readonly branch?: string }; +} + +/** The `git` and `swift` steps, which name the step and its tool when they fail. */ +interface Runner { + exec( + command: string, + args: readonly string[], + cwd: string, + step: string, + ): Promise; + /** Like `exec`, but a non-zero exit is a `ToolFailedError` with the tool's output. */ + run( + command: string, + args: readonly string[], + cwd: string, + step: string, + ): Promise; +} + +function createRunner(host: Host, spawn: NonNullable): Runner { + const exec: Runner["exec"] = async (command, args, cwd, step) => { + 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: `${step} needs \`${command}\`, which was not found on PATH. ${installHint}`, + cause: error, + }); + } + throw error; + } + }; + return { + exec, + async run(command, args, cwd, step) { + const result = await exec(command, args, cwd, step); + 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: `${step} failed: \`${commandLine.join(" ")}\` ${ + result.exitCode === null + ? "was terminated by a signal" + : `exited with code ${result.exitCode}` + }.${output ? `\n${output}` : ""}`, + }); + } + return result; + }, + }; +} + async function resolveBinary( host: Host, - { platform, arch, fetch }: SwiftDependencies, + dependencies: SwiftDependencies, + source: string | undefined, ): 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 (spawn === undefined) { return BINARY; } - const path = join(cacheDirectory(host.env, platform), version, BINARY); - if (existsSync(path)) { - return path; - } - const url = `${RELEASES}/download/v${version}/${asset}`; - const response = await fetch(url, host.signal ? { signal: host.signal } : {}); - if (response.status === 404) { - throw new ToolNotInstalledError({ + if (dependencies.platform === "win32") { + throw 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: `Generating Swift types builds \`${BINARY}\` from supabase-swift's source, which is not supported on Windows. Run supabase gen types --lang swift on macOS or Linux.`, }); } - if (!response.ok) { - throw new TypegenError({ - language: "swift", - message: `Downloading ${url} failed with HTTP ${response.status}. ${installHint}`, + const runner = createRunner(host, spawn); + let target: Target; + if (source === undefined) { + target = await defaultTarget(host, dependencies, runner); + } else { + const parsed = parseTypegenSource(source, { + cwd: host.cwd, + home: absolute(host.env.HOME) ?? homedir(), }); + if (parsed.kind === "path") { + return await buildCheckout(parsed.path, dependencies, runner); + } + target = await chosenTarget(parsed, host, dependencies, runner); } - const binary = extract( - gunzipSync(new Uint8Array(await response.arrayBuffer())), + const path = join( + cacheDirectory(host.env, dependencies.platform), + target.revision, BINARY, ); - if (!binary) { - throw new TypegenError({ + if (!existsSync(path)) { + await build({ host, dependencies, runner, target, path }); + } + return path; +} + +const abbreviated = (revision: string) => `\`${revision.slice(0, 7)}…\``; + +/** Resolves a version, branch or commit from `swift-typegen-source` to a commit. */ +async function chosenTarget( + source: Exclude, + host: Host, + dependencies: SwiftDependencies, + runner: Runner, +): Promise { + const lookups = dependencies.temporaryDirectory(); + switch (source.kind) { + case "commit": + dependencies.log(`Using commit ${abbreviated(source.revision)}.`); + return { + revision: source.revision, + provenance: { kind: "branch" }, + }; + case "version": { + const { version } = source; + if ((parseVersion(version)?.core[0] ?? 0) < MAJOR) { + throw new InvalidOptionError({ + language: "swift", + option: SWIFT_TYPEGEN_SOURCE, + message: `supabase-swift ${version} has no ${BINARY}; pass ${FIRST_RELEASE} or newer to --${SWIFT_TYPEGEN_SOURCE}.`, + }); + } + const tags = [`v${version}`, version].map((name) => `refs/tags/${name}`); + const listed = await runner.run( + "git", + ["ls-remote", SOURCE, ...tags.flatMap((tag) => [tag, `${tag}^{}`])], + lookups, + `Looking up supabase-swift's tag v${version}`, + ); + const refs = parseRefs(listed.stdout); + const revision = tags + .map((tag) => refs.get(tag)) + .find((sha) => sha !== undefined); + if (revision === undefined) { + throw new TypegenError({ + language: "swift", + message: `No tag named \`v${version}\` in supabase/supabase-swift (looked for ${tags.join(" and ")}).`, + }); + } + dependencies.log( + `Using version \`${version}\` at ${abbreviated(revision)}.`, + ); + return { revision, version }; + } + case "branch": { + const { branch } = source; + const ref = `refs/heads/${branch}`; + const listed = await runner.run( + "git", + ["ls-remote", SOURCE, ref], + lookups, + `Looking up supabase-swift's branch ${branch}`, + ); + const revision = parseRefs(listed.stdout).get(ref); + if (revision === undefined) { + const folder = resolve(host.cwd, branch); + throw new TypegenError({ + language: "swift", + message: `No branch named \`${branch}\` in supabase/supabase-swift.${ + existsSync(folder) + ? ` To build the local checkout at ${folder}, pass ./${branch}.` + : "" + }`, + }); + } + dependencies.log( + `Using branch \`${branch}\` at ${abbreviated(revision)}.`, + ); + return { revision }; + } + } +} + +/** The lockfile pin, or else the latest 3.x release with a notice. */ +async function defaultTarget( + host: Host, + dependencies: SwiftDependencies, + runner: Runner, +): Promise { + const pin = await findPin(host.cwd, dependencies.userId()); + if (pin === undefined) { + const listed = await runner.run( + "git", + ["ls-remote", "--tags", SOURCE], + dependencies.temporaryDirectory(), + "Looking up supabase-swift's releases", + ); + const refs = parseRefs(listed.stdout); + const prefix = "refs/tags/"; + const latest = latestRelease( + [...refs.keys()] + .filter((ref) => ref.startsWith(prefix)) + .map((ref) => ref.slice(prefix.length)), + MAJOR, + ); + const revision = latest && refs.get(`${prefix}${latest.tag}`); + if (latest === undefined || revision === undefined) { + throw new TypegenError({ + language: "swift", + message: `No supabase-swift pin found from \`${host.cwd}\`, and supabase/supabase-swift has no ${MAJOR}.x release yet. Run from your app directory, or pass --${SWIFT_TYPEGEN_SOURCE}.`, + }); + } + dependencies.log( + `No supabase-swift pin found from \`${host.cwd}\`. Using ${latest.version} (latest) at ${abbreviated(revision)}. Run from your app directory, or pass --${SWIFT_TYPEGEN_SOURCE}, to match your pinned version.`, + ); + return { revision, version: latest.version }; + } + const { revision, version, branch, file } = pin; + const where = relative(host.cwd, file) || file; + const invalid = (field: string, value: string, expected: string) => + new TypegenError({ language: "swift", - message: `${url} does not contain \`${BINARY}\`. ${installHint}`, + message: `The supabase-swift pin in ${where} has ${field} ${printable(value)}, which is not ${expected}. Resolve the package again, or pass --${SWIFT_TYPEGEN_SOURCE}.`, }); + if (!COMMIT.test(revision)) { + throw invalid( + "revision", + revision, + "a 40-character lowercase hex commit SHA", + ); } - // 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; + if (version !== undefined && !VERSION.test(version)) { + throw invalid("version", version, "a semantic version"); + } + if (version === undefined && branch !== undefined && !BRANCH.test(branch)) { + throw invalid("branch", branch, "a plain branch name"); + } + if (version !== undefined) { + dependencies.log( + `Using version \`${version}\` at ${abbreviated(revision)}, pinned in ${where}.`, + ); + return { revision, version, provenance: { kind: "tag", version } }; + } + dependencies.log( + branch === undefined + ? `Using commit ${abbreviated(revision)}, pinned in ${where}.` + : `Using branch \`${branch}\` at ${abbreviated(revision)}, pinned in ${where}.`, + ); + return { revision, provenance: { kind: "branch", branch } }; +} + +/** + * Builds the generator in a local supabase-swift checkout, in its own + * `.build` folder so later builds are incremental, and returns the binary + * there. Never cached: the working tree can hold uncommitted changes. + */ +async function buildCheckout( + checkout: string, + dependencies: SwiftDependencies, + runner: Runner, +): Promise { + const packagePath = join(checkout, PACKAGE_PATH); + if (!existsSync(join(packagePath, "Package.swift"))) { + throw new InvalidOptionError({ + language: "swift", + option: SWIFT_TYPEGEN_SOURCE, + message: `${checkout} has no ${PACKAGE_PATH}. Pass --${SWIFT_TYPEGEN_SOURCE} a checkout of supabase-swift ${FIRST_RELEASE} or newer.`, + }); + } + dependencies.log( + `Using the local checkout ${checkout}, with any uncommitted changes. Building ${BINARY} there; the first build takes a few minutes, later ones rebuild only what changed.`, + ); + await runner.run( + "swift", + ["build", "-c", "release", "--package-path", packagePath], + checkout, + `Building \`${BINARY}\` in ${checkout}`, + ); + return join(packagePath, ".build", "release", BINARY); +} + +interface Build { + readonly host: Host; + readonly dependencies: SwiftDependencies; + readonly runner: Runner; + readonly target: Target; + /** Where the finished binary goes in the cache. */ + readonly path: string; +} + +/** + * Checks that `target.revision` is upstream's when it did not come from + * upstream's refs, 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, reports that version. + */ +async function build({ + host, + dependencies, + runner, + target, + path, +}: Build): Promise { + const { revision, version, provenance } = target; + const step = `Building \`${BINARY}\` ${version ?? revision} from source`; + const run = (command: string, args: readonly string[], cwd: string) => + runner.run(command, args, cwd, step); + + await run("git", ["--version"], host.cwd); + await run("swift", ["--version"], host.cwd); + dependencies.log( + `Building ${BINARY} ${version ?? revision} 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 (provenance?.kind === "tag") { + // A tag can only come from upstream's maintainers; annotated tags list the commit as `^{}`. + const tags = [provenance.version, `v${provenance.version}`].map( + (name) => `refs/tags/${name}`, + ); + const listed = await run( + "git", + ["ls-remote", SOURCE, ...tags.flatMap((tag) => [tag, `${tag}^{}`])], + scratch, + ); + const refs = parseRefs(listed.stdout); + if (!tags.some((tag) => refs.get(tag) === revision)) { + throw new TypegenError({ + language: "swift", + message: `supabase-swift's ${provenance.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 if (provenance?.kind === "branch") { + const { branch } = provenance; + // 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 runner.exec( + "git", + ["merge-base", "--is-ancestor", revision, "HEAD"], + join(scratch, "history.git"), + step, + ); + if (ancestry.exitCode !== 0) { + throw new TypegenError({ + language: "swift", + message: `${revision} is not on supabase-swift's ${branch ?? "default"} branch, and a revision from a fork or a pull request is not built. Resolve the package again if the branch moved, or pass a local checkout to --${SWIFT_TYPEGEN_SOURCE}.`, + }); + } + } + 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 ${version ?? revision} 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 ${printable(reported)}, not ${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", + }; } +const absolute = (path: string | undefined) => + path && isAbsolute(path) ? path : undefined; + +/** Relative or empty values are ignored, as the XDG base directory spec asks. */ function cacheDirectory( env: Readonly>, platform: string, ): string { 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"); } +/** A value for the terminal: quoted, with control characters escaped. */ +const printable = (value: string): string => + JSON.stringify(value).replace( + /[\u007f-\u009f]/g, + (character) => + `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`, + ); + +interface Pin { + readonly revision: string; + readonly version?: string; + readonly branch?: string; + /** The `Package.resolved` it came from. */ + readonly file: 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. Without one there, a monorepo's + * apps may sit below `cwd`, so it looks up to `SEARCH_DEPTH` levels down and + * accepts what it finds only when every pin there agrees on the revision. + * Only regular files that `userId` owns count. */ -async function pinnedVersion(cwd: string): Promise { +async function findPin( + 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; - } + const [pin] = await pinsIn(directory, userId); + if (pin) { + return pin; } if (dirname(directory) === directory) { - return undefined; + break; } } + const pins: Pin[] = []; + for (const directory of await foldersBelow(cwd)) { + pins.push(...(await pinsIn(directory, userId))); + } + const revisions = new Set(pins.map((pin) => pin.revision)); + if (revisions.size > 1) { + throw new TypegenError({ + language: "swift", + message: `No supabase-swift pin found in \`${cwd}\` or its parents, and the Package.resolved files below it pin different supabase-swift revisions:\n${pins + .map( + (pin) => ` ${relative(cwd, pin.file)}: ${printable(pin.revision)}`, + ) + .join( + "\n", + )}\nRun from your app directory, or pass --${SWIFT_TYPEGEN_SOURCE} to choose the source.`, + }); + } + return pins[0]; } -async function resolvedFiles(directory: string): Promise { +/** Folders up to `SEARCH_DEPTH` levels below `cwd`, in a stable order, without following links. */ +async function foldersBelow(cwd: string): Promise { + const found: string[] = []; + let level = [cwd]; + for (let depth = 0; depth < SEARCH_DEPTH; depth++) { + const next: string[] = []; + for (const directory of level) { + const entries = await readdir(directory, { withFileTypes: true }).catch( + () => [], + ); + for (const entry of entries) { + // Xcode's copies inside these bundles are read with their parent folder. + if ( + entry.isDirectory() && + !SKIPPED_FOLDERS.has(entry.name) && + !/\.(xcodeproj|xcworkspace)$/.test(entry.name) + ) { + next.push(join(directory, entry.name)); + } + } + } + next.sort(); + found.push(...next); + level = next; + } + return found; +} + +async function pinsIn( + directory: string, + userId: number | undefined, +): Promise { + const pins: Pin[] = []; + for (const file of await resolvedFiles(directory, userId)) { + const state = stateIn(await readFile(file, "utf8")); + if (state) { + pins.push({ ...state, file }); + } + } + return pins; +} + +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 +967,59 @@ 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; + +const text = (value: unknown): string | undefined => + typeof value === "string" ? value : 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[] } }; +/** + * Reads every `Package.resolved` format (version 1 nests the pins under + * `object`). Anything that is not the expected shape, including a + * supabase-swift pin without a revision, counts as no pin. + */ +function stateIn(contents: string): Omit | 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); + const revision = text(state?.revision); + return revision === undefined + ? undefined + : { + revision, + version: text(state?.version), + branch: text(state?.branch), + }; } - offset = start + Math.ceil(size / 512) * 512; } return undefined; } diff --git a/packages/typegen/test/languages.test.ts b/packages/typegen/test/languages.test.ts index c5156de..d36cbbd 100644 --- a/packages/typegen/test/languages.test.ts +++ b/packages/typegen/test/languages.test.ts @@ -46,6 +46,12 @@ describe("languages", () => { default: "internal", help: "Access control for Swift generated types.", }, + { + name: "swift-typegen-source", + audience: "user", + kind: "string", + help: "Where to build supabase-typegen from: a supabase-swift version (3.0.0-alpha.1), a branch (main), a full commit SHA, or a local checkout (./supabase-swift). Default: the supabase-swift pin in the nearest Package.resolved, else the latest 3.x release.", + }, ]); expect(findLanguage("go")?.options).toEqual([]); expect(findLanguage("python")?.options).toEqual([]); diff --git a/packages/typegen/test/swift.test.ts b/packages/typegen/test/swift.test.ts index 09ba2ac..6227688 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, @@ -21,20 +26,67 @@ import { ToolNotInstalledError, TypegenError, } from "../src/index.ts"; -import { createSwift } from "../src/languages/swift.ts"; +import { + compareVersions, + createSwift, + latestRelease, + parseRefs, + parseTypegenSource, +} from "../src/languages/swift.ts"; import { commandNotFound, createFakeHost, rejection } from "./helpers.ts"; import { unsortedMetadata } from "./fixtures.ts"; const success = (stdout: string) => ({ exitCode: 0, stdout, stderr: "" }); +const LIST_TAGS = + "git ls-remote --tags https://github.com/supabase/supabase-swift"; + +const REVISION = "0123456789abcdef0123456789abcdef01234567"; +const OTHER = "fedcba9876543210fedcba9876543210fedcba98"; describe("swift", () => { + let cache: string; + + beforeEach(() => { + cache = realpathSync(mkdtempSync(join(tmpdir(), "typegen-swift-cache-"))); + mkdirSync(dirname(binary()), { recursive: true }); + writeFileSync(binary(), ""); + }); + + afterEach(() => { + rmSync(cache, { recursive: true, force: true }); + }); + + /** The binary a commit-SHA source finds already built, so nothing else is spawned. */ + const binary = () => + join(cache, "supabase/typegen", REVISION, "supabase-typegen"); + + const generate = ( + respond: Parameters[0], + values: Record = {}, + ) => { + const host = createFakeHost(respond, { + env: { PATH: "/usr/bin", HOME: "/home/dev", XDG_CACHE_HOME: cache }, + }); + const output = createSwift({ + platform: "darwin", + temporaryDirectory: () => cache, + log: () => {}, + userId: () => undefined, + }).generate( + unsortedMetadata, + { "swift-typegen-source": REVISION, ...values }, + host, + ); + return { host, output }; + }; + test("runs supabase-typegen in the project with the sorted document on stdin", async () => { - const host = createFakeHost(() => success("struct Tickets {}\n")); - const output = await swift.generate(unsortedMetadata, {}, host); + const { host, output } = generate(() => success("struct Tickets {}\n")); - expect(output).toBe("struct Tickets {}\n"); + expect(await output).toBe("struct Tickets {}\n"); + expect(host.requests).toHaveLength(1); const request = host.requests[0]!; - expect(request.command).toBe("supabase-typegen"); + expect(request.command).toBe(binary()); expect(request.args).toEqual(["--access-control", "internal"]); expect(request.cwd).toBe(host.cwd); expect(request.stdin).toBe( @@ -43,12 +95,10 @@ describe("swift", () => { }); test("maps swift-access-control to --access-control", async () => { - const host = createFakeHost(() => success("")); - await swift.generate( - unsortedMetadata, - { "swift-access-control": "public" }, - host, - ); + const { host, output } = generate(() => success(""), { + "swift-access-control": "public", + }); + await output; expect(host.requests[0]!.args).toEqual(["--access-control", "public"]); }); @@ -68,26 +118,26 @@ describe("swift", () => { } }); - test("reports a missing supabase-typegen with an install hint", async () => { - const host = createFakeHost(() => { - throw commandNotFound("supabase-typegen"); + test("reports a missing supabase-typegen with a hint that names swift-typegen-source", async () => { + const { output } = generate(() => { + throw commandNotFound(binary()); }); - const error = await rejection(swift.generate(unsortedMetadata, {}, host)); + const error = await rejection(output); 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.tool).toBe(binary()); + expect(notInstalled.installHint).toContain("--swift-typegen-source"); + expect(notInstalled.installHint).not.toContain("SUPABASE_TYPEGEN"); + expect(notInstalled.installHint).not.toContain("PATH"); }); test("reports exit 65 as a rejected document", async () => { - const host = createFakeHost(() => ({ + const { output } = generate(() => ({ exitCode: 65, stdout: "", stderr: "error: unsupported GeneratorMetadata version '7'.\n", })); - const error = await rejection(swift.generate(unsortedMetadata, {}, host)); + const error = await rejection(output); expect(error).toBeInstanceOf(MetadataRejectedError); expect((error as MetadataRejectedError).stderr).toBe( "error: unsupported GeneratorMetadata version '7'.", @@ -95,119 +145,376 @@ describe("swift", () => { }); test("reports any other failure with the exit code and stderr", async () => { - const host = createFakeHost(() => ({ + const { output } = generate(() => ({ exitCode: 1, stdout: "", stderr: "error: the primary key column 'todos.id' is nullable.\n", })); - const error = await rejection(swift.generate(unsortedMetadata, {}, host)); + const error = await rejection(output); expect(error).toBeInstanceOf(ToolFailedError); expect(error).not.toBeInstanceOf(MetadataRejectedError); expect((error as ToolFailedError).message).toBe( - "`supabase-typegen --access-control internal` exited with code 1.\nerror: the primary key column 'todos.id' is nullable.", + `\`${binary()} --access-control internal\` exited with code 1.\nerror: the primary key column 'todos.id' is nullable.`, ); }); + + test("refuses Windows, where the source build is not supported", async () => { + const host = createFakeHost(() => success("")); + const error = await rejection( + createSwift({ + platform: "win32", + temporaryDirectory: () => cache, + log: () => {}, + userId: () => undefined, + }).generate(unsortedMetadata, {}, host), + ); + expect(error).toBeInstanceOf(TypegenError); + expect((error as TypegenError).message).toContain("Windows"); + expect(host.requests).toEqual([]); + }); }); -describe("swift binary resolution", () => { - const binary = "#!/bin/sh\necho generated\n"; - let tarball: Uint8Array; +/** + * Stand-ins for `git` and `swift`, put alone on PATH. Each appends its command + * line to `$FAKE_LOG`. `git ls-remote` lists `$FAKE_HEADS` for a branch and + * `$FAKE_TAGS` otherwise (`\c` lists nothing), `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` + * into its `--package-path` that prints `$FAKE_TYPEGEN_VERSION` for + * `--version` and otherwise the package's `marker` file, or `generated` + * without one. 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) + case "$*" in + *refs/heads/*) + if [ -n "$FAKE_HEADS" ]; then printf '%b' "$FAKE_HEADS" + else printf '%s\\trefs/heads/main\\n' ${REVISION}; fi ;; + *) + if [ -n "$FAKE_TAGS" ]; then printf '%b' "$FAKE_TAGS" + else printf '%s\\trefs/tags/v3.0.0-alpha.1\\n' ${REVISION}; fi ;; + esac ;; + 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 + package=tools/supabase-typegen + previous= + for argument; do + if [ "$previous" = --package-path ]; then package=$argument; fi + previous=$argument + done + output=generated + if [ -f "$package/marker" ]; then output=$(/bin/cat "$package/marker"); fi + /bin/mkdir -p "$package/.build/release" + binary="$package/.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 %s\\n' "\${FAKE_TYPEGEN_VERSION:-3.0.0-alpha.1}" "$output" > "$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", + values: Record = {}, + ) => + make(platform).generate( + unsortedMetadata, + values, + 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); + /** Runs with `swift-typegen-source` set to `source`. */ + const runFrom = ( + cwd: string, + source: string, + env: Record = {}, + ) => run(cwd, env, "darwin", { "swift-typegen-source": source }); + const cached = (cache = "home/Library/Caches", revision = REVISION) => + 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(2); + expect(messages[0]).toBe( + "Using version `3.0.0-alpha.1` at `0123456…`, pinned in Package.resolved.", + ); + expect(messages[1]).toContain( + "Building supabase-typegen 3.0.0-alpha.1 from source", + ); + expect(messages[1]).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([ + "Using version `3.0.0-alpha.1` at `0123456…`, pinned in Package.resolved.", + ]); + }); + + 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() }); + expect( + await make("darwin", (process.getuid?.() ?? 0) + 1).generate( + unsortedMetadata, + {}, + createNodeHost({ + cwd, + env: { PATH: bin, HOME: join(root, "home"), FAKE_LOG: logFile }, + }), ), - ); - expect(readFileSync(command, "utf8")).toBe(binary); - expect(statSync(command).mode & 0o777).toBe(0o755); + ).toBe("generated\n"); + expect(commands()[0]).toBe(LIST_TAGS); + expect(messages[0]).toStartWith("No supabase-swift pin found"); }); - 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 () => { + const malformed = [ + "null", + '{"pins":{}}', + '{"pins":[null]}', + "[1]", + pin({ version: "3.0.0-alpha.1" }), + ]; + for (const contents of malformed) { + writeFileSync(logFile, ""); + const cwd = project({ "Package.resolved": contents }); + expect(await run(cwd)).toBe("generated\n"); + expect(commands()[0]).toBe(LIST_TAGS); + } + writeFileSync(logFile, ""); + const cwd = project({}); + rmSync(join(cwd, "Package.resolved"), { force: true }); + execFileSync("/usr/bin/mkfifo", [join(cwd, "Package.resolved")]); + expect(await run(cwd)).toBe("generated\n"); + expect(commands()[0]).toBe(LIST_TAGS); }); test("reads an Xcode project's Package.resolved in the version 1 format", async () => { @@ -219,135 +526,721 @@ 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"), - }); - const host = createFakeHost(() => success(""), { cwd }); - await language.generate(unsortedMetadata, {}, host); - expect(host.requests[0]!.command).toBe("supabase-typegen"); + 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("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", - }); - await language.generate(unsortedMetadata, {}, host); - expect(urls).toEqual([]); - expect(host.requests[0]!.command).toBe("/opt/dev/supabase-typegen"); + 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("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("reads neither SUPABASE_TYPEGEN nor a supabase-typegen on PATH", async () => { + const cwd = project({}); + const stray = join(bin, "supabase-typegen"); + writeFileSync( + stray, + '#!/bin/sh\necho "stray $*" >> "$FAKE_LOG"\necho stray\n', + { + mode: 0o755, + }, ); + expect(await run(cwd, { SUPABASE_TYPEGEN: stray })).toBe("generated\n"); + expect(ran("stray")).toBe(false); + expect(existsSync(cached())).toBe(true); + }); + + 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 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 } }), + ), + ); + expect(error).toBeInstanceOf(InvalidOptionError); + expect(commands()).toEqual([]); + }); + + describe("swift-typegen-source", () => { + const tagLookup = (tag: string) => + `git ls-remote https://github.com/supabase/supabase-swift refs/tags/v${tag} refs/tags/v${tag}^{} refs/tags/${tag} refs/tags/${tag}^{}`; + const checkoutAndBuild = [ + "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", + ]; + + for (const value of ["3.0.0-alpha.1", "v3.0.0-alpha.1"]) { + test(`builds the tagged commit of version ${value} over the lockfile pin`, async () => { + const cwd = project({ + "Package.resolved": pin({ + version: "3.0.0-alpha.2", + revision: OTHER, + }), + }); + + expect(await runFrom(cwd, value)).toBe("generated\n"); + + expect(commands()).toEqual([ + tagLookup("3.0.0-alpha.1"), + "git --version", + "swift --version", + ...checkoutAndBuild, + "typegen --version", + "typegen --access-control internal", + ]); + expect(messages[0]).toBe( + "Using version `3.0.0-alpha.1` at `0123456…`.", + ); + expect(existsSync(cached())).toBe(true); + expect(existsSync(cached(undefined, OTHER))).toBe(false); + }); + } + + test("builds the commit an annotated version tag points at", async () => { + const cwd = project({}); + expect( + await runFrom(cwd, "3.0.0-alpha.1", { + FAKE_TAGS: `${"a".repeat(40)}\\trefs/tags/v3.0.0-alpha.1\\n${REVISION}\\trefs/tags/v3.0.0-alpha.1^{}\\n`, + }), + ).toBe("generated\n"); + expect(existsSync(cached())).toBe(true); + }); + + test("resolves a branch on every run and reuses the cache while it stays put", async () => { + const cwd = project({}); + + expect(await runFrom(cwd, "main")).toBe("generated\n"); + expect(commands()).toEqual([ + "git ls-remote https://github.com/supabase/supabase-swift refs/heads/main", + "git --version", + "swift --version", + ...checkoutAndBuild, + "typegen --access-control internal", + ]); + expect(messages[0]).toBe("Using branch `main` at `0123456…`."); + + writeFileSync(logFile, ""); + expect(await runFrom(cwd, "main")).toBe("generated\n"); + expect(commands()).toEqual([ + "git ls-remote https://github.com/supabase/supabase-swift refs/heads/main", + "typegen --access-control internal", + ]); + + writeFileSync(logFile, ""); + messages = []; + expect( + await runFrom(cwd, "main", { + FAKE_HEADS: `${OTHER}\\trefs/heads/main\\n`, + }), + ).toBe("generated\n"); + expect(ran("swift build")).toBe(true); + expect(messages[0]).toBe("Using branch `main` at `fedcba9…`."); + expect(existsSync(cached(undefined, OTHER))).toBe(true); + }); + + test("matches the branch exactly in the ls-remote listing", async () => { + const cwd = project({}); + const error = await rejection( + runFrom(cwd, "main", { + FAKE_HEADS: `${REVISION}\\trefs/heads/feature/main\\n`, + }), + ); + expect(error).toBeInstanceOf(TypegenError); + expect((error as TypegenError).message).toBe( + "No branch named `main` in supabase/supabase-swift.", + ); + }); + + test("builds a full commit SHA once it is on the default branch", async () => { + const cwd = project({}); + + expect(await runFrom(cwd, REVISION.toUpperCase())).toBe("generated\n"); + + expect(commands()).toEqual([ + "git --version", + "swift --version", + "git clone --bare --filter=tree:0 --no-tags --single-branch https://github.com/supabase/supabase-swift history.git", + `git merge-base --is-ancestor ${REVISION} HEAD`, + ...checkoutAndBuild, + "typegen --access-control internal", + ]); + expect(messages[0]).toBe("Using commit `0123456…`."); + expect(existsSync(cached())).toBe(true); + }); + + test("refuses a full commit SHA that is not upstream's", async () => { + const cwd = project({}); + const error = await rejection( + runFrom(cwd, REVISION, { FAKE_NOT_ON_BRANCH: "1" }), + ); + expect(error).toBeInstanceOf(TypegenError); + expect((error as TypegenError).message).toContain( + `${REVISION} is not on supabase-swift's default branch`, + ); + expect((error as TypegenError).message).toContain( + "--swift-typegen-source", + ); + expect(ran("git fetch")).toBe(false); + expect(leftovers()).toEqual([]); + }); + + test("builds a local checkout in place, with its uncommitted changes, and never caches it", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + const checkout = join(root, "supabase-swift"); + const packagePath = join(checkout, "tools/supabase-typegen"); + mkdirSync(packagePath, { recursive: true }); + writeFileSync(join(packagePath, "Package.swift"), ""); + writeFileSync(join(packagePath, "marker"), "first"); + + expect(await runFrom(cwd, "../supabase-swift")).toBe("first\n"); + expect(commands()).toEqual([ + `swift build -c release --package-path ${packagePath}`, + "typegen --access-control internal", + ]); + expect(messages[0]).toContain(`Using the local checkout ${checkout}`); + expect( + existsSync(join(packagePath, ".build/release/supabase-typegen")), + ).toBe(true); + + writeFileSync(join(packagePath, "marker"), "second"); + writeFileSync(logFile, ""); + expect(await runFrom(cwd, "../supabase-swift")).toBe("second\n"); + expect(ran("swift build")).toBe(true); + expect(existsSync(join(root, "home/Library/Caches"))).toBe(false); + expect(readdirSync(scratch)).toEqual([]); + }); + + test("expands ~ in a local checkout path", async () => { + const cwd = project({}); + const packagePath = join( + root, + "home/src/supabase-swift/tools/supabase-typegen", + ); + mkdirSync(packagePath, { recursive: true }); + writeFileSync(join(packagePath, "Package.swift"), ""); + expect(await runFrom(cwd, "~/src/supabase-swift")).toBe("generated\n"); + expect(commands()[0]).toBe( + `swift build -c release --package-path ${packagePath}`, + ); + }); + + test("refuses a local path without tools/supabase-typegen", async () => { + const cwd = project({}); + mkdirSync(join(cwd, "old-checkout")); + for (const value of ["./old-checkout", "./missing"]) { + const error = await rejection(runFrom(cwd, value)); + expect(error).toBeInstanceOf(InvalidOptionError); + expect((error as InvalidOptionError).message).toContain( + "has no tools/supabase-typegen", + ); + } + expect(commands()).toEqual([]); + }); + + test("refuses an abbreviated commit SHA without running git", async () => { + const cwd = project({}); + const error = await rejection(runFrom(cwd, REVISION.slice(0, 7))); + expect(error).toBeInstanceOf(InvalidOptionError); + expect((error as InvalidOptionError).option).toBe("swift-typegen-source"); + expect((error as InvalidOptionError).message).toContain( + "full 40-character SHA", + ); + expect(commands()).toEqual([]); + }); + + test("points a relative path without ./ at the ./ form", async () => { + const cwd = project({}); + mkdirSync(join(cwd, "supabase-swift")); + const error = await rejection( + runFrom(cwd, "supabase-swift", { FAKE_HEADS: "\\c" }), + ); + expect(error).toBeInstanceOf(TypegenError); + expect((error as TypegenError).message).toBe( + `No branch named \`supabase-swift\` in supabase/supabase-swift. To build the local checkout at ${join(cwd, "supabase-swift")}, pass ./supabase-swift.`, + ); + expect(ran("git fetch")).toBe(false); + }); + + test("names the tag or branch a failed lookup tried", async () => { + const cwd = project({}); + const tag = await rejection( + runFrom(cwd, "v3.0.0-alpa.1", { FAKE_TAGS: "\\c" }), + ); + expect(tag).toBeInstanceOf(TypegenError); + expect((tag as TypegenError).message).toStartWith( + "No tag named `v3.0.0-alpa.1` in supabase/supabase-swift", + ); + const branch = await rejection( + runFrom(cwd, "release/v9", { FAKE_HEADS: "\\c" }), + ); + expect((branch as TypegenError).message).toBe( + "No branch named `release/v9` in supabase/supabase-swift.", + ); + expect(ran("git fetch")).toBe(false); + expect(leftovers()).toEqual([]); + }); + + test("refuses a 2.x version without running git", async () => { + const cwd = project({}); + const error = await rejection(runFrom(cwd, "2.56.0")); + expect(error).toBeInstanceOf(InvalidOptionError); + expect((error as InvalidOptionError).message).toContain( + "supabase-swift 2.56.0 has no supabase-typegen; pass 3.0.0-alpha.1 or newer", + ); + expect(commands()).toEqual([]); + }); + + test("refuses a branch name git could read as an option or a refspec", async () => { + const cwd = project({}); + for (const value of [ + "--upload-pack=touch", + "-b", + "has space", + "a..b", + "main:refs/heads/x", + "main\u001b]0;x\u0007", + "", + ]) { + const error = await rejection(runFrom(cwd, value)); + expect(error).toBeInstanceOf(InvalidOptionError); + expect((error as InvalidOptionError).message).not.toContain("\u001b"); + } + expect(commands()).toEqual([]); + }); + }); + + describe("lockfile walk", () => { + test("skips a Package.resolved without a supabase-swift pin", async () => { + const app = project({ + "Package.resolved": versionPin(), + "ios/Package.resolved": JSON.stringify({ + pins: [ + { + identity: "swift-crypto", + location: "https://github.com/apple/swift-crypto.git", + state: { version: "3.0.0", revision: OTHER }, + }, + ], + version: 2, + }), + }); + expect(await run(join(app, "ios"))).toBe("generated\n"); + expect(messages[0]).toBe( + "Using version `3.0.0-alpha.1` at `0123456…`, pinned in ../Package.resolved.", + ); + }); + + test("reads an Xcode workspace's Package.resolved", async () => { + const cwd = project({ + "App.xcworkspace/xcshareddata/swiftpm/Package.resolved": versionPin(), + }); + await run(cwd); + expect(existsSync(cached())).toBe(true); + }); + + test("searches two levels down when no parent has a pin, skipping dependency folders", async () => { + const cwd = project({ + "apps/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved": + versionPin(), + "apps/macos/Package.resolved": versionPin(), + "apps/ios/deeper/Package.resolved": pin({ revision: OTHER }), + "node_modules/Package.resolved": pin({ revision: OTHER }), + ".build/checkouts/Package.resolved": pin({ revision: OTHER }), + "Pods/Package.resolved": pin({ revision: OTHER }), + "DerivedData/Package.resolved": pin({ revision: OTHER }), + ".git/Package.resolved": pin({ revision: OTHER }), + }); + expect(await run(cwd)).toBe("generated\n"); + expect(messages[0]).toBe( + "Using version `3.0.0-alpha.1` at `0123456…`, pinned in apps/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved.", + ); + expect(existsSync(cached(undefined, OTHER))).toBe(false); + }); + + test("asks for swift-typegen-source when the lockfiles below disagree", async () => { + const cwd = project({ + "ios/Package.resolved": versionPin(), + "server/Package.resolved": pin({ branch: "main", revision: OTHER }), + }); + const error = await rejection(run(cwd)); + expect(error).toBeInstanceOf(TypegenError); + const { message } = error as TypegenError; + expect(message).toContain("pin different supabase-swift revisions"); + expect(message).toContain(`ios/Package.resolved: "${REVISION}"`); + expect(message).toContain(`server/Package.resolved: "${OTHER}"`); + expect(message).toContain("--swift-typegen-source"); + expect(commands()).toEqual([]); + }); + + test("prefers a parent's pin over the lockfiles below", async () => { + const cwd = project({ + "Package.resolved": versionPin(), + "web/ios/Package.resolved": pin({ revision: OTHER }), + "web/server/Package.resolved": pin({ revision: "e".repeat(40) }), + }); + expect(await run(cwd)).toBe("generated\n"); + expect(existsSync(cached())).toBe(true); + }); + }); + + describe("latest release", () => { + test("builds the highest 3.x prerelease and says why", async () => { + const cwd = project({}); + const annotated = "a".repeat(40); + const tags = [ + `${"1".repeat(40)}\trefs/tags/v2.40.0`, + `${"2".repeat(40)}\trefs/tags/v3.0.0-alpha.9`, + `${annotated}\trefs/tags/v3.0.0-alpha.10`, + `${OTHER}\trefs/tags/v3.0.0-alpha.10^{}`, + `${"4".repeat(40)}\trefs/tags/v4.0.0-alpha.1`, + ]; + + expect( + await run(cwd, { + FAKE_TAGS: tags.join("\\n").replaceAll("\t", "\\t"), + FAKE_TYPEGEN_VERSION: "3.0.0-alpha.10", + }), + ).toBe("generated\n"); + + expect(commands()[0]).toBe(LIST_TAGS); + expect(commands()).toContain( + `git fetch --depth 1 https://github.com/supabase/supabase-swift ${OTHER}`, + ); + expect(commands()).toContain("typegen --version"); + expect(ran("git clone")).toBe(false); + expect(messages[0]).toBe( + `No supabase-swift pin found from \`${cwd}\`. Using 3.0.0-alpha.10 (latest) at \`fedcba9…\`. Run from your app directory, or pass --swift-typegen-source, to match your pinned version.`, + ); + expect(existsSync(cached(undefined, OTHER))).toBe(true); + }); + + test("fails when supabase-swift has no 3.x release", async () => { + const cwd = project({}); + const error = await rejection( + run(cwd, { FAKE_TAGS: `${OTHER}\\trefs/tags/v2.40.0\\n` }), + ); + expect(error).toBeInstanceOf(TypegenError); + expect((error as TypegenError).message).toContain("no 3.x release"); + expect(commands()).toEqual([LIST_TAGS]); + }); + }); +}); + +describe("parseTypegenSource", () => { + const base = { cwd: "/work/app", home: "/home/dev" }; + const parse = (value: string) => parseTypegenSource(value, base); + const reason = (value: string) => { + try { + parse(value); + } catch (error) { + expect(error).toBeInstanceOf(InvalidOptionError); + return (error as InvalidOptionError).message; + } + throw new Error(`${value} was accepted`); + }; + + test("reads ./, ../, / and ~/ as local paths", () => { + expect(parse("./supabase-swift")).toEqual({ + kind: "path", + path: "/work/app/supabase-swift", + }); + expect(parse("../supabase-swift")).toEqual({ + kind: "path", + path: "/work/supabase-swift", + }); + expect(parse(".")).toEqual({ kind: "path", path: "/work/app" }); + expect(parse("/src/supabase-swift/")).toEqual({ + kind: "path", + path: "/src/supabase-swift", + }); + expect(parse("~/src/supabase-swift")).toEqual({ + kind: "path", + path: "/home/dev/src/supabase-swift", + }); + expect(parse("~")).toEqual({ kind: "path", path: "/home/dev" }); + expect(parse("./3.0.0")).toEqual({ kind: "path", path: "/work/app/3.0.0" }); + }); + + test("refuses other forms that start like a path", () => { + for (const value of [".hidden", "...", "~other/supabase-swift"]) { + expect(reason(value)).toContain("Start a relative path with ./"); + } + }); + + test("reads 40 hex characters as a commit, in lowercase", () => { + expect(parse(REVISION.toUpperCase())).toEqual({ + kind: "commit", + revision: REVISION, + }); + }); + + test("refuses an abbreviated commit SHA", () => { + for (const value of [REVISION.slice(0, 7), REVISION.slice(0, 39)]) { + expect(reason(value)).toContain("full 40-character SHA"); + } + }); + + test("reads a semantic version with or without v", () => { + for (const value of [ + "3.0.0", + "v3.0.0", + "3.0.0-alpha.1", + "v3.1.2+build.5", + ]) { + expect(parse(value)).toEqual({ + kind: "version", + version: value.replace(/^v/, ""), + }); + } + }); + + test("reads anything else that is a plain branch name as a branch", () => { + for (const value of ["main", "release/v3", "feat/typegen-1.2", "v3"]) { + expect(parse(value)).toEqual({ kind: "branch", branch: value }); + } + }); + + test("refuses a branch name git could read as an option or a refspec", () => { + for (const value of [ + "-b", + "--upload-pack=touch", + "a b", + "a\tb", + "a..b", + "main:x", + "main^{}", + "x\u0000y", + "x\u009by", + "", + ]) { + const message = reason(value); + expect(message).toContain("not a version"); + const control = Array.from({ length: message.length }, (_unused, index) => + message.charCodeAt(index), + ).filter((code) => code < 0x20 || (code >= 0x7f && code <= 0x9f)); + expect(control).toEqual([]); + } }); +}); - test("validates options before downloading", async () => { - const cwd = project({ "Package.resolved": resolvedV2("2.56.0") }); - const { language, host, urls } = setup(cwd); +describe("compareVersions", () => { + test("orders by semantic version precedence", () => { + const ordered = [ + "2.99.99", + "3.0.0-alpha", + "3.0.0-alpha.1", + "3.0.0-alpha.2", + "3.0.0-alpha.9", + "3.0.0-alpha.10", + "3.0.0-alpha.beta", + "3.0.0-beta", + "3.0.0-beta.2", + "3.0.0-beta.11", + "3.0.0-rc.1", + "3.0.0", + "3.0.1", + "3.1.0", + "3.10.0", + ]; + const shuffled = [...ordered].reverse(); + expect(shuffled.sort(compareVersions)).toEqual(ordered); + expect(compareVersions("3.0.0+build.1", "3.0.0")).toBe(0); + }); +}); + +describe("latestRelease", () => { + test("takes the highest prerelease while there is no stable release", () => { expect( - await rejection( - language.generate( - unsortedMetadata, - { "swift-access-control": "open" }, - host, - ), + latestRelease( + ["v3.0.0-alpha.9", "v3.0.0-alpha.10", "v3.0.0-alpha.2", "v2.40.0"], + 3, ), - ).toBeInstanceOf(InvalidOptionError); - expect(urls).toEqual([]); + ).toEqual({ tag: "v3.0.0-alpha.10", version: "3.0.0-alpha.10" }); + }); + + test("prefers the highest stable release once one exists", () => { + expect( + latestRelease(["v3.0.0", "v3.2.0-beta.1", "v3.1.4", "v3.1.10"], 3), + ).toEqual({ tag: "v3.1.10", version: "3.1.10" }); + }); + + test("never crosses the major version", () => { + expect( + latestRelease(["v2.40.0", "v4.0.0", "v4.0.0-alpha.1"], 3), + ).toBeUndefined(); + expect(latestRelease(["v3.0.0-alpha.1", "v4.0.0"], 3)).toEqual({ + tag: "v3.0.0-alpha.1", + version: "3.0.0-alpha.1", + }); + }); + + test("reads tags without v and skips what is not a version", () => { + expect( + latestRelease(["3.0.0", "latest", "v3", "release-3.5.0"], 3), + ).toEqual({ tag: "3.0.0", version: "3.0.0" }); }); }); -const fail = (message: string): never => { - throw new Error(message); -}; +describe("parseRefs", () => { + test("maps refs to commits, preferring an annotated tag's commit", () => { + const listing = [ + `${"a".repeat(40)}\trefs/tags/v3.0.0`, + `${REVISION}\trefs/tags/v3.0.0^{}`, + `${OTHER}\trefs/heads/main`, + "not-a-sha\trefs/heads/broken", + "", + ].join("\n"); + expect([...parseRefs(listing)]).toEqual([ + ["refs/tags/v3.0.0", REVISION], + ["refs/heads/main", OTHER], + ]); + }); +});