neonctl 2.28.0 → 2.29.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.
Files changed (135) hide show
  1. package/README.md +2 -2
  2. package/dist/analytics.js +35 -33
  3. package/dist/api.js +34 -34
  4. package/dist/auth.js +50 -44
  5. package/dist/cli.js +2 -2
  6. package/dist/commands/auth.js +58 -52
  7. package/dist/commands/bootstrap.js +115 -157
  8. package/dist/commands/branches.js +154 -147
  9. package/dist/commands/bucket.js +124 -118
  10. package/dist/commands/checkout.js +49 -49
  11. package/dist/commands/config.js +212 -88
  12. package/dist/commands/connection_string.js +62 -62
  13. package/dist/commands/data_api.js +96 -96
  14. package/dist/commands/databases.js +23 -23
  15. package/dist/commands/deploy.js +12 -12
  16. package/dist/commands/dev.js +114 -114
  17. package/dist/commands/env.js +43 -43
  18. package/dist/commands/functions.js +97 -98
  19. package/dist/commands/index.js +26 -26
  20. package/dist/commands/init.js +23 -22
  21. package/dist/commands/ip_allow.js +29 -29
  22. package/dist/commands/link.js +223 -166
  23. package/dist/commands/neon_auth.js +381 -363
  24. package/dist/commands/operations.js +11 -11
  25. package/dist/commands/orgs.js +8 -8
  26. package/dist/commands/projects.js +101 -99
  27. package/dist/commands/psql.js +31 -31
  28. package/dist/commands/roles.js +21 -21
  29. package/dist/commands/schema_diff.js +23 -23
  30. package/dist/commands/set_context.js +17 -17
  31. package/dist/commands/status.js +17 -17
  32. package/dist/commands/user.js +5 -5
  33. package/dist/commands/vpc_endpoints.js +50 -50
  34. package/dist/config.js +7 -7
  35. package/dist/config_format.js +5 -5
  36. package/dist/context.js +23 -16
  37. package/dist/current_branch_fast_path.js +6 -6
  38. package/dist/dev/env.js +33 -33
  39. package/dist/dev/functions.js +4 -4
  40. package/dist/dev/inputs.js +6 -6
  41. package/dist/dev/runtime.js +25 -25
  42. package/dist/env.js +14 -14
  43. package/dist/env_file.js +13 -13
  44. package/dist/errors.js +19 -19
  45. package/dist/functions_api.js +10 -10
  46. package/dist/help.js +15 -15
  47. package/dist/index.js +94 -92
  48. package/dist/log.js +2 -2
  49. package/dist/pkg.js +5 -5
  50. package/dist/psql/cli.js +4 -2
  51. package/dist/psql/command/cmd_cond.js +61 -61
  52. package/dist/psql/command/cmd_connect.js +159 -154
  53. package/dist/psql/command/cmd_copy.js +107 -97
  54. package/dist/psql/command/cmd_describe.js +368 -363
  55. package/dist/psql/command/cmd_format.js +276 -263
  56. package/dist/psql/command/cmd_io.js +269 -263
  57. package/dist/psql/command/cmd_lo.js +74 -66
  58. package/dist/psql/command/cmd_meta.js +148 -148
  59. package/dist/psql/command/cmd_misc.js +17 -17
  60. package/dist/psql/command/cmd_pipeline.js +142 -135
  61. package/dist/psql/command/cmd_restrict.js +25 -25
  62. package/dist/psql/command/cmd_show.js +183 -168
  63. package/dist/psql/command/dispatch.js +26 -26
  64. package/dist/psql/command/shared.js +14 -14
  65. package/dist/psql/complete/filenames.js +16 -16
  66. package/dist/psql/complete/index.js +4 -4
  67. package/dist/psql/complete/matcher.js +33 -32
  68. package/dist/psql/complete/psqlVars.js +173 -173
  69. package/dist/psql/complete/queries.js +5 -3
  70. package/dist/psql/complete/rules.js +900 -863
  71. package/dist/psql/core/common.js +136 -133
  72. package/dist/psql/core/help.js +343 -343
  73. package/dist/psql/core/mainloop.js +160 -153
  74. package/dist/psql/core/prompt.js +126 -123
  75. package/dist/psql/core/settings.js +111 -111
  76. package/dist/psql/core/sqlHelp.js +150 -150
  77. package/dist/psql/core/startup.js +211 -205
  78. package/dist/psql/core/syncVars.js +14 -14
  79. package/dist/psql/core/variables.js +24 -24
  80. package/dist/psql/describe/formatters.js +302 -289
  81. package/dist/psql/describe/processNamePattern.js +28 -28
  82. package/dist/psql/describe/queries.js +656 -651
  83. package/dist/psql/index.js +436 -411
  84. package/dist/psql/io/history.js +36 -36
  85. package/dist/psql/io/input.js +15 -15
  86. package/dist/psql/io/lineEditor/buffer.js +27 -25
  87. package/dist/psql/io/lineEditor/complete.js +15 -15
  88. package/dist/psql/io/lineEditor/filename.js +22 -22
  89. package/dist/psql/io/lineEditor/index.js +65 -62
  90. package/dist/psql/io/lineEditor/keymap.js +325 -318
  91. package/dist/psql/io/lineEditor/vt100.js +60 -60
  92. package/dist/psql/io/pgpass.js +18 -18
  93. package/dist/psql/io/pgservice.js +14 -14
  94. package/dist/psql/io/psqlrc.js +46 -46
  95. package/dist/psql/print/aligned.js +175 -166
  96. package/dist/psql/print/asciidoc.js +51 -51
  97. package/dist/psql/print/crosstab.js +34 -31
  98. package/dist/psql/print/csv.js +25 -22
  99. package/dist/psql/print/html.js +54 -54
  100. package/dist/psql/print/json.js +12 -12
  101. package/dist/psql/print/latex.js +118 -118
  102. package/dist/psql/print/pager.js +28 -26
  103. package/dist/psql/print/troff.js +48 -48
  104. package/dist/psql/print/unaligned.js +15 -14
  105. package/dist/psql/print/units.js +17 -17
  106. package/dist/psql/scanner/slash.js +48 -46
  107. package/dist/psql/scanner/sql.js +88 -84
  108. package/dist/psql/scanner/stringutils.js +21 -17
  109. package/dist/psql/types/index.js +7 -7
  110. package/dist/psql/types/scanner.js +8 -8
  111. package/dist/psql/wire/connection.js +341 -327
  112. package/dist/psql/wire/copy.js +7 -7
  113. package/dist/psql/wire/pipeline.js +26 -24
  114. package/dist/psql/wire/protocol.js +102 -102
  115. package/dist/psql/wire/sasl.js +62 -62
  116. package/dist/psql/wire/tls.js +79 -73
  117. package/dist/storage_api.js +15 -15
  118. package/dist/test_utils/fixtures.js +34 -31
  119. package/dist/test_utils/oauth_server.js +5 -5
  120. package/dist/utils/api_enums.js +13 -13
  121. package/dist/utils/branch_notice.js +5 -5
  122. package/dist/utils/branch_picker.js +26 -26
  123. package/dist/utils/compute_units.js +4 -4
  124. package/dist/utils/enrichers.js +20 -15
  125. package/dist/utils/esbuild.js +28 -28
  126. package/dist/utils/formats.js +1 -1
  127. package/dist/utils/middlewares.js +3 -3
  128. package/dist/utils/package_manager.js +68 -0
  129. package/dist/utils/point_in_time.js +12 -12
  130. package/dist/utils/psql.js +30 -30
  131. package/dist/utils/string.js +2 -2
  132. package/dist/utils/ui.js +9 -9
  133. package/dist/utils/zip.js +1 -1
  134. package/dist/writer.js +17 -17
  135. package/package.json +6 -7
