neon 8.10.3 → 8.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1091,6 +1091,28 @@ neon snapshots schedule set --branch main --schedule '[{"frequency":"weekly","da
1091
1091
 
1092
1092
  All sub-commands honor the [global options](#global-options), including `--output json|yaml|table`.
1093
1093
 
1094
+ ## Function templates (`functions new`)
1095
+
1096
+ `neon functions new <template>` fetches a template from the Neon Function Registry, writes its modules into `functions/<slug>/` next to `neon.ts`, and declares the result in `neon.ts` (creating it when missing), the same way `config add function` does.
1097
+
1098
+ ```bash
1099
+ neon functions templates list
1100
+ neon functions new
1101
+ neon functions new basic
1102
+ neon functions new basic --slug hello --no-add-to-config
1103
+ neon functions new <template> --operation <id> --operation <id> --install
1104
+ ```
1105
+
1106
+ Without a template, a terminal asks which one to use; anything else exits with the available IDs. Like `bootstrap`, it asks once whether to install the template's dependencies (plus `@neon/config` and `@neon/env` when it creates `neon.ts`): `--yes` installs, `--no-install` skips, and a run without a terminal leaves the install command under `Next:`.
1107
+
1108
+ A template's `layout` decides what you get. `router` writes one module per operation plus a generated `index.ts` that dispatches on the URL path, and declares one function. `separate` declares each operation as its own function under the slug the template gives it. Without `--operation` or `--all-operations`, a TTY asks which operations to include and anything else uses the template's recommended ones.
1109
+
1110
+ Variables a template needs are declared on each function in `neon.ts` (`env: { API_KEY: process.env.API_KEY! }`) and added, without values, to `.env.example` next to it. Values are never written to `.env` or `.env.local` for you: a blank placeholder there would deploy as an empty string and delete the live value. Put the real value in the file `env pull` uses and deploy with `neon config apply --env .env.local`. `neon dev` reads function env from your shell, so export it there as well.
1111
+
1112
+ Nothing is written until every check passes: a file that already exists with different contents needs `--force`, and a slug `neon.ts` already declares needs `--slug`, `--force`, or `--no-add-to-config`. A `separate` template sets its own slugs, so `--slug` only changes its directory; remove the existing declarations from `neon.ts`, or use `--force` or `--no-add-to-config`.
1113
+
1114
+ The registry lives in [`registry/`](https://github.com/neondatabase/examples/tree/main/registry) in `neondatabase/examples`. The CLI reads the index from `https://neon.com/functions/registry/registry.json`, which serves `main` through a CDN cache, and falls back to raw GitHub when neon.com is unreachable. Template files are always read from raw GitHub. Set `NEON_FUNCTION_REGISTRY_DIR` to a local checkout to try templates before they are merged, or `NEON_FUNCTION_REGISTRY_URL` to read an index from somewhere else.
1115
+
1094
1116
  ## Function triggers (`triggers`)
1095
1117
 
1096
1118
  `neon triggers` (alias `neon trigger`) manages Function triggers on a branch. Types are `schedule` (a five-field UTC cron expression) and `storage_object_created` (an object-storage bucket, with an optional object-key prefix).
@@ -6,7 +6,7 @@ import { withExclusiveLock } from "../refresh_lock.js";
6
6
  import { getApiClient, isNeonApiError } from "../api.js";
7
7
  import { setAuthContext } from "../auth_context.js";
8
8
  import { a as isOwnedCredentialPath } from "../_chunks/paths-DMq0Lt7a.js";
9
- import { currentContextFile, isAskCommand, isClaimCommand, isConfigAdd, isConfigInit, isCurrentBranchProbe, isDocsCommand, isFeedbackCommand, isGitLocalCommand, isInspectDbUrl, isMcpCommand, isMcpOauth, isPluginsCommand, isProfileCommand, isSkillsCommand, isUnfollowedGitHookSync, readContextFile } from "../context.js";
9
+ import { currentContextFile, isAskCommand, isClaimCommand, isConfigAdd, isConfigInit, isCurrentBranchProbe, isDocsCommand, isFeedbackCommand, isFunctionsTemplateCommand, isGitLocalCommand, isInspectDbUrl, isMcpCommand, isMcpOauth, isPluginsCommand, isProfileCommand, isSkillsCommand, isUnfollowedGitHookSync, readContextFile } from "../context.js";
10
10
  import { _ as upsertProfile, d as newProfileLocation, g as selectProfileName, h as resolveProfile, i as assertValidProfileName, l as locationForName, m as readProfiles, n as KEYRING_CREDENTIALS, p as profilesUsingPath, r as assertProfilesUsable, s as isKeyringPointer, t as DEFAULT_PROFILE, u as locationOf } from "../_chunks/profiles-DIB1av2Q.js";
11
11
  import { extendTokenSet } from "../utils/auth.js";
12
12
  import { AuthRefreshError, auth, classifyRefreshFailure, refreshToken } from "../auth.js";
@@ -293,6 +293,7 @@ const ensureAuth = async (props) => {
293
293
  if (isCurrentBranchProbe(props)) return;
294
294
  if (isConfigInit(props)) return;
295
295
  if (isConfigAdd(props)) return;
296
+ if (isFunctionsTemplateCommand(props)) return;
296
297
  if (isProfileCommand(props)) return;
297
298
  if (isGitLocalCommand(props)) return;
298
299
  if (isUnfollowedGitHookSync(props)) return;
@@ -1,12 +1,12 @@
1
1
  import { t as __exportAll } from "../_chunks/rolldown-runtime-8H4AJuhK.js";
2
- import { isCi } from "../env.js";
2
+ import { isCi, isInteractiveTerminal } from "../env.js";
3
3
  import { log } from "../log.js";
4
4
  import { getApiClient } from "../api.js";
5
5
  import { contextBranch, ensureDirectoryGitignored, readContextFile } from "../context.js";
6
6
  import { getCliName } from "../utils/cli_name.js";
7
7
  import { NEON_SERVICES, NEON_SERVICE_LABELS, deprecatedServiceMessage, servicesFlagValue, servicesOption } from "../neon_services.js";
8
8
  import { FUNCTION_FILENAME, FUNCTION_SLUG, FUNCTION_TEMPLATE, REQUIRED_PACKAGES, parseConfigInitServices, renderFunctionSource, renderNeonConfig, renderNeonConfigFromView } from "../config_template.js";
9
- import { editNeonConfig } from "../config_edit.js";
9
+ import { TYPESCRIPT_EXTENSION, asRelativeModulePath, editNeonConfig } from "../config_edit.js";
10
10
  import { toNeonConfigView } from "../config_format.js";
11
11
  import { declaredNeonServices } from "../config_services.js";
12
12
  import { loadProjectConfig, neonConfigFilename, projectConfigPath, projectDir } from "../project.js";
@@ -18,6 +18,7 @@ import { assertAiGatewayProvisionable, warnAiGateway } from "../utils/ai_gateway
18
18
  import { announceTargetBranch } from "../utils/branch_notice.js";
19
19
  import { renderAppliedChanges, renderBranchSettingConflicts, renderCustomDomainFollowup } from "../utils/config_diff.js";
20
20
  import { bundleEntry } from "../utils/esbuild.js";
21
+ import { FUNCTION_SLUG_PATTERN, deriveFunctionSlug } from "../utils/function_slug.js";
21
22
  import { readGitContext } from "../utils/git.js";
22
23
  import { resolveHookEnv, runDeployAfterHook, runDeployBeforeHook } from "../utils/hooks.js";
23
24
  import { pickServicesInteractively } from "../utils/service_picker.js";
@@ -39,10 +40,13 @@ var config_exports = /* @__PURE__ */ __exportAll({
39
40
  command: () => command,
40
41
  createBranchFromPolicyOnCheckout: () => createBranchFromPolicyOnCheckout,
41
42
  describe: () => describe,
43
+ ensureConfigPackages: () => ensureConfigPackages,
42
44
  envFlag: () => envFlag,
43
45
  envPullFlag: () => envPullFlag,
44
46
  handler: () => handler,
45
47
  initCmd: () => initCmd,
48
+ installCommandFrom: () => installCommandFrom,
49
+ missingDependencies: () => missingDependencies,
46
50
  planCmd: () => planCmd,
47
51
  status: () => status
48
52
  });
@@ -205,8 +209,8 @@ const seedFromBranch = async (props) => {
205
209
  };
206
210
  };
207
211
  /** The install command to print, prefixed with a `cd` when the project is not the cwd. */
208
- const installCommandFrom = (dir, pm, missing) => {
209
- const command = formatInstallCommand(pm, [...missing]);
212
+ const installCommandFrom = (dir, pm, missing, options) => {
213
+ const command = formatInstallCommand(pm, [...missing], options);
210
214
  const rel = relative(process.cwd(), dir);
211
215
  if (rel === "") return command;
212
216
  const target = rel.length <= dir.length ? rel : dir;
@@ -274,11 +278,7 @@ const initCmd = async (props) => {
274
278
  await ensureConfigPackages(cwd, props);
275
279
  if (!props.silent) log.info("Next: edit neon.ts, then run `neon config plan` to preview and `neon config apply`.");
276
280
  };
277
- /** The rule the platform enforces on function slugs, so a bad one fails before any write. */
278
- const FUNCTION_SLUG_PATTERN = /^[a-z0-9]{1,20}$/;
279
281
  const SCRIPT_EXTENSION = /\.(?:ts|mts|cts|js|mjs|cjs)$/;
280
- const TYPESCRIPT_EXTENSION = /\.(?:ts|mts|cts)$/;
281
- const asRelativeModulePath = (path) => path.startsWith("./") || path.startsWith("../") || path.startsWith("/") ? path : `./${path}`;
282
282
  const shellQuote = (value) => /^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`;
283
283
  /**
284
284
  * `explicitConfig` is the `--config` the user passed. The commands suggested afterwards must
@@ -322,7 +322,7 @@ const planAdd = (target, configPath, explicitConfig) => {
322
322
  };
323
323
  case "function": {
324
324
  if (!FUNCTION_SLUG_PATTERN.test(target.slug)) {
325
- const suggestion = target.slug.toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 20);
325
+ const suggestion = deriveFunctionSlug(target.slug);
326
326
  throw new Error(`"${target.slug}" is not a valid function slug: use 1-20 lowercase letters and digits, no hyphens.` + (suggestion ? ` Try "${suggestion}".` : ""));
327
327
  }
328
328
  if (target.source !== void 0 && !SCRIPT_EXTENSION.test(target.source)) throw new Error(`--source must be a .ts or .js file, got "${target.source}". Declare a directory source in neon.ts by hand.`);
@@ -900,7 +900,7 @@ const reportPushResult = (props, result, mode, services) => {
900
900
  out.text(`\nUtilized services: ${services.join(", ")}\n`);
901
901
  };
902
902
  /** A prompt needs both ends of a terminal, and would corrupt `-o json|yaml` on stdout. */
903
- const canPromptForApply = (props) => !isCi() && Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY) && props.output !== "json" && props.output !== "yaml";
903
+ const canPromptForApply = (props) => isInteractiveTerminal() && props.output !== "json" && props.output !== "yaml";
904
904
  const askToApply = async (context) => {
905
905
  const branch = JSON.stringify(context.branchName);
906
906
  const message = context.protectedBranch && context.overrideUpdates ? `Branch ${branch} is protected, and this overrides the settings shown above. Apply anyway?` : context.protectedBranch ? `Branch ${branch} is protected. Apply to it anyway?` : `Override the settings shown above on branch ${branch}?`;
@@ -1110,4 +1110,4 @@ const createBranchFromPolicyOnCheckout = async (props) => {
1110
1110
  }
1111
1111
  };
1112
1112
  //#endregion
1113
- export { ConfigInstallFailed, addCmd, applyCmd, applyFlags, applyPolicyOnCreate, builder, command, createBranchFromPolicyOnCheckout, describe, envFlag, envPullFlag, handler, initCmd, planCmd, status, config_exports as t };
1113
+ export { ConfigInstallFailed, addCmd, applyCmd, applyFlags, applyPolicyOnCreate, builder, command, createBranchFromPolicyOnCheckout, describe, ensureConfigPackages, envFlag, envPullFlag, handler, initCmd, installCommandFrom, missingDependencies, planCmd, status, config_exports as t };
@@ -4,12 +4,12 @@ import { isClaimableEnvTarget } from "../claimable/state.js";
4
4
  import { getCliName } from "../utils/cli_name.js";
5
5
  import { defaultDevEnvFiles, readEnvFile, resolveEnvFilePath } from "../env_file.js";
6
6
  import { l as functionBaseUrlKey } from "../_chunks/env-BpP3Dqpe.js";
7
- import { branchIdResolve } from "../utils/enrichers.js";
8
- import { bundleEntry } from "../utils/esbuild.js";
9
7
  import { a as resolveDevEnv } from "../_chunks/env-BNm69Qh8.js";
10
- import { envFlag } from "./config.js";
11
8
  import { findConfigFunctionBySource, resolveFunctionsFromConfig } from "../dev/functions.js";
12
9
  import { resolveWatchInputs } from "../dev/inputs.js";
10
+ import { branchIdResolve } from "../utils/enrichers.js";
11
+ import { bundleEntry } from "../utils/esbuild.js";
12
+ import { envFlag } from "./config.js";
13
13
  import { existsSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs";
14
14
  import { basename, dirname, join, relative, resolve } from "node:path";
15
15
  import { fileURLToPath } from "node:url";
@@ -7,9 +7,9 @@ import { deprecatedServiceMessage, parseServices, servicesFlagValue, servicesOpt
7
7
  import { mergeEnvFile, readEnvFile, resolveEnvFilePath } from "../env_file.js";
8
8
  import { t as NEON_ENV_VAR_KEYS, u as isFunctionBaseUrlKey } from "../_chunks/env-BpP3Dqpe.js";
9
9
  import { ENV_PULL_KEY_HELP, ENV_PULL_SERVICES, envKeysForSelection, envPullFlagValue, ownedEnvServiceKeys, parseEnvPullKeys } from "../env_services.js";
10
+ import { i as dropClaimableUnsupported, o as resolveNeonEnvVars } from "../_chunks/env-BNm69Qh8.js";
10
11
  import { fillSingleProject, resolveBranchRef } from "../utils/enrichers.js";
11
12
  import { warnAiGateway } from "../utils/ai_gateway_notice.js";
12
- import { i as dropClaimableUnsupported, o as resolveNeonEnvVars } from "../_chunks/env-BNm69Qh8.js";
13
13
  import { formatPulledEnv, pulledBranch } from "./env_output.js";
14
14
  import { existsSync } from "node:fs";
15
15
  import chalk from "chalk";
@@ -7,8 +7,10 @@ import { createDeployment, deleteFunction, getFunction, listFunctions } from "..
7
7
  import { writer } from "../writer.js";
8
8
  import { branchIdFromProps, fillSingleProject } from "../utils/enrichers.js";
9
9
  import { bundleEntry } from "../utils/esbuild.js";
10
+ import { FUNCTION_SLUG_HELP, FUNCTION_SLUG_PATTERN } from "../utils/function_slug.js";
10
11
  import { zipBundle } from "../utils/zip.js";
11
12
  import { DEPLOY_COMMANDS_EPILOGUE } from "./deploy_help.js";
13
+ import { handleNew, listTemplates } from "./functions_new.js";
12
14
  import { statSync } from "node:fs";
13
15
  import { dirname } from "node:path";
14
16
  import { FUNCTION_SOURCE_ENTRIES } from "@neon/config";
@@ -77,8 +79,6 @@ const writeDeploymentErrorSection = (out, dep) => {
77
79
  title: "Deployment error"
78
80
  });
79
81
  };
80
- const SLUG_PATTERN = /^[a-z0-9]{1,20}$/;
81
- const SLUG_HELP = "Use 1-20 lowercase letters and digits (no hyphens or other characters).";
82
82
  const POLL_INTERVAL_OVERRIDE_MS = Number(process.env.NEON_FUNCTIONS_POLL_INTERVAL_MS) || void 0;
83
83
  const FIRST_POLL_DELAY_MS = 200;
84
84
  const POLL_INTERVAL_MS = 2e3;
@@ -101,7 +101,54 @@ const builder = (argv) => argv.usage("$0 function <sub-command> [options]").opti
101
101
  describe: "Branch ID or name",
102
102
  type: "string"
103
103
  }
104
- }).middleware(fillSingleProject).command("deploy <slug>", "Deploy a function from a local directory", (yargs) => yargs.positional("slug", {
104
+ }).middleware(fillSingleProject).command("new [template]", "Create a function from a Function Registry template", (yargs) => yargs.strict().positional("template", {
105
+ describe: "Template ID. Omit it in a terminal to pick one; `neon functions templates list` shows them all.",
106
+ type: "string"
107
+ }).options({
108
+ "project-id": { hidden: true },
109
+ branch: { hidden: true },
110
+ slug: {
111
+ describe: "Function slug, also used as the directory name. A separate template keeps its own slugs and uses this only for the directory. Defaults to the template ID",
112
+ type: "string"
113
+ },
114
+ operation: {
115
+ describe: "Operation to include (repeatable). Without it, a TTY asks and anything else uses the recommended operations.",
116
+ type: "string",
117
+ array: true
118
+ },
119
+ "all-operations": {
120
+ describe: "Include every operation. Conflicts with --operation.",
121
+ type: "boolean"
122
+ },
123
+ yes: {
124
+ alias: ["y", "default"],
125
+ describe: "Skip prompts: use the recommended operations and install dependencies",
126
+ type: "boolean"
127
+ },
128
+ dir: {
129
+ describe: "Destination directory (defaults to functions/<slug> next to neon.ts)",
130
+ type: "string"
131
+ },
132
+ force: {
133
+ describe: "Overwrite differing files and keep an existing neon.ts declaration",
134
+ type: "boolean"
135
+ },
136
+ install: {
137
+ describe: "Install the template's dependencies, plus @neon/config and @neon/env when neon.ts is created. A TTY asks when omitted, --yes installs, and anything else leaves the install command in Next.",
138
+ type: "boolean"
139
+ },
140
+ "add-to-config": {
141
+ describe: "Declare the function in neon.ts, creating it when missing. On by default; use --no-add-to-config to skip.",
142
+ type: "boolean"
143
+ },
144
+ config: {
145
+ describe: "Path to the neon.ts to edit (defaults to the neon.ts next to .neon, or in cwd without one; created there when missing)",
146
+ type: "string"
147
+ }
148
+ }), (args) => handleNew(args)).command("templates", "Browse Function Registry templates", (yargs) => yargs.command("list", "List function templates", (yargs) => yargs.strict().options({
149
+ "project-id": { hidden: true },
150
+ branch: { hidden: true }
151
+ }), (args) => listTemplates(args))).command("deploy <slug>", "Deploy a function from a local directory", (yargs) => yargs.positional("slug", {
105
152
  describe: "Function slug (1-20 lowercase letters and digits)",
106
153
  type: "string",
107
154
  demandOption: true
@@ -200,7 +247,7 @@ const isTransient = (err) => isNeonApiError(err) && (err.status === void 0 || er
200
247
  const deploy = async (props) => {
201
248
  if (props.path !== void 0 || props.entry !== void 0) throw new Error(`--path and --entry were removed. Use --src <dir>; the entry point is discovered as ${FUNCTION_SOURCE_ENTRIES.join(", ")} in that directory.`);
202
249
  if (!(props.src !== void 0 || props.env !== void 0 || props.runtime !== void 0 || props.bundle === false)) throw new Error(`Provide at least one option to deploy, e.g. --src, --env, or --no-bundle. See: ${getCliName()} function deploy --help.`);
203
- if (!SLUG_PATTERN.test(props.slug)) throw new Error(`Invalid function slug "${props.slug}". ${SLUG_HELP}`);
250
+ if (!FUNCTION_SLUG_PATTERN.test(props.slug)) throw new Error(`Invalid function slug "${props.slug}". ${FUNCTION_SLUG_HELP}`);
204
251
  const src = props.src ?? ".";
205
252
  const runtime = props.runtime ?? "nodejs24";
206
253
  const environment = parseEnv(props.env);
@@ -392,7 +439,7 @@ const listDomains = async (props) => {
392
439
  });
393
440
  };
394
441
  const registerDomain = async (props) => {
395
- if (!SLUG_PATTERN.test(props.slug)) throw new Error(`Invalid function slug "${props.slug}". ${SLUG_HELP}`);
442
+ if (!FUNCTION_SLUG_PATTERN.test(props.slug)) throw new Error(`Invalid function slug "${props.slug}". ${FUNCTION_SLUG_HELP}`);
396
443
  const branchId = await branchIdFromProps(props);
397
444
  const registered = await retryOnLock(() => registerCustomDomain(props.apiClient, props.projectId, branchId, {
398
445
  domain: props.domain,
@@ -0,0 +1,332 @@
1
+ import { isInteractiveTerminal } from "../env.js";
2
+ import { log } from "../log.js";
3
+ import { getCliName } from "../utils/cli_name.js";
4
+ import { projectConfigPath, projectDir } from "../project.js";
5
+ import { resolveEnvFilePath } from "../env_file.js";
6
+ import { installArgs, resolvePackageManager, runCommand } from "../utils/package_manager.js";
7
+ import { writer } from "../writer.js";
8
+ import { formatInitDone, printInitDone } from "../init/chrome.js";
9
+ import { FUNCTION_SLUG_PATTERN, deriveFunctionSlug } from "../utils/function_slug.js";
10
+ import { displayPath } from "./env_output.js";
11
+ import { ensureConfigPackages, installCommandFrom, missingDependencies } from "./config.js";
12
+ import { recommendedOperations } from "../functions/registry.js";
13
+ import { collidingFiles, operationsFromFlags, planConfigRegistration, planEnvExample, planScaffold, writeScaffold } from "../functions/scaffold.js";
14
+ import { describeRegistrySource, openRegistry } from "../functions/source.js";
15
+ import { writeFileSync } from "node:fs";
16
+ import { dirname, join, resolve } from "node:path";
17
+ import prompts from "prompts";
18
+ //#region src/commands/functions_new.ts
19
+ const TEMPLATE_FIELDS = [
20
+ "id",
21
+ "title",
22
+ "provider",
23
+ "description"
24
+ ];
25
+ const NEW_FUNCTION_FIELDS = [
26
+ "template",
27
+ "layout",
28
+ "operations",
29
+ "routes",
30
+ "functions",
31
+ "directory",
32
+ "config",
33
+ "dependencies",
34
+ "installed",
35
+ "environment",
36
+ "env_example",
37
+ "next_steps"
38
+ ];
39
+ const structured = (props) => props.output === "json" || props.output === "yaml";
40
+ const providerOf = (item) => item.provider ?? "Neon";
41
+ const writeTemplates = (props, entries) => writer(props).end(entries.map((entry) => ({
42
+ id: entry.id,
43
+ title: entry.title,
44
+ provider: providerOf(entry),
45
+ description: entry.description
46
+ })), {
47
+ fields: TEMPLATE_FIELDS,
48
+ emptyMessage: "No function templates found."
49
+ });
50
+ const listTemplates = async (props) => {
51
+ writeTemplates(props, (await openRegistry()).entries);
52
+ };
53
+ const unknownTemplate = (id, registry) => /* @__PURE__ */ new Error(`Unknown function template "${id}" in ${describeRegistrySource(registry.source)}. Available templates: ${registry.entries.map((entry) => entry.id).join(", ")}.`);
54
+ const pickOperations = async (operations, provider) => {
55
+ const answer = await prompts({
56
+ type: "multiselect",
57
+ name: "value",
58
+ message: `Which ${provider} operations should this function expose?`,
59
+ hint: "Space to toggle. Enter to confirm.",
60
+ choices: operations.map((operation) => ({
61
+ title: operation.id,
62
+ description: operation.description,
63
+ value: operation.id,
64
+ selected: operation.recommended
65
+ })),
66
+ min: 1,
67
+ instructions: false
68
+ });
69
+ if (!Array.isArray(answer.value)) return void 0;
70
+ const chosen = new Set(answer.value);
71
+ return operations.filter((operation) => chosen.has(operation.id));
72
+ };
73
+ const confirm = async (message, initial) => {
74
+ return (await prompts({
75
+ type: "confirm",
76
+ name: "value",
77
+ message,
78
+ initial
79
+ })).value;
80
+ };
81
+ const assertSlug = (slug) => {
82
+ if (slug === void 0 || FUNCTION_SLUG_PATTERN.test(slug)) return;
83
+ const suggestion = deriveFunctionSlug(slug);
84
+ throw new Error(`"${slug}" is not a valid function slug: use 1-20 lowercase letters and digits, no hyphens.` + (suggestion ? ` Try "${suggestion}".` : ""));
85
+ };
86
+ const scaffoldFromTemplate = async (options) => {
87
+ assertSlug(options.slug);
88
+ const registry = options.registry ?? await openRegistry();
89
+ const entry = registry.entries.find(({ id }) => id === options.template);
90
+ if (!entry) throw unknownTemplate(options.template, registry);
91
+ const cwd = options.cwd ?? process.cwd();
92
+ const { interactive } = options;
93
+ const loaded = await registry.load(entry);
94
+ const { template } = loaded;
95
+ const provider = providerOf(template);
96
+ let operations = operationsFromFlags(template, {
97
+ operations: options.operations,
98
+ allOperations: options.allOperations
99
+ }) ?? recommendedOperations(template.operations ?? []);
100
+ if (interactive && options.operations === void 0 && options.allOperations === void 0 && (template.operations?.length ?? 0) > 1) {
101
+ const picked = await pickOperations(template.operations ?? [], provider);
102
+ if (picked === void 0) return { cancelled: true };
103
+ operations = picked;
104
+ }
105
+ const slug = options.slug ?? deriveFunctionSlug(template.id);
106
+ const plan = planScaffold(loaded, operations, slug);
107
+ const location = {
108
+ cwd,
109
+ ...options.contextFile ? { contextFile: options.contextFile } : {}
110
+ };
111
+ const configPath = options.config ? resolve(cwd, options.config) : projectConfigPath(location) ?? join(projectDir(location), "neon.ts");
112
+ const projectRoot = dirname(configPath);
113
+ const targetDir = options.dir ? resolve(cwd, options.dir) : join(projectRoot, "functions", slug);
114
+ const directory = displayPath(targetDir, cwd);
115
+ const collisions = collidingFiles(targetDir, plan.files);
116
+ if (collisions.length > 0 && options.force !== true) {
117
+ const list = collisions.join(", ");
118
+ if (!interactive || options.force === false) throw new Error(`${directory} already has different ${list}. Pass --force to overwrite, or --dir to scaffold elsewhere.`);
119
+ if (await confirm(`${directory} already has different ${list}. Overwrite?`, false) !== true) return { cancelled: true };
120
+ }
121
+ const register = options.addToConfig !== false;
122
+ const registration = register ? planConfigRegistration(configPath, targetDir, plan.functions) : void 0;
123
+ if (registration && registration.alreadyDeclared.length > 0 && !options.force) {
124
+ const declared = `${displayPath(configPath, cwd)} already declares ${registration.alreadyDeclared.join(", ")}.`;
125
+ throw new Error(plan.layout === "separate" ? `${declared} The template sets these slugs, so --slug cannot change them. Remove them from neon.ts, pass --force to keep the declarations and overwrite the files, or pass --no-add-to-config.` : `${declared} Pass --slug to pick another one, --force to keep the declaration and overwrite the files, or --no-add-to-config.`);
126
+ }
127
+ const envExample = planEnvExample(projectRoot, template.environment);
128
+ writeScaffold(targetDir, plan.files);
129
+ if (registration?.source !== void 0) writeFileSync(configPath, registration.source);
130
+ if (envExample.contents !== void 0) writeFileSync(envExample.path, envExample.contents);
131
+ const { dependencies } = template;
132
+ const pm = resolvePackageManager(projectRoot);
133
+ const installHint = (packages, exact = false) => installCommandFrom(projectRoot, pm, packages, { exact });
134
+ const configPackages = registration?.created ? missingDependencies(projectRoot) : [];
135
+ const packages = [...configPackages, ...dependencies];
136
+ let install = options.install === true;
137
+ if (options.install === void 0 && packages.length > 0 && interactive) install = await confirm(`Install ${packages.join(", ")} with ${pm}?`, true) === true;
138
+ if (configPackages.length > 0) await ensureConfigPackages(projectRoot, {
139
+ install,
140
+ packageManager: pm,
141
+ ...options.run ? { run: options.run } : {},
142
+ silent: true
143
+ });
144
+ let installed = false;
145
+ if (install && dependencies.length > 0) {
146
+ installed = await (options.run ?? runCommand)(pm, installArgs(pm, dependencies, { exact: true }), projectRoot);
147
+ if (!installed) log.warning("Could not install %s. Run by hand: `%s`", dependencies.join(", "), installHint(dependencies, true));
148
+ }
149
+ const cli = getCliName();
150
+ const functions = plan.functions.map((fn) => ({
151
+ slug: fn.slug,
152
+ name: fn.name,
153
+ ...fn.operation ? { operation: fn.operation } : {},
154
+ source: displayPath(join(targetDir, fn.file), cwd)
155
+ }));
156
+ const nextSteps = [];
157
+ const next = [];
158
+ const missingConfig = registration?.created ? missingDependencies(projectRoot) : [];
159
+ if (missingConfig.length > 0) {
160
+ nextSteps.push(`Install the Neon config packages: \`${installHint(missingConfig)}\``);
161
+ next.push(installHint(missingConfig));
162
+ }
163
+ if (dependencies.length > 0 && !installed) {
164
+ nextSteps.push(`Install the template's dependencies: \`${installHint(dependencies, true)}\``);
165
+ next.push(installHint(dependencies, true));
166
+ }
167
+ const environment = template.environment.map(({ name }) => name);
168
+ const variables = template.environment.map((variable) => `${variable.name} (${variable.description})`).join(", ");
169
+ const envFile = displayPath(resolveEnvFilePath(location), cwd);
170
+ const names = environment.join(", ");
171
+ if (variables && register) {
172
+ const them = environment.length === 1 ? "it" : "them";
173
+ nextSteps.push(`Set ${variables} in ${envFile}. \`${cli} dev\` reads ${them} from your shell, so export ${them} there too.`);
174
+ next.push(`Set ${names} in ${envFile}, and export ${them} for ${cli} dev`);
175
+ } else if (variables) {
176
+ nextSteps.push(`Set ${variables} in your shell.`);
177
+ next.push(`Set ${names} in your shell`);
178
+ }
179
+ if (plan.routes) nextSteps.push(`Routes: ${plan.routes.join(", ")}. Other paths return 404.`);
180
+ if (register) {
181
+ const flag = options.config ? ` --config ${options.config}` : "";
182
+ const envFlag = variables ? ` --env ${envFile}` : "";
183
+ nextSteps.push(`Next: \`${cli} dev${flag}\` to run it locally, \`${cli} config apply${flag}${envFlag}\` to deploy.`);
184
+ next.push(`${cli} dev${flag}`, `${cli} config apply${flag}${envFlag}`);
185
+ } else {
186
+ const envFlags = environment.map((name) => ` --env ${name}=<value>`).join("");
187
+ for (const fn of functions) {
188
+ nextSteps.push(`Next: \`${cli} dev --source ${fn.source}\` to run ${fn.slug} locally, \`${cli} functions deploy ${fn.slug} --src ${fn.source}${envFlags}\` to deploy.`);
189
+ next.push(`${cli} dev --source ${fn.source}`, `${cli} functions deploy ${fn.slug} --src ${fn.source}${envFlags}`);
190
+ }
191
+ }
192
+ return {
193
+ cancelled: false,
194
+ template: template.id,
195
+ title: template.title,
196
+ provider,
197
+ layout: plan.layout,
198
+ operations: operations.map(({ id }) => id),
199
+ ...plan.routes ? { routes: plan.routes } : {},
200
+ functions,
201
+ directory,
202
+ ...registration && registration.changes.length > 0 ? { config: {
203
+ path: displayPath(configPath, cwd),
204
+ action: registration.created ? "created" : "updated",
205
+ changes: registration.changes
206
+ } } : {},
207
+ dependencies: [...dependencies],
208
+ packages,
209
+ packageManager: pm,
210
+ installed,
211
+ install: packages.length === 0 || !install ? "skipped" : missingConfig.length > 0 || dependencies.length > 0 && !installed ? "failed" : "installed",
212
+ environment,
213
+ ...envExample.added.length > 0 ? { envExample: {
214
+ path: displayPath(envExample.path, cwd),
215
+ added: envExample.added
216
+ } } : {},
217
+ nextSteps,
218
+ next
219
+ };
220
+ };
221
+ const formatNewFunctionDone = (result) => {
222
+ const plural = result.functions.length !== 1;
223
+ const rows = [
224
+ {
225
+ label: "Template",
226
+ value: `${result.title} (${result.provider})`
227
+ },
228
+ {
229
+ label: plural ? "Functions" : "Function",
230
+ value: result.functions.map(({ slug }) => slug).join(", ")
231
+ },
232
+ {
233
+ label: "Directory",
234
+ value: result.directory
235
+ }
236
+ ];
237
+ if (result.routes) rows.push({
238
+ label: "Routes",
239
+ value: `${result.routes.join(", ")} (other paths return 404)`
240
+ });
241
+ rows.push({
242
+ label: "Config",
243
+ value: result.config ? `${result.config.path} ${result.config.action}: ${result.config.changes.join(", ")}` : "not added"
244
+ });
245
+ if (result.packages.length > 0) rows.push({
246
+ label: "Dependencies",
247
+ value: result.install === "failed" ? "install failed" : result.install === "installed" ? `installed with ${result.packageManager}` : "not installed"
248
+ });
249
+ if (result.envExample) rows.push({
250
+ label: "Env",
251
+ value: `${result.envExample.added.join(", ")} added to ${result.envExample.path}`
252
+ });
253
+ else if (result.environment.length > 0) rows.push({
254
+ label: "Env",
255
+ value: result.environment.join(", ")
256
+ });
257
+ return formatInitDone({
258
+ heading: result.install === "failed" ? "Setup did not finish." : plural ? "Functions created." : "Function created.",
259
+ rows,
260
+ next: result.next
261
+ });
262
+ };
263
+ const pickTemplate = async (entries) => {
264
+ const answer = await prompts({
265
+ type: "select",
266
+ name: "value",
267
+ message: "Which template would you like to use?",
268
+ choices: entries.map((entry) => ({
269
+ title: `${entry.title} (${providerOf(entry)})`,
270
+ description: entry.description,
271
+ value: entry.id
272
+ })),
273
+ initial: 0
274
+ });
275
+ return typeof answer.value === "string" ? answer.value : void 0;
276
+ };
277
+ const handleNew = async (props) => {
278
+ const interactive = props.yes !== true && !structured(props) && isInteractiveTerminal();
279
+ assertSlug(props.slug);
280
+ const registry = await openRegistry();
281
+ let template = props.template;
282
+ if (template === void 0) {
283
+ if (!interactive) throw new Error(`No template selected. Re-run in an interactive terminal to pick one, or pass a template ID. Available templates: ${registry.entries.map((entry) => entry.id).join(", ")}.`);
284
+ template = await pickTemplate(registry.entries);
285
+ if (template === void 0) {
286
+ log.info("Cancelled; no files were changed.");
287
+ return;
288
+ }
289
+ }
290
+ if (!structured(props)) log.info("Fetching template \"%s\" from the Function Registry…", template);
291
+ const result = await scaffoldFromTemplate({
292
+ template,
293
+ slug: props.slug,
294
+ operations: props.operation,
295
+ allOperations: props.allOperations,
296
+ dir: props.dir,
297
+ force: props.force,
298
+ install: props.install ?? (props.yes === true ? true : void 0),
299
+ addToConfig: props.addToConfig,
300
+ config: props.config,
301
+ contextFile: props.contextFile,
302
+ registry,
303
+ interactive
304
+ });
305
+ if (result.cancelled) {
306
+ log.info("Cancelled; no files were changed.");
307
+ return;
308
+ }
309
+ if (structured(props)) {
310
+ writer(props).end({
311
+ template: result.template,
312
+ layout: result.layout,
313
+ operations: result.operations,
314
+ routes: result.routes ?? null,
315
+ functions: result.functions,
316
+ directory: result.directory,
317
+ config: result.config ? {
318
+ path: result.config.path,
319
+ action: result.config.action
320
+ } : null,
321
+ dependencies: result.dependencies,
322
+ installed: result.installed,
323
+ environment: result.environment,
324
+ env_example: result.envExample ?? null,
325
+ next_steps: result.nextSteps
326
+ }, { fields: NEW_FUNCTION_FIELDS });
327
+ return;
328
+ }
329
+ printInitDone(formatNewFunctionDone(result));
330
+ };
331
+ //#endregion
332
+ export { formatNewFunctionDone, handleNew, listTemplates, scaffoldFromTemplate };
@@ -1,5 +1,5 @@
1
1
  import { t as __exportAll } from "../_chunks/rolldown-runtime-8H4AJuhK.js";
2
- import { isCi } from "../env.js";
2
+ import { isInteractiveTerminal } from "../env.js";
3
3
  import { log } from "../log.js";
4
4
  import { isNeonApiError, messageFromBody } from "../api.js";
5
5
  import { r as defaultDir } from "../_chunks/paths-DMq0Lt7a.js";
@@ -30,7 +30,7 @@ var link_exports = /* @__PURE__ */ __exportAll({
30
30
  });
31
31
  const PROJECTS_LIST_LIMIT = 100;
32
32
  const CREATE_NEW_SENTINEL = "__create_new__";
33
- const canPromptInteractively = () => !isCi() && Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
33
+ const canPromptInteractively = () => isInteractiveTerminal();
34
34
  const nonInteractiveLinkCommands = () => {
35
35
  const cli = getCliName();
36
36
  return [
@@ -1,6 +1,6 @@
1
1
  import { writer } from "../writer.js";
2
- import { branchIdFromProps } from "../utils/enrichers.js";
3
2
  import { o as resolveNeonEnvVars } from "../_chunks/env-BNm69Qh8.js";
3
+ import { branchIdFromProps } from "../utils/enrichers.js";
4
4
  import YAML from "yaml";
5
5
  //#region src/commands/subscribe.ts
6
6
  const builder = (argv) => argv.usage("$0 realtime subscribe \"<query>\" [options]").positional("query", {
@@ -9,6 +9,8 @@ import { renderKey } from "./config_template.js";
9
9
  * file already declares it (`changes` is empty). A file whose layout the scanner cannot
10
10
  * place an edit into is refused with the exact lines to add by hand.
11
11
  */
12
+ const TYPESCRIPT_EXTENSION = /\.(?:ts|mts|cts)$/;
13
+ const asRelativeModulePath = (path) => path.startsWith("./") || path.startsWith("../") || path.startsWith("/") ? path : `./${path}`;
12
14
  var ConfigEditError = class extends Error {
13
15
  constructor(message) {
14
16
  super(message);
@@ -262,9 +264,12 @@ const apply = (source, edit) => {
262
264
  run((parsed) => setToggle(parsed, "dataApi"));
263
265
  }
264
266
  break;
265
- case "function":
266
- run((parsed) => addNamed(parsed, "functions", edit.slug, [`${renderKey(edit.slug)}: { name: ${JSON.stringify(edit.name)}, source: ${JSON.stringify(edit.source)} },`]));
267
+ case "function": {
268
+ const assertion = edit.typescript === false ? "" : "!";
269
+ const env = edit.env?.length ? `, env: { ${edit.env.map((name) => `${renderKey(name)}: process.env.${name}${assertion}`).join(", ")} }` : "";
270
+ run((parsed) => addNamed(parsed, "functions", edit.slug, [`${renderKey(edit.slug)}: { name: ${JSON.stringify(edit.name)}, source: ${JSON.stringify(edit.source)}${env} },`]));
267
271
  break;
272
+ }
268
273
  case "bucket": run((parsed) => addNamed(parsed, "buckets", edit.name, [`${renderKey(edit.name)}: { access: ${JSON.stringify(edit.access)} },`]));
269
274
  }
270
275
  return {
@@ -283,4 +288,4 @@ const editNeonConfig = (source, edit) => {
283
288
  return result;
284
289
  };
285
290
  //#endregion
286
- export { ConfigEditError, editNeonConfig };
291
+ export { ConfigEditError, TYPESCRIPT_EXTENSION, asRelativeModulePath, editNeonConfig };
package/dist/context.js CHANGED
@@ -41,6 +41,7 @@ const isConfigInit = (args) => args._[0] === "config" && args._[1] === "init" &&
41
41
  * middleware and the single-project resolver skip it, like {@link isConfigInit}.
42
42
  */
43
43
  const isConfigAdd = (args) => args._[0] === "config" && args._[1] === "add";
44
+ const isFunctionsTemplateCommand = (args) => (args._[0] === "functions" || args._[0] === "function") && (args._[1] === "new" || args._[1] === "templates");
44
45
  /**
45
46
  * `neon profile …` manages credentials on disk and never calls the Neon API, so the global
46
47
  * auth middleware must skip it — mirroring {@link isConfigInit}.
@@ -359,4 +360,4 @@ const globToRegExp = (pattern) => {
359
360
  return new RegExp(`^${source}$`);
360
361
  };
361
362
  //#endregion
362
- export { CONTEXT_FILE, applyContext, contextBranch, currentContextFile, enrichFromContext, ensureDirectoryGitignored, ensureGitignored, gitBranchMap, gitBranchMapping, isApiKeysCommand, isAskCommand, isClaimCommand, isConfigAdd, isConfigInit, isCurrentBranchProbe, isDocsCommand, isFeedbackCommand, isGitLocalCommand, isInspectDbUrl, isMcpCommand, isMcpOauth, isPluginsCommand, isProfileCommand, isSkillsCommand, isUnfollowedGitHookSync, readContextFile, setContext, setGitBranchMap, setGitBranchMapping, setGitFollow, updateContextFile, walkContextFile };
363
+ export { CONTEXT_FILE, applyContext, contextBranch, currentContextFile, enrichFromContext, ensureDirectoryGitignored, ensureGitignored, gitBranchMap, gitBranchMapping, isApiKeysCommand, isAskCommand, isClaimCommand, isConfigAdd, isConfigInit, isCurrentBranchProbe, isDocsCommand, isFeedbackCommand, isFunctionsTemplateCommand, isGitLocalCommand, isInspectDbUrl, isMcpCommand, isMcpOauth, isPluginsCommand, isProfileCommand, isSkillsCommand, isUnfollowedGitHookSync, readContextFile, setContext, setGitBranchMap, setGitBranchMapping, setGitFollow, updateContextFile, walkContextFile };
package/dist/env.js CHANGED
@@ -2,6 +2,7 @@
2
2
  const isCi = () => {
3
3
  return process.env.CI !== "false" && Boolean(process.env.CI);
4
4
  };
5
+ const isInteractiveTerminal = () => !isCi() && Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
5
6
  const isDebug = () => {
6
7
  return Boolean(process.env.DEBUG);
7
8
  };
@@ -38,4 +39,4 @@ const getGithubEnvVars = (env) => {
38
39
  return Object.fromEntries(map);
39
40
  };
40
41
  //#endregion
41
- export { getCliAgent, getGithubEnvVars, isCi, isDebug };
42
+ export { getCliAgent, getGithubEnvVars, isCi, isDebug, isInteractiveTerminal };
@@ -0,0 +1,174 @@
1
+ import { FUNCTION_SLUG_PATTERN } from "../utils/function_slug.js";
2
+ //#region src/functions/registry.ts
3
+ const MAX_PATH_LENGTH = 255;
4
+ const MAX_INDEX_ENTRIES = 500;
5
+ const MAX_OPERATIONS = 40;
6
+ const MAX_LOGO_URL_LENGTH = 2048;
7
+ const MAX_FILE_BYTES = 262144;
8
+ const MAX_TOTAL_BYTES = 2097152;
9
+ const MAX_JSON_BYTES = 1048576;
10
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
11
+ const isText = (value) => typeof value === "string" && value.trim() !== "";
12
+ const matches = (value, pattern) => typeof value === "string" && pattern.test(value);
13
+ const isOptionalString = (value) => value === void 0 || typeof value === "string";
14
+ const TEMPLATE_ID = /^[a-z0-9][a-z0-9-]*$/;
15
+ const OPERATION_ID = /^[a-z0-9][a-z0-9-]*$/;
16
+ const OPERATION_SLUG = FUNCTION_SLUG_PATTERN;
17
+ const ENV_NAME = /^[A-Z_][A-Z0-9_]*$/;
18
+ const SINGLE_LINE = /^[^\r\n]*$/;
19
+ const WINDOWS_ABSOLUTE = /^[A-Za-z]:/;
20
+ const SOURCE_EXTENSION = /\.ts$/;
21
+ const ROUTE_PATTERN = /^\/[A-Za-z0-9\-._~/]*$/;
22
+ const EXACT_DEPENDENCY = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*@\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
23
+ const TEMPLATE_LAYOUTS = ["router", "separate"];
24
+ const REGISTRY_CONTRACT = {
25
+ layouts: TEMPLATE_LAYOUTS,
26
+ requiredTemplateFields: [
27
+ "id",
28
+ "title",
29
+ "description",
30
+ "layout"
31
+ ],
32
+ requiredOperationFields: [
33
+ "id",
34
+ "title",
35
+ "description",
36
+ "source",
37
+ "recommended"
38
+ ],
39
+ templateId: TEMPLATE_ID,
40
+ operationId: OPERATION_ID,
41
+ operationSlug: OPERATION_SLUG,
42
+ envName: ENV_NAME,
43
+ envDescription: SINGLE_LINE,
44
+ source: SOURCE_EXTENSION,
45
+ route: ROUTE_PATTERN,
46
+ dependency: EXACT_DEPENDENCY,
47
+ maxOperations: MAX_OPERATIONS,
48
+ maxIndexEntries: MAX_INDEX_ENTRIES,
49
+ maxFileBytes: MAX_FILE_BYTES,
50
+ maxJsonBytes: MAX_JSON_BYTES
51
+ };
52
+ const isContainedRelativePath = (value) => value !== "" && value.length <= MAX_PATH_LENGTH && !value.includes("\0") && !value.includes("\\") && !value.startsWith("/") && !WINDOWS_ABSOLUTE.test(value) && !value.split("/").some((part) => part === "" || part === "." || part === "..");
53
+ const isSafeSourcePath = (value) => isContainedRelativePath(value) && SOURCE_EXTENSION.test(value);
54
+ const isDisplayLogoUrl = (value) => {
55
+ if (value.length > MAX_LOGO_URL_LENGTH || !URL.canParse(value)) return false;
56
+ return new URL(value).protocol === "https:";
57
+ };
58
+ const stringList = (value) => {
59
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) return;
60
+ return value;
61
+ };
62
+ const parseEnvironment = (value) => {
63
+ if (!Array.isArray(value)) return void 0;
64
+ const result = [];
65
+ for (const item of value) {
66
+ if (!isRecord(item) || typeof item.name !== "string" || typeof item.description !== "string" || !ENV_NAME.test(item.name) || !SINGLE_LINE.test(item.description)) return;
67
+ result.push({
68
+ name: item.name,
69
+ description: item.description
70
+ });
71
+ }
72
+ return result;
73
+ };
74
+ const parseTemplateOperations = (value, layout) => {
75
+ if (!Array.isArray(value) || value.length === 0) return void 0;
76
+ if (value.length > MAX_OPERATIONS) return void 0;
77
+ const operations = [];
78
+ const ids = /* @__PURE__ */ new Set();
79
+ const routes = /* @__PURE__ */ new Set();
80
+ const slugs = /* @__PURE__ */ new Set();
81
+ const sources = /* @__PURE__ */ new Set();
82
+ let recommended = 0;
83
+ for (const item of value) {
84
+ if (!isRecord(item) || !matches(item.id, OPERATION_ID) || ids.has(item.id) || !isText(item.title) || !isText(item.description) || typeof item.source !== "string" || !isSafeSourcePath(item.source) || sources.has(item.source) || !isOptionalString(item.route) || typeof item.recommended !== "boolean") return;
85
+ const operation = {
86
+ id: item.id,
87
+ title: item.title,
88
+ description: item.description,
89
+ source: item.source,
90
+ recommended: item.recommended
91
+ };
92
+ if (layout === "separate") {
93
+ if (typeof item.slug !== "string" || !OPERATION_SLUG.test(item.slug) || slugs.has(item.slug)) return;
94
+ slugs.add(item.slug);
95
+ operation.slug = item.slug;
96
+ } else {
97
+ const route = item.route === void 0 ? `/${item.id}` : item.route;
98
+ if (!ROUTE_PATTERN.test(route) || routes.has(route)) return;
99
+ routes.add(route);
100
+ operation.route = route;
101
+ }
102
+ if (item.recommended) recommended++;
103
+ ids.add(item.id);
104
+ sources.add(item.source);
105
+ operations.push(operation);
106
+ }
107
+ if (recommended < 1) return void 0;
108
+ return operations;
109
+ };
110
+ const recommendedOperations = (operations) => operations.filter((operation) => operation.recommended);
111
+ const resolveOperationById = (operations, id) => {
112
+ const operation = operations.find((candidate) => candidate.id === id);
113
+ if (!operation) throw new Error(`Unknown operation "${id}". Available operations: ${operations.map((candidate) => candidate.id).join(", ")}.`);
114
+ return operation;
115
+ };
116
+ const parseTemplateItem = (text, expectedId) => {
117
+ const data = JSON.parse(text);
118
+ if (!isRecord(data)) throw new Error("Invalid template.json: expected a JSON object.");
119
+ if (!matches(data.id, TEMPLATE_ID) || expectedId !== void 0 && data.id !== expectedId) throw new Error("Invalid template.json: bad or mismatched id.");
120
+ if (!isText(data.title) || !isText(data.description)) throw new Error(`Invalid template.json for "${data.id}": missing title or description.`);
121
+ if (!isOptionalString(data.provider)) throw new Error(`Invalid template.json for "${data.id}": provider must be a string.`);
122
+ if (typeof data.layout !== "string" || !TEMPLATE_LAYOUTS.includes(data.layout)) throw new Error(`Invalid template.json for "${data.id}": layout must be one of ${TEMPLATE_LAYOUTS.join(", ")}.`);
123
+ const layout = data.layout;
124
+ const dependencies = stringList(data.dependencies ?? []);
125
+ if (dependencies === void 0 || !dependencies.every((dependency) => EXACT_DEPENDENCY.test(dependency))) throw new Error(`Invalid template.json for "${data.id}": dependencies must be exact name@version pins.`);
126
+ const environment = parseEnvironment(data.environment ?? []);
127
+ if (environment === void 0) throw new Error(`Invalid template.json for "${data.id}": environment names must be UPPER_SNAKE with a one-line description.`);
128
+ let operations;
129
+ if (data.operations !== void 0) {
130
+ operations = parseTemplateOperations(data.operations, layout);
131
+ if (operations === void 0) throw new Error(`Invalid template.json for "${data.id}": malformed operations.`);
132
+ }
133
+ return {
134
+ id: data.id,
135
+ ...data.provider ? { provider: data.provider } : {},
136
+ title: data.title,
137
+ description: data.description,
138
+ layout,
139
+ dependencies,
140
+ environment,
141
+ ...operations ? { operations } : {}
142
+ };
143
+ };
144
+ const parseRegistryIndex = (text) => {
145
+ const data = JSON.parse(text);
146
+ const items = isRecord(data) ? data.templates : void 0;
147
+ if (!Array.isArray(items)) throw new Error("Invalid function registry: missing \"templates\" array.");
148
+ if (items.length > MAX_INDEX_ENTRIES) throw new Error(`Invalid function registry: ${items.length} templates, more than the ${MAX_INDEX_ENTRIES} the CLI reads.`);
149
+ const entries = [];
150
+ const seen = /* @__PURE__ */ new Set();
151
+ for (const [index, item] of items.entries()) {
152
+ const at = `templates[${index}]${isRecord(item) && typeof item.id === "string" ? ` ("${item.id}")` : ""}`;
153
+ const invalid = (reason) => /* @__PURE__ */ new Error(`Invalid function registry: ${at} ${reason}.`);
154
+ if (!isRecord(item)) throw invalid("is not an object");
155
+ if (!matches(item.id, TEMPLATE_ID)) throw invalid("has a bad id");
156
+ if (seen.has(item.id)) throw invalid("repeats an id");
157
+ if (!isText(item.title) || !isText(item.description)) throw invalid("is missing a title or description");
158
+ if (!isOptionalString(item.provider)) throw invalid("has a provider that is not a string");
159
+ if (typeof item.path !== "string" || !isContainedRelativePath(item.path)) throw invalid("has a missing or unsafe path");
160
+ if (item.logo !== void 0 && (typeof item.logo !== "string" || !isDisplayLogoUrl(item.logo))) throw invalid("has a logo that is not an https:// URL");
161
+ seen.add(item.id);
162
+ entries.push({
163
+ id: item.id,
164
+ ...item.provider ? { provider: item.provider } : {},
165
+ title: item.title,
166
+ description: item.description,
167
+ path: item.path,
168
+ ...item.logo ? { logo: item.logo } : {}
169
+ });
170
+ }
171
+ return entries;
172
+ };
173
+ //#endregion
174
+ export { MAX_FILE_BYTES, MAX_JSON_BYTES, MAX_TOTAL_BYTES, REGISTRY_CONTRACT, TEMPLATE_LAYOUTS, isContainedRelativePath, parseEnvironment, parseRegistryIndex, parseTemplateItem, parseTemplateOperations, recommendedOperations, resolveOperationById };
@@ -0,0 +1,149 @@
1
+ import { renderNeonConfig } from "../config_template.js";
2
+ import { TYPESCRIPT_EXTENSION, asRelativeModulePath, editNeonConfig } from "../config_edit.js";
3
+ import { readEnvFile } from "../env_file.js";
4
+ import { FUNCTION_SLUG_HELP, FUNCTION_SLUG_PATTERN, deriveFunctionSlug } from "../utils/function_slug.js";
5
+ import { resolveOperationById } from "./registry.js";
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { dirname, join, relative, resolve } from "node:path";
8
+ //#region src/functions/scaffold.ts
9
+ const operationsFromFlags = (template, choice) => {
10
+ const operations = template.operations ?? [];
11
+ const explicit = choice.operations ?? [];
12
+ if (choice.allOperations && explicit.length > 0) throw new Error("Pass either --all-operations or --operation, not both.");
13
+ if (operations.length === 0 && (choice.allOperations || explicit.length > 0)) throw new Error(`Template "${template.id}" has no operations. Remove --operation and --all-operations.`);
14
+ if (choice.allOperations) return [...operations];
15
+ if (explicit.length > 0) return [...new Set(explicit)].map((id) => resolveOperationById(operations, id));
16
+ };
17
+ const routeOf = (operation) => operation.route ?? `/${operation.id}`;
18
+ const routerSource = (operations) => {
19
+ return `${operations.map((operation, index) => `import op${index} from "./${operation.id}";`).join("\n")}
20
+
21
+ const routes: Record<
22
+ \tstring,
23
+ \t{ fetch: (request: Request) => Response | Promise<Response> }
24
+ > = {
25
+ ${operations.map((operation, index) => `\t${JSON.stringify(routeOf(operation))}: op${index},`).join("\n")}
26
+ };
27
+
28
+ export default {
29
+ \tfetch(request: Request): Response | Promise<Response> {
30
+ \t\tconst { pathname } = new URL(request.url);
31
+ \t\tconst route = routes[pathname];
32
+ \t\tif (!route) {
33
+ \t\t\treturn Response.json(
34
+ \t\t\t\t{ error: "Not found", routes: Object.keys(routes) },
35
+ \t\t\t\t{ status: 404 },
36
+ \t\t\t);
37
+ \t\t}
38
+ \t\treturn route.fetch(request);
39
+ \t},
40
+ };
41
+ `;
42
+ };
43
+ const sourceOf = (loaded, path) => {
44
+ const text = loaded.sources.get(path);
45
+ if (text === void 0) throw new Error(`Template "${loaded.template.id}" did not load ${path}.`);
46
+ return text;
47
+ };
48
+ const planScaffold = (loaded, operations, slug) => {
49
+ const { template } = loaded;
50
+ const env = template.environment.map(({ name }) => name);
51
+ if (!FUNCTION_SLUG_PATTERN.test(slug)) throw new Error(`Invalid function slug "${slug}". ${FUNCTION_SLUG_HELP}`);
52
+ if ((template.operations?.length ?? 0) === 0) return {
53
+ layout: template.layout,
54
+ files: [{
55
+ path: "index.ts",
56
+ contents: sourceOf(loaded, "functions/index.ts")
57
+ }],
58
+ functions: [{
59
+ slug,
60
+ name: template.title,
61
+ file: "index.ts",
62
+ env
63
+ }]
64
+ };
65
+ if (operations.length === 0) throw new Error("Select at least one operation.");
66
+ const modules = operations.map((operation) => ({
67
+ path: `${operation.id}.ts`,
68
+ contents: sourceOf(loaded, operation.source)
69
+ }));
70
+ if (template.layout === "separate") return {
71
+ layout: "separate",
72
+ files: modules,
73
+ functions: operations.map((operation) => ({
74
+ slug: operation.slug ?? deriveFunctionSlug(operation.id),
75
+ name: operation.title,
76
+ operation: operation.id,
77
+ file: `${operation.id}.ts`,
78
+ env
79
+ }))
80
+ };
81
+ return {
82
+ layout: "router",
83
+ files: [...modules, {
84
+ path: "index.ts",
85
+ contents: routerSource(operations)
86
+ }],
87
+ functions: [{
88
+ slug,
89
+ name: template.title,
90
+ file: "index.ts",
91
+ env
92
+ }],
93
+ routes: operations.map(routeOf)
94
+ };
95
+ };
96
+ const collidingFiles = (targetDir, files) => files.filter(({ path, contents }) => {
97
+ const target = join(targetDir, path);
98
+ return existsSync(target) && readFileSync(target, "utf8") !== contents;
99
+ }).map(({ path }) => path);
100
+ const writeScaffold = (targetDir, files) => {
101
+ mkdirSync(targetDir, { recursive: true });
102
+ for (const { path, contents } of files) writeFileSync(join(targetDir, path), contents);
103
+ };
104
+ const planConfigRegistration = (configPath, targetDir, functions) => {
105
+ const created = !existsSync(configPath);
106
+ let source = created ? renderNeonConfig([]) : readFileSync(configPath, "utf8");
107
+ const changes = [];
108
+ const alreadyDeclared = [];
109
+ for (const fn of functions) {
110
+ const result = editNeonConfig(source, {
111
+ kind: "function",
112
+ slug: fn.slug,
113
+ name: fn.name,
114
+ source: asRelativeModulePath(relative(dirname(configPath), resolve(targetDir, fn.file)).split("\\").join("/")),
115
+ env: fn.env,
116
+ typescript: TYPESCRIPT_EXTENSION.test(configPath)
117
+ });
118
+ if (result.changes.length === 0) {
119
+ alreadyDeclared.push(fn.slug);
120
+ continue;
121
+ }
122
+ source = result.source;
123
+ changes.push(...result.changes);
124
+ }
125
+ return {
126
+ ...created || changes.length > 0 ? { source } : {},
127
+ created,
128
+ changes,
129
+ alreadyDeclared
130
+ };
131
+ };
132
+ const planEnvExample = (projectRoot, environment) => {
133
+ const path = join(projectRoot, ".env.example");
134
+ const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
135
+ const declared = new Set(Object.keys(existing ? readEnvFile(path) : {}));
136
+ const missing = environment.filter(({ name }) => !declared.has(name));
137
+ if (missing.length === 0) return {
138
+ path,
139
+ added: []
140
+ };
141
+ const block = missing.map(({ name, description }) => `# ${description}\n${name}=\n`).join("\n");
142
+ return {
143
+ path,
144
+ contents: `${existing}${existing === "" ? "" : existing.endsWith("\n") ? "\n" : "\n\n"}${block}`,
145
+ added: missing.map(({ name }) => name)
146
+ };
147
+ };
148
+ //#endregion
149
+ export { collidingFiles, operationsFromFlags, planConfigRegistration, planEnvExample, planScaffold, writeScaffold };
@@ -0,0 +1,141 @@
1
+ import { MAX_FILE_BYTES, MAX_JSON_BYTES, MAX_TOTAL_BYTES, isContainedRelativePath, parseRegistryIndex, parseTemplateItem } from "./registry.js";
2
+ import { readFileSync, realpathSync, statSync } from "node:fs";
3
+ import { posix, resolve, sep } from "node:path";
4
+ //#region src/functions/source.ts
5
+ const DEFAULT_REGISTRY_URL = "https://neon.com/functions/registry/registry.json";
6
+ const GITHUB_REGISTRY_URL = "https://raw.githubusercontent.com/neondatabase/examples/main/registry/registry.json";
7
+ const REGISTRY_DIR_ENV = "NEON_FUNCTION_REGISTRY_DIR";
8
+ const REGISTRY_URL_ENV = "NEON_FUNCTION_REGISTRY_URL";
9
+ const REGISTRY_TIMEOUT_MS = 1e4;
10
+ const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
11
+ "localhost",
12
+ "127.0.0.1",
13
+ "[::1]"
14
+ ]);
15
+ var RegistryError = class extends Error {
16
+ constructor(message, status) {
17
+ super(message);
18
+ this.name = "RegistryError";
19
+ if (status !== void 0) this.status = status;
20
+ }
21
+ };
22
+ const resolveRegistrySource = (env = process.env, cwd = process.cwd(), defaultUrl = DEFAULT_REGISTRY_URL) => {
23
+ const dir = env[REGISTRY_DIR_ENV];
24
+ if (dir) return {
25
+ kind: "dir",
26
+ dir: resolve(cwd, dir)
27
+ };
28
+ const raw = env["NEON_FUNCTION_REGISTRY_URL"] || defaultUrl;
29
+ if (!URL.canParse(raw)) throw new RegistryError(`${REGISTRY_URL_ENV} is not a URL: ${raw}`);
30
+ const url = new URL(raw);
31
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname))) throw new RegistryError(`${REGISTRY_URL_ENV} must be an https:// URL, got ${raw}`);
32
+ return {
33
+ kind: "url",
34
+ url
35
+ };
36
+ };
37
+ const describeRegistrySource = (source) => source.kind === "dir" ? source.dir : source.url.href;
38
+ const tooLarge = (path, limit) => new RegistryError(`Registry file ${path} is larger than the ${limit}-byte limit.`);
39
+ const dirReader = (dir) => {
40
+ let root;
41
+ return async (relativePath, limit) => {
42
+ try {
43
+ root ??= realpathSync(dir);
44
+ } catch {
45
+ throw new RegistryError(`${REGISTRY_DIR_ENV} points at ${dir}, which does not exist.`);
46
+ }
47
+ let path;
48
+ try {
49
+ path = realpathSync(resolve(root, relativePath));
50
+ } catch {
51
+ throw new RegistryError(`Registry file ${relativePath} not found in ${dir}.`);
52
+ }
53
+ if (!path.startsWith(root + sep)) throw new RegistryError(`Registry file ${relativePath} resolves outside ${dir}.`);
54
+ if (statSync(path).size > limit) throw tooLarge(relativePath, limit);
55
+ return readFileSync(path, "utf8");
56
+ };
57
+ };
58
+ const urlReader = (index) => {
59
+ const base = new URL(".", index);
60
+ return async (relativePath, limit) => {
61
+ const url = new URL(relativePath, base);
62
+ if (url.origin !== base.origin || !url.pathname.startsWith(base.pathname)) throw new RegistryError(`Registry file ${relativePath} resolves outside ${base.href}.`);
63
+ let response;
64
+ try {
65
+ response = await fetch(url, {
66
+ redirect: "error",
67
+ signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS)
68
+ });
69
+ } catch {
70
+ throw new RegistryError(`Could not reach the Function Registry at ${url.href}. Check your connection, or set ${REGISTRY_DIR_ENV} to a local copy.`);
71
+ }
72
+ if (!response.ok) throw new RegistryError(`The Function Registry answered ${response.status} for ${url.href}.`, response.status);
73
+ if (Number(response.headers.get("content-length")) > limit) throw tooLarge(relativePath, limit);
74
+ const text = await response.text();
75
+ if (Buffer.byteLength(text, "utf8") > limit) throw tooLarge(relativePath, limit);
76
+ return text;
77
+ };
78
+ };
79
+ const templateSourcePaths = (template) => template.operations?.map((operation) => operation.source) ?? ["functions/index.ts"];
80
+ const INDEX_FILE = "registry.json";
81
+ const openRegistry = async (source, { primary = DEFAULT_REGISTRY_URL, fallback = GITHUB_REGISTRY_URL, env = process.env } = {}) => {
82
+ if (source) return openRegistryAt(source);
83
+ const resolved = resolveRegistrySource(env, process.cwd(), primary);
84
+ if (resolved.kind !== "url" || resolved.url.href !== new URL(primary).href) return openRegistryAt(resolved);
85
+ const github = {
86
+ kind: "url",
87
+ url: new URL(fallback)
88
+ };
89
+ try {
90
+ return await openRegistryAt(resolved, github);
91
+ } catch (error) {
92
+ if (!(error instanceof RegistryError)) throw error;
93
+ return openRegistryAt(github);
94
+ }
95
+ };
96
+ const readerFor = (source) => source.kind === "dir" ? dirReader(source.dir) : urlReader(source.url);
97
+ const withStaleIndexHint = (read, index, files) => async (path, limit) => {
98
+ try {
99
+ return await read(path, limit);
100
+ } catch (error) {
101
+ if (!(error instanceof RegistryError) || error.status !== 404) throw error;
102
+ throw new RegistryError(`${error.message} The index from ${describeRegistrySource(index)} is cached and may still list a template that moved or was removed. Try again in a few minutes, or set ${REGISTRY_URL_ENV}=${describeRegistrySource(files)} to read the current index.`, 404);
103
+ }
104
+ };
105
+ const openRegistryAt = async (source, files = source) => {
106
+ const read = readerFor(source);
107
+ const readFile = files === source ? read : withStaleIndexHint(readerFor(files), source, files);
108
+ const indexPath = source.kind === "dir" ? INDEX_FILE : posix.basename(source.url.pathname);
109
+ const parse = (path, text, fn) => {
110
+ try {
111
+ return fn(text);
112
+ } catch (error) {
113
+ throw new RegistryError(`Invalid registry file ${path}: ${error instanceof Error ? error.message : String(error)}`);
114
+ }
115
+ };
116
+ const entries = parse(indexPath, await read(indexPath, MAX_JSON_BYTES), parseRegistryIndex);
117
+ const load = async (entry) => {
118
+ if (!entry.path || !isContainedRelativePath(entry.path)) throw new RegistryError(`Template "${entry.id}" has no valid path.`);
119
+ const template = parse(entry.path, await readFile(entry.path, MAX_JSON_BYTES), (text) => parseTemplateItem(text, entry.id));
120
+ const dir = posix.dirname(entry.path);
121
+ const sources = /* @__PURE__ */ new Map();
122
+ let total = 0;
123
+ for (const path of templateSourcePaths(template)) {
124
+ if (!isContainedRelativePath(path)) throw new RegistryError(`Template "${entry.id}" references an unsafe path: ${path}`);
125
+ const text = await readFile(dir === "." ? path : posix.join(dir, path), Math.min(MAX_FILE_BYTES, MAX_TOTAL_BYTES - total));
126
+ total += Buffer.byteLength(text, "utf8");
127
+ sources.set(path, text);
128
+ }
129
+ return {
130
+ template,
131
+ sources
132
+ };
133
+ };
134
+ return {
135
+ source,
136
+ entries,
137
+ load
138
+ };
139
+ };
140
+ //#endregion
141
+ export { DEFAULT_REGISTRY_URL, GITHUB_REGISTRY_URL, REGISTRY_DIR_ENV, REGISTRY_URL_ENV, RegistryError, describeRegistrySource, openRegistry, resolveRegistrySource, templateSourcePaths };
@@ -0,0 +1,6 @@
1
+ import { fileURLToPath } from "node:url";
2
+ //#region src/test_utils/registry.ts
3
+ const REGISTRY_SCHEMAS_DIR = fileURLToPath(new URL("../functions/schemas", import.meta.url));
4
+ const FIXTURE_REGISTRY_DIR = fileURLToPath(new URL("../../tests/function-registry", import.meta.url));
5
+ //#endregion
6
+ export { FIXTURE_REGISTRY_DIR, REGISTRY_SCHEMAS_DIR };
@@ -1,9 +1,9 @@
1
- import { isCi } from "../env.js";
1
+ import { isInteractiveTerminal } from "../env.js";
2
2
  import { getAgentDisplayName, resolveAddMcpAgentId, uniqueAgentIds } from "../mcp/agents.js";
