neonctl 2.22.0 → 2.23.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 +242 -16
- package/analytics.js +5 -2
- package/commands/branches.js +9 -1
- package/commands/checkout.js +249 -0
- package/commands/connection_string.js +15 -2
- package/commands/data_api.js +286 -0
- package/commands/functions.js +277 -0
- package/commands/index.js +12 -0
- package/commands/link.js +667 -0
- package/commands/neon_auth.js +1013 -0
- package/commands/projects.js +9 -1
- package/commands/psql.js +62 -0
- package/commands/set_context.js +7 -2
- package/context.js +86 -14
- package/functions_api.js +44 -0
- package/index.js +3 -0
- package/package.json +60 -51
- package/psql/cli.js +51 -0
- package/psql/command/cmd_cond.js +437 -0
- package/psql/command/cmd_connect.js +815 -0
- package/psql/command/cmd_copy.js +1025 -0
- package/psql/command/cmd_describe.js +1810 -0
- package/psql/command/cmd_format.js +909 -0
- package/psql/command/cmd_io.js +2187 -0
- package/psql/command/cmd_lo.js +385 -0
- package/psql/command/cmd_meta.js +970 -0
- package/psql/command/cmd_misc.js +187 -0
- package/psql/command/cmd_pipeline.js +1141 -0
- package/psql/command/cmd_restrict.js +171 -0
- package/psql/command/cmd_show.js +751 -0
- package/psql/command/dispatch.js +343 -0
- package/psql/command/inputQueue.js +42 -0
- package/psql/command/shared.js +71 -0
- package/psql/complete/filenames.js +139 -0
- package/psql/complete/index.js +104 -0
- package/psql/complete/matcher.js +314 -0
- package/psql/complete/psqlVars.js +247 -0
- package/psql/complete/queries.js +491 -0
- package/psql/complete/rules.js +2387 -0
- package/psql/core/common.js +1250 -0
- package/psql/core/help.js +576 -0
- package/psql/core/mainloop.js +1353 -0
- package/psql/core/prompt.js +437 -0
- package/psql/core/settings.js +684 -0
- package/psql/core/sqlHelp.js +1066 -0
- package/psql/core/startup.js +840 -0
- package/psql/core/syncVars.js +116 -0
- package/psql/core/variables.js +287 -0
- package/psql/describe/formatters.js +1277 -0
- package/psql/describe/processNamePattern.js +270 -0
- package/psql/describe/queries.js +2373 -0
- package/psql/describe/versionGate.js +43 -0
- package/psql/index.js +2005 -0
- package/psql/io/history.js +299 -0
- package/psql/io/input.js +120 -0
- package/psql/io/lineEditor/buffer.js +323 -0
- package/psql/io/lineEditor/complete.js +227 -0
- package/psql/io/lineEditor/filename.js +159 -0
- package/psql/io/lineEditor/index.js +891 -0
- package/psql/io/lineEditor/keymap.js +738 -0
- package/psql/io/lineEditor/vt100.js +363 -0
- package/psql/io/pgpass.js +202 -0
- package/psql/io/pgservice.js +194 -0
- package/psql/io/psqlrc.js +422 -0
- package/psql/print/aligned.js +1756 -0
- package/psql/print/asciidoc.js +248 -0
- package/psql/print/crosstab.js +460 -0
- package/psql/print/csv.js +92 -0
- package/psql/print/html.js +258 -0
- package/psql/print/json.js +96 -0
- package/psql/print/latex.js +396 -0
- package/psql/print/pager.js +265 -0
- package/psql/print/troff.js +258 -0
- package/psql/print/unaligned.js +118 -0
- package/psql/print/units.js +135 -0
- package/psql/scanner/slash.js +513 -0
- package/psql/scanner/sql.js +910 -0
- package/psql/scanner/stringutils.js +390 -0
- package/psql/types/backslash.js +1 -0
- package/psql/types/connection.js +1 -0
- package/psql/types/index.js +7 -0
- package/psql/types/printer.js +1 -0
- package/psql/types/repl.js +1 -0
- package/psql/types/scanner.js +24 -0
- package/psql/types/settings.js +1 -0
- package/psql/types/variables.js +1 -0
- package/psql/wire/connection.js +2844 -0
- package/psql/wire/copy.js +108 -0
- package/psql/wire/notify.js +59 -0
- package/psql/wire/pipeline.js +519 -0
- package/psql/wire/protocol.js +466 -0
- package/psql/wire/sasl.js +296 -0
- package/psql/wire/tls.js +596 -0
- package/test_utils/fixtures.js +1 -0
- package/utils/enrichers.js +18 -1
- package/utils/esbuild.js +147 -0
- package/utils/middlewares.js +1 -1
- package/utils/psql.js +107 -11
- package/utils/zip.js +4 -0
- package/writer.js +1 -1
- package/commands/auth.test.js +0 -211
- package/commands/branches.test.js +0 -460
- package/commands/connection_string.test.js +0 -196
- package/commands/databases.test.js +0 -39
- package/commands/help.test.js +0 -9
- package/commands/init.test.js +0 -56
- package/commands/ip_allow.test.js +0 -59
- package/commands/operations.test.js +0 -7
- package/commands/orgs.test.js +0 -7
- package/commands/projects.test.js +0 -144
- package/commands/roles.test.js +0 -37
- package/commands/set_context.test.js +0 -159
- package/commands/vpc_endpoints.test.js +0 -69
- package/env.test.js +0 -55
- package/utils/formats.test.js +0 -32
- package/writer.test.js +0 -104
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { isAxiosError } from 'axios';
|
|
2
|
+
import { retryOnLock } from '../api.js';
|
|
3
|
+
import { branchIdFromProps, fillSingleProject, resolveSingleDatabase, } from '../utils/enrichers.js';
|
|
4
|
+
import { log } from '../log.js';
|
|
5
|
+
import { writer } from '../writer.js';
|
|
6
|
+
const SETTINGS_FIELDS = [
|
|
7
|
+
'db_aggregates_enabled',
|
|
8
|
+
'db_anon_role',
|
|
9
|
+
'db_extra_search_path',
|
|
10
|
+
'db_max_rows',
|
|
11
|
+
'db_schemas',
|
|
12
|
+
'jwt_role_claim_key',
|
|
13
|
+
'jwt_cache_max_lifetime',
|
|
14
|
+
'openapi_mode',
|
|
15
|
+
'server_cors_allowed_origins',
|
|
16
|
+
'server_timing_enabled',
|
|
17
|
+
];
|
|
18
|
+
const settingsFlags = {
|
|
19
|
+
'db-aggregates-enabled': {
|
|
20
|
+
type: 'boolean',
|
|
21
|
+
describe: 'Enable aggregate functions in queries',
|
|
22
|
+
},
|
|
23
|
+
'db-anon-role': {
|
|
24
|
+
type: 'string',
|
|
25
|
+
describe: 'Database role used for anonymous (unauthenticated) requests',
|
|
26
|
+
},
|
|
27
|
+
'db-extra-search-path': {
|
|
28
|
+
type: 'string',
|
|
29
|
+
describe: 'Extra schemas appended to the search path',
|
|
30
|
+
},
|
|
31
|
+
'db-max-rows': {
|
|
32
|
+
type: 'number',
|
|
33
|
+
describe: 'Maximum number of rows returned by a single request',
|
|
34
|
+
},
|
|
35
|
+
'db-schemas': {
|
|
36
|
+
type: 'string',
|
|
37
|
+
describe: 'Comma-separated list of schemas exposed via the Data API',
|
|
38
|
+
},
|
|
39
|
+
'jwt-role-claim-key': {
|
|
40
|
+
type: 'string',
|
|
41
|
+
describe: 'JWT claim path used to extract the role',
|
|
42
|
+
},
|
|
43
|
+
'jwt-cache-max-lifetime': {
|
|
44
|
+
type: 'number',
|
|
45
|
+
describe: 'Maximum JWT cache lifetime in seconds',
|
|
46
|
+
},
|
|
47
|
+
'openapi-mode': {
|
|
48
|
+
type: 'string',
|
|
49
|
+
choices: ['ignore-privileges', 'disabled'],
|
|
50
|
+
describe: 'OpenAPI mode',
|
|
51
|
+
},
|
|
52
|
+
'server-cors-allowed-origins': {
|
|
53
|
+
type: 'string',
|
|
54
|
+
describe: 'CORS allowed origins',
|
|
55
|
+
},
|
|
56
|
+
'server-timing-enabled': {
|
|
57
|
+
type: 'boolean',
|
|
58
|
+
describe: 'Enable Server-Timing response headers',
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
export const command = 'data-api';
|
|
62
|
+
export const describe = 'Manage the Neon Data API for a database';
|
|
63
|
+
export const builder = (argv) => argv
|
|
64
|
+
.usage('$0 data-api <sub-command> [options]')
|
|
65
|
+
.options({
|
|
66
|
+
'project-id': {
|
|
67
|
+
describe: 'Project ID',
|
|
68
|
+
type: 'string',
|
|
69
|
+
},
|
|
70
|
+
branch: {
|
|
71
|
+
describe: 'Branch ID or name',
|
|
72
|
+
type: 'string',
|
|
73
|
+
},
|
|
74
|
+
database: {
|
|
75
|
+
describe: 'Database name',
|
|
76
|
+
type: 'string',
|
|
77
|
+
},
|
|
78
|
+
})
|
|
79
|
+
.middleware(fillSingleProject)
|
|
80
|
+
.command('create', 'Provision the Neon Data API for a database', (yargs) => yargs.options({
|
|
81
|
+
'auth-provider': {
|
|
82
|
+
type: 'string',
|
|
83
|
+
choices: ['neon_auth', 'external'],
|
|
84
|
+
describe: 'Authentication provider',
|
|
85
|
+
},
|
|
86
|
+
'jwks-url': {
|
|
87
|
+
type: 'string',
|
|
88
|
+
describe: 'URL that lists the JWKS (used with external auth)',
|
|
89
|
+
},
|
|
90
|
+
'provider-name': {
|
|
91
|
+
type: 'string',
|
|
92
|
+
describe: 'Name of the auth provider (e.g. Clerk, Stytch, Auth0)',
|
|
93
|
+
},
|
|
94
|
+
'jwt-audience': {
|
|
95
|
+
type: 'string',
|
|
96
|
+
describe: 'Expected JWT audience claim',
|
|
97
|
+
},
|
|
98
|
+
'add-default-grants': {
|
|
99
|
+
type: 'boolean',
|
|
100
|
+
describe: 'Grant all permissions on tables in the public schema to authenticated users',
|
|
101
|
+
},
|
|
102
|
+
'skip-auth-schema': {
|
|
103
|
+
type: 'boolean',
|
|
104
|
+
describe: 'Skip creating the auth schema and RLS functions',
|
|
105
|
+
},
|
|
106
|
+
...settingsFlags,
|
|
107
|
+
}), (args) => create(args))
|
|
108
|
+
.command('get', 'Show the Neon Data API status and settings', (yargs) => yargs, (args) => get(args))
|
|
109
|
+
.command('update', 'Update Neon Data API settings (merges with current settings by default)', (yargs) => yargs.options({
|
|
110
|
+
replace: {
|
|
111
|
+
type: 'boolean',
|
|
112
|
+
default: false,
|
|
113
|
+
describe: 'Replace settings with only the flags provided. Omitted settings revert to server defaults.',
|
|
114
|
+
},
|
|
115
|
+
...settingsFlags,
|
|
116
|
+
}), (args) => update(args))
|
|
117
|
+
.command('refresh-schema', 'Refresh the Data API schema cache without changing settings', (yargs) => yargs, (args) => refreshSchema(args))
|
|
118
|
+
.command('delete', 'Tear down the Neon Data API for a database', (yargs) => yargs, (args) => deleteDataApi(args));
|
|
119
|
+
export const handler = (args) => {
|
|
120
|
+
return args;
|
|
121
|
+
};
|
|
122
|
+
const TOP_LEVEL_CREATE_FIELDS = [
|
|
123
|
+
'auth_provider',
|
|
124
|
+
'jwks_url',
|
|
125
|
+
'provider_name',
|
|
126
|
+
'jwt_audience',
|
|
127
|
+
'add_default_grants',
|
|
128
|
+
'skip_auth_schema',
|
|
129
|
+
];
|
|
130
|
+
const argKey = (snake) => snake.replace(/_/g, '-');
|
|
131
|
+
const buildSettings = (args) => {
|
|
132
|
+
const settings = {};
|
|
133
|
+
for (const field of SETTINGS_FIELDS) {
|
|
134
|
+
const value = args[argKey(field)];
|
|
135
|
+
if (value === undefined)
|
|
136
|
+
continue;
|
|
137
|
+
if (field === 'db_schemas' && typeof value === 'string') {
|
|
138
|
+
settings[field] = value
|
|
139
|
+
.split(',')
|
|
140
|
+
.map((s) => s.trim())
|
|
141
|
+
.filter(Boolean);
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
settings[field] = value;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return Object.keys(settings).length > 0
|
|
148
|
+
? settings
|
|
149
|
+
: undefined;
|
|
150
|
+
};
|
|
151
|
+
const buildCreateBody = (args) => {
|
|
152
|
+
const body = {};
|
|
153
|
+
for (const field of TOP_LEVEL_CREATE_FIELDS) {
|
|
154
|
+
const value = args[argKey(field)];
|
|
155
|
+
if (value !== undefined)
|
|
156
|
+
body[field] = value;
|
|
157
|
+
}
|
|
158
|
+
const settings = buildSettings(args);
|
|
159
|
+
if (settings)
|
|
160
|
+
body.settings = settings;
|
|
161
|
+
return body;
|
|
162
|
+
};
|
|
163
|
+
const create = async (props) => {
|
|
164
|
+
const branchId = await branchIdFromProps(props);
|
|
165
|
+
const database = await resolveSingleDatabase({
|
|
166
|
+
apiClient: props.apiClient,
|
|
167
|
+
projectId: props.projectId,
|
|
168
|
+
branchId,
|
|
169
|
+
database: props.database,
|
|
170
|
+
});
|
|
171
|
+
const body = buildCreateBody(props);
|
|
172
|
+
const { data } = await retryOnLock(() => props.apiClient.createProjectBranchDataApi(props.projectId, branchId, database, body));
|
|
173
|
+
writer(props).end(data, { fields: ['url'] });
|
|
174
|
+
};
|
|
175
|
+
const GET_FIELDS = ['url', 'status', 'db_schemas'];
|
|
176
|
+
const get = async (props) => {
|
|
177
|
+
const branchId = await branchIdFromProps(props);
|
|
178
|
+
const database = await resolveSingleDatabase({
|
|
179
|
+
apiClient: props.apiClient,
|
|
180
|
+
projectId: props.projectId,
|
|
181
|
+
branchId,
|
|
182
|
+
database: props.database,
|
|
183
|
+
});
|
|
184
|
+
const { data } = await props.apiClient.getProjectBranchDataApi(props.projectId, branchId, database);
|
|
185
|
+
// Drop available_schemas from json/yaml output (not part of the public surface).
|
|
186
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
187
|
+
const { available_schemas: _ignored, ...publicData } = data;
|
|
188
|
+
// For table output, flatten db_schemas onto the top-level for column rendering.
|
|
189
|
+
const tableRow = {
|
|
190
|
+
url: publicData.url,
|
|
191
|
+
status: publicData.status,
|
|
192
|
+
db_schemas: (publicData.settings?.db_schemas ?? []).join(', '),
|
|
193
|
+
};
|
|
194
|
+
if (props.output === 'json' || props.output === 'yaml') {
|
|
195
|
+
writer(props).end(publicData, {
|
|
196
|
+
fields: ['url', 'status', 'settings'],
|
|
197
|
+
});
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
writer(props).end(tableRow, { fields: GET_FIELDS });
|
|
201
|
+
};
|
|
202
|
+
const update = async (props) => {
|
|
203
|
+
const branchId = await branchIdFromProps(props);
|
|
204
|
+
const database = await resolveSingleDatabase({
|
|
205
|
+
apiClient: props.apiClient,
|
|
206
|
+
projectId: props.projectId,
|
|
207
|
+
branchId,
|
|
208
|
+
database: props.database,
|
|
209
|
+
});
|
|
210
|
+
const userSettings = buildSettings(props);
|
|
211
|
+
if (!userSettings) {
|
|
212
|
+
throw new Error('No settings flags provided. Pass at least one setting flag to update, or use `data-api refresh-schema` to refresh the schema cache without changing settings.');
|
|
213
|
+
}
|
|
214
|
+
let settings;
|
|
215
|
+
if (props.replace) {
|
|
216
|
+
settings = userSettings;
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
let current;
|
|
220
|
+
try {
|
|
221
|
+
const { data } = await props.apiClient.getProjectBranchDataApi(props.projectId, branchId, database);
|
|
222
|
+
current = data.settings ?? undefined;
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
if (isAxiosError(err) && err.response?.status === 404) {
|
|
226
|
+
throw new Error(`Data API is not provisioned for ${database} on branch ${branchId}. Run \`neonctl data-api create\` first.`);
|
|
227
|
+
}
|
|
228
|
+
throw err;
|
|
229
|
+
}
|
|
230
|
+
if (!current) {
|
|
231
|
+
throw new Error(`Could not read current Data API settings for ${database} on branch ${branchId}. Retry, or pass --replace to overwrite.`);
|
|
232
|
+
}
|
|
233
|
+
settings = { ...current, ...(userSettings ?? {}) };
|
|
234
|
+
}
|
|
235
|
+
const body = {};
|
|
236
|
+
if (settings)
|
|
237
|
+
body.settings = settings;
|
|
238
|
+
try {
|
|
239
|
+
await retryOnLock(() => props.apiClient.updateProjectBranchDataApi(props.projectId, branchId, database, body));
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
if (isAxiosError(err) && err.response?.status === 404) {
|
|
243
|
+
throw new Error(`Data API is not provisioned for ${database} on branch ${branchId}. Run \`neonctl data-api create\` first.`);
|
|
244
|
+
}
|
|
245
|
+
throw err;
|
|
246
|
+
}
|
|
247
|
+
log.info(`Data API settings updated for ${database} on branch ${branchId}`);
|
|
248
|
+
};
|
|
249
|
+
const refreshSchema = async (props) => {
|
|
250
|
+
const branchId = await branchIdFromProps(props);
|
|
251
|
+
const database = await resolveSingleDatabase({
|
|
252
|
+
apiClient: props.apiClient,
|
|
253
|
+
projectId: props.projectId,
|
|
254
|
+
branchId,
|
|
255
|
+
database: props.database,
|
|
256
|
+
});
|
|
257
|
+
try {
|
|
258
|
+
await retryOnLock(() => props.apiClient.updateProjectBranchDataApi(props.projectId, branchId, database, {}));
|
|
259
|
+
}
|
|
260
|
+
catch (err) {
|
|
261
|
+
if (isAxiosError(err) && err.response?.status === 404) {
|
|
262
|
+
throw new Error(`Data API is not provisioned for ${database} on branch ${branchId}. Run \`neonctl data-api create\` first.`);
|
|
263
|
+
}
|
|
264
|
+
throw err;
|
|
265
|
+
}
|
|
266
|
+
log.info(`Data API schema cache refreshed for ${database} on branch ${branchId}`);
|
|
267
|
+
};
|
|
268
|
+
const deleteDataApi = async (props) => {
|
|
269
|
+
const branchId = await branchIdFromProps(props);
|
|
270
|
+
const database = await resolveSingleDatabase({
|
|
271
|
+
apiClient: props.apiClient,
|
|
272
|
+
projectId: props.projectId,
|
|
273
|
+
branchId,
|
|
274
|
+
database: props.database,
|
|
275
|
+
});
|
|
276
|
+
try {
|
|
277
|
+
await retryOnLock(() => props.apiClient.deleteProjectBranchDataApi(props.projectId, branchId, database));
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
if (isAxiosError(err) && err.response?.status === 404) {
|
|
281
|
+
throw new Error(`Data API is not provisioned for ${database} on branch ${branchId}.`);
|
|
282
|
+
}
|
|
283
|
+
throw err;
|
|
284
|
+
}
|
|
285
|
+
log.info(`Data API deleted for ${database} on branch ${branchId}`);
|
|
286
|
+
};
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { isAxiosError } from 'axios';
|
|
4
|
+
import { retryOnLock } from '../api.js';
|
|
5
|
+
import { log } from '../log.js';
|
|
6
|
+
import { branchIdFromProps, fillSingleProject } from '../utils/enrichers.js';
|
|
7
|
+
import { zipBundle } from '../utils/zip.js';
|
|
8
|
+
import { bundleEntry } from '../utils/esbuild.js';
|
|
9
|
+
import { writer } from '../writer.js';
|
|
10
|
+
import { createDeployment, deleteFunction, getFunction, listFunctions, } from '../functions_api.js';
|
|
11
|
+
const FUNCTION_FIELDS = [
|
|
12
|
+
'slug',
|
|
13
|
+
'name',
|
|
14
|
+
'invocation_url',
|
|
15
|
+
'created_at',
|
|
16
|
+
];
|
|
17
|
+
const DEPLOYMENT_FIELDS = [
|
|
18
|
+
'id',
|
|
19
|
+
'status',
|
|
20
|
+
'runtime',
|
|
21
|
+
'memory_mib',
|
|
22
|
+
'created_at',
|
|
23
|
+
];
|
|
24
|
+
const SLUG_PATTERN = /^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$/;
|
|
25
|
+
const SLUG_HELP = 'Use 1-40 lowercase letters, digits, and hyphens; it must start and end with a letter or digit.';
|
|
26
|
+
const MEMORY_CHOICES = [256, 512, 1024, 2048, 4096, 8192];
|
|
27
|
+
// Overridable so tests can poll fast; defaults to 2s in real use.
|
|
28
|
+
const POLL_INTERVAL_MS = Number(process.env.NEON_FUNCTIONS_POLL_INTERVAL_MS) || 2000;
|
|
29
|
+
// Upper bound on --wait polling so the CLI never hangs (e.g. if our deployment
|
|
30
|
+
// never becomes active_deployment). Overridable so tests can time out fast;
|
|
31
|
+
// defaults to 10 minutes in real use.
|
|
32
|
+
const POLL_TIMEOUT_MS = Number(process.env.NEON_FUNCTIONS_POLL_TIMEOUT_MS) || 600000;
|
|
33
|
+
export const command = 'functions';
|
|
34
|
+
export const describe = 'Manage Neon Functions';
|
|
35
|
+
export const aliases = ['function'];
|
|
36
|
+
export const builder = (argv) => argv
|
|
37
|
+
.usage('$0 functions <sub-command> [options]')
|
|
38
|
+
.options({
|
|
39
|
+
'project-id': {
|
|
40
|
+
describe: 'Project ID',
|
|
41
|
+
type: 'string',
|
|
42
|
+
},
|
|
43
|
+
branch: {
|
|
44
|
+
describe: 'Branch ID or name',
|
|
45
|
+
type: 'string',
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
.middleware(fillSingleProject)
|
|
49
|
+
.command('deploy <slug>', 'Deploy a function from a local directory', (yargs) => yargs
|
|
50
|
+
.positional('slug', {
|
|
51
|
+
describe: 'Function slug (lowercase DNS label)',
|
|
52
|
+
type: 'string',
|
|
53
|
+
demandOption: true,
|
|
54
|
+
})
|
|
55
|
+
.options({
|
|
56
|
+
path: {
|
|
57
|
+
describe: 'Base directory for the function (resolves --entry)',
|
|
58
|
+
type: 'string',
|
|
59
|
+
},
|
|
60
|
+
entry: {
|
|
61
|
+
describe: 'Entry file to bundle, relative to --path',
|
|
62
|
+
type: 'string',
|
|
63
|
+
},
|
|
64
|
+
'memory-mib': {
|
|
65
|
+
describe: 'Memory in MiB',
|
|
66
|
+
type: 'number',
|
|
67
|
+
choices: MEMORY_CHOICES,
|
|
68
|
+
},
|
|
69
|
+
runtime: {
|
|
70
|
+
describe: 'Function runtime',
|
|
71
|
+
type: 'string',
|
|
72
|
+
choices: ['nodejs24'],
|
|
73
|
+
},
|
|
74
|
+
env: {
|
|
75
|
+
describe: 'Environment variable as KEY=VALUE (repeatable)',
|
|
76
|
+
type: 'string',
|
|
77
|
+
array: true,
|
|
78
|
+
},
|
|
79
|
+
wait: {
|
|
80
|
+
describe: 'Wait for the deployment to finish building',
|
|
81
|
+
type: 'boolean',
|
|
82
|
+
default: true,
|
|
83
|
+
},
|
|
84
|
+
}), (args) => deploy(args))
|
|
85
|
+
.command('list', 'List functions on the branch', (yargs) => yargs, (args) => list(args))
|
|
86
|
+
.command('get <slug>', "Show a function's details", (yargs) => yargs.positional('slug', {
|
|
87
|
+
describe: 'Function slug',
|
|
88
|
+
type: 'string',
|
|
89
|
+
demandOption: true,
|
|
90
|
+
}), (args) => get(args))
|
|
91
|
+
.command('delete <slug>', 'Delete a function on the branch', (yargs) => yargs.positional('slug', {
|
|
92
|
+
describe: 'Function slug',
|
|
93
|
+
type: 'string',
|
|
94
|
+
demandOption: true,
|
|
95
|
+
}), (args) => deleteFn(args));
|
|
96
|
+
export const handler = (args) => {
|
|
97
|
+
return args;
|
|
98
|
+
};
|
|
99
|
+
const parseEnv = (entries) => {
|
|
100
|
+
if (!entries || entries.length === 0)
|
|
101
|
+
return undefined;
|
|
102
|
+
const map = {};
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
const eq = entry.indexOf('=');
|
|
105
|
+
if (eq <= 0) {
|
|
106
|
+
throw new Error(`Invalid --env value "${entry}". Expected KEY=VALUE.`);
|
|
107
|
+
}
|
|
108
|
+
map[entry.slice(0, eq)] = entry.slice(eq + 1);
|
|
109
|
+
}
|
|
110
|
+
return JSON.stringify(map);
|
|
111
|
+
};
|
|
112
|
+
const statusHint = (slug, projectId, branchId) => `Check status with: neonctl functions get ${slug} --project-id ${projectId} --branch ${branchId}`;
|
|
113
|
+
// A poll error worth retrying: a network error (no HTTP response), a 5xx, or a
|
|
114
|
+
// 404 from eventual consistency. Anything else (e.g. 401/403) is surfaced.
|
|
115
|
+
const isTransient = (err) => isAxiosError(err) &&
|
|
116
|
+
(err.response === undefined ||
|
|
117
|
+
err.response.status === 404 ||
|
|
118
|
+
err.response.status >= 500);
|
|
119
|
+
const deploy = async (props) => {
|
|
120
|
+
// At least one deploy option must be passed (--wait is excluded: it controls
|
|
121
|
+
// output, not what gets deployed).
|
|
122
|
+
const hasOption = props.path !== undefined ||
|
|
123
|
+
props.entry !== undefined ||
|
|
124
|
+
props.env !== undefined ||
|
|
125
|
+
props.memoryMib !== undefined ||
|
|
126
|
+
props.runtime !== undefined;
|
|
127
|
+
if (!hasOption) {
|
|
128
|
+
throw new Error('Provide at least one option to deploy, e.g. --path, --entry, or --env. ' +
|
|
129
|
+
'See: neonctl functions deploy --help.');
|
|
130
|
+
}
|
|
131
|
+
// Cheap, offline validation first - fail before any network round-trip.
|
|
132
|
+
if (!SLUG_PATTERN.test(props.slug)) {
|
|
133
|
+
throw new Error(`Invalid function slug "${props.slug}". ${SLUG_HELP}`);
|
|
134
|
+
}
|
|
135
|
+
const path = props.path ?? '.';
|
|
136
|
+
const entry = props.entry ?? 'index.ts';
|
|
137
|
+
const memoryMib = props.memoryMib ?? 256;
|
|
138
|
+
const runtime = props.runtime ?? 'nodejs24';
|
|
139
|
+
const environment = parseEnv(props.env);
|
|
140
|
+
const source = join(path, entry);
|
|
141
|
+
if (!existsSync(source)) {
|
|
142
|
+
throw new Error(`Entry file not found: ${source}. Pass --entry to point at your function's entry file (defaults to index.ts).`);
|
|
143
|
+
}
|
|
144
|
+
// Bundle before any network round-trip so a bundling failure fails fast.
|
|
145
|
+
const zip = zipBundle(await bundleEntry(source));
|
|
146
|
+
const branchId = await branchIdFromProps(props);
|
|
147
|
+
// Snapshot the active version before deploy so we can detect the new one
|
|
148
|
+
// afterward. A missing function (404) or no active version → undefined.
|
|
149
|
+
let before;
|
|
150
|
+
try {
|
|
151
|
+
const fn = await getFunction(props.apiClient, props.projectId, branchId, props.slug);
|
|
152
|
+
before = fn.active_deployment?.id;
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
if (!(isAxiosError(err) && err.response?.status === 404))
|
|
156
|
+
throw err;
|
|
157
|
+
}
|
|
158
|
+
await retryOnLock(() => createDeployment(props.apiClient, props.projectId, branchId, props.slug, {
|
|
159
|
+
zip,
|
|
160
|
+
memoryMib,
|
|
161
|
+
runtime,
|
|
162
|
+
environment,
|
|
163
|
+
}));
|
|
164
|
+
log.info(`Function deployment triggered for function ${props.slug}.`);
|
|
165
|
+
// Best-effort interrupt: a Ctrl-C lands at the next poll boundary. (No
|
|
166
|
+
// automated test; mirrors the resolution branches below, verified manually.)
|
|
167
|
+
let interrupted = false;
|
|
168
|
+
const onSignal = () => {
|
|
169
|
+
interrupted = true;
|
|
170
|
+
};
|
|
171
|
+
process.once('SIGINT', onSignal);
|
|
172
|
+
process.once('SIGTERM', onSignal);
|
|
173
|
+
// Poll until a NEW active version appears (id greater than the snapshot, or
|
|
174
|
+
// any version if there was none). --no-wait stops there; --wait stops at a
|
|
175
|
+
// terminal status. Bounded by POLL_TIMEOUT_MS so it never hangs.
|
|
176
|
+
let resolved;
|
|
177
|
+
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
|
178
|
+
try {
|
|
179
|
+
while (!interrupted && Date.now() < deadline) {
|
|
180
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
181
|
+
if (interrupted)
|
|
182
|
+
break;
|
|
183
|
+
// The deploy already succeeded server-side; tolerate transient poll
|
|
184
|
+
// failures and retry on the next interval. Surface anything else.
|
|
185
|
+
let dep;
|
|
186
|
+
try {
|
|
187
|
+
dep = (await getFunction(props.apiClient, props.projectId, branchId, props.slug)).active_deployment;
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
if (isTransient(err))
|
|
191
|
+
continue;
|
|
192
|
+
throw err;
|
|
193
|
+
}
|
|
194
|
+
const isNew = dep !== undefined && (before === undefined || dep.id > before);
|
|
195
|
+
if (isNew && dep) {
|
|
196
|
+
resolved = dep;
|
|
197
|
+
if (!props.wait)
|
|
198
|
+
break;
|
|
199
|
+
if (dep.status === 'completed' || dep.status === 'failed')
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
process.removeListener('SIGINT', onSignal);
|
|
206
|
+
process.removeListener('SIGTERM', onSignal);
|
|
207
|
+
}
|
|
208
|
+
if (interrupted) {
|
|
209
|
+
log.info(statusHint(props.slug, props.projectId, branchId));
|
|
210
|
+
if (resolved)
|
|
211
|
+
writer(props).end(resolved, { fields: DEPLOYMENT_FIELDS });
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (resolved === undefined) {
|
|
215
|
+
log.info(statusHint(props.slug, props.projectId, branchId));
|
|
216
|
+
throw new Error(`Timed out waiting for the deployment of ${props.slug} to start. It may still be in progress.`);
|
|
217
|
+
}
|
|
218
|
+
writer(props).end(resolved, { fields: DEPLOYMENT_FIELDS });
|
|
219
|
+
if (!props.wait) {
|
|
220
|
+
log.info(statusHint(props.slug, props.projectId, branchId));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (resolved.status === 'completed') {
|
|
224
|
+
log.info(`Function deployment ${props.slug}/${resolved.id} completed.`);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (resolved.status === 'failed') {
|
|
228
|
+
throw new Error(`Function deployment ${props.slug}/${resolved.id} failed.`);
|
|
229
|
+
}
|
|
230
|
+
// --wait, new version appeared but the deadline hit before it finished.
|
|
231
|
+
log.info(statusHint(props.slug, props.projectId, branchId));
|
|
232
|
+
throw new Error(`Timed out waiting for function deployment ${props.slug}/${resolved.id} to finish. It may still be building.`);
|
|
233
|
+
};
|
|
234
|
+
const get = async (props) => {
|
|
235
|
+
const branchId = await branchIdFromProps(props);
|
|
236
|
+
const fn = await getFunction(props.apiClient, props.projectId, branchId, props.slug);
|
|
237
|
+
if (props.output === 'json' || props.output === 'yaml') {
|
|
238
|
+
writer(props).end(fn, { fields: FUNCTION_FIELDS });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const out = writer(props).write(fn, {
|
|
242
|
+
fields: FUNCTION_FIELDS,
|
|
243
|
+
title: 'function',
|
|
244
|
+
});
|
|
245
|
+
if (fn.active_deployment) {
|
|
246
|
+
out.write(fn.active_deployment, {
|
|
247
|
+
fields: DEPLOYMENT_FIELDS,
|
|
248
|
+
title: 'active deployment',
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
out.end();
|
|
252
|
+
};
|
|
253
|
+
const deleteFn = async (props) => {
|
|
254
|
+
const branchId = await branchIdFromProps(props);
|
|
255
|
+
try {
|
|
256
|
+
await retryOnLock(() => deleteFunction(props.apiClient, props.projectId, branchId, props.slug));
|
|
257
|
+
}
|
|
258
|
+
catch (err) {
|
|
259
|
+
if (isAxiosError(err) && err.response?.status === 404) {
|
|
260
|
+
throw new Error(`Function "${props.slug}" not found on branch ${branchId}.`);
|
|
261
|
+
}
|
|
262
|
+
throw err;
|
|
263
|
+
}
|
|
264
|
+
log.info(`Function ${props.slug} deleted from branch ${branchId}`);
|
|
265
|
+
};
|
|
266
|
+
const list = async (props) => {
|
|
267
|
+
const branchId = await branchIdFromProps(props);
|
|
268
|
+
const functions = await listFunctions(props.apiClient, props.projectId, branchId);
|
|
269
|
+
if (props.output === 'json' || props.output === 'yaml') {
|
|
270
|
+
writer(props).end(functions, { fields: FUNCTION_FIELDS });
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
writer(props).end(functions, {
|
|
274
|
+
fields: FUNCTION_FIELDS,
|
|
275
|
+
emptyMessage: 'No functions found on this branch.',
|
|
276
|
+
});
|
|
277
|
+
};
|
package/commands/index.js
CHANGED
|
@@ -9,8 +9,14 @@ import * as databases from './databases.js';
|
|
|
9
9
|
import * as roles from './roles.js';
|
|
10
10
|
import * as operations from './operations.js';
|
|
11
11
|
import * as cs from './connection_string.js';
|
|
12
|
+
import * as psql from './psql.js';
|
|
12
13
|
import * as setContext from './set_context.js';
|
|
14
|
+
import * as checkout from './checkout.js';
|
|
15
|
+
import * as link from './link.js';
|
|
13
16
|
import * as init from './init.js';
|
|
17
|
+
import * as dataApi from './data_api.js';
|
|
18
|
+
import * as neonAuth from './neon_auth.js';
|
|
19
|
+
import * as functions from './functions.js';
|
|
14
20
|
export default [
|
|
15
21
|
auth,
|
|
16
22
|
users,
|
|
@@ -18,11 +24,17 @@ export default [
|
|
|
18
24
|
projects,
|
|
19
25
|
ipAllow,
|
|
20
26
|
vpcEndpoints,
|
|
27
|
+
neonAuth,
|
|
21
28
|
branches,
|
|
22
29
|
databases,
|
|
23
30
|
roles,
|
|
24
31
|
operations,
|
|
25
32
|
cs,
|
|
33
|
+
psql,
|
|
26
34
|
setContext,
|
|
35
|
+
checkout,
|
|
36
|
+
link,
|
|
27
37
|
init,
|
|
38
|
+
dataApi,
|
|
39
|
+
functions,
|
|
28
40
|
];
|