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 +60 -1
- package/commands/auth.js +12 -0
- package/commands/checkout.js +75 -30
- package/commands/config.js +249 -0
- package/commands/deploy.js +25 -0
- package/commands/dev.js +709 -0
- package/commands/env.js +67 -0
- package/commands/index.js +8 -0
- package/commands/link.js +4 -2
- package/config_format.js +66 -0
- package/dev/env.js +183 -0
- package/dev/functions.js +72 -0
- package/dev/inputs.js +63 -0
- package/dev/runtime.js +146 -0
- package/env_file.js +146 -0
- package/index.js +2 -0
- package/package.json +7 -2
- package/psql/command/cmd_format.js +12 -12
- package/psql/command/cmd_io.js +2 -2
- package/psql/command/cmd_restrict.js +8 -8
- package/psql/describe/formatters.js +1 -1
- package/psql/print/aligned.js +30 -30
- package/psql/print/json.js +5 -5
- package/psql/scanner/sql.js +4 -4
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? ›
|
|
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;
|
package/commands/checkout.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
const {
|
|
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: '
|
|
134
|
+
name: 'choice',
|
|
121
135
|
message: 'Which branch would you like to check out?',
|
|
122
|
-
choices:
|
|
123
|
-
title:
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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 (
|
|
145
|
+
if (choice === undefined) {
|
|
129
146
|
throw new Error('Aborted: no branch selected.');
|
|
130
147
|
}
|
|
131
|
-
|
|
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);
|