neon 2.1.2 → 2.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/LICENSE.md +178 -0
  2. package/README.md +601 -0
  3. package/dist/analytics.js +156 -0
  4. package/dist/api.js +665 -0
  5. package/dist/auth.js +127 -0
  6. package/dist/callback.html +51 -0
  7. package/dist/cli.js +9 -0
  8. package/dist/commands/auth.js +214 -0
  9. package/dist/commands/bootstrap.js +481 -0
  10. package/dist/commands/branches.js +481 -0
  11. package/dist/commands/bucket.js +543 -0
  12. package/dist/commands/checkout.js +289 -0
  13. package/dist/commands/config.js +544 -0
  14. package/dist/commands/connection_string.js +172 -0
  15. package/dist/commands/data_api.js +285 -0
  16. package/dist/commands/databases.js +82 -0
  17. package/dist/commands/deploy.js +26 -0
  18. package/dist/commands/dev.js +698 -0
  19. package/dist/commands/env.js +166 -0
  20. package/dist/commands/functions.js +373 -0
  21. package/dist/commands/index.js +54 -0
  22. package/dist/commands/init.js +73 -0
  23. package/dist/commands/ip_allow.js +137 -0
  24. package/dist/commands/link.js +1121 -0
  25. package/dist/commands/neon_auth.js +1028 -0
  26. package/dist/commands/operations.js +28 -0
  27. package/dist/commands/orgs.js +24 -0
  28. package/dist/commands/projects.js +372 -0
  29. package/dist/commands/psql.js +62 -0
  30. package/dist/commands/roles.js +65 -0
  31. package/dist/commands/schema_diff.js +151 -0
  32. package/dist/commands/set_context.js +29 -0
  33. package/dist/commands/status.js +40 -0
  34. package/dist/commands/user.js +15 -0
  35. package/dist/commands/vpc_endpoints.js +134 -0
  36. package/dist/config.js +11 -0
  37. package/dist/config_format.js +72 -0
  38. package/dist/context.js +177 -0
  39. package/dist/current_branch_fast_path.js +55 -0
  40. package/dist/dev/env.js +240 -0
  41. package/dist/dev/functions.js +70 -0
  42. package/dist/dev/inputs.js +63 -0
  43. package/dist/dev/runtime.js +146 -0
  44. package/dist/env.js +36 -0
  45. package/dist/env_file.js +159 -0
  46. package/dist/errors.js +80 -0
  47. package/dist/functions_api.js +44 -0
  48. package/dist/help.js +146 -0
  49. package/dist/index.js +234 -0
  50. package/dist/log.js +18 -0
  51. package/dist/parameters.gen.js +480 -0
  52. package/dist/pkg.js +25 -0
  53. package/dist/psql/cli.js +53 -0
  54. package/dist/psql/command/cmd_cond.js +437 -0
  55. package/dist/psql/command/cmd_connect.js +820 -0
  56. package/dist/psql/command/cmd_copy.js +1035 -0
  57. package/dist/psql/command/cmd_describe.js +1815 -0
  58. package/dist/psql/command/cmd_format.js +922 -0
  59. package/dist/psql/command/cmd_io.js +2193 -0
  60. package/dist/psql/command/cmd_lo.js +393 -0
  61. package/dist/psql/command/cmd_meta.js +970 -0
  62. package/dist/psql/command/cmd_misc.js +187 -0
  63. package/dist/psql/command/cmd_pipeline.js +1148 -0
  64. package/dist/psql/command/cmd_restrict.js +171 -0
  65. package/dist/psql/command/cmd_show.js +766 -0
  66. package/dist/psql/command/dispatch.js +343 -0
  67. package/dist/psql/command/inputQueue.js +42 -0
  68. package/dist/psql/command/shared.js +71 -0
  69. package/dist/psql/complete/filenames.js +139 -0
  70. package/dist/psql/complete/index.js +104 -0
  71. package/dist/psql/complete/matcher.js +315 -0
  72. package/dist/psql/complete/psqlVars.js +247 -0
  73. package/dist/psql/complete/queries.js +493 -0
  74. package/dist/psql/complete/rules.js +2424 -0
  75. package/dist/psql/core/common.js +1253 -0
  76. package/dist/psql/core/help.js +576 -0
  77. package/dist/psql/core/mainloop.js +1360 -0
  78. package/dist/psql/core/prompt.js +440 -0
  79. package/dist/psql/core/settings.js +684 -0
  80. package/dist/psql/core/sqlHelp.js +1066 -0
  81. package/dist/psql/core/startup.js +846 -0
  82. package/dist/psql/core/syncVars.js +116 -0
  83. package/dist/psql/core/variables.js +287 -0
  84. package/dist/psql/describe/formatters.js +1290 -0
  85. package/dist/psql/describe/processNamePattern.js +270 -0
  86. package/dist/psql/describe/queries.js +2378 -0
  87. package/dist/psql/describe/versionGate.js +43 -0
  88. package/dist/psql/index.js +2030 -0
  89. package/dist/psql/io/history.js +299 -0
  90. package/dist/psql/io/input.js +120 -0
  91. package/dist/psql/io/lineEditor/buffer.js +325 -0
  92. package/dist/psql/io/lineEditor/complete.js +227 -0
  93. package/dist/psql/io/lineEditor/filename.js +159 -0
  94. package/dist/psql/io/lineEditor/index.js +894 -0
  95. package/dist/psql/io/lineEditor/keymap.js +745 -0
  96. package/dist/psql/io/lineEditor/vt100.js +363 -0
  97. package/dist/psql/io/pgpass.js +202 -0
  98. package/dist/psql/io/pgservice.js +194 -0
  99. package/dist/psql/io/psqlrc.js +422 -0
  100. package/dist/psql/print/aligned.js +1765 -0
  101. package/dist/psql/print/asciidoc.js +248 -0
  102. package/dist/psql/print/crosstab.js +463 -0
  103. package/dist/psql/print/csv.js +95 -0
  104. package/dist/psql/print/html.js +258 -0
  105. package/dist/psql/print/json.js +96 -0
  106. package/dist/psql/print/latex.js +396 -0
  107. package/dist/psql/print/pager.js +267 -0
  108. package/dist/psql/print/troff.js +258 -0
  109. package/dist/psql/print/unaligned.js +119 -0
  110. package/dist/psql/print/units.js +135 -0
  111. package/dist/psql/scanner/slash.js +515 -0
  112. package/dist/psql/scanner/sql.js +914 -0
  113. package/dist/psql/scanner/stringutils.js +394 -0
  114. package/dist/psql/types/backslash.js +1 -0
  115. package/dist/psql/types/connection.js +1 -0
  116. package/dist/psql/types/index.js +7 -0
  117. package/dist/psql/types/printer.js +1 -0
  118. package/dist/psql/types/repl.js +1 -0
  119. package/dist/psql/types/scanner.js +24 -0
  120. package/dist/psql/types/settings.js +1 -0
  121. package/dist/psql/types/variables.js +1 -0
  122. package/dist/psql/wire/connection.js +2858 -0
  123. package/dist/psql/wire/copy.js +108 -0
  124. package/dist/psql/wire/notify.js +59 -0
  125. package/dist/psql/wire/pipeline.js +521 -0
  126. package/dist/psql/wire/protocol.js +466 -0
  127. package/dist/psql/wire/sasl.js +296 -0
  128. package/dist/psql/wire/tls.js +602 -0
  129. package/dist/storage_api.js +147 -0
  130. package/dist/test_utils/fixtures.js +122 -0
  131. package/dist/test_utils/oauth_server.js +9 -0
  132. package/dist/types.js +1 -0
  133. package/dist/utils/api_enums.js +33 -0
  134. package/dist/utils/auth.js +5 -0
  135. package/dist/utils/branch_notice.js +22 -0
  136. package/dist/utils/branch_picker.js +103 -0
  137. package/dist/utils/compute_units.js +28 -0
  138. package/dist/utils/enrichers.js +161 -0
  139. package/dist/utils/esbuild.js +158 -0
  140. package/dist/utils/formats.js +18 -0
  141. package/dist/utils/middlewares.js +20 -0
  142. package/dist/utils/package_manager.js +68 -0
  143. package/dist/utils/point_in_time.js +56 -0
  144. package/dist/utils/psql.js +120 -0
  145. package/dist/utils/string.js +5 -0
  146. package/dist/utils/ui.js +59 -0
  147. package/dist/utils/zip.js +4 -0
  148. package/dist/writer.js +97 -0
  149. package/package.json +117 -14
  150. package/.jshintrc +0 -3
  151. package/CHANGELOG.markdown +0 -22
  152. package/LICENSE +0 -21
  153. package/README.markdown +0 -76
  154. package/bower.json +0 -23
  155. package/license.txt +0 -9
  156. package/neon.js +0 -234
  157. package/stdlib/bubbling_support.js +0 -32
  158. package/stdlib/custom_event.js +0 -54
  159. package/stdlib/custom_event_support.js +0 -195
  160. package/stdlib/index.js +0 -7
  161. package/stdlib/node_support.js +0 -123
  162. package/stdlib/widget.js +0 -340
  163. package/test/neon_browser.html +0 -11
  164. package/test/neon_stdlib_browser.html +0 -15
  165. package/test/neon_stdlib_test.js +0 -84
  166. package/test/neon_test.js +0 -64