@@ -1,95 +1,99 @@
1
- import { isNeonApiError, messageFromBody } from '../api.js';
2
- import prompts from 'prompts';
3
- import { applyContext, contextBranch, readContextFile, setContext, updateContextFile, } from '../context.js';
4
- import { isCi } from '../env.js';
5
- import { log } from '../log.js';
6
- import { createBranch, pickBranchInteractively, } from '../utils/branch_picker.js';
7
- import { autoPullEnvAfterPin, renderAgentPullNote } from './env.js';
8
- import { REGIONS } from './projects.js';
1
+ import prompts from "prompts";
2
+ import { isNeonApiError, messageFromBody } from "../api.js";
3
+ import { applyContext, contextBranch, readContextFile, setContext, updateContextFile, } from "../context.js";
4
+ import { isCi } from "../env.js";
5
+ import { log } from "../log.js";
6
+ import { createBranch, pickBranchInteractively, } from "../utils/branch_picker.js";
7
+ import { hasNeonConfigFile, initCmd } from "./config.js";
8
+ import { autoPullEnvAfterPin, renderAgentPullNote } from "./env.js";
9
+ import { REGIONS } from "./projects.js";
9
10
  const PROJECTS_LIST_LIMIT = 100;
10
- const CREATE_NEW_SENTINEL = '__create_new__';
11
- export const command = 'link';
12
- export const describe = 'Link the current directory to a Neon project';
11
+ const CREATE_NEW_SENTINEL = "__create_new__";
12
+ export const command = "link";
13
+ export const describe = "Link the current directory to a Neon project";
13
14
  export const builder = (argv) => argv
