neon 8.0.0 → 8.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -943,6 +943,17 @@ neon ask --prompt "How do schema-only branches work?" --output json
943
943
 
944
944
  Default table output is the assistant's text. On a TTY that is a spinner, then the streamed reply. `--output json` and `--output yaml` print `{ "text": "…" }` after the full response.
945
945
 
946
+ ## Send feedback (`feedback`)
947
+
948
+ `neon feedback --message` sends feedback to Neon. It does not log in, and it sends only your message, not your account, project, or branch.
949
+
950
+ ```bash
951
+ neon feedback --message "The branch docs were unclear"
952
+ neon feedback --message "The branch docs were unclear" --output json
953
+ ```
954
+
955
+ Default table output is `Feedback received. Thank you!`. `--output json` and `--output yaml` print `{ "received": true }`. Messages are limited to 10,000 characters. Do not include passwords, API keys, connection strings, or other secrets.
956
+
946
957
  ## Install the Neon plugin (`plugins`)
947
958
 
948
959
  `neon plugins` installs the Neon agent plugin (`neon-postgres`) by running `npx plugins add`. It does not call the Neon API.
@@ -1410,6 +1421,7 @@ Id Name Project Created At Last Used At Last
1410
1421
  | plugins | | Install the Neon plugin |
1411
1422
  | skills | `update` | Install Neon agent skills |
1412
1423
  | ask | | Ask a question about Neon |
1424
+ | feedback | | Send feedback to Neon |
1413
1425
  | bucket | `create`, `list`, `delete`, `object list`, `object get`, `object put`, `object delete` (incl. `--recursive`) | Manage buckets and their objects |
1414
1426
  | [completion](https://neon.com/docs/reference/cli-completion) | | Generate a completion script |
1415
1427
 
package/dist/analytics.js CHANGED
@@ -149,12 +149,21 @@ const analyticsMiddleware = async (args) => {
149
149
  context: { direct: true }
150
150
  });
151
151
  };
