From f45d52993c587081b62a9e03812aaf3486e45596 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Fri, 11 Sep 2026 01:30:24 -0300 Subject: [PATCH 1/5] refactor(cli): preserve the stream failure type in collectText The parameter was typed Stream, so folding a child process's stderr widened PlatformError to unknown and carried it into every caller's error channel. Making it generic infers the stream's own failure type instead; no call site changes. --- apps/cli/src/command-internal/container-cli.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/command-internal/container-cli.ts b/apps/cli/src/command-internal/container-cli.ts index 9318b3dcd8..3f423788e8 100644 --- a/apps/cli/src/command-internal/container-cli.ts +++ b/apps/cli/src/command-internal/container-cli.ts @@ -109,8 +109,8 @@ export const containerCliExitCode = ( ), ); -/** Folds a byte stream into a decoded string. */ -export function collectText(stream: Stream.Stream) { +/** Folds a byte stream into a decoded string, preserving the stream's own failure type. */ +export function collectText(stream: Stream.Stream) { const decoder = new TextDecoder(); return Stream.runFold( stream, From 09be387fcf656b4c6c257d3d79c813789c5a5844 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Fri, 11 Sep 2026 01:47:17 -0300 Subject: [PATCH 2/5] refactor(cli): refuse a compute new destination before its dial prompts Nothing about the destination depends on the runtime, size or exposure, so resolving --source and refusing an occupied directory after those three prompts asked the user to answer them for a run that was already going to be refused. Both checks now run first. --- .../experimental/compute/new/SIDE_EFFECTS.md | 5 +++- .../experimental/compute/new/new.handler.ts | 25 +++++++++++-------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/apps/cli/src/commands/experimental/compute/new/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/new/SIDE_EFFECTS.md index 3a1e04bdf9..e02be3f41e 100644 --- a/apps/cli/src/commands/experimental/compute/new/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/new/SIDE_EFFECTS.md @@ -71,7 +71,10 @@ and before anything reaches disk — because editing an entry the user owns is not this command's job. Nothing at the destination is ever removed or overwritten: a destination that -exists and is not empty is refused, and clearing it is left to the user. +exists and is not empty is refused, and clearing it is left to the user. That +refusal, and a bad `--source`, are both checked before the runtime, size and +exposure are asked for — none of them depend on the destination, so a run that is +going to be refused for it is refused without asking three questions first. `--source` is refused when it resolves to the project root, `supabase/`, `supabase/functions/`, `supabase/migrations/`, or outside the project. Symlinks are resolved first, so a path inside the project that points outside it is diff --git a/apps/cli/src/commands/experimental/compute/new/new.handler.ts b/apps/cli/src/commands/experimental/compute/new/new.handler.ts index e62b9ffb31..f67c7c8439 100644 --- a/apps/cli/src/commands/experimental/compute/new/new.handler.ts +++ b/apps/cli/src/commands/experimental/compute/new/new.handler.ts @@ -267,16 +267,14 @@ export const computeNew = Effect.fn("compute.new")(function* (flags: ComputeNewF }); } - // Resolved before anything is written, so cancelling any prompt leaves nothing - // behind. With nowhere to ask, the defaults stand — only the name has no fallback. - const runtime = yield* resolveRuntime({ explicit: flags.runtime, canPrompt }); - const size = yield* resolveSize({ explicit: flags.size, canPrompt }); - const exposure = yield* resolveExposure({ explicit: flags.exposure, canPrompt }); - const instances = recordedInstances(flags.instances); - - // Validated before anything is written: this is the directory the starter files - // land in, so a value naming the project root, `supabase/`, or anywhere outside - // the project must never reach the write below. `--source` resolves against the + // Validated before the dials are asked for, not just before the write: nothing + // about the destination depends on the runtime, size or exposure, so a run that + // is going to be refused for its destination is refused without asking three + // questions first. + // + // This is the directory the starter files land in, so a value naming the project + // root, `supabase/`, or anywhere outside the project must never reach the write + // below. `--source` resolves against the // directory the user typed it in, the way a shell would: `--source generated` // from `apps/web` means `apps/web/generated`. const destination = Option.isSome(flags.source) @@ -311,6 +309,13 @@ export const computeNew = Effect.fn("compute.new")(function* (flags: ComputeNewF }); } + // Resolved before anything is written, so cancelling any prompt leaves nothing + // behind. With nowhere to ask, the defaults stand — only the name has no fallback. + const runtime = yield* resolveRuntime({ explicit: flags.runtime, canPrompt }); + const size = yield* resolveSize({ explicit: flags.size, canPrompt }); + const exposure = yield* resolveExposure({ explicit: flags.exposure, canPrompt }); + const instances = recordedInstances(flags.instances); + // Recorded as forward slashes whatever platform wrote it: `config.toml` is // shared, and `path.relative` yields backslashes on Windows that POSIX // resolvers elsewhere would read as a literal filename character. From 9f878260ea8ecee607420142add5420c607bcc79 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Fri, 11 Sep 2026 01:48:13 -0300 Subject: [PATCH 3/5] feat(cli): add a git template fetcher for compute scaffolds Stages a starter tree with git, the way degit does: a depth-1 clone into a scoped temporary directory whose .git is then removed, so a template is a starting point rather than a checkout. Cloning rather than reading a host's archive API is what keeps it host-agnostic -- anything git can clone works, private repositories included, since the user's own credential helper answers for them. Accepts a GitHub owner/repo slug with optional subdirectory and #ref, a github.com URL including the /tree// form a browser produces, or any other repository URL. A ref is fetched by name rather than through clone --branch, so a commit SHA works as well as a branch or tag. A subdirectory is only read out of a GitHub slug or URL, where the repository boundary is part of the syntax. Since the cloned tree decides what it resolves to, a subdirectory that links outside the repository is refused rather than staged. GIT_TERMINAL_PROMPT=0 keeps git from blocking on a password prompt drawn over the CLI's own output. No command consumes it yet. --- .../compute-template.integration.test.ts | 153 +++++++++ .../src/shared/compute/compute-template.ts | 318 ++++++++++++++++++ .../compute/compute-template.unit.test.ts | 114 +++++++ .../telemetry/__fixtures__/error-tags.txt | 3 + apps/cli/tests/helpers/git-repo.ts | 63 ++++ 5 files changed, 651 insertions(+) create mode 100644 apps/cli/src/shared/compute/compute-template.integration.test.ts create mode 100644 apps/cli/src/shared/compute/compute-template.ts create mode 100644 apps/cli/src/shared/compute/compute-template.unit.test.ts create mode 100644 apps/cli/tests/helpers/git-repo.ts diff --git a/apps/cli/src/shared/compute/compute-template.integration.test.ts b/apps/cli/src/shared/compute/compute-template.integration.test.ts new file mode 100644 index 0000000000..7ff04cda6f --- /dev/null +++ b/apps/cli/src/shared/compute/compute-template.integration.test.ts @@ -0,0 +1,153 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Path } from "effect"; +import { makeGitRepo, runFixtureGit } from "../../../tests/helpers/git-repo.ts"; +import { + ComputeTemplateContentError, + ComputeTemplateFetchError, + stageComputeTemplate, + type ComputeTemplateSpec, +} from "./compute-template.ts"; + +function spec( + overrides: Partial & { readonly url: string }, +): ComputeTemplateSpec { + return { ref: undefined, subdir: [], display: overrides.url, ...overrides }; +} + +describe("stageComputeTemplate", () => { + it.live("stages the repository's files without its history", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const repo = yield* makeGitRepo({ + "index.mjs": "export default { fetch: () => new Response('hi') };\n", + "lib/util.mjs": "export const one = 1;\n", + }); + + const staged = yield* stageComputeTemplate(spec({ url: repo })); + + expect(yield* fs.readFileString(path.join(staged, "index.mjs"))).toContain("Response('hi')"); + expect(yield* fs.readFileString(path.join(staged, "lib", "util.mjs"))).toContain("one = 1"); + expect(yield* fs.exists(path.join(staged, ".git"))).toBe(false); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("stages only the named subdirectory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const repo = yield* makeGitRepo({ + "README.md": "the whole repo\n", + "examples/hono/index.ts": "export default {};\n", + }); + + const staged = yield* stageComputeTemplate(spec({ url: repo, subdir: ["examples", "hono"] })); + + expect(yield* fs.exists(path.join(staged, "index.ts"))).toBe(true); + expect(yield* fs.exists(path.join(staged, "README.md"))).toBe(false); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("checks out a tag by name", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const repo = yield* makeGitRepo({ "index.mjs": "v1\n" }); + yield* Effect.scoped(runFixtureGit(repo, ["tag", "v1.0.0"])); + yield* fs.writeFileString(path.join(repo, "index.mjs"), "v2\n"); + yield* Effect.scoped(runFixtureGit(repo, ["commit", "--quiet", "-am", "v2"])); + + const staged = yield* stageComputeTemplate(spec({ url: repo, ref: "v1.0.0" })); + + expect(yield* fs.readFileString(path.join(staged, "index.mjs"))).toBe("v1\n"); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("reports what git said when the ref does not exist", () => + Effect.gen(function* () { + const repo = yield* makeGitRepo({ "index.mjs": "v1\n" }); + + const error = yield* stageComputeTemplate(spec({ url: repo, ref: "nope" })).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ComputeTemplateFetchError); + expect(error.detail).toContain("nope"); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("refuses a repository that is not there", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parent = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-template-absent-" }); + + const error = yield* stageComputeTemplate(spec({ url: path.join(parent, "missing") })).pipe( + Effect.flip, + ); + + expect(error).toBeInstanceOf(ComputeTemplateFetchError); + expect(error.suggestion).toContain("git is installed"); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("refuses a subdirectory the repository does not have", () => + Effect.gen(function* () { + const repo = yield* makeGitRepo({ "index.mjs": "v1\n" }); + + const error = yield* stageComputeTemplate(spec({ url: repo, subdir: ["nope"] })).pipe( + Effect.flip, + ); + + expect(error).toBeInstanceOf(ComputeTemplateContentError); + expect(error.detail).toContain("does not have"); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("refuses a subdirectory that names a file", () => + Effect.gen(function* () { + const repo = yield* makeGitRepo({ "index.mjs": "v1\n" }); + + const error = yield* stageComputeTemplate(spec({ url: repo, subdir: ["index.mjs"] })).pipe( + Effect.flip, + ); + + expect(error).toBeInstanceOf(ComputeTemplateContentError); + expect(error.detail).toContain("not a directory"); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + // The cloned repository decides what the subdirectory resolves to, so this is the + // one way a template could aim the copy at the rest of the machine. + it.live("refuses a subdirectory that is a link leading out of the repository", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const outside = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-template-outside-" }); + yield* fs.writeFileString(path.join(outside, "secret"), "not yours\n"); + const repo = yield* makeGitRepo({ "index.mjs": "v1\n" }); + yield* fs.symlink(outside, path.join(repo, "escape")); + yield* Effect.scoped(runFixtureGit(repo, ["add", "-A"])); + yield* Effect.scoped(runFixtureGit(repo, ["commit", "--quiet", "-m", "escape"])); + + const error = yield* stageComputeTemplate(spec({ url: repo, subdir: ["escape"] })).pipe( + Effect.flip, + ); + + expect(error).toBeInstanceOf(ComputeTemplateContentError); + expect(error.detail).toContain("outside the repository"); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("refuses a template whose tree holds no files", () => + Effect.gen(function* () { + const repo = yield* makeGitRepo({ ".keep": "" }); + yield* Effect.scoped(runFixtureGit(repo, ["rm", "--quiet", ".keep"])); + yield* Effect.scoped(runFixtureGit(repo, ["commit", "--quiet", "-m", "empty"])); + + const error = yield* stageComputeTemplate(spec({ url: repo })).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ComputeTemplateContentError); + expect(error.detail).toContain("holds no files"); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); +}); diff --git a/apps/cli/src/shared/compute/compute-template.ts b/apps/cli/src/shared/compute/compute-template.ts new file mode 100644 index 0000000000..2d964ee65e --- /dev/null +++ b/apps/cli/src/shared/compute/compute-template.ts @@ -0,0 +1,318 @@ +/** + * `supabase compute new --template` — a starter tree fetched with `git` rather than + * read out of `./stacks/`. + * + * The fetch is a depth-1 clone into a temporary directory whose `.git` is then + * removed, the way `degit` works. Cloning instead of reading a host's archive API + * is what keeps the flag host-agnostic: anything `git` can clone works, private + * repositories included, since the user's own credential helper answers for them. + */ + +import { Data, Effect, FileSystem, Option, Path, PlatformError } from "effect"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; +import { collectText } from "../../command-internal/container-cli.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; + +/** What `git` is asked to clone, and which part of the result to copy. */ +export interface ComputeTemplateSpec { + /** A repository URL `git clone` accepts. */ + readonly url: string; + /** Branch, tag or commit to check out; `undefined` takes the remote's default branch. */ + readonly ref: string | undefined; + /** Path segments inside the repository, empty for the whole tree. */ + readonly subdir: ReadonlyArray; + /** `--template` as the user typed it, for output and error messages. */ + readonly display: string; +} + +/** `--template` names something this command cannot turn into a repository URL. */ +export class InvalidComputeTemplateError extends Data.TaggedError("InvalidComputeTemplateError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** `git` is missing, or the clone it ran did not succeed. */ +export class ComputeTemplateFetchError extends Data.TaggedError("ComputeTemplateFetchError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} + +/** + * The clone succeeded and does not hold what `--template` named: a subdirectory that + * isn't there, isn't a directory, or is a link leading out of the repository, or a + * template tree with no files in it at all. + */ +export class ComputeTemplateContentError extends Data.TaggedError("ComputeTemplateContentError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** A GitHub owner or repository name. */ +const GITHUB_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; + +/** + * A branch, tag or commit. Narrower than git's own ref rules, which allow almost any + * byte — this has to be safe to hand to `git fetch` as a positional argument. + */ +const REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/; + +/** + * Something `git clone` treats as a repository location: a scheme-qualified URL, an + * scp-style `user@host:path` remote, or an absolute local path. A value matching none + * of these is read as an `/` slug instead, which is also what keeps + * `ext::` — git's arbitrary-command transport — out of the clone. + */ +const CLONEABLE_URL = /^(?:[a-z][a-z0-9+.-]*:\/\/|[^\s/\\:@]+@[^\s/\\:]+:|\/|[A-Za-z]:[\\/])/; + +const TEMPLATE_SUGGESTION = + "Pass --template as a GitHub owner/repo slug, optionally with a subdirectory and a #ref " + + "(supabase/templates/compute/api#main), or as any repository URL git can clone."; + +const FETCH_SUGGESTION = + "Check that git is installed, that the repository and ref exist, and that you can " + + "clone it from this machine."; + +/** A github.com web or clone URL, reduced to the path after the host. */ +function githubPath(base: string): string | undefined { + return /^https?:\/\/(?:www\.)?github\.com\/(.+)$/.exec(base)?.[1]; +} + +/** + * Parses `--template` into a clone plan. + * + * Accepts a GitHub `/` slug with optional trailing subdirectory, a + * github.com URL including the `/tree//` form the browser produces, or + * any other repository URL `git` can clone. A trailing `#` pins a branch, tag or + * commit on every form. + */ +export const parseComputeTemplate = Effect.fnUntraced(function* (raw: string) { + const trimmed = raw.trim(); + const refuse = (why: string) => + new InvalidComputeTemplateError({ + detail: `--template "${raw}" ${why}.`, + suggestion: TEMPLATE_SUGGESTION, + }); + + if (trimmed === "") { + return yield* refuse("is empty"); + } + // Otherwise this reaches `git` as an option rather than as a repository. + if (trimmed.startsWith("-")) { + return yield* refuse("starts with a hyphen"); + } + + const hash = trimmed.lastIndexOf("#"); + const base = hash === -1 ? trimmed : trimmed.slice(0, hash); + const hashRef = hash === -1 ? undefined : trimmed.slice(hash + 1); + + if (base === "") { + return yield* refuse("names a ref with no repository"); + } + if (hashRef !== undefined && !REF_PATTERN.test(hashRef)) { + return yield* refuse(`names "${hashRef}" after # which is not a branch, tag or commit`); + } + + const hosted = githubPath(base); + + if (hosted === undefined && CLONEABLE_URL.test(base)) { + return { + url: base, + ref: hashRef, + // A subdirectory is only recognized on a GitHub slug or URL, where the repository + // boundary is part of the syntax; every other URL is a repository in full. + subdir: [], + display: trimmed, + } satisfies ComputeTemplateSpec; + } + + const segments = (hosted ?? base).replace(/\/+$/, "").split("/"); + const owner = segments[0] ?? ""; + const repo = (segments[1] ?? "").replace(/\.git$/, ""); + if (!GITHUB_SEGMENT.test(owner) || !GITHUB_SEGMENT.test(repo)) { + return yield* refuse("is neither a GitHub owner/repo slug nor a URL git can clone"); + } + + // `/tree//` is the github.com web URL, so it is read only there — in a + // bare slug a `tree` segment is an ordinary directory name. + const rest = segments.slice(2); + const isWebTree = hosted !== undefined && rest[0] === "tree"; + const treeRef = isWebTree ? rest[1] : undefined; + const subdir = isWebTree ? rest.slice(2) : rest; + + if (treeRef !== undefined && hashRef !== undefined) { + return yield* refuse(`names a ref twice, as /tree/${treeRef} and as #${hashRef}`); + } + if (isWebTree && treeRef === undefined) { + return yield* refuse("ends at /tree with no branch, tag or commit after it"); + } + if (treeRef !== undefined && !REF_PATTERN.test(treeRef)) { + return yield* refuse(`names "${treeRef}" as a ref, which is not a branch, tag or commit`); + } + for (const segment of subdir) { + // `\` is not a separator here, so a segment carrying one would survive the join + // and be read as one by the host filesystem. + if (segment === "" || segment === "." || segment === ".." || segment.includes("\\")) { + return yield* refuse( + `names "${subdir.join("/")}", which is not a path inside the repository`, + ); + } + } + + return { + url: `https://github.com/${owner}/${repo}.git`, + ref: treeRef ?? hashRef, + subdir, + display: trimmed, + } satisfies ComputeTemplateSpec; +}); + +/** + * Runs `git`, failing with its stderr when it exits non-zero. + * + * `GIT_TERMINAL_PROMPT=0` is set because the alternative is git blocking on a username + * prompt drawn over the CLI's own output; a private template comes from a credential + * helper or an SSH key instead. + */ +function runGit( + spec: ComputeTemplateSpec, + args: ReadonlyArray, + cwd?: string, +): Effect.Effect { + const fail = (why: string) => + new ComputeTemplateFetchError({ + detail: `Could not fetch --template "${spec.display}": ${why}`, + suggestion: FETCH_SUGGESTION, + }); + + return Effect.scoped( + Effect.gen(function* () { + const handle = yield* ChildProcess.make("git", [...args], { + ...(cwd === undefined ? {} : { cwd }), + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + env: { GIT_TERMINAL_PROMPT: "0" }, + extendEnv: true, + }).pipe(Effect.mapError((error) => fail(`git could not be started (${error.message})`))); + + const [exitCode, stderr] = yield* Effect.all( + [handle.exitCode.pipe(Effect.map(Number)), collectText(handle.stderr)], + { concurrency: "unbounded" }, + ).pipe(Effect.mapError(() => fail("git did not report an exit status"))); + + if (exitCode !== 0) { + const message = stderr.trim(); + return yield* fail(message.length > 0 ? message : `git exited with code ${exitCode}`); + } + }), + ); +} + +/** + * Reports a failure reading the tree that was just cloned as a fetch failure: the + * staged directory is the CLI's own, so the clone is what did not land usably. + */ +const staging = (spec: ComputeTemplateSpec) => + Effect.mapError( + (error: PlatformError.PlatformError) => + new ComputeTemplateFetchError({ + detail: `Could not fetch --template "${spec.display}": the clone could not be read (${error.message})`, + suggestion: FETCH_SUGGESTION, + }), + ); + +/** + * Clones `spec` into a temporary directory owned by the current scope and returns the + * directory its files start at. + * + * Nothing in the project is touched: the caller copies out of the staged tree, so a + * fetch that fails for any reason leaves the destination as it found it. + */ +export const stageComputeTemplate = Effect.fnUntraced(function* (spec: ComputeTemplateSpec) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const clone = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-compute-template-" }).pipe( + Effect.mapError( + (error) => + new ComputeTemplateFetchError({ + detail: `Could not fetch --template "${spec.display}": no temporary directory to clone into (${error.message})`, + suggestion: FETCH_SUGGESTION, + }), + ), + ); + + if (spec.ref === undefined) { + yield* runGit(spec, [ + "clone", + "--depth", + "1", + "--single-branch", + "--quiet", + "--", + spec.url, + clone, + ]); + } else { + // Fetching one ref by name, rather than `clone --branch`, is what makes a commit + // SHA work: `--branch` accepts only branches and tags. + yield* runGit(spec, ["init", "--quiet", clone]); + yield* runGit(spec, ["fetch", "--depth", "1", "--quiet", spec.url, spec.ref], clone); + yield* runGit(spec, ["checkout", "--quiet", "FETCH_HEAD"], clone); + } + + // A template is a starting point, not a checkout. Left in place, its history would + // become the compute directory's own, and `push` would package it. + yield* fs.remove(path.join(clone, ".git"), { recursive: true, force: true }).pipe(staging(spec)); + + const root = spec.subdir.length === 0 ? clone : path.join(clone, ...spec.subdir); + const refuse = (why: string) => + new ComputeTemplateContentError({ + detail: `--template "${spec.display}" ${why}.`, + suggestion: TEMPLATE_SUGGESTION, + }); + + const canonicalClone = yield* fs.realPath(clone).pipe(staging(spec)); + const canonicalRoot = yield* fs.realPath(root).pipe(Effect.option); + if (Option.isNone(canonicalRoot)) { + return yield* refuse(`names ${spec.subdir.join("/")}, which the repository does not have`); + } + + // The cloned repository decides what `root` resolves to, so a subdirectory that is a + // symlink could otherwise point the copy at anything on this machine. + const relative = path.relative(canonicalClone, canonicalRoot.value); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + return yield* refuse( + `names ${spec.subdir.join("/")}, which is a link leading outside the repository`, + ); + } + + const info = yield* fs.stat(canonicalRoot.value).pipe(staging(spec)); + if (info.type !== "Directory") { + return yield* refuse(`names ${spec.subdir.join("/")}, which is not a directory`); + } + + const entries = yield* fs.readDirectory(canonicalRoot.value).pipe(staging(spec)); + if (entries.length === 0) { + return yield* refuse("holds no files"); + } + + return canonicalRoot.value; +}); diff --git a/apps/cli/src/shared/compute/compute-template.unit.test.ts b/apps/cli/src/shared/compute/compute-template.unit.test.ts new file mode 100644 index 0000000000..2345c73860 --- /dev/null +++ b/apps/cli/src/shared/compute/compute-template.unit.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { + InvalidComputeTemplateError, + parseComputeTemplate, + type ComputeTemplateSpec, +} from "./compute-template.ts"; + +const parse = (raw: string): ComputeTemplateSpec => Effect.runSync(parseComputeTemplate(raw)); + +const refusal = (raw: string): InvalidComputeTemplateError => + Effect.runSync(parseComputeTemplate(raw).pipe(Effect.flip)); + +describe("parseComputeTemplate", () => { + it("reads a GitHub owner/repo slug as an https clone URL", () => { + expect(parse("supabase/compute-starters")).toEqual({ + url: "https://github.com/supabase/compute-starters.git", + ref: undefined, + subdir: [], + display: "supabase/compute-starters", + }); + }); + + it("reads trailing slug segments as a subdirectory", () => { + expect(parse("supabase/compute-starters/examples/hono")).toMatchObject({ + url: "https://github.com/supabase/compute-starters.git", + subdir: ["examples", "hono"], + }); + }); + + it("pins a branch, tag or commit given after #", () => { + expect(parse("supabase/starters#v2.1.0")).toMatchObject({ ref: "v2.1.0" }); + expect(parse("supabase/starters/api#3f4b0c0")).toMatchObject({ + ref: "3f4b0c0", + subdir: ["api"], + }); + }); + + it("reads the github.com URL a browser produces, including /tree//", () => { + expect(parse("https://github.com/supabase/starters")).toMatchObject({ + url: "https://github.com/supabase/starters.git", + ref: undefined, + subdir: [], + }); + expect(parse("https://github.com/supabase/starters.git")).toMatchObject({ + url: "https://github.com/supabase/starters.git", + }); + expect(parse("https://github.com/supabase/starters/tree/main/examples/api")).toMatchObject({ + url: "https://github.com/supabase/starters.git", + ref: "main", + subdir: ["examples", "api"], + }); + }); + + // `tree` is only a ref marker in a github.com URL; in a slug it is a directory + // name like any other. + it("treats a tree segment in a bare slug as a directory", () => { + expect(parse("supabase/starters/tree/main")).toMatchObject({ + subdir: ["tree", "main"], + ref: undefined, + }); + }); + + it("passes any other cloneable URL through untouched", () => { + expect(parse("https://gitlab.com/acme/api.git#v2")).toEqual({ + url: "https://gitlab.com/acme/api.git", + ref: "v2", + subdir: [], + display: "https://gitlab.com/acme/api.git#v2", + }); + expect(parse("git@github.com:supabase/starters.git")).toMatchObject({ + url: "git@github.com:supabase/starters.git", + }); + expect(parse("ssh://git@git.acme.dev:2222/acme/api")).toMatchObject({ + url: "ssh://git@git.acme.dev:2222/acme/api", + }); + expect(parse("file:///srv/templates/api")).toMatchObject({ + url: "file:///srv/templates/api", + }); + expect(parse("/srv/templates/api")).toMatchObject({ url: "/srv/templates/api" }); + }); + + // A non-GitHub URL has no syntax marking where the repository ends, so the whole + // repository is the template. + it("does not read a subdirectory out of a non-GitHub URL", () => { + expect(parse("https://gitlab.com/acme/api/examples/hono")).toMatchObject({ + url: "https://gitlab.com/acme/api/examples/hono", + subdir: [], + }); + }); + + it.each([ + { raw: "", why: "is empty" }, + { raw: " ", why: "is empty" }, + { raw: "--upload-pack=touch /tmp/x", why: "starts with a hyphen" }, + { raw: "#main", why: "names a ref with no repository" }, + { raw: "supabase/starters#--flag", why: "not a branch, tag or commit" }, + { raw: "supabase/starters#", why: "not a branch, tag or commit" }, + { raw: "supabase", why: "neither a GitHub owner/repo slug nor a URL" }, + { raw: "ext::sh -c whoami", why: "neither a GitHub owner/repo slug nor a URL" }, + { raw: "supabase/starters/../../etc", why: "not a path inside the repository" }, + { raw: "supabase/starters/a\\..\\b", why: "not a path inside the repository" }, + { + raw: "https://github.com/supabase/starters/tree/main/api#dev", + why: "names a ref twice", + }, + { raw: "https://github.com/supabase/starters/tree", why: "ends at /tree" }, + ])("refuses $raw", ({ raw, why }) => { + const error = refusal(raw); + expect(error).toBeInstanceOf(InvalidComputeTemplateError); + expect(error.detail).toContain(why); + expect(error.suggestion).toContain("--template"); + }); +}); diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index 9417dee711..394b5ee3e8 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -64,6 +64,8 @@ ComputeRoutingError ComputeSourceEscapingLinkError ComputeSourceMissingError ComputeStacksValidationError +ComputeTemplateContentError +ComputeTemplateFetchError ComputeUnavailableError ComputeUploadFailedError ConfigDiffBranchNotFoundError @@ -288,6 +290,7 @@ InspectReportWriteError InvalidAccessTokenError InvalidComputeNameError InvalidComputeSourceError +InvalidComputeTemplateError InvalidFunctionDeploySlugError InvalidFunctionDownloadResponseError InvalidFunctionSlugError diff --git a/apps/cli/tests/helpers/git-repo.ts b/apps/cli/tests/helpers/git-repo.ts new file mode 100644 index 0000000000..9d1029dbd0 --- /dev/null +++ b/apps/cli/tests/helpers/git-repo.ts @@ -0,0 +1,63 @@ +import { Effect, FileSystem, Path } from "effect"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import { collectText } from "../../src/command-internal/container-cli.ts"; + +/** + * Real git repositories for the tests that clone one, so `--template` is exercised + * against git itself without reaching the network. + */ + +/** + * Runs `git` in `cwd`, dying with its stderr when it exits non-zero. + * + * Identity and configuration are pinned so a fixture does not depend on the + * developer's own `~/.gitconfig` (or on there being one at all). + */ +export const runFixtureGit = Effect.fnUntraced(function* ( + cwd: string, + args: ReadonlyArray, +) { + const handle = yield* ChildProcess.make("git", [...args], { + cwd, + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + env: { + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_SYSTEM: "/dev/null", + GIT_AUTHOR_NAME: "Fixture", + GIT_AUTHOR_EMAIL: "fixture@example.com", + GIT_COMMITTER_NAME: "Fixture", + GIT_COMMITTER_EMAIL: "fixture@example.com", + }, + extendEnv: true, + }); + const [exitCode, stderr] = yield* Effect.all( + [handle.exitCode.pipe(Effect.map(Number)), collectText(handle.stderr)], + { concurrency: "unbounded" }, + ); + if (exitCode !== 0) { + return yield* Effect.die(`git ${args.join(" ")} failed: ${stderr}`); + } +}); + +/** + * A scoped temp directory holding `files`, committed once on `main`. The returned + * absolute path is a repository location `git clone` accepts. + */ +export const makeGitRepo = Effect.fnUntraced(function* (files: Readonly>) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-git-repo-" }); + + for (const [relativePath, contents] of Object.entries(files)) { + const absolutePath = path.join(dir, relativePath); + yield* fs.makeDirectory(path.dirname(absolutePath), { recursive: true }); + yield* fs.writeFileString(absolutePath, contents); + } + + yield* Effect.scoped(runFixtureGit(dir, ["init", "--quiet", "--initial-branch=main"])); + yield* Effect.scoped(runFixtureGit(dir, ["add", "-A"])); + yield* Effect.scoped(runFixtureGit(dir, ["commit", "--quiet", "-m", "fixture"])); + return dir; +}); From 21541999947fe566adcdbb9115ceca0fd37b3ece Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Fri, 11 Sep 2026 01:49:33 -0300 Subject: [PATCH 4/5] feat(cli): add compute new --template Wires the git template fetcher into the scaffold: --template makes a repository the compute's entire contents. No starter files are written alongside it -- the starters are what a compute with no code yet needs, and writing both would leave behind whichever the template happened not to name. That is not merely untidy, because the catalog runtimes load a fixed entry file: a node template whose entry is src/server.js would be shadowed by the starter's index.mjs and never run. An omitted --runtime is defaulted from the template's own marker files, by the same classifier push uses on an unconfigured directory, so a template shipping a Dockerfile is not recorded as deno and then deployed as a base image that never reads it. It stays a default: --runtime wins, and an interactive run is still asked with the inference pre-selected. Reading that default means cloning before the dials resolve, which is why the refusals that do not depend on them run first -- the clone is wasted work for a run that was never going to scaffold anything. It stages into a temporary directory either way, so a bad ref, an absent repository or a missing git leaves the destination untouched and config.toml unwritten, the same way a cancelled prompt does. --- apps/cli/docs/compute-commands.md | 7 + .../experimental/compute/new/SIDE_EFFECTS.md | 55 +++- .../experimental/compute/new/new.command.ts | 16 +- .../experimental/compute/new/new.handler.ts | 95 ++++++- .../compute/new/new.integration.test.ts | 264 ++++++++++++++++++ 5 files changed, 420 insertions(+), 17 deletions(-) diff --git a/apps/cli/docs/compute-commands.md b/apps/cli/docs/compute-commands.md index f69581da9b..40b261cad8 100644 --- a/apps/cli/docs/compute-commands.md +++ b/apps/cli/docs/compute-commands.md @@ -40,6 +40,13 @@ When enabled, the CLI exposes: Compute source directories live under `supabase/compute//`. +`compute new --template` bootstraps the source directory from a git repository +instead of the runtime's starter files — a GitHub `owner/repo` slug (optionally +with a subdirectory and a `#ref`), or any repository URL `git` can clone. The +repository becomes the compute's entire contents; no starter files are written +alongside it. When `--runtime` is omitted, the template's own marker files pick +the runtime. + `compute new` edits TOML configuration. It refuses projects whose authoritative configuration is JSON before prompting or writing, so it cannot save deployment settings into an ignored file. To deploy a source directory without a Compute diff --git a/apps/cli/src/commands/experimental/compute/new/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/new/SIDE_EFFECTS.md index e02be3f41e..63a233c96b 100644 --- a/apps/cli/src/commands/experimental/compute/new/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/new/SIDE_EFFECTS.md @@ -21,6 +21,7 @@ and the command handler does not run. See the [Compute command guide](../../../. | `/` | dir | always, to refuse a destination that is not empty | | `/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | | `` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | +| `/supabase-compute-template-*/` | varies | when `--template` is given — the clone is read back to locate the template tree and copy out of it | ## Files Written @@ -29,6 +30,7 @@ and the command handler does not run. See the [Compute command guide](../../../. | `/supabase/config.toml` | TOML | on success — appends `[compute.]` with `runtime`, `size` and `exposure` always, `instances` only when it differs from the default of 1, and `source` only when `--source` was passed, preserving surrounding formatting | | `/supabase/compute//*` | varies | on success, unless `--source` names another directory | | `//*` | varies | on success, when `--source` is given | +| `/supabase-compute-template-*/` | varies | when `--template` is given — a depth-1 clone, removed when the command ends | | `/telemetry.json` | JSON | whenever the handler runs — flushed on success and on failure | Compute resources are recorded in `config.toml` only. The project config loader prefers @@ -70,11 +72,57 @@ Writes to `config.toml` are append-only. A compute already recorded under and before anything reaches disk — because editing an entry the user owns is not this command's job. +`--template` bootstraps the destination from a git repository instead of the +runtime's starter files. It accepts a GitHub `/` slug, with +optional trailing subdirectory and `#`; a github.com URL, including the +`/tree//` form a browser produces; or any other repository URL +`git` can clone, with an optional `#`. A subdirectory is only read out of a +GitHub slug or URL, where the repository boundary is part of the syntax; every +other URL is cloned in full. A value naming a ref twice, starting with a hyphen, +or resolving to neither a slug nor a cloneable URL is refused before any prompt +runs. + +Fetching is `git clone --depth 1` into a temporary directory, or +`git init` + `git fetch --depth 1` + `git checkout FETCH_HEAD` when a ref is +given — fetching the ref by name is what makes a commit SHA work as well as a +branch or tag. `GIT_TERMINAL_PROMPT=0` is set, so a private template must come +from a credential helper or an SSH key rather than an interactive password +prompt. The clone's `.git` is removed before anything is copied, so the +template's history never becomes the compute directory's own. The clone happens +before the destination is created, so a template that cannot be fetched, a ref +that does not exist, a subdirectory the repository does not have, or a template +tree with no files in it leaves nothing on disk and no `config.toml` entry. + +A template is the compute's entire contents: when one is given, none of the +runtime's starter files are written, and the destination holds exactly what the +template's tree holds. The starters are what a compute with no code yet needs, and +writing both would leave behind whichever of them the template happened not to +name — which is not merely untidy, because the catalog runtimes load a fixed entry +file. A node template whose entry is `src/server.js` would sit next to the +starter's `index.mjs`, and `index.mjs` is what the runtime loads, so the deployed +compute would serve the greeting scaffold instead of the template's code, with +nothing reporting it. Nothing about the template itself is recorded in +`config.toml`. + +When `--runtime` is omitted, the runtime _default_ is read out of the staged +template's own marker files — `Dockerfile`, then `deno.json`/`deno.jsonc`/ +`deno.lock`, then `package.json`, falling back to `deno` — the same markers +`push` classifies an unconfigured directory by. It is a default, not an answer: +`--runtime` still wins, and an interactive run is still asked, with the inference +pre-selected. Recording the catalog default instead would write `runtime = "deno"` +for a template that ships a `Dockerfile`, and `push` would then deploy a base +image that never reads it. + +Because the runtime default depends on the template, the clone happens before the +runtime, size and exposure are resolved — after every refusal that does not depend +on them, as above. Cancelling a dial prompt after the clone still writes nothing. + Nothing at the destination is ever removed or overwritten: a destination that exists and is not empty is refused, and clearing it is left to the user. That refusal, and a bad `--source`, are both checked before the runtime, size and -exposure are asked for — none of them depend on the destination, so a run that is -going to be refused for it is refused without asking three questions first. +exposure are asked for and before any `--template` is cloned — none of them depend +on the destination, so a run that is going to be refused for it is refused without +asking three questions or paying for a fetch first. `--source` is refused when it resolves to the project root, `supabase/`, `supabase/functions/`, `supabase/migrations/`, or outside the project. Symlinks are resolved first, so a path inside the project that points outside it is @@ -99,6 +147,9 @@ root. | `1` | no name given, and nowhere to ask for one — stdin or stdout is not a terminal, or `-o` is in force | | `1` | bad `--source`: outside the project, or a path the CLI owns | | `1` | destination exists and is not empty | +| `1` | `--template` is neither a GitHub slug nor a URL git can clone (`InvalidComputeTemplateError`) — refused before any prompt | +| `1` | the template could not be cloned: no `git`, no such repository, or no such ref (`ComputeTemplateFetchError`) | +| `1` | the clone does not hold what `--template` named, or holds no files (`ComputeTemplateContentError`) | | `1` | the compute is already recorded in `config.toml`, in any form | | `1` | the rendered `config.toml` would not parse, or `[compute]` is a sealed inline table | diff --git a/apps/cli/src/commands/experimental/compute/new/new.command.ts b/apps/cli/src/commands/experimental/compute/new/new.command.ts index 167d15055a..6748dfd484 100644 --- a/apps/cli/src/commands/experimental/compute/new/new.command.ts +++ b/apps/cli/src/commands/experimental/compute/new/new.command.ts @@ -23,7 +23,7 @@ const config = { ), runtime: Flag.choice("runtime", COMPUTE_RUNTIMES).pipe( Flag.withDescription( - "Runtime to scaffold and record in supabase/config.toml. Prompted when omitted.", + "Runtime to scaffold and record in supabase/config.toml. Prompted when omitted, defaulting to what --template looks like when one is given.", ), Flag.optional, ), @@ -57,6 +57,12 @@ const config = { ), Flag.optional, ), + template: Flag.string("template").pipe( + Flag.withDescription( + "Bootstrap the compute from a git repository instead of the runtime's starter files: a GitHub owner/repo slug, optionally with a subdirectory and a #ref, or any repository URL git can clone. The repository becomes the compute's entire contents in place of those starter files, and its marker files pick the runtime when --runtime is omitted.", + ), + Flag.optional, + ), } as const; export type ComputeNewFlags = CliCommand.Command.Config.Infer; @@ -100,6 +106,14 @@ export const computeNewCommand = Command.make("new", config).pipe( command: "supabase compute new api --source packages/api", description: "Scaffold the compute outside the compute directory", }, + { + command: "supabase compute new api --template supabase-community/compute-starters/hono", + description: "Bootstrap from a subdirectory of a GitHub repository", + }, + { + command: "supabase compute new api --template https://gitlab.com/acme/api.git#v2", + description: "Bootstrap from any git repository, at a branch, tag or commit", + }, ]), Command.withHandler((flags) => computeNew(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), diff --git a/apps/cli/src/commands/experimental/compute/new/new.handler.ts b/apps/cli/src/commands/experimental/compute/new/new.handler.ts index f67c7c8439..25e50d7eec 100644 --- a/apps/cli/src/commands/experimental/compute/new/new.handler.ts +++ b/apps/cli/src/commands/experimental/compute/new/new.handler.ts @@ -38,7 +38,12 @@ import { type ComputeRuntime, type ComputeSize, } from "../../../../shared/compute/compute-runtimes.ts"; +import { classifyComputeDir } from "../../../../shared/compute/compute-classify.ts"; import { COMPUTE_STACKS } from "../../../../shared/compute/compute-stacks.ts"; +import { + parseComputeTemplate, + stageComputeTemplate, +} from "../../../../shared/compute/compute-template.ts"; import { MissingComputeNameError, ComputeDirectoryExistsError, @@ -60,6 +65,13 @@ import { ComputeNewWorkdirError } from "./new.errors.ts"; * written, so a cancelled prompt leaves nothing behind for this compute at all. * `--instances` is recorded rather than resolved: it has no prompt, and it only * reaches `config.toml` when it differs from the default. + * + * `--template` is cloned into a temporary directory on the same terms, and is then + * the compute's entire contents — the runtime's starter files are what a compute + * with no code yet needs, so a template replaces them rather than layering over + * them. The clone happens before the dials are resolved, because an omitted + * `--runtime` is defaulted from the template's own marker files rather than from + * the catalog. */ /** `values`, with `defaultValue` first, so a prompt pre-selects what it shows first. */ @@ -118,10 +130,20 @@ const resolveName = Effect.fnUntraced(function* (options: { }); }); +/** + * The runtime to scaffold and record. + * + * `inferred` is what a staged `--template`'s own marker files point at, and it + * displaces the catalog default: a template that ships a `Dockerfile` is asking to + * be built from it, and recording `deno` for it would deploy a base image that + * never reads the file. It is a default, not an answer — `--runtime` still wins, + * and an interactive run is still asked, with the inference pre-selected. + */ const resolveRuntime = Effect.fnUntraced(function* (options: { readonly explicit: Option.Option; /** Whether there is a terminal to ask on — see `canPromptFor`. */ readonly canPrompt: boolean; + readonly inferred: ComputeRuntime | undefined; }) { // `--runtime` is a choice flag, so the parser has already rejected anything // outside the catalog by the time it gets here. @@ -129,20 +151,22 @@ const resolveRuntime = Effect.fnUntraced(function* (options: { return options.explicit.value; } + const fallback = options.inferred ?? DEFAULT_COMPUTE_RUNTIME; + if (options.canPrompt) { const output = yield* Output; const selected = yield* output.promptSelect( "Which runtime should this compute use?", - defaultFirst([...COMPUTE_RUNTIMES], DEFAULT_COMPUTE_RUNTIME).map((runtime) => ({ + defaultFirst([...COMPUTE_RUNTIMES], fallback).map((runtime) => ({ value: runtime, label: runtime, hint: COMPUTE_RUNTIME_DESCRIPTIONS[runtime], })), ); - return parseComputeRuntime(selected) ?? DEFAULT_COMPUTE_RUNTIME; + return parseComputeRuntime(selected) ?? fallback; } - return DEFAULT_COMPUTE_RUNTIME; + return fallback; }); const resolveSize = Effect.fnUntraced(function* (options: { @@ -247,6 +271,13 @@ export const computeNew = Effect.fn("compute.new")(function* (flags: ComputeNewF const project = yield* loadComputeProjectForEntryWrite(); + // Parsed before anything is asked: `--template` is a command-line value, so a + // slug or URL this command can't clone is the user's to fix now, not after + // three prompts. + const template = Option.isSome(flags.template) + ? yield* parseComputeTemplate(flags.template.value) + : undefined; + // Decided once, before the first prompt rather than beside the last, since // the name is now asked for too — every prompt below shares the answer. const machineOutput = yield* computeMachineOutputRequested(); @@ -267,16 +298,16 @@ export const computeNew = Effect.fn("compute.new")(function* (flags: ComputeNewF }); } - // Validated before the dials are asked for, not just before the write: nothing + // Validated before the dials are asked for, and before the clone below: nothing // about the destination depends on the runtime, size or exposure, so a run that // is going to be refused for its destination is refused without asking three - // questions first. + // questions or paying for a fetch first. // - // This is the directory the starter files land in, so a value naming the project - // root, `supabase/`, or anywhere outside the project must never reach the write - // below. `--source` resolves against the - // directory the user typed it in, the way a shell would: `--source generated` - // from `apps/web` means `apps/web/generated`. + // This is the directory the template and the starter files land in, so a value + // naming the project root, `supabase/`, or anywhere outside the project must + // never reach the write below. `--source` resolves against the directory the + // user typed it in, the way a shell would: `--source generated` from `apps/web` + // means `apps/web/generated`. const destination = Option.isSome(flags.source) ? yield* resolveComputeSource({ projectRoot: project.projectRoot, @@ -309,9 +340,30 @@ export const computeNew = Effect.fn("compute.new")(function* (flags: ComputeNewF }); } + // Fetched before the dials are resolved, because the runtime default is read + // out of the template's own marker files, and before the destination exists, + // so a template that can't be fetched — a bad ref, no network, no git — + // leaves nothing behind, the same as a cancelled prompt. The refusals above + // come first so a knowably doomed run never pays for a clone. + const staged = + template === undefined + ? undefined + : yield* Effect.gen(function* () { + const fetching = yield* output.task(`Fetching template ${template.display}...`); + const root = yield* stageComputeTemplate(template).pipe( + Effect.tapError(() => fetching.fail()), + ); + yield* fetching.clear(); + return root; + }); + // Resolved before anything is written, so cancelling any prompt leaves nothing // behind. With nowhere to ask, the defaults stand — only the name has no fallback. - const runtime = yield* resolveRuntime({ explicit: flags.runtime, canPrompt }); + const runtime = yield* resolveRuntime({ + explicit: flags.runtime, + canPrompt, + inferred: staged === undefined ? undefined : (yield* classifyComputeDir(staged)).runtime, + }); const size = yield* resolveSize({ explicit: flags.size, canPrompt }); const exposure = yield* resolveExposure({ explicit: flags.exposure, canPrompt }); const instances = recordedInstances(flags.instances); @@ -343,8 +395,17 @@ export const computeNew = Effect.fn("compute.new")(function* (flags: ComputeNewF // can fail for a reason the plan above could have caught. yield* fs.makeDirectory(destination, { recursive: true }); - for (const [filename, contents] of Object.entries(COMPUTE_STACKS[runtime])) { - yield* fs.writeFileString(path.join(destination, filename), contents); + // A template is the whole compute, not an overlay on the runtime's starter + // files. Writing both would leave behind whichever starter the template + // happened not to name — and the catalog runtimes load a fixed entry file, so + // a surviving `index.mjs` is served *instead of* the entry the template + // actually wrote, with nothing reporting it. + if (staged === undefined) { + for (const [filename, contents] of Object.entries(COMPUTE_STACKS[runtime])) { + yield* fs.writeFileString(path.join(destination, filename), contents); + } + } else { + yield* fs.copy(staged, destination, { overwrite: true }); } yield* commitComputeEntry(configWrite); @@ -369,6 +430,7 @@ export const computeNew = Effect.fn("compute.new")(function* (flags: ComputeNewF instances: instances ?? DEFAULT_COMPUTE_INSTANCES, source: sourceDisplay, config_path: project.configPath, + ...(template === undefined ? {} : { template: template.display }), }; // `-o` asks for a machine-readable stdout, so nothing human may be written @@ -391,6 +453,9 @@ export const computeNew = Effect.fn("compute.new")(function* (flags: ComputeNewF yield* output.raw( renderComputeDetails([ ["Runtime", runtime], + ...(template === undefined + ? [] + : [["Template", template.display] satisfies [string, string]]), ["Size", `${size} (${vcpuForSize(size)} vCPU)`], ["Access", exposure], // `declared`, the way `compute status` labels the same number: nothing @@ -402,5 +467,7 @@ export const computeNew = Effect.fn("compute.new")(function* (flags: ComputeNewF // "start your app" line: the shell prints trailers once at the end of the // run, so the next step is the last thing on screen. yield* emitSuccessTrailer(`Deploy it with ${aqua(`supabase compute push ${name}`)}.\n`); - }).pipe(Effect.ensuring(telemetryState.flush)); + // Scoped because `--template` stages its clone in a temporary directory that + // has to outlive the copy into the destination and no longer. + }).pipe(Effect.scoped, Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/commands/experimental/compute/new/new.integration.test.ts b/apps/cli/src/commands/experimental/compute/new/new.integration.test.ts index ae1ba17456..2400888fdc 100644 --- a/apps/cli/src/commands/experimental/compute/new/new.integration.test.ts +++ b/apps/cli/src/commands/experimental/compute/new/new.integration.test.ts @@ -2,10 +2,15 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Option, FileSystem, Path, Predicate, Schema } from "effect"; import { makeComputeProject, setupCompute } from "../../../../../tests/helpers/compute.ts"; +import { makeGitRepo } from "../../../../../tests/helpers/git-repo.ts"; import { ComputeAlreadyConfiguredError, ComputeConfigWriteUnsafeError, } from "../../../../shared/compute/compute-config.ts"; +import { + ComputeTemplateFetchError, + InvalidComputeTemplateError, +} from "../../../../shared/compute/compute-template.ts"; import { InvalidComputeNameError, InvalidComputeSourceError, @@ -32,6 +37,7 @@ function flags(overrides: Partial = {}): ComputeNewFlags { exposure: Option.none(), instances: Option.none(), source: Option.none(), + template: Option.none(), ...overrides, }; } @@ -916,6 +922,7 @@ describe("compute new", () => { exposure: Option.none(), instances: Option.none(), source: Option.none(), + template: Option.none(), }); expect(yield* repo.config).toContain(`runtime = "deno"`); @@ -978,4 +985,261 @@ describe("compute new", () => { }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); + + describe("--template", () => { + it.live("bootstraps the directory from a git repository, replacing the runtime's files", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const repo = yield* project(); + const template = yield* makeGitRepo({ + "index.mjs": "export default { fetch: () => new Response('from the template') };\n", + "lib/db.mjs": "export const query = () => [];\n", + "README.md": "# api\n", + }); + const { layer, out } = setupCompute({ workdir: repo.dir }); + + return yield* Effect.gen(function* () { + yield* computeNew( + flags({ runtime: Option.some("node"), template: Option.some(template) }), + ); + + const computeDir = path.join(repo.dir, "supabase", "compute", "api"); + expect(yield* fs.readFileString(path.join(computeDir, "index.mjs"))).toContain( + "from the template", + ); + expect(yield* fs.exists(path.join(computeDir, "lib", "db.mjs"))).toBe(true); + expect(yield* fs.exists(path.join(computeDir, "README.md"))).toBe(true); + // A template is a starting point, not a checkout. + expect(yield* fs.exists(path.join(computeDir, ".git"))).toBe(false); + + expect(yield* repo.config).toContain('runtime = "node"'); + expect(out.stdoutText).toContain("Template"); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + // A starter the template never names would otherwise survive, and `main.ts` is + // the entry the deno catalog runtime loads — so the compute would serve the + // greeting scaffold instead of the template's own code. + it.live("writes none of the runtime's starter files alongside a template", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const repo = yield* project(); + const template = yield* makeGitRepo({ + "deno.json": '{ "imports": {} }\n', + "src/app.ts": "export default {};\n", + }); + const { layer } = setupCompute({ workdir: repo.dir, format: "json" }); + + return yield* Effect.gen(function* () { + yield* computeNew( + flags({ runtime: Option.some("deno"), template: Option.some(template) }), + ); + + const computeDir = path.join(repo.dir, "supabase", "compute", "api"); + expect(yield* fs.readDirectory(computeDir)).toEqual( + expect.arrayContaining(["deno.json", "src"]), + ); + expect(yield* fs.exists(path.join(computeDir, "main.ts"))).toBe(false); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + // The same hazard one runtime over: a node template naming its entry anything + // other than index.mjs used to be shadowed by the starter's index.mjs. + it.live("leaves no starter entry file to shadow a node template's own", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const repo = yield* project(); + const template = yield* makeGitRepo({ + "package.json": '{ "type": "module", "main": "src/server.js" }\n', + "src/server.js": "export default {};\n", + }); + const { layer } = setupCompute({ workdir: repo.dir, format: "json" }); + + return yield* Effect.gen(function* () { + yield* computeNew(flags({ template: Option.some(template) })); + + const computeDir = path.join(repo.dir, "supabase", "compute", "api"); + expect(yield* repo.config).toContain('runtime = "node"'); + expect(yield* fs.exists(path.join(computeDir, "src", "server.js"))).toBe(true); + expect(yield* fs.exists(path.join(computeDir, "index.mjs"))).toBe(false); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + // Recording the catalog default here would deploy a base image that never + // reads the Dockerfile the template shipped. + it.live("defaults the runtime to what the template's marker files point at", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const repo = yield* project(); + const template = yield* makeGitRepo({ + Dockerfile: "FROM node:22-slim\n", + "server.js": 'console.log("hi");\n', + }); + const { layer } = setupCompute({ workdir: repo.dir, format: "json" }); + + return yield* Effect.gen(function* () { + yield* computeNew(flags({ template: Option.some(template) })); + + expect(yield* repo.config).toContain('runtime = "dockerfile"'); + // The deno starter never lands: it was never the resolved runtime. + expect( + yield* fs.exists(path.join(repo.dir, "supabase", "compute", "api", "main.ts")), + ).toBe(false); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("pre-selects the inferred runtime at the prompt", () => + Effect.gen(function* () { + const repo = yield* project(); + const template = yield* makeGitRepo({ "package.json": "{}\n" }); + const { layer, out } = setupCompute({ + workdir: repo.dir, + // The mock picks the first option, which is what a pre-selected default is. + promptSelectResponses: [], + }); + + return yield* Effect.gen(function* () { + yield* computeNew(flags({ template: Option.some(template) })); + + expect(out.promptSelectCalls[0]?.options[0]).toMatchObject({ value: "node" }); + expect(yield* repo.config).toContain('runtime = "node"'); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("lets --runtime override what the template looks like", () => + Effect.gen(function* () { + const repo = yield* project(); + const template = yield* makeGitRepo({ Dockerfile: "FROM node:22-slim\n" }); + const { layer } = setupCompute({ workdir: repo.dir, format: "json" }); + + return yield* Effect.gen(function* () { + yield* computeNew( + flags({ runtime: Option.some("node"), template: Option.some(template) }), + ); + + expect(yield* repo.config).toContain('runtime = "node"'); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + // A knowably doomed run should not pay for a clone. + it.live("refuses an occupied destination before fetching anything", () => + Effect.gen(function* () { + const repo = yield* project({ "supabase/compute/api/leftover.txt": "old" }); + const { layer, out } = setupCompute({ workdir: repo.dir, format: "json" }); + + return yield* Effect.gen(function* () { + const error = yield* computeNew( + flags({ template: Option.some("owner/does-not-exist-at-all") }), + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ComputeDirectoryExistsError); + expect(out.progressEvents).toEqual([]); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + // The flag says where the code comes from; the runtime still says how the + // platform builds and runs it. + it.live("leaves the runtime, size and exposure the command resolved alone", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const repo = yield* project(); + const template = yield* makeGitRepo({ Dockerfile: "FROM scratch\n" }); + const { layer } = setupCompute({ workdir: repo.dir, format: "json" }); + + return yield* Effect.gen(function* () { + yield* computeNew( + flags({ + runtime: Option.some("dockerfile"), + size: Option.some("4gb"), + exposure: Option.some("private"), + template: Option.some(template), + }), + ); + + expect( + yield* fs.readFileString( + path.join(repo.dir, "supabase", "compute", "api", "Dockerfile"), + ), + ).toBe("FROM scratch\n"); + expect(yield* repo.config).toContain('runtime = "dockerfile"'); + expect(yield* repo.config).toContain('size = "4gb"'); + expect(yield* repo.config).toContain('exposure = "private"'); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("scaffolds a template into a --source directory", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const repo = yield* project(); + const template = yield* makeGitRepo({ "index.ts": "export default {};\n" }); + const { layer } = setupCompute({ workdir: repo.dir, format: "json" }); + + return yield* Effect.gen(function* () { + yield* computeNew( + flags({ source: Option.some("packages/api"), template: Option.some(template) }), + ); + + expect(yield* fs.exists(path.join(repo.dir, "packages", "api", "index.ts"))).toBe(true); + expect(yield* repo.config).toContain('source = "packages/api"'); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("refuses an unusable --template before asking anything", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const repo = yield* project(); + const { layer, out } = setupCompute({ + workdir: repo.dir, + promptSelectResponses: ["node", "2gb"], + }); + + return yield* Effect.gen(function* () { + const error = yield* computeNew(flags({ template: Option.some("not-a-repo") })).pipe( + Effect.flip, + ); + + expect(error).toBeInstanceOf(InvalidComputeTemplateError); + expect(out.promptSelectCalls).toEqual([]); + expect(yield* fs.exists(path.join(repo.dir, "supabase", "compute"))).toBe(false); + expect(yield* repo.config).toBe(CONFIG_WITH_COMMENTS); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("leaves nothing behind when the fetch fails", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const repo = yield* project(); + const template = yield* makeGitRepo({ "index.mjs": "export default {};\n" }); + const { layer } = setupCompute({ workdir: repo.dir, format: "json" }); + + return yield* Effect.gen(function* () { + const error = yield* computeNew( + flags({ template: Option.some(`${template}#no-such-ref`) }), + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ComputeTemplateFetchError); + expect(yield* fs.exists(path.join(repo.dir, "supabase", "compute"))).toBe(false); + expect(yield* repo.config).toBe(CONFIG_WITH_COMMENTS); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + }); }); From 7921cb529fa0f4827d7735dfba63db0159829402 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Fri, 11 Sep 2026 01:49:33 -0300 Subject: [PATCH 5/5] feat(cli): add compute new --template Wires the git template fetcher into the scaffold: --template makes a repository the compute's entire contents. No starter files are written alongside it -- the starters are what a compute with no code yet needs, and writing both would leave behind whichever the template happened not to name. That is not merely untidy, because the catalog runtimes load a fixed entry file: a node template whose entry is src/server.js would be shadowed by the starter's index.mjs and never run. An omitted --runtime is defaulted from the template's own marker files, by the same classifier push uses on an unconfigured directory, so a template shipping a Dockerfile is not recorded as deno and then deployed as a base image that never reads it. It stays a default: --runtime wins, and an interactive run is still asked with the inference pre-selected. Reading that default means cloning before the dials resolve, which is why the refusals that do not depend on them run first -- the clone is wasted work for a run that was never going to scaffold anything. It stages into a temporary directory either way, so a bad ref, an absent repository or a missing git leaves the destination untouched and config.toml unwritten, the same way a cancelled prompt does. --- apps/cli/docs/compute-commands.md | 7 + .../experimental/compute/new/SIDE_EFFECTS.md | 55 +++- .../experimental/compute/new/new.command.ts | 16 +- .../experimental/compute/new/new.handler.ts | 95 ++++++- .../compute/new/new.integration.test.ts | 264 ++++++++++++++++++ .../src/shared/compute/compute-template.ts | 2 +- 6 files changed, 421 insertions(+), 18 deletions(-) diff --git a/apps/cli/docs/compute-commands.md b/apps/cli/docs/compute-commands.md index f69581da9b..40b261cad8 100644 --- a/apps/cli/docs/compute-commands.md +++ b/apps/cli/docs/compute-commands.md @@ -40,6 +40,13 @@ When enabled, the CLI exposes: Compute source directories live under `supabase/compute//`. +`compute new --template` bootstraps the source directory from a git repository +instead of the runtime's starter files — a GitHub `owner/repo` slug (optionally +with a subdirectory and a `#ref`), or any repository URL `git` can clone. The +repository becomes the compute's entire contents; no starter files are written +alongside it. When `--runtime` is omitted, the template's own marker files pick +the runtime. + `compute new` edits TOML configuration. It refuses projects whose authoritative configuration is JSON before prompting or writing, so it cannot save deployment settings into an ignored file. To deploy a source directory without a Compute diff --git a/apps/cli/src/commands/experimental/compute/new/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/new/SIDE_EFFECTS.md index e02be3f41e..63a233c96b 100644 --- a/apps/cli/src/commands/experimental/compute/new/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/new/SIDE_EFFECTS.md @@ -21,6 +21,7 @@ and the command handler does not run. See the [Compute command guide](../../../. | `/` | dir | always, to refuse a destination that is not empty | | `/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | | `` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | +| `/supabase-compute-template-*/` | varies | when `--template` is given — the clone is read back to locate the template tree and copy out of it | ## Files Written @@ -29,6 +30,7 @@ and the command handler does not run. See the [Compute command guide](../../../. | `/supabase/config.toml` | TOML | on success — appends `[compute.]` with `runtime`, `size` and `exposure` always, `instances` only when it differs from the default of 1, and `source` only when `--source` was passed, preserving surrounding formatting | | `/supabase/compute//*` | varies | on success, unless `--source` names another directory | | `//*` | varies | on success, when `--source` is given | +| `/supabase-compute-template-*/` | varies | when `--template` is given — a depth-1 clone, removed when the command ends | | `/telemetry.json` | JSON | whenever the handler runs — flushed on success and on failure | Compute resources are recorded in `config.toml` only. The project config loader prefers @@ -70,11 +72,57 @@ Writes to `config.toml` are append-only. A compute already recorded under and before anything reaches disk — because editing an entry the user owns is not this command's job. +`--template` bootstraps the destination from a git repository instead of the +runtime's starter files. It accepts a GitHub `/` slug, with +optional trailing subdirectory and `#`; a github.com URL, including the +`/tree//` form a browser produces; or any other repository URL +`git` can clone, with an optional `#`. A subdirectory is only read out of a +GitHub slug or URL, where the repository boundary is part of the syntax; every +other URL is cloned in full. A value naming a ref twice, starting with a hyphen, +or resolving to neither a slug nor a cloneable URL is refused before any prompt +runs. + +Fetching is `git clone --depth 1` into a temporary directory, or +`git init` + `git fetch --depth 1` + `git checkout FETCH_HEAD` when a ref is +given — fetching the ref by name is what makes a commit SHA work as well as a +branch or tag. `GIT_TERMINAL_PROMPT=0` is set, so a private template must come +from a credential helper or an SSH key rather than an interactive password +prompt. The clone's `.git` is removed before anything is copied, so the +template's history never becomes the compute directory's own. The clone happens +before the destination is created, so a template that cannot be fetched, a ref +that does not exist, a subdirectory the repository does not have, or a template +tree with no files in it leaves nothing on disk and no `config.toml` entry. + +A template is the compute's entire contents: when one is given, none of the +runtime's starter files are written, and the destination holds exactly what the +template's tree holds. The starters are what a compute with no code yet needs, and +writing both would leave behind whichever of them the template happened not to +name — which is not merely untidy, because the catalog runtimes load a fixed entry +file. A node template whose entry is `src/server.js` would sit next to the +starter's `index.mjs`, and `index.mjs` is what the runtime loads, so the deployed +compute would serve the greeting scaffold instead of the template's code, with +nothing reporting it. Nothing about the template itself is recorded in +`config.toml`. + +When `--runtime` is omitted, the runtime _default_ is read out of the staged +template's own marker files — `Dockerfile`, then `deno.json`/`deno.jsonc`/ +`deno.lock`, then `package.json`, falling back to `deno` — the same markers +`push` classifies an unconfigured directory by. It is a default, not an answer: +`--runtime` still wins, and an interactive run is still asked, with the inference +pre-selected. Recording the catalog default instead would write `runtime = "deno"` +for a template that ships a `Dockerfile`, and `push` would then deploy a base +image that never reads it. + +Because the runtime default depends on the template, the clone happens before the +runtime, size and exposure are resolved — after every refusal that does not depend +on them, as above. Cancelling a dial prompt after the clone still writes nothing. + Nothing at the destination is ever removed or overwritten: a destination that exists and is not empty is refused, and clearing it is left to the user. That refusal, and a bad `--source`, are both checked before the runtime, size and -exposure are asked for — none of them depend on the destination, so a run that is -going to be refused for it is refused without asking three questions first. +exposure are asked for and before any `--template` is cloned — none of them depend +on the destination, so a run that is going to be refused for it is refused without +asking three questions or paying for a fetch first. `--source` is refused when it resolves to the project root, `supabase/`, `supabase/functions/`, `supabase/migrations/`, or outside the project. Symlinks are resolved first, so a path inside the project that points outside it is @@ -99,6 +147,9 @@ root. | `1` | no name given, and nowhere to ask for one — stdin or stdout is not a terminal, or `-o` is in force | | `1` | bad `--source`: outside the project, or a path the CLI owns | | `1` | destination exists and is not empty | +| `1` | `--template` is neither a GitHub slug nor a URL git can clone (`InvalidComputeTemplateError`) — refused before any prompt | +| `1` | the template could not be cloned: no `git`, no such repository, or no such ref (`ComputeTemplateFetchError`) | +| `1` | the clone does not hold what `--template` named, or holds no files (`ComputeTemplateContentError`) | | `1` | the compute is already recorded in `config.toml`, in any form | | `1` | the rendered `config.toml` would not parse, or `[compute]` is a sealed inline table | diff --git a/apps/cli/src/commands/experimental/compute/new/new.command.ts b/apps/cli/src/commands/experimental/compute/new/new.command.ts index 167d15055a..ff510382c9 100644 --- a/apps/cli/src/commands/experimental/compute/new/new.command.ts +++ b/apps/cli/src/commands/experimental/compute/new/new.command.ts @@ -23,7 +23,7 @@ const config = { ), runtime: Flag.choice("runtime", COMPUTE_RUNTIMES).pipe( Flag.withDescription( - "Runtime to scaffold and record in supabase/config.toml. Prompted when omitted.", + "Runtime to scaffold and record in supabase/config.toml. Prompted when omitted, defaulting to what --template looks like when one is given.", ), Flag.optional, ), @@ -57,6 +57,12 @@ const config = { ), Flag.optional, ), + template: Flag.string("template").pipe( + Flag.withDescription( + "Bootstrap the compute from a git repository instead of the runtime's starter files: a GitHub owner/repo slug, optionally with a subdirectory and a #ref, or any repository URL git can clone. The repository becomes the compute's entire contents in place of those starter files, and its marker files pick the runtime when --runtime is omitted.", + ), + Flag.optional, + ), } as const; export type ComputeNewFlags = CliCommand.Command.Config.Infer; @@ -100,6 +106,14 @@ export const computeNewCommand = Command.make("new", config).pipe( command: "supabase compute new api --source packages/api", description: "Scaffold the compute outside the compute directory", }, + { + command: "supabase compute new api --template my-org/my-templates/compute/api", + description: "Bootstrap from a subdirectory of a GitHub repository", + }, + { + command: "supabase compute new api --template https://gitlab.com/my-org/api.git#v2", + description: "Bootstrap from any git repository, at a branch, tag or commit", + }, ]), Command.withHandler((flags) => computeNew(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), diff --git a/apps/cli/src/commands/experimental/compute/new/new.handler.ts b/apps/cli/src/commands/experimental/compute/new/new.handler.ts index f67c7c8439..25e50d7eec 100644 --- a/apps/cli/src/commands/experimental/compute/new/new.handler.ts +++ b/apps/cli/src/commands/experimental/compute/new/new.handler.ts @@ -38,7 +38,12 @@ import { type ComputeRuntime, type ComputeSize, } from "../../../../shared/compute/compute-runtimes.ts"; +import { classifyComputeDir } from "../../../../shared/compute/compute-classify.ts"; import { COMPUTE_STACKS } from "../../../../shared/compute/compute-stacks.ts"; +import { + parseComputeTemplate, + stageComputeTemplate, +} from "../../../../shared/compute/compute-template.ts"; import { MissingComputeNameError, ComputeDirectoryExistsError, @@ -60,6 +65,13 @@ import { ComputeNewWorkdirError } from "./new.errors.ts"; * written, so a cancelled prompt leaves nothing behind for this compute at all. * `--instances` is recorded rather than resolved: it has no prompt, and it only * reaches `config.toml` when it differs from the default. + * + * `--template` is cloned into a temporary directory on the same terms, and is then + * the compute's entire contents — the runtime's starter files are what a compute + * with no code yet needs, so a template replaces them rather than layering over + * them. The clone happens before the dials are resolved, because an omitted + * `--runtime` is defaulted from the template's own marker files rather than from + * the catalog. */ /** `values`, with `defaultValue` first, so a prompt pre-selects what it shows first. */ @@ -118,10 +130,20 @@ const resolveName = Effect.fnUntraced(function* (options: { }); }); +/** + * The runtime to scaffold and record. + * + * `inferred` is what a staged `--template`'s own marker files point at, and it + * displaces the catalog default: a template that ships a `Dockerfile` is asking to + * be built from it, and recording `deno` for it would deploy a base image that + * never reads the file. It is a default, not an answer — `--runtime` still wins, + * and an interactive run is still asked, with the inference pre-selected. + */ const resolveRuntime = Effect.fnUntraced(function* (options: { readonly explicit: Option.Option; /** Whether there is a terminal to ask on — see `canPromptFor`. */ readonly canPrompt: boolean; + readonly inferred: ComputeRuntime | undefined; }) { // `--runtime` is a choice flag, so the parser has already rejected anything // outside the catalog by the time it gets here. @@ -129,20 +151,22 @@ const resolveRuntime = Effect.fnUntraced(function* (options: { return options.explicit.value; } + const fallback = options.inferred ?? DEFAULT_COMPUTE_RUNTIME; + if (options.canPrompt) { const output = yield* Output; const selected = yield* output.promptSelect( "Which runtime should this compute use?", - defaultFirst([...COMPUTE_RUNTIMES], DEFAULT_COMPUTE_RUNTIME).map((runtime) => ({ + defaultFirst([...COMPUTE_RUNTIMES], fallback).map((runtime) => ({ value: runtime, label: runtime, hint: COMPUTE_RUNTIME_DESCRIPTIONS[runtime], })), ); - return parseComputeRuntime(selected) ?? DEFAULT_COMPUTE_RUNTIME; + return parseComputeRuntime(selected) ?? fallback; } - return DEFAULT_COMPUTE_RUNTIME; + return fallback; }); const resolveSize = Effect.fnUntraced(function* (options: { @@ -247,6 +271,13 @@ export const computeNew = Effect.fn("compute.new")(function* (flags: ComputeNewF const project = yield* loadComputeProjectForEntryWrite(); + // Parsed before anything is asked: `--template` is a command-line value, so a + // slug or URL this command can't clone is the user's to fix now, not after + // three prompts. + const template = Option.isSome(flags.template) + ? yield* parseComputeTemplate(flags.template.value) + : undefined; + // Decided once, before the first prompt rather than beside the last, since // the name is now asked for too — every prompt below shares the answer. const machineOutput = yield* computeMachineOutputRequested(); @@ -267,16 +298,16 @@ export const computeNew = Effect.fn("compute.new")(function* (flags: ComputeNewF }); } - // Validated before the dials are asked for, not just before the write: nothing + // Validated before the dials are asked for, and before the clone below: nothing // about the destination depends on the runtime, size or exposure, so a run that // is going to be refused for its destination is refused without asking three - // questions first. + // questions or paying for a fetch first. // - // This is the directory the starter files land in, so a value naming the project - // root, `supabase/`, or anywhere outside the project must never reach the write - // below. `--source` resolves against the - // directory the user typed it in, the way a shell would: `--source generated` - // from `apps/web` means `apps/web/generated`. + // This is the directory the template and the starter files land in, so a value + // naming the project root, `supabase/`, or anywhere outside the project must + // never reach the write below. `--source` resolves against the directory the + // user typed it in, the way a shell would: `--source generated` from `apps/web` + // means `apps/web/generated`. const destination = Option.isSome(flags.source) ? yield* resolveComputeSource({ projectRoot: project.projectRoot, @@ -309,9 +340,30 @@ export const computeNew = Effect.fn("compute.new")(function* (flags: ComputeNewF }); } + // Fetched before the dials are resolved, because the runtime default is read + // out of the template's own marker files, and before the destination exists, + // so a template that can't be fetched — a bad ref, no network, no git — + // leaves nothing behind, the same as a cancelled prompt. The refusals above + // come first so a knowably doomed run never pays for a clone. + const staged = + template === undefined + ? undefined + : yield* Effect.gen(function* () { + const fetching = yield* output.task(`Fetching template ${template.display}...`); + const root = yield* stageComputeTemplate(template).pipe( + Effect.tapError(() => fetching.fail()), + ); + yield* fetching.clear(); + return root; + }); + // Resolved before anything is written, so cancelling any prompt leaves nothing // behind. With nowhere to ask, the defaults stand — only the name has no fallback. - const runtime = yield* resolveRuntime({ explicit: flags.runtime, canPrompt }); + const runtime = yield* resolveRuntime({ + explicit: flags.runtime, + canPrompt, + inferred: staged === undefined ? undefined : (yield* classifyComputeDir(staged)).runtime, + }); const size = yield* resolveSize({ explicit: flags.size, canPrompt }); const exposure = yield* resolveExposure({ explicit: flags.exposure, canPrompt }); const instances = recordedInstances(flags.instances); @@ -343,8 +395,17 @@ export const computeNew = Effect.fn("compute.new")(function* (flags: ComputeNewF // can fail for a reason the plan above could have caught. yield* fs.makeDirectory(destination, { recursive: true }); - for (const [filename, contents] of Object.entries(COMPUTE_STACKS[runtime])) { - yield* fs.writeFileString(path.join(destination, filename), contents); + // A template is the whole compute, not an overlay on the runtime's starter + // files. Writing both would leave behind whichever starter the template + // happened not to name — and the catalog runtimes load a fixed entry file, so + // a surviving `index.mjs` is served *instead of* the entry the template + // actually wrote, with nothing reporting it. + if (staged === undefined) { + for (const [filename, contents] of Object.entries(COMPUTE_STACKS[runtime])) { + yield* fs.writeFileString(path.join(destination, filename), contents); + } + } else { + yield* fs.copy(staged, destination, { overwrite: true }); } yield* commitComputeEntry(configWrite); @@ -369,6 +430,7 @@ export const computeNew = Effect.fn("compute.new")(function* (flags: ComputeNewF instances: instances ?? DEFAULT_COMPUTE_INSTANCES, source: sourceDisplay, config_path: project.configPath, + ...(template === undefined ? {} : { template: template.display }), }; // `-o` asks for a machine-readable stdout, so nothing human may be written @@ -391,6 +453,9 @@ export const computeNew = Effect.fn("compute.new")(function* (flags: ComputeNewF yield* output.raw( renderComputeDetails([ ["Runtime", runtime], + ...(template === undefined + ? [] + : [["Template", template.display] satisfies [string, string]]), ["Size", `${size} (${vcpuForSize(size)} vCPU)`], ["Access", exposure], // `declared`, the way `compute status` labels the same number: nothing @@ -402,5 +467,7 @@ export const computeNew = Effect.fn("compute.new")(function* (flags: ComputeNewF // "start your app" line: the shell prints trailers once at the end of the // run, so the next step is the last thing on screen. yield* emitSuccessTrailer(`Deploy it with ${aqua(`supabase compute push ${name}`)}.\n`); - }).pipe(Effect.ensuring(telemetryState.flush)); + // Scoped because `--template` stages its clone in a temporary directory that + // has to outlive the copy into the destination and no longer. + }).pipe(Effect.scoped, Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/commands/experimental/compute/new/new.integration.test.ts b/apps/cli/src/commands/experimental/compute/new/new.integration.test.ts index ae1ba17456..2400888fdc 100644 --- a/apps/cli/src/commands/experimental/compute/new/new.integration.test.ts +++ b/apps/cli/src/commands/experimental/compute/new/new.integration.test.ts @@ -2,10 +2,15 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Option, FileSystem, Path, Predicate, Schema } from "effect"; import { makeComputeProject, setupCompute } from "../../../../../tests/helpers/compute.ts"; +import { makeGitRepo } from "../../../../../tests/helpers/git-repo.ts"; import { ComputeAlreadyConfiguredError, ComputeConfigWriteUnsafeError, } from "../../../../shared/compute/compute-config.ts"; +import { + ComputeTemplateFetchError, + InvalidComputeTemplateError, +} from "../../../../shared/compute/compute-template.ts"; import { InvalidComputeNameError, InvalidComputeSourceError, @@ -32,6 +37,7 @@ function flags(overrides: Partial = {}): ComputeNewFlags { exposure: Option.none(), instances: Option.none(), source: Option.none(), + template: Option.none(), ...overrides, }; } @@ -916,6 +922,7 @@ describe("compute new", () => { exposure: Option.none(), instances: Option.none(), source: Option.none(), + template: Option.none(), }); expect(yield* repo.config).toContain(`runtime = "deno"`); @@ -978,4 +985,261 @@ describe("compute new", () => { }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); + + describe("--template", () => { + it.live("bootstraps the directory from a git repository, replacing the runtime's files", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const repo = yield* project(); + const template = yield* makeGitRepo({ + "index.mjs": "export default { fetch: () => new Response('from the template') };\n", + "lib/db.mjs": "export const query = () => [];\n", + "README.md": "# api\n", + }); + const { layer, out } = setupCompute({ workdir: repo.dir }); + + return yield* Effect.gen(function* () { + yield* computeNew( + flags({ runtime: Option.some("node"), template: Option.some(template) }), + ); + + const computeDir = path.join(repo.dir, "supabase", "compute", "api"); + expect(yield* fs.readFileString(path.join(computeDir, "index.mjs"))).toContain( + "from the template", + ); + expect(yield* fs.exists(path.join(computeDir, "lib", "db.mjs"))).toBe(true); + expect(yield* fs.exists(path.join(computeDir, "README.md"))).toBe(true); + // A template is a starting point, not a checkout. + expect(yield* fs.exists(path.join(computeDir, ".git"))).toBe(false); + + expect(yield* repo.config).toContain('runtime = "node"'); + expect(out.stdoutText).toContain("Template"); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + // A starter the template never names would otherwise survive, and `main.ts` is + // the entry the deno catalog runtime loads — so the compute would serve the + // greeting scaffold instead of the template's own code. + it.live("writes none of the runtime's starter files alongside a template", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const repo = yield* project(); + const template = yield* makeGitRepo({ + "deno.json": '{ "imports": {} }\n', + "src/app.ts": "export default {};\n", + }); + const { layer } = setupCompute({ workdir: repo.dir, format: "json" }); + + return yield* Effect.gen(function* () { + yield* computeNew( + flags({ runtime: Option.some("deno"), template: Option.some(template) }), + ); + + const computeDir = path.join(repo.dir, "supabase", "compute", "api"); + expect(yield* fs.readDirectory(computeDir)).toEqual( + expect.arrayContaining(["deno.json", "src"]), + ); + expect(yield* fs.exists(path.join(computeDir, "main.ts"))).toBe(false); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + // The same hazard one runtime over: a node template naming its entry anything + // other than index.mjs used to be shadowed by the starter's index.mjs. + it.live("leaves no starter entry file to shadow a node template's own", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const repo = yield* project(); + const template = yield* makeGitRepo({ + "package.json": '{ "type": "module", "main": "src/server.js" }\n', + "src/server.js": "export default {};\n", + }); + const { layer } = setupCompute({ workdir: repo.dir, format: "json" }); + + return yield* Effect.gen(function* () { + yield* computeNew(flags({ template: Option.some(template) })); + + const computeDir = path.join(repo.dir, "supabase", "compute", "api"); + expect(yield* repo.config).toContain('runtime = "node"'); + expect(yield* fs.exists(path.join(computeDir, "src", "server.js"))).toBe(true); + expect(yield* fs.exists(path.join(computeDir, "index.mjs"))).toBe(false); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + // Recording the catalog default here would deploy a base image that never + // reads the Dockerfile the template shipped. + it.live("defaults the runtime to what the template's marker files point at", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const repo = yield* project(); + const template = yield* makeGitRepo({ + Dockerfile: "FROM node:22-slim\n", + "server.js": 'console.log("hi");\n', + }); + const { layer } = setupCompute({ workdir: repo.dir, format: "json" }); + + return yield* Effect.gen(function* () { + yield* computeNew(flags({ template: Option.some(template) })); + + expect(yield* repo.config).toContain('runtime = "dockerfile"'); + // The deno starter never lands: it was never the resolved runtime. + expect( + yield* fs.exists(path.join(repo.dir, "supabase", "compute", "api", "main.ts")), + ).toBe(false); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("pre-selects the inferred runtime at the prompt", () => + Effect.gen(function* () { + const repo = yield* project(); + const template = yield* makeGitRepo({ "package.json": "{}\n" }); + const { layer, out } = setupCompute({ + workdir: repo.dir, + // The mock picks the first option, which is what a pre-selected default is. + promptSelectResponses: [], + }); + + return yield* Effect.gen(function* () { + yield* computeNew(flags({ template: Option.some(template) })); + + expect(out.promptSelectCalls[0]?.options[0]).toMatchObject({ value: "node" }); + expect(yield* repo.config).toContain('runtime = "node"'); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("lets --runtime override what the template looks like", () => + Effect.gen(function* () { + const repo = yield* project(); + const template = yield* makeGitRepo({ Dockerfile: "FROM node:22-slim\n" }); + const { layer } = setupCompute({ workdir: repo.dir, format: "json" }); + + return yield* Effect.gen(function* () { + yield* computeNew( + flags({ runtime: Option.some("node"), template: Option.some(template) }), + ); + + expect(yield* repo.config).toContain('runtime = "node"'); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + // A knowably doomed run should not pay for a clone. + it.live("refuses an occupied destination before fetching anything", () => + Effect.gen(function* () { + const repo = yield* project({ "supabase/compute/api/leftover.txt": "old" }); + const { layer, out } = setupCompute({ workdir: repo.dir, format: "json" }); + + return yield* Effect.gen(function* () { + const error = yield* computeNew( + flags({ template: Option.some("owner/does-not-exist-at-all") }), + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ComputeDirectoryExistsError); + expect(out.progressEvents).toEqual([]); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + // The flag says where the code comes from; the runtime still says how the + // platform builds and runs it. + it.live("leaves the runtime, size and exposure the command resolved alone", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const repo = yield* project(); + const template = yield* makeGitRepo({ Dockerfile: "FROM scratch\n" }); + const { layer } = setupCompute({ workdir: repo.dir, format: "json" }); + + return yield* Effect.gen(function* () { + yield* computeNew( + flags({ + runtime: Option.some("dockerfile"), + size: Option.some("4gb"), + exposure: Option.some("private"), + template: Option.some(template), + }), + ); + + expect( + yield* fs.readFileString( + path.join(repo.dir, "supabase", "compute", "api", "Dockerfile"), + ), + ).toBe("FROM scratch\n"); + expect(yield* repo.config).toContain('runtime = "dockerfile"'); + expect(yield* repo.config).toContain('size = "4gb"'); + expect(yield* repo.config).toContain('exposure = "private"'); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("scaffolds a template into a --source directory", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const repo = yield* project(); + const template = yield* makeGitRepo({ "index.ts": "export default {};\n" }); + const { layer } = setupCompute({ workdir: repo.dir, format: "json" }); + + return yield* Effect.gen(function* () { + yield* computeNew( + flags({ source: Option.some("packages/api"), template: Option.some(template) }), + ); + + expect(yield* fs.exists(path.join(repo.dir, "packages", "api", "index.ts"))).toBe(true); + expect(yield* repo.config).toContain('source = "packages/api"'); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("refuses an unusable --template before asking anything", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const repo = yield* project(); + const { layer, out } = setupCompute({ + workdir: repo.dir, + promptSelectResponses: ["node", "2gb"], + }); + + return yield* Effect.gen(function* () { + const error = yield* computeNew(flags({ template: Option.some("not-a-repo") })).pipe( + Effect.flip, + ); + + expect(error).toBeInstanceOf(InvalidComputeTemplateError); + expect(out.promptSelectCalls).toEqual([]); + expect(yield* fs.exists(path.join(repo.dir, "supabase", "compute"))).toBe(false); + expect(yield* repo.config).toBe(CONFIG_WITH_COMMENTS); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("leaves nothing behind when the fetch fails", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const repo = yield* project(); + const template = yield* makeGitRepo({ "index.mjs": "export default {};\n" }); + const { layer } = setupCompute({ workdir: repo.dir, format: "json" }); + + return yield* Effect.gen(function* () { + const error = yield* computeNew( + flags({ template: Option.some(`${template}#no-such-ref`) }), + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ComputeTemplateFetchError); + expect(yield* fs.exists(path.join(repo.dir, "supabase", "compute"))).toBe(false); + expect(yield* repo.config).toBe(CONFIG_WITH_COMMENTS); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + }); }); diff --git a/apps/cli/src/shared/compute/compute-template.ts b/apps/cli/src/shared/compute/compute-template.ts index 2d964ee65e..502e264375 100644 --- a/apps/cli/src/shared/compute/compute-template.ts +++ b/apps/cli/src/shared/compute/compute-template.ts @@ -83,7 +83,7 @@ const CLONEABLE_URL = /^(?:[a-z][a-z0-9+.-]*:\/\/|[^\s/\\:@]+@[^\s/\\:]+:|\/|[A- const TEMPLATE_SUGGESTION = "Pass --template as a GitHub owner/repo slug, optionally with a subdirectory and a #ref " + - "(supabase/templates/compute/api#main), or as any repository URL git can clone."; + "(my-org/my-templates/compute/api#main), or as any repository URL git can clone."; const FETCH_SUGGESTION = "Check that git is installed, that the repository and ref exist, and that you can " +