From 276b5bbf8151a5256b8bafb8d898a02f7468f719 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:58:55 +0530 Subject: [PATCH 1/3] feat --- apps/cli/docs/go-cli-porting-status.md | 2 +- .../commands/functions/new/SIDE_EFFECTS.md | 67 ++-- .../commands/functions/new/new.command.ts | 25 +- .../commands/functions/new/new.errors.ts | 21 ++ .../commands/functions/new/new.handler.ts | 295 +++++++++++++++++- .../functions/new/new.integration.test.ts | 285 +++++++++++++++++ .../commands/functions/new/new.templates.ts | 166 ++++++++++ apps/cli/src/shared/init/project-init.ts | 30 +- 8 files changed, 851 insertions(+), 40 deletions(-) create mode 100644 apps/cli/src/legacy/commands/functions/new/new.errors.ts create mode 100644 apps/cli/src/legacy/commands/functions/new/new.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/functions/new/new.templates.ts diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 49a7fe9c95..7ce6a5de77 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -289,7 +289,7 @@ Legend: | `functions delete` | `ported` | [`../src/legacy/commands/functions/delete/delete.command.ts`](../src/legacy/commands/functions/delete/delete.command.ts) | | `functions download` | `ported` | [`../src/legacy/commands/functions/download/download.command.ts`](../src/legacy/commands/functions/download/download.command.ts) | | `functions deploy` | `ported` | [`../src/legacy/commands/functions/deploy/deploy.command.ts`](../src/legacy/commands/functions/deploy/deploy.command.ts) | -| `functions new` | `wrapped` | [`../src/legacy/commands/functions/new/new.command.ts`](../src/legacy/commands/functions/new/new.command.ts) | +| `functions new` | `ported` | [`../src/legacy/commands/functions/new/new.command.ts`](../src/legacy/commands/functions/new/new.command.ts) | | `functions serve` | `ported` | [`../src/legacy/commands/functions/serve/serve.command.ts`](../src/legacy/commands/functions/serve/serve.command.ts) | | `storage ls` | `wrapped` | [`../src/legacy/commands/storage/ls/ls.command.ts`](../src/legacy/commands/storage/ls/ls.command.ts) | | `storage cp` | `wrapped` | [`../src/legacy/commands/storage/cp/cp.command.ts`](../src/legacy/commands/storage/cp/cp.command.ts) | diff --git a/apps/cli/src/legacy/commands/functions/new/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/new/SIDE_EFFECTS.md index dcb804db3e..5d85740927 100644 --- a/apps/cli/src/legacy/commands/functions/new/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/new/SIDE_EFFECTS.md @@ -2,53 +2,78 @@ ## Files Read -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ----------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/profile` | plain text | when `--profile` and `SUPABASE_PROFILE` are both unset | +| `.yaml` | YAML | when `SUPABASE_PROFILE` or `--profile` points to a file | +| `/supabase/config.toml` | TOML | best-effort when resolving template values, detecting existing `[functions.]` declarations, and scanning declared function slugs | +| `/supabase/functions/*/index.ts` | TypeScript | when checking whether this is the first local function | +| `/.vscode/extensions.json` | JSONC | when merging VS Code recommendations into an existing file | +| `/.vscode/settings.json` | JSONC | when merging Deno settings into an existing file | +| `/telemetry.json` | JSON | when present, before post-run telemetry state is refreshed | ## Files Written -| Path | Format | When | -| ---------------------------------------------- | ---------- | ---------------------------------------------------------------- | -| `/supabase/functions//index.ts` | TypeScript | always (creates function scaffold, template depends on `--auth`) | +| Path | Format | When | +| ----------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------- | +| `/supabase/functions//index.ts` | TypeScript | always | +| `/supabase/functions//deno.json` | JSON | always | +| `/supabase/functions//.npmrc` | plain text | always | +| `/supabase/config.toml` | TOML | always unless `[functions.]` is already declared | +| `/.vscode/extensions.json` | JSON | when this is the first function and VS Code settings are accepted or auto-accepted | +| `/.vscode/settings.json` | JSON | when this is the first function and VS Code settings are accepted or auto-accepted | +| `/.idea/deno.xml` | XML | when this is the first function, VS Code settings are declined, and IntelliJ settings are accepted | +| `/telemetry.json` | JSON | after command completion, flushed on both success and failure paths | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---- | ---- | ------------ | ---------------------- | -| — | — | — | — | — | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------ | ------ | ------------ | ---------------------- | +| `none` | `none` | `none` | `none` | `none` | ## Environment Variables -| Variable | Purpose | Required? | -| -------- | ------- | --------- | -| — | — | — | +| Variable | Purpose | Required? | +| ----------------------- | ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | resolved into legacy CLI config even though this command performs no API calls | no (falls back to credential lookup paths that are not used here) | +| `SUPABASE_HOME` | changes where telemetry state is persisted | no (defaults to `~/.supabase`) | +| `SUPABASE_PROFILE` | selects a built-in profile or YAML profile path during legacy CLI config resolution | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | resolved into legacy CLI config even though this command does not use a linked project ref | no | +| `SUPABASE_WORKDIR` | sets `` for all local project reads and writes | no (falls back to `--workdir` -> current working dir) | ## Exit Codes -| Code | Condition | -| ---- | --------------------------------- | -| `0` | success | -| `1` | invalid function name | -| `1` | function directory already exists | +| Code | Condition | +| ---- | ---------------------------------- | +| `0` | success | +| `1` | invalid function name | +| `1` | function entrypoint already exists | +| `1` | local file write failed | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | ## Output ### `--output-format text` (Go CLI compatible) -Prints a success message with the path to the created function file. +Prints `Created new Function at ` and, when this is the first function, may also print the IDE prompt plus generated settings messages. ### `--output-format json` -Not applicable (proxied to Go binary). +Emits a structured success payload with `path`, `function_name`, and `auth`. ### `--output-format stream-json` -Not applicable (proxied to Go binary). +Emits a structured success result event with `path`, `function_name`, and `auth`. ## Notes - Creates a new Edge Function scaffold locally. - Requires exactly one argument: the function name. - `--auth` selects the auth-mode template (`none` | `apikey` | `user`, default: `apikey`). -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary. +- Best-effort config parsing is intentionally non-fatal here: malformed `config.toml` does not block scaffolding or config append, matching the Go command. +- No Management API requests are made; all behavior is local filesystem work plus telemetry flush. diff --git a/apps/cli/src/legacy/commands/functions/new/new.command.ts b/apps/cli/src/legacy/commands/functions/new/new.command.ts index 6aa54c1eb8..8a161adf28 100644 --- a/apps/cli/src/legacy/commands/functions/new/new.command.ts +++ b/apps/cli/src/legacy/commands/functions/new/new.command.ts @@ -1,5 +1,12 @@ +import { Layer } from "effect"; import { Argument, Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; +import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; +import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyFunctionsNew } from "./new.handler.ts"; const AUTH_MODE_VALUES = ["none", "apikey", "user"] as const; @@ -10,14 +17,28 @@ const config = { ), auth: Flag.choice("auth", AUTH_MODE_VALUES).pipe( Flag.withDescription("use a specific auth mode"), - Flag.optional, + Flag.withDefault("apikey" as const), ), } as const; export type LegacyFunctionsNewFlags = CliCommand.Command.Config.Infer; +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + +const legacyFunctionsNewRuntimeLayer = Layer.mergeAll( + cliConfig, + legacyTelemetryStateLayer, + commandRuntimeLayer(["functions", "new"]), +); + export const legacyFunctionsNewCommand = Command.make("new", config).pipe( Command.withDescription("Create a new Function locally."), Command.withShortDescription("Create a new Function locally"), - Command.withHandler((flags) => legacyFunctionsNew(flags)), + Command.withHandler((flags) => + legacyFunctionsNew(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyFunctionsNewRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/functions/new/new.errors.ts b/apps/cli/src/legacy/commands/functions/new/new.errors.ts new file mode 100644 index 0000000000..f10f663996 --- /dev/null +++ b/apps/cli/src/legacy/commands/functions/new/new.errors.ts @@ -0,0 +1,21 @@ +import { Data } from "effect"; + +export class LegacyFunctionsNewInvalidSlugError extends Data.TaggedError( + "LegacyFunctionsNewInvalidSlugError", +)<{ + readonly message: string; + readonly detail: string; +}> {} + +export class LegacyFunctionsNewFileExistsError extends Data.TaggedError( + "LegacyFunctionsNewFileExistsError", +)<{ + readonly path: string; + readonly message: string; + readonly suggestion: string; +}> {} + +export class LegacyFunctionsNewWriteError extends Data.TaggedError("LegacyFunctionsNewWriteError")<{ + readonly path: string; + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/functions/new/new.handler.ts b/apps/cli/src/legacy/commands/functions/new/new.handler.ts index a686ef7a5b..7ee12c65b1 100644 --- a/apps/cli/src/legacy/commands/functions/new/new.handler.ts +++ b/apps/cli/src/legacy/commands/functions/new/new.handler.ts @@ -1,12 +1,295 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { loadProjectConfig } from "@supabase/config"; +import { defaultPublishableKey } from "@supabase/stack/effect"; +import { Effect, FileSystem, Option, Path } from "effect"; + +import { + invalidFunctionSlugDetail, + validateFunctionSlugMessage, +} from "../../../../shared/functions/functions.shared.ts"; +import { writeIntelliJConfig, writeVscodeConfig } from "../../../../shared/init/project-init.ts"; +import { LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { Tty } from "../../../../shared/runtime/tty.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { legacyBold } from "../../../shared/legacy-colors.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import type { LegacyFunctionsNewFlags } from "./new.command.ts"; +import { + LegacyFunctionsNewFileExistsError, + LegacyFunctionsNewInvalidSlugError, + LegacyFunctionsNewWriteError, +} from "./new.errors.ts"; +import { + LEGACY_FUNCTIONS_NEW_DENO_JSON, + LEGACY_FUNCTIONS_NEW_NPMRC, + type LegacyFunctionsNewAuthMode, + renderLegacyFunctionsNewConfig, + renderLegacyFunctionsNewEntrypoint, +} from "./new.templates.ts"; + +const DEFAULT_LOCAL_API_PORT = 54321; + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function readDeclaredFunctionSlugs(contents: string): ReadonlySet { + const slugs = new Set(); + const pattern = /^\s*\[functions\.([^\]\s]+)\]\s*$/gm; + for (const match of contents.matchAll(pattern)) { + const slug = match[1]; + if (slug !== undefined) { + slugs.add(slug); + } + } + return slugs; +} + +function hasFunctionConfigDeclaration(contents: string, slug: string): boolean { + const pattern = new RegExp(`^\\s*\\[functions\\.${escapeRegExp(slug)}\\]\\s*$`, "m"); + return pattern.test(contents); +} + +function mapIdeWriteError(cause: unknown): LegacyFunctionsNewWriteError { + if (typeof cause === "object" && cause !== null && "message" in cause) { + return new LegacyFunctionsNewWriteError({ + path: ".vscode", + message: String(cause.message), + }); + } + return new LegacyFunctionsNewWriteError({ + path: ".vscode", + message: String(cause), + }); +} + +const listExistingFunctionSlugs = Effect.fnUntraced(function* (workdir: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const slugs = new Set(); + const functionsDir = path.join(workdir, "supabase", "functions"); + const hasFunctionsDir = yield* fs.exists(functionsDir).pipe(Effect.orElseSucceed(() => false)); + if (hasFunctionsDir) { + const entries = yield* fs + .readDirectory(functionsDir) + .pipe(Effect.orElseSucceed(() => Array())); + for (const entry of entries) { + const indexPath = path.join(functionsDir, entry, "index.ts"); + const exists = yield* fs.exists(indexPath).pipe(Effect.orElseSucceed(() => false)); + if (exists && validateFunctionSlugMessage(entry) === undefined) { + slugs.add(entry); + } + } + } + + const configPath = path.join(workdir, "supabase", "config.toml"); + const configContents = yield* fs.readFileString(configPath).pipe(Effect.option); + if (Option.isSome(configContents)) { + for (const slug of readDeclaredFunctionSlugs(configContents.value)) { + slugs.add(slug); + } + } + + return slugs; +}); + +const resolveTemplateInputs = Effect.fnUntraced(function* (workdir: string, slug: string) { + const loaded = yield* loadProjectConfig(workdir).pipe(Effect.orElseSucceed(() => null)); + const port = loaded?.config.api.port ?? DEFAULT_LOCAL_API_PORT; + const publishableKey = loaded?.config.auth.publishable_key ?? defaultPublishableKey; + return { + url: `http://127.0.0.1:${port}/functions/v1/${slug}`, + publishableKey, + }; +}); + +const promptForIdeSettings = Effect.fnUntraced(function* ( + workdir: string, + announce: boolean, + format: "text" | "json" | "stream-json", +) { + const output = yield* Output; + const tty = yield* Tty; + const yes = yield* LegacyYesFlag; + + if (yes) { + yield* output.raw("Generate VS Code settings for Deno? [Y/n] y\n", "stderr"); + yield* writeVscodeConfig(workdir, { announce }).pipe(Effect.mapError(mapIdeWriteError)); + return; + } + + if (!tty.stdinIsTty) { + yield* writeVscodeConfig(workdir, { announce }).pipe(Effect.mapError(mapIdeWriteError)); + return; + } + + if (format !== "text") { + return; + } + + if (yield* output.promptConfirm("Generate VS Code settings for Deno?", { defaultValue: true })) { + yield* writeVscodeConfig(workdir, { announce: true }).pipe(Effect.mapError(mapIdeWriteError)); + return; + } + + if ( + yield* output.promptConfirm("Generate IntelliJ IDEA settings for Deno?", { + defaultValue: false, + }) + ) { + yield* writeIntelliJConfig(workdir, { announce: true }).pipe( + Effect.mapError( + (cause) => + new LegacyFunctionsNewWriteError({ + path: ".idea/deno.xml", + message: + typeof cause === "object" && cause !== null && "message" in cause + ? String(cause.message) + : String(cause), + }), + ), + ); + } +}); + +const appendFunctionConfig = Effect.fnUntraced(function* ( + workdir: string, + slug: string, + verifyJwt: boolean, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const output = yield* Output; + const relPath = path.join("supabase", "config.toml"); + const configPath = path.join(workdir, relPath); + const existing = yield* fs.readFileString(configPath).pipe(Effect.option); + + if (Option.isSome(existing) && hasFunctionConfigDeclaration(existing.value, slug)) { + yield* output.raw( + `[functions.${slug}] is already declared in ${legacyBold(relPath)}\n`, + "stderr", + ); + return; + } + + const next = `${Option.getOrElse(existing, () => "")}${renderLegacyFunctionsNewConfig( + slug, + verifyJwt, + )}`; + yield* fs.writeFileString(configPath, next).pipe( + Effect.mapError( + (cause) => + new LegacyFunctionsNewWriteError({ + path: relPath, + message: `failed to append config: ${String(cause)}`, + }), + ), + ); +}); export const legacyFunctionsNew = Effect.fn("legacy.functions.new")(function* ( flags: LegacyFunctionsNewFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["functions", "new", flags.functionName]; - if (Option.isSome(flags.auth)) args.push("--auth", flags.auth.value); - yield* proxy.exec(args); + const output = yield* Output; + const cliConfig = yield* LegacyCliConfig; + const telemetryState = yield* LegacyTelemetryState; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + yield* Effect.gen(function* () { + const invalidSlugMessage = validateFunctionSlugMessage(flags.functionName); + if (invalidSlugMessage !== undefined) { + return yield* Effect.fail( + new LegacyFunctionsNewInvalidSlugError({ + message: invalidSlugMessage, + detail: invalidFunctionSlugDetail, + }), + ); + } + + const existingSlugs = yield* listExistingFunctionSlugs(cliConfig.workdir); + const isFirstFunction = existingSlugs.size === 0; + const authMode: LegacyFunctionsNewAuthMode = flags.auth; + + const relFunctionDir = path.join("supabase", "functions", flags.functionName); + const relEntrypoint = path.join(relFunctionDir, "index.ts"); + const functionDir = path.join(cliConfig.workdir, relFunctionDir); + const entrypointPath = path.join(cliConfig.workdir, relEntrypoint); + + yield* fs.makeDirectory(functionDir, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new LegacyFunctionsNewWriteError({ + path: relFunctionDir, + message: String(cause), + }), + ), + ); + + const entrypointExists = yield* fs + .exists(entrypointPath) + .pipe(Effect.orElseSucceed(() => false)); + if (entrypointExists) { + return yield* Effect.fail( + new LegacyFunctionsNewFileExistsError({ + path: relEntrypoint, + message: "failed to create entrypoint: file already exists", + suggestion: `Remove ${relEntrypoint} or use a different Function name.`, + }), + ); + } + + const templateInputs = yield* resolveTemplateInputs(cliConfig.workdir, flags.functionName); + yield* fs + .writeFileString(entrypointPath, renderLegacyFunctionsNewEntrypoint(authMode, templateInputs)) + .pipe( + Effect.mapError( + (cause) => + new LegacyFunctionsNewWriteError({ + path: relEntrypoint, + message: `failed to write entrypoint: ${String(cause)}`, + }), + ), + ); + + yield* appendFunctionConfig(cliConfig.workdir, flags.functionName, authMode === "user"); + + yield* fs + .writeFileString(path.join(functionDir, "deno.json"), LEGACY_FUNCTIONS_NEW_DENO_JSON) + .pipe( + Effect.mapError( + (cause) => + new LegacyFunctionsNewWriteError({ + path: path.join(relFunctionDir, "deno.json"), + message: `failed to create deno.json config: ${String(cause)}`, + }), + ), + ); + yield* fs.writeFileString(path.join(functionDir, ".npmrc"), LEGACY_FUNCTIONS_NEW_NPMRC).pipe( + Effect.mapError( + (cause) => + new LegacyFunctionsNewWriteError({ + path: path.join(relFunctionDir, ".npmrc"), + message: `failed to create .npmrc config: ${String(cause)}`, + }), + ), + ); + + if (output.format === "text") { + yield* output.raw(`Created new Function at ${legacyBold(relFunctionDir)}\n`); + } + + if (isFirstFunction) { + yield* promptForIdeSettings(cliConfig.workdir, output.format === "text", output.format); + } + + if (output.format === "json" || output.format === "stream-json") { + yield* output.success("", { + path: relFunctionDir, + function_name: flags.functionName, + auth: authMode, + }); + } + }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/functions/new/new.integration.test.ts b/apps/cli/src/legacy/commands/functions/new/new.integration.test.ts new file mode 100644 index 0000000000..b9e869a64e --- /dev/null +++ b/apps/cli/src/legacy/commands/functions/new/new.integration.test.ts @@ -0,0 +1,285 @@ +import { existsSync, readFileSync } from "node:fs"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, Layer } from "effect"; + +import { + mockLegacyCliConfig, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { mockOutput, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; +import { legacyFunctionsNew } from "./new.handler.ts"; +import { LEGACY_FUNCTIONS_NEW_DENO_JSON, LEGACY_FUNCTIONS_NEW_NPMRC } from "./new.templates.ts"; + +const tempRoot = useLegacyTempWorkdir("supabase-functions-new-int-"); + +interface SetupOptions { + readonly format?: "text" | "json" | "stream-json"; + readonly stdinIsTty?: boolean; + readonly stdoutIsTty?: boolean; + readonly yes?: boolean; + readonly promptConfirmResponses?: ReadonlyArray; +} + +function setup(options: SetupOptions = {}) { + const out = mockOutput({ + format: options.format ?? "text", + promptConfirmResponses: options.promptConfirmResponses, + }); + const telemetry = mockLegacyTelemetryStateTracked(); + const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); + const layer = Layer.mergeAll( + BunServices.layer, + out.layer, + telemetry.layer, + cliConfig, + mockTty({ + stdinIsTty: options.stdinIsTty ?? false, + stdoutIsTty: options.stdoutIsTty ?? false, + }), + Layer.succeed(LegacyYesFlag, options.yes ?? false), + ); + return { layer, out, telemetry, workdir: tempRoot.current }; +} + +function exitTag(exit: Exit.Exit): string | undefined { + if (!Exit.isFailure(exit)) { + return undefined; + } + const failure = Cause.findErrorOption(exit.cause); + if (failure._tag !== "Some") { + return undefined; + } + if (typeof failure.value !== "object" || failure.value === null || !("_tag" in failure.value)) { + return undefined; + } + return String(failure.value._tag); +} + +describe("legacy functions new integration", () => { + it.live("creates the default apikey scaffold, config snippet, and optional files", () => { + const { layer, out, telemetry, workdir } = setup(); + return Effect.gen(function* () { + yield* legacyFunctionsNew({ functionName: "hello-world", auth: "apikey" }); + + const functionDir = join(workdir, "supabase", "functions", "hello-world"); + const entrypoint = yield* Effect.tryPromise(() => + readFile(join(functionDir, "index.ts"), "utf8"), + ); + const config = yield* Effect.tryPromise(() => + readFile(join(workdir, "supabase", "config.toml"), "utf8"), + ); + + expect(entrypoint).toContain('withSupabase({ auth: ["publishable", "secret"] }'); + expect(entrypoint).toContain("--header 'apiKey: sb_publishable_"); + expect(entrypoint).toContain("http://127.0.0.1:54321/functions/v1/hello-world"); + expect(config).toContain("[functions.hello-world]"); + expect(config).toContain("verify_jwt = false"); + expect(config).toContain('import_map = "./functions/hello-world/deno.json"'); + expect(readFileSync(join(functionDir, "deno.json"), "utf8")).toBe( + LEGACY_FUNCTIONS_NEW_DENO_JSON, + ); + expect(readFileSync(join(functionDir, ".npmrc"), "utf8")).toBe(LEGACY_FUNCTIONS_NEW_NPMRC); + expect(out.stdoutText).toContain("Created new Function at "); + expect(out.stdoutText).toContain(join("supabase", "functions", "hello-world")); + expect(existsSync(join(workdir, ".vscode", "settings.json"))).toBe(true); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("uses the none-auth scaffold and keeps verify_jwt disabled", () => { + const { layer, workdir } = setup(); + return Effect.gen(function* () { + yield* legacyFunctionsNew({ functionName: "public-fn", auth: "none" }); + const entrypoint = yield* Effect.tryPromise(() => + readFile(join(workdir, "supabase", "functions", "public-fn", "index.ts"), "utf8"), + ); + const config = yield* Effect.tryPromise(() => + readFile(join(workdir, "supabase", "config.toml"), "utf8"), + ); + expect(entrypoint).toContain('withSupabase({ auth: "none" }'); + expect(entrypoint).toContain("--header 'Content-Type: application/json'"); + expect(config).toContain("verify_jwt = false"); + }).pipe(Effect.provide(layer)); + }); + + it.live("uses the user-auth scaffold and enables verify_jwt", () => { + const { layer, workdir } = setup(); + return Effect.gen(function* () { + yield* legacyFunctionsNew({ functionName: "user-fn", auth: "user" }); + const entrypoint = yield* Effect.tryPromise(() => + readFile(join(workdir, "supabase", "functions", "user-fn", "index.ts"), "utf8"), + ); + const config = yield* Effect.tryPromise(() => + readFile(join(workdir, "supabase", "config.toml"), "utf8"), + ); + expect(entrypoint).toContain('withSupabase({ auth: "user" }'); + expect(entrypoint).toContain("--header 'Authorization: Bearer '"); + expect(config).toContain("verify_jwt = true"); + }).pipe(Effect.provide(layer)); + }); + + it.live("uses api.port and auth.publishable_key from config.toml when present", () => { + const { layer, workdir } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + mkdir(join(workdir, "supabase"), { recursive: true }).then(() => + writeFile( + join(workdir, "supabase", "config.toml"), + [ + 'project_id = "test-project"', + "", + "[api]", + "port = 54310", + "", + "[auth]", + 'publishable_key = "sb_publishable_custom"', + "", + ].join("\n"), + ), + ), + ); + + yield* legacyFunctionsNew({ functionName: "customized", auth: "apikey" }); + const entrypoint = yield* Effect.tryPromise(() => + readFile(join(workdir, "supabase", "functions", "customized", "index.ts"), "utf8"), + ); + expect(entrypoint).toContain("http://127.0.0.1:54310/functions/v1/customized"); + expect(entrypoint).toContain("--header 'apiKey: sb_publishable_custom'"); + }).pipe(Effect.provide(layer)); + }); + + it.live("appends config even when the existing config.toml is malformed", () => { + const { layer, workdir } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + mkdir(join(workdir, "supabase"), { recursive: true }).then(() => + writeFile(join(workdir, "supabase", "config.toml"), "not valid toml ]["), + ), + ); + + yield* legacyFunctionsNew({ functionName: "after-bad-config", auth: "none" }); + const config = yield* Effect.tryPromise(() => + readFile(join(workdir, "supabase", "config.toml"), "utf8"), + ); + expect(config).toContain("not valid toml ]["); + expect(config).toContain("[functions.after-bad-config]"); + }).pipe(Effect.provide(layer)); + }); + + it.live("warns and skips the config append when the function is already declared", () => { + const { layer, out, workdir } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + mkdir(join(workdir, "supabase"), { recursive: true }).then(() => + writeFile( + join(workdir, "supabase", "config.toml"), + ["[functions.hello-world]", "enabled = true", ""].join("\n"), + ), + ), + ); + + yield* legacyFunctionsNew({ functionName: "hello-world", auth: "apikey" }); + const config = yield* Effect.tryPromise(() => + readFile(join(workdir, "supabase", "config.toml"), "utf8"), + ); + expect(config.match(/\[functions\.hello-world\]/g) ?? []).toHaveLength(1); + expect(out.stderrText).toContain("[functions.hello-world] is already declared in "); + }).pipe(Effect.provide(layer)); + }); + + it.live("does not auto-generate IDE files when another function already exists", () => { + const { layer, workdir } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + mkdir(join(workdir, "supabase", "functions", "existing"), { recursive: true }).then(() => + writeFile( + join(workdir, "supabase", "functions", "existing", "index.ts"), + "// existing\n", + ), + ), + ); + + yield* legacyFunctionsNew({ functionName: "second-fn", auth: "apikey" }); + expect(existsSync(join(workdir, ".vscode", "settings.json"))).toBe(false); + expect(existsSync(join(workdir, ".idea", "deno.xml"))).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("supports --yes by echoing the VS Code prompt and generating settings", () => { + const { layer, out, workdir } = setup({ yes: true }); + return Effect.gen(function* () { + yield* legacyFunctionsNew({ functionName: "with-yes", auth: "apikey" }); + expect(out.stderrText).toContain("Generate VS Code settings for Deno? [Y/n] y"); + expect(existsSync(join(workdir, ".vscode", "settings.json"))).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("writes IntelliJ settings when VS Code is declined and IntelliJ is accepted", () => { + const { layer, out, workdir } = setup({ + stdinIsTty: true, + stdoutIsTty: true, + promptConfirmResponses: [false, true], + }); + return Effect.gen(function* () { + yield* legacyFunctionsNew({ functionName: "idea-fn", auth: "apikey" }); + expect(existsSync(join(workdir, ".vscode", "settings.json"))).toBe(false); + expect(existsSync(join(workdir, ".idea", "deno.xml"))).toBe(true); + expect(out.stdoutText).toContain("Generated IntelliJ settings in .idea/deno.xml."); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits structured success in json mode", () => { + const { layer, out, workdir } = setup({ format: "json" }); + return Effect.gen(function* () { + yield* legacyFunctionsNew({ functionName: "json-fn", auth: "apikey" }); + const success = out.messages.find((message) => message.type === "success"); + expect(success?.data).toMatchObject({ + path: join("supabase", "functions", "json-fn"), + function_name: "json-fn", + auth: "apikey", + }); + expect(out.stdoutText).toBe(""); + expect(existsSync(join(workdir, ".vscode", "settings.json"))).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits structured success in stream-json mode", () => { + const { layer, out } = setup({ format: "stream-json" }); + return Effect.gen(function* () { + yield* legacyFunctionsNew({ functionName: "stream-fn", auth: "user" }); + const success = out.messages.find((message) => message.type === "success"); + expect(success?.data).toMatchObject({ + path: join("supabase", "functions", "stream-fn"), + auth: "user", + }); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails on invalid function slugs", () => { + const { layer, telemetry } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyFunctionsNew({ functionName: "@", auth: "none" })); + expect(exitTag(exit)).toBe("LegacyFunctionsNewInvalidSlugError"); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when the entrypoint already exists", () => { + const { layer, workdir } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + mkdir(join(workdir, "supabase", "functions", "dupe"), { recursive: true }).then(() => + writeFile(join(workdir, "supabase", "functions", "dupe", "index.ts"), "// existing\n"), + ), + ); + const exit = yield* Effect.exit(legacyFunctionsNew({ functionName: "dupe", auth: "apikey" })); + expect(exitTag(exit)).toBe("LegacyFunctionsNewFileExistsError"); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/functions/new/new.templates.ts b/apps/cli/src/legacy/commands/functions/new/new.templates.ts new file mode 100644 index 0000000000..0a6c2943ed --- /dev/null +++ b/apps/cli/src/legacy/commands/functions/new/new.templates.ts @@ -0,0 +1,166 @@ +export const LEGACY_FUNCTIONS_NEW_DENO_JSON = `{ + "imports": { + "@supabase/functions-js": "jsr:@supabase/functions-js@^2", + "@supabase/server": "npm:@supabase/server@^1" + } +} +`; + +export const LEGACY_FUNCTIONS_NEW_NPMRC = `# Configuration for private npm package dependencies +# For more information on using private registries with Edge Functions, see: +# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries +`; + +const INDEX_AUTH_MODE_NONE = `// Follow this setup guide to integrate the Deno language server with your editor: +// https://deno.land/manual/getting_started/setup_your_environment +// This enables autocomplete, go to definition, etc. + +// Setup type definitions for built-in Supabase Runtime APIs +import "@supabase/functions-js/edge-runtime.d.ts"; +import { withSupabase } from "@supabase/server"; + +console.log("Hello from Functions!"); + +// This endpoint uses auth 'none', no credentials required, every request is accepted. +// Use it for health checks, public APIs, or when you need to implement your own auth logic. +export default { + fetch: withSupabase({ auth: "none" }, async (req, ctx) => { + const { name } = await req.json(); + + return Response.json({ + message: \`Hello \${name}!\`, + }); + }), +}; + +/* To invoke locally: + + 1. Run \`supabase start\` (see: https://supabase.com/docs/reference/cli/supabase-start) + 2. Make an HTTP request: + + curl -i --location --request POST '__URL__' \\ + --header 'Content-Type: application/json' \\ + --data '{"name":"Functions"}' + +*/ +`; + +const INDEX_AUTH_MODE_APIKEY = `// Follow this setup guide to integrate the Deno language server with your editor: +// https://deno.land/manual/getting_started/setup_your_environment +// This enables autocomplete, go to definition, etc. + +// Setup type definitions for built-in Supabase Runtime APIs +import "@supabase/functions-js/edge-runtime.d.ts"; +import { withSupabase } from "@supabase/server"; + +console.log("Hello from Functions!"); + +// This endpoint uses 'publishable' | 'secret' access, apiKey is required. +// Use publishable for Client-facing, key-validated endpoints +// Use secret for Server-to-server, internal calls +export default { + fetch: withSupabase({ auth: ["publishable", "secret"] }, async (req, ctx) => { + // Called by another service with a secret key + // ctx.supabaseAdmin bypasses RLS - use for privileged operations + /* + if (ctx.authMode === "secret") { + const { user_id } = await req.json(); + const { data } = await ctx.supabaseAdmin.auth.admin.getUserById(user_id); + + return Response.json({ + email: data?.user?.email, + }); + } + */ + + const { name } = await req.json(); + + return Response.json({ + message: \`Hello \${name}!\`, + }); + }), +}; + +/* To invoke locally: + + 1. Run \`supabase start\` (see: https://supabase.com/docs/reference/cli/supabase-start) + 2. Make an HTTP request: + + curl -i --location --request POST '__URL__' \\ + --header 'apiKey: __PUBLISHABLE_KEY__' \\ + --data '{"name":"Functions"}' + +*/ +`; + +const INDEX_AUTH_MODE_USER = `// Follow this setup guide to integrate the Deno language server with your editor: +// https://deno.land/manual/getting_started/setup_your_environment +// This enables autocomplete, go to definition, etc. + +// Setup type definitions for built-in Supabase Runtime APIs +import "@supabase/functions-js/edge-runtime.d.ts" +import { withSupabase } from "@supabase/server" + +console.log("Hello from Functions!") + +// This endpoint uses 'user' access, credentials is required. +export default { + fetch: withSupabase({ auth: "user" }, async (_req, ctx) => { + const email = ctx.userClaims?.email; + + return Response.json({ + message: \`Hello \${email}!\`, + }) + }), +} + +/* To invoke locally: + + 1. Run \`supabase start\` (see: https://supabase.com/docs/reference/cli/supabase-start) + 2. Make an HTTP request: + + curl -i --location --request POST '__URL__' \\ + --header 'apiKey: __PUBLISHABLE_KEY__' \\ + --header 'Authorization: Bearer ' +*/ +`; + +const FUNCTION_CONFIG_TEMPLATE = ` +[functions.__SLUG__] +enabled = true +verify_jwt = __VERIFY_JWT__ +import_map = "./functions/__SLUG__/deno.json" +# Uncomment to specify a custom file path to the entrypoint. +# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx +entrypoint = "./functions/__SLUG__/index.ts" +# Specifies static files to be bundled with the function. Supports glob patterns. +# For example, if you want to serve static HTML pages in your function: +# static_files = [ "./functions/__SLUG__/*.html" ] +`; + +export type LegacyFunctionsNewAuthMode = "none" | "apikey" | "user"; + +export function renderLegacyFunctionsNewEntrypoint( + authMode: LegacyFunctionsNewAuthMode, + options: { + readonly url: string; + readonly publishableKey: string; + }, +): string { + const template = + authMode === "none" + ? INDEX_AUTH_MODE_NONE + : authMode === "user" + ? INDEX_AUTH_MODE_USER + : INDEX_AUTH_MODE_APIKEY; + return template + .replaceAll("__URL__", options.url) + .replaceAll("__PUBLISHABLE_KEY__", options.publishableKey); +} + +export function renderLegacyFunctionsNewConfig(slug: string, verifyJwt: boolean): string { + return FUNCTION_CONFIG_TEMPLATE.replaceAll("__SLUG__", slug).replaceAll( + "__VERIFY_JWT__", + verifyJwt ? "true" : "false", + ); +} diff --git a/apps/cli/src/shared/init/project-init.ts b/apps/cli/src/shared/init/project-init.ts index 4805a41f13..f2cafdc852 100644 --- a/apps/cli/src/shared/init/project-init.ts +++ b/apps/cli/src/shared/init/project-init.ts @@ -163,7 +163,10 @@ function updateJsonFile(pathname: string, template: string) { }); } -const writeVscodeConfig = Effect.fnUntraced(function* (cwd: string) { +export const writeVscodeConfig = Effect.fnUntraced(function* ( + cwd: string, + options?: { readonly announce?: boolean }, +) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const output = yield* Output; @@ -176,13 +179,18 @@ const writeVscodeConfig = Effect.fnUntraced(function* (cwd: string) { yield* updateJsonFile(extensionsPath, VSCODE_EXTENSIONS_TEMPLATE); yield* updateJsonFile(settingsPath, VSCODE_SETTINGS_TEMPLATE); - yield* output.raw("Generated VS Code settings in .vscode/settings.json.\n"); - yield* output.raw( - "Please install the Deno extension for VS Code: https://marketplace.visualstudio.com/items?itemName=denoland.vscode-deno\n", - ); + if (options?.announce ?? true) { + yield* output.raw("Generated VS Code settings in .vscode/settings.json.\n"); + yield* output.raw( + "Please install the Deno extension for VS Code: https://marketplace.visualstudio.com/items?itemName=denoland.vscode-deno\n", + ); + } }); -const writeIntelliJConfig = Effect.fnUntraced(function* (cwd: string) { +export const writeIntelliJConfig = Effect.fnUntraced(function* ( + cwd: string, + options?: { readonly announce?: boolean }, +) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const output = yield* Output; @@ -193,10 +201,12 @@ const writeIntelliJConfig = Effect.fnUntraced(function* (cwd: string) { yield* fs.makeDirectory(intellijDir, { recursive: true }); yield* fs.writeFileString(denoPath, INTELLIJ_DENO_TEMPLATE); - yield* output.raw("Generated IntelliJ settings in .idea/deno.xml.\n"); - yield* output.raw( - "Please install the Deno plugin for IntelliJ: https://plugins.jetbrains.com/plugin/14382-deno\n", - ); + if (options?.announce ?? true) { + yield* output.raw("Generated IntelliJ settings in .idea/deno.xml.\n"); + yield* output.raw( + "Please install the Deno plugin for IntelliJ: https://plugins.jetbrains.com/plugin/14382-deno\n", + ); + } }); const promptForIdeSettings = Effect.fnUntraced(function* (cwd: string) { From 610f33221d47a1dee69af85a5b0f7c6cd865a600 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:25:06 +0530 Subject: [PATCH 2/3] nit --- apps/cli/src/legacy/commands/functions/new/new.handler.ts | 6 +++++- .../legacy/commands/functions/new/new.integration.test.ts | 1 + apps/cli/src/legacy/commands/functions/new/new.templates.ts | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/legacy/commands/functions/new/new.handler.ts b/apps/cli/src/legacy/commands/functions/new/new.handler.ts index 7ee12c65b1..e73288ec1a 100644 --- a/apps/cli/src/legacy/commands/functions/new/new.handler.ts +++ b/apps/cli/src/legacy/commands/functions/new/new.handler.ts @@ -120,6 +120,7 @@ const promptForIdeSettings = Effect.fnUntraced(function* ( } if (!tty.stdinIsTty) { + yield* output.raw("Generate VS Code settings for Deno? [Y/n]\n", "stderr"); yield* writeVscodeConfig(workdir, { announce }).pipe(Effect.mapError(mapIdeWriteError)); return; } @@ -196,6 +197,7 @@ export const legacyFunctionsNew = Effect.fn("legacy.functions.new")(function* ( const telemetryState = yield* LegacyTelemetryState; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const tty = yield* Tty; yield* Effect.gen(function* () { const invalidSlugMessage = validateFunctionSlugMessage(flags.functionName); @@ -277,7 +279,9 @@ export const legacyFunctionsNew = Effect.fn("legacy.functions.new")(function* ( ); if (output.format === "text") { - yield* output.raw(`Created new Function at ${legacyBold(relFunctionDir)}\n`); + yield* output.raw( + `Created new Function at ${tty.stdoutIsTty ? legacyBold(relFunctionDir) : relFunctionDir}\n`, + ); } if (isFirstFunction) { diff --git a/apps/cli/src/legacy/commands/functions/new/new.integration.test.ts b/apps/cli/src/legacy/commands/functions/new/new.integration.test.ts index b9e869a64e..2c7c20df85 100644 --- a/apps/cli/src/legacy/commands/functions/new/new.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/new/new.integration.test.ts @@ -87,6 +87,7 @@ describe("legacy functions new integration", () => { expect(readFileSync(join(functionDir, ".npmrc"), "utf8")).toBe(LEGACY_FUNCTIONS_NEW_NPMRC); expect(out.stdoutText).toContain("Created new Function at "); expect(out.stdoutText).toContain(join("supabase", "functions", "hello-world")); + expect(out.stderrText).toContain("Generate VS Code settings for Deno? [Y/n]"); expect(existsSync(join(workdir, ".vscode", "settings.json"))).toBe(true); expect(telemetry.flushed).toBe(true); }).pipe(Effect.provide(layer)); diff --git a/apps/cli/src/legacy/commands/functions/new/new.templates.ts b/apps/cli/src/legacy/commands/functions/new/new.templates.ts index 0a6c2943ed..275c7aa1af 100644 --- a/apps/cli/src/legacy/commands/functions/new/new.templates.ts +++ b/apps/cli/src/legacy/commands/functions/new/new.templates.ts @@ -61,7 +61,7 @@ console.log("Hello from Functions!"); export default { fetch: withSupabase({ auth: ["publishable", "secret"] }, async (req, ctx) => { // Called by another service with a secret key - // ctx.supabaseAdmin bypasses RLS - use for privileged operations + // ctx.supabaseAdmin bypasses RLS — use for privileged operations /* if (ctx.authMode === "secret") { const { user_id } = await req.json(); From c35942b96965935c5a2255b6bcad94b3018dc77f Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 24 Jun 2026 18:05:33 +0100 Subject: [PATCH 3/3] fix(cli): harden functions new IDE prompt and config append Address code review on the functions new port: - Gate IDE settings scaffolding on text mode so json/stream-json runs stay payload-only and never write .vscode/.idea as an undisclosed side effect. - Append the [functions.] section with O_APPEND semantics (flag: "a") instead of read-then-rewrite, matching Go's appendConfigFile and removing the truncation risk on a partial write. - Move IDE write-error mapping into new.errors.ts as a path-parameterized helper, fixing the hardcoded .vscode path on IntelliJ failures. - Match Go's non-TTY prompt bytes (trailing space) and document the raw-text vs parsed-config declaration scan divergence. - Add a config.toml write-failure test and flip the json-mode test to assert no IDE files are written. --- .../commands/functions/new/SIDE_EFFECTS.md | 27 ++--- .../commands/functions/new/new.errors.ts | 17 ++++ .../commands/functions/new/new.handler.ts | 99 +++++++++---------- .../functions/new/new.integration.test.ts | 23 ++++- 4 files changed, 100 insertions(+), 66 deletions(-) diff --git a/apps/cli/src/legacy/commands/functions/new/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/new/SIDE_EFFECTS.md index 5d85740927..787d6112bd 100644 --- a/apps/cli/src/legacy/commands/functions/new/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/new/SIDE_EFFECTS.md @@ -14,16 +14,16 @@ ## Files Written -| Path | Format | When | -| ----------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------- | -| `/supabase/functions//index.ts` | TypeScript | always | -| `/supabase/functions//deno.json` | JSON | always | -| `/supabase/functions//.npmrc` | plain text | always | -| `/supabase/config.toml` | TOML | always unless `[functions.]` is already declared | -| `/.vscode/extensions.json` | JSON | when this is the first function and VS Code settings are accepted or auto-accepted | -| `/.vscode/settings.json` | JSON | when this is the first function and VS Code settings are accepted or auto-accepted | -| `/.idea/deno.xml` | XML | when this is the first function, VS Code settings are declined, and IntelliJ settings are accepted | -| `/telemetry.json` | JSON | after command completion, flushed on both success and failure paths | +| Path | Format | When | +| ----------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------ | +| `/supabase/functions//index.ts` | TypeScript | always | +| `/supabase/functions//deno.json` | JSON | always | +| `/supabase/functions//.npmrc` | plain text | always | +| `/supabase/config.toml` | TOML | always unless `[functions.]` is already declared | +| `/.vscode/extensions.json` | JSON | text mode only, when this is the first function and VS Code settings are accepted or auto-accepted | +| `/.vscode/settings.json` | JSON | text mode only, when this is the first function and VS Code settings are accepted or auto-accepted | +| `/.idea/deno.xml` | XML | text mode only, when this is the first function, VS Code settings are declined, and IntelliJ settings are accepted | +| `/telemetry.json` | JSON | after command completion, flushed on both success and failure paths | ## API Routes @@ -64,11 +64,11 @@ Prints `Created new Function at ` and, when this is the first function, ma ### `--output-format json` -Emits a structured success payload with `path`, `function_name`, and `auth`. +Emits a structured success payload with `path`, `function_name`, and `auth`. No IDE settings are scaffolded and no IDE prompt is printed — machine formats are payload-only. ### `--output-format stream-json` -Emits a structured success result event with `path`, `function_name`, and `auth`. +Emits a structured success result event with `path`, `function_name`, and `auth`. No IDE settings are scaffolded and no IDE prompt is printed — machine formats are payload-only. ## Notes @@ -76,4 +76,7 @@ Emits a structured success result event with `path`, `function_name`, and `auth` - Requires exactly one argument: the function name. - `--auth` selects the auth-mode template (`none` | `apikey` | `user`, default: `apikey`). - Best-effort config parsing is intentionally non-fatal here: malformed `config.toml` does not block scaffolding or config append, matching the Go command. +- The `[functions.]` config section is **appended** (`O_APPEND` semantics, `flag: "a"`), never rewritten, so the existing file is left byte-for-byte untouched and a partial write cannot truncate it — matching Go's `appendConfigFile`. +- Existing-declaration detection scans the raw `config.toml` text (`^\s*\[functions\.\]\s*$`) rather than the parsed config map Go uses. This is a deliberate divergence: config loading here is non-fatal, so a raw-text scan stays deterministic even when the file fails to parse. For all well-formed configs the two approaches agree. +- IDE settings scaffolding (`.vscode`, `.idea`) only runs in `--output-format text`; json / stream-json runs are payload-only. - No Management API requests are made; all behavior is local filesystem work plus telemetry flush. diff --git a/apps/cli/src/legacy/commands/functions/new/new.errors.ts b/apps/cli/src/legacy/commands/functions/new/new.errors.ts index f10f663996..ac34daf516 100644 --- a/apps/cli/src/legacy/commands/functions/new/new.errors.ts +++ b/apps/cli/src/legacy/commands/functions/new/new.errors.ts @@ -19,3 +19,20 @@ export class LegacyFunctionsNewWriteError extends Data.TaggedError("LegacyFuncti readonly path: string; readonly message: string; }> {} + +/** + * Maps an arbitrary thrown cause from a filesystem write to a typed + * `LegacyFunctionsNewWriteError` tagged with the given `path`. Used by the IDE + * settings writers, where the same shape is needed for both the `.vscode` and + * `.idea/deno.xml` targets. + */ +export function mapLegacyFunctionsNewWriteError(path: string) { + return (cause: unknown): LegacyFunctionsNewWriteError => + new LegacyFunctionsNewWriteError({ + path, + message: + typeof cause === "object" && cause !== null && "message" in cause + ? String(cause.message) + : String(cause), + }); +} diff --git a/apps/cli/src/legacy/commands/functions/new/new.handler.ts b/apps/cli/src/legacy/commands/functions/new/new.handler.ts index e73288ec1a..b448c0307a 100644 --- a/apps/cli/src/legacy/commands/functions/new/new.handler.ts +++ b/apps/cli/src/legacy/commands/functions/new/new.handler.ts @@ -18,6 +18,7 @@ import { LegacyFunctionsNewFileExistsError, LegacyFunctionsNewInvalidSlugError, LegacyFunctionsNewWriteError, + mapLegacyFunctionsNewWriteError, } from "./new.errors.ts"; import { LEGACY_FUNCTIONS_NEW_DENO_JSON, @@ -33,6 +34,12 @@ function escapeRegExp(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } +// Go's `appendConfigFile` checks the *parsed* config map (`utils.Config.Functions[slug]`) +// after a best-effort `flags.LoadConfig`. We intentionally scan the raw TOML text instead: +// config loading here is non-fatal (a malformed `config.toml` must still allow scaffolding, +// matching Go), so a raw-text section scan is the deterministic fallback that does not depend +// on a successful parse. The strict `^\s*\[functions\.\]\s*$` anchoring keeps this in +// practical lock-step with the parsed-map check for all well-formed configs. function readDeclaredFunctionSlugs(contents: string): ReadonlySet { const slugs = new Set(); const pattern = /^\s*\[functions\.([^\]\s]+)\]\s*$/gm; @@ -50,19 +57,6 @@ function hasFunctionConfigDeclaration(contents: string, slug: string): boolean { return pattern.test(contents); } -function mapIdeWriteError(cause: unknown): LegacyFunctionsNewWriteError { - if (typeof cause === "object" && cause !== null && "message" in cause) { - return new LegacyFunctionsNewWriteError({ - path: ".vscode", - message: String(cause.message), - }); - } - return new LegacyFunctionsNewWriteError({ - path: ".vscode", - message: String(cause), - }); -} - const listExistingFunctionSlugs = Effect.fnUntraced(function* (workdir: string) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -104,33 +98,39 @@ const resolveTemplateInputs = Effect.fnUntraced(function* (workdir: string, slug }; }); -const promptForIdeSettings = Effect.fnUntraced(function* ( - workdir: string, - announce: boolean, - format: "text" | "json" | "stream-json", -) { +// Mirrors Go's `_init.PromptForIDESettings` (console-driven). Only invoked in text mode — the +// caller gates on `output.format === "text"` so json / stream-json runs stay payload-only and +// never scaffold IDE settings as an undisclosed side effect. +const promptForIdeSettings = Effect.fnUntraced(function* (workdir: string) { const output = yield* Output; const tty = yield* Tty; const yes = yield* LegacyYesFlag; + // `--yes`: echo the accepted prompt and write, matching Go's `viper.GetBool("YES")` branch + // (`fmt.Fprintln(os.Stderr, label+"y")`). if (yes) { yield* output.raw("Generate VS Code settings for Deno? [Y/n] y\n", "stderr"); - yield* writeVscodeConfig(workdir, { announce }).pipe(Effect.mapError(mapIdeWriteError)); + yield* writeVscodeConfig(workdir).pipe( + Effect.mapError(mapLegacyFunctionsNewWriteError(".vscode")), + ); return; } + // Non-TTY: Go's `PromptYesNo` prints the label, reads nothing within the 100ms timeout, and + // falls back to the default (`true` for VS Code). The trailing space + newline matches the + // bytes Go writes — the `"... [Y/n] "` label followed by the echoed empty line. if (!tty.stdinIsTty) { - yield* output.raw("Generate VS Code settings for Deno? [Y/n]\n", "stderr"); - yield* writeVscodeConfig(workdir, { announce }).pipe(Effect.mapError(mapIdeWriteError)); - return; - } - - if (format !== "text") { + yield* output.raw("Generate VS Code settings for Deno? [Y/n] \n", "stderr"); + yield* writeVscodeConfig(workdir).pipe( + Effect.mapError(mapLegacyFunctionsNewWriteError(".vscode")), + ); return; } if (yield* output.promptConfirm("Generate VS Code settings for Deno?", { defaultValue: true })) { - yield* writeVscodeConfig(workdir, { announce: true }).pipe(Effect.mapError(mapIdeWriteError)); + yield* writeVscodeConfig(workdir).pipe( + Effect.mapError(mapLegacyFunctionsNewWriteError(".vscode")), + ); return; } @@ -139,17 +139,8 @@ const promptForIdeSettings = Effect.fnUntraced(function* ( defaultValue: false, }) ) { - yield* writeIntelliJConfig(workdir, { announce: true }).pipe( - Effect.mapError( - (cause) => - new LegacyFunctionsNewWriteError({ - path: ".idea/deno.xml", - message: - typeof cause === "object" && cause !== null && "message" in cause - ? String(cause.message) - : String(cause), - }), - ), + yield* writeIntelliJConfig(workdir).pipe( + Effect.mapError(mapLegacyFunctionsNewWriteError(".idea/deno.xml")), ); } }); @@ -174,19 +165,21 @@ const appendFunctionConfig = Effect.fnUntraced(function* ( return; } - const next = `${Option.getOrElse(existing, () => "")}${renderLegacyFunctionsNewConfig( - slug, - verifyJwt, - )}`; - yield* fs.writeFileString(configPath, next).pipe( - Effect.mapError( - (cause) => - new LegacyFunctionsNewWriteError({ - path: relPath, - message: `failed to append config: ${String(cause)}`, - }), - ), - ); + // Append (never rewrite) the rendered section, matching Go's + // `os.OpenFile(ConfigPath, O_WRONLY|O_CREATE|O_APPEND)`: the existing file is left + // byte-for-byte untouched and a partial write can never truncate it. The template begins + // with a newline, so it attaches cleanly whether or not the file ends with one. + yield* fs + .writeFileString(configPath, renderLegacyFunctionsNewConfig(slug, verifyJwt), { flag: "a" }) + .pipe( + Effect.mapError( + (cause) => + new LegacyFunctionsNewWriteError({ + path: relPath, + message: `failed to append config: ${String(cause)}`, + }), + ), + ); }); export const legacyFunctionsNew = Effect.fn("legacy.functions.new")(function* ( @@ -284,8 +277,10 @@ export const legacyFunctionsNew = Effect.fn("legacy.functions.new")(function* ( ); } - if (isFirstFunction) { - yield* promptForIdeSettings(cliConfig.workdir, output.format === "text", output.format); + // IDE scaffolding is a human-facing nicety: only offer it in text mode so json / + // stream-json runs stay payload-only and never write IDE files as an undisclosed side effect. + if (isFirstFunction && output.format === "text") { + yield* promptForIdeSettings(cliConfig.workdir); } if (output.format === "json" || output.format === "stream-json") { diff --git a/apps/cli/src/legacy/commands/functions/new/new.integration.test.ts b/apps/cli/src/legacy/commands/functions/new/new.integration.test.ts index 2c7c20df85..f5d38145a4 100644 --- a/apps/cli/src/legacy/commands/functions/new/new.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/new/new.integration.test.ts @@ -235,7 +235,7 @@ describe("legacy functions new integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("emits structured success in json mode", () => { + it.live("stays payload-only in json mode without writing IDE files", () => { const { layer, out, workdir } = setup({ format: "json" }); return Effect.gen(function* () { yield* legacyFunctionsNew({ functionName: "json-fn", auth: "apikey" }); @@ -246,7 +246,11 @@ describe("legacy functions new integration", () => { auth: "apikey", }); expect(out.stdoutText).toBe(""); - expect(existsSync(join(workdir, ".vscode", "settings.json"))).toBe(true); + // Machine formats are payload-only: the IDE prompt is suppressed and no IDE settings + // are scaffolded as an undisclosed side effect. + expect(out.stderrText).not.toContain("Generate VS Code settings"); + expect(existsSync(join(workdir, ".vscode", "settings.json"))).toBe(false); + expect(existsSync(join(workdir, ".idea", "deno.xml"))).toBe(false); }).pipe(Effect.provide(layer)); }); @@ -283,4 +287,19 @@ describe("legacy functions new integration", () => { expect(exitTag(exit)).toBe("LegacyFunctionsNewFileExistsError"); }).pipe(Effect.provide(layer)); }); + + it.live("fails with a write error when config.toml cannot be appended", () => { + const { layer, telemetry, workdir } = setup(); + return Effect.gen(function* () { + // A directory at the config.toml path makes the append write fail (EISDIR). + yield* Effect.tryPromise(() => + mkdir(join(workdir, "supabase", "config.toml"), { recursive: true }), + ); + const exit = yield* Effect.exit( + legacyFunctionsNew({ functionName: "write-fail", auth: "apikey" }), + ); + expect(exitTag(exit)).toBe("LegacyFunctionsNewWriteError"); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); });