neonctl 2.23.1 → 2.24.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,12 +1,13 @@
1
- import { EndpointType } from '@neondatabase/api-client';
2
1
  import { isAxiosError } from 'axios';
3
2
  import prompts from 'prompts';
4
- import { retryOnLock } from '../api.js';
5
3
  import { applyContext, readContextFile } from '../context.js';
6
4
  import { isCi } from '../env.js';
7
5
  import { log } from '../log.js';
6
+ import { createBranch, pickBranchInteractively, } from '../utils/branch_picker.js';
8
7
  import { fillSingleProject } from '../utils/enrichers.js';
9
8
  import { looksLikeBranchId } from '../utils/formats.js';
9
+ import { autoPullEnvAfterPin } from './env.js';
10
+ import { applyPolicyOnCreate } from './config.js';
10
11
  import { handler as linkHandler } from './link.js';
11
12
  // The positional is optional: omitting it in an interactive terminal opens a
12
13
  // branch picker. In non-interactive contexts a missing branch is an error.
@@ -23,6 +24,13 @@ export const builder = (argv) => argv
23
24
  describe: 'Project ID',
24
25
  type: 'string',
25
26
  },
27
+ 'env-pull': {
28
+ describe: "Pull the branch's Neon env vars (DATABASE_URL, …) into a local .env after " +
29
+ 'checkout. On by default; use --no-env-pull to skip (e.g. when injecting env at ' +
30
+ 'runtime with `neon-env run` / `neon dev`).',
31
+ type: 'boolean',
32
+ default: true,
33
+ },
26
34
  })