@@ -0,0 +1,1121 @@
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";
10
+ const PROJECTS_LIST_LIMIT = 100;
11
+ const CREATE_NEW_SENTINEL = "__create_new__";
12
+ export const command = "link";
13
+ export const describe = "Link the current directory to a Neon project";
14
+ export const builder = (argv) => argv
15
+ .usage("$0 link [options]")
16
+ .options({
17
+ "org-id": {
18
+ describe: "Organization ID to link to",
19
+ type: "string",
20
+ },
21
+ "project-id": {
22
+ describe: "Existing project ID to link to",
23
+ type: "string",
24
+ },
25
+ "project-name": {
26
+ describe: "Name for a new project to create and link to",
27
+ type: "string",
28
+ },
29
+ "region-id": {
30
+ describe: "Region ID for a new project (e.g. aws-us-east-2). Required with --project-name.",
31
+ type: "string",
32
+ },
33
+ branch: {
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",
39
+ },
40
+ params: {
41
+ describe: 'JSON object with link parameters, e.g. \'{"orgId":"...","projectId":"..."}\' or \'{"orgId":"...","projectName":"...","regionId":"..."}\'. Flags take precedence over fields in --params.',
42
+ type: "string",
43
+ },
44
+ agent: {
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",
47
+ default: false,
48
+ },
49
+ yes: {
50
+ alias: "y",
51
+ describe: 'Skip the "already linked" confirmation in interactive mode and re-link anyway.',
52
+ type: "boolean",
53
+ default: false,
54
+ },
55
+ clear: {
56
+ describe: "Remove the org/project/branch context (writes an empty context file) instead of linking.",
57
+ type: "boolean",
58
+ default: false,
59
+ },
60
+ checks: {
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",
66
+ default: true,
67
+ },
68
+ "env-pull": {
69
+ describe: "Pull the linked branch's Neon env vars (DATABASE_URL, …) into a local .env after " +
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",
73
+ default: true,
74
+ },
75
+ })
76
+ .example([
77
+ [
78
+ "$0 link --project-id polished-snowflake-12345678",
79
+ "Link an existing project (org is inferred); pin a branch later with 'neonctl checkout'",
80
+ ],
81
+ [
82
+ "$0 link --org-id org-… --project-name my-app --region-id aws-us-east-2",
83
+ "Create a new project and link it",
84
+ ],
85
+ [
86
+ "$0 link --branch-id br-…",
87
+ "Pin a branch in the already-linked project",
88
+ ],
89
+ [
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",
96
+ ],
97
+ ]);
98
+ export const handler = async (props) => {
99
+ if (props.clear) {
100
+ clearContext(props.contextFile);
101
+ return;
102
+ }
103
+ if (!props.checks) {
104
+ runWithoutChecks(props);
105
+ return;
106
+ }
107
+ if (props.agent) {
108
+ await runAgentSafely(props);
109
+ return;
110
+ }
111
+ const inputs = parseInputs(props);
112
+ validateInputs(inputs);
113
+ const existing = readContextFile(props.contextFile);
114
+ if (canResolveNonInteractively(inputs, existing)) {
115
+ await runNonInteractive(props, inputs, existing);
116
+ return;
117
+ }
118
+ if (isCi()) {
119
+ log.error([
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"));
127
+ process.exit(1);
128
+ return;
129
+ }
130
+ await runInteractive(props, inputs);
131
+ };
132
+ // ----------------------------------------------------------------------------
133
+ // Input parsing & validation
134
+ // ----------------------------------------------------------------------------
135
+ const parseInputs = (props) => {
136
+ let fromParams = {};
137
+ if (props.params !== undefined && props.params !== "") {
138
+ let parsed;
139
+ try {
140
+ parsed = JSON.parse(props.params);
141
+ }
142
+ catch (err) {
143
+ const message = err instanceof Error ? err.message : String(err);
144
+ throw new Error(`Failed to parse --params JSON: ${message}`);
145
+ }
146
+ fromParams = extractParams(parsed);
147
+ }
148
+ return {
149
+ orgId: props.orgId ?? fromParams.orgId,
150
+ projectId: props.projectId ?? fromParams.projectId,
151
+ projectName: props.projectName ?? fromParams.projectName,
152
+ regionId: props.regionId ?? fromParams.regionId,
153
+ branch: props.branch ?? fromParams.branch,
154
+ };
155
+ };
156
+ const extractParams = (raw) => {
157
+ if (raw === null || typeof raw !== "object") {
158
+ throw new Error("--params must be a JSON object");
159
+ }
160
+ const obj = raw;
161
+ const pickString = (key) => {
162
+ const value = obj[key];
163
+ if (value === undefined || value === null)
164
+ return undefined;
165
+ if (typeof value !== "string") {
166
+ throw new Error(`--params.${key} must be a string`);
167
+ }
168
+ return value;
169
+ };
170
+ return {
171
+ orgId: pickString("orgId"),
172
+ projectId: pickString("projectId"),
173
+ projectName: pickString("projectName"),
174
+ regionId: pickString("regionId"),
175
+ branch: pickString("branch") ?? pickString("branchId"),
176
+ };
177
+ };
178
+ const validateInputs = (inputs) => {
179
+ if (inputs.projectId && (inputs.projectName || inputs.regionId)) {
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.");
181
+ }
182
+ if (inputs.projectName && inputs.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>`.");
184
+ }
185
+ };
186
+ /**
187
+ * Whether the inputs (combined with the existing `.neon`) fully determine what
188
+ * to write without prompting the user. Everything else falls back to the
189
+ * interactive picker (TTY) or the CI guard.
190
+ *
191
+ * - `--project-id` is always enough: the org is inferred from the project and
192
+ * the branch is left to an explicit `checkout` (never auto-defaulted).
193
+ * - `--org-id --project-name --region-id` fully describes a project to create.
194
+ * - `--branch-id` needs a project, which it takes from the existing `.neon`.
195
+ * - `--org-id` on its own just records the default org (merged into any
196
+ * existing context).
197
+ */
198
+ const canResolveNonInteractively = (inputs, existing) => {
199
+ if (inputs.projectId)
200
+ return true;
201
+ if (inputs.orgId && inputs.projectName && inputs.regionId)
202
+ return true;
203
+ if (inputs.branch && existing.projectId)
204
+ return true;
205
+ if (inputs.orgId && !inputs.projectName && !inputs.branch)
206
+ return true;
207
+ return false;
208
+ };
209
+ // ----------------------------------------------------------------------------
210
+ // Context helpers
211
+ // ----------------------------------------------------------------------------
212
+ const clearContext = (contextFile) => {
213
+ updateContextFile(contextFile, {});
214
+ process.stdout.write(`Cleared ${contextFile}. The directory is no longer linked to a Neon org/project/branch.\n`);
215
+ };
216
+ /**
217
+ * `--no-checks`: write the context offline. Makes no API calls — so no org
218
+ * inference, no existence/access verification, and no env pull — which means
219
+ * the caller must supply both `--org-id` and `--project-id` (the org can't be
220
+ * inferred without the network). `--branch` stays optional. This is the CLI
221
+ * surface over {@link setContext}, useful for scripted/offline setups and for
222
+ * re-creating a `.neon` from values you already trust.
223
+ */
224
+ const runWithoutChecks = (props) => {
225
+ const inputs = parseInputs(props);
226
+ validateInputs(inputs);
227
+ if (inputs.projectName) {
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.");
229
+ }
230
+ if (!inputs.orgId || !inputs.projectId) {
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).");
232
+ }
233
+ setContext(props.contextFile, {
234
+ orgId: inputs.orgId,
235
+ projectId: inputs.projectId,
236
+ branch: inputs.branch,
237
+ });
238
+ if (props.agent) {
239
+ emitAgent({
240
+ status: "linked",
241
+ context_file: props.contextFile,
242
+ context: {
243
+ orgId: inputs.orgId,
244
+ projectId: inputs.projectId,
245
+ branch: inputs.branch,
246
+ },
247
+ project: { id: inputs.projectId },
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.`,
249
+ });
250
+ return;
251
+ }
252
+ printSummary(props, {
253
+ contextFile: props.contextFile,
254
+ orgId: inputs.orgId,
255
+ projectId: inputs.projectId,
256
+ branch: inputs.branch,
257
+ created: false,
258
+ noChecks: true,
259
+ });
260
+ };
261
+ /**
262
+ * A bad user-supplied identifier (project/org/branch that doesn't exist or
263
+ * isn't accessible). Carries an `agentCode` so `--agent` mode can report a
264
+ * precise `status: error` code instead of a generic INTERNAL_ERROR, while the
265
+ * human path just prints the clear `message`.
266
+ */
267
+ class LinkInputError extends Error {
268
+ constructor(message, agentCode) {
269
+ super(message);
270
+ this.name = "LinkInputError";
271
+ this.agentCode = agentCode;
272
+ }
273
+ }
274
+ const httpStatus = (err) => isNeonApiError(err) ? err.status : undefined;
275
+ /**
276
+ * Fetch a project, turning the common failure modes into clear, actionable
277
+ * errors. 401 is rethrown so the global handler can refresh credentials;
278
+ * everything else surfaces as a `LinkInputError` the user (or agent) can act on.
279
+ */
280
+ const fetchProjectOrThrow = async (props, projectId) => {
281
+ try {
282
+ const { data } = await props.apiClient.getProject(projectId);
283
+ return data.project;
284
+ }
285
+ catch (err) {
286
+ const status = httpStatus(err);
287
+ if (status === 401) {
288
+ throw err;
289
+ }
290
+ if (status === 403) {
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");
292
+ }
293
+ if (status === 404) {
294
+ throw new LinkInputError(`Project '${projectId}' not found. Double-check the project ID — or that your API key has access to it.`, "NOT_FOUND");
295
+ }
296
+ throw err;
297
+ }
298
+ };
299
+ /**
300
+ * Confirm the org exists and is reachable with the current API key by listing
301
+ * its projects (allowed for both user and org-scoped keys). Maps 403/404 to a
302
+ * clear message; 401 is rethrown for credential refresh.
303
+ */
304
+ const verifyOrgAccess = async (props, orgId) => {
305
+ try {
306
+ await props.apiClient.listProjects({
307
+ org_id: orgId,
308
+ limit: PROJECTS_LIST_LIMIT,
309
+ });
310
+ }
311
+ catch (err) {
312
+ const status = httpStatus(err);
313
+ if (status === 401) {
314
+ throw err;
315
+ }
316
+ if (status === 403 || status === 404) {
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");
318
+ }
319
+ throw err;
320
+ }
321
+ };
322
+ /**
323
+ * Resolve a branch reference (name *or* id) to the matching branch, while
324
+ * confirming it actually exists in the project. Unlike the shared
325
+ * `branchIdResolve`, this also verifies references that already look like ids
326
+ * (so a typo'd `br-…` doesn't silently get written), and surfaces the available
327
+ * branches when nothing matches so the user can correct it (or run `checkout`).
328
+ */
329
+ const resolveBranchRef = async (props, projectId, branchRef) => {
330
+ const { data } = await props.apiClient.listProjectBranches({ projectId });
331
+ const match = data.branches.find((b) => b.id === branchRef) ??
332
+ data.branches.find((b) => b.name === branchRef);
333
+ if (match) {
334
+ return match;
335
+ }
336
+ const available = data.branches.length > 0
337
+ ? data.branches
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");
342
+ };
343
+ /**
344
+ * The value to persist for a branch: prefer its human-readable **name** (nicer
345
+ * to read in `.neon`, and still resolvable by every command), falling back to
346
+ * the id when the branch has no name.
347
+ */
348
+ const branchPersistValue = (branch) => branch.name ?? branch.id;
349
+ /**
350
+ * Verify the project (and the org, when supplied) and resolve the org id to
351
+ * persist.
352
+ *
353
+ * The project is always fetched, which both validates it and yields its
354
+ * `org_id`. When `--org-id` is passed too: if the project reports an org it must
355
+ * match (else a clear mismatch error); if it reports none, the supplied org is
356
+ * verified on its own. Without `--org-id` the project's own org is used, falling
357
+ * back to the org already recorded for the *same* project in `.neon`. Projects
358
+ * on a personal account have no org, so `undefined` is a valid result — the
359
+ * field is simply omitted.
360
+ */
361
+ const resolveOrgForProject = async (props, inputs, existing, projectId) => {
362
+ const project = await fetchProjectOrThrow(props, projectId);
363
+ const projectOrg = project.org_id ?? undefined;
364
+ if (inputs.orgId) {
365
+ if (projectOrg && projectOrg !== inputs.orgId) {
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");
367
+ }
368
+ if (!projectOrg) {
369
+ await verifyOrgAccess(props, inputs.orgId);
370
+ }
371
+ return inputs.orgId;
372
+ }
373
+ if (projectOrg) {
374
+ return projectOrg;
375
+ }
376
+ if (projectId === existing.projectId && existing.orgId) {
377
+ return existing.orgId;
378
+ }
379
+ return undefined;
380
+ };
381
+ /**
382
+ * Resolve the branch to persist alongside a project in non-interactive mode.
383
+ *
384
+ * `link` never guesses the project's default branch — that's `checkout`'s job —
385
+ * so the only sources are an explicit `--branch` (name or id, verified and
386
+ * normalized to its name) or a branch already pinned for the *same* project (so
387
+ * re-linking it doesn't drop your checked-out branch). Reading the existing
388
+ * branch via {@link contextBranch} also recovers a legacy `branchId` field.
389
+ */
390
+ const resolvePinnedBranch = async (props, inputs, existing, projectId) => {
391
+ if (inputs.branch) {
392
+ const branch = await resolveBranchRef(props, projectId, inputs.branch);
393
+ return branchPersistValue(branch);
394
+ }
395
+ if (projectId === existing.projectId) {
396
+ return contextBranch(existing);
397
+ }
398
+ return undefined;
399
+ };
400
+ // ----------------------------------------------------------------------------
401
+ // Non-interactive flag-driven mode
402
+ // ----------------------------------------------------------------------------
403
+ const runNonInteractive = async (props, inputs, existing) => {
404
+ // Create a new project and link it.
405
+ if (inputs.projectName) {
406
+ const orgId = mustString(inputs.orgId, "orgId");
407
+ await verifyOrgAccess(props, orgId);
408
+ const created = await createProject(props, {
409
+ orgId,
410
+ name: inputs.projectName,
411
+ regionId: mustString(inputs.regionId, "regionId"),
412
+ });
413
+ applyContext(props.contextFile, {
414
+ orgId,
415
+ projectId: created.project.id,
416
+ branch: created.branchName,
417
+ });
418
+ await finalizeLink(props, {
419
+ contextFile: props.contextFile,
420
+ orgId,
421
+ projectId: created.project.id,
422
+ branch: created.branchName,
423
+ created: true,
424
+ projectName: created.project.name,
425
+ regionId: created.project.region_id,
426
+ });
427
+ return;
428
+ }
429
+ // Link an explicitly named existing project.
430
+ if (inputs.projectId) {
431
+ const orgId = await resolveOrgForProject(props, inputs, existing, inputs.projectId);
432
+ const branch = await resolvePinnedBranch(props, inputs, existing, inputs.projectId);
433
+ applyContext(props.contextFile, {
434
+ orgId,
435
+ projectId: inputs.projectId,
436
+ branch,
437
+ });
438
+ await finalizeLink(props, {
439
+ contextFile: props.contextFile,
440
+ orgId,
441
+ projectId: inputs.projectId,
442
+ branch,
443
+ created: false,
444
+ });
445
+ return;
446
+ }
447
+ // Pin a branch in the already-linked project.
448
+ if (inputs.branch && existing.projectId) {
449
+ const projectId = existing.projectId;
450
+ const orgId = await resolveOrgForProject(props, inputs, existing, projectId);
451
+ const branch = await resolvePinnedBranch(props, inputs, existing, projectId);
452
+ applyContext(props.contextFile, {
453
+ orgId,
454
+ projectId,
455
+ branch,
456
+ });
457
+ await finalizeLink(props, {
458
+ contextFile: props.contextFile,
459
+ orgId,
460
+ projectId,
461
+ branch,
462
+ created: false,
463
+ });
464
+ return;
465
+ }
466
+ // Record the default org, preserving any existing project/branch.
467
+ if (inputs.orgId) {
468
+ const orgId = inputs.orgId;
469
+ await verifyOrgAccess(props, orgId);
470
+ const projectId = existing.projectId;
471
+ const branch = projectId ? contextBranch(existing) : undefined;
472
+ applyContext(props.contextFile, { orgId, projectId, branch });
473
+ printSummary(props, {
474
+ contextFile: props.contextFile,
475
+ orgId,
476
+ projectId,
477
+ branch,
478
+ created: false,
479
+ orgOnly: true,
480
+ });
481
+ return;
482
+ }
483
+ };
484
+ // ----------------------------------------------------------------------------
485
+ // Interactive mode (TTY)
486
+ // ----------------------------------------------------------------------------
487
+ const runInteractive = async (props, inputs) => {
488
+ if (!props.yes) {
489
+ await confirmRelinkIfNeeded(props);
490
+ }
491
+ const orgResolution = await resolveOrg(props, inputs.orgId);
492
+ let orgId;
493
+ if (orgResolution.kind === "resolved") {
494
+ orgId = orgResolution.orgId;
495
+ if (orgResolution.autoDetected) {
496
+ log.info(`Detected organization ${orgId} from your existing projects (organization-scoped API key).`);
497
+ }
498
+ }
499
+ else if (orgResolution.orgKeyLimited) {
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).");
503
+ }
504
+ else {
505
+ orgId = await promptOrgFromList(orgResolution.orgs);
506
+ }
507
+ if (inputs.projectName && inputs.regionId) {
508
+ const created = await createProject(props, {
509
+ orgId,
510
+ name: inputs.projectName,
511
+ regionId: inputs.regionId,
512
+ });
513
+ applyContext(props.contextFile, {
514
+ orgId,
515
+ projectId: created.project.id,
516
+ branch: created.branchName,
517
+ });
518
+ await finalizeInteractiveLink(props, {
519
+ contextFile: props.contextFile,
520
+ orgId,
521
+ projectId: created.project.id,
522
+ branch: created.branchName,
523
+ created: true,
524
+ projectName: created.project.name,
525
+ regionId: created.project.region_id,
526
+ });
527
+ return;
528
+ }
529
+ // Need to ask: existing project or create a new one?
530
+ const projects = await listAllProjects(props, orgId);
531
+ const action = await promptProjectChoice(projects, inputs.projectName);
532
+ if (action.type === "existing") {
533
+ const branch = await resolveInteractiveBranch(props, action.projectId);
534
+ applyContext(props.contextFile, {
535
+ orgId,
536
+ projectId: action.projectId,
537
+ branch,
538
+ });
539
+ await finalizeInteractiveLink(props, {
540
+ contextFile: props.contextFile,
541
+ orgId,
542
+ projectId: action.projectId,
543
+ branch,
544
+ created: false,
545
+ projectName: action.name,
546
+ regionId: action.regionId,
547
+ });
548
+ return;
549
+ }
550
+ const projectName = inputs.projectName ?? (await promptProjectName(action.suggestedName));
551
+ const regionId = inputs.regionId ?? (await promptRegion(props));
552
+ const created = await createProject(props, {
553
+ orgId,
554
+ name: projectName,
555
+ regionId,
556
+ });
557
+ applyContext(props.contextFile, {
558
+ orgId,
559
+ projectId: created.project.id,
560
+ branch: created.branchName,
561
+ });
562
+ await finalizeInteractiveLink(props, {
563
+ contextFile: props.contextFile,
564
+ orgId,
565
+ projectId: created.project.id,
566
+ branch: created.branchName,
567
+ created: true,
568
+ projectName: created.project.name,
569
+ regionId: created.project.region_id,
570
+ });
571
+ };
572
+ const confirmRelinkIfNeeded = async (props) => {
573
+ const existing = readContextFile(props.contextFile);
574
+ if (!existing.orgId || !existing.projectId) {
575
+ return;
576
+ }
577
+ const { proceed } = await prompts({
578
+ onState: onPromptState,
579
+ type: "confirm",
580
+ name: "proceed",
581
+ message: `${props.contextFile} is already linked to project ${existing.projectId} (org ${existing.orgId}). Re-link?`,
582
+ initial: true,
583
+ });
584
+ if (!proceed) {
585
+ process.stdout.write("Aborted. Existing link preserved.\n");
586
+ process.exit(0);
587
+ }
588
+ };
589
+ const promptOrgFromList = async (orgs) => {
590
+ if (!orgs.length) {
591
+ throw new Error(`You don't belong to any organizations. Create one in the Neon Console first: https://console.neon.tech/`);
592
+ }
593
+ // A single organization leaves nothing to choose, so skip the prompt and link
594
+ // it directly — go straight on to the project step.
595
+ if (orgs.length === 1) {
596
+ const [only] = orgs;
597
+ log.info(`Linking organization ${only.name} (${only.id}).`);
598
+ return only.id;
599
+ }
600
+ const { orgId } = await prompts({
601
+ onState: onPromptState,
602
+ type: "select",
603
+ name: "orgId",
604
+ message: "Which organization would you like to link?",
605
+ choices: orgs.map((org) => ({
606
+ title: `${org.name} (${org.id})`,
607
+ value: org.id,
608
+ })),
609
+ initial: 0,
610
+ });
611
+ return orgId;
612
+ };
613
+ const promptProjectChoice = async (projects, suggestedName) => {
614
+ const choices = [
615
+ { title: "+ Create new project…", value: CREATE_NEW_SENTINEL },
616
+ ...projects.map((project) => ({
617
+ title: `${project.name} (${project.id})`,
618
+ value: project.id,
619
+ })),
620
+ ];
621
+ // Create sits at the top, so default to the first existing project (index 1) when there
622
+ // is one; with no projects to show, the create option (index 0) is the only choice.
623
+ const { selection } = await prompts({
624
+ onState: onPromptState,
625
+ type: "select",
626
+ name: "selection",
627
+ message: "Which project would you like to link?",
628
+ choices,
629
+ initial: projects.length > 0 ? 1 : 0,
630
+ });
631
+ if (selection === CREATE_NEW_SENTINEL) {
632
+ return { type: "create", suggestedName };
633
+ }
634
+ const project = projects.find((p) => p.id === selection);
635
+ return {
636
+ type: "existing",
637
+ projectId: selection,
638
+ name: project?.name,
639
+ regionId: project?.region_id,
640
+ };
641
+ };
642
+ const promptProjectName = async (suggestedName) => {
643
+ const { name } = await prompts({
644
+ onState: onPromptState,
645
+ type: "text",
646
+ name: "name",
647
+ message: "Name for the new project:",
648
+ initial: suggestedName,
649
+ validate: (value) => value && value.trim().length > 0
650
+ ? true
651
+ : "Project name is required",
652
+ });
653
+ return String(name).trim();
654
+ };
655
+ const promptRegion = async (props) => {
656
+ const regions = await fetchRegions(props);
657
+ const defaultIndex = Math.max(0, regions.findIndex((r) => r.default));
658
+ const { regionId } = await prompts({
659
+ onState: onPromptState,
660
+ type: "select",
661
+ name: "regionId",
662
+ message: "Which region should the new project run in?",
663
+ choices: regions.map((region) => ({
664
+ title: `${region.name} (${region.region_id})`,
665
+ value: region.region_id,
666
+ })),
667
+ initial: defaultIndex,
668
+ });
669
+ return regionId;
670
+ };
671
+ // ----------------------------------------------------------------------------
672
+ // Agent mode (JSON state machine)
673
+ // ----------------------------------------------------------------------------
674
+ const runAgentSafely = async (props) => {
675
+ try {
676
+ const inputs = parseInputs(props);
677
+ validateInputs(inputs);
678
+ await runAgent(props, inputs);
679
+ }
680
+ catch (err) {
681
+ emitAgent(toAgentError(err));
682
+ process.exit(1);
683
+ }
684
+ };
685
+ const runAgent = async (props, inputs) => {
686
+ const { projectId, projectName, regionId, branch } = inputs;
687
+ const existing = readContextFile(props.contextFile);
688
+ // Existing project: infer the org and link it. The branch is left to an
689
+ // explicit `checkout` unless one was passed or is already pinned.
690
+ if (projectId) {
691
+ const orgId = await resolveOrgForProject(props, inputs, existing, projectId);
692
+ const pinnedBranch = await resolvePinnedBranch(props, inputs, existing, projectId);
693
+ applyContext(props.contextFile, {
694
+ orgId,
695
+ projectId,
696
+ branch: pinnedBranch,
697
+ });
698
+ const orgSuffix = orgId ? ` (org ${orgId})` : "";
699
+ if (pinnedBranch) {
700
+ const pullNote = renderAgentPullNote(await autoPullEnvAfterPin({
701
+ ...props,
702
+ projectId,
703
+ branch: pinnedBranch,
704
+ envPull: props.envPull,
705
+ }));
706
+ emitAgent({
707
+ status: "linked",
708
+ context_file: props.contextFile,
709
+ context: { orgId, projectId, branch: pinnedBranch },
710
+ project: { id: projectId },
711
+ message: `Linked ${props.contextFile} to project ${projectId}${orgSuffix} on branch ${pinnedBranch}.${pullNote}`,
712
+ });
713
+ return;
714
+ }
715
+ emitAgent({
716
+ status: "linked",
717
+ context_file: props.contextFile,
718
+ context: { orgId, projectId },
719
+ project: { id: projectId },
720
+ message: `Linked ${props.contextFile} to project ${projectId}${orgSuffix}. No branch pinned — run \`neonctl checkout <branch>\` (omit the branch to list options) to pin one and pull its env vars.`,
721
+ });
722
+ return;
723
+ }
724
+ const orgResolution = await resolveOrg(props, inputs.orgId);
725
+ if (orgResolution.kind === "needs_selection") {
726
+ emitAgent(buildNeedsOrgResponse(orgResolution));
727
+ return;
728
+ }
729
+ const orgId = orgResolution.orgId;
730
+ if (projectName && !regionId) {
731
+ const regions = await fetchRegions(props);
732
+ emitAgent({
733
+ status: "needs_project_details",
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.`,
735
+ regions: regions.map((region) => ({
736
+ id: region.region_id,
737
+ name: region.name,
738
+ default: region.default,
739
+ })),
740
+ next_command_template: `neonctl link --agent --org-id ${shellArg(orgId)} --project-name ${shellArg(projectName)} --region-id <region_id>`,
741
+ });
742
+ return;
743
+ }
744
+ if (projectName && regionId) {
745
+ await verifyOrgAccess(props, orgId);
746
+ const created = await createProject(props, {
747
+ orgId,
748
+ name: projectName,
749
+ regionId,
750
+ });
751
+ applyContext(props.contextFile, {
752
+ orgId,
753
+ projectId: created.project.id,
754
+ branch: created.branchName,
755
+ });
756
+ const pullNote = renderAgentPullNote(await autoPullEnvAfterPin({
757
+ ...props,
758
+ projectId: created.project.id,
759
+ branch: created.branchName,
760
+ envPull: props.envPull,
761
+ }));
762
+ emitAgent({
763
+ status: "linked",
764
+ context_file: props.contextFile,
765
+ context: {
766
+ orgId,
767
+ projectId: created.project.id,
768
+ branch: created.branchName,
769
+ },
770
+ project: {
771
+ id: created.project.id,
772
+ name: created.project.name,
773
+ region_id: created.project.region_id,
774
+ },
775
+ message: `Created project ${created.project.id} ("${created.project.name ?? projectName}") in ${created.project.region_id ?? regionId} and linked ${props.contextFile} on branch ${created.branchName}.${pullNote}`,
776
+ });
777
+ return;
778
+ }
779
+ // orgId is set but no project info — list projects to choose from. A pending
780
+ // --branch can't be applied until a project is chosen, so it's surfaced in
781
+ // the instruction rather than silently dropped.
782
+ const projects = await listAllProjects(props, orgId);
783
+ 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 \`neonctl checkout ${branch}\`.`
785
+ : "";
786
+ emitAgent({
787
+ status: "needs_project",
788
+ instruction: (projects.length === 0
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.`
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).`) +
791
+ branchNote,
792
+ options: projects.map((project) => ({
793
+ id: project.id,
794
+ name: project.name,
795
+ region_id: project.region_id,
796
+ })),
797
+ create_option: {
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.",
799
+ next_command_template: `neonctl link --agent --org-id ${shellArg(orgId)} --project-name <name> --region-id <region_id>`,
800
+ },
801
+ next_command_template: `neonctl link --agent --org-id ${shellArg(orgId)} --project-id <project_id>`,
802
+ });
803
+ };
804
+ const emitAgent = (response) => {
805
+ process.stdout.write(`${JSON.stringify(response, null, 2)}\n`);
806
+ };
807
+ // ----------------------------------------------------------------------------
808
+ // API helpers
809
+ // ----------------------------------------------------------------------------
810
+ const ORG_KEY_LIMITED_FRAGMENT = "not allowed for organization API keys";
811
+ const isOrgKeyLimitedError = (err) => {
812
+ if (!isNeonApiError(err))
813
+ return false;
814
+ const message = messageFromBody(err.data);
815
+ return (typeof message === "string" &&
816
+ message.includes(ORG_KEY_LIMITED_FRAGMENT));
817
+ };
818
+ const fetchOrganizations = async (props) => {
819
+ const { data } = await props.apiClient.getCurrentUserOrganizations();
820
+ return data.organizations ?? [];
821
+ };
822
+ /**
823
+ * Resolves the org id from the explicit flag, falling back to listing user orgs.
824
+ *
825
+ * For organization-scoped API keys, `getCurrentUserOrganizations` is forbidden;
826
+ * in that case we try to auto-detect the org from the first existing project
827
+ * (since all projects of an org key live in the same org). If no project exists
828
+ * yet, we return `needs_selection` with `orgKeyLimited: true` so callers can
829
+ * give a precise instruction to the user.
830
+ */
831
+ const resolveOrg = async (props, given) => {
832
+ if (given) {
833
+ return { kind: "resolved", orgId: given, autoDetected: false };
834
+ }
835
+ try {
836
+ const orgs = await fetchOrganizations(props);
837
+ return { kind: "needs_selection", orgs, orgKeyLimited: false };
838
+ }
839
+ catch (err) {
840
+ if (!isOrgKeyLimitedError(err)) {
841
+ throw err;
842
+ }
843
+ log.debug("getCurrentUserOrganizations not allowed (org-scoped API key); attempting to derive org from existing projects.");
844
+ }
845
+ const detected = await detectOrgIdFromProjects(props);
846
+ if (detected) {
847
+ return { kind: "resolved", orgId: detected, autoDetected: true };
848
+ }
849
+ return { kind: "needs_selection", orgs: [], orgKeyLimited: true };
850
+ };
851
+ const detectOrgIdFromProjects = async (props) => {
852
+ try {
853
+ const { data } = await props.apiClient.listProjects({ limit: 1 });
854
+ return data.projects[0]?.org_id ?? undefined;
855
+ }
856
+ catch (err) {
857
+ const message = err instanceof Error ? err.message : String(err);
858
+ log.debug("detectOrgIdFromProjects failed: %s", message);
859
+ return undefined;
860
+ }
861
+ };
862
+ const buildNeedsOrgResponse = (resolution) => {
863
+ if (resolution.orgKeyLimited) {
864
+ return {
865
+ status: "needs_org",
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.",
867
+ options: [],
868
+ next_command_template: "neonctl link --agent --org-id <org_id>",
869
+ };
870
+ }
871
+ const orgs = resolution.orgs;
872
+ return {
873
+ status: "needs_org",
874
+ instruction: orgs.length === 0
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.`,
877
+ options: orgs.map((org) => ({ id: org.id, name: org.name })),
878
+ next_command_template: "neonctl link --agent --org-id <org_id>",
879
+ };
880
+ };
881
+ const toAgentError = (err) => {
882
+ if (err instanceof LinkInputError) {
883
+ return { status: "error", code: err.agentCode, message: err.message };
884
+ }
885
+ if (isNeonApiError(err)) {
886
+ const status = err.status;
887
+ const apiMessage = messageFromBody(err.data);
888
+ const message = apiMessage !== undefined && apiMessage.length > 0
889
+ ? apiMessage
890
+ : err.message;
891
+ let code = "API_ERROR";
892
+ if (status === 401 || status === 403) {
893
+ code = "AUTH_ERROR";
894
+ }
895
+ else if (status !== undefined && status >= 400 && status < 500) {
896
+ code = "CLIENT_ERROR";
897
+ }
898
+ else if (status !== undefined && status >= 500) {
899
+ code = "SERVER_ERROR";
900
+ }
901
+ else if (err.code === "ECONNABORTED") {
902
+ code = "TIMEOUT";
903
+ }
904
+ return { status: "error", code, message };
905
+ }
906
+ if (err instanceof Error) {
907
+ return {
908
+ status: "error",
909
+ code: "INTERNAL_ERROR",
910
+ message: err.message,
911
+ };
912
+ }
913
+ return {
914
+ status: "error",
915
+ code: "INTERNAL_ERROR",
916
+ message: String(err),
917
+ };
918
+ };
919
+ const listAllProjects = async (props, orgId) => {
920
+ const result = [];
921
+ let cursor;
922
+ while (true) {
923
+ const { data } = await props.apiClient.listProjects({
924
+ limit: PROJECTS_LIST_LIMIT,
925
+ org_id: orgId,
926
+ cursor,
927
+ });
928
+ result.push(...data.projects);
929
+ cursor = data.pagination?.cursor;
930
+ if (data.projects.length < PROJECTS_LIST_LIMIT) {
931
+ break;
932
+ }
933
+ }
934
+ return result;
935
+ };
936
+ /**
937
+ * Resolve which branch to pin for an interactively-chosen project, returned as the value to
938
+ * persist (its name when known, see {@link branchPersistValue}). When the project has a
939
+ * single branch there is nothing to choose, so we pin it silently. Otherwise we offer the
940
+ * shared branch picker (the same "+ Create a new branch…" + list as `neonctl checkout`),
941
+ * creating the branch when the user opts to. This makes interactive `link` a full org →
942
+ * project → branch flow; non-interactive `link` instead defers the branch to `checkout`.
943
+ */
944
+ const resolveInteractiveBranch = async (props, projectId) => {
945
+ const { data } = await props.apiClient.listProjectBranches({ projectId });
946
+ const branches = data.branches;
947
+ if (branches.length <= 1) {
948
+ const only = branches.find((b) => b.default) ?? branches[0];
949
+ if (!only) {
950
+ throw new Error(`Could not find a default branch for project ${projectId}.`);
951
+ }
952
+ return branchPersistValue(only);
953
+ }
954
+ const picked = await pickBranchInteractively(branches, {
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.",
958
+ });
959
+ if (picked.kind === "existing") {
960
+ const existing = branches.find((b) => b.id === picked.branchId);
961
+ return existing ? branchPersistValue(existing) : picked.branchId;
962
+ }
963
+ // A freshly-created branch: we already know the name the user typed.
964
+ await createBranch(props.apiClient, projectId, picked.name, branches);
965
+ return picked.name;
966
+ };
967
+ const fetchRegions = async (props) => {
968
+ try {
969
+ const { data } = await props.apiClient.getActiveRegions();
970
+ if (data.regions && data.regions.length > 0) {
971
+ return data.regions;
972
+ }
973
+ }
974
+ catch (err) {
975
+ if (isNeonApiError(err)) {
976
+ log.debug("getActiveRegions failed (%s), falling back to the static region list.", err.status ?? err.code ?? err.message);
977
+ }
978
+ else {
979
+ const message = err instanceof Error ? err.message : String(err);
980
+ log.debug("getActiveRegions failed (%s), falling back to the static region list.", message);
981
+ }
982
+ }
983
+ return staticRegionsFallback();
984
+ };
985
+ const staticRegionsFallback = () => REGIONS.map((id) => ({
986
+ region_id: id,
987
+ name: id,
988
+ default: id === "aws-us-east-2",
989
+ geo_lat: "",
990
+ geo_long: "",
991
+ }));
992
+ const createProject = async (props, args) => {
993
+ const project = {
994
+ name: args.name,
995
+ region_id: args.regionId,
996
+ org_id: args.orgId,
997
+ branch: {},
998
+ };
999
+ const { data } = await props.apiClient.createProject({ project });
1000
+ if (!data.branch?.id) {
1001
+ throw new Error("Project was created but the API response did not include a default branch id.");
1002
+ }
1003
+ return {
1004
+ project: {
1005
+ id: data.project.id,
1006
+ name: data.project.name,
1007
+ region_id: data.project.region_id,
1008
+ },
1009
+ branchId: data.branch.id,
1010
+ branchName: data.branch.name ?? data.branch.id,
1011
+ };
1012
+ };
1013
+ const printSummary = (_props, summary) => {
1014
+ const lines = [];
1015
+ if (summary.created) {
1016
+ lines.push(`Created project ${summary.projectId}${summary.projectName ? ` ("${summary.projectName}")` : ""}${summary.regionId ? ` in ${summary.regionId}` : ""}.`);
1017
+ }
1018
+ lines.push(`${summary.orgOnly ? "Updated" : "Linked"} ${summary.contextFile}:`);
1019
+ if (summary.orgId) {
1020
+ lines.push(` orgId: ${summary.orgId}`);
1021
+ }
1022
+ if (summary.projectId) {
1023
+ lines.push(` projectId: ${summary.projectId}`);
1024
+ }
1025
+ if (summary.branch) {
1026
+ lines.push(` branch: ${summary.branch}`);
1027
+ }
1028
+ if (summary.noChecks) {
1029
+ lines.push("");
1030
+ lines.push("Written offline (--no-checks): nothing was verified.");
1031
+ }
1032
+ else if (summary.projectId && !summary.branch && !summary.orgOnly) {
1033
+ lines.push("");
1034
+ lines.push("No branch pinned. Run `neonctl checkout <branch>` to pin a branch and pull its env vars.");
1035
+ }
1036
+ lines.push("");
1037
+ process.stdout.write(`${lines.join("\n")}\n`);
1038
+ };
1039
+ /**
1040
+ * Print the link summary, then run the bundled `env pull` so a human `link` that pinned a
1041
+ * branch ends with the branch's connection string already on disk. When no branch was pinned
1042
+ * there is nothing to pull, so env pull is skipped and the summary nudges `checkout` instead.
1043
+ * `--no-env-pull` opts out (env pull's own status / skip hint is logged to stderr).
1044
+ */
1045
+ const finalizeLink = async (props, summary) => {
1046
+ printSummary(props, summary);
1047
+ if (!summary.branch || !summary.projectId) {
1048
+ return;
1049
+ }
1050
+ await autoPullEnvAfterPin({
1051
+ ...props,
1052
+ projectId: summary.projectId,
1053
+ branch: summary.branch,
1054
+ envPull: props.envPull,
1055
+ });
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
+ };
1103
+ const onPromptState = (state) => {
1104
+ if (state.aborted) {
1105
+ process.stdout.write("\x1B[?25h");
1106
+ process.stdout.write("\n");
1107
+ process.exit(1);
1108
+ }
1109
+ };
1110
+ const shellArg = (value) => {
1111
+ if (/^[A-Za-z0-9._:/-]+$/.test(value)) {
1112
+ return value;
1113
+ }
1114
+ return `'${value.replace(/'/g, `'\\''`)}'`;
1115
+ };
1116
+ const mustString = (value, name) => {
1117
+ if (value === undefined) {
1118
+ throw new Error(`Internal error: expected ${name} to be set.`);
1119
+ }
1120
+ return value;
1121
+ };