neonctl 2.24.2 → 2.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { existsSync } from 'node:fs';
1
+ import { existsSync, statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { isAxiosError } from 'axios';
4
4
  import { retryOnLock } from '../api.js';
@@ -21,8 +21,27 @@ const DEPLOYMENT_FIELDS = [
21
21
  'memory_mib',
22
22
  'created_at',
23
23
  ];
24
+ // Deploy emits the resolved deployment plus the function's invocation_url, so a
25
+ // successful `functions deploy` tells the user exactly where to call the function.
26
+ const DEPLOY_RESULT_FIELDS = [
27
+ 'id',
28
+ 'status',
29
+ 'invocation_url',
30
+ 'runtime',
31
+ 'memory_mib',
32
+ 'created_at',
33
+ ];
34
+ // In table mode a failed build's reason gets its own "deployment error"
35
+ // section after the deployment table; json/yaml carry the raw `error` field.
36
+ const writeDeploymentErrorSection = (out, dep) => {
37
+ if (dep.status === 'failed' && dep.error) {
38
+ out.write({ reason: dep.error }, { fields: ['reason'], title: 'deployment error' });
39
+ }
40
+ };
24
41
  const SLUG_PATTERN = /^[a-z0-9]{1,20}$/;
25
42
  const SLUG_HELP = 'Use 1-20 lowercase letters and digits (no hyphens or other characters).';
43
+ // Entry-point discovery order inside --src.
44
+ const ENTRY_CANDIDATES = ['index.ts', 'index.mjs', 'index.js'];
26
45
  // Overridable so tests can poll fast; defaults to 2s in real use.
27
46
  const POLL_INTERVAL_MS = Number(process.env.NEON_FUNCTIONS_POLL_INTERVAL_MS) || 2000;
28
47
  // Upper bound on --wait polling so the CLI never hangs (e.g. if our deployment
@@ -52,13 +71,19 @@ export const builder = (argv) => argv
52
71
  demandOption: true,
53
72
  })
54
73
  .options({
74
+ src: {
75
+ describe: 'Function source: a directory containing index.ts, index.mjs, or index.js, or a path to the entry file',
76
+ type: 'string',
77
+ },
78
+ // Removed flags, kept hidden so old invocations fail loudly instead
79
+ // of being silently ignored (the CLI has no .strictOptions()).
55
80
  path: {
56
- describe: 'Base directory for the function (resolves --entry)',
57
81
  type: 'string',
82
+ hidden: true,
58
83
  },
59
84
  entry: {
60
- describe: 'Entry file to bundle, relative to --path',
61
85
  type: 'string',
86
+ hidden: true,
62
87
  },
63
88
  runtime: {
64
89
  describe: 'Function runtime',
@@ -77,10 +102,19 @@ export const builder = (argv) => argv
77
102
  },
78
103
  }), (args) => deploy(args))
79
104
  .command('list', 'List functions on the branch', (yargs) => yargs, (args) => list(args))