3
3
  import prompts from "prompts";
4
4
  //#region src/utils/agent_picker.ts
5
5
  var AgentSelectionSkipped = class extends Error {};
6
- const canPickAgentsInteractively = () => !isCi() && Boolean(process.stdout.isTTY) && Boolean(process.stdin.isTTY);
6
+ const canPickAgentsInteractively = () => isInteractiveTerminal();
7
7
  const restoreCursorOnAbort = (exit) => (state) => {
8
8
  if (state.aborted) {
9
9
  process.stdout.write("\x1B[?25h");
@@ -1,5 +1,5 @@
1
1
  import { isNeonApiError, messageFromBody } from "../api.js";
2
- import { isConfigAdd, isConfigInit, isCurrentBranchProbe } from "../context.js";
2
+ import { isConfigAdd, isConfigInit, isCurrentBranchProbe, isFunctionsTemplateCommand } from "../context.js";
3
3
  import { looksLikeBranchId } from "./formats.js";
4
4
  //#region src/utils/enrichers.ts
5
5
  const BRANCHES_LIST_LIMIT = 100;
@@ -127,6 +127,7 @@ const fillSingleProject = async (props) => {
127
127
  if (isCurrentBranchProbe(props)) return props;
128
128
  if (isConfigInit(props)) return props;
129
129
  if (isConfigAdd(props)) return props;
130
+ if (isFunctionsTemplateCommand(props)) return props;
130
131
  if (props.projectId) return {
131
132
  ...props,
132
133
  projectId: props.projectId
@@ -0,0 +1,7 @@
1
+ //#region src/utils/function_slug.ts
2
+ /** The rule the platform enforces on function slugs, so a bad one fails before any write. */
3
+ const FUNCTION_SLUG_PATTERN = /^[a-z0-9]{1,20}$/;
4
+ const FUNCTION_SLUG_HELP = "Use 1-20 lowercase letters and digits (no hyphens or other characters).";
5
+ const deriveFunctionSlug = (value) => value.toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 20);
6
+ //#endregion
7
+ export { FUNCTION_SLUG_HELP, FUNCTION_SLUG_PATTERN, deriveFunctionSlug };
@@ -189,6 +189,7 @@ const recommendedCliUpgradeCommand = (modulePath) => {
189
189
  const installArgs = (pm, packages, options) => packages?.length ? [
190
190
  pm === "npm" ? "install" : "add",
191
191
  ...options?.dev ? ["-D"] : [],
192
+ ...options?.exact ? ["-E"] : [],
192
193
  ...packages
193
194
  ] : ["install"];
194
195
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neon",
3
- "version": "8.10.3",
3
+ "version": "8.11.1",
4
4
  "description": "CLI tool for Neon, the cloud backend primitives built around Lakebase Postgres",
5
5
  "keywords": [
6
6
  "neon",
@@ -70,8 +70,8 @@
70
70
  "yaml": "^2.9.0",
71
71
  "yargs": "17.7.2",
72
72
  "yoctocolors": "^2.1.2",
73
- "@neon/realtime": "0.1.2",
74
73
  "@neon/config-runtime": "1.10.2",
74
+ "@neon/realtime": "0.1.2",
75
75
  "@neon/sdk": "7.4.0",
76
76
  "@neon/config": "1.9.3"
77
77
  },
@@ -111,9 +111,9 @@
111
111
  "vitest": "^3.0.9",
112
112
  "@neon-internals/cli-core": "0.0.0",
113
113
  "@neon-internals/env-core": "0.0.34",
114
+ "@neon/e2e-harness": "0.0.0",
114
115
  "@neon/functions": "0.11.0",
115
- "@neon/env": "1.6.4",
116
- "@neon/e2e-harness": "0.0.0"
116
+ "@neon/env": "1.6.4"
117
117
  },
118
118
  "publishConfig": {
119
119
  "access": "public",