neonctl 2.24.1 → 2.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/commands/link.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { isAxiosError } from 'axios';
2
2
  import prompts from 'prompts';
3
- import { applyContext, readContextFile } from '../context.js';
3
+ import { applyContext, contextBranch, readContextFile, setContext, updateContextFile, } from '../context.js';
4
4
  import { isCi } from '../env.js';
5
5
  import { log } from '../log.js';
6
6
  import { createBranch, pickBranchInteractively, } from '../utils/branch_picker.js';
@@ -10,7 +10,9 @@ const PROJECTS_LIST_LIMIT = 100;
10
10
  const CREATE_NEW_SENTINEL = '__create_new__';
11
11
  export const command = 'link';
12
12
  export const describe = 'Link the current directory to a Neon project';
13
- export const builder = (argv) => argv.usage('$0 link [options]').options({
13
+ export const builder = (argv) => argv
14
+ .usage('$0 link [options]')
15
+ .options({
14
16
  'org-id': {
15
17
  describe: 'Organization ID to link to',
16
18
  type: 'string',
@@ -27,6 +29,13 @@ export const builder = (argv) => argv.usage('$0 link [options]').options({
27
29
  describe: 'Region ID for a new project (e.g. aws-us-east-2). Required with --project-name.',
28
30
  type: 'string',
29
31
  },
32
+ 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',
38
+ },
30
39
  params: {
31
40
  describe: 'JSON object with link parameters, e.g. \'{"orgId":"...","projectId":"..."}\' or \'{"orgId":"...","projectName":"...","regionId":"..."}\'. Flags take precedence over fields in --params.',
32
41
  type: 'string',
@@ -42,23 +51,64 @@ export const builder = (argv) => argv.usage('$0 link [options]').options({
42
51
  type: 'boolean',
43
52
  default: false,
44
53
  },
54
+ clear: {
55
+ describe: 'Remove the org/project/branch context (writes an empty context file) instead of linking.',
56
+ type: 'boolean',
57
+ default: false,
58
+ },
59
+ 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',
65
+ default: true,
66
+ },
45
67
  'env-pull': {
46
68
  describe: "Pull the linked branch's Neon env vars (DATABASE_URL, …) into a local .env after " +
47
69
  'linking. On by default; use --no-env-pull to skip (e.g. when injecting env at ' +
48
- 'runtime with `neon-env run` / `neon dev`).',
70
+ 'runtime with `neon-env run` / `neon dev`). Only runs when a branch is pinned.',
49
71
  type: 'boolean',
50
72
  default: true,
51
73
  },
52
- });
74
+ })
75
+ .example([
76
+ [
77
+ '$0 link --project-id polished-snowflake-12345678',
78
+ "Link an existing project (org is inferred); pin a branch later with 'neonctl checkout'",
79
+ ],
80
+ [
81
+ '$0 link --org-id org-… --project-name my-app --region-id aws-us-east-2',
82
+ 'Create a new project and link it',
83
+ ],
84
+ [
85
+ '$0 link --branch-id br-…',
86
+ 'Pin a branch in the already-linked project',
87
+ ],
88
+ [
89
+ '$0 link --no-checks --org-id org-… --project-id polished-snowflake-12345678',
90
+ 'Write the context offline (no API calls, no verification)',
91
+ ],
92
+ ['$0 link --clear', 'Forget the current org/project/branch context'],
93
+ ]);
53
94
  export const handler = async (props) => {
95
+ if (props.clear) {
96
+ clearContext(props.contextFile);
97
+ return;
98
+ }
99
+ if (!props.checks) {
100
+ runWithoutChecks(props);
101
+ return;
102
+ }
54
103
  if (props.agent) {
55
104
  await runAgentSafely(props);
56
105
  return;
57
106
  }
58
107
  const inputs = parseInputs(props);
59
108
  validateInputs(inputs);
60
- if (hasEnoughForNonInteractive(inputs)) {
61
- await runNonInteractive(props, inputs);
109
+ const existing = readContextFile(props.contextFile);
110
+ if (canResolveNonInteractively(inputs, existing)) {
111
+ await runNonInteractive(props, inputs, existing);
62
112
  return;
63
113
  }
64
114
  if (isCi()) {
@@ -67,7 +117,7 @@ export const handler = async (props) => {
67
117
  '',
68
118
  'Use one of:',
69
119
  ' neonctl link --agent (JSON state machine for agents)',
70
- ' neonctl link --org-id <org> --project-id <project> (link to an existing project)',
120
+ ' neonctl link --project-id <project> (link to an existing project; org is inferred)',
71
121
  ' neonctl link --org-id <org> --project-name <name> --region-id <region> (create a new project and link)',
72
122
  ].join('\n'));
73
123
  process.exit(1);
@@ -96,6 +146,7 @@ const parseInputs = (props) => {
96
146
  projectId: props.projectId ?? fromParams.projectId,
97
147
  projectName: props.projectName ?? fromParams.projectName,
98
148
  regionId: props.regionId ?? fromParams.regionId,
149
+ branch: props.branch ?? fromParams.branch,
99
150
  };
100
151
  };
101
152
  const extractParams = (raw) => {
@@ -117,60 +168,314 @@ const extractParams = (raw) => {
117
168
  projectId: pickString('projectId'),
118
169
  projectName: pickString('projectName'),
119
170
  regionId: pickString('regionId'),
171
+ branch: pickString('branch') ?? pickString('branchId'),
120
172
  };
121
173
  };
122
174
  const validateInputs = (inputs) => {
123
175
  if (inputs.projectId && (inputs.projectName || inputs.regionId)) {
124
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.');
125
177
  }
178
+ 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>`.');
180
+ }
126
181
  };
127
- const hasEnoughForNonInteractive = (inputs) => {
128
- if (inputs.orgId && inputs.projectId)
182
+ /**
183
+ * Whether the inputs (combined with the existing `.neon`) fully determine what
184
+ * to write without prompting the user. Everything else falls back to the
185
+ * interactive picker (TTY) or the CI guard.
186
+ *
187
+ * - `--project-id` is always enough: the org is inferred from the project and
188
+ * the branch is left to an explicit `checkout` (never auto-defaulted).
189
+ * - `--org-id --project-name --region-id` fully describes a project to create.
190
+ * - `--branch-id` needs a project, which it takes from the existing `.neon`.
191
+ * - `--org-id` on its own just records the default org (merged into any
192
+ * existing context).
193
+ */
194
+ const canResolveNonInteractively = (inputs, existing) => {
195
+ if (inputs.projectId)
129
196
  return true;
130
197
  if (inputs.orgId && inputs.projectName && inputs.regionId)
131
198
  return true;
199
+ if (inputs.branch && existing.projectId)
200
+ return true;
201
+ if (inputs.orgId && !inputs.projectName && !inputs.branch)
202
+ return true;
132
203
  return false;
133
204
  };
134
205
  // ----------------------------------------------------------------------------
206
+ // Context helpers
207
+ // ----------------------------------------------------------------------------
208
+ const clearContext = (contextFile) => {
209
+ updateContextFile(contextFile, {});
210
+ process.stdout.write(`Cleared ${contextFile}. The directory is no longer linked to a Neon org/project/branch.\n`);
211
+ };
212
+ /**
213
+ * `--no-checks`: write the context offline. Makes no API calls — so no org
214
+ * inference, no existence/access verification, and no env pull — which means
215
+ * the caller must supply both `--org-id` and `--project-id` (the org can't be
216
+ * inferred without the network). `--branch` stays optional. This is the CLI
217
+ * surface over {@link setContext}, useful for scripted/offline setups and for
218
+ * re-creating a `.neon` from values you already trust.
219
+ */
220
+ const runWithoutChecks = (props) => {
221
+ const inputs = parseInputs(props);
222
+ validateInputs(inputs);
223
+ if (inputs.projectName) {
224
+ 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
+ }
226
+ 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).');
228
+ }
229
+ setContext(props.contextFile, {
230
+ orgId: inputs.orgId,
231
+ projectId: inputs.projectId,
232
+ branch: inputs.branch,
233
+ });
234
+ if (props.agent) {
235
+ emitAgent({
236
+ status: 'linked',
237
+ context_file: props.contextFile,
238
+ context: {
239
+ orgId: inputs.orgId,
240
+ projectId: inputs.projectId,
241
+ branch: inputs.branch,
242
+ },
243
+ 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.`,
245
+ });
246
+ return;
247
+ }
248
+ printSummary(props, {
249
+ contextFile: props.contextFile,
250
+ orgId: inputs.orgId,
251
+ projectId: inputs.projectId,
252
+ branch: inputs.branch,
253
+ created: false,
254
+ noChecks: true,
255
+ });
256
+ };
257
+ /**
258
+ * A bad user-supplied identifier (project/org/branch that doesn't exist or
259
+ * isn't accessible). Carries an `agentCode` so `--agent` mode can report a
260
+ * precise `status: error` code instead of a generic INTERNAL_ERROR, while the
261
+ * human path just prints the clear `message`.
262
+ */
263
+ class LinkInputError extends Error {
264
+ constructor(message, agentCode) {
265
+ super(message);
266
+ this.name = 'LinkInputError';
267
+ this.agentCode = agentCode;
268
+ }
269
+ }
270
+ const httpStatus = (err) => isAxiosError(err) ? err.response?.status : undefined;
271
+ /**
272
+ * Fetch a project, turning the common failure modes into clear, actionable
273
+ * errors. 401 is rethrown so the global handler can refresh credentials;
274
+ * everything else surfaces as a `LinkInputError` the user (or agent) can act on.
275
+ */
276
+ const fetchProjectOrThrow = async (props, projectId) => {
277
+ try {
278
+ const { data } = await props.apiClient.getProject(projectId);
279
+ return data.project;
280
+ }
281
+ catch (err) {
282
+ const status = httpStatus(err);
283
+ if (status === 401) {
284
+ throw err;
285
+ }
286
+ 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');
288
+ }
289
+ 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');
291
+ }
292
+ throw err;
293
+ }
294
+ };
295
+ /**
296
+ * Confirm the org exists and is reachable with the current API key by listing
297
+ * its projects (allowed for both user and org-scoped keys). Maps 403/404 to a
298
+ * clear message; 401 is rethrown for credential refresh.
299
+ */
300
+ const verifyOrgAccess = async (props, orgId) => {
301
+ try {
302
+ await props.apiClient.listProjects({
303
+ org_id: orgId,
304
+ limit: PROJECTS_LIST_LIMIT,
305
+ });
306
+ }
307
+ catch (err) {
308
+ const status = httpStatus(err);
309
+ if (status === 401) {
310
+ throw err;
311
+ }
312
+ 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');
314
+ }
315
+ throw err;
316
+ }
317
+ };
318
+ /**
319
+ * Resolve a branch reference (name *or* id) to the matching branch, while
320
+ * confirming it actually exists in the project. Unlike the shared
321
+ * `branchIdResolve`, this also verifies references that already look like ids
322
+ * (so a typo'd `br-…` doesn't silently get written), and surfaces the available
323
+ * branches when nothing matches so the user can correct it (or run `checkout`).
324
+ */
325
+ const resolveBranchRef = async (props, projectId, branchRef) => {
326
+ const { data } = await props.apiClient.listProjectBranches({ projectId });
327
+ const match = data.branches.find((b) => b.id === branchRef) ??
328
+ data.branches.find((b) => b.name === branchRef);
329
+ if (match) {
330
+ return match;
331
+ }
332
+ const available = data.branches.length > 0
333
+ ? 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
+ };
339
+ /**
340
+ * The value to persist for a branch: prefer its human-readable **name** (nicer
341
+ * to read in `.neon`, and still resolvable by every command), falling back to
342
+ * the id when the branch has no name.
343
+ */
344
+ const branchPersistValue = (branch) => branch.name ?? branch.id;
345
+ /**
346
+ * Verify the project (and the org, when supplied) and resolve the org id to
347
+ * persist.
348
+ *
349
+ * The project is always fetched, which both validates it and yields its
350
+ * `org_id`. When `--org-id` is passed too: if the project reports an org it must
351
+ * match (else a clear mismatch error); if it reports none, the supplied org is
352
+ * verified on its own. Without `--org-id` the project's own org is used, falling
353
+ * back to the org already recorded for the *same* project in `.neon`. Projects
354
+ * on a personal account have no org, so `undefined` is a valid result — the
355
+ * field is simply omitted.
356
+ */
357
+ const resolveOrgForProject = async (props, inputs, existing, projectId) => {
358
+ const project = await fetchProjectOrThrow(props, projectId);
359
+ const projectOrg = project.org_id ?? undefined;
360
+ if (inputs.orgId) {
361
+ 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');
363
+ }
364
+ if (!projectOrg) {
365
+ await verifyOrgAccess(props, inputs.orgId);
366
+ }
367
+ return inputs.orgId;
368
+ }
369
+ if (projectOrg) {
370
+ return projectOrg;
371
+ }
372
+ if (projectId === existing.projectId && existing.orgId) {
373
+ return existing.orgId;
374
+ }
375
+ return undefined;
376
+ };
377
+ /**
378
+ * Resolve the branch to persist alongside a project in non-interactive mode.
379
+ *
380
+ * `link` never guesses the project's default branch — that's `checkout`'s job —
381
+ * so the only sources are an explicit `--branch` (name or id, verified and
382
+ * normalized to its name) or a branch already pinned for the *same* project (so
383
+ * re-linking it doesn't drop your checked-out branch). Reading the existing
384
+ * branch via {@link contextBranch} also recovers a legacy `branchId` field.
385
+ */
386
+ const resolvePinnedBranch = async (props, inputs, existing, projectId) => {
387
+ if (inputs.branch) {
388
+ const branch = await resolveBranchRef(props, projectId, inputs.branch);
389
+ return branchPersistValue(branch);
390
+ }
391
+ if (projectId === existing.projectId) {
392
+ return contextBranch(existing);
393
+ }
394
+ return undefined;
395
+ };
396
+ // ----------------------------------------------------------------------------
135
397
  // Non-interactive flag-driven mode