152
- const closeAnalytics = async (opts) => {
153
- if (client) {
152
+ let closing;
153
+ /**
154
+ * Close the client and flush queued events. Later calls share the first flush: the psql
155
+ * launcher starts one while psql runs, and a second `closeAndFlush` would only make the SDK
156
+ * warn about overlapping flushes.
157
+ */
158
+ const closeAnalytics = (opts) => {
159
+ if (!client) return Promise.resolve();
160
+ if (!closing) {
154
161
  log.debug("Flushing CLI analytics");
155
- await client.closeAndFlush(opts);
156
- log.debug("Flushed CLI analytics");
162
+ closing = client.closeAndFlush(opts).then(() => {
163
+ log.debug("Flushed CLI analytics");
164
+ });
157
165
  }
166
+ return closing;
158
167
  };
159
168
  const getErrorAnalyticsEventContext = (_args) => ({
160
169
  version: pkg_default.version,
@@ -5,7 +5,7 @@ import { log } from "../log.js";
5
5
  import { getApiClient, isNeonApiError } from "../api.js";
6
6
  import { setAuthContext } from "../auth_context.js";
7
7
  import { a as isOwnedCredentialPath } from "../_chunks/paths-DMq0Lt7a.js";
8
- import { currentContextFile, isAskCommand, isClaimCommand, isConfigAdd, isConfigInit, isCurrentBranchProbe, isGitLocalCommand, isInspectDbUrl, isMcpCommand, isMcpOauth, isPluginsCommand, isProfileCommand, isSkillsCommand, isUnfollowedGitHookSync, readContextFile } from "../context.js";
8
+ import { currentContextFile, isAskCommand, isClaimCommand, isConfigAdd, isConfigInit, isCurrentBranchProbe, isFeedbackCommand, isGitLocalCommand, isInspectDbUrl, isMcpCommand, isMcpOauth, isPluginsCommand, isProfileCommand, isSkillsCommand, isUnfollowedGitHookSync, readContextFile } from "../context.js";
9
9
  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-CvnFEQyd.js";
10
10
  import { extendTokenSet } from "../utils/auth.js";
11
11
  import { AuthRefreshError, auth, classifyRefreshFailure, refreshToken } from "../auth.js";
@@ -304,7 +304,7 @@ const ensureAuth = async (props) => {
304
304
  if (isInspectDbUrl(props)) return;
305
305
  if (props._[0] === "open") return;
306
306
  if (isSkillsCommand(props) || isPluginsCommand(props)) return;
307
- if (isAskCommand(props)) return;
307
+ if (isAskCommand(props) || isFeedbackCommand(props)) return;
308
308
  if (props._[0] === "init") {
309
309
  selectCredential({
310
310
  ...credentialInputs(),
@@ -0,0 +1,88 @@
1
+ import { t as __exportAll } from "../_chunks/rolldown-runtime-8H4AJuhK.js";
2
+ import { isNetworkError } from "../errors.js";
3
+ import { writer } from "../writer.js";
4
+ import { noPassthrough, single } from "../utils/flags.js";
5
+ //#region src/commands/feedback.ts
6
+ var feedback_exports = /* @__PURE__ */ __exportAll({
7
+ DEFAULT_FEEDBACK_URL: () => DEFAULT_FEEDBACK_URL,
8
+ MAX_FEEDBACK_LENGTH: () => MAX_FEEDBACK_LENGTH,
9
+ builder: () => builder,
10
+ command: () => command,
11
+ describe: () => describe,
12
+ handler: () => handler,
13
+ resolveFeedbackUrl: () => resolveFeedbackUrl
14
+ });
15
+ const DEFAULT_FEEDBACK_URL = "https://feedback.neon.tech/";
16
+ /** The feedback service truncates longer messages, so the CLI rejects them instead. */
17
+ const MAX_FEEDBACK_LENGTH = 1e4;
18
+ const FEEDBACK_SOURCE = "neon_cli";
19
+ const FEEDBACK_TIMEOUT_MS = 3e4;
20
+ const command = "feedback";
21
+ const describe = "Send feedback to Neon";
22
+ const builder = (argv) => argv.usage("$0 feedback --message <feedback>").option("message", {
23
+ describe: "The feedback to send. Do not include passwords, API keys, or connection strings",
24
+ type: "string",
25
+ demandOption: true,
26
+ coerce: single("message", { required: true })
27
+ }).option("url", {
28
+ describe: "Override the feedback URL",
29
+ type: "string",
30
+ hidden: true,
31
+ coerce: single("url")
32
+ }).strict().check(noPassthrough("feedback")).check((args) => {
33
+ if ((typeof args.message === "string" ? args.message : "").trim().length > 1e4) throw new Error(`--message must be ${MAX_FEEDBACK_LENGTH.toLocaleString("en-US")} characters or fewer`);
34
+ return true;
35
+ }).example("$0 feedback --message \"The branch docs were unclear\"", describe);
36
+ function resolveFeedbackUrl(opts) {
37
+ const fromFlag = opts.url?.trim();
38
+ if (fromFlag) return fromFlag;
39
+ const fromEnv = opts.envUrl?.trim();
40
+ if (fromEnv) return fromEnv;
41
+ return DEFAULT_FEEDBACK_URL;
42
+ }
43
+ const handler = async (props) => {
44
+ await sendFeedback({
45
+ message: props.message.trim(),
46
+ url: resolveFeedbackUrl({
47
+ url: props.url,
48
+ envUrl: process.env.NEON_FEEDBACK_URL
49
+ })
50
+ });
51
+ if (props.output === "json" || props.output === "yaml") {
52
+ writer(props).end({ received: true }, { fields: ["received"] });
53
+ return;
54
+ }
55
+ writer(props).text("Feedback received. Thank you!\n");
56
+ };
57
+ function isTimeout(error) {
58
+ return error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
59
+ }
60
+ async function feedbackErrorMessage(response) {
61
+ if (response.status === 429) return "Too many feedback requests. Wait a minute and try again.";
62
+ try {
63
+ const body = await response.json();
64
+ if (typeof body === "object" && body !== null && "error" in body && typeof body.error === "string" && body.error.trim() !== "") return body.error;
65
+ } catch {}
66
+ return `The Neon feedback service returned ${response.status}.`;
67
+ }
68
+ async function sendFeedback(opts) {
69
+ let response;
70
+ try {
71
+ response = await fetch(opts.url, {
72
+ method: "POST",
73
+ headers: { "Content-Type": "application/json" },
74
+ body: JSON.stringify({
75
+ feedback: opts.message,
76
+ source: FEEDBACK_SOURCE
77
+ }),
78
+ signal: AbortSignal.timeout(FEEDBACK_TIMEOUT_MS)
79
+ });
80
+ } catch (error) {
81
+ if (isTimeout(error)) throw new Error("The Neon feedback service did not respond in time.");
82
+ if (isNetworkError(error)) throw new Error("Could not reach the Neon feedback service. Check your internet connection and try again.");
83
+ throw error;
84
+ }
85
+ if (!response.ok) throw new Error(await feedbackErrorMessage(response));
86
+ }
87
+ //#endregion
88
+ export { DEFAULT_FEEDBACK_URL, MAX_FEEDBACK_LENGTH, builder, command, describe, handler, resolveFeedbackUrl, feedback_exports as t };
@@ -21,6 +21,7 @@ import { t as databases_exports } from "./databases.js";
21
21
  import { t as deploy_exports } from "./deploy.js";
22
22
  import { t as dev_exports } from "./dev.js";
23
23
  import { t as diff_exports } from "./diff.js";
24
+ import { t as feedback_exports } from "./feedback.js";
24
25
  import { t as functions_exports } from "./functions.js";
25
26
  import { t as git_exports } from "./git.js";
26
27
  import { t as init_exports } from "./init.js";
@@ -72,6 +73,7 @@ var commands_default = [
72
73
  plugins_exports,
73
74
  skills_exports,
74
75
  ask_exports,
76
+ feedback_exports,
75
77
  data_api_exports,
76
78
  functions_exports,
77
79
  triggers_exports,
@@ -176,6 +176,11 @@ const commandManifest = [
176
176
  describe: "Ask a question about Neon",
177
177
  load: () => import("./ask.js")
178
178
  },
179
+ {
180
+ command: "feedback",
181
+ describe: "Send feedback to Neon",
182
+ load: () => import("./feedback.js")
183
+ },
179
184
  {
180
185
  command: "data-api",
181
186
  describe: "Manage the Neon Data API for a database",
@@ -1,7 +1,7 @@
1
1
  import { t as __exportAll } from "../_chunks/rolldown-runtime-8H4AJuhK.js";
2
2
  import { retryOnLock } from "../api.js";
3
3
  import { writer } from "../writer.js";
4
- import { branchIdFromProps, fillSingleProject } from "../utils/enrichers.js";
4
+ import { fillSingleProject, resolveBranchFromProps } from "../utils/enrichers.js";
5
5
  //#region src/commands/roles.ts
6
6
  var roles_exports = /* @__PURE__ */ __exportAll({
7
7
  aliases: () => aliases,
@@ -14,6 +14,11 @@ var roles_exports = /* @__PURE__ */ __exportAll({
14
14
  list: () => list
15
15
  });
16
16
  const ROLES_FIELDS = ["name", "created_at"];
17
+ const CREATED_ROLE_FIELDS = [
18
+ "name",
19
+ "password",
20
+ "created_at"
21
+ ];
17
22
  const command = "roles";
18
23
  const describe = "Manage roles";
19
24
  const aliases = ["role"];
@@ -41,22 +46,34 @@ const handler = (args) => {
41
46
  return args;
42
47
  };
43
48
  const list = async (props) => {
44
- const branchId = await branchIdFromProps(props);
49
+ const { branchId, branch } = await resolveBranchFromProps(props);
45
50
  const { data } = await props.apiClient.listProjectBranchRoles(props.projectId, branchId);
46
- writer(props).end(data.roles, { fields: ROLES_FIELDS });
51
+ const branchLabel = branch?.name ?? branchId;
52
+ writer(props).end(data.roles, {
53
+ fields: ROLES_FIELDS,
54
+ humanTitle: `Roles on ${branchLabel}`,
55
+ emptyMessage: `No roles on ${branchLabel}.`
56
+ });
47
57
  };
48
58
  const create = async (props) => {
49
- const branchId = await branchIdFromProps(props);
59
+ const noLogin = props["no-login"] === true || props.login === false;
60
+ const { branchId, branch } = await resolveBranchFromProps(props);
50
61
  const { data } = await retryOnLock(() => props.apiClient.createProjectBranchRole(props.projectId, branchId, { role: {
51
62
  name: props.name,
52
- no_login: props["no-login"]
63
+ ...noLogin ? { no_login: true } : {}
53
64
  } }));
54
- writer(props).end(data.role, { fields: ROLES_FIELDS });
65
+ writer(props).end(data.role, {
66
+ fields: CREATED_ROLE_FIELDS,
67
+ humanTitle: `Role created on ${branch?.name ?? branchId}`
68
+ });
55
69
  };
56
70
  const deleteRole = async (props) => {
57
- const branchId = await branchIdFromProps(props);
71
+ const { branchId, branch } = await resolveBranchFromProps(props);
58
72
  const { data } = await retryOnLock(() => props.apiClient.deleteProjectBranchRole(props.projectId, branchId, props.role));
59
- if (data) writer(props).end(data.role, { fields: ROLES_FIELDS });
73
+ if (data) writer(props).end(data.role, {
74
+ fields: ROLES_FIELDS,
75
+ humanTitle: `Role deleted from ${branch?.name ?? branchId}`
76
+ });
60
77
  };
61
78
  //#endregion
62
79
  export { aliases, builder, command, create, deleteRole, describe, handler, list, roles_exports as t };
package/dist/context.js CHANGED
@@ -88,6 +88,8 @@ const isUnfollowedGitHookSync = (args, cwd = process.cwd()) => {
88
88
  const isSkillsCommand = (args) => args._[0] === "skills" || args._[0] === "skill";
89
89
  const isPluginsCommand = (args) => args._[0] === "plugins" || args._[0] === "plugin";
90
90
  const isAskCommand = (args) => args._[0] === "ask";
91
+ /** Feedback is anonymous, so it must never read credentials or `.neon` context. */
92
+ const isFeedbackCommand = (args) => args._[0] === "feedback";
91
93
  /**
92
94
  * `inspect db --db-url` talks to Postgres with that URI. Skip only when the
93
95
  * parsed value is nonempty: empty `--db-url` still goes through project
@@ -175,7 +177,7 @@ const enrichFromContext = (args) => {
175
177
  if (isProfileCommand(args)) return;
176
178
  if (isMcpCommand(args)) return;
177
179
  if (isSkillsCommand(args) || isPluginsCommand(args)) return;
178
- if (isAskCommand(args)) return;
180
+ if (isAskCommand(args) || isFeedbackCommand(args)) return;
179
181
  if (isClaimCommand(args)) return;
180
182
  const context = readContextFile(args.contextFile);
181
183
  if (!args.orgId) args.orgId = context.orgId;
@@ -355,4 +357,4 @@ const globToRegExp = (pattern) => {
355
357
  return new RegExp(`^${source}$`);
356
358
  };
357
359
  //#endregion
358
- export { CONTEXT_FILE, applyContext, contextBranch, currentContextFile, enrichFromContext, ensureDirectoryGitignored, ensureGitignored, gitBranchMap, gitBranchMapping, isApiKeysCommand, isAskCommand, isClaimCommand, isConfigAdd, isConfigInit, isCurrentBranchProbe, isGitLocalCommand, isInspectDbUrl, isMcpCommand, isMcpOauth, isPluginsCommand, isProfileCommand, isSkillsCommand, isUnfollowedGitHookSync, readContextFile, setContext, setGitBranchMap, setGitBranchMapping, setGitFollow, updateContextFile, walkContextFile };
360
+ export { CONTEXT_FILE, applyContext, contextBranch, currentContextFile, enrichFromContext, ensureDirectoryGitignored, ensureGitignored, gitBranchMap, gitBranchMapping, isApiKeysCommand, isAskCommand, isClaimCommand, isConfigAdd, isConfigInit, isCurrentBranchProbe, isFeedbackCommand, isGitLocalCommand, isInspectDbUrl, isMcpCommand, isMcpOauth, isPluginsCommand, isProfileCommand, isSkillsCommand, isUnfollowedGitHookSync, readContextFile, setContext, setGitBranchMap, setGitBranchMapping, setGitFollow, updateContextFile, walkContextFile };
package/dist/index.js CHANGED
@@ -39,6 +39,7 @@ const NO_SUBCOMMANDS_VERBS = [
39
39
  "skills",
40
40
  "skill",
41
41
  "ask",
42
+ "feedback",
42
43
  "dev",
43
44
  "deploy",
44
45
  "diff",
@@ -4,7 +4,7 @@ import which from "which";
4
4
  import { spawn } from "child_process";
5
5
  //#region src/utils/psql.ts
6
6
  const FALLBACK_ENV = "NEONCTL_PSQL_FALLBACK";
7
- /** Max time we wait for the analytics flush before handing off to psql. */
7
+ /** Cap on the analytics flush, counted from when psql launches. */
8
8
  const ANALYTICS_FLUSH_TIMEOUT_MS = 3e3;
9
9
  /**
10
10
  * Decide which psql implementation will run, and why. The PATH probe is
@@ -45,59 +45,60 @@ const planPsql = async (opts) => {
45
45
  nativePath
46
46
  };
47
47
  };
48
- /**
49
- * Record which psql implementation is about to run, then flush analytics.
50
- *
51
- * Both exec paths below call `process.exit()`, which short-circuits the
52
- * main loop's `closeAnalytics()` — so without flushing here the event (and
53
- * any earlier queued events, e.g. `CLI Started`) would be dropped. The
54
- * flush is bounded by {@link ANALYTICS_FLUSH_TIMEOUT_MS} so a slow or
55
- * unreachable analytics endpoint can't stall the psql launch. No-ops when
56
- * analytics is disabled (`--analytics false`), since the client is absent.
57
- */
58
- const reportPsqlInvocation = async (plan) => {
59
- trackEvent("psql_invoked", {
60
- implementation: plan.implementation,
61
- reason: plan.reason,
62
- nativeAvailable: plan.nativeAvailable
63
- });
64
- await closeAnalytics({ timeout: ANALYTICS_FLUSH_TIMEOUT_MS });
65
- };
66
48
  const execNative = async (binary, connection_uri, args) => {
67
- log.info("Connecting to the database using psql...");
49
+ log.info(`${connectionSummary(connection_uri, args)}; launching psql...`);
68
50
  const child = spawn(binary, [connection_uri, ...args], { stdio: "inherit" });
69
51
  for (const signame of ["SIGINT", "SIGTERM"]) process.on(signame, (code) => {
70
52
  if (!child.killed && code !== null) child.kill(code);
71
53
  });
72
- return new Promise((_, reject) => {
54
+ return new Promise((resolve, reject) => {
73
55
  child.on("exit", (code) => {
74
- process.exit(code === null ? 1 : code);
56
+ resolve(code === null ? 1 : code);
75
57
  });
76
58
  child.on("error", reject);
77
59
  });
78
60
  };
79
61
  const execTs = async (connection_uri, args) => {
80
- log.info("Connecting to the database using embedded psql (TypeScript)...");
62
+ log.info(`${connectionSummary(connection_uri, args)}; launching embedded psql (TypeScript)...`);
81
63
  const { runPsql } = await import("../psql/index.js");
82
- const code = await runPsql([connection_uri, ...args], {
64
+ return runPsql([connection_uri, ...args], {
83
65
  stdin: process.stdin,
84
66
  stdout: process.stdout,
85
67
  stderr: process.stderr
86
68
  });
87
- process.exit(code);
69
+ };
70
+ const GENERIC_CONNECTING = "Connecting to the database";
71
+ const connectionSummary = (connection_uri, args = []) => {
72
+ if (args.length > 0) return GENERIC_CONNECTING;
73
+ try {
74
+ const url = new URL(connection_uri);
75
+ const part = (value) => decodeURIComponent(value).replace(/[\u0000-\u001f\u007f]/g, "");
76
+ const database = part(url.pathname.replace(/^\//, ""));
77
+ const role = part(url.username);
78
+ if (!database || !role || !url.host) return GENERIC_CONNECTING;
79
+ return `Neon connection: ${database} as ${role} on ${url.host}`;
80
+ } catch {
81
+ return GENERIC_CONNECTING;
82
+ }
88
83
  };
89
84
  const psql = async (connection_uri, args = [], opts = {}) => {
90
85
  const plan = await planPsql(opts);
91
- await reportPsqlInvocation(plan);
86
+ trackEvent("psql_invoked", {
87
+ implementation: plan.implementation,
88
+ reason: plan.reason,
89
+ nativeAvailable: plan.nativeAvailable
90
+ });
91
+ const flushed = closeAnalytics({ timeout: ANALYTICS_FLUSH_TIMEOUT_MS });
92
+ let code;
92
93
  if (plan.implementation === "ts") {
93
94
  if (plan.reason === "fallback_no_native") log.info("psql binary not found on PATH; falling back to embedded TypeScript psql");
94
- return execTs(connection_uri, args);
95
- }
96
- if (plan.nativePath === null) {
95
+ code = await execTs(connection_uri, args);
96
+ } else if (plan.nativePath === null) {
97
97
  log.error(`psql is not available in the PATH`);
98
- process.exit(1);
99
- }
100
- return execNative(plan.nativePath, connection_uri, args);
98
+ code = 1;
99
+ } else code = await execNative(plan.nativePath, connection_uri, args);
100
+ await flushed;
101
+ process.exit(code);
101
102
  };
102
103
  //#endregion
103
- export { psql };
104
+ export { connectionSummary, psql };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neon",
3
- "version": "8.0.0",
3
+ "version": "8.0.2",
4
4
  "description": "CLI tool for Neon, the cloud backend primitives built around Lakebase Postgres",
5
5
  "keywords": [
6
6
  "neon",
@@ -64,8 +64,8 @@
64
64
  "yaml": "^2.9.0",
65
65
  "yargs": "17.7.2",
66
66
  "yoctocolors": "^2.1.2",
67
- "@neon/sdk": "7.0.0",
68
67
  "@neon/config-runtime": "1.7.3",
68
+ "@neon/sdk": "7.0.0",
69
69
  "@neon/config": "1.8.3"
70
70
  },
71
71
  "optionalDependencies": {