80
- .command('get <slug>', "Show a function's details", (yargs) => yargs.positional('slug', {
105
+ .command('get <slug>', "Show a function's details", (yargs) => yargs
106
+ .positional('slug', {
81
107
  describe: 'Function slug',
82
108
  type: 'string',
83
109
  demandOption: true,
110
+ })
111
+ .options({
112
+ 'list-env-variables': {
113
+ describe: 'List the environment variable names of the active deployment',
114
+ type: 'boolean',
115
+ alias: 'E',
116
+ default: false,
117
+ },
84
118
  }), (args) => get(args))
85
119
  .command('delete <slug>', 'Delete a function on the branch', (yargs) => yargs.positional('slug', {
86
120
  describe: 'Function slug',
@@ -104,6 +138,15 @@ const parseEnv = (entries) => {
104
138
  return JSON.stringify(map);
105
139
  };
106
140
  const statusHint = (slug, projectId, branchId) => `Check status with: neonctl functions get ${slug} --project-id ${projectId} --branch ${branchId}`;
141
+ // Emit the resolved deployment together with the function's invocation_url, so the
142
+ // deploy output shows where the function is reachable (not just the deployment id).
143
+ const emitDeployResult = (props, deployment, fn) => {
144
+ const out = writer(props).write({ ...deployment, invocation_url: fn?.invocation_url }, { fields: DEPLOY_RESULT_FIELDS });
145
+ if (props.output !== 'json' && props.output !== 'yaml') {
146
+ writeDeploymentErrorSection(out, deployment);
147
+ }
148
+ out.end();
149
+ };
107
150
  // A poll error worth retrying: a network error (no HTTP response), a 5xx, or a
108
151
  // 404 from eventual consistency. Anything else (e.g. 401/403) is surfaced.
109
152
  const isTransient = (err) => isAxiosError(err) &&
@@ -111,27 +154,36 @@ const isTransient = (err) => isAxiosError(err) &&
111
154
  err.response.status === 404 ||
112
155
  err.response.status >= 500);
113
156
  const deploy = async (props) => {
157
+ if (props.path !== undefined || props.entry !== undefined) {
158
+ throw new Error('--path and --entry were removed. Use --src <dir>; the entry point ' +
159
+ 'is discovered as index.ts, index.mjs, or index.js in that directory.');
160
+ }
114
161
  // At least one deploy option must be passed (--wait is excluded: it controls
115
162
  // output, not what gets deployed).
116
- const hasOption = props.path !== undefined ||
117
- props.entry !== undefined ||
163
+ const hasOption = props.src !== undefined ||
118
164
  props.env !== undefined ||
119
165
  props.runtime !== undefined;
120
166
  if (!hasOption) {
121
- throw new Error('Provide at least one option to deploy, e.g. --path, --entry, or --env. ' +
167
+ throw new Error('Provide at least one option to deploy, e.g. --src or --env. ' +
122
168
  'See: neonctl functions deploy --help.');
123
169
  }
124
170
  // Cheap, offline validation first - fail before any network round-trip.
125
171
  if (!SLUG_PATTERN.test(props.slug)) {
126
172
  throw new Error(`Invalid function slug "${props.slug}". ${SLUG_HELP}`);
127
173
  }
128
- const path = props.path ?? '.';
129
- const entry = props.entry ?? 'index.ts';
174
+ const src = props.src ?? '.';
130
175
  const runtime = props.runtime ?? 'nodejs24';
131
176
  const environment = parseEnv(props.env);
132
- const source = join(path, entry);
133
- if (!existsSync(source)) {
134
- throw new Error(`Entry file not found: ${source}. Pass --entry to point at your function's entry file (defaults to index.ts).`);
177
+ const srcStat = statSync(src, { throwIfNoEntry: false });
178
+ if (srcStat === undefined) {
179
+ throw new Error(`--src path not found: ${src}.`);
180
+ }
181
+ // A file is used as the entry point directly; a directory triggers discovery.
182
+ const source = srcStat.isFile()
183
+ ? src
184
+ : ENTRY_CANDIDATES.map((name) => join(src, name)).find((p) => existsSync(p));
185
+ if (source === undefined) {
186
+ throw new Error(`No entry file found in ${src}. Expected one of: ${ENTRY_CANDIDATES.join(', ')}.`);
135
187
  }
136
188
  // Bundle before any network round-trip so a bundling failure fails fast.
137
189
  const zip = zipBundle(await bundleEntry(source));
@@ -165,6 +217,9 @@ const deploy = async (props) => {
165
217
  // any version if there was none). --no-wait stops there; --wait stops at a
166
218
  // terminal status. Bounded by POLL_TIMEOUT_MS so it never hangs.
167
219
  let resolved;
220
+ // The function carries the invocation_url; keep the whole record (not just its
221
+ // active_deployment) so we can surface that URL on success.
222
+ let resolvedFn;
168
223
  const deadline = Date.now() + POLL_TIMEOUT_MS;
169
224
  try {
170
225
  while (!interrupted && Date.now() < deadline) {
@@ -173,18 +228,20 @@ const deploy = async (props) => {
173
228
  break;
174
229
  // The deploy already succeeded server-side; tolerate transient poll
175
230
  // failures and retry on the next interval. Surface anything else.
176
- let dep;
231
+ let fn;
177
232
  try {
178
- dep = (await getFunction(props.apiClient, props.projectId, branchId, props.slug)).active_deployment;
233
+ fn = await getFunction(props.apiClient, props.projectId, branchId, props.slug);
179
234
  }
180
235
  catch (err) {
181
236
  if (isTransient(err))
182
237
  continue;
183
238
  throw err;
184
239
  }
240
+ const dep = fn.active_deployment;
185
241
  const isNew = dep !== undefined && (before === undefined || dep.id > before);
186
242
  if (isNew && dep) {
187
243
  resolved = dep;
244
+ resolvedFn = fn;
188
245
  if (!props.wait)
189
246
  break;
190
247
  if (dep.status === 'completed' || dep.status === 'failed')
@@ -199,14 +256,14 @@ const deploy = async (props) => {
199
256
  if (interrupted) {
200
257
  log.info(statusHint(props.slug, props.projectId, branchId));
201
258
  if (resolved)
202
- writer(props).end(resolved, { fields: DEPLOYMENT_FIELDS });
259
+ emitDeployResult(props, resolved, resolvedFn);
203
260
  return;
204
261
  }
205
262
  if (resolved === undefined) {
206
263
  log.info(statusHint(props.slug, props.projectId, branchId));
207
264
  throw new Error(`Timed out waiting for the deployment of ${props.slug} to start. It may still be in progress.`);
208
265
  }
209
- writer(props).end(resolved, { fields: DEPLOYMENT_FIELDS });
266
+ emitDeployResult(props, resolved, resolvedFn);
210
267
  if (!props.wait) {
211
268
  log.info(statusHint(props.slug, props.projectId, branchId));
212
269
  return;
@@ -238,6 +295,14 @@ const get = async (props) => {
238
295
  fields: DEPLOYMENT_FIELDS,
239
296
  title: 'active deployment',
240
297
  });
298
+ writeDeploymentErrorSection(out, fn.active_deployment);
299
+ }
300
+ if (props.listEnvVariables) {
301
+ out.write((fn.active_deployment?.environment ?? []).map((name) => ({ name })), {
302
+ fields: ['name'],
303
+ title: 'environment',
304
+ emptyMessage: 'No environment variables on the active deployment.',
305
+ });
241
306
  }
242
307
  out.end();
243
308
  };
package/commands/index.js CHANGED
@@ -22,6 +22,7 @@ import * as config from './config.js';
22
22
  import * as deploy from './deploy.js';
23
23
  import * as env from './env.js';
24
24
  import * as bucket from './bucket.js';
25
+ import * as bootstrap from './bootstrap.js';
25
26
  export default [
26
27
  auth,
27
28
  users,
@@ -47,4 +48,5 @@ export default [
47
48
  deploy,
48
49
  env,
49
50
  bucket,
51
+ bootstrap,
50
52
  ];