neonctl 2.24.0 → 2.24.2
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 +51 -19
- package/commands/branches.js +14 -6
- package/commands/bucket.js +368 -0
- package/commands/checkout.js +64 -94
- package/commands/config.js +46 -1
- package/commands/dev.js +50 -14
- package/commands/env.js +57 -2
- package/commands/index.js +2 -0
- package/commands/link.js +72 -10
- package/dev/env.js +33 -14
- package/package.json +4 -4
- package/pkg.js +23 -1
- package/storage_api.js +114 -0
- package/utils/branch_picker.js +103 -0
- package/utils/esbuild.js +8 -5
- package/utils/zip.js +1 -1
package/commands/checkout.js
CHANGED
|
@@ -1,13 +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';
|
|
10
|
-
import {
|
|
9
|
+
import { autoPullEnvAfterPin } from './env.js';
|
|
10
|
+
import { applyPolicyOnCreate, createBranchFromPolicyOnCheckout, } from './config.js';
|
|
11
11
|
import { handler as linkHandler } from './link.js';
|
|
12
12
|
// The positional is optional: omitting it in an interactive terminal opens a
|
|
13
13
|
// branch picker. In non-interactive contexts a missing branch is an error.
|
|
@@ -24,6 +24,13 @@ export const builder = (argv) => argv
|
|
|
24
24
|
describe: 'Project ID',
|
|
25
25
|
type: 'string',
|
|
26
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
|
+
},
|
|
27
34
|
})
|
|
28
35
|
.example([
|
|
29
36
|
[
|
|
@@ -45,7 +52,7 @@ export const handler = async (props) => {
|
|
|
45
52
|
// (--project-id flag > .neon file > single-project auto-detect); when
|
|
46
53
|
// nothing resolves, fall back to an interactive `neonctl link`.
|
|
47
54
|
const projectId = await resolveProjectId(props);
|
|
48
|
-
const { branchId, created } = await resolveBranchId(props, projectId);
|
|
55
|
+
const { branchId, created, policyApplied } = await resolveBranchId(props, projectId);
|
|
49
56
|
const orgId = await resolveOrgId(props, projectId);
|
|
50
57
|
// `checkout` is a thin helper over `set-context`. It fully "heals" the
|
|
51
58
|
// context file: it always (re)writes `projectId`, `branchId`, and `orgId`
|
|
@@ -57,44 +64,59 @@ export const handler = async (props) => {
|
|
|
57
64
|
branchId,
|
|
58
65
|
});
|
|
59
66
|
log.info('Checked out branch %s on project %s%s. Updated %s.', branchId, projectId, orgId ? ` (org ${orgId})` : '', props.contextFile);
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
67
|
+
// When checkout *created* the branch and a neon.ts exists, the branch was created straight
|
|
68
|
+
// from the policy (evaluated as a new branch) so its settings/infra are already applied —
|
|
69
|
+
// see `policyApplied`. The fallback below covers the case where the branch was created bare
|
|
70
|
+
// (e.g. a policy-driven create wasn't possible); `applyPolicyOnCreate` is a no-op when there
|
|
71
|
+
// is no neon.ts on disk. Checking out an existing branch never reconciles it.
|
|
72
|
+
if (created && !policyApplied) {
|
|
65
73
|
await applyPolicyOnCreate({
|
|
66
74
|
projectId,
|
|
67
75
|
branchId,
|
|
68
76
|
...(props.apiKey ? { apiKey: props.apiKey } : {}),
|
|
77
|
+
...(props.apiHost ? { apiHost: props.apiHost } : {}),
|
|
69
78
|
});
|
|
70
79
|
}
|
|
80
|
+
// Bundle `env pull` so the branch-first loop is just link + checkout: the branch you
|
|
81
|
+
// checked out is immediately usable for local dev. `--no-env-pull` opts out.
|
|
82
|
+
await autoPullEnvAfterPin({
|
|
83
|
+
...props,
|
|
84
|
+
projectId,
|
|
85
|
+
branch: branchId,
|
|
86
|
+
envPull: props.envPull,
|
|
87
|
+
});
|
|
71
88
|
};
|
|
72
89
|
const resolveBranchId = async (props, projectId) => {
|
|
73
90
|
const branches = (await props.apiClient.listProjectBranches({ projectId }))
|
|
74
91
|
.data.branches;
|
|
75
92
|
if (!props.id) {
|
|
76
|
-
const picked = await pickBranchInteractively(branches
|
|
93
|
+
const picked = await pickBranchInteractively(branches, {
|
|
94
|
+
message: 'Which branch would you like to check out?',
|
|
95
|
+
nonInteractiveMessage: 'No branch specified. Pass a branch name or id (e.g. `neonctl checkout main`), ' +
|
|
96
|
+
'or run interactively to pick one from a list.',
|
|
97
|
+
});
|
|
77
98
|
if (picked.kind === 'existing') {
|
|
78
|
-
return {
|
|
99
|
+
return {
|
|
100
|
+
branchId: picked.branchId,
|
|
101
|
+
created: false,
|
|
102
|
+
policyApplied: false,
|
|
103
|
+
};
|
|
79
104
|
}
|
|
80
105
|
// 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
|
-
};
|
|
106
|
+
return createCheckoutBranch(props, projectId, picked.name, branches);
|
|
85
107
|
}
|
|
86
108
|
const ref = props.id;
|
|
87
109
|
// A `br-…` value is an id; match strictly by id and never offer to create.
|
|
88
110
|
if (looksLikeBranchId(ref)) {
|
|
89
111
|
const byId = branches.find((b) => b.id === ref);
|
|
90
112
|
if (byId) {
|
|
91
|
-
return { branchId: byId.id, created: false };
|
|
113
|
+
return { branchId: byId.id, created: false, policyApplied: false };
|
|
92
114
|
}
|
|
93
115
|
throw new Error(notFoundMessage(ref, branches));
|
|
94
116
|
}
|
|
95
117
|
const byName = branches.find((b) => b.name === ref);
|
|
96
118
|
if (byName) {
|
|
97
|
-
return { branchId: byName.id, created: false };
|
|
119
|
+
return { branchId: byName.id, created: false, policyApplied: false };
|
|
98
120
|
}
|
|
99
121
|
// Name not found: offer to create it interactively, mirroring `branch create`.
|
|
100
122
|
if (isCi() || !process.stdout.isTTY) {
|
|
@@ -110,90 +132,38 @@ const resolveBranchId = async (props, projectId) => {
|
|
|
110
132
|
if (!create) {
|
|
111
133
|
throw new Error(`Aborted: branch "${ref}" was not found and not created.`);
|
|
112
134
|
}
|
|
135
|
+
return createCheckoutBranch(props, projectId, ref, branches);
|
|
136
|
+
};
|
|
137
|
+
/**
|
|
138
|
+
* Create the branch to check out. When a `neon.ts` exists, route through the policy-driven
|
|
139
|
+
* create so the new branch comes up branched from the policy's `parent` and configured with
|
|
140
|
+
* its declared TTL / compute / services (evaluated as a *new* branch). Otherwise fall back to
|
|
141
|
+
* a bare branch off the default — the handler then applies the policy (a no-op with no
|
|
142
|
+
* `neon.ts`).
|
|
143
|
+
*/
|
|
144
|
+
const createCheckoutBranch = async (props, projectId, name, branches) => {
|
|
145
|
+
const fromPolicy = await createBranchFromPolicyOnCheckout({
|
|
146
|
+
projectId,
|
|
147
|
+
branchName: name,
|
|
148
|
+
...(props.apiKey ? { apiKey: props.apiKey } : {}),
|
|
149
|
+
...(props.apiHost ? { apiHost: props.apiHost } : {}),
|
|
150
|
+
});
|
|
151
|
+
if (fromPolicy) {
|
|
152
|
+
return {
|
|
153
|
+
branchId: fromPolicy.branchId,
|
|
154
|
+
created: true,
|
|
155
|
+
policyApplied: true,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
113
158
|
return {
|
|
114
|
-
branchId: await createBranch(props, projectId,
|
|
159
|
+
branchId: await createBranch(props.apiClient, projectId, name, branches),
|
|
115
160
|
created: true,
|
|
161
|
+
policyApplied: false,
|
|
116
162
|
};
|
|
117
163
|
};
|
|
118
164
|
const notFoundMessage = (ref, branches) => `Branch ${ref} not found.\nAvailable branches: ${branches
|
|
119
165
|
.map((b) => b.name)
|
|
120
166
|
.join(', ')}`;
|
|
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) => {
|
|
124
|
-
if (isCi() || !process.stdout.isTTY) {
|
|
125
|
-
throw new Error('No branch specified. Pass a branch name or id (e.g. `neonctl checkout main`), ' +
|
|
126
|
-
'or run interactively to pick one from a list.');
|
|
127
|
-
}
|
|
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({
|
|
133
|
-
type: 'select',
|
|
134
|
-
name: 'choice',
|
|
135
|
-
message: 'Which branch would you like to check out?',
|
|
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,
|
|
144
|
-
});
|
|
145
|
-
if (choice === undefined) {
|
|
146
|
-
throw new Error('Aborted: no branch selected.');
|
|
147
|
-
}
|
|
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;
|
|
177
|
-
};
|
|
178
|
-
/**
|
|
179
|
-
* Create a branch with the same defaults as `neonctl branch create --name <name>`:
|
|
180
|
-
* branched from the project's default branch with a read-write compute endpoint.
|
|
181
|
-
*/
|
|
182
|
-
const createBranch = async (props, projectId, name, branches) => {
|
|
183
|
-
const defaultBranch = branches.find((b) => b.default);
|
|
184
|
-
if (!defaultBranch) {
|
|
185
|
-
throw new Error('No default branch found');
|
|
186
|
-
}
|
|
187
|
-
const { data } = await retryOnLock(() => props.apiClient.createProjectBranch(projectId, {
|
|
188
|
-
branch: { name, parent_id: defaultBranch.id },
|
|
189
|
-
endpoints: [{ type: EndpointType.ReadWrite }],
|
|
190
|
-
}));
|
|
191
|
-
if (defaultBranch.protected) {
|
|
192
|
-
log.warning('The parent branch is protected; a unique role password has been generated for the new branch.');
|
|
193
|
-
}
|
|
194
|
-
log.info('Created branch %s (%s).', data.branch.name, data.branch.id);
|
|
195
|
-
return data.branch.id;
|
|
196
|
-
};
|
|
197
167
|
/**
|
|
198
168
|
* Resolve the org id to heal into the context file.
|
|
199
169
|
*
|
package/commands/config.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { resolveConfig } from '@neondatabase/config';
|
|
2
|
-
import { apply, inspect, loadConfigFromFile, plan, } from '@neondatabase/config-runtime';
|
|
2
|
+
import { apply, createBranch as createBranchFromPolicy, inspect, loadConfigFromFile, plan, } from '@neondatabase/config-runtime';
|
|
3
3
|
import { toNeonConfigView } from '../config_format.js';
|
|
4
4
|
import { log } from '../log.js';
|
|
5
5
|
import { loadEnvFileIntoProcess } from '../env_file.js';
|
|
@@ -107,6 +107,7 @@ export const status = async (props) => {
|
|
|
107
107
|
projectId: props.projectId,
|
|
108
108
|
branchId,
|
|
109
109
|
...(props.apiKey ? { apiKey: props.apiKey } : {}),
|
|
110
|
+
...(props.apiHost ? { apiHost: props.apiHost } : {}),
|
|
110
111
|
...(props.runtimeApi ? { api: props.runtimeApi } : {}),
|
|
111
112
|
});
|
|
112
113
|
// The pulled `config` carries the branch's tuning inside a closure that JSON can't
|
|
@@ -141,6 +142,7 @@ export const planCmd = async (props) => {
|
|
|
141
142
|
projectId: props.projectId,
|
|
142
143
|
branchId,
|
|
143
144
|
...(props.apiKey ? { apiKey: props.apiKey } : {}),
|
|
145
|
+
...(props.apiHost ? { apiHost: props.apiHost } : {}),
|
|
144
146
|
...(props.runtimeApi ? { api: props.runtimeApi } : {}),
|
|
145
147
|
});
|
|
146
148
|
reportPushResult(props, result, 'plan');
|
|
@@ -152,6 +154,7 @@ export const applyCmd = async (props) => {
|
|
|
152
154
|
projectId: props.projectId,
|
|
153
155
|
branchId,
|
|
154
156
|
...(props.apiKey ? { apiKey: props.apiKey } : {}),
|
|
157
|
+
...(props.apiHost ? { apiHost: props.apiHost } : {}),
|
|
155
158
|
...(props.runtimeApi ? { api: props.runtimeApi } : {}),
|
|
156
159
|
...(props.updateExisting ? { updateExisting: true } : {}),
|
|
157
160
|
...(props.allowProtected ? { allowProtectedBranch: true } : {}),
|
|
@@ -235,11 +238,16 @@ export const applyPolicyOnCreate = async (props) => {
|
|
|
235
238
|
projectId: props.projectId,
|
|
236
239
|
branchId: props.branchId,
|
|
237
240
|
...(props.apiKey ? { apiKey: props.apiKey } : {}),
|
|
241
|
+
...(props.apiHost ? { apiHost: props.apiHost } : {}),
|
|
238
242
|
...(props.runtimeApi ? { api: props.runtimeApi } : {}),
|
|
239
243
|
updateExisting: true,
|
|
240
244
|
allowProtectedBranch: true,
|
|
241
245
|
bundleFunction: neonctlBundler,
|
|
242
246
|
});
|
|
247
|
+
logPolicyResult(result);
|
|
248
|
+
};
|
|
249
|
+
/** Log a one-line summary of what applying a `neon.ts` policy changed (or that nothing did). */
|
|
250
|
+
const logPolicyResult = (result) => {
|
|
243
251
|
const changes = result.applied.filter((c) => c.action !== 'noop');
|
|
244
252
|
if (changes.length === 0) {
|
|
245
253
|
log.info('neon.ts applied — no changes were needed.');
|
|
@@ -247,3 +255,40 @@ export const applyPolicyOnCreate = async (props) => {
|
|
|
247
255
|
}
|
|
248
256
|
log.info('neon.ts applied — %d change%s: %s', changes.length, changes.length === 1 ? '' : 's', changes.map((c) => `${c.action} ${c.identifier}`).join(', '));
|
|
249
257
|
};
|
|
258
|
+
/**
|
|
259
|
+
* Create a branch **from** the local `neon.ts` policy. Returns `null` when there is no
|
|
260
|
+
* `neon.ts` on the path from cwd up to the repo root, so `neonctl checkout` can fall back to a
|
|
261
|
+
* bare branch create.
|
|
262
|
+
*
|
|
263
|
+
* Unlike a bare create followed by {@link applyPolicyOnCreate}, this evaluates the policy for
|
|
264
|
+
* the **new** branch (`exists: false`): the runtime branches from the policy's `parent` and
|
|
265
|
+
* brings the branch up with its declared TTL / compute settings / services. That's what makes
|
|
266
|
+
* a policy keyed on `!branch.exists` (the common "only configure new branches" shape) take
|
|
267
|
+
* effect on the very first `checkout` — a bare create + `apply` always saw `exists: true` and
|
|
268
|
+
* skipped that block.
|
|
269
|
+
*/
|
|
270
|
+
export const createBranchFromPolicyOnCheckout = async (props) => {
|
|
271
|
+
let config;
|
|
272
|
+
try {
|
|
273
|
+
({ config } = await loadConfigFromFile({
|
|
274
|
+
...(props.cwd ? { cwd: props.cwd } : {}),
|
|
275
|
+
}));
|
|
276
|
+
}
|
|
277
|
+
catch (err) {
|
|
278
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
279
|
+
if (/Could not find a Neon config file/i.test(message))
|
|
280
|
+
return null;
|
|
281
|
+
throw err;
|
|
282
|
+
}
|
|
283
|
+
const { branchId, branchName, result } = await createBranchFromPolicy(config, {
|
|
284
|
+
projectId: props.projectId,
|
|
285
|
+
branchName: props.branchName,
|
|
286
|
+
...(props.apiKey ? { apiKey: props.apiKey } : {}),
|
|
287
|
+
...(props.apiHost ? { apiHost: props.apiHost } : {}),
|
|
288
|
+
...(props.runtimeApi ? { api: props.runtimeApi } : {}),
|
|
289
|
+
bundleFunction: neonctlBundler,
|
|
290
|
+
});
|
|
291
|
+
log.info('Created branch %s (%s) from neon.ts policy.', branchName, branchId);
|
|
292
|
+
logPolicyResult(result);
|
|
293
|
+
return { branchId };
|
|
294
|
+
};
|
package/commands/dev.js
CHANGED
|
@@ -51,11 +51,12 @@ const runSingleSource = async (props) => {
|
|
|
51
51
|
throw new Error(`Source file not found: ${source}`);
|
|
52
52
|
}
|
|
53
53
|
const branchId = await resolveBranchId(props);
|
|
54
|
-
const neonEnv = await resolveDevEnv({
|
|
54
|
+
const { vars: neonEnv, skipped } = await resolveDevEnv({
|
|
55
55
|
cwd: process.cwd(),
|
|
56
56
|
...(props.projectId ? { projectId: props.projectId } : {}),
|
|
57
57
|
...(branchId ? { branchId } : {}),
|
|
58
58
|
...(props.apiKey ? { apiKey: props.apiKey } : {}),
|
|
59
|
+
...(props.apiHost ? { apiHost: props.apiHost } : {}),
|
|
59
60
|
});
|
|
60
61
|
const unit = {
|
|
61
62
|
slug: null,
|
|
@@ -63,10 +64,13 @@ const runSingleSource = async (props) => {
|
|
|
63
64
|
bundleDir: join(process.cwd(), 'node_modules', '.neon-dev'),
|
|
64
65
|
childEnv: buildChildEnv(neonEnv, portFromProps(props.port)),
|
|
65
66
|
label: null,
|
|
67
|
+
envSummary: { neon: Object.keys(neonEnv), fn: [] },
|
|
66
68
|
};
|
|
67
69
|
// No config reload in single-source mode: there's exactly one file to serve, and
|
|
68
70
|
// nothing to add or remove. neon.ts hot-reload is config-mode only.
|
|
69
|
-
await runSupervisor([unit]
|
|
71
|
+
await runSupervisor([unit], {
|
|
72
|
+
...(skipped ? { envNote: skipped.reason } : {}),
|
|
73
|
+
});
|
|
70
74
|
};
|
|
71
75
|
/**
|
|
72
76
|
* Multi-function mode: serve every function declared in neon.ts. Requires a neon.ts
|
|
@@ -85,11 +89,12 @@ const runFromConfig = async (props) => {
|
|
|
85
89
|
throw new Error('neon.ts has no functions to serve. Add at least one under ' +
|
|
86
90
|
'`preview.functions`, or pass --source <path>.');
|
|
87
91
|
}
|
|
88
|
-
const neonEnv = await resolveDevEnv({
|
|
92
|
+
const { vars: neonEnv, skipped } = await resolveDevEnv({
|
|
89
93
|
cwd: process.cwd(),
|
|
90
94
|
...(props.projectId ? { projectId: props.projectId } : {}),
|
|
91
95
|
...(branchId ? { branchId } : {}),
|
|
92
96
|
...(props.apiKey ? { apiKey: props.apiKey } : {}),
|
|
97
|
+
...(props.apiHost ? { apiHost: props.apiHost } : {}),
|
|
93
98
|
});
|
|
94
99
|
const units = planFunctionsToUnits(functions, neonEnv, DEFAULT_PORT_BASE);
|
|
95
100
|
// Re-derive the units from neon.ts on demand so the config watcher can hot-add/remove
|
|
@@ -102,7 +107,10 @@ const runFromConfig = async (props) => {
|
|
|
102
107
|
return null;
|
|
103
108
|
return planFunctionsToUnits(re.functions, neonEnv, searchBase);
|
|
104
109
|
};
|
|
105
|
-
await runSupervisor(units, {
|
|
110
|
+
await runSupervisor(units, {
|
|
111
|
+
reload: { configPath, replan },
|
|
112
|
+
...(skipped ? { envNote: skipped.reason } : {}),
|
|
113
|
+
});
|
|
106
114
|
};
|
|
107
115
|
/**
|
|
108
116
|
* Map a list of {@link PlannedFunction}s to {@link ServedUnit}s, coordinating the search
|
|
@@ -174,6 +182,7 @@ const plannedToUnit = (fn, branchEnv, searchBase) => {
|
|
|
174
182
|
bundleDir: join(process.cwd(), 'node_modules', '.neon-dev', fn.slug),
|
|
175
183
|
childEnv,
|
|
176
184
|
label: fn.slug,
|
|
185
|
+
envSummary: { neon: Object.keys(branchEnv), fn: Object.keys(fn.env) },
|
|
177
186
|
// Signature of the function's *own* neon.ts config (NOT the dynamically-chosen search
|
|
178
187
|
// base) so reconcile can tell a real change from a no-op save. A search-mode function
|
|
179
188
|
// re-planned with a different base must hash identically, or it would be needlessly
|
|
@@ -221,7 +230,8 @@ const READY_PATTERN = /neon-dev:ready (\d+)/;
|
|
|
221
230
|
* stayed the same. A function whose config (env/port/portless/source) changed is restarted
|
|
222
231
|
* in place; siblings are untouched.
|
|
223
232
|
*/
|
|
224
|
-
const runSupervisor = async (units,
|
|
233
|
+
const runSupervisor = async (units, options = {}) => {
|
|
234
|
+
const { reload, envNote } = options;
|
|
225
235
|
if (hasPortlessUnit(units)) {
|
|
226
236
|
assertPortlessAvailable();
|
|
227
237
|
}
|
|
@@ -310,7 +320,7 @@ const runSupervisor = async (units, reload) => {
|
|
|
310
320
|
await Promise.all(running.map((r) => stopUnit(r)));
|
|
311
321
|
throw new Error('No function started. See the output above for details.');
|
|
312
322
|
}
|
|
313
|
-
printBanner(running);
|
|
323
|
+
printBanner(running, envNote);
|
|
314
324
|
// Config mode only: watch neon.ts and reconcile the live unit set when it changes.
|
|
315
325
|
// Reconciles are serialized: a burst of saves (editor write-then-format) must not run
|
|
316
326
|
// overlapping diffs against the mutating `running` array. A trailing run coalesces the
|
|
@@ -448,7 +458,10 @@ const reconcileOnce = async (running, replan, ops) => {
|
|
|
448
458
|
await Promise.all(added.map((r) => ops.startUnit(r)));
|
|
449
459
|
for (const r of added) {
|
|
450
460
|
if (r.status === 'ready') {
|
|
451
|
-
|
|
461
|
+
const env = formatEnvSummary(r.unit.envSummary);
|
|
462
|
+
logUnit(r.unit, chalk.green('ready') +
|
|
463
|
+
` ${urlFor(r.boundPort)}` +
|
|
464
|
+
(env ? chalk.dim(` ${env}`) : ''));
|
|
452
465
|
}
|
|
453
466
|
}
|
|
454
467
|
}
|
|
@@ -513,15 +526,13 @@ const spawnSyncCheck = (bin) => {
|
|
|
513
526
|
const writeBundle = async (source, bundleDir) => {
|
|
514
527
|
const files = await bundleEntry(source);
|
|
515
528
|
mkdirSync(bundleDir, { recursive: true });
|
|
516
|
-
//
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
// ESM. (A bare `out.mjs` would also work but breaks the `out.js.map` sourcemap link.)
|
|
520
|
-
writeFileSync(join(bundleDir, 'package.json'), '{"type":"module"}\n');
|
|
529
|
+
// bundleEntry emits `index.mjs` (+ `index.mjs.map`). The `.mjs` extension makes Node load
|
|
530
|
+
// it as ESM directly, so no `package.json` `"type": "module"` marker is needed, and esbuild
|
|
531
|
+
// points the sourcemap link at `index.mjs.map` for us.
|
|
521
532
|
for (const [name, contents] of Object.entries(files)) {
|
|
522
533
|
writeFileSync(join(bundleDir, name), contents);
|
|
523
534
|
}
|
|
524
|
-
return join(bundleDir, '
|
|
535
|
+
return join(bundleDir, 'index.mjs');
|
|
525
536
|
};
|
|
526
537
|
const urlFor = (port) => port === null ? chalk.red('not running') : `http://localhost:${port}`;
|
|
527
538
|
const waitForReady = (child) => new Promise((resolveReady) => {
|
|
@@ -567,7 +578,7 @@ const pipeChildOutput = (child, label) => {
|
|
|
567
578
|
forward('stdout');
|
|
568
579
|
forward('stderr');
|
|
569
580
|
};
|
|
570
|
-
const printBanner = (running) => {
|
|
581
|
+
const printBanner = (running, envNote) => {
|
|
571
582
|
log.info('');
|
|
572
583
|
log.info(chalk.green.bold(' Neon Functions dev server'));
|
|
573
584
|
log.info('');
|
|
@@ -575,9 +586,34 @@ const printBanner = (running) => {
|
|
|
575
586
|
const name = r.unit.label ?? 'function';
|
|
576
587
|
const url = urlFor(r.boundPort);
|
|
577
588
|
log.info(` ${chalk.dim(name.padEnd(20))} ${url}`);
|
|
589
|
+
const env = formatEnvSummary(r.unit.envSummary);
|
|
590
|
+
if (env)
|
|
591
|
+
log.info(` ${' '.repeat(20)} ${chalk.dim(env)}`);
|
|
592
|
+
}
|
|
593
|
+
if (envNote) {
|
|
594
|
+
log.info('');
|
|
595
|
+
log.info(` ${chalk.yellow('!')} ${chalk.dim(`Neon env: ${envNote}`)}`);
|
|
578
596
|
}
|
|
579
597
|
log.info('');
|
|
580
598
|
};
|
|
599
|
+
/**
|
|
600
|
+
* Render a unit's injected env into one transparent line for the banner, e.g.
|
|
601
|
+
* `env: DATABASE_URL, DATABASE_URL_UNPOOLED · neon.ts: RESEND_API_KEY`. Var **names** only
|
|
602
|
+
* (never values — they're secrets). Returns `''` when nothing is injected, so the caller can
|
|
603
|
+
* skip the line. Exported for unit testing.
|
|
604
|
+
*/
|
|
605
|
+
export const formatEnvSummary = (summary) => {
|
|
606
|
+
if (!summary)
|
|
607
|
+
return '';
|
|
608
|
+
const parts = [];
|
|
609
|
+
if (summary.neon.length > 0) {
|
|
610
|
+
parts.push(`env: ${[...summary.neon].sort().join(', ')}`);
|
|
611
|
+
}
|
|
612
|
+
if (summary.fn.length > 0) {
|
|
613
|
+
parts.push(`neon.ts: ${[...summary.fn].sort().join(', ')}`);
|
|
614
|
+
}
|
|
615
|
+
return parts.join(' · ');
|
|
616
|
+
};
|
|
581
617
|
const logUnit = (unit, message) => {
|
|
582
618
|
const prefix = unit.label ? chalk.dim(`[${unit.label}] `) : '';
|
|
583
619
|
log.info(`${prefix}${message}`);
|
package/commands/env.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
1
2
|
import { NEON_ENV_VAR_KEYS } from '@neondatabase/env';
|
|
2
3
|
import { log } from '../log.js';
|
|
3
4
|
import { resolveNeonEnvVars } from '../dev/env.js';
|
|
@@ -5,6 +6,13 @@ import { mergeEnvFile, resolveEnvFilePath } from '../env_file.js';
|
|
|
5
6
|
import { branchIdFromProps, fillSingleProject } from '../utils/enrichers.js';
|
|
6
7
|
export const command = 'env';
|
|
7
8
|
export const describe = "Manage a branch's Neon env variables locally";
|
|
9
|
+
/**
|
|
10
|
+
* Shown (to stderr) when `link` / `checkout` skip the bundled env pull because the user passed
|
|
11
|
+
* `--no-env-pull`. Names the two ways to get the branch's vars without an on-disk file written
|
|
12
|
+
* eagerly: an explicit `neonctl env pull`, or runtime injection via `neon-env run`.
|
|
13
|
+
*/
|
|
14
|
+
export const ENV_PULL_SKIPPED_HINT = 'Skipped env pull (--no-env-pull). Run `neonctl env pull` to write this branch’s env vars ' +
|
|
15
|
+
'(DATABASE_URL, …) into a local .env, or inject them at runtime with `neon-env run -- <your dev command>`.';
|
|
8
16
|
export const builder = (argv) => argv
|
|
9
17
|
.usage('$0 env <sub-command> [options]')
|
|
10
18
|
.options({
|
|
@@ -23,7 +31,9 @@ export const builder = (argv) => argv
|
|
|
23
31
|
},
|
|
24
32
|
})
|
|
25
33
|
.example('$0 env pull', "Write the linked branch's Neon vars into .env.local (or .env if present)")
|
|
26
|
-
.example('$0 env pull --branch preview --file .env.preview', 'Pull a specific branch into a specific file'), (args) =>
|
|
34
|
+
.example('$0 env pull --branch preview --file .env.preview', 'Pull a specific branch into a specific file'), async (args) => {
|
|
35
|
+
await pull(args);
|
|
36
|
+
})
|
|
27
37
|
.demandCommand(1);
|
|
28
38
|
export const handler = (args) => args;
|
|
29
39
|
/** Every OS-level env var name `@neondatabase/env` can emit, used only for reporting. */
|
|
@@ -39,17 +49,62 @@ export const pull = async (props) => {
|
|
|
39
49
|
projectId: props.projectId,
|
|
40
50
|
branchId,
|
|
41
51
|
...(props.apiKey ? { apiKey: props.apiKey } : {}),
|
|
52
|
+
...(props.apiHost ? { apiHost: props.apiHost } : {}),
|
|
42
53
|
...(props.runtimeApi ? { api: props.runtimeApi } : {}),
|
|
43
54
|
});
|
|
44
55
|
const neonVars = pickNeonVars(vars);
|
|
45
56
|
if (Object.keys(neonVars).length === 0) {
|
|
46
57
|
log.info('No Neon env variables to pull for this branch (no DATABASE_URL or ' +
|
|
47
58
|
'enabled Auth / Data API).');
|
|
48
|
-
return;
|
|
59
|
+
return { status: 'empty' };
|
|
49
60
|
}
|
|
50
61
|
const targetPath = resolveEnvFilePath(cwd, props.file);
|
|
51
62
|
const { written } = mergeEnvFile(targetPath, neonVars);
|
|
52
63
|
log.info('Pulled %d Neon variable%s into %s: %s', written.length, written.length === 1 ? '' : 's', targetPath, written.join(', '));
|
|
64
|
+
return { status: 'written', written, file: targetPath };
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Pull a freshly-pinned branch's Neon env vars into a local `.env`, bundled into `link` and
|
|
68
|
+
* `checkout` so the branch-first loop is just *link + checkout* — `env pull` runs for you.
|
|
69
|
+
*
|
|
70
|
+
* On by default; `--no-env-pull` opts out (e.g. when env is injected at runtime via
|
|
71
|
+
* `neon-env run` / `neon dev`, or to keep secrets out of the working tree). The pin is the
|
|
72
|
+
* command's primary effect and has already succeeded by the time this runs, so a pull failure
|
|
73
|
+
* degrades to a warning rather than failing the command. Returns what happened so
|
|
74
|
+
* `link --agent` can fold an accurate note into its JSON message.
|
|
75
|
+
*/
|
|
76
|
+
export const autoPullEnvAfterPin = async (props) => {
|
|
77
|
+
if (!props.envPull) {
|
|
78
|
+
log.info(chalk.dim(ENV_PULL_SKIPPED_HINT));
|
|
79
|
+
return { status: 'skipped' };
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
return await pull(props);
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
86
|
+
log.warning('Branch pinned, but pulling its Neon env vars failed: %s\n' +
|
|
87
|
+
'Run `neonctl env pull` once resolved (e.g. `neonctl deploy` if a declared service ' +
|
|
88
|
+
'is missing), or inject them at runtime with `neon-env run -- <your dev command>`.', message);
|
|
89
|
+
return { status: 'failed', message };
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* Render the one-line env-pull note appended to `link --agent`'s JSON `message`, so an agent
|
|
94
|
+
* reading the structured output knows whether its branch env is already on disk.
|
|
95
|
+
*/
|
|
96
|
+
export const renderAgentPullNote = (result) => {
|
|
97
|
+
switch (result.status) {
|
|
98
|
+
case 'written':
|
|
99
|
+
return ` Pulled ${result.written.length} Neon env var${result.written.length === 1 ? '' : 's'} into ${result.file}.`;
|
|
100
|
+
case 'empty':
|
|
101
|
+
return ' No Neon env vars to pull for this branch yet.';
|
|
102
|
+
case 'skipped':
|
|
103
|
+
return (' Skipped env pull (--no-env-pull); run `neonctl env pull` later, ' +
|
|
104
|
+
'or inject env at runtime with `neon-env run -- <your dev command>`.');
|
|
105
|
+
case 'failed':
|
|
106
|
+
return ` Could not pull env vars (${result.message}); run \`neonctl env pull\` once resolved.`;
|
|
107
|
+
}
|
|
53
108
|
};
|
|
54
109
|
/**
|
|
55
110
|
* Keep only the recognized Neon variables from the resolved set, so a stray inherited
|
package/commands/index.js
CHANGED
|
@@ -21,6 +21,7 @@ import * as dev from './dev.js';
|
|
|
21
21
|
import * as config from './config.js';
|
|
22
22
|
import * as deploy from './deploy.js';
|
|
23
23
|
import * as env from './env.js';
|
|
24
|
+
import * as bucket from './bucket.js';
|
|
24
25
|
export default [
|
|
25
26
|
auth,
|
|
26
27
|
users,
|
|
@@ -45,4 +46,5 @@ export default [
|
|
|
45
46
|
config,
|
|
46
47
|
deploy,
|
|
47
48
|
env,
|
|
49
|
+
bucket,
|
|
48
50
|
];
|