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 +22 -0
- package/dist/commands/auth.js +2 -1
- package/dist/commands/config.js +11 -11
- package/dist/commands/dev.js +3 -3
- package/dist/commands/env.js +1 -1
- package/dist/commands/functions.js +52 -5
- package/dist/commands/functions_new.js +332 -0
- package/dist/commands/link.js +2 -2
- package/dist/commands/subscribe.js +1 -1
- package/dist/config_edit.js +8 -3
- package/dist/context.js +2 -1
- package/dist/env.js +2 -1
- package/dist/functions/registry.js +174 -0
- package/dist/functions/scaffold.js +149 -0
- package/dist/functions/source.js +141 -0
- package/dist/test_utils/registry.js +6 -0
- package/dist/utils/agent_picker.js +2 -2
- package/dist/utils/enrichers.js +2 -1
- package/dist/utils/function_slug.js +7 -0
- package/dist/utils/package_manager.js +1 -0
- package/package.json +4 -4
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).
|
package/dist/commands/auth.js
CHANGED
|
@@ -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;
|
package/dist/commands/config.js
CHANGED
|
@@ -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
|
|
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) =>
|
|
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 };
|
package/dist/commands/dev.js
CHANGED
|
@@ -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";
|
package/dist/commands/env.js
CHANGED
|
@@ -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("
|
|
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 (!
|
|
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 (!
|
|
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 };
|
package/dist/commands/link.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { t as __exportAll } from "../_chunks/rolldown-runtime-8H4AJuhK.js";
|
|
2
|
-
import {
|
|
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 = () =>
|
|
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", {
|
package/dist/config_edit.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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 = () =>
|
|
6
|
+
const canPickAgentsInteractively = () => isInteractiveTerminal();
|
|
7
7
|
const restoreCursorOnAbort = (exit) => (state) => {
|
|
8
8
|
if (state.aborted) {
|
|
9
9
|
process.stdout.write("\x1B[?25h");
|
package/dist/utils/enrichers.js
CHANGED
|
@@ -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.
|
|
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",
|