14
- .usage('$0 link [options]')
15
+ .usage("$0 link [options]")
15
16
  .options({
16
- 'org-id': {
17
- describe: 'Organization ID to link to',
18
- type: 'string',
17
+ "org-id": {
18
+ describe: "Organization ID to link to",
19
+ type: "string",
19
20
  },
20
- 'project-id': {
21
- describe: 'Existing project ID to link to',
22
- type: 'string',
21
+ "project-id": {
22
+ describe: "Existing project ID to link to",
23
+ type: "string",
23
24
  },
24
- 'project-name': {
25
- describe: 'Name for a new project to create and link to',
26
- type: 'string',
25
+ "project-name": {
26
+ describe: "Name for a new project to create and link to",
27
+ type: "string",
27
28
  },
28
- 'region-id': {
29
- describe: 'Region ID for a new project (e.g. aws-us-east-2). Required with --project-name.',
30
- type: 'string',
29
+ "region-id": {
30
+ describe: "Region ID for a new project (e.g. aws-us-east-2). Required with --project-name.",
31
+ type: "string",
31
32
  },
32
33
  branch: {
33
- alias: 'branch-id',
34
- describe: 'Branch name or ID to pin in the context (resolved to its ID before writing). ' +
35
- 'Without it, link only resolves the org and project — pin a branch with ' +
36
- '`neonctl checkout <branch>` (link never guesses a default).',
37
- type: 'string',
34
+ alias: "branch-id",
35
+ describe: "Branch name or ID to pin in the context (resolved to its ID before writing). " +
36
+ "Without it, link only resolves the org and project — pin a branch with " +
37
+ "`neonctl checkout <branch>` (link never guesses a default).",
38
+ type: "string",
38
39
  },
39
40
  params: {
40
41
  describe: 'JSON object with link parameters, e.g. \'{"orgId":"...","projectId":"..."}\' or \'{"orgId":"...","projectName":"...","regionId":"..."}\'. Flags take precedence over fields in --params.',
41
- type: 'string',
42
+ type: "string",
42
43
  },
43
44
  agent: {
44
- describe: 'Emit a JSON state-machine response designed for AI agents instead of prompting. The output is a single JSON object with a discriminated `status` field describing the next step.',
45
- type: 'boolean',
45
+ describe: "Emit a JSON state-machine response designed for AI agents instead of prompting. The output is a single JSON object with a discriminated `status` field describing the next step.",
46
+ type: "boolean",
46
47
  default: false,
47
48
  },
48
49
  yes: {
49
- alias: 'y',
50
+ alias: "y",
50
51
  describe: 'Skip the "already linked" confirmation in interactive mode and re-link anyway.',
51
- type: 'boolean',
52
+ type: "boolean",
52
53
  default: false,
53
54
  },
54
55
  clear: {
55
- describe: 'Remove the org/project/branch context (writes an empty context file) instead of linking.',
56
- type: 'boolean',
56
+ describe: "Remove the org/project/branch context (writes an empty context file) instead of linking.",
57
+ type: "boolean",
57
58
  default: false,
58
59
  },
59
60
  checks: {
60
- describe: 'Verify the org/project/branch exist (and resolve the org from the project) before ' +
61
- 'writing. On by default; use --no-checks to write the context offline with no API ' +
62
- 'calls — it then requires --org-id and --project-id (--branch optional) and skips ' +
63
- 'env pull.',
64
- type: 'boolean',
61
+ describe: "Verify the org/project/branch exist (and resolve the org from the project) before " +
62
+ "writing. On by default; use --no-checks to write the context offline with no API " +
63
+ "calls — it then requires --org-id and --project-id (--branch optional) and skips " +
64
+ "env pull.",
65
+ type: "boolean",
65
66
  default: true,
66
67
  },
67
- 'env-pull': {
68
+ "env-pull": {
68
69
  describe: "Pull the linked branch's Neon env vars (DATABASE_URL, …) into a local .env after " +
69
- 'linking. On by default; use --no-env-pull to skip (e.g. when injecting env at ' +
70
- 'runtime with `neon-env run` / `neon dev`). Only runs when a branch is pinned.',
71
- type: 'boolean',
70
+ "linking. On by default; use --no-env-pull to skip (e.g. when injecting env at " +
71
+ "runtime with `neon-env run` / `neon dev`). Only runs when a branch is pinned.",
72
+ type: "boolean",
72
73
  default: true,
73
74
  },
74
75
  })
75
76
  .example([
76
77
  [
77
- '$0 link --project-id polished-snowflake-12345678',
78
+ "$0 link --project-id polished-snowflake-12345678",
78
79
  "Link an existing project (org is inferred); pin a branch later with 'neonctl checkout'",
79
80
  ],
80
81
  [
81
- '$0 link --org-id org-… --project-name my-app --region-id aws-us-east-2',
82
- 'Create a new project and link it',
82
+ "$0 link --org-id org-… --project-name my-app --region-id aws-us-east-2",
83
+ "Create a new project and link it",
83
84
  ],
84
85
  [
85
- '$0 link --branch-id br-…',
86
- 'Pin a branch in the already-linked project',
86
+ "$0 link --branch-id br-…",
87
+ "Pin a branch in the already-linked project",
87
88
  ],
88
89
  [
89
- '$0 link --no-checks --org-id org-… --project-id polished-snowflake-12345678',
90
- 'Write the context offline (no API calls, no verification)',
90
+ "$0 link --no-checks --org-id org-… --project-id polished-snowflake-12345678",
91
+ "Write the context offline (no API calls, no verification)",
92
+ ],
93
+ [
94
+ "$0 link --clear",
95
+ "Forget the current org/project/branch context",
91
96
  ],
92
- ['$0 link --clear', 'Forget the current org/project/branch context'],
93
97
  ]);
94
98
  export const handler = async (props) => {
95
99
  if (props.clear) {
@@ -113,13 +117,13 @@ export const handler = async (props) => {
113
117
  }
114
118
  if (isCi()) {
115
119
  log.error([
116
- 'Missing inputs and CI environment detected (no TTY for prompts).',
117
- '',
118
- 'Use one of:',
119
- ' neonctl link --agent (JSON state machine for agents)',
120
- ' neonctl link --project-id <project> (link to an existing project; org is inferred)',
121
- ' neonctl link --org-id <org> --project-name <name> --region-id <region> (create a new project and link)',
122
- ].join('\n'));
120
+ "Missing inputs and CI environment detected (no TTY for prompts).",
121
+ "",
122
+ "Use one of:",
123
+ " neonctl link --agent (JSON state machine for agents)",
124
+ " neonctl link --project-id <project> (link to an existing project; org is inferred)",
125
+ " neonctl link --org-id <org> --project-name <name> --region-id <region> (create a new project and link)",
126
+ ].join("\n"));
123
127
  process.exit(1);
124
128
  return;
125
129
  }
@@ -130,7 +134,7 @@ export const handler = async (props) => {
130
134
  // ----------------------------------------------------------------------------
131
135
  const parseInputs = (props) => {
132
136
  let fromParams = {};
133
- if (props.params !== undefined && props.params !== '') {
137
+ if (props.params !== undefined && props.params !== "") {
134
138
  let parsed;
135
139
  try {
136
140
  parsed = JSON.parse(props.params);
@@ -150,33 +154,33 @@ const parseInputs = (props) => {
150
154
  };
151
155
  };
152
156
  const extractParams = (raw) => {
153
- if (raw === null || typeof raw !== 'object') {
154
- throw new Error('--params must be a JSON object');
157
+ if (raw === null || typeof raw !== "object") {
158
+ throw new Error("--params must be a JSON object");
155
159
  }
156
160
  const obj = raw;
157
161
  const pickString = (key) => {
158
162
  const value = obj[key];
159
163
  if (value === undefined || value === null)
160
164
  return undefined;
161
- if (typeof value !== 'string') {
165
+ if (typeof value !== "string") {
162
166
  throw new Error(`--params.${key} must be a string`);
163
167
  }
164
168
  return value;
165
169
  };
166
170
  return {
167
- orgId: pickString('orgId'),
168
- projectId: pickString('projectId'),
169
- projectName: pickString('projectName'),
170
- regionId: pickString('regionId'),
171
- branch: pickString('branch') ?? pickString('branchId'),
171
+ orgId: pickString("orgId"),
172
+ projectId: pickString("projectId"),
173
+ projectName: pickString("projectName"),
174
+ regionId: pickString("regionId"),
175
+ branch: pickString("branch") ?? pickString("branchId"),
172
176
  };
173
177
  };
174
178
  const validateInputs = (inputs) => {
175
179
  if (inputs.projectId && (inputs.projectName || inputs.regionId)) {
176
- throw new Error('Conflicting inputs: --project-id selects an existing project; --project-name and --region-id describe a new one. Pass only one set.');
180
+ throw new Error("Conflicting inputs: --project-id selects an existing project; --project-name and --region-id describe a new one. Pass only one set.");
177
181
  }
178
182
  if (inputs.projectName && inputs.branch) {
179
- 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 `neonctl checkout <branch>`.');
183
+ 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 `neonctl checkout <branch>`.");
180
184
  }
181
185
  };
182
186
  /**
@@ -224,7 +228,7 @@ const runWithoutChecks = (props) => {
224
228
  throw new Error("--no-checks can't create a project (that needs API access). Pass --org-id and --project-id for an existing project, or drop --no-checks.");
225
229
  }
226
230
  if (!inputs.orgId || !inputs.projectId) {
227
- throw new Error('--no-checks writes the context with no API calls, so it needs both --org-id and --project-id (--branch is optional).');
231
+ throw new Error("--no-checks writes the context with no API calls, so it needs both --org-id and --project-id (--branch is optional).");
228
232
  }
229
233
  setContext(props.contextFile, {
230
234
  orgId: inputs.orgId,
@@ -233,7 +237,7 @@ const runWithoutChecks = (props) => {
233
237
  });
234
238
  if (props.agent) {
235
239
  emitAgent({
236
- status: 'linked',
240
+ status: "linked",
237
241
  context_file: props.contextFile,
238
242
  context: {
239
243
  orgId: inputs.orgId,
@@ -241,7 +245,7 @@ const runWithoutChecks = (props) => {
241
245
  branch: inputs.branch,
242
246
  },
243
247
  project: { id: inputs.projectId },
244
- message: `Wrote ${props.contextFile} without checks (org ${inputs.orgId}, project ${inputs.projectId}${inputs.branch ? `, branch ${inputs.branch}` : ''}). No verification or env pull was performed.`,
248
+ message: `Wrote ${props.contextFile} without checks (org ${inputs.orgId}, project ${inputs.projectId}${inputs.branch ? `, branch ${inputs.branch}` : ""}). No verification or env pull was performed.`,
245
249
  });
246
250
  return;
247
251
  }
@@ -263,7 +267,7 @@ const runWithoutChecks = (props) => {
263
267
  class LinkInputError extends Error {
264
268
  constructor(message, agentCode) {
265
269
  super(message);
266
- this.name = 'LinkInputError';
270
+ this.name = "LinkInputError";
267
271
  this.agentCode = agentCode;
268
272
  }
269
273
  }
@@ -284,10 +288,10 @@ const fetchProjectOrThrow = async (props, projectId) => {
284
288
  throw err;
285
289
  }
286
290
  if (status === 403) {
287
- throw new LinkInputError(`You don't have access to project '${projectId}'. Check that your API key's account or organization can see it.`, 'NO_ACCESS');
291
+ throw new LinkInputError(`You don't have access to project '${projectId}'. Check that your API key's account or organization can see it.`, "NO_ACCESS");
288
292
  }
289
293
  if (status === 404) {
290
- throw new LinkInputError(`Project '${projectId}' not found. Double-check the project ID — or that your API key has access to it.`, 'NOT_FOUND');
294
+ throw new LinkInputError(`Project '${projectId}' not found. Double-check the project ID — or that your API key has access to it.`, "NOT_FOUND");
291
295
  }
292
296
  throw err;
293
297
  }
@@ -310,7 +314,7 @@ const verifyOrgAccess = async (props, orgId) => {
310
314
  throw err;
311
315
  }
312
316
  if (status === 403 || status === 404) {
313
- throw new LinkInputError(`Organization '${orgId}' not found, or your API key doesn't have access to it. Find your org ID in the Neon Console under Settings.`, status === 403 ? 'NO_ACCESS' : 'NOT_FOUND');
317
+ throw new LinkInputError(`Organization '${orgId}' not found, or your API key doesn't have access to it. Find your org ID in the Neon Console under Settings.`, status === 403 ? "NO_ACCESS" : "NOT_FOUND");
314
318
  }
315
319
  throw err;
316
320
  }
@@ -331,10 +335,10 @@ const resolveBranchRef = async (props, projectId, branchRef) => {
331
335
  }
332
336
  const available = data.branches.length > 0
333
337
  ? data.branches
334
- .map((b) => `${b.id}${b.name ? ` (${b.name})` : ''}`)
335
- .join(', ')
336
- : '(none)';
337
- throw new LinkInputError(`Branch '${branchRef}' not found in project '${projectId}'. Available branches: ${available}. Pin one with \`neonctl checkout <branch>\`.`, 'NOT_FOUND');
338
+ .map((b) => `${b.id}${b.name ? ` (${b.name})` : ""}`)
339
+ .join(", ")
340
+ : "(none)";
341
+ throw new LinkInputError(`Branch '${branchRef}' not found in project '${projectId}'. Available branches: ${available}. Pin one with \`neonctl checkout <branch>\`.`, "NOT_FOUND");
338
342
  };
339
343
  /**
340
344
  * The value to persist for a branch: prefer its human-readable **name** (nicer
@@ -359,7 +363,7 @@ const resolveOrgForProject = async (props, inputs, existing, projectId) => {
359
363
  const projectOrg = project.org_id ?? undefined;
360
364
  if (inputs.orgId) {
361
365
  if (projectOrg && projectOrg !== inputs.orgId) {
362
- throw new LinkInputError(`Project '${projectId}' belongs to organization '${projectOrg}', not '${inputs.orgId}'. Omit --org-id to use the project's own org, or pass the matching ID.`, 'ORG_MISMATCH');
366
+ throw new LinkInputError(`Project '${projectId}' belongs to organization '${projectOrg}', not '${inputs.orgId}'. Omit --org-id to use the project's own org, or pass the matching ID.`, "ORG_MISMATCH");
363
367
  }
364
368
  if (!projectOrg) {
365
369
  await verifyOrgAccess(props, inputs.orgId);
@@ -399,12 +403,12 @@ const resolvePinnedBranch = async (props, inputs, existing, projectId) => {
399
403
  const runNonInteractive = async (props, inputs, existing) => {
400
404
  // Create a new project and link it.
401
405
  if (inputs.projectName) {
402
- const orgId = mustString(inputs.orgId, 'orgId');
406
+ const orgId = mustString(inputs.orgId, "orgId");
403
407
  await verifyOrgAccess(props, orgId);
404
408
  const created = await createProject(props, {
405
409
  orgId,
406
410
  name: inputs.projectName,
407
- regionId: mustString(inputs.regionId, 'regionId'),
411
+ regionId: mustString(inputs.regionId, "regionId"),
408
412
  });
409
413
  applyContext(props.contextFile, {
410
414
  orgId,
@@ -486,16 +490,16 @@ const runInteractive = async (props, inputs) => {
486
490
  }
487
491
  const orgResolution = await resolveOrg(props, inputs.orgId);
488
492
  let orgId;
489
- if (orgResolution.kind === 'resolved') {
493
+ if (orgResolution.kind === "resolved") {
490
494
  orgId = orgResolution.orgId;
491
495
  if (orgResolution.autoDetected) {
492
496
  log.info(`Detected organization ${orgId} from your existing projects (organization-scoped API key).`);
493
497
  }
494
498
  }
495
499
  else if (orgResolution.orgKeyLimited) {
496
- throw new Error('This API key is organization-scoped, so the CLI cannot list your organizations, ' +
497
- 'and no existing project was found in this org to auto-detect the ID. ' +
498
- 'Re-run with `--org-id <your_org_id>` (find it in the Neon Console under Settings).');
500
+ throw new Error("This API key is organization-scoped, so the CLI cannot list your organizations, " +
501
+ "and no existing project was found in this org to auto-detect the ID. " +
502
+ "Re-run with `--org-id <your_org_id>` (find it in the Neon Console under Settings).");
499
503
  }
500
504
  else {
501
505
  orgId = await promptOrgFromList(orgResolution.orgs);
@@ -511,7 +515,7 @@ const runInteractive = async (props, inputs) => {
511
515
  projectId: created.project.id,
512
516
  branch: created.branchName,
513
517
  });
514
- await finalizeLink(props, {
518
+ await finalizeInteractiveLink(props, {
515
519
  contextFile: props.contextFile,
516
520
  orgId,
517
521
  projectId: created.project.id,
@@ -525,14 +529,14 @@ const runInteractive = async (props, inputs) => {
525
529
  // Need to ask: existing project or create a new one?
526
530
  const projects = await listAllProjects(props, orgId);
527
531
  const action = await promptProjectChoice(projects, inputs.projectName);
528
- if (action.type === 'existing') {
532
+ if (action.type === "existing") {
529
533
  const branch = await resolveInteractiveBranch(props, action.projectId);
530
534
  applyContext(props.contextFile, {
531
535
  orgId,
532
536
  projectId: action.projectId,
533
537
  branch,
534
538
  });
535
- await finalizeLink(props, {
539
+ await finalizeInteractiveLink(props, {
536
540
  contextFile: props.contextFile,
537
541
  orgId,
538
542
  projectId: action.projectId,
@@ -555,7 +559,7 @@ const runInteractive = async (props, inputs) => {
555
559
  projectId: created.project.id,
556
560
  branch: created.branchName,
557
561
  });
558
- await finalizeLink(props, {
562
+ await finalizeInteractiveLink(props, {
559
563
  contextFile: props.contextFile,
560
564
  orgId,
561
565
  projectId: created.project.id,
@@ -572,13 +576,13 @@ const confirmRelinkIfNeeded = async (props) => {
572
576
  }
573
577
  const { proceed } = await prompts({
574
578
  onState: onPromptState,
575
- type: 'confirm',
576
- name: 'proceed',
579
+ type: "confirm",
580
+ name: "proceed",
577
581
  message: `${props.contextFile} is already linked to project ${existing.projectId} (org ${existing.orgId}). Re-link?`,
578
582
  initial: true,
579
583
  });
580
584
  if (!proceed) {
581
- process.stdout.write('Aborted. Existing link preserved.\n');
585
+ process.stdout.write("Aborted. Existing link preserved.\n");
582
586
  process.exit(0);
583
587
  }
584
588
  };
@@ -595,9 +599,9 @@ const promptOrgFromList = async (orgs) => {
595
599
  }
596
600
  const { orgId } = await prompts({
597
601
  onState: onPromptState,
598
- type: 'select',
599
- name: 'orgId',
600
- message: 'Which organization would you like to link?',
602
+ type: "select",
603
+ name: "orgId",
604
+ message: "Which organization would you like to link?",
601
605
  choices: orgs.map((org) => ({
602
606
  title: `${org.name} (${org.id})`,
603
607
  value: org.id,
@@ -608,7 +612,7 @@ const promptOrgFromList = async (orgs) => {
608
612
  };
609
613
  const promptProjectChoice = async (projects, suggestedName) => {
610
614
  const choices = [
611
- { title: '+ Create new project…', value: CREATE_NEW_SENTINEL },
615
+ { title: "+ Create new project…", value: CREATE_NEW_SENTINEL },
612
616
  ...projects.map((project) => ({
613
617
  title: `${project.name} (${project.id})`,
614
618
  value: project.id,
@@ -618,18 +622,18 @@ const promptProjectChoice = async (projects, suggestedName) => {
618
622
  // is one; with no projects to show, the create option (index 0) is the only choice.
619
623
  const { selection } = await prompts({
620
624
  onState: onPromptState,
621
- type: 'select',
622
- name: 'selection',
623
- message: 'Which project would you like to link?',
625
+ type: "select",
626
+ name: "selection",
627
+ message: "Which project would you like to link?",
624
628
  choices,
625
629
  initial: projects.length > 0 ? 1 : 0,
626
630
  });
627
631
  if (selection === CREATE_NEW_SENTINEL) {
628
- return { type: 'create', suggestedName };
632
+ return { type: "create", suggestedName };
629
633
  }
630
634
  const project = projects.find((p) => p.id === selection);
631
635
  return {
632
- type: 'existing',
636
+ type: "existing",
633
637
  projectId: selection,
634
638
  name: project?.name,
635
639
  regionId: project?.region_id,
@@ -638,11 +642,13 @@ const promptProjectChoice = async (projects, suggestedName) => {
638
642
  const promptProjectName = async (suggestedName) => {
639
643
  const { name } = await prompts({
640
644
  onState: onPromptState,
641
- type: 'text',
642
- name: 'name',
643
- message: 'Name for the new project:',
645
+ type: "text",
646
+ name: "name",
647
+ message: "Name for the new project:",
644
648
  initial: suggestedName,
645
- validate: (value) => value && value.trim().length > 0 ? true : 'Project name is required',
649
+ validate: (value) => value && value.trim().length > 0
650
+ ? true
651
+ : "Project name is required",
646
652
  });
647
653
  return String(name).trim();
648
654
  };
@@ -651,9 +657,9 @@ const promptRegion = async (props) => {
651
657
  const defaultIndex = Math.max(0, regions.findIndex((r) => r.default));
652
658
  const { regionId } = await prompts({
653
659
  onState: onPromptState,
654
- type: 'select',
655
- name: 'regionId',
656
- message: 'Which region should the new project run in?',
660
+ type: "select",
661
+ name: "regionId",
662
+ message: "Which region should the new project run in?",
657
663
  choices: regions.map((region) => ({
658
664
  title: `${region.name} (${region.region_id})`,
659
665
  value: region.region_id,
@@ -689,7 +695,7 @@ const runAgent = async (props, inputs) => {
689
695
  projectId,
690
696
  branch: pinnedBranch,
691
697
  });
692
- const orgSuffix = orgId ? ` (org ${orgId})` : '';
698
+ const orgSuffix = orgId ? ` (org ${orgId})` : "";
693
699
  if (pinnedBranch) {
694
700
  const pullNote = renderAgentPullNote(await autoPullEnvAfterPin({
695
701
  ...props,
@@ -698,7 +704,7 @@ const runAgent = async (props, inputs) => {
698
704
  envPull: props.envPull,
699
705
  }));
700
706
  emitAgent({
701
- status: 'linked',
707
+ status: "linked",
702
708
  context_file: props.contextFile,
703
709
  context: { orgId, projectId, branch: pinnedBranch },
704
710
  project: { id: projectId },
@@ -707,7 +713,7 @@ const runAgent = async (props, inputs) => {
707
713
  return;
708
714
  }
709
715
  emitAgent({
710
- status: 'linked',
716
+ status: "linked",
711
717
  context_file: props.contextFile,
712
718
  context: { orgId, projectId },
713
719
  project: { id: projectId },
@@ -716,7 +722,7 @@ const runAgent = async (props, inputs) => {
716
722
  return;
717
723
  }
718
724
  const orgResolution = await resolveOrg(props, inputs.orgId);
719
- if (orgResolution.kind === 'needs_selection') {
725
+ if (orgResolution.kind === "needs_selection") {
720
726
  emitAgent(buildNeedsOrgResponse(orgResolution));
721
727
  return;
722
728
  }
@@ -724,7 +730,7 @@ const runAgent = async (props, inputs) => {
724
730
  if (projectName && !regionId) {
725
731
  const regions = await fetchRegions(props);
726
732
  emitAgent({
727
- status: 'needs_project_details',
733
+ status: "needs_project_details",
728
734
  instruction: `Ask the user which region to create project "${projectName}" in. After they pick one, re-run the next_command_template with the chosen --region-id value.`,
729
735
  regions: regions.map((region) => ({
730
736
  id: region.region_id,
@@ -754,7 +760,7 @@ const runAgent = async (props, inputs) => {
754
760
  envPull: props.envPull,
755
761
  }));
756
762
  emitAgent({
757
- status: 'linked',
763
+ status: "linked",
758
764
  context_file: props.contextFile,
759
765
  context: {
760
766
  orgId,
@@ -776,9 +782,9 @@ const runAgent = async (props, inputs) => {
776
782
  const projects = await listAllProjects(props, orgId);
777
783
  const branchNote = branch
778
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 \`neonctl checkout ${branch}\`.`
779
- : '';
785
+ : "";
780
786
  emitAgent({
781
- status: 'needs_project',
787
+ status: "needs_project",
782
788
  instruction: (projects.length === 0
783
789
  ? `Organization ${orgId} has no projects yet. Ask the user for a name for the new project, then re-run the create_option.next_command_template.`
784
790
  : `Ask the user whether to link to one of these ${projects.length} existing projects (use next_command_template with --project-id) or create a new project (use create_option.next_command_template).`) +
@@ -789,7 +795,7 @@ const runAgent = async (props, inputs) => {
789
795
  region_id: project.region_id,
790
796
  })),
791
797
  create_option: {
792
- 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.',
798
+ 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.",
793
799
  next_command_template: `neonctl link --agent --org-id ${shellArg(orgId)} --project-name <name> --region-id <region_id>`,
794
800
  },
795
801
  next_command_template: `neonctl link --agent --org-id ${shellArg(orgId)} --project-id <project_id>`,
@@ -801,12 +807,13 @@ const emitAgent = (response) => {
801
807
  // ----------------------------------------------------------------------------
802
808
  // API helpers
803
809
  // ----------------------------------------------------------------------------
804
- const ORG_KEY_LIMITED_FRAGMENT = 'not allowed for organization API keys';
810
+ const ORG_KEY_LIMITED_FRAGMENT = "not allowed for organization API keys";
805
811
  const isOrgKeyLimitedError = (err) => {
806
812
  if (!isNeonApiError(err))
807
813
  return false;
808
814
  const message = messageFromBody(err.data);
809
- return (typeof message === 'string' && message.includes(ORG_KEY_LIMITED_FRAGMENT));
815
+ return (typeof message === "string" &&
816
+ message.includes(ORG_KEY_LIMITED_FRAGMENT));
810
817
  };
811
818
  const fetchOrganizations = async (props) => {
812
819
  const { data } = await props.apiClient.getCurrentUserOrganizations();
@@ -823,23 +830,23 @@ const fetchOrganizations = async (props) => {
823
830
  */
824
831
  const resolveOrg = async (props, given) => {
825
832
  if (given) {
826
- return { kind: 'resolved', orgId: given, autoDetected: false };
833
+ return { kind: "resolved", orgId: given, autoDetected: false };
827
834
  }
828
835
  try {
829
836
  const orgs = await fetchOrganizations(props);
830
- return { kind: 'needs_selection', orgs, orgKeyLimited: false };
837
+ return { kind: "needs_selection", orgs, orgKeyLimited: false };
831
838
  }
832
839
  catch (err) {
833
840
  if (!isOrgKeyLimitedError(err)) {
834
841
  throw err;
835
842
  }
836
- log.debug('getCurrentUserOrganizations not allowed (org-scoped API key); attempting to derive org from existing projects.');
843
+ log.debug("getCurrentUserOrganizations not allowed (org-scoped API key); attempting to derive org from existing projects.");
837
844
  }
838
845
  const detected = await detectOrgIdFromProjects(props);
839
846
  if (detected) {
840
- return { kind: 'resolved', orgId: detected, autoDetected: true };
847
+ return { kind: "resolved", orgId: detected, autoDetected: true };
841
848
  }
842
- return { kind: 'needs_selection', orgs: [], orgKeyLimited: true };
849
+ return { kind: "needs_selection", orgs: [], orgKeyLimited: true };
843
850
  };
844
851
  const detectOrgIdFromProjects = async (props) => {
845
852
  try {
@@ -848,32 +855,32 @@ const detectOrgIdFromProjects = async (props) => {
848
855
  }
849
856
  catch (err) {
850
857
  const message = err instanceof Error ? err.message : String(err);
851
- log.debug('detectOrgIdFromProjects failed: %s', message);
858
+ log.debug("detectOrgIdFromProjects failed: %s", message);
852
859
  return undefined;
853
860
  }
854
861
  };
855
862
  const buildNeedsOrgResponse = (resolution) => {
856
863
  if (resolution.orgKeyLimited) {
857
864
  return {
858
- status: 'needs_org',
865
+ status: "needs_org",
859
866
  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.",
860
867
  options: [],
861
- next_command_template: 'neonctl link --agent --org-id <org_id>',
868
+ next_command_template: "neonctl link --agent --org-id <org_id>",
862
869
  };
863
870
  }
864
871
  const orgs = resolution.orgs;
865
872
  return {
866
- status: 'needs_org',
873
+ status: "needs_org",
867
874
  instruction: orgs.length === 0
868
- ? 'The user does not belong to any organizations. Ask them to create one in the Neon Console (https://console.neon.tech/) before linking.'
869
- : `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.`,
875
+ ? "The user does not belong to any organizations. Ask them to create one in the Neon Console (https://console.neon.tech/) before linking."
876
+ : `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.`,
870
877
  options: orgs.map((org) => ({ id: org.id, name: org.name })),
871
- next_command_template: 'neonctl link --agent --org-id <org_id>',
878
+ next_command_template: "neonctl link --agent --org-id <org_id>",
872
879
  };
873
880
  };
874
881
  const toAgentError = (err) => {
875
882
  if (err instanceof LinkInputError) {
876
- return { status: 'error', code: err.agentCode, message: err.message };
883
+ return { status: "error", code: err.agentCode, message: err.message };
877
884
  }
878
885
  if (isNeonApiError(err)) {
879
886
  const status = err.status;
@@ -881,27 +888,31 @@ const toAgentError = (err) => {
881
888
  const message = apiMessage !== undefined && apiMessage.length > 0
882
889
  ? apiMessage
883
890
  : err.message;
884
- let code = 'API_ERROR';
891
+ let code = "API_ERROR";
885
892
  if (status === 401 || status === 403) {
886
- code = 'AUTH_ERROR';
893
+ code = "AUTH_ERROR";
887
894
  }
888
895
  else if (status !== undefined && status >= 400 && status < 500) {
889
- code = 'CLIENT_ERROR';
896
+ code = "CLIENT_ERROR";
890
897
  }
891
898
  else if (status !== undefined && status >= 500) {
892
- code = 'SERVER_ERROR';
899
+ code = "SERVER_ERROR";
893
900
  }
894
- else if (err.code === 'ECONNABORTED') {
895
- code = 'TIMEOUT';
901
+ else if (err.code === "ECONNABORTED") {
902
+ code = "TIMEOUT";
896
903
  }
897
- return { status: 'error', code, message };
904
+ return { status: "error", code, message };
898
905
  }
899
906
  if (err instanceof Error) {
900
- return { status: 'error', code: 'INTERNAL_ERROR', message: err.message };
907
+ return {
908
+ status: "error",
909
+ code: "INTERNAL_ERROR",
910
+ message: err.message,
911
+ };
901
912
  }
902
913
  return {
903
- status: 'error',
904
- code: 'INTERNAL_ERROR',
914
+ status: "error",
915
+ code: "INTERNAL_ERROR",
905
916
  message: String(err),
906
917
  };
907
918
  };
@@ -941,11 +952,11 @@ const resolveInteractiveBranch = async (props, projectId) => {
941
952
  return branchPersistValue(only);
942
953
  }
943
954
  const picked = await pickBranchInteractively(branches, {
944
- message: 'Which branch would you like to link?',
945
- nonInteractiveMessage: 'No branch could be selected without an interactive terminal. ' +
946
- 'Re-run `neonctl link` interactively, or `neonctl checkout <branch>` to pin one.',
955
+ message: "Which branch would you like to link?",
956
+ nonInteractiveMessage: "No branch could be selected without an interactive terminal. " +
957
+ "Re-run `neonctl link` interactively, or `neonctl checkout <branch>` to pin one.",
947
958
  });
948
- if (picked.kind === 'existing') {
959
+ if (picked.kind === "existing") {
949
960
  const existing = branches.find((b) => b.id === picked.branchId);
950
961
  return existing ? branchPersistValue(existing) : picked.branchId;
951
962
  }
@@ -962,11 +973,11 @@ const fetchRegions = async (props) => {
962
973
  }
963
974
  catch (err) {
964
975
  if (isNeonApiError(err)) {
965
- log.debug('getActiveRegions failed (%s), falling back to the static region list.', err.status ?? err.code ?? err.message);
976
+ log.debug("getActiveRegions failed (%s), falling back to the static region list.", err.status ?? err.code ?? err.message);
966
977
  }
967
978
  else {
968
979
  const message = err instanceof Error ? err.message : String(err);
969
- log.debug('getActiveRegions failed (%s), falling back to the static region list.', message);
980
+ log.debug("getActiveRegions failed (%s), falling back to the static region list.", message);
970
981
  }
971
982
  }
972
983
  return staticRegionsFallback();
@@ -974,9 +985,9 @@ const fetchRegions = async (props) => {
974
985
  const staticRegionsFallback = () => REGIONS.map((id) => ({
975
986
  region_id: id,
976
987
  name: id,
977
- default: id === 'aws-us-east-2',
978
- geo_lat: '',
979
- geo_long: '',
988
+ default: id === "aws-us-east-2",
989
+ geo_lat: "",
990
+ geo_long: "",
980
991
  }));
981
992
  const createProject = async (props, args) => {
982
993
  const project = {
@@ -987,7 +998,7 @@ const createProject = async (props, args) => {
987
998
  };
988
999
  const { data } = await props.apiClient.createProject({ project });
989
1000
  if (!data.branch?.id) {
990
- throw new Error('Project was created but the API response did not include a default branch id.');
1001
+ throw new Error("Project was created but the API response did not include a default branch id.");
991
1002
  }
992
1003
  return {
993
1004
  project: {
@@ -1002,9 +1013,9 @@ const createProject = async (props, args) => {
1002
1013
  const printSummary = (_props, summary) => {
1003
1014
  const lines = [];
1004
1015
  if (summary.created) {
1005
- lines.push(`Created project ${summary.projectId}${summary.projectName ? ` ("${summary.projectName}")` : ''}${summary.regionId ? ` in ${summary.regionId}` : ''}.`);
1016
+ lines.push(`Created project ${summary.projectId}${summary.projectName ? ` ("${summary.projectName}")` : ""}${summary.regionId ? ` in ${summary.regionId}` : ""}.`);
1006
1017
  }
1007
- lines.push(`${summary.orgOnly ? 'Updated' : 'Linked'} ${summary.contextFile}:`);
1018
+ lines.push(`${summary.orgOnly ? "Updated" : "Linked"} ${summary.contextFile}:`);
1008
1019
  if (summary.orgId) {
1009
1020
  lines.push(` orgId: ${summary.orgId}`);
1010
1021
  }
@@ -1015,15 +1026,15 @@ const printSummary = (_props, summary) => {
1015
1026
  lines.push(` branch: ${summary.branch}`);
1016
1027
  }
1017
1028
  if (summary.noChecks) {
1018
- lines.push('');
1019
- lines.push('Written offline (--no-checks): nothing was verified.');
1029
+ lines.push("");
1030
+ lines.push("Written offline (--no-checks): nothing was verified.");
1020
1031
  }
1021
1032
  else if (summary.projectId && !summary.branch && !summary.orgOnly) {
1022
- lines.push('');
1023
- lines.push('No branch pinned. Run `neonctl checkout <branch>` to pin a branch and pull its env vars.');
1033
+ lines.push("");
1034
+ lines.push("No branch pinned. Run `neonctl checkout <branch>` to pin a branch and pull its env vars.");
1024
1035
  }
1025
- lines.push('');
1026
- process.stdout.write(`${lines.join('\n')}\n`);
1036
+ lines.push("");
1037
+ process.stdout.write(`${lines.join("\n")}\n`);
1027
1038
  };
1028
1039
  /**
1029
1040
  * Print the link summary, then run the bundled `env pull` so a human `link` that pinned a
@@ -1043,10 +1054,56 @@ const finalizeLink = async (props, summary) => {
1043
1054
  envPull: props.envPull,
1044
1055
  });
1045
1056
  };
1057
+ /**
1058
+ * Interactive `link` finalize: the shared {@link finalizeLink} (summary + env
1059
+ * pull), then — as the last step — offer to manage the project's Neon setup as
1060
+ * code with a `neon.ts`. Kept out of {@link finalizeLink} so the non-interactive
1061
+ * paths never prompt.
1062
+ */
1063
+ const finalizeInteractiveLink = async (props, summary) => {
1064
+ await finalizeLink(props, summary);
1065
+ await maybeOfferConfigInit(props, summary);
1066
+ };
1067
+ /**
1068
+ * Offer to set up infrastructure-as-code at the end of an interactive `link` —
1069
+ * the natural moment, since the project is now linked. Skipped when the project
1070
+ * already has a `neon.ts` (nothing to scaffold). On yes, `config init` writes the
1071
+ * starter `neon.ts` and installs the config packages, then env is pulled again so
1072
+ * the local `.env` reflects the policy — the same pull `link` runs when a project
1073
+ * already ships a `neon.ts`.
1074
+ */
1075
+ const maybeOfferConfigInit = async (props, summary) => {
1076
+ const cwd = process.cwd();
1077
+ if (hasNeonConfigFile(cwd)) {
1078
+ return;
1079
+ }
1080
+ const { value } = await prompts({
1081
+ onState: onPromptState,
1082
+ type: "confirm",
1083
+ name: "value",
1084
+ message: "Manage this project's Neon setup as code? Adds a neon.ts you can edit and apply with `neon config apply`.",
1085
+ initial: true,
1086
+ });
1087
+ if (value !== true) {
1088
+ return;
1089
+ }
1090
+ await initCmd({ cwd, install: true });
1091
+ // The neon.ts (and its deps) now exist — pull env again so the local .env
1092
+ // reflects the policy, matching how `link` pulls when a project already ships
1093
+ // a neon.ts. Only meaningful when a branch was pinned (same guard as finalize).
1094
+ if (summary.branch && summary.projectId) {
1095
+ await autoPullEnvAfterPin({
1096
+ ...props,
1097
+ projectId: summary.projectId,
1098
+ branch: summary.branch,
1099
+ envPull: props.envPull,
1100
+ });
1101
+ }
1102
+ };
1046
1103
  const onPromptState = (state) => {
1047
1104
  if (state.aborted) {
1048
- process.stdout.write('\x1B[?25h');
1049
- process.stdout.write('\n');
1105
+ process.stdout.write("\x1B[?25h");
1106
+ process.stdout.write("\n");
1050
1107
  process.exit(1);
1051
1108
  }
1052
1109
  };