neonctl 2.21.2 → 2.22.2
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 +158 -16
- package/commands/checkout.js +249 -0
- package/commands/checkout.test.js +170 -0
- package/commands/connection_string.js +6 -1
- package/commands/data_api.js +286 -0
- package/commands/data_api.test.js +169 -0
- package/commands/index.js +8 -0
- package/commands/link.js +667 -0
- package/commands/link.test.js +381 -0
- package/commands/psql.js +57 -0
- package/commands/psql.test.js +49 -0
- package/commands/set_context.js +7 -2
- package/context.js +86 -14
- package/context.test.js +119 -0
- package/index.js +3 -0
- package/package.json +48 -52
- package/utils/enrichers.js +18 -1
- package/utils/middlewares.js +1 -1
package/commands/link.js
ADDED
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
import { isAxiosError } from 'axios';
|
|
2
|
+
import prompts from 'prompts';
|
|
3
|
+
import { applyContext, readContextFile } from '../context.js';
|
|
4
|
+
import { isCi } from '../env.js';
|
|
5
|
+
import { log } from '../log.js';
|
|
6
|
+
import { REGIONS } from './projects.js';
|
|
7
|
+
const PROJECTS_LIST_LIMIT = 100;
|
|
8
|
+
const CREATE_NEW_SENTINEL = '__create_new__';
|
|
9
|
+
export const command = 'link';
|
|
10
|
+
export const describe = 'Link the current directory to a Neon project';
|
|
11
|
+
export const builder = (argv) => argv.usage('$0 link [options]').options({
|
|
12
|
+
'org-id': {
|
|
13
|
+
describe: 'Organization ID to link to',
|
|
14
|
+
type: 'string',
|
|
15
|
+
},
|
|
16
|
+
'project-id': {
|
|
17
|
+
describe: 'Existing project ID to link to',
|
|
18
|
+
type: 'string',
|
|
19
|
+
},
|
|
20
|
+
'project-name': {
|
|
21
|
+
describe: 'Name for a new project to create and link to',
|
|
22
|
+
type: 'string',
|
|
23
|
+
},
|
|
24
|
+
'region-id': {
|
|
25
|
+
describe: 'Region ID for a new project (e.g. aws-us-east-2). Required with --project-name.',
|
|
26
|
+
type: 'string',
|
|
27
|
+
},
|
|
28
|
+
params: {
|
|
29
|
+
describe: 'JSON object with link parameters, e.g. \'{"orgId":"...","projectId":"..."}\' or \'{"orgId":"...","projectName":"...","regionId":"..."}\'. Flags take precedence over fields in --params.',
|
|
30
|
+
type: 'string',
|
|
31
|
+
},
|
|
32
|
+
agent: {
|
|
33
|
+
describe: 'Emit a JSON state-machine response designed for AI agents instead of prompting. The output is a single JSON object with a discriminated `status` field describing the next step.',
|
|
34
|
+
type: 'boolean',
|
|
35
|
+
default: false,
|
|
36
|
+
},
|
|
37
|
+
yes: {
|
|
38
|
+
alias: 'y',
|
|
39
|
+
describe: 'Skip the "already linked" confirmation in interactive mode and re-link anyway.',
|
|
40
|
+
type: 'boolean',
|
|
41
|
+
default: false,
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
export const handler = async (props) => {
|
|
45
|
+
if (props.agent) {
|
|
46
|
+
await runAgentSafely(props);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const inputs = parseInputs(props);
|
|
50
|
+
validateInputs(inputs);
|
|
51
|
+
if (hasEnoughForNonInteractive(inputs)) {
|
|
52
|
+
await runNonInteractive(props, inputs);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (isCi()) {
|
|
56
|
+
log.error([
|
|
57
|
+
'Missing inputs and CI environment detected (no TTY for prompts).',
|
|
58
|
+
'',
|
|
59
|
+
'Use one of:',
|
|
60
|
+
' neonctl link --agent (JSON state machine for agents)',
|
|
61
|
+
' neonctl link --org-id <org> --project-id <project> (link to an existing project)',
|
|
62
|
+
' neonctl link --org-id <org> --project-name <name> --region-id <region> (create a new project and link)',
|
|
63
|
+
].join('\n'));
|
|
64
|
+
process.exit(1);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
await runInteractive(props, inputs);
|
|
68
|
+
};
|
|
69
|
+
// ----------------------------------------------------------------------------
|
|
70
|
+
// Input parsing & validation
|
|
71
|
+
// ----------------------------------------------------------------------------
|
|
72
|
+
const parseInputs = (props) => {
|
|
73
|
+
let fromParams = {};
|
|
74
|
+
if (props.params !== undefined && props.params !== '') {
|
|
75
|
+
let parsed;
|
|
76
|
+
try {
|
|
77
|
+
parsed = JSON.parse(props.params);
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
81
|
+
throw new Error(`Failed to parse --params JSON: ${message}`);
|
|
82
|
+
}
|
|
83
|
+
fromParams = extractParams(parsed);
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
orgId: props.orgId ?? fromParams.orgId,
|
|
87
|
+
projectId: props.projectId ?? fromParams.projectId,
|
|
88
|
+
projectName: props.projectName ?? fromParams.projectName,
|
|
89
|
+
regionId: props.regionId ?? fromParams.regionId,
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
const extractParams = (raw) => {
|
|
93
|
+
if (raw === null || typeof raw !== 'object') {
|
|
94
|
+
throw new Error('--params must be a JSON object');
|
|
95
|
+
}
|
|
96
|
+
const obj = raw;
|
|
97
|
+
const pickString = (key) => {
|
|
98
|
+
const value = obj[key];
|
|
99
|
+
if (value === undefined || value === null)
|
|
100
|
+
return undefined;
|
|
101
|
+
if (typeof value !== 'string') {
|
|
102
|
+
throw new Error(`--params.${key} must be a string`);
|
|
103
|
+
}
|
|
104
|
+
return value;
|
|
105
|
+
};
|
|
106
|
+
return {
|
|
107
|
+
orgId: pickString('orgId'),
|
|
108
|
+
projectId: pickString('projectId'),
|
|
109
|
+
projectName: pickString('projectName'),
|
|
110
|
+
regionId: pickString('regionId'),
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
const validateInputs = (inputs) => {
|
|
114
|
+
if (inputs.projectId && (inputs.projectName || inputs.regionId)) {
|
|
115
|
+
throw new Error('Conflicting inputs: --project-id selects an existing project; --project-name and --region-id describe a new one. Pass only one set.');
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
const hasEnoughForNonInteractive = (inputs) => {
|
|
119
|
+
if (inputs.orgId && inputs.projectId)
|
|
120
|
+
return true;
|
|
121
|
+
if (inputs.orgId && inputs.projectName && inputs.regionId)
|
|
122
|
+
return true;
|
|
123
|
+
return false;
|
|
124
|
+
};
|
|
125
|
+
// ----------------------------------------------------------------------------
|
|
126
|
+
// Non-interactive flag-driven mode
|
|
127
|
+
// ----------------------------------------------------------------------------
|
|
128
|
+
const runNonInteractive = async (props, inputs) => {
|
|
129
|
+
const orgId = mustString(inputs.orgId, 'orgId');
|
|
130
|
+
if (inputs.projectId) {
|
|
131
|
+
const branchId = await resolveDefaultBranchId(props, inputs.projectId);
|
|
132
|
+
applyContext(props.contextFile, {
|
|
133
|
+
orgId,
|
|
134
|
+
projectId: inputs.projectId,
|
|
135
|
+
branchId,
|
|
136
|
+
});
|
|
137
|
+
printHumanSummary(props, {
|
|
138
|
+
contextFile: props.contextFile,
|
|
139
|
+
orgId,
|
|
140
|
+
projectId: inputs.projectId,
|
|
141
|
+
branchId,
|
|
142
|
+
created: false,
|
|
143
|
+
});
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const created = await createProject(props, {
|
|
147
|
+
orgId,
|
|
148
|
+
name: mustString(inputs.projectName, 'projectName'),
|
|
149
|
+
regionId: mustString(inputs.regionId, 'regionId'),
|
|
150
|
+
});
|
|
151
|
+
applyContext(props.contextFile, {
|
|
152
|
+
orgId,
|
|
153
|
+
projectId: created.project.id,
|
|
154
|
+
branchId: created.branchId,
|
|
155
|
+
});
|
|
156
|
+
printHumanSummary(props, {
|
|
157
|
+
contextFile: props.contextFile,
|
|
158
|
+
orgId,
|
|
159
|
+
projectId: created.project.id,
|
|
160
|
+
branchId: created.branchId,
|
|
161
|
+
created: true,
|
|
162
|
+
projectName: created.project.name,
|
|
163
|
+
regionId: created.project.region_id,
|
|
164
|
+
});
|
|
165
|
+
};
|
|
166
|
+
// ----------------------------------------------------------------------------
|
|
167
|
+
// Interactive mode (TTY)
|
|
168
|
+
// ----------------------------------------------------------------------------
|
|
169
|
+
const runInteractive = async (props, inputs) => {
|
|
170
|
+
if (!props.yes) {
|
|
171
|
+
await confirmRelinkIfNeeded(props);
|
|
172
|
+
}
|
|
173
|
+
const orgResolution = await resolveOrg(props, inputs.orgId);
|
|
174
|
+
let orgId;
|
|
175
|
+
if (orgResolution.kind === 'resolved') {
|
|
176
|
+
orgId = orgResolution.orgId;
|
|
177
|
+
if (orgResolution.autoDetected) {
|
|
178
|
+
log.info(`Detected organization ${orgId} from your existing projects (organization-scoped API key).`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
else if (orgResolution.orgKeyLimited) {
|
|
182
|
+
throw new Error('This API key is organization-scoped, so the CLI cannot list your organizations, ' +
|
|
183
|
+
'and no existing project was found in this org to auto-detect the ID. ' +
|
|
184
|
+
'Re-run with `--org-id <your_org_id>` (find it in the Neon Console under Settings).');
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
orgId = await promptOrgFromList(orgResolution.orgs);
|
|
188
|
+
}
|
|
189
|
+
if (inputs.projectId) {
|
|
190
|
+
const branchId = await resolveDefaultBranchId(props, inputs.projectId);
|
|
191
|
+
applyContext(props.contextFile, {
|
|
192
|
+
orgId,
|
|
193
|
+
projectId: inputs.projectId,
|
|
194
|
+
branchId,
|
|
195
|
+
});
|
|
196
|
+
printHumanSummary(props, {
|
|
197
|
+
contextFile: props.contextFile,
|
|
198
|
+
orgId,
|
|
199
|
+
projectId: inputs.projectId,
|
|
200
|
+
branchId,
|
|
201
|
+
created: false,
|
|
202
|
+
});
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (inputs.projectName && inputs.regionId) {
|
|
206
|
+
const created = await createProject(props, {
|
|
207
|
+
orgId,
|
|
208
|
+
name: inputs.projectName,
|
|
209
|
+
regionId: inputs.regionId,
|
|
210
|
+
});
|
|
211
|
+
applyContext(props.contextFile, {
|
|
212
|
+
orgId,
|
|
213
|
+
projectId: created.project.id,
|
|
214
|
+
branchId: created.branchId,
|
|
215
|
+
});
|
|
216
|
+
printHumanSummary(props, {
|
|
217
|
+
contextFile: props.contextFile,
|
|
218
|
+
orgId,
|
|
219
|
+
projectId: created.project.id,
|
|
220
|
+
branchId: created.branchId,
|
|
221
|
+
created: true,
|
|
222
|
+
projectName: created.project.name,
|
|
223
|
+
regionId: created.project.region_id,
|
|
224
|
+
});
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
// Need to ask: existing project or create a new one?
|
|
228
|
+
const projects = await listAllProjects(props, orgId);
|
|
229
|
+
const action = await promptProjectChoice(projects, inputs.projectName);
|
|
230
|
+
if (action.type === 'existing') {
|
|
231
|
+
const branchId = await resolveDefaultBranchId(props, action.projectId);
|
|
232
|
+
applyContext(props.contextFile, {
|
|
233
|
+
orgId,
|
|
234
|
+
projectId: action.projectId,
|
|
235
|
+
branchId,
|
|
236
|
+
});
|
|
237
|
+
printHumanSummary(props, {
|
|
238
|
+
contextFile: props.contextFile,
|
|
239
|
+
orgId,
|
|
240
|
+
projectId: action.projectId,
|
|
241
|
+
branchId,
|
|
242
|
+
created: false,
|
|
243
|
+
projectName: action.name,
|
|
244
|
+
regionId: action.regionId,
|
|
245
|
+
});
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const projectName = inputs.projectName ?? (await promptProjectName(action.suggestedName));
|
|
249
|
+
const regionId = inputs.regionId ?? (await promptRegion(props));
|
|
250
|
+
const created = await createProject(props, {
|
|
251
|
+
orgId,
|
|
252
|
+
name: projectName,
|
|
253
|
+
regionId,
|
|
254
|
+
});
|
|
255
|
+
applyContext(props.contextFile, {
|
|
256
|
+
orgId,
|
|
257
|
+
projectId: created.project.id,
|
|
258
|
+
branchId: created.branchId,
|
|
259
|
+
});
|
|
260
|
+
printHumanSummary(props, {
|
|
261
|
+
contextFile: props.contextFile,
|
|
262
|
+
orgId,
|
|
263
|
+
projectId: created.project.id,
|
|
264
|
+
branchId: created.branchId,
|
|
265
|
+
created: true,
|
|
266
|
+
projectName: created.project.name,
|
|
267
|
+
regionId: created.project.region_id,
|
|
268
|
+
});
|
|
269
|
+
};
|
|
270
|
+
const confirmRelinkIfNeeded = async (props) => {
|
|
271
|
+
const existing = readContextFile(props.contextFile);
|
|
272
|
+
if (!existing.orgId || !existing.projectId) {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const { proceed } = await prompts({
|
|
276
|
+
onState: onPromptState,
|
|
277
|
+
type: 'confirm',
|
|
278
|
+
name: 'proceed',
|
|
279
|
+
message: `${props.contextFile} is already linked to project ${existing.projectId} (org ${existing.orgId}). Re-link?`,
|
|
280
|
+
initial: true,
|
|
281
|
+
});
|
|
282
|
+
if (!proceed) {
|
|
283
|
+
process.stdout.write('Aborted. Existing link preserved.\n');
|
|
284
|
+
process.exit(0);
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
const promptOrgFromList = async (orgs) => {
|
|
288
|
+
if (!orgs.length) {
|
|
289
|
+
throw new Error(`You don't belong to any organizations. Create one in the Neon Console first: https://console.neon.tech/`);
|
|
290
|
+
}
|
|
291
|
+
const { orgId } = await prompts({
|
|
292
|
+
onState: onPromptState,
|
|
293
|
+
type: 'select',
|
|
294
|
+
name: 'orgId',
|
|
295
|
+
message: 'Which organization would you like to link?',
|
|
296
|
+
choices: orgs.map((org) => ({
|
|
297
|
+
title: `${org.name} (${org.id})`,
|
|
298
|
+
value: org.id,
|
|
299
|
+
})),
|
|
300
|
+
initial: 0,
|
|
301
|
+
});
|
|
302
|
+
return orgId;
|
|
303
|
+
};
|
|
304
|
+
const promptProjectChoice = async (projects, suggestedName) => {
|
|
305
|
+
const choices = [
|
|
306
|
+
...projects.map((project) => ({
|
|
307
|
+
title: `${project.name} (${project.id})`,
|
|
308
|
+
value: project.id,
|
|
309
|
+
})),
|
|
310
|
+
{ title: '+ Create new project', value: CREATE_NEW_SENTINEL },
|
|
311
|
+
];
|
|
312
|
+
const { selection } = await prompts({
|
|
313
|
+
onState: onPromptState,
|
|
314
|
+
type: 'select',
|
|
315
|
+
name: 'selection',
|
|
316
|
+
message: 'Which project would you like to link?',
|
|
317
|
+
choices,
|
|
318
|
+
initial: choices.length === 1 ? 0 : 0,
|
|
319
|
+
});
|
|
320
|
+
if (selection === CREATE_NEW_SENTINEL) {
|
|
321
|
+
return { type: 'create', suggestedName };
|
|
322
|
+
}
|
|
323
|
+
const project = projects.find((p) => p.id === selection);
|
|
324
|
+
return {
|
|
325
|
+
type: 'existing',
|
|
326
|
+
projectId: selection,
|
|
327
|
+
name: project?.name,
|
|
328
|
+
regionId: project?.region_id,
|
|
329
|
+
};
|
|
330
|
+
};
|
|
331
|
+
const promptProjectName = async (suggestedName) => {
|
|
332
|
+
const { name } = await prompts({
|
|
333
|
+
onState: onPromptState,
|
|
334
|
+
type: 'text',
|
|
335
|
+
name: 'name',
|
|
336
|
+
message: 'Name for the new project:',
|
|
337
|
+
initial: suggestedName,
|
|
338
|
+
validate: (value) => value && value.trim().length > 0 ? true : 'Project name is required',
|
|
339
|
+
});
|
|
340
|
+
return String(name).trim();
|
|
341
|
+
};
|
|
342
|
+
const promptRegion = async (props) => {
|
|
343
|
+
const regions = await fetchRegions(props);
|
|
344
|
+
const defaultIndex = Math.max(0, regions.findIndex((r) => r.default));
|
|
345
|
+
const { regionId } = await prompts({
|
|
346
|
+
onState: onPromptState,
|
|
347
|
+
type: 'select',
|
|
348
|
+
name: 'regionId',
|
|
349
|
+
message: 'Which region should the new project run in?',
|
|
350
|
+
choices: regions.map((region) => ({
|
|
351
|
+
title: `${region.name} (${region.region_id})`,
|
|
352
|
+
value: region.region_id,
|
|
353
|
+
})),
|
|
354
|
+
initial: defaultIndex,
|
|
355
|
+
});
|
|
356
|
+
return regionId;
|
|
357
|
+
};
|
|
358
|
+
// ----------------------------------------------------------------------------
|
|
359
|
+
// Agent mode (JSON state machine)
|
|
360
|
+
// ----------------------------------------------------------------------------
|
|
361
|
+
const runAgentSafely = async (props) => {
|
|
362
|
+
try {
|
|
363
|
+
const inputs = parseInputs(props);
|
|
364
|
+
validateInputs(inputs);
|
|
365
|
+
await runAgent(props, inputs);
|
|
366
|
+
}
|
|
367
|
+
catch (err) {
|
|
368
|
+
emitAgent(toAgentError(err));
|
|
369
|
+
process.exit(1);
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
const runAgent = async (props, inputs) => {
|
|
373
|
+
const { projectId, projectName, regionId } = inputs;
|
|
374
|
+
const orgResolution = await resolveOrg(props, inputs.orgId);
|
|
375
|
+
if (orgResolution.kind === 'needs_selection') {
|
|
376
|
+
emitAgent(buildNeedsOrgResponse(orgResolution));
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const orgId = orgResolution.orgId;
|
|
380
|
+
if (projectId) {
|
|
381
|
+
const branchId = await resolveDefaultBranchId(props, projectId);
|
|
382
|
+
applyContext(props.contextFile, { orgId, projectId, branchId });
|
|
383
|
+
emitAgent({
|
|
384
|
+
status: 'linked',
|
|
385
|
+
context_file: props.contextFile,
|
|
386
|
+
context: { orgId, projectId, branchId },
|
|
387
|
+
project: { id: projectId },
|
|
388
|
+
message: `Linked ${props.contextFile} to project ${projectId} (org ${orgId}) on branch ${branchId}.`,
|
|
389
|
+
});
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
if (projectName && !regionId) {
|
|
393
|
+
const regions = await fetchRegions(props);
|
|
394
|
+
emitAgent({
|
|
395
|
+
status: 'needs_project_details',
|
|
396
|
+
instruction: `Ask the user which region to create project "${projectName}" in. After they pick one, re-run the next_command_template with the chosen --region-id value.`,
|
|
397
|
+
regions: regions.map((region) => ({
|
|
398
|
+
id: region.region_id,
|
|
399
|
+
name: region.name,
|
|
400
|
+
default: region.default,
|
|
401
|
+
})),
|
|
402
|
+
next_command_template: `neonctl link --agent --org-id ${shellArg(orgId)} --project-name ${shellArg(projectName)} --region-id <region_id>`,
|
|
403
|
+
});
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (projectName && regionId) {
|
|
407
|
+
const created = await createProject(props, {
|
|
408
|
+
orgId,
|
|
409
|
+
name: projectName,
|
|
410
|
+
regionId,
|
|
411
|
+
});
|
|
412
|
+
applyContext(props.contextFile, {
|
|
413
|
+
orgId,
|
|
414
|
+
projectId: created.project.id,
|
|
415
|
+
branchId: created.branchId,
|
|
416
|
+
});
|
|
417
|
+
emitAgent({
|
|
418
|
+
status: 'linked',
|
|
419
|
+
context_file: props.contextFile,
|
|
420
|
+
context: {
|
|
421
|
+
orgId,
|
|
422
|
+
projectId: created.project.id,
|
|
423
|
+
branchId: created.branchId,
|
|
424
|
+
},
|
|
425
|
+
project: {
|
|
426
|
+
id: created.project.id,
|
|
427
|
+
name: created.project.name,
|
|
428
|
+
region_id: created.project.region_id,
|
|
429
|
+
},
|
|
430
|
+
message: `Created project ${created.project.id} ("${created.project.name ?? projectName}") in ${created.project.region_id ?? regionId} and linked ${props.contextFile}.`,
|
|
431
|
+
});
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
// orgId is set but no project info — list projects to choose from.
|
|
435
|
+
const projects = await listAllProjects(props, orgId);
|
|
436
|
+
emitAgent({
|
|
437
|
+
status: 'needs_project',
|
|
438
|
+
instruction: projects.length === 0
|
|
439
|
+
? `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.`
|
|
440
|
+
: `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).`,
|
|
441
|
+
options: projects.map((project) => ({
|
|
442
|
+
id: project.id,
|
|
443
|
+
name: project.name,
|
|
444
|
+
region_id: project.region_id,
|
|
445
|
+
})),
|
|
446
|
+
create_option: {
|
|
447
|
+
instruction: 'To create a new project, ask the user for a project name. The region can be omitted to receive a follow-up needs_project_details response that lists available regions.',
|
|
448
|
+
next_command_template: `neonctl link --agent --org-id ${shellArg(orgId)} --project-name <name> --region-id <region_id>`,
|
|
449
|
+
},
|
|
450
|
+
next_command_template: `neonctl link --agent --org-id ${shellArg(orgId)} --project-id <project_id>`,
|
|
451
|
+
});
|
|
452
|
+
};
|
|
453
|
+
const emitAgent = (response) => {
|
|
454
|
+
process.stdout.write(`${JSON.stringify(response, null, 2)}\n`);
|
|
455
|
+
};
|
|
456
|
+
// ----------------------------------------------------------------------------
|
|
457
|
+
// API helpers
|
|
458
|
+
// ----------------------------------------------------------------------------
|
|
459
|
+
const ORG_KEY_LIMITED_FRAGMENT = 'not allowed for organization API keys';
|
|
460
|
+
const isOrgKeyLimitedError = (err) => {
|
|
461
|
+
if (!isAxiosError(err))
|
|
462
|
+
return false;
|
|
463
|
+
const data = err.response?.data;
|
|
464
|
+
if (data === undefined || data === null || typeof data !== 'object') {
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
const message = data.message;
|
|
468
|
+
return (typeof message === 'string' && message.includes(ORG_KEY_LIMITED_FRAGMENT));
|
|
469
|
+
};
|
|
470
|
+
const fetchOrganizations = async (props) => {
|
|
471
|
+
const { data } = await props.apiClient.getCurrentUserOrganizations();
|
|
472
|
+
return data.organizations ?? [];
|
|
473
|
+
};
|
|
474
|
+
/**
|
|
475
|
+
* Resolves the org id from the explicit flag, falling back to listing user orgs.
|
|
476
|
+
*
|
|
477
|
+
* For organization-scoped API keys, `getCurrentUserOrganizations` is forbidden;
|
|
478
|
+
* in that case we try to auto-detect the org from the first existing project
|
|
479
|
+
* (since all projects of an org key live in the same org). If no project exists
|
|
480
|
+
* yet, we return `needs_selection` with `orgKeyLimited: true` so callers can
|
|
481
|
+
* give a precise instruction to the user.
|
|
482
|
+
*/
|
|
483
|
+
const resolveOrg = async (props, given) => {
|
|
484
|
+
if (given) {
|
|
485
|
+
return { kind: 'resolved', orgId: given, autoDetected: false };
|
|
486
|
+
}
|
|
487
|
+
try {
|
|
488
|
+
const orgs = await fetchOrganizations(props);
|
|
489
|
+
return { kind: 'needs_selection', orgs, orgKeyLimited: false };
|
|
490
|
+
}
|
|
491
|
+
catch (err) {
|
|
492
|
+
if (!isOrgKeyLimitedError(err)) {
|
|
493
|
+
throw err;
|
|
494
|
+
}
|
|
495
|
+
log.debug('getCurrentUserOrganizations not allowed (org-scoped API key); attempting to derive org from existing projects.');
|
|
496
|
+
}
|
|
497
|
+
const detected = await detectOrgIdFromProjects(props);
|
|
498
|
+
if (detected) {
|
|
499
|
+
return { kind: 'resolved', orgId: detected, autoDetected: true };
|
|
500
|
+
}
|
|
501
|
+
return { kind: 'needs_selection', orgs: [], orgKeyLimited: true };
|
|
502
|
+
};
|
|
503
|
+
const detectOrgIdFromProjects = async (props) => {
|
|
504
|
+
try {
|
|
505
|
+
const { data } = await props.apiClient.listProjects({ limit: 1 });
|
|
506
|
+
return data.projects[0]?.org_id ?? undefined;
|
|
507
|
+
}
|
|
508
|
+
catch (err) {
|
|
509
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
510
|
+
log.debug('detectOrgIdFromProjects failed: %s', message);
|
|
511
|
+
return undefined;
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
const buildNeedsOrgResponse = (resolution) => {
|
|
515
|
+
if (resolution.orgKeyLimited) {
|
|
516
|
+
return {
|
|
517
|
+
status: 'needs_org',
|
|
518
|
+
instruction: "This Neon API key is organization-scoped, so the CLI cannot list the user's organizations and no existing project was found to auto-detect the org ID. Ask the user for their Neon organization ID (visible in the Neon Console under the org's Settings page, formatted like `org-bitter-breeze-12345678`) and re-run the next_command_template with that --org-id.",
|
|
519
|
+
options: [],
|
|
520
|
+
next_command_template: 'neonctl link --agent --org-id <org_id>',
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
const orgs = resolution.orgs;
|
|
524
|
+
return {
|
|
525
|
+
status: 'needs_org',
|
|
526
|
+
instruction: orgs.length === 0
|
|
527
|
+
? 'The user does not belong to any organizations. Ask them to create one in the Neon Console (https://console.neon.tech/) before linking.'
|
|
528
|
+
: `Ask the user which of these ${orgs.length} organization${orgs.length === 1 ? '' : 's'} they want to link the current directory to. After they pick one, re-run the next_command_template with the chosen --org-id value.`,
|
|
529
|
+
options: orgs.map((org) => ({ id: org.id, name: org.name })),
|
|
530
|
+
next_command_template: 'neonctl link --agent --org-id <org_id>',
|
|
531
|
+
};
|
|
532
|
+
};
|
|
533
|
+
const toAgentError = (err) => {
|
|
534
|
+
if (isAxiosError(err)) {
|
|
535
|
+
const status = err.response?.status;
|
|
536
|
+
const data = err.response?.data;
|
|
537
|
+
const apiMessage = typeof data === 'object' && data !== null
|
|
538
|
+
? data.message
|
|
539
|
+
: undefined;
|
|
540
|
+
const message = typeof apiMessage === 'string' && apiMessage.length > 0
|
|
541
|
+
? apiMessage
|
|
542
|
+
: err.message;
|
|
543
|
+
let code = 'API_ERROR';
|
|
544
|
+
if (status === 401 || status === 403) {
|
|
545
|
+
code = 'AUTH_ERROR';
|
|
546
|
+
}
|
|
547
|
+
else if (status !== undefined && status >= 400 && status < 500) {
|
|
548
|
+
code = 'CLIENT_ERROR';
|
|
549
|
+
}
|
|
550
|
+
else if (status !== undefined && status >= 500) {
|
|
551
|
+
code = 'SERVER_ERROR';
|
|
552
|
+
}
|
|
553
|
+
else if (err.code === 'ECONNABORTED') {
|
|
554
|
+
code = 'TIMEOUT';
|
|
555
|
+
}
|
|
556
|
+
return { status: 'error', code, message };
|
|
557
|
+
}
|
|
558
|
+
if (err instanceof Error) {
|
|
559
|
+
return { status: 'error', code: 'INTERNAL_ERROR', message: err.message };
|
|
560
|
+
}
|
|
561
|
+
return {
|
|
562
|
+
status: 'error',
|
|
563
|
+
code: 'INTERNAL_ERROR',
|
|
564
|
+
message: String(err),
|
|
565
|
+
};
|
|
566
|
+
};
|
|
567
|
+
const listAllProjects = async (props, orgId) => {
|
|
568
|
+
const result = [];
|
|
569
|
+
let cursor;
|
|
570
|
+
while (true) {
|
|
571
|
+
const { data } = await props.apiClient.listProjects({
|
|
572
|
+
limit: PROJECTS_LIST_LIMIT,
|
|
573
|
+
org_id: orgId,
|
|
574
|
+
cursor,
|
|
575
|
+
});
|
|
576
|
+
result.push(...data.projects);
|
|
577
|
+
cursor = data.pagination?.cursor;
|
|
578
|
+
if (data.projects.length < PROJECTS_LIST_LIMIT) {
|
|
579
|
+
break;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return result;
|
|
583
|
+
};
|
|
584
|
+
const resolveDefaultBranchId = async (props, projectId) => {
|
|
585
|
+
const { data } = await props.apiClient.listProjectBranches({ projectId });
|
|
586
|
+
const branch = data.branches.find((b) => b.default);
|
|
587
|
+
if (!branch) {
|
|
588
|
+
throw new Error(`Could not find a default branch for project ${projectId}.`);
|
|
589
|
+
}
|
|
590
|
+
return branch.id;
|
|
591
|
+
};
|
|
592
|
+
const fetchRegions = async (props) => {
|
|
593
|
+
try {
|
|
594
|
+
const { data } = await props.apiClient.getActiveRegions();
|
|
595
|
+
if (data.regions && data.regions.length > 0) {
|
|
596
|
+
return data.regions;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
catch (err) {
|
|
600
|
+
if (isAxiosError(err)) {
|
|
601
|
+
log.debug('getActiveRegions failed (%s), falling back to the static region list.', err.response?.status ?? err.code ?? err.message);
|
|
602
|
+
}
|
|
603
|
+
else {
|
|
604
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
605
|
+
log.debug('getActiveRegions failed (%s), falling back to the static region list.', message);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return staticRegionsFallback();
|
|
609
|
+
};
|
|
610
|
+
const staticRegionsFallback = () => REGIONS.map((id) => ({
|
|
611
|
+
region_id: id,
|
|
612
|
+
name: id,
|
|
613
|
+
default: id === 'aws-us-east-2',
|
|
614
|
+
geo_lat: '',
|
|
615
|
+
geo_long: '',
|
|
616
|
+
}));
|
|
617
|
+
const createProject = async (props, args) => {
|
|
618
|
+
const project = {
|
|
619
|
+
name: args.name,
|
|
620
|
+
region_id: args.regionId,
|
|
621
|
+
org_id: args.orgId,
|
|
622
|
+
branch: {},
|
|
623
|
+
};
|
|
624
|
+
const { data } = await props.apiClient.createProject({ project });
|
|
625
|
+
if (!data.branch?.id) {
|
|
626
|
+
throw new Error('Project was created but the API response did not include a default branch id.');
|
|
627
|
+
}
|
|
628
|
+
return {
|
|
629
|
+
project: {
|
|
630
|
+
id: data.project.id,
|
|
631
|
+
name: data.project.name,
|
|
632
|
+
region_id: data.project.region_id,
|
|
633
|
+
},
|
|
634
|
+
branchId: data.branch.id,
|
|
635
|
+
};
|
|
636
|
+
};
|
|
637
|
+
const printHumanSummary = (_props, summary) => {
|
|
638
|
+
const lines = [];
|
|
639
|
+
if (summary.created) {
|
|
640
|
+
lines.push(`Created project ${summary.projectId}${summary.projectName ? ` ("${summary.projectName}")` : ''}${summary.regionId ? ` in ${summary.regionId}` : ''}.`);
|
|
641
|
+
}
|
|
642
|
+
lines.push(`Linked ${summary.contextFile}:`);
|
|
643
|
+
lines.push(` orgId: ${summary.orgId}`);
|
|
644
|
+
lines.push(` projectId: ${summary.projectId}`);
|
|
645
|
+
lines.push(` branchId: ${summary.branchId}`);
|
|
646
|
+
lines.push('');
|
|
647
|
+
process.stdout.write(`${lines.join('\n')}\n`);
|
|
648
|
+
};
|
|
649
|
+
const onPromptState = (state) => {
|
|
650
|
+
if (state.aborted) {
|
|
651
|
+
process.stdout.write('\x1B[?25h');
|
|
652
|
+
process.stdout.write('\n');
|
|
653
|
+
process.exit(1);
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
const shellArg = (value) => {
|
|
657
|
+
if (/^[A-Za-z0-9._:/-]+$/.test(value)) {
|
|
658
|
+
return value;
|
|
659
|
+
}
|
|
660
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
661
|
+
};
|
|
662
|
+
const mustString = (value, name) => {
|
|
663
|
+
if (value === undefined) {
|
|
664
|
+
throw new Error(`Internal error: expected ${name} to be set.`);
|
|
665
|
+
}
|
|
666
|
+
return value;
|
|
667
|
+
};
|