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/README.md +127 -42
- 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 +64 -20
- package/commands/config.js +144 -11
- package/commands/deploy.js +2 -1
- package/commands/dev.js +13 -57
- package/commands/env.js +10 -2
- package/commands/functions.js +53 -5
- package/commands/index.js +2 -0
- package/commands/link.js +441 -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 +47 -6
- 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/branch_picker.js +16 -1
- 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,
|
|
@@ -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
|
|
386
|
-
|
|
387
|
-
|
|
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
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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:
|
|
398
|
-
|
|
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
|
|
705
|
+
context: { orgId, projectId },
|
|
404
706
|
project: { id: projectId },
|
|
405
|
-
message: `Linked ${props.contextFile} to project ${projectId}
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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 →
|
|
620
|
-
* branch flow
|
|
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
|
|
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
|
|
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
|
-
|
|
949
|
+
const existing = branches.find((b) => b.id === picked.branchId);
|
|
950
|
+
return existing ? branchPersistValue(existing) : picked.branchId;
|
|
639
951
|
}
|
|
640
|
-
|
|
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
|
|
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(
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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`
|
|
701
|
-
* branch's connection string already on disk
|
|
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
|
|
705
|
-
|
|
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.
|
|
1042
|
+
branch: summary.branch,
|
|
710
1043
|
envPull: props.envPull,
|
|
711
1044
|
});
|
|
712
1045
|
};
|