27
35
  .example([
28
36
  [
@@ -44,7 +52,7 @@ export const handler = async (props) => {
44
52
  // (--project-id flag > .neon file > single-project auto-detect); when
45
53
  // nothing resolves, fall back to an interactive `neonctl link`.
46
54
  const projectId = await resolveProjectId(props);
47
- const branchId = await resolveBranchId(props, projectId);
55
+ const { branchId, created } = await resolveBranchId(props, projectId);
48
56
  const orgId = await resolveOrgId(props, projectId);
49
57
  // `checkout` is a thin helper over `set-context`. It fully "heals" the
50
58
  // context file: it always (re)writes `projectId`, `branchId`, and `orgId`
@@ -56,36 +64,56 @@ export const handler = async (props) => {
56
64
  branchId,
57
65
  });
58
66
  log.info('Checked out branch %s on project %s%s. Updated %s.', branchId, projectId, orgId ? ` (org ${orgId})` : '', props.contextFile);
67
+ // Only when checkout just *created* the branch do we apply the local neon.ts policy,
68
+ // so a new branch comes up with the declared settings/infra immediately. Checking out an
69
+ // existing branch never reconciles it — that's an explicit `neonctl deploy` / `config
70
+ // apply`. No neon.ts on disk → nothing to apply.
71
+ if (created) {
72
+ await applyPolicyOnCreate({
73
+ projectId,
74
+ branchId,
75
+ ...(props.apiKey ? { apiKey: props.apiKey } : {}),
76
+ });
77
+ }
78
+ // Bundle `env pull` so the branch-first loop is just link + checkout: the branch you
79
+ // checked out is immediately usable for local dev. `--no-env-pull` opts out.
80
+ await autoPullEnvAfterPin({
81
+ ...props,
82
+ projectId,
83
+ branch: branchId,
84
+ envPull: props.envPull,
85
+ });
59
86
  };
60
- /**
61
- * Resolve the branch id to check out.
62
- *
63
- * - Branch **id** (`br-…`): looked up by id. A non-existent id is a hard "not
64
- * found" error — we never offer to create one, since ids are server-assigned.
65
- * - Branch **name**: looked up by name. If it doesn't exist, in an interactive
66
- * terminal we offer to create it (like `neonctl branch create --name <name>`);
67
- * in a non-interactive context it's the usual "not found" error.
68
- * - **Omitted**: open an interactive picker listing the project's branches (TTY
69
- * only); in a non-interactive context a missing branch is a hard error.
70
- */
71
87
  const resolveBranchId = async (props, projectId) => {
72
88
  const branches = (await props.apiClient.listProjectBranches({ projectId }))
73
89
  .data.branches;
74
90
  if (!props.id) {
75
- return pickBranchInteractively(branches, projectId);
91
+ const picked = await pickBranchInteractively(branches, {
92
+ message: 'Which branch would you like to check out?',
93
+ nonInteractiveMessage: 'No branch specified. Pass a branch name or id (e.g. `neonctl checkout main`), ' +
94
+ 'or run interactively to pick one from a list.',
95
+ });
96
+ if (picked.kind === 'existing') {
97
+ return { branchId: picked.branchId, created: false };
98
+ }
99
+ // The user chose "create a new branch" from the picker.
100
+ return {
101
+ branchId: await createBranch(props.apiClient, projectId, picked.name, branches),
102
+ created: true,
103
+ };
76
104
  }
77
105
  const ref = props.id;
78
106
  // A `br-…` value is an id; match strictly by id and never offer to create.
79
107
  if (looksLikeBranchId(ref)) {
80
108
  const byId = branches.find((b) => b.id === ref);
81
109
  if (byId) {
82
- return byId.id;
110
+ return { branchId: byId.id, created: false };
83
111
  }
84
112
  throw new Error(notFoundMessage(ref, branches));
85
113
  }
86
114
  const byName = branches.find((b) => b.name === ref);
87
115
  if (byName) {
88
- return byName.id;
116
+ return { branchId: byName.id, created: false };
89
117
  }
90
118
  // Name not found: offer to create it interactively, mirroring `branch create`.
91
119
  if (isCi() || !process.stdout.isTTY) {
@@ -101,54 +129,14 @@ const resolveBranchId = async (props, projectId) => {
101
129
  if (!create) {
102
130
  throw new Error(`Aborted: branch "${ref}" was not found and not created.`);
103
131
  }
104
- return createBranch(props, projectId, ref, branches);
132
+ return {
133
+ branchId: await createBranch(props.apiClient, projectId, ref, branches),
134
+ created: true,
135
+ };
105
136
  };
106
137
  const notFoundMessage = (ref, branches) => `Branch ${ref} not found.\nAvailable branches: ${branches
107
138
  .map((b) => b.name)
108
139
  .join(', ')}`;
109
- const pickBranchInteractively = async (branches, projectId) => {
110
- if (isCi() || !process.stdout.isTTY) {
111
- throw new Error('No branch specified. Pass a branch name or id (e.g. `neonctl checkout main`), ' +
112
- 'or run interactively to pick one from a list.');
113
- }
114
- if (branches.length === 0) {
115
- throw new Error(`No branches found for project ${projectId}.`);
116
- }
117
- const defaultIndex = Math.max(0, branches.findIndex((b) => b.default));
118
- const { branchId } = await prompts({
119
- type: 'select',
120
- name: 'branchId',
121
- message: 'Which branch would you like to check out?',
122
- choices: branches.map((b) => ({
123
- title: `${b.default ? '✱ ' : ''}${b.name} (${b.id})`,
124
- value: b.id,
125
- })),
126
- initial: defaultIndex,
127
- });
128
- if (!branchId) {
129
- throw new Error('Aborted: no branch selected.');
130
- }
131
- return branchId;
132
- };
133
- /**
134
- * Create a branch with the same defaults as `neonctl branch create --name <name>`:
135
- * branched from the project's default branch with a read-write compute endpoint.
136
- */
137
- const createBranch = async (props, projectId, name, branches) => {
138
- const defaultBranch = branches.find((b) => b.default);
139
- if (!defaultBranch) {
140
- throw new Error('No default branch found');
141
- }
142
- const { data } = await retryOnLock(() => props.apiClient.createProjectBranch(projectId, {
143
- branch: { name, parent_id: defaultBranch.id },
144
- endpoints: [{ type: EndpointType.ReadWrite }],
145
- }));
146
- if (defaultBranch.protected) {
147
- log.warning('The parent branch is protected; a unique role password has been generated for the new branch.');
148
- }
149
- log.info('Created branch %s (%s).', data.branch.name, data.branch.id);
150
- return data.branch.id;
151
- };
152
140
  /**
153
141
  * Resolve the org id to heal into the context file.
154
142
  *
@@ -0,0 +1,249 @@
1
+ import { resolveConfig } from '@neondatabase/config';
2
+ import { apply, inspect, loadConfigFromFile, plan, } from '@neondatabase/config-runtime';
3
+ import { toNeonConfigView } from '../config_format.js';
4
+ import { log } from '../log.js';
5
+ import { loadEnvFileIntoProcess } from '../env_file.js';
6
+ import { branchIdFromProps, fillSingleProject } from '../utils/enrichers.js';
7
+ import { bundleEntry } from '../utils/esbuild.js';
8
+ import { zipBundle } from '../utils/zip.js';
9
+ import { writer } from '../writer.js';
10
+ /**
11
+ * Bundle a function with neonctl's OWN bundler (the shared esbuild helper) so the
12
+ * config-runtime never has to import esbuild itself. Injecting this keeps esbuild
13
+ * out of config-runtime's static module graph — and therefore out of the packaged
14
+ * neonctl snapshot, which resolves esbuild dynamically at deploy time.
15
+ */
16
+ const neonctlBundler = async (fn) => zipBundle(await bundleEntry(fn.source));
17
+ const INSPECT_FIELDS = ['project', 'branch', 'config'];
18
+ const APPLIED_FIELDS = ['action', 'kind', 'identifier', 'details'];
19
+ const CONFLICT_FIELDS = [
20
+ 'identifier',
21
+ 'field',
22
+ 'current',
23
+ 'desired',
24
+ 'reason',
25
+ ];
26
+ /**
27
+ * Shared `--env` flag for `config plan|apply` and `deploy`. Loads a `.env` into
28
+ * `process.env` before the policy is evaluated.
29
+ */
30
+ export const envFlag = {
31
+ env: {
32
+ describe: 'Path to a .env file to load into the environment before evaluating neon.ts ' +
33
+ '(so function env values resolve from it). Existing env vars are not overridden.',
34
+ type: 'string',
35
+ },
36
+ };
37
+ /** Apply-only flags, exported so `deploy` can reuse the exact same surface. */
38
+ export const applyFlags = {
39
+ 'update-existing': {
40
+ describe: 'Auto-confirm overriding existing remote settings on the branch',
41
+ type: 'boolean',
42
+ default: false,
43
+ },
44
+ 'allow-protected': {
45
+ describe: 'Auto-confirm applying to a branch marked protected on Neon',
46
+ type: 'boolean',
47
+ default: false,
48
+ },
49
+ };
50
+ export const command = 'config';
51
+ export const describe = 'Manage a branch with a neon.ts policy';
52
+ export const builder = (argv) => argv
53
+ .usage('$0 config <sub-command> [options]')
54
+ .options({
55
+ 'project-id': {
56
+ describe: 'Project ID',
57
+ type: 'string',
58
+ },
59
+ branch: {
60
+ describe: 'Branch ID or name',
61
+ type: 'string',
62
+ },
63
+ })
64
+ .middleware(fillSingleProject)
65
+ .command('status', "Show the branch's live Neon state", (yargs) => yargs.options({
66
+ 'config-json': {
67
+ describe: "Print only the branch's live config as neon.ts-shaped JSON " +
68
+ '(services + branch tuning + preview), to stdout. Useful for ' +
69
+ 'scripting or copying into a neon.ts.',
70
+ type: 'boolean',
71
+ default: false,
72
+ },
73
+ }), (args) => status(args))
74
+ .command('plan', 'Show what `config apply` would change (dry run)', (yargs) => yargs.options({
75
+ config: {
76
+ describe: 'Path to a neon.ts policy (defaults to walking up from cwd)',
77
+ type: 'string',
78
+ },
79
+ ...envFlag,
80
+ }), (args) => planCmd(args))
81
+ .command('apply', 'Apply a neon.ts policy to the branch', (yargs) => yargs.options({
82
+ config: {
83
+ describe: 'Path to a neon.ts policy (defaults to walking up from cwd)',
84
+ type: 'string',
85
+ },
86
+ ...envFlag,
87
+ ...applyFlags,
88
+ }), (args) => applyCmd(args));
89
+ export const handler = (args) => {
90
+ return args;
91
+ };
92
+ const loadConfig = async (props) => {
93
+ // Load the optional --env file FIRST so a `neon.ts` whose function `env` values read
94
+ // `process.env.X` sees them. Must happen before the policy module is imported/evaluated.
95
+ if (props.env) {
96
+ const applied = loadEnvFileIntoProcess(props.env);
97
+ log.debug('Loaded %d var(s) from %s into the environment: %s', applied.length, props.env, applied.join(', '));
98
+ }
99
+ const { config } = await loadConfigFromFile({
100
+ ...(props.config ? { path: props.config } : {}),
101
+ });
102
+ return config;
103
+ };
104
+ export const status = async (props) => {
105
+ const branchId = await branchIdFromProps(props);
106
+ const live = await inspect({
107
+ projectId: props.projectId,
108
+ branchId,
109
+ ...(props.apiKey ? { apiKey: props.apiKey } : {}),
110
+ ...(props.runtimeApi ? { api: props.runtimeApi } : {}),
111
+ });
112
+ // The pulled `config` carries the branch's tuning inside a closure that JSON can't
113
+ // render. Resolve it against the live branch target to get the concrete settings, then
114
+ // project both that and the separately-pulled preview state into a neon.ts-shaped view.
115
+ const resolved = resolveConfig(live.config, {
116
+ name: live.branch.name,
117
+ id: live.branch.id,
118
+ exists: true,
119
+ isDefault: live.branch.isDefault,
120
+ isProtected: live.branch.protected,
121
+ ...(live.branch.parent ? { parentId: live.branch.parent } : {}),
122
+ ...(live.branch.expiresAt ? { expiresAt: live.branch.expiresAt } : {}),
123
+ });
124
+ const configView = toNeonConfigView(resolved, live.preview);
125
+ // `--config-json`: emit just the neon.ts-shaped config to stdout (script-friendly,
126
+ // copy-paste-able), regardless of the global --output.
127
+ if (props.configJson) {
128
+ process.stdout.write(`${JSON.stringify(configView, null, 2)}\n`);
129
+ return;
130
+ }
131
+ // Default: the live project/branch tables, but with the unhelpful raw `config` replaced
132
+ // by the resolved neon.ts-shaped view so the user sees enabled infra + branch tuning.
133
+ writer(props).end({ project: live.project, branch: live.branch, config: configView }, { fields: INSPECT_FIELDS });
134
+ };
135
+ export const planCmd = async (props) => {
136
+ const config = await loadConfig(props);
137
+ const branchId = await branchIdFromProps(props);
138
+ // `plan` is a dry run that never bundles, so its options don't accept (or need)
139
+ // an injected bundler — only `apply` does (it uses neonctlBundler).
140
+ const result = await plan(config, {
141
+ projectId: props.projectId,
142
+ branchId,
143
+ ...(props.apiKey ? { apiKey: props.apiKey } : {}),
144
+ ...(props.runtimeApi ? { api: props.runtimeApi } : {}),
145
+ });
146
+ reportPushResult(props, result, 'plan');
147
+ };
148
+ export const applyCmd = async (props) => {
149
+ const config = await loadConfig(props);
150
+ const branchId = await branchIdFromProps(props);
151
+ const result = await apply(config, {
152
+ projectId: props.projectId,
153
+ branchId,
154
+ ...(props.apiKey ? { apiKey: props.apiKey } : {}),
155
+ ...(props.runtimeApi ? { api: props.runtimeApi } : {}),
156
+ ...(props.updateExisting ? { updateExisting: true } : {}),
157
+ ...(props.allowProtected ? { allowProtectedBranch: true } : {}),
158
+ bundleFunction: neonctlBundler,
159
+ });
160
+ reportPushResult(props, result, 'apply');
161
+ };
162
+ /**
163
+ * Render a {@link PushResult}. JSON/YAML output emits the raw result verbatim so it
164
+ * can be piped; the human-readable path renders the actual changes (dropping noops)
165
+ * and any blocking conflicts as tables, or a "nothing to do" line when both are empty.
166
+ */
167
+ const reportPushResult = (props, result, mode) => {
168
+ if (props.output === 'json' || props.output === 'yaml') {
169
+ writer(props).end(result, { fields: [] });
170
+ return;
171
+ }
172
+ const changes = result.applied
173
+ .filter((change) => change.action !== 'noop')
174
+ .map((change) => ({
175
+ action: change.action,
176
+ kind: change.kind,
177
+ identifier: change.identifier,
178
+ details: change.details ? JSON.stringify(change.details) : '',
179
+ }));
180
+ const conflicts = result.conflicts.map((conflict) => ({
181
+ identifier: conflict.identifier,
182
+ field: conflict.field,
183
+ current: stringify(conflict.current),
184
+ desired: stringify(conflict.desired),
185
+ reason: conflict.reason,
186
+ }));
187
+ if (changes.length === 0 && conflicts.length === 0) {
188
+ log.info(`No changes — branch ${result.branchName} already matches the policy.`);
189
+ return;
190
+ }
191
+ const out = writer(props);
192
+ if (changes.length > 0) {
193
+ out.write(changes, {
194
+ fields: APPLIED_FIELDS,
195
+ title: mode === 'plan' ? 'Planned changes' : 'Applied changes',
196
+ });
197
+ }
198
+ if (conflicts.length > 0) {
199
+ out.write(conflicts, { fields: CONFLICT_FIELDS, title: 'Conflicts' });
200
+ }
201
+ out.end();
202
+ if (conflicts.length > 0) {
203
+ log.info('Resolve the conflicts above, or re-run with --update-existing to override the current remote settings.');
204
+ }
205
+ };
206
+ const stringify = (value) => value === undefined
207
+ ? ''
208
+ : typeof value === 'string'
209
+ ? value
210
+ : JSON.stringify(value);
211
+ /**
212
+ * Apply a `neon.ts` policy to a **freshly created** branch (used by `neonctl checkout`
213
+ * when it creates a branch). No-op when there is no `neon.ts` on the path from cwd up to
214
+ * the repo root — checkout still succeeds, it just has no policy to apply.
215
+ *
216
+ * The branch was just created by us, so we apply non-interactively (`updateExisting` /
217
+ * `allowProtectedBranch`) — there is no pre-existing state a user would be surprised to
218
+ * see overridden. Functions are bundled with neonctl's own esbuild helper.
219
+ */
220
+ export const applyPolicyOnCreate = async (props) => {
221
+ let config;
222
+ try {
223
+ ({ config } = await loadConfigFromFile({
224
+ ...(props.cwd ? { cwd: props.cwd } : {}),
225
+ }));
226
+ }
227
+ catch (err) {
228
+ const message = err instanceof Error ? err.message : String(err);
229
+ if (/Could not find a Neon config file/i.test(message))
230
+ return;
231
+ throw err;
232
+ }
233
+ log.info('Applying neon.ts policy to the new branch…');
234
+ const result = await apply(config, {
235
+ projectId: props.projectId,
236
+ branchId: props.branchId,
237
+ ...(props.apiKey ? { apiKey: props.apiKey } : {}),
238
+ ...(props.runtimeApi ? { api: props.runtimeApi } : {}),
239
+ updateExisting: true,
240
+ allowProtectedBranch: true,
241
+ bundleFunction: neonctlBundler,
242
+ });
243
+ const changes = result.applied.filter((c) => c.action !== 'noop');
244
+ if (changes.length === 0) {
245
+ log.info('neon.ts applied — no changes were needed.');
246
+ return;
247
+ }
248
+ log.info('neon.ts applied — %d change%s: %s', changes.length, changes.length === 1 ? '' : 's', changes.map((c) => `${c.action} ${c.identifier}`).join(', '));
249
+ };
@@ -0,0 +1,25 @@
1
+ import { fillSingleProject } from '../utils/enrichers.js';
2
+ import { applyCmd, applyFlags, envFlag } from './config.js';
3
+ export const command = 'deploy';
4
+ export const describe = 'Apply a neon.ts policy to a branch (alias for `config apply`)';
5
+ export const builder = (argv) => argv
6
+ .usage('$0 deploy [options]')
7
+ .options({
8
+ 'project-id': {
9
+ describe: 'Project ID',
10
+ type: 'string',
11
+ },
12
+ branch: {
13
+ describe: 'Branch ID or name',
14
+ type: 'string',
15
+ },
16
+ config: {
17
+ describe: 'Path to a neon.ts policy (defaults to walking up from cwd)',
18
+ type: 'string',
19
+ },
20
+ ...envFlag,
21
+ ...applyFlags,
22
+ })
23
+ .middleware(fillSingleProject)
24
+ .strict();
25
+ export const handler = (props) => applyCmd(props);