neon 2.45.0 → 2.47.0
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 +111 -4
- package/dist/_shared/paths.js +3 -4
- package/dist/analytics.js +82 -25
- package/dist/commands/bootstrap.js +12 -11
- package/dist/commands/checkout.js +7 -6
- package/dist/commands/config.js +26 -11
- package/dist/commands/data_api.js +4 -3
- package/dist/commands/dev.js +67 -14
- package/dist/commands/env.js +131 -9
- package/dist/commands/functions.js +3 -2
- package/dist/commands/init.js +82 -37
- package/dist/commands/ip_allow.js +3 -2
- package/dist/commands/link.js +17 -16
- package/dist/commands/projects.js +3 -2
- package/dist/commands/set_context.js +5 -4
- package/dist/config_template.js +20 -42
- package/dist/current_branch_fast_path.js +2 -1
- package/dist/dev/env.js +214 -10
- package/dist/env_services.js +51 -0
- package/dist/index.js +2 -2
- package/dist/init/agents.js +127 -0
- package/dist/init/auth.js +77 -0
- package/dist/init/bootstrap.js +448 -0
- package/dist/init/build_config.js +2 -0
- package/dist/init/detect_agent.js +108 -0
- package/dist/init/editors.js +62 -0
- package/dist/init/enrich_output.js +71 -0
- package/dist/init/extension.js +191 -0
- package/dist/init/inspect.js +287 -0
- package/dist/init/interactive.js +651 -0
- package/dist/init/neonctl.js +184 -0
- package/dist/init/orchestrate.js +190 -0
- package/dist/init/phases/auth.js +209 -0
- package/dist/init/phases/cleanup.js +27 -0
- package/dist/init/phases/db.js +283 -0
- package/dist/init/phases/getting_started.js +228 -0
- package/dist/init/phases/mcp.js +227 -0
- package/dist/init/phases/migrations.js +251 -0
- package/dist/init/phases/neon_auth.js +135 -0
- package/dist/init/phases/setup.js +729 -0
- package/dist/init/phases/skills.js +89 -0
- package/dist/init/phases/status.js +70 -0
- package/dist/init/resolve_context.js +107 -0
- package/dist/init/route_command.js +100 -0
- package/dist/init/skills.js +248 -0
- package/dist/init/types.js +1 -0
- package/dist/init/vsix.js +111 -0
- package/dist/neon_services.js +143 -0
- package/dist/psql/command/cmd_meta.js +2 -2
- package/dist/psql/core/mainloop.js +1 -1
- package/dist/psql/core/startup.js +1 -1
- package/dist/psql/core/syncVars.js +3 -3
- package/dist/psql/index.js +1 -1
- package/dist/utils/cli_name.js +14 -0
- package/dist/utils/esbuild.js +1 -1
- package/dist/utils/package_manager.js +51 -4
- package/dist/utils/service_picker.js +6 -6
- package/dist/utils/write_sync.js +39 -0
- package/package.json +18 -12
package/dist/commands/link.js
CHANGED
|
@@ -4,6 +4,7 @@ import { applyContext, contextBranch, readContextFile, setContext, updateContext
|
|
|
4
4
|
import { isCi } from "../env.js";
|
|
5
5
|
import { log } from "../log.js";
|
|
6
6
|
import { createBranch, pickBranchInteractively, } from "../utils/branch_picker.js";
|
|
7
|
+
import { getCliName } from "../utils/cli_name.js";
|
|
7
8
|
import { hasNeonConfigFile, initCmd } from "./config.js";
|
|
8
9
|
import { autoPullEnvAfterPin, renderAgentPullNote } from "./env.js";
|
|
9
10
|
import { REGIONS } from "./projects.js";
|
|
@@ -34,7 +35,7 @@ export const builder = (argv) => argv
|
|
|
34
35
|
alias: "branch-id",
|
|
35
36
|
describe: "Branch name or ID to pin in the context (resolved to its ID before writing). " +
|
|
36
37
|
"Without it, link only resolves the org and project — pin a branch with " +
|
|
37
|
-
|
|
38
|
+
`\`${getCliName()} checkout <branch>\` (link never guesses a default).`,
|
|
38
39
|
type: "string",
|
|
39
40
|
},
|
|
40
41
|
params: {
|
|
@@ -76,7 +77,7 @@ export const builder = (argv) => argv
|
|
|
76
77
|
.example([
|
|
77
78
|
[
|
|
78
79
|
"$0 link --project-id polished-snowflake-12345678",
|
|
79
|
-
|
|
80
|
+
`Link an existing project (org is inferred); pin a branch later with '${getCliName()} checkout'`,
|
|
80
81
|
],
|
|
81
82
|
[
|
|
82
83
|
"$0 link --org-id org-… --project-name my-app --region-id aws-us-east-2",
|
|
@@ -120,9 +121,9 @@ export const handler = async (props) => {
|
|
|
120
121
|
"Missing inputs and CI environment detected (no TTY for prompts).",
|
|
121
122
|
"",
|
|
122
123
|
"Use one of:",
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
124
|
+
` ${getCliName()} link --agent (JSON state machine for agents)`,
|
|
125
|
+
` ${getCliName()} link --project-id <project> (link to an existing project; org is inferred)`,
|
|
126
|
+
` ${getCliName()} link --org-id <org> --project-name <name> --region-id <region> (create a new project and link)`,
|
|
126
127
|
].join("\n"));
|
|
127
128
|
process.exit(1);
|
|
128
129
|
return;
|
|
@@ -180,7 +181,7 @@ const validateInputs = (inputs) => {
|
|
|
180
181
|
throw new Error("Conflicting inputs: --project-id selects an existing project; --project-name and --region-id describe a new one. Pass only one set.");
|
|
181
182
|
}
|
|
182
183
|
if (inputs.projectName && inputs.branch) {
|
|
183
|
-
throw new Error(
|
|
184
|
+
throw new Error(`Conflicting inputs: --branch pins a branch of an existing project, but --project-name creates a new one. Create the project first, then \`${getCliName()} checkout <branch>\`.`);
|
|
184
185
|
}
|
|
185
186
|
};
|
|
186
187
|
/**
|
|
@@ -338,7 +339,7 @@ const resolveBranchRef = async (props, projectId, branchRef) => {
|
|
|
338
339
|
.map((b) => `${b.id}${b.name ? ` (${b.name})` : ""}`)
|
|
339
340
|
.join(", ")
|
|
340
341
|
: "(none)";
|
|
341
|
-
throw new LinkInputError(`Branch '${branchRef}' not found in project '${projectId}'. Available branches: ${available}. Pin one with
|
|
342
|
+
throw new LinkInputError(`Branch '${branchRef}' not found in project '${projectId}'. Available branches: ${available}. Pin one with \`${getCliName()} checkout <branch>\`.`, "NOT_FOUND");
|
|
342
343
|
};
|
|
343
344
|
/**
|
|
344
345
|
* The value to persist for a branch: prefer its human-readable **name** (nicer
|
|
@@ -717,7 +718,7 @@ const runAgent = async (props, inputs) => {
|
|
|
717
718
|
context_file: props.contextFile,
|
|
718
719
|
context: { orgId, projectId },
|
|
719
720
|
project: { id: projectId },
|
|
720
|
-
message: `Linked ${props.contextFile} to project ${projectId}${orgSuffix}. No branch pinned — run
|
|
721
|
+
message: `Linked ${props.contextFile} to project ${projectId}${orgSuffix}. No branch pinned — run \`${getCliName()} checkout <branch>\` (omit the branch to list options) to pin one and pull its env vars.`,
|
|
721
722
|
});
|
|
722
723
|
return;
|
|
723
724
|
}
|
|
@@ -737,7 +738,7 @@ const runAgent = async (props, inputs) => {
|
|
|
737
738
|
name: region.name,
|
|
738
739
|
default: region.default,
|
|
739
740
|
})),
|
|
740
|
-
next_command_template:
|
|
741
|
+
next_command_template: `${getCliName()} link --agent --org-id ${shellArg(orgId)} --project-name ${shellArg(projectName)} --region-id <region_id>`,
|
|
741
742
|
});
|
|
742
743
|
return;
|
|
743
744
|
}
|
|
@@ -781,7 +782,7 @@ const runAgent = async (props, inputs) => {
|
|
|
781
782
|
// the instruction rather than silently dropped.
|
|
782
783
|
const projects = await listAllProjects(props, orgId);
|
|
783
784
|
const branchNote = branch
|
|
784
|
-
? ` A branch was requested (--branch ${branch}) but a branch can only be pinned once a project is chosen — re-run with --project-id first, then
|
|
785
|
+
? ` A branch was requested (--branch ${branch}) but a branch can only be pinned once a project is chosen — re-run with --project-id first, then \`${getCliName()} checkout ${branch}\`.`
|
|
785
786
|
: "";
|
|
786
787
|
emitAgent({
|
|
787
788
|
status: "needs_project",
|
|
@@ -796,9 +797,9 @@ const runAgent = async (props, inputs) => {
|
|
|
796
797
|
})),
|
|
797
798
|
create_option: {
|
|
798
799
|
instruction: "To create a new project, ask the user for a project name. The region can be omitted to receive a follow-up needs_project_details response that lists available regions.",
|
|
799
|
-
next_command_template:
|
|
800
|
+
next_command_template: `${getCliName()} link --agent --org-id ${shellArg(orgId)} --project-name <name> --region-id <region_id>`,
|
|
800
801
|
},
|
|
801
|
-
next_command_template:
|
|
802
|
+
next_command_template: `${getCliName()} link --agent --org-id ${shellArg(orgId)} --project-id <project_id>`,
|
|
802
803
|
});
|
|
803
804
|
};
|
|
804
805
|
const emitAgent = (response) => {
|
|
@@ -865,7 +866,7 @@ const buildNeedsOrgResponse = (resolution) => {
|
|
|
865
866
|
status: "needs_org",
|
|
866
867
|
instruction: "This Neon API key is organization-scoped, so the CLI cannot list the user's organizations and no existing project was found to auto-detect the org ID. Ask the user for their Neon organization ID (visible in the Neon Console under the org's Settings page, formatted like `org-bitter-breeze-12345678`) and re-run the next_command_template with that --org-id.",
|
|
867
868
|
options: [],
|
|
868
|
-
next_command_template:
|
|
869
|
+
next_command_template: `${getCliName()} link --agent --org-id <org_id>`,
|
|
869
870
|
};
|
|
870
871
|
}
|
|
871
872
|
const orgs = resolution.orgs;
|
|
@@ -875,7 +876,7 @@ const buildNeedsOrgResponse = (resolution) => {
|
|
|
875
876
|
? "The user does not belong to any organizations. Ask them to create one in the Neon Console (https://console.neon.tech/) before linking."
|
|
876
877
|
: `Ask the user which of these ${orgs.length} organization${orgs.length === 1 ? "" : "s"} they want to link the current directory to. After they pick one, re-run the next_command_template with the chosen --org-id value.`,
|
|
877
878
|
options: orgs.map((org) => ({ id: org.id, name: org.name })),
|
|
878
|
-
next_command_template:
|
|
879
|
+
next_command_template: `${getCliName()} link --agent --org-id <org_id>`,
|
|
879
880
|
};
|
|
880
881
|
};
|
|
881
882
|
const toAgentError = (err) => {
|
|
@@ -954,7 +955,7 @@ const resolveInteractiveBranch = async (props, projectId) => {
|
|
|
954
955
|
const picked = await pickBranchInteractively(branches, {
|
|
955
956
|
message: "Which branch would you like to link?",
|
|
956
957
|
nonInteractiveMessage: "No branch could be selected without an interactive terminal. " +
|
|
957
|
-
|
|
958
|
+
`Re-run \`${getCliName()} link\` interactively, or \`${getCliName()} checkout <branch>\` to pin one.`,
|
|
958
959
|
});
|
|
959
960
|
if (picked.kind === "existing") {
|
|
960
961
|
const existing = branches.find((b) => b.id === picked.branchId);
|
|
@@ -1031,7 +1032,7 @@ const printSummary = (_props, summary) => {
|
|
|
1031
1032
|
}
|
|
1032
1033
|
else if (summary.projectId && !summary.branch && !summary.orgOnly) {
|
|
1033
1034
|
lines.push("");
|
|
1034
|
-
lines.push(
|
|
1035
|
+
lines.push(`No branch pinned. Run \`${getCliName()} checkout <branch>\` to pin a branch and pull its env vars.`);
|
|
1035
1036
|
}
|
|
1036
1037
|
lines.push("");
|
|
1037
1038
|
process.stdout.write(`${lines.join("\n")}\n`);
|
|
@@ -4,6 +4,7 @@ import { updateContextFile } from "../context.js";
|
|
|
4
4
|
import { isCi } from "../env.js";
|
|
5
5
|
import { log } from "../log.js";
|
|
6
6
|
import { projectCreateRequest, projectUpdateRequest, } from "../parameters.gen.js";
|
|
7
|
+
import { getCliName } from "../utils/cli_name.js";
|
|
7
8
|
import { getComputeUnits } from "../utils/compute_units.js";
|
|
8
9
|
import { psql } from "../utils/psql.js";
|
|
9
10
|
import { writer } from "../writer.js";
|
|
@@ -392,11 +393,11 @@ The organization ID has been saved in ${props.contextFile}
|
|
|
392
393
|
|
|
393
394
|
If you'd like to change the default organization later, use
|
|
394
395
|
|
|
395
|
-
|
|
396
|
+
${getCliName()} link --org-id <org_id>
|
|
396
397
|
|
|
397
398
|
Or to clear the context file and forget the default organization
|
|
398
399
|
|
|
399
|
-
|
|
400
|
+
${getCliName()} link --clear
|
|
400
401
|
|
|
401
402
|
`);
|
|
402
403
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { applyContext } from "../context.js";
|
|
2
2
|
import { log } from "../log.js";
|
|
3
|
+
import { getCliName } from "../utils/cli_name.js";
|
|
3
4
|
export const command = "set-context";
|
|
4
|
-
export const describe =
|
|
5
|
+
export const describe = `Deprecated: use \`${getCliName()} link\`. Set the .neon context (raw write).`;
|
|
5
6
|
export const builder = (argv) => argv.usage("$0 set-context [options]").options({
|
|
6
7
|
"project-id": {
|
|
7
8
|
describe: "Project ID",
|
|
@@ -17,9 +18,9 @@ export const builder = (argv) => argv.usage("$0 set-context [options]").options(
|
|
|
17
18
|
},
|
|
18
19
|
});
|
|
19
20
|
export const handler = (props) => {
|
|
20
|
-
log.warning(
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
log.warning(`\`${getCliName()} set-context\` is deprecated and will be removed in a future release. ` +
|
|
22
|
+
`Use \`${getCliName()} link\` instead — it verifies inputs and infers the org for you ` +
|
|
23
|
+
`(or \`${getCliName()} link --no-checks\` for the same write-without-checks behavior).`);
|
|
23
24
|
const context = {
|
|
24
25
|
projectId: props.projectId,
|
|
25
26
|
orgId: props.orgId,
|
package/dist/config_template.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { NEON_SERVICES } from "./neon_services.js";
|
|
1
2
|
/**
|
|
2
3
|
* The published npm packages a `neon.ts` project needs — the `@neon/*` org names.
|
|
3
4
|
*
|
|
@@ -11,55 +12,32 @@ export const CONFIG_PACKAGE = "@neon/config";
|
|
|
11
12
|
export const ENV_PACKAGE = "@neon/env";
|
|
12
13
|
export const REQUIRED_PACKAGES = [CONFIG_PACKAGE, ENV_PACKAGE];
|
|
13
14
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
15
|
+
* The services `config init` can declare in the `neon.ts` it scaffolds — the subset of
|
|
16
|
+
* {@link NEON_SERVICES} a policy has a field for. {@link renderNeonConfig} owns the mapping
|
|
17
|
+
* from these names to the `neon.ts` fields (`aiGateway`, `buckets`).
|
|
17
18
|
*
|
|
18
|
-
* Postgres is absent because every branch has it,
|
|
19
|
-
* with the default `authProvider: "neon"` requires `auth` — a
|
|
20
|
-
* enforce rather than offer.
|
|
19
|
+
* Postgres is absent because every branch has it, so there is nothing to declare. `data-api`
|
|
20
|
+
* is absent because enabling it with the default `authProvider: "neon"` requires `auth` — a
|
|
21
|
+
* pairing the picker would have to enforce rather than offer.
|
|
21
22
|
*/
|
|
22
|
-
export const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
23
|
+
export const CONFIG_INIT_SERVICES = NEON_SERVICES.filter((service) => service !== "postgres" && service !== "data-api");
|
|
24
|
+
/**
|
|
25
|
+
* What `config init --services none` produces. One constant because it is both the help text
|
|
26
|
+
* and what tells the parser `none` is a value here — passing the literal at each call site
|
|
27
|
+
* lets the two drift into documenting something the parser does not accept.
|
|
28
|
+
*/
|
|
29
|
+
export const CONFIG_INIT_NONE_MEANS = "the bare starter policy";
|
|
30
|
+
/** Why the two a policy cannot declare are not selectable, for the refusal message. */
|
|
31
|
+
export const CONFIG_INIT_UNAVAILABLE = {
|
|
32
|
+
postgres: "every branch has Postgres, so a policy has nothing to declare for it",
|
|
33
|
+
"data-api": "enabling it with the default provider requires auth, so declare auth here and turn the Data API on with `neon data-api create`",
|
|
34
|
+
};
|
|
30
35
|
/** Slug, display name, and source path of the function scaffolded for `functions`. */
|
|
31
36
|
export const FUNCTION_SLUG = "hello";
|
|
32
37
|
export const FUNCTION_NAME = "Hello World";
|
|
33
38
|
export const FUNCTION_FILENAME = "hello.ts";
|
|
34
39
|
/** Name of the bucket scaffolded for `storage`. */
|
|
35
40
|
export const BUCKET_NAME = "assets";
|
|
36
|
-
/**
|
|
37
|
-
* Parse a `--services` value into a canonical service list: comma-separated
|
|
38
|
-
* {@link NEON_SERVICES} names, or {@link NO_SERVICES} on its own for none.
|
|
39
|
-
*
|
|
40
|
-
* Unknown names are rejected here rather than silently dropped — a typo'd service would
|
|
41
|
-
* otherwise scaffold a policy missing exactly the service the user asked for. The result is
|
|
42
|
-
* deduplicated and ordered by {@link NEON_SERVICES} so the rendered file doesn't depend on the
|
|
43
|
-
* order they were typed in.
|
|
44
|
-
*/
|
|
45
|
-
export const parseServices = (raw) => {
|
|
46
|
-
const names = raw
|
|
47
|
-
.split(",")
|
|
48
|
-
.map((name) => name.trim())
|
|
49
|
-
.filter((name) => name !== "");
|
|
50
|
-
if (names.includes(NO_SERVICES)) {
|
|
51
|
-
if (names.length > 1) {
|
|
52
|
-
throw new Error(`--services ${NO_SERVICES} cannot be combined with other services.`);
|
|
53
|
-
}
|
|
54
|
-
return [];
|
|
55
|
-
}
|
|
56
|
-
const unknown = names.filter((name) => !NEON_SERVICES.includes(name));
|
|
57
|
-
if (unknown.length > 0) {
|
|
58
|
-
throw new Error(`Unknown service${unknown.length === 1 ? "" : "s"} ${unknown.join(", ")}. ` +
|
|
59
|
-
`Supported values: ${NEON_SERVICES.join(", ")}, ${NO_SERVICES}.`);
|
|
60
|
-
}
|
|
61
|
-
return NEON_SERVICES.filter((service) => names.includes(service));
|
|
62
|
-
};
|
|
63
41
|
/**
|
|
64
42
|
* One indentation level in the emitted `neon.ts`. Two spaces, which is what every renderer
|
|
65
43
|
* here produces and what `config_template.format.test.ts` holds them to.
|
|
@@ -88,7 +66,7 @@ const renderPreview = (services) => {
|
|
|
88
66
|
...at(3, `${FUNCTION_SLUG}: { name: "${FUNCTION_NAME}", source: "./${FUNCTION_FILENAME}" },`),
|
|
89
67
|
]));
|
|
90
68
|
}
|
|
91
|
-
if (services.includes("storage")) {
|
|
69
|
+
if (services.includes("object-storage")) {
|
|
92
70
|
lines.push(...block(2, "buckets", [
|
|
93
71
|
...at(3, `// "private" is the default; use "public_read" for anonymous reads`, `${BUCKET_NAME}: { access: "private" },`),
|
|
94
72
|
]));
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { contextBranch, currentContextFile, readContextFile, } from "./context.js";
|
|
2
2
|
import { log } from "./log.js";
|
|
3
|
+
import { getCliName } from "./utils/cli_name.js";
|
|
3
4
|
/**
|
|
4
5
|
* Offline fast path for `(config) status --current-branch` (used by shell prompts).
|
|
5
6
|
*
|
|
@@ -35,7 +36,7 @@ cwd = process.cwd()) => {
|
|
|
35
36
|
process.stdout.write(`${branch}\n`);
|
|
36
37
|
}
|
|
37
38
|
else {
|
|
38
|
-
log.info(
|
|
39
|
+
log.info(`No branch pinned. Run \`${getCliName()} checkout <branch>\` to pin a branch and pull its env vars.`);
|
|
39
40
|
process.exitCode = 1;
|
|
40
41
|
}
|
|
41
42
|
return true;
|
package/dist/dev/env.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { loadConfigFromFile } from "@neon/config";
|
|
1
|
+
import { createNeonApiFromOptions, loadConfigFromFile, } from "@neon/config";
|
|
2
2
|
import { plan, pullConfig } from "@neon/config-runtime";
|
|
3
|
+
import { NEON_ENV_VAR_KEYS } from "@neon/env";
|
|
3
4
|
import { fetchEnvReusingSecrets, } from "@neon/env/runtime";
|
|
4
5
|
import { log } from "../log.js";
|
|
6
|
+
import { getCliName } from "../utils/cli_name.js";
|
|
5
7
|
/** The API-targeting options every runtime call forwards from the context. */
|
|
6
8
|
const apiOptions = (ctx) => ({
|
|
7
9
|
...(ctx.apiKey ? { apiKey: ctx.apiKey } : {}),
|
|
@@ -33,6 +35,18 @@ export class MissingBranchContextError extends Error {
|
|
|
33
35
|
this.name = "MissingBranchContextError";
|
|
34
36
|
}
|
|
35
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Thrown when an explicit `--service` selection names a service the branch does not have.
|
|
40
|
+
* Unlike the policy path — where the same situation is a {@link DevEnvMismatchError} pointing
|
|
41
|
+
* at `deploy` — the user named the service on the command line, so the fix is to provision it
|
|
42
|
+
* or drop it from the selection.
|
|
43
|
+
*/
|
|
44
|
+
export class ServiceNotOnBranchError extends Error {
|
|
45
|
+
constructor() {
|
|
46
|
+
super(...arguments);
|
|
47
|
+
this.name = "ServiceNotOnBranchError";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
36
50
|
/**
|
|
37
51
|
* Resolve the branch's Neon env vars (pooled / direct `DATABASE_URL`, plus Auth /
|
|
38
52
|
* Data API when enabled) into a `{ KEY: value }` map. Shared by `neon dev` (which
|
|
@@ -40,6 +54,8 @@ export class MissingBranchContextError extends Error {
|
|
|
40
54
|
*
|
|
41
55
|
* Tiered:
|
|
42
56
|
*
|
|
57
|
+
* 0. {@link DevEnvContext.services} is set -> that selection *is* the policy, and any
|
|
58
|
+
* `neon.ts` is ignored. See {@link resolveSelectedServices}.
|
|
43
59
|
* 1. a `neon.ts` policy is found -> the policy is the source of truth. We first
|
|
44
60
|
* check it against the branch's live state (`plan`); if it declares a resource
|
|
45
61
|
* the branch is missing, we stop with a {@link DevEnvMismatchError} pointing at
|
|
@@ -48,17 +64,22 @@ export class MissingBranchContextError extends Error {
|
|
|
48
64
|
* branch's live state (Auth / Data API enablement plus any object-storage
|
|
49
65
|
* buckets) into a config, then `fetchEnv` resolves what is actually enabled —
|
|
50
66
|
* so a branch with a bucket gets its `AWS_*` storage vars pulled with no policy.
|
|
67
|
+
* With {@link DevEnvContext.implyAiGateway}, the AI Gateway is added on top, since
|
|
68
|
+
* `pullConfig` cannot read it back.
|
|
51
69
|
* 3. otherwise -> throw {@link MissingBranchContextError}.
|
|
52
70
|
*
|
|
53
71
|
* Unlike {@link resolveDevEnv}, this never swallows errors — callers decide how to
|
|
54
72
|
* handle them.
|
|
55
73
|
*/
|
|
56
74
|
export const resolveNeonEnvVars = async (ctx) => {
|
|
75
|
+
if (ctx.services) {
|
|
76
|
+
return await resolveSelectedServices(ctx, ctx.services);
|
|
77
|
+
}
|
|
57
78
|
const config = await loadNeonConfig(ctx.cwd);
|
|
58
79
|
if (config) {
|
|
59
80
|
if (!ctx.projectId || !ctx.branchId) {
|
|
60
81
|
throw new MissingBranchContextError("Found a neon.ts but could not resolve the project/branch. " +
|
|
61
|
-
|
|
82
|
+
`Run \`${getCliName()} link\` and \`${getCliName()} checkout <branch>\`, or pass ` +
|
|
62
83
|
"--project-id / --branch.");
|
|
63
84
|
}
|
|
64
85
|
// Resolve env from the policy with its `preview.functions` removed. Functions carry no
|
|
@@ -84,10 +105,192 @@ export const resolveNeonEnvVars = async (ctx) => {
|
|
|
84
105
|
// straight into fetchEnv — no wrapping needed. pullConfig excludes functions and
|
|
85
106
|
// the AI Gateway (neither can be faithfully read back), so fetchEnv never probes
|
|
86
107
|
// the functions API here and only mints a storage credential when a bucket exists.
|
|
87
|
-
|
|
108
|
+
if (!ctx.implyAiGateway) {
|
|
109
|
+
return await fetchAndProject(pulled.config, ctx);
|
|
110
|
+
}
|
|
111
|
+
return await resolveWithImpliedGateway(pulled.config, ctx, {
|
|
112
|
+
projectId: ctx.projectId,
|
|
113
|
+
branchId: ctx.branchId,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
throw new MissingBranchContextError(`No project/branch context found. Link a branch (\`${getCliName()} link\` / ` +
|
|
117
|
+
`\`${getCliName()} checkout\`) or pass --project-id and --branch.`);
|
|
118
|
+
};
|
|
119
|
+
/** The same config with the AI Gateway enabled, leaving any other `preview` entries intact. */
|
|
120
|
+
const withAiGateway = (config) => ({
|
|
121
|
+
...config,
|
|
122
|
+
preview: { ...config.preview, aiGateway: true },
|
|
123
|
+
});
|
|
124
|
+
/**
|
|
125
|
+
* Tier-2 resolution with the AI Gateway added on top of the branch's read-back state.
|
|
126
|
+
*
|
|
127
|
+
* The gateway is not detectable — `pullConfig` reports no enabled flag for it — so it is
|
|
128
|
+
* implied rather than observed. Nobody named it, so it must never be the reason the whole
|
|
129
|
+
* resolve fails: a project outside the regions where branch credentials exist would otherwise
|
|
130
|
+
* lose its `DATABASE_URL` too, and `neon dev` would start with no env at all.
|
|
131
|
+
*
|
|
132
|
+
* So the gateway is only added once its credential endpoint has been shown to answer, by
|
|
133
|
+
* reading the branch's credentials first. A project that does not have them says so on a
|
|
134
|
+
* read, before anything is minted — which is the whole question, since the gateway's env is a
|
|
135
|
+
* credential and nothing else.
|
|
136
|
+
*
|
|
137
|
+
* Deciding this **before** resolving, rather than by catching and retrying, is what keeps it
|
|
138
|
+
* honest. A retry re-runs every call the first attempt made, so it would blame the gateway for
|
|
139
|
+
* a one-off failure in shared work, and — worse — a first attempt that minted a credential and
|
|
140
|
+
* then failed would be papered over by a second that succeeds without one, swallowing the
|
|
141
|
+
* error and stranding a secret nobody holds. Once the read succeeds, a later failure is a real
|
|
142
|
+
* failure and propagates: the same thing already happens on a branch with object storage,
|
|
143
|
+
* whose credential is minted whether or not the gateway is involved.
|
|
144
|
+
*/
|
|
145
|
+
const resolveWithImpliedGateway = async (config, ctx,
|
|
146
|
+
/** Resolved by the caller, which is the branch this env belongs to. */
|
|
147
|
+
branch) => {
|
|
148
|
+
const unreachable = await credentialsUnreachable(ctx, branch);
|
|
149
|
+
if (unreachable === null) {
|
|
150
|
+
return await fetchAndProject(withAiGateway(config), ctx);
|
|
151
|
+
}
|
|
152
|
+
// Deliberately does not assert that the project lacks the gateway: a read can also fail
|
|
153
|
+
// for a reason that has nothing to do with the feature, and this is not the place to
|
|
154
|
+
// guess which. Name both, and the command that answers it.
|
|
155
|
+
log.warning("Could not reach the AI Gateway's credentials, so %s were not resolved. Everything " +
|
|
156
|
+
"else was. Either this project does not have the AI Gateway, or the call failed — " +
|
|
157
|
+
`\`${getCliName()} env pull -s ai-gateway\` will say which.\nDetails: %s`, [
|
|
158
|
+
NEON_ENV_VAR_KEYS.aiGateway.apiKey,
|
|
159
|
+
NEON_ENV_VAR_KEYS.aiGateway.baseUrl,
|
|
160
|
+
].join(" and "), unreachable);
|
|
161
|
+
return {
|
|
162
|
+
...(await fetchAndProject(config, ctx)),
|
|
163
|
+
skipped: ["ai-gateway"],
|
|
164
|
+
};
|
|
165
|
+
};
|
|
166
|
+
/**
|
|
167
|
+
* Why the branch's credentials could not be read, or `null` when they could. A plain read: it
|
|
168
|
+
* mints nothing, revokes nothing, and changes nothing, so asking is free of the side effects
|
|
169
|
+
* that make a failed resolve ambiguous.
|
|
170
|
+
*/
|
|
171
|
+
const credentialsUnreachable = async (ctx, branch) => {
|
|
172
|
+
try {
|
|
173
|
+
await apiFor(ctx).listCredentials(branch.projectId, branch.branchId);
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
return err instanceof Error ? err.message : String(err);
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
/** The adapter for direct branch reads: the injected one in tests, else built from options. */
|
|
181
|
+
const apiFor = (ctx) => ctx.api ??
|
|
182
|
+
createNeonApiFromOptions("neon env", {
|
|
183
|
+
...(ctx.apiKey ? { apiKey: ctx.apiKey } : {}),
|
|
184
|
+
...(ctx.apiHost ? { apiHost: ctx.apiHost } : {}),
|
|
185
|
+
});
|
|
186
|
+
/**
|
|
187
|
+
* Tier-0: resolve exactly the services `--service` named, with `neon.ts` out of the picture.
|
|
188
|
+
*
|
|
189
|
+
* The selection is checked against the branch's live state so a service that is named but not
|
|
190
|
+
* provisioned fails by name, instead of quietly contributing no vars. `postgres` and the AI
|
|
191
|
+
* Gateway are not checked: every branch has Postgres, and the gateway has no branch-level
|
|
192
|
+
* state to check (an unavailable one surfaces when its credential is minted).
|
|
193
|
+
*/
|
|
194
|
+
const resolveSelectedServices = async (ctx, services) => {
|
|
195
|
+
const { projectId, branchId } = ctx;
|
|
196
|
+
if (!projectId || !branchId) {
|
|
197
|
+
throw new MissingBranchContextError("--service needs a project and branch to read from. " +
|
|
198
|
+
`Run \`${getCliName()} link\` and \`${getCliName()} checkout <branch>\`, or pass ` +
|
|
199
|
+
"--project-id / --branch.");
|
|
200
|
+
}
|
|
201
|
+
// Read only the services that were named, rather than going through `pullConfig`. That
|
|
202
|
+
// keeps a selection independent of everything else on the branch — `pullConfig` also
|
|
203
|
+
// enumerates functions and credentials, so a failure there would abort `-s auth` — and it
|
|
204
|
+
// keeps an "object storage isn't available for this project" error intact, which
|
|
205
|
+
// `pullConfig` degrades to an empty bucket list and would report as "no buckets".
|
|
206
|
+
const api = apiFor(ctx);
|
|
207
|
+
const has = (service) => services.includes(service);
|
|
208
|
+
const [auth, dataApiEnabled, buckets] = await Promise.all([
|
|
209
|
+
has("auth") ? api.getNeonAuth(projectId, branchId) : null,
|
|
210
|
+
has("data-api") ? readDataApiEnabled(api, projectId, branchId) : null,
|
|
211
|
+
has("object-storage")
|
|
212
|
+
? api.listBranchBuckets(projectId, branchId)
|
|
213
|
+
: null,
|
|
214
|
+
]);
|
|
215
|
+
const config = configForServices(services, branchId, {
|
|
216
|
+
authEnabled: auth !== null,
|
|
217
|
+
dataApiEnabled,
|
|
218
|
+
buckets: buckets ?? [],
|
|
219
|
+
});
|
|
220
|
+
// A selection resolves part of the branch, so it must not revoke: the credential its
|
|
221
|
+
// persisted secrets name may also back a service it is not resolving. See
|
|
222
|
+
// `fetchEnvReusingSecrets`'s `revokeSuperseded`.
|
|
223
|
+
return await fetchAndProject(config, ctx, { revokeSuperseded: false });
|
|
224
|
+
};
|
|
225
|
+
/**
|
|
226
|
+
* Whether the branch has a Data API integration — or `null` when that cannot be determined.
|
|
227
|
+
*
|
|
228
|
+
* It is enabled per branch *and database*, so this has to probe the database `fetchEnv` will
|
|
229
|
+
* resolve the URL from, or the two would disagree. That is Neon's default `neondb`, else the
|
|
230
|
+
* only database; several databases with no `neondb` is a case `fetchEnv` refuses to auto-pick
|
|
231
|
+
* at all. Reporting "no Data API integration" there would be a claim this read cannot support,
|
|
232
|
+
* so it answers `null` and lets `fetchEnv` raise its own ambiguity error, which names the
|
|
233
|
+
* databases and the fix.
|
|
234
|
+
*/
|
|
235
|
+
const readDataApiEnabled = async (api, projectId, branchId) => {
|
|
236
|
+
const databases = await api.listBranchDatabases(projectId, branchId);
|
|
237
|
+
const database = databases.find((db) => db.name === NEON_DEFAULT_DATABASE) ??
|
|
238
|
+
(databases.length === 1 ? databases[0] : undefined);
|
|
239
|
+
if (!database)
|
|
240
|
+
return databases.length === 0 ? false : null;
|
|
241
|
+
const dataApi = await api.getNeonDataApi(projectId, branchId, database.name);
|
|
242
|
+
return dataApi !== null;
|
|
243
|
+
};
|
|
244
|
+
/** Neon's default database, and the one `fetchEnv` prefers when a branch has several. */
|
|
245
|
+
const NEON_DEFAULT_DATABASE = "neondb";
|
|
246
|
+
/**
|
|
247
|
+
* Build the `Config` an explicit `--service` selection stands for, raising
|
|
248
|
+
* {@link ServiceNotOnBranchError} for anything the branch does not have. Naming a service
|
|
249
|
+
* that isn't there has to fail rather than contribute no vars, or a scoped pull would report
|
|
250
|
+
* "no Neon env variables to pull" — which reads as a statement about the branch rather than
|
|
251
|
+
* about the selection.
|
|
252
|
+
*/
|
|
253
|
+
const configForServices = (services, branchId, branch) => {
|
|
254
|
+
// The command that provisions each one, for a user who may well have no `neon.ts` — in
|
|
255
|
+
// which case `deploy` / `config apply` would be no help at all.
|
|
256
|
+
const provisionWith = {
|
|
257
|
+
auth: `${getCliName()} neon-auth enable`,
|
|
258
|
+
"data-api": `${getCliName()} data-api create`,
|
|
259
|
+
"object-storage": `${getCliName()} buckets create <name>`,
|
|
260
|
+
};
|
|
261
|
+
const notOnBranch = (service, what) => {
|
|
262
|
+
throw new ServiceNotOnBranchError(`--service ${service}: branch ${branchId} has no ${what}, so there are no ` +
|
|
263
|
+
`${service} env vars to pull. Provision it first (\`${provisionWith[service]}\`, ` +
|
|
264
|
+
`or in the Neon Console), or drop ${service} from --service.`);
|
|
265
|
+
};
|
|
266
|
+
const config = {};
|
|
267
|
+
if (services.includes("auth")) {
|
|
268
|
+
if (!branch.authEnabled)
|
|
269
|
+
notOnBranch("auth", "Neon Auth integration");
|
|
270
|
+
config.auth = true;
|
|
271
|
+
}
|
|
272
|
+
if (services.includes("data-api")) {
|
|
273
|
+
// Only a positive "not there" is an error; an undecidable read defers to `fetchEnv`.
|
|
274
|
+
if (branch.dataApiEnabled === false) {
|
|
275
|
+
notOnBranch("data-api", "Data API integration");
|
|
276
|
+
}
|
|
277
|
+
config.dataApi = true;
|
|
278
|
+
}
|
|
279
|
+
const preview = {};
|
|
280
|
+
if (services.includes("object-storage")) {
|
|
281
|
+
if (branch.buckets.length === 0) {
|
|
282
|
+
notOnBranch("object-storage", "object-storage buckets");
|
|
283
|
+
}
|
|
284
|
+
preview.buckets = Object.fromEntries(branch.buckets.map((bucket) => [
|
|
285
|
+
bucket.name,
|
|
286
|
+
{ access: bucket.accessLevel },
|
|
287
|
+
]));
|
|
88
288
|
}
|
|
89
|
-
|
|
90
|
-
|
|
289
|
+
if (services.includes("ai-gateway"))
|
|
290
|
+
preview.aiGateway = true;
|
|
291
|
+
if (Object.keys(preview).length > 0)
|
|
292
|
+
config.preview = preview;
|
|
293
|
+
return config;
|
|
91
294
|
};
|
|
92
295
|
/**
|
|
93
296
|
* `neon dev`'s env resolver: {@link resolveNeonEnvVars} with graceful degradation.
|
|
@@ -113,8 +316,8 @@ export const resolveDevEnv = async (ctx) => {
|
|
|
113
316
|
return {
|
|
114
317
|
vars: {},
|
|
115
318
|
skipped: {
|
|
116
|
-
reason:
|
|
117
|
-
|
|
319
|
+
reason: `no linked Neon branch — run \`${getCliName()} link\`, then ` +
|
|
320
|
+
`\`${getCliName()} checkout <branch>\`, to inject DATABASE_URL and friends`,
|
|
118
321
|
},
|
|
119
322
|
};
|
|
120
323
|
}
|
|
@@ -167,8 +370,8 @@ const assertPolicyMatchesBranch = async (config, ctx) => {
|
|
|
167
370
|
const names = missing.map((change) => change.identifier).join(", ");
|
|
168
371
|
throw new DevEnvMismatchError(`Your neon.ts declares ${names} for branch ${ctx.branchId}, but the branch ` +
|
|
169
372
|
"does not have it yet, so the matching env vars cannot be injected. " +
|
|
170
|
-
|
|
171
|
-
|
|
373
|
+
`Provision it first with \`${getCliName()} deploy\` (or \`${getCliName()} config apply\`), ` +
|
|
374
|
+
`then re-run \`${getCliName()} dev\`.`);
|
|
172
375
|
};
|
|
173
376
|
/**
|
|
174
377
|
* A planned change that provisions a branch-level resource the branch lacks: a
|
|
@@ -179,11 +382,12 @@ const assertPolicyMatchesBranch = async (config, ctx) => {
|
|
|
179
382
|
const isMissingResource = (change) => change.kind === "service" &&
|
|
180
383
|
change.action === "create" &&
|
|
181
384
|
!change.identifier.startsWith("function:");
|
|
182
|
-
const fetchAndProject = async (config, ctx) => fetchEnvReusingSecrets(config, {
|
|
385
|
+
const fetchAndProject = async (config, ctx, opts = {}) => fetchEnvReusingSecrets(config, {
|
|
183
386
|
projectId: ctx.projectId,
|
|
184
387
|
branch: ctx.branchId,
|
|
185
388
|
...apiOptions(ctx),
|
|
186
389
|
...(ctx.env ? { env: ctx.env } : {}),
|
|
390
|
+
...(opts.revokeSuperseded === false ? { revokeSuperseded: false } : {}),
|
|
187
391
|
});
|
|
188
392
|
/**
|
|
189
393
|
* Load a `neon.ts` policy if one exists on the path from `cwd` up to the repo
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { NEON_ENV_VAR_KEYS } from "@neon/env";
|
|
2
|
+
import { NEON_SERVICES } from "./neon_services.js";
|
|
3
|
+
/**
|
|
4
|
+
* The services `env pull --service` can select: every Neon service that produces branch env
|
|
5
|
+
* vars. `functions` is the one left out — a function's env comes from the local `neon.ts`,
|
|
6
|
+
* never from the branch, so there is nothing to pull.
|
|
7
|
+
*/
|
|
8
|
+
export const ENV_PULL_SERVICES = NEON_SERVICES.filter((service) => service !== "functions");
|
|
9
|
+
/** Why the services `env pull` leaves out are not selectable, for the refusal message. */
|
|
10
|
+
export const ENV_PULL_UNAVAILABLE = {
|
|
11
|
+
functions: "a function's env comes from your neon.ts, not from the branch, so there is nothing to pull",
|
|
12
|
+
};
|
|
13
|
+
/** The OS-level env vars each service contributes to a pulled `.env`. */
|
|
14
|
+
const SERVICE_ENV_KEYS = {
|
|
15
|
+
postgres: Object.values(NEON_ENV_VAR_KEYS.postgres),
|
|
16
|
+
auth: Object.values(NEON_ENV_VAR_KEYS.auth),
|
|
17
|
+
"data-api": Object.values(NEON_ENV_VAR_KEYS.dataApi),
|
|
18
|
+
"object-storage": Object.values(NEON_ENV_VAR_KEYS.storage),
|
|
19
|
+
"ai-gateway": Object.values(NEON_ENV_VAR_KEYS.aiGateway),
|
|
20
|
+
functions: [],
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* The subset of {@link SERVICE_ENV_KEYS} a pull *owns*, and so may prune from the target file
|
|
24
|
+
* when the branch no longer has it. Object storage is deliberately absent: it is emitted under
|
|
25
|
+
* the third-party `AWS_*` names, which collide with credentials a user may set by hand, so
|
|
26
|
+
* `env pull` only ever writes them.
|
|
27
|
+
*/
|
|
28
|
+
const SERVICE_OWNED_ENV_KEYS = {
|
|
29
|
+
...SERVICE_ENV_KEYS,
|
|
30
|
+
"object-storage": [],
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Branch identity. Not a service — every branch has a name — so a scoped pull refreshes it
|
|
34
|
+
* alongside whatever services were selected.
|
|
35
|
+
*/
|
|
36
|
+
export const BRANCH_ENV_KEY = NEON_ENV_VAR_KEYS.branch.name;
|
|
37
|
+
/** Every env var the selected services contribute, plus branch identity. */
|
|
38
|
+
export const envServiceKeys = (services) => {
|
|
39
|
+
const keys = new Set([BRANCH_ENV_KEY]);
|
|
40
|
+
for (const service of services) {
|
|
41
|
+
for (const key of SERVICE_ENV_KEYS[service])
|
|
42
|
+
keys.add(key);
|
|
43
|
+
}
|
|
44
|
+
return keys;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* The env vars a pull scoped to `services` may prune. Narrower than the unscoped set on
|
|
48
|
+
* purpose: `env pull -s ai-gateway` says nothing about `DATABASE_URL`, so it must leave it
|
|
49
|
+
* alone rather than treat its absence from this pull as "the branch no longer has it".
|
|
50
|
+
*/
|
|
51
|
+
export const ownedEnvServiceKeys = (services) => services.flatMap((service) => SERVICE_OWNED_ENV_KEYS[service]);
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { basename } from "node:path";
|
|
2
1
|
import yargs from "yargs";
|
|
3
2
|
import { hideBin } from "yargs/helpers";
|
|
4
3
|
import { analyticsMiddleware, closeAnalytics, getAnalyticsEventProperties, initAnalyticsClientMiddleware, sendError, trackEvent, } from "./analytics.js";
|
|
@@ -13,6 +12,7 @@ import { isNetworkError, matchErrorCode, NETWORK_ERROR_MESSAGE, } from "./errors
|
|
|
13
12
|
import { showHelp } from "./help.js";
|
|
14
13
|
import { log } from "./log.js";
|
|
15
14
|
import pkg from "./pkg.js";
|
|
15
|
+
import { getCliName } from "./utils/cli_name.js";
|
|
16
16
|
import { fillInArgs, resolveApiKeyFromEnv } from "./utils/middlewares.js";
|
|
17
17
|
const NO_SUBCOMMANDS_VERBS = [
|
|
18
18
|
// `api <path>` has a handler but no subcommands (like `status`), so the
|
|
@@ -159,7 +159,7 @@ builder = builder
|
|
|
159
159
|
.group("version", "Global options:")
|
|
160
160
|
.alias("version", "v")
|
|
161
161
|
.completion()
|
|
162
|
-
.scriptName(
|
|
162
|
+
.scriptName(getCliName())
|
|
163
163
|
.epilog("For more information, visit https://neon.com/docs/reference/neon-cli")
|
|
164
164
|
.wrap(null)
|
|
165
165
|
.fail(false);
|