136
398
  // ----------------------------------------------------------------------------
137
- const runNonInteractive = async (props, inputs) => {
138
- const orgId = mustString(inputs.orgId, 'orgId');
399
+ const runNonInteractive = async (props, inputs, existing) => {
400
+ // Create a new project and link it.
401
+ if (inputs.projectName) {
402
+ const orgId = mustString(inputs.orgId, 'orgId');
403
+ await verifyOrgAccess(props, orgId);
404
+ const created = await createProject(props, {
405
+ orgId,
406
+ name: inputs.projectName,
407
+ regionId: mustString(inputs.regionId, 'regionId'),
408
+ });
409
+ applyContext(props.contextFile, {
410
+ orgId,
411
+ projectId: created.project.id,
412
+ branch: created.branchName,
413
+ });
414
+ await finalizeLink(props, {
415
+ contextFile: props.contextFile,
416
+ orgId,
417
+ projectId: created.project.id,
418
+ branch: created.branchName,
419
+ created: true,
420
+ projectName: created.project.name,
421
+ regionId: created.project.region_id,
422
+ });
423
+ return;
424
+ }
425
+ // Link an explicitly named existing project.
139
426
  if (inputs.projectId) {
140
- const branchId = await resolveDefaultBranchId(props, inputs.projectId);
427
+ const orgId = await resolveOrgForProject(props, inputs, existing, inputs.projectId);
428
+ const branch = await resolvePinnedBranch(props, inputs, existing, inputs.projectId);
141
429
  applyContext(props.contextFile, {
142
430
  orgId,
143
431
  projectId: inputs.projectId,
144
- branchId,
432
+ branch,
145
433
  });
146
- await finalizeHumanLink(props, {
434
+ await finalizeLink(props, {
147
435
  contextFile: props.contextFile,
148
436
  orgId,
149
437
  projectId: inputs.projectId,
150
- branchId,
438
+ branch,
151
439
  created: false,
152
440
  });
153
441
  return;
154
442
  }
155
- const created = await createProject(props, {
156
- orgId,
157
- name: mustString(inputs.projectName, 'projectName'),
158
- regionId: mustString(inputs.regionId, 'regionId'),
159
- });
160
- applyContext(props.contextFile, {
161
- orgId,
162
- projectId: created.project.id,
163
- branchId: created.branchId,
164
- });
165
- await finalizeHumanLink(props, {
166
- contextFile: props.contextFile,
167
- orgId,
168
- projectId: created.project.id,
169
- branchId: created.branchId,
170
- created: true,
171
- projectName: created.project.name,
172
- regionId: created.project.region_id,
173
- });
443
+ // Pin a branch in the already-linked project.
444
+ if (inputs.branch && existing.projectId) {
445
+ const projectId = existing.projectId;
446
+ const orgId = await resolveOrgForProject(props, inputs, existing, projectId);
447
+ const branch = await resolvePinnedBranch(props, inputs, existing, projectId);
448
+ applyContext(props.contextFile, {
449
+ orgId,
450
+ projectId,
451
+ branch,
452
+ });
453
+ await finalizeLink(props, {
454
+ contextFile: props.contextFile,
455
+ orgId,
456
+ projectId,
457
+ branch,
458
+ created: false,
459
+ });
460
+ return;
461
+ }
462
+ // Record the default org, preserving any existing project/branch.
463
+ if (inputs.orgId) {
464
+ const orgId = inputs.orgId;
465
+ await verifyOrgAccess(props, orgId);
466
+ const projectId = existing.projectId;
467
+ const branch = projectId ? contextBranch(existing) : undefined;
468
+ applyContext(props.contextFile, { orgId, projectId, branch });
469
+ printSummary(props, {
470
+ contextFile: props.contextFile,
471
+ orgId,
472
+ projectId,
473
+ branch,
474
+ created: false,
475
+ orgOnly: true,
476
+ });
477
+ return;
478
+ }
174
479
  };
175
480
  // ----------------------------------------------------------------------------
176
481
  // Interactive mode (TTY)
@@ -195,22 +500,6 @@ const runInteractive = async (props, inputs) => {
195
500
  else {
196
501
  orgId = await promptOrgFromList(orgResolution.orgs);
197
502
  }
198
- if (inputs.projectId) {
199
- const branchId = await resolveInteractiveBranchId(props, inputs.projectId);
200
- applyContext(props.contextFile, {
201
- orgId,
202
- projectId: inputs.projectId,
203
- branchId,
204
- });
205
- await finalizeHumanLink(props, {
206
- contextFile: props.contextFile,
207
- orgId,
208
- projectId: inputs.projectId,
209
- branchId,
210
- created: false,
211
- });
212
- return;
213
- }
214
503
  if (inputs.projectName && inputs.regionId) {
215
504
  const created = await createProject(props, {
216
505
  orgId,
@@ -220,13 +509,13 @@ const runInteractive = async (props, inputs) => {
220
509
  applyContext(props.contextFile, {
221
510
  orgId,
222
511
  projectId: created.project.id,
223
- branchId: created.branchId,
512
+ branch: created.branchName,
224
513
  });
225
- await finalizeHumanLink(props, {
514
+ await finalizeLink(props, {
226
515
  contextFile: props.contextFile,
227
516
  orgId,
228
517
  projectId: created.project.id,
229
- branchId: created.branchId,
518
+ branch: created.branchName,
230
519
  created: true,
231
520
  projectName: created.project.name,
232
521
  regionId: created.project.region_id,
@@ -237,17 +526,17 @@ const runInteractive = async (props, inputs) => {
237
526
  const projects = await listAllProjects(props, orgId);
238
527
  const action = await promptProjectChoice(projects, inputs.projectName);
239
528
  if (action.type === 'existing') {
240
- const branchId = await resolveInteractiveBranchId(props, action.projectId);
529
+ const branch = await resolveInteractiveBranch(props, action.projectId);
241
530
  applyContext(props.contextFile, {
242
531
  orgId,
243
532
  projectId: action.projectId,
244
- branchId,
533
+ branch,
245
534
  });
246
- await finalizeHumanLink(props, {
535
+ await finalizeLink(props, {
247
536
  contextFile: props.contextFile,
248
537
  orgId,
249
538
  projectId: action.projectId,
250
- branchId,
539
+ branch,
251
540
  created: false,
252
541
  projectName: action.name,
253
542
  regionId: action.regionId,
@@ -264,13 +553,13 @@ const runInteractive = async (props, inputs) => {
264
553
  applyContext(props.contextFile, {
265
554
  orgId,
266
555
  projectId: created.project.id,
267
- branchId: created.branchId,
556
+ branch: created.branchName,
268
557
  });
269
- await finalizeHumanLink(props, {
558
+ await finalizeLink(props, {
270
559
  contextFile: props.contextFile,
271
560
  orgId,
272
561
  projectId: created.project.id,
273
- branchId: created.branchId,
562
+ branch: created.branchName,
274
563
  created: true,
275
564
  projectName: created.project.name,
276
565
  regionId: created.project.region_id,
@@ -381,31 +670,50 @@ const runAgentSafely = async (props) => {
381
670
  }
382
671
  };
383
672
  const runAgent = async (props, inputs) => {
384
- const { projectId, projectName, regionId } = inputs;
385
- const orgResolution = await resolveOrg(props, inputs.orgId);
386
- if (orgResolution.kind === 'needs_selection') {
387
- emitAgent(buildNeedsOrgResponse(orgResolution));
388
- return;
389
- }
390
- const orgId = orgResolution.orgId;
673
+ const { projectId, projectName, regionId, branch } = inputs;
674
+ const existing = readContextFile(props.contextFile);
675
+ // Existing project: infer the org and link it. The branch is left to an
676
+ // explicit `checkout` unless one was passed or is already pinned.
391
677
  if (projectId) {
392
- const branchId = await resolveDefaultBranchId(props, projectId);
393
- applyContext(props.contextFile, { orgId, projectId, branchId });
394
- const pullNote = renderAgentPullNote(await autoPullEnvAfterPin({
395
- ...props,
678
+ const orgId = await resolveOrgForProject(props, inputs, existing, projectId);
679
+ const pinnedBranch = await resolvePinnedBranch(props, inputs, existing, projectId);
680
+ applyContext(props.contextFile, {
681
+ orgId,
396
682
  projectId,
397
- branch: branchId,
398
- envPull: props.envPull,
399
- }));
683
+ branch: pinnedBranch,
684
+ });
685
+ const orgSuffix = orgId ? ` (org ${orgId})` : '';
686
+ if (pinnedBranch) {
687
+ const pullNote = renderAgentPullNote(await autoPullEnvAfterPin({
688
+ ...props,
689
+ projectId,
690
+ branch: pinnedBranch,
691
+ envPull: props.envPull,
692
+ }));
693
+ emitAgent({
694
+ status: 'linked',
695
+ context_file: props.contextFile,
696
+ context: { orgId, projectId, branch: pinnedBranch },
697
+ project: { id: projectId },
698
+ message: `Linked ${props.contextFile} to project ${projectId}${orgSuffix} on branch ${pinnedBranch}.${pullNote}`,
699
+ });
700
+ return;
701
+ }
400
702
  emitAgent({
401
703
  status: 'linked',
402
704
  context_file: props.contextFile,
403
- context: { orgId, projectId, branchId },
705
+ context: { orgId, projectId },
404
706
  project: { id: projectId },
405
- message: `Linked ${props.contextFile} to project ${projectId} (org ${orgId}) on branch ${branchId}.${pullNote}`,
707
+ 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.`,
406
708
  });
407
709
  return;
408
710
  }
711
+ const orgResolution = await resolveOrg(props, inputs.orgId);
712
+ if (orgResolution.kind === 'needs_selection') {
713
+ emitAgent(buildNeedsOrgResponse(orgResolution));
714
+ return;
715
+ }
716
+ const orgId = orgResolution.orgId;
409
717
  if (projectName && !regionId) {
410
718
  const regions = await fetchRegions(props);
411
719
  emitAgent({
@@ -421,6 +729,7 @@ const runAgent = async (props, inputs) => {
421
729
  return;
422
730
  }
423
731
  if (projectName && regionId) {
732
+ await verifyOrgAccess(props, orgId);
424
733
  const created = await createProject(props, {
425
734
  orgId,
426
735
  name: projectName,
@@ -429,12 +738,12 @@ const runAgent = async (props, inputs) => {
429
738
  applyContext(props.contextFile, {
430
739
  orgId,
431
740
  projectId: created.project.id,
432
- branchId: created.branchId,
741
+ branch: created.branchName,
433
742
  });
434
743
  const pullNote = renderAgentPullNote(await autoPullEnvAfterPin({
435
744
  ...props,
436
745
  projectId: created.project.id,
437
- branch: created.branchId,
746
+ branch: created.branchName,
438
747
  envPull: props.envPull,
439
748
  }));
440
749
  emitAgent({
@@ -443,24 +752,30 @@ const runAgent = async (props, inputs) => {
443
752
  context: {
444
753
  orgId,
445
754
  projectId: created.project.id,
446
- branchId: created.branchId,
755
+ branch: created.branchName,
447
756
  },
448
757
  project: {
449
758
  id: created.project.id,
450
759
  name: created.project.name,
451
760
  region_id: created.project.region_id,
452
761
  },
453
- message: `Created project ${created.project.id} ("${created.project.name ?? projectName}") in ${created.project.region_id ?? regionId} and linked ${props.contextFile}.${pullNote}`,
762
+ 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}`,
454
763
  });
455
764
  return;
456
765
  }
457
- // orgId is set but no project info — list projects to choose from.
766
+ // orgId is set but no project info — list projects to choose from. A pending
767
+ // --branch can't be applied until a project is chosen, so it's surfaced in
768
+ // the instruction rather than silently dropped.
458
769
  const projects = await listAllProjects(props, orgId);
770
+ const branchNote = branch
771
+ ? ` 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}\`.`
772
+ : '';
459
773
  emitAgent({
460
774
  status: 'needs_project',
461
- instruction: projects.length === 0
775
+ instruction: (projects.length === 0
462
776
  ? `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.`
463
- : `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).`,
777
+ : `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).`) +
778
+ branchNote,
464
779
  options: projects.map((project) => ({
465
780
  id: project.id,
466
781
  name: project.name,
@@ -554,6 +869,9 @@ const buildNeedsOrgResponse = (resolution) => {
554
869
  };
555
870
  };
556
871
  const toAgentError = (err) => {
872
+ if (err instanceof LinkInputError) {
873
+ return { status: 'error', code: err.agentCode, message: err.message };
874
+ }
557
875
  if (isAxiosError(err)) {
558
876
  const status = err.response?.status;
559
877
  const data = err.response?.data;
@@ -604,22 +922,15 @@ const listAllProjects = async (props, orgId) => {
604
922
  }
605
923
  return result;
606
924
  };
607
- const resolveDefaultBranchId = async (props, projectId) => {
608
- const { data } = await props.apiClient.listProjectBranches({ projectId });
609
- const branch = data.branches.find((b) => b.default);
610
- if (!branch) {
611
- throw new Error(`Could not find a default branch for project ${projectId}.`);
612
- }
613
- return branch.id;
614
- };
615
925
  /**
616
- * Resolve which branch to pin for an interactively-chosen project. When the project has a
926
+ * Resolve which branch to pin for an interactively-chosen project, returned as the value to
927
+ * persist (its name when known, see {@link branchPersistValue}). When the project has a
617
928
  * single branch there is nothing to choose, so we pin it silently. Otherwise we offer the
618
929
  * shared branch picker (the same "+ Create a new branch…" + list as `neonctl checkout`),
619
- * creating the branch when the user opts to. This makes `link` a full org → project →
620
- * branch flow instead of always pinning the default branch.
930
+ * creating the branch when the user opts to. This makes interactive `link` a full org →
931
+ * project → branch flow; non-interactive `link` instead defers the branch to `checkout`.
621
932
  */
622
- const resolveInteractiveBranchId = async (props, projectId) => {
933
+ const resolveInteractiveBranch = async (props, projectId) => {
623
934
  const { data } = await props.apiClient.listProjectBranches({ projectId });
624
935
  const branches = data.branches;
625
936
  if (branches.length <= 1) {
@@ -627,7 +938,7 @@ const resolveInteractiveBranchId = async (props, projectId) => {
627
938
  if (!only) {
628
939
  throw new Error(`Could not find a default branch for project ${projectId}.`);
629
940
  }
630
- return only.id;
941
+ return branchPersistValue(only);
631
942
  }
632
943
  const picked = await pickBranchInteractively(branches, {
633
944
  message: 'Which branch would you like to link?',
@@ -635,9 +946,12 @@ const resolveInteractiveBranchId = async (props, projectId) => {
635
946
  'Re-run `neonctl link` interactively, or `neonctl checkout <branch>` to pin one.',
636
947
  });
637
948
  if (picked.kind === 'existing') {
638
- return picked.branchId;
949
+ const existing = branches.find((b) => b.id === picked.branchId);
950
+ return existing ? branchPersistValue(existing) : picked.branchId;
639
951
  }
640
- return createBranch(props.apiClient, projectId, picked.name, branches);
952
+ // A freshly-created branch: we already know the name the user typed.
953
+ await createBranch(props.apiClient, projectId, picked.name, branches);
954
+ return picked.name;
641
955
  };
642
956
  const fetchRegions = async (props) => {
643
957
  try {
@@ -682,31 +996,50 @@ const createProject = async (props, args) => {
682
996
  region_id: data.project.region_id,
683
997
  },
684
998
  branchId: data.branch.id,
999
+ branchName: data.branch.name ?? data.branch.id,
685
1000
  };
686
1001
  };
687
- const printHumanSummary = (_props, summary) => {
1002
+ const printSummary = (_props, summary) => {
688
1003
  const lines = [];
689
1004
  if (summary.created) {
690
1005
  lines.push(`Created project ${summary.projectId}${summary.projectName ? ` ("${summary.projectName}")` : ''}${summary.regionId ? ` in ${summary.regionId}` : ''}.`);
691
1006
  }
692
- lines.push(`Linked ${summary.contextFile}:`);
693
- lines.push(` orgId: ${summary.orgId}`);
694
- lines.push(` projectId: ${summary.projectId}`);
695
- lines.push(` branchId: ${summary.branchId}`);
1007
+ lines.push(`${summary.orgOnly ? 'Updated' : 'Linked'} ${summary.contextFile}:`);
1008
+ if (summary.orgId) {
1009
+ lines.push(` orgId: ${summary.orgId}`);
1010
+ }
1011
+ if (summary.projectId) {
1012
+ lines.push(` projectId: ${summary.projectId}`);
1013
+ }
1014
+ if (summary.branch) {
1015
+ lines.push(` branch: ${summary.branch}`);
1016
+ }
1017
+ if (summary.noChecks) {
1018
+ lines.push('');
1019
+ lines.push('Written offline (--no-checks): nothing was verified.');
1020
+ }
1021
+ 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.');
1024
+ }
696
1025
  lines.push('');
697
1026
  process.stdout.write(`${lines.join('\n')}\n`);
698
1027
  };
699
1028
  /**
700
- * Print the link summary, then run the bundled `env pull` so a human `link` ends with the
701
- * branch's connection string already on disk the branch-first loop is just link + checkout.
1029
+ * Print the link summary, then run the bundled `env pull` so a human `link` that pinned a
1030
+ * branch ends with the branch's connection string already on disk. When no branch was pinned
1031
+ * there is nothing to pull, so env pull is skipped and the summary nudges `checkout` instead.
702
1032
  * `--no-env-pull` opts out (env pull's own status / skip hint is logged to stderr).
703
1033
  */
704
- const finalizeHumanLink = async (props, summary) => {
705
- printHumanSummary(props, summary);
1034
+ const finalizeLink = async (props, summary) => {
1035
+ printSummary(props, summary);
1036
+ if (!summary.branch || !summary.projectId) {
1037
+ return;
1038
+ }
706
1039
  await autoPullEnvAfterPin({
707
1040
  ...props,
708
1041
  projectId: summary.projectId,
709
- branch: summary.branchId,
1042
+ branch: summary.branch,
710
1043
  envPull: props.envPull,
711
1044
  });
712
1045
  };