neonctl 2.23.1 → 2.24.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 CHANGED
@@ -147,7 +147,7 @@ There are three modes:
147
147
  ```bash
148
148
  $ neonctl link
149
149
  ? Which organization would you like to link? › Personal Org (org-abc123)
150
- ? Which project would you like to link? › + Create new project
150
+ ? Which project would you like to link? › Create new project
151
151
  ? Name for the new project: › my-app
152
152
  ? Which region should the new project run in? › AWS US East (Ohio) (aws-us-east-2)
153
153
  Created project polished-snowflake-12345678 ("my-app") in aws-us-east-2.
@@ -269,6 +269,63 @@ $ cat .neon
269
269
 
270
270
  **`.gitignore` scaffolding**: when `.neon` is **created** for the first time, the CLI also makes sure a `.gitignore` sits alongside it listing `.neon`. If `.gitignore` doesn't exist it's created with a single `.neon` line; if it does exist, `.neon` is appended only when missing (no duplicates, your other entries are left alone). On subsequent updates to an existing `.neon`, `.gitignore` is left untouched — so if you deliberately un-ignore `.neon` (e.g. to commit shared context), the entry is not re-added on every command.
271
271
 
272
+ ## Config as code (`config` / `deploy`)
273
+
274
+ Describe a branch's desired state in a `neon.ts` policy and reconcile it from the CLI — the Neon equivalent of `terraform status` / `plan` / `apply`. The policy is a function of the branch it's being evaluated for; you switch on the branch (`name`, `isDefault`, …) and return the config you want (auth, Data API, compute settings, TTL, protection, and Preview features like Functions and buckets):
275
+
276
+ ```ts
277
+ // neon.ts
278
+ import { defineConfig } from '@neondatabase/config/v1';
279
+
280
+ export default defineConfig((branch) => {
281
+ if (branch.isDefault) {
282
+ return { protected: true, auth: {} };
283
+ }
284
+ return { parent: 'main', ttl: '7d' };
285
+ });
286
+ ```
287
+
288
+ Three sub-commands plus a top-level alias drive it:
289
+
290
+ ```bash
291
+ # Inspect the branch's live Neon state (read-only — never mutates)
292
+ neon config status
293
+
294
+ # Dry-run diff: show exactly what `apply` would change
295
+ neon config plan
296
+
297
+ # Reconcile the policy against the branch
298
+ neon config apply
299
+
300
+ # `neon deploy` is an alias for `neon config apply`
301
+ neon deploy
302
+ ```
303
+
304
+ **Project & branch resolution** follows the same chain as the rest of the CLI, each entry winning over the next:
305
+
306
+ 1. `--project-id <id>` flag
307
+ 2. `projectId` from the closest `.neon` file (found by walking up from the current directory — see "Where `.neon` lives" above)
308
+ 3. If still unresolved and the API key maps to exactly one project, that project is auto-detected
309
+
310
+ The branch is chosen with `--branch <id|name>`; without it the project's default branch is used. The policy itself is found by walking up from the current directory for a `neon.ts`, or pass `--config <path>` to point at one explicitly.
311
+
312
+ **Apply-only flags** (also available on `deploy`):
313
+
314
+ - `--update-existing` — auto-confirm overriding existing remote settings on the branch. Without it, drift on settings already present remotely (compute, TTL, `protected`) is reported as a **conflict** and `apply` makes no changes until you resolve it or pass this flag.
315
+ - `--allow-protected` — auto-confirm applying to a branch Neon marks as protected. Without it, `apply` refuses to touch a protected branch.
316
+
317
+ **Output**: `status` prints the project, branch, and reverse-engineered config; `plan` / `apply` print the planned/applied changes and any conflicts as tables. Pass `--output json` (or `--output yaml`) to emit the full machine-readable result (`PushResult`) for piping into other tools or CI.
318
+
319
+ ```bash
320
+ # CI gate: fail the build if the branch has drifted from the policy
321
+ neon config plan --project-id polished-snowflake-12345678 --output json
322
+
323
+ # Reconcile a feature branch, overriding any manual tweaks made in the console
324
+ neon deploy --branch my-feature --update-existing
325
+ ```
326
+
327
+ Function deploys declared under `preview.functions` are bundled by neonctl's own esbuild helper and uploaded as part of `apply`, so the policy stays declarative and the packaged CLI never has to embed esbuild's native binary.
328
+
272
329
  ## Commands
273
330
 
274
331
  | Command | Subcommands | Description |
@@ -287,6 +344,8 @@ $ cat .neon
287
344
  | [set-context](https://neon.com/docs/reference/cli-set-context) | | Set context for session |
288
345
  | checkout | | Pin a branch in `.neon` |
289
346
  | [link](https://neon.com/docs/reference/cli-link) | | Link a directory to a project |
347
+ | config | `status`, `plan`, `apply` | Drive a branch from `neon.ts` |
348
+ | deploy | | Alias for `config apply` |
290
349
  | [completion](https://neon.com/docs/reference/cli-completion) | | Generate a completion script |
291
350
 
292
351
  ## Global options
package/commands/auth.js CHANGED
@@ -100,6 +100,11 @@ export const ensureAuth = async (props) => {
100
100
  if (props._.length === 0 || props.help) {
101
101
  return;
102
102
  }
103
+ // `dev` runs a function locally. It injects the selected branch's env vars
104
+ // when credentials happen to be available, but must never trigger an
105
+ // interactive login: use an API key or existing stored credentials if
106
+ // present, otherwise run with no API client (env injection is skipped).
107
+ const isLocalDev = props._[0] === 'dev';
103
108
  // Use existing API key or handle auth command
104
109
  if (props.apiKey || props._[0] === 'auth') {
105
110
  if (props.apiKey) {
@@ -141,6 +146,13 @@ export const ensureAuth = async (props) => {
141
146
  else {
142
147
  log.debug('Credentials file %s does not exist, starting authentication', credentialsPath);
143
148
  }
149
+ // `dev` never launches the interactive browser flow. With no usable
150
+ // credentials it proceeds without an API client; env injection is skipped
151
+ // and the function still runs locally.
152
+ if (isLocalDev) {
153
+ log.debug('dev: no usable credentials; running without env injection');
154
+ return;
155
+ }
144
156
  // Start new auth flow if no valid token exists or refresh failed
145
157
  const apiKey = await authFlow(props);
146
158
  props.apiKey = apiKey;
@@ -7,6 +7,7 @@ import { isCi } from '../env.js';
7
7
  import { log } from '../log.js';
8
8
  import { fillSingleProject } from '../utils/enrichers.js';
9
9
  import { looksLikeBranchId } from '../utils/formats.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.
@@ -44,7 +45,7 @@ export const handler = async (props) => {
44
45
  // (--project-id flag > .neon file > single-project auto-detect); when
45
46
  // nothing resolves, fall back to an interactive `neonctl link`.
46
47
  const projectId = await resolveProjectId(props);
47
- const branchId = await resolveBranchId(props, projectId);
48
+ const { branchId, created } = await resolveBranchId(props, projectId);
48
49
  const orgId = await resolveOrgId(props, projectId);
49
50
  // `checkout` is a thin helper over `set-context`. It fully "heals" the
50
51
  // context file: it always (re)writes `projectId`, `branchId`, and `orgId`
@@ -56,36 +57,44 @@ export const handler = async (props) => {
56
57
  branchId,
57
58
  });
58
59
  log.info('Checked out branch %s on project %s%s. Updated %s.', branchId, projectId, orgId ? ` (org ${orgId})` : '', props.contextFile);
60
+ // Only when checkout just *created* the branch do we apply the local neon.ts policy,
61
+ // so a new branch comes up with the declared settings/infra immediately. Checking out an
62
+ // existing branch never reconciles it — that's an explicit `neonctl deploy` / `config
63
+ // apply`. No neon.ts on disk → nothing to apply.
64
+ if (created) {
65
+ await applyPolicyOnCreate({
66
+ projectId,
67
+ branchId,
68
+ ...(props.apiKey ? { apiKey: props.apiKey } : {}),
69
+ });
70
+ }
59
71
  };
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
72
  const resolveBranchId = async (props, projectId) => {
72
73
  const branches = (await props.apiClient.listProjectBranches({ projectId }))
73
74
  .data.branches;
74
75
  if (!props.id) {
75
- return pickBranchInteractively(branches, projectId);
76
+ const picked = await pickBranchInteractively(branches);
77
+ if (picked.kind === 'existing') {
78
+ return { branchId: picked.branchId, created: false };
79
+ }
80
+ // The user chose "create a new branch" from the picker.
81
+ return {
82
+ branchId: await createBranch(props, projectId, picked.name, branches),
83
+ created: true,
84
+ };
76
85
  }
77
86
  const ref = props.id;
78
87
  // A `br-…` value is an id; match strictly by id and never offer to create.
79
88
  if (looksLikeBranchId(ref)) {
80
89
  const byId = branches.find((b) => b.id === ref);
81
90
  if (byId) {
82
- return byId.id;
91
+ return { branchId: byId.id, created: false };
83
92
  }
84
93
  throw new Error(notFoundMessage(ref, branches));
85
94
  }
86
95
  const byName = branches.find((b) => b.name === ref);
87
96
  if (byName) {
88
- return byName.id;
97
+ return { branchId: byName.id, created: false };
89
98
  }
90
99
  // Name not found: offer to create it interactively, mirroring `branch create`.
91
100
  if (isCi() || !process.stdout.isTTY) {
@@ -101,34 +110,70 @@ const resolveBranchId = async (props, projectId) => {
101
110
  if (!create) {
102
111
  throw new Error(`Aborted: branch "${ref}" was not found and not created.`);
103
112
  }
104
- return createBranch(props, projectId, ref, branches);
113
+ return {
114
+ branchId: await createBranch(props, projectId, ref, branches),
115
+ created: true,
116
+ };
105
117
  };
106
118
  const notFoundMessage = (ref, branches) => `Branch ${ref} not found.\nAvailable branches: ${branches
107
119
  .map((b) => b.name)
108
120
  .join(', ')}`;
109
- const pickBranchInteractively = async (branches, projectId) => {
121
+ /** Sentinel `value` for the "create a new branch" choice (no branch id can collide). */
122
+ const CREATE_BRANCH_CHOICE = Symbol('create-branch');
123
+ const pickBranchInteractively = async (branches) => {
110
124
  if (isCi() || !process.stdout.isTTY) {
111
125
  throw new Error('No branch specified. Pass a branch name or id (e.g. `neonctl checkout main`), ' +
112
126
  'or run interactively to pick one from a list.');
113
127
  }
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({
128
+ // The default selection is the project's default branch when there are branches to
129
+ // show; the create option sits at the top, so offset the default index by one.
130
+ const defaultBranchIndex = branches.findIndex((b) => b.default);
131
+ const initial = defaultBranchIndex >= 0 ? defaultBranchIndex + 1 : 0;
132
+ const { choice } = await prompts({
119
133
  type: 'select',
120
- name: 'branchId',
134
+ name: 'choice',
121
135
  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,
136
+ choices: [
137
+ { title: '+ Create a new branch…', value: CREATE_BRANCH_CHOICE },
138
+ ...branches.map((b) => ({
139
+ title: `${b.default ? '✱ ' : ''}${b.name} (${b.id})`,
140
+ value: b.id,
141
+ })),
142
+ ],
143
+ initial,
127
144
  });
128
- if (!branchId) {
145
+ if (choice === undefined) {
129
146
  throw new Error('Aborted: no branch selected.');
130
147
  }
131
- return branchId;
148
+ if (choice === CREATE_BRANCH_CHOICE) {
149
+ return { kind: 'create', name: await promptNewBranchName(branches) };
150
+ }
151
+ return { kind: 'existing', branchId: choice };
152
+ };
153
+ /**
154
+ * Prompt for a new branch name, rejecting empty input and names already taken on the
155
+ * project (so we never silently check out a different, pre-existing branch).
156
+ */
157
+ const promptNewBranchName = async (branches) => {
158
+ const existing = new Set(branches.map((b) => b.name));
159
+ const { name } = await prompts({
160
+ type: 'text',
161
+ name: 'name',
162
+ message: 'New branch name:',
163
+ validate: (value) => {
164
+ const trimmed = value.trim();
165
+ if (trimmed === '')
166
+ return 'Branch name cannot be empty.';
167
+ if (existing.has(trimmed))
168
+ return `A branch named "${trimmed}" already exists.`;
169
+ return true;
170
+ },
171
+ });
172
+ const trimmed = typeof name === 'string' ? name.trim() : '';
173
+ if (trimmed === '') {
174
+ throw new Error('Aborted: no branch name provided.');
175
+ }
176
+ return trimmed;
132
177
  };
133
178
  /**
134
179
  * Create a branch with the same defaults as `neonctl branch create --name <name>`:
@@ -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);