neonctl 2.24.2 → 2.25.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.
- package/README.md +137 -47
- package/commands/auth.js +9 -0
- package/commands/bootstrap.js +603 -0
- package/commands/branches.js +6 -4
- package/commands/bucket.js +118 -5
- package/commands/checkout.js +25 -8
- package/commands/config.js +98 -10
- package/commands/deploy.js +2 -1
- package/commands/dev.js +11 -57
- package/commands/env.js +9 -2
- package/commands/functions.js +81 -16
- package/commands/index.js +2 -0
- package/commands/link.js +448 -108
- package/commands/projects.js +2 -2
- package/commands/set_context.js +5 -1
- package/config_format.js +8 -2
- package/context.js +33 -5
- package/dev/env.js +38 -0
- package/dev/functions.js +2 -4
- package/dev/runtime.js +2 -2
- package/index.js +1 -0
- package/package.json +5 -5
- package/storage_api.js +34 -0
- package/utils/bootstrap.js +243 -0
- package/utils/esbuild.js +11 -2
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
|
|
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
|
-
|
|
61
|
-
|
|
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 --
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
432
|
+
branch,
|
|
145
433
|
});
|
|
146
|
-
await
|
|
434
|
+
await finalizeLink(props, {
|
|
147
435
|
contextFile: props.contextFile,
|
|
148
436
|
orgId,
|
|
149
437
|
projectId: inputs.projectId,
|
|
150
|
-
|
|
438
|
+
branch,
|
|
151
439
|
created: false,
|
|
152
440
|
});
|
|
153
441
|
return;
|
|
154
442
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
512
|
+
branch: created.branchName,
|
|
224
513
|
});
|
|
225
|
-
await
|
|
514
|
+
await finalizeLink(props, {
|
|
226
515
|
contextFile: props.contextFile,
|
|
227
516
|
orgId,
|
|
228
517
|
projectId: created.project.id,
|
|
229
|
-
|
|
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
|
|
529
|
+
const branch = await resolveInteractiveBranch(props, action.projectId);
|
|
241
530
|
applyContext(props.contextFile, {
|
|
242
531
|
orgId,
|
|
243
532
|
projectId: action.projectId,
|
|
244
|
-
|
|
533
|
+
branch,
|
|
245
534
|
});
|
|
246
|
-
await
|
|
535
|
+
await finalizeLink(props, {
|
|
247
536
|
contextFile: props.contextFile,
|
|
248
537
|
orgId,
|
|
249
538
|
projectId: action.projectId,
|
|
250
|
-
|
|
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
|
-
|
|
556
|
+
branch: created.branchName,
|
|
268
557
|
});
|
|
269
|
-
await
|
|
558
|
+
await finalizeLink(props, {
|
|
270
559
|
contextFile: props.contextFile,
|
|
271
560
|
orgId,
|
|
272
561
|
projectId: created.project.id,
|
|
273
|
-
|
|
562
|
+
branch: created.branchName,
|
|
274
563
|
created: true,
|
|
275
564
|
projectName: created.project.name,
|
|
276
565
|
regionId: created.project.region_id,
|
|
@@ -297,6 +586,13 @@ const promptOrgFromList = async (orgs) => {
|
|
|
297
586
|
if (!orgs.length) {
|
|
298
587
|
throw new Error(`You don't belong to any organizations. Create one in the Neon Console first: https://console.neon.tech/`);
|
|
299
588
|
}
|
|
589
|
+
// A single organization leaves nothing to choose, so skip the prompt and link
|
|
590
|
+
// it directly — go straight on to the project step.
|
|
591
|
+
if (orgs.length === 1) {
|
|
592
|
+
const [only] = orgs;
|
|
593
|
+
log.info(`Linking organization ${only.name} (${only.id}).`);
|
|
594
|
+
return only.id;
|
|
595
|
+
}
|
|
300
596
|
const { orgId } = await prompts({
|
|
301
597
|
onState: onPromptState,
|
|
302
598
|
type: 'select',
|
|
@@ -381,31 +677,50 @@ const runAgentSafely = async (props) => {
|
|
|
381
677
|
}
|
|
382
678
|
};
|
|
383
679
|
const runAgent = async (props, inputs) => {
|
|
384
|
-
const { projectId, projectName, regionId } = inputs;
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
return;
|
|
389
|
-
}
|
|
390
|
-
const orgId = orgResolution.orgId;
|
|
680
|
+
const { projectId, projectName, regionId, branch } = inputs;
|
|
681
|
+
const existing = readContextFile(props.contextFile);
|
|
682
|
+
// Existing project: infer the org and link it. The branch is left to an
|
|
683
|
+
// explicit `checkout` unless one was passed or is already pinned.
|
|
391
684
|
if (projectId) {
|
|
392
|
-
const
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
685
|
+
const orgId = await resolveOrgForProject(props, inputs, existing, projectId);
|
|
686
|
+
const pinnedBranch = await resolvePinnedBranch(props, inputs, existing, projectId);
|
|
687
|
+
applyContext(props.contextFile, {
|
|
688
|
+
orgId,
|
|
396
689
|
projectId,
|
|
397
|
-
branch:
|
|
398
|
-
|
|
399
|
-
})
|
|
690
|
+
branch: pinnedBranch,
|
|
691
|
+
});
|
|
692
|
+
const orgSuffix = orgId ? ` (org ${orgId})` : '';
|
|
693
|
+
if (pinnedBranch) {
|
|
694
|
+
const pullNote = renderAgentPullNote(await autoPullEnvAfterPin({
|
|
695
|
+
...props,
|
|
696
|
+
projectId,
|
|
697
|
+
branch: pinnedBranch,
|
|
698
|
+
envPull: props.envPull,
|
|
699
|
+
}));
|
|
700
|
+
emitAgent({
|
|
701
|
+
status: 'linked',
|
|
702
|
+
context_file: props.contextFile,
|
|
703
|
+
context: { orgId, projectId, branch: pinnedBranch },
|
|
704
|
+
project: { id: projectId },
|
|
705
|
+
message: `Linked ${props.contextFile} to project ${projectId}${orgSuffix} on branch ${pinnedBranch}.${pullNote}`,
|
|
706
|
+
});
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
400
709
|
emitAgent({
|
|
401
710
|
status: 'linked',
|
|
402
711
|
context_file: props.contextFile,
|
|
403
|
-
context: { orgId, projectId
|
|
712
|
+
context: { orgId, projectId },
|
|
404
713
|
project: { id: projectId },
|
|
405
|
-
message: `Linked ${props.contextFile} to project ${projectId}
|
|
714
|
+
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
715
|
});
|
|
407
716
|
return;
|
|
408
717
|
}
|
|
718
|
+
const orgResolution = await resolveOrg(props, inputs.orgId);
|
|
719
|
+
if (orgResolution.kind === 'needs_selection') {
|
|
720
|
+
emitAgent(buildNeedsOrgResponse(orgResolution));
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
const orgId = orgResolution.orgId;
|
|
409
724
|
if (projectName && !regionId) {
|
|
410
725
|
const regions = await fetchRegions(props);
|
|
411
726
|
emitAgent({
|
|
@@ -421,6 +736,7 @@ const runAgent = async (props, inputs) => {
|
|
|
421
736
|
return;
|
|
422
737
|
}
|
|
423
738
|
if (projectName && regionId) {
|
|
739
|
+
await verifyOrgAccess(props, orgId);
|
|
424
740
|
const created = await createProject(props, {
|
|
425
741
|
orgId,
|
|
426
742
|
name: projectName,
|
|
@@ -429,12 +745,12 @@ const runAgent = async (props, inputs) => {
|
|
|
429
745
|
applyContext(props.contextFile, {
|
|
430
746
|
orgId,
|
|
431
747
|
projectId: created.project.id,
|
|
432
|
-
|
|
748
|
+
branch: created.branchName,
|
|
433
749
|
});
|
|
434
750
|
const pullNote = renderAgentPullNote(await autoPullEnvAfterPin({
|
|
435
751
|
...props,
|
|
436
752
|
projectId: created.project.id,
|
|
437
|
-
branch: created.
|
|
753
|
+
branch: created.branchName,
|
|
438
754
|
envPull: props.envPull,
|
|
439
755
|
}));
|
|
440
756
|
emitAgent({
|
|
@@ -443,24 +759,30 @@ const runAgent = async (props, inputs) => {
|
|
|
443
759
|
context: {
|
|
444
760
|
orgId,
|
|
445
761
|
projectId: created.project.id,
|
|
446
|
-
|
|
762
|
+
branch: created.branchName,
|
|
447
763
|
},
|
|
448
764
|
project: {
|
|
449
765
|
id: created.project.id,
|
|
450
766
|
name: created.project.name,
|
|
451
767
|
region_id: created.project.region_id,
|
|
452
768
|
},
|
|
453
|
-
message: `Created project ${created.project.id} ("${created.project.name ?? projectName}") in ${created.project.region_id ?? regionId} and linked ${props.contextFile}.${pullNote}`,
|
|
769
|
+
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
770
|
});
|
|
455
771
|
return;
|
|
456
772
|
}
|
|
457
|
-
// orgId is set but no project info — list projects to choose from.
|
|
773
|
+
// orgId is set but no project info — list projects to choose from. A pending
|
|
774
|
+
// --branch can't be applied until a project is chosen, so it's surfaced in
|
|
775
|
+
// the instruction rather than silently dropped.
|
|
458
776
|
const projects = await listAllProjects(props, orgId);
|
|
777
|
+
const branchNote = branch
|
|
778
|
+
? ` 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
|
+
: '';
|
|
459
780
|
emitAgent({
|
|
460
781
|
status: 'needs_project',
|
|
461
|
-
instruction: projects.length === 0
|
|
782
|
+
instruction: (projects.length === 0
|
|
462
783
|
? `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)
|
|
784
|
+
: `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).`) +
|
|
785
|
+
branchNote,
|
|
464
786
|
options: projects.map((project) => ({
|
|
465
787
|
id: project.id,
|
|
466
788
|
name: project.name,
|
|
@@ -554,6 +876,9 @@ const buildNeedsOrgResponse = (resolution) => {
|
|
|
554
876
|
};
|
|
555
877
|
};
|
|
556
878
|
const toAgentError = (err) => {
|
|
879
|
+
if (err instanceof LinkInputError) {
|
|
880
|
+
return { status: 'error', code: err.agentCode, message: err.message };
|
|
881
|
+
}
|
|
557
882
|
if (isAxiosError(err)) {
|
|
558
883
|
const status = err.response?.status;
|
|
559
884
|
const data = err.response?.data;
|
|
@@ -604,22 +929,15 @@ const listAllProjects = async (props, orgId) => {
|
|
|
604
929
|
}
|
|
605
930
|
return result;
|
|
606
931
|
};
|
|
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
932
|
/**
|
|
616
|
-
* Resolve which branch to pin for an interactively-chosen project
|
|
933
|
+
* Resolve which branch to pin for an interactively-chosen project, returned as the value to
|
|
934
|
+
* persist (its name when known, see {@link branchPersistValue}). When the project has a
|
|
617
935
|
* single branch there is nothing to choose, so we pin it silently. Otherwise we offer the
|
|
618
936
|
* 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 →
|
|
620
|
-
* branch flow
|
|
937
|
+
* creating the branch when the user opts to. This makes interactive `link` a full org →
|
|
938
|
+
* project → branch flow; non-interactive `link` instead defers the branch to `checkout`.
|
|
621
939
|
*/
|
|
622
|
-
const
|
|
940
|
+
const resolveInteractiveBranch = async (props, projectId) => {
|
|
623
941
|
const { data } = await props.apiClient.listProjectBranches({ projectId });
|
|
624
942
|
const branches = data.branches;
|
|
625
943
|
if (branches.length <= 1) {
|
|
@@ -627,7 +945,7 @@ const resolveInteractiveBranchId = async (props, projectId) => {
|
|
|
627
945
|
if (!only) {
|
|
628
946
|
throw new Error(`Could not find a default branch for project ${projectId}.`);
|
|
629
947
|
}
|
|
630
|
-
return only
|
|
948
|
+
return branchPersistValue(only);
|
|
631
949
|
}
|
|
632
950
|
const picked = await pickBranchInteractively(branches, {
|
|
633
951
|
message: 'Which branch would you like to link?',
|
|
@@ -635,9 +953,12 @@ const resolveInteractiveBranchId = async (props, projectId) => {
|
|
|
635
953
|
'Re-run `neonctl link` interactively, or `neonctl checkout <branch>` to pin one.',
|
|
636
954
|
});
|
|
637
955
|
if (picked.kind === 'existing') {
|
|
638
|
-
|
|
956
|
+
const existing = branches.find((b) => b.id === picked.branchId);
|
|
957
|
+
return existing ? branchPersistValue(existing) : picked.branchId;
|
|
639
958
|
}
|
|
640
|
-
|
|
959
|
+
// A freshly-created branch: we already know the name the user typed.
|
|
960
|
+
await createBranch(props.apiClient, projectId, picked.name, branches);
|
|
961
|
+
return picked.name;
|
|
641
962
|
};
|
|
642
963
|
const fetchRegions = async (props) => {
|
|
643
964
|
try {
|
|
@@ -682,31 +1003,50 @@ const createProject = async (props, args) => {
|
|
|
682
1003
|
region_id: data.project.region_id,
|
|
683
1004
|
},
|
|
684
1005
|
branchId: data.branch.id,
|
|
1006
|
+
branchName: data.branch.name ?? data.branch.id,
|
|
685
1007
|
};
|
|
686
1008
|
};
|
|
687
|
-
const
|
|
1009
|
+
const printSummary = (_props, summary) => {
|
|
688
1010
|
const lines = [];
|
|
689
1011
|
if (summary.created) {
|
|
690
1012
|
lines.push(`Created project ${summary.projectId}${summary.projectName ? ` ("${summary.projectName}")` : ''}${summary.regionId ? ` in ${summary.regionId}` : ''}.`);
|
|
691
1013
|
}
|
|
692
|
-
lines.push(
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
1014
|
+
lines.push(`${summary.orgOnly ? 'Updated' : 'Linked'} ${summary.contextFile}:`);
|
|
1015
|
+
if (summary.orgId) {
|
|
1016
|
+
lines.push(` orgId: ${summary.orgId}`);
|
|
1017
|
+
}
|
|
1018
|
+
if (summary.projectId) {
|
|
1019
|
+
lines.push(` projectId: ${summary.projectId}`);
|
|
1020
|
+
}
|
|
1021
|
+
if (summary.branch) {
|
|
1022
|
+
lines.push(` branch: ${summary.branch}`);
|
|
1023
|
+
}
|
|
1024
|
+
if (summary.noChecks) {
|
|
1025
|
+
lines.push('');
|
|
1026
|
+
lines.push('Written offline (--no-checks): nothing was verified.');
|
|
1027
|
+
}
|
|
1028
|
+
else if (summary.projectId && !summary.branch && !summary.orgOnly) {
|
|
1029
|
+
lines.push('');
|
|
1030
|
+
lines.push('No branch pinned. Run `neonctl checkout <branch>` to pin a branch and pull its env vars.');
|
|
1031
|
+
}
|
|
696
1032
|
lines.push('');
|
|
697
1033
|
process.stdout.write(`${lines.join('\n')}\n`);
|
|
698
1034
|
};
|
|
699
1035
|
/**
|
|
700
|
-
* Print the link summary, then run the bundled `env pull` so a human `link`
|
|
701
|
-
* branch's connection string already on disk
|
|
1036
|
+
* Print the link summary, then run the bundled `env pull` so a human `link` that pinned a
|
|
1037
|
+
* branch ends with the branch's connection string already on disk. When no branch was pinned
|
|
1038
|
+
* there is nothing to pull, so env pull is skipped and the summary nudges `checkout` instead.
|
|
702
1039
|
* `--no-env-pull` opts out (env pull's own status / skip hint is logged to stderr).
|
|
703
1040
|
*/
|
|
704
|
-
const
|
|
705
|
-
|
|
1041
|
+
const finalizeLink = async (props, summary) => {
|
|
1042
|
+
printSummary(props, summary);
|
|
1043
|
+
if (!summary.branch || !summary.projectId) {
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
706
1046
|
await autoPullEnvAfterPin({
|
|
707
1047
|
...props,
|
|
708
1048
|
projectId: summary.projectId,
|
|
709
|
-
branch: summary.
|
|
1049
|
+
branch: summary.branch,
|
|
710
1050
|
envPull: props.envPull,
|
|
711
1051
|
});
|
|
712
1052
|
};
|