neon 2.39.1 → 2.42.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 +113 -5
- package/dist/analytics.js +2 -4
- package/dist/auth.js +29 -0
- package/dist/commands/auth.js +61 -14
- package/dist/commands/config.js +134 -55
- package/dist/commands/index.js +2 -0
- package/dist/commands/profile.js +123 -0
- package/dist/config.js +24 -6
- package/dist/config_template.js +242 -0
- package/dist/context.js +19 -1
- package/dist/index.js +5 -0
- package/dist/profiles.js +190 -0
- package/dist/utils/enrichers.js +1 -3
- package/dist/utils/service_picker.js +64 -0
- package/package.json +6 -6
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import prompts from "prompts";
|
|
4
|
+
import { revokeToken } from "../auth.js";
|
|
5
|
+
import { isCi } from "../env.js";
|
|
6
|
+
import { log } from "../log.js";
|
|
7
|
+
import { DEFAULT_PROFILE, listProfiles, onlyDefaultRemains, profilesFilePath, readProfiles, resolveProfile, selectProfileName, } from "../profiles.js";
|
|
8
|
+
import { writer } from "../writer.js";
|
|
9
|
+
export const command = "profile";
|
|
10
|
+
export const aliases = ["profiles"];
|
|
11
|
+
export const describe = "Manage named sets of Neon credentials";
|
|
12
|
+
export const builder = (argv) => argv
|
|
13
|
+
.usage("$0 profile <sub-command> [options]")
|
|
14
|
+
.command("list", "List profiles, the account each holds, and where its credentials live", (y) => y, async (args) => await list(args))
|
|
15
|
+
.command("remove <name>", "Revoke a profile's token and remove it", (y) => y
|
|
16
|
+
.positional("name", {
|
|
17
|
+
describe: "Profile to remove",
|
|
18
|
+
type: "string",
|
|
19
|
+
demandOption: true,
|
|
20
|
+
})
|
|
21
|
+
.option("yes", {
|
|
22
|
+
alias: "y",
|
|
23
|
+
describe: "Skip the confirmation prompt",
|
|
24
|
+
type: "boolean",
|
|
25
|
+
default: false,
|
|
26
|
+
}), async (args) => await remove(args))
|
|
27
|
+
.demandCommand(1, "Run `neon profile --help` to see the subcommands.");
|
|
28
|
+
export const handler = (_args) => {
|
|
29
|
+
/* subcommands only */
|
|
30
|
+
};
|
|
31
|
+
const list = async (props) => {
|
|
32
|
+
const active = selectProfileName(props.profile);
|
|
33
|
+
const rows = listProfiles(props.configDir).map((p) => ({
|
|
34
|
+
active: p.name === active ? "*" : "",
|
|
35
|
+
name: p.name,
|
|
36
|
+
account: p.label ?? p.userId ?? "-",
|
|
37
|
+
credentials: p.credentialsPath,
|
|
38
|
+
signedIn: existsSync(p.credentialsPath) ? "yes" : "no",
|
|
39
|
+
}));
|
|
40
|
+
writer(props).end(rows, {
|
|
41
|
+
title: "Profiles",
|
|
42
|
+
fields: ["active", "name", "account", "signedIn", "credentials"],
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
const remove = async (props) => {
|
|
46
|
+
const { name } = props;
|
|
47
|
+
// Resolve before touching anything: an unknown name must fail having deleted nothing.
|
|
48
|
+
const profile = resolveProfile(props.configDir, name);
|
|
49
|
+
if (!props.yes) {
|
|
50
|
+
if (isCi()) {
|
|
51
|
+
throw new Error("Refusing to remove a profile without confirmation in CI. Pass --yes.");
|
|
52
|
+
}
|
|
53
|
+
const who = profile.label ?? profile.userId ?? "unknown account";
|
|
54
|
+
const { ok } = await prompts({
|
|
55
|
+
type: "confirm",
|
|
56
|
+
name: "ok",
|
|
57
|
+
message: `Remove profile "${name}" (${who})?`,
|
|
58
|
+
initial: false,
|
|
59
|
+
});
|
|
60
|
+
if (!ok) {
|
|
61
|
+
log.info("Cancelled.");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// 1. Revoke upstream, so the token dies rather than merely becoming unreachable by us.
|
|
66
|
+
// Best-effort: a profile is often removed precisely because its access already broke.
|
|
67
|
+
const revoked = await revokeStoredToken(profile.credentialsPath, props);
|
|
68
|
+
log.info(revoked
|
|
69
|
+
? "Revoked the OAuth token"
|
|
70
|
+
: "Could not revoke the OAuth token — removing locally anyway");
|
|
71
|
+
// 2. Delete the credentials file only if we created it. A profile pointing outside the
|
|
72
|
+
// config directory was adopted from elsewhere; unlink it and say so, because the
|
|
73
|
+
// secret is still on disk and silence would imply otherwise.
|
|
74
|
+
if (existsSync(profile.credentialsPath)) {
|
|
75
|
+
if (isInsideConfigDir(props.configDir, profile.credentialsPath)) {
|
|
76
|
+
rmSync(profile.credentialsPath);
|
|
77
|
+
log.info("Deleted %s", profile.credentialsPath);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
log.info("Left %s on disk — not created by neon", profile.credentialsPath);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// 3. Drop the entry, and the file once nothing but DEFAULT is left — the mirror image
|
|
84
|
+
// of creating it lazily, so a single-account install ends up with no profiles.json.
|
|
85
|
+
const path = profilesFilePath(props.configDir);
|
|
86
|
+
const file = readProfiles(props.configDir);
|
|
87
|
+
if (file?.profiles[name]) {
|
|
88
|
+
delete file.profiles[name];
|
|
89
|
+
if (onlyDefaultRemains(file)) {
|
|
90
|
+
rmSync(path);
|
|
91
|
+
log.info('Removed "%s" — no profiles left, deleted %s', name, path);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
writeFileSync(path, `${JSON.stringify(file, null, 2)}\n`, {
|
|
95
|
+
mode: 0o600,
|
|
96
|
+
});
|
|
97
|
+
log.info('Removed "%s" from %s', name, path);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
else if (name === DEFAULT_PROFILE) {
|
|
101
|
+
log.info("Signed out of DEFAULT");
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
const revokeStoredToken = async (credentialsPath, props) => {
|
|
105
|
+
if (!existsSync(credentialsPath))
|
|
106
|
+
return false;
|
|
107
|
+
let tokenSet;
|
|
108
|
+
try {
|
|
109
|
+
tokenSet = JSON.parse(readFileSync(credentialsPath, "utf8"));
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
return await revokeToken({
|
|
115
|
+
oauthHost: props.oauthHost,
|
|
116
|
+
clientId: props.clientId,
|
|
117
|
+
...(props.allowUnsafeTls
|
|
118
|
+
? { allowUnsafeTls: props.allowUnsafeTls }
|
|
119
|
+
: {}),
|
|
120
|
+
}, tokenSet);
|
|
121
|
+
};
|
|
122
|
+
/** Whether a credentials file is one the CLI created, rather than an adopted path. */
|
|
123
|
+
const isInsideConfigDir = (configDir, file) => `${resolve(file)}/`.startsWith(`${resolve(configDir)}/`);
|
package/dist/config.js
CHANGED
|
@@ -1,11 +1,29 @@
|
|
|
1
1
|
import { existsSync, mkdirSync } from "node:fs";
|
|
2
|
-
import {
|
|
3
|
-
import { join } from "node:path";
|
|
2
|
+
import { configDir, resolveConfigFile } from "@neon/config/paths";
|
|
4
3
|
import { isCi } from "./env.js";
|
|
5
4
|
export const CREDENTIALS_FILE = "credentials.json";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Default for `--config-dir`: `$XDG_CONFIG_HOME/neon`, else `~/.config/neon`.
|
|
7
|
+
*
|
|
8
|
+
* The directory was called `neonctl` until the CLI was renamed. An existing one is still
|
|
9
|
+
* read — see {@link credentialsPath} — but it is never written to, moved, or deleted.
|
|
10
|
+
*/
|
|
11
|
+
export const defaultDir = configDir();
|
|
12
|
+
/**
|
|
13
|
+
* Where this invocation's `credentials.json` lives.
|
|
14
|
+
*
|
|
15
|
+
* When `--config-dir` was left at its default, an existing file in the legacy `neonctl`
|
|
16
|
+
* directory is used **in place**: an install that predates the rename keeps working, and
|
|
17
|
+
* its credentials are never duplicated into a second location where one copy could go
|
|
18
|
+
* stale while another tool still reads it.
|
|
19
|
+
*
|
|
20
|
+
* A `--config-dir` the user actually passed is used exactly as given. Falling back out of
|
|
21
|
+
* an explicitly chosen directory would defeat the reason for choosing it — a CI run
|
|
22
|
+
* pointed at a scratch directory must never pick up a developer's real credentials.
|
|
23
|
+
*/
|
|
24
|
+
export const credentialsPath = (dir) => resolveConfigFile(CREDENTIALS_FILE, dir === defaultDir ? {} : { dir }).path;
|
|
25
|
+
export const ensureConfigDir = ({ "config-dir": configDirArg, "force-auth": forceAuth, }) => {
|
|
26
|
+
if (!existsSync(configDirArg) && (!isCi() || forceAuth)) {
|
|
27
|
+
mkdirSync(configDirArg, { recursive: true });
|
|
10
28
|
}
|
|
11
29
|
};
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The published npm packages a `neon.ts` project needs — the `@neon/*` org names.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ These ship to users the next time `neonctl` is released, so do NOT release
|
|
5
|
+
* neonctl until `@neon/config` and `@neon/env` are published to npm — otherwise
|
|
6
|
+
* `config init` would install packages that don't exist yet. (The libraries are
|
|
7
|
+
* mid-migration from `@neondatabase/*`; track their publish before cutting a CLI
|
|
8
|
+
* release.)
|
|
9
|
+
*/
|
|
10
|
+
export const CONFIG_PACKAGE = "@neon/config";
|
|
11
|
+
export const ENV_PACKAGE = "@neon/env";
|
|
12
|
+
export const REQUIRED_PACKAGES = [CONFIG_PACKAGE, ENV_PACKAGE];
|
|
13
|
+
/**
|
|
14
|
+
* A Neon service `config init` can declare in the `neon.ts` it scaffolds, spelled the way a
|
|
15
|
+
* user types it in `--services`. Kebab-case rather than the `neon.ts` field names (`aiGateway`,
|
|
16
|
+
* `buckets`) so the flag reads like a flag; {@link renderNeonConfig} owns the mapping.
|
|
17
|
+
*
|
|
18
|
+
* Postgres is absent because every branch has it, and `dataApi` is absent because enabling it
|
|
19
|
+
* with the default `authProvider: "neon"` requires `auth` — a pairing the picker would have to
|
|
20
|
+
* enforce rather than offer.
|
|
21
|
+
*/
|
|
22
|
+
export const NEON_SERVICES = [
|
|
23
|
+
"auth",
|
|
24
|
+
"functions",
|
|
25
|
+
"storage",
|
|
26
|
+
"ai-gateway",
|
|
27
|
+
];
|
|
28
|
+
/** `--services none`: declare nothing, i.e. scaffold the bare starter policy. */
|
|
29
|
+
export const NO_SERVICES = "none";
|
|
30
|
+
/** Slug, display name, and source path of the function scaffolded for `functions`. */
|
|
31
|
+
export const FUNCTION_SLUG = "hello";
|
|
32
|
+
export const FUNCTION_NAME = "Hello World";
|
|
33
|
+
export const FUNCTION_FILENAME = "hello.ts";
|
|
34
|
+
/** Name of the bucket scaffolded for `storage`. */
|
|
35
|
+
export const BUCKET_NAME = "assets";
|
|
36
|
+
/**
|
|
37
|
+
* Parse a `--services` value into a canonical service list: comma-separated
|
|
38
|
+
* {@link NEON_SERVICES} names, or {@link NO_SERVICES} on its own for none.
|
|
39
|
+
*
|
|
40
|
+
* Unknown names are rejected here rather than silently dropped — a typo'd service would
|
|
41
|
+
* otherwise scaffold a policy missing exactly the service the user asked for. The result is
|
|
42
|
+
* deduplicated and ordered by {@link NEON_SERVICES} so the rendered file doesn't depend on the
|
|
43
|
+
* order they were typed in.
|
|
44
|
+
*/
|
|
45
|
+
export const parseServices = (raw) => {
|
|
46
|
+
const names = raw
|
|
47
|
+
.split(",")
|
|
48
|
+
.map((name) => name.trim())
|
|
49
|
+
.filter((name) => name !== "");
|
|
50
|
+
if (names.includes(NO_SERVICES)) {
|
|
51
|
+
if (names.length > 1) {
|
|
52
|
+
throw new Error(`--services ${NO_SERVICES} cannot be combined with other services.`);
|
|
53
|
+
}
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
const unknown = names.filter((name) => !NEON_SERVICES.includes(name));
|
|
57
|
+
if (unknown.length > 0) {
|
|
58
|
+
throw new Error(`Unknown service${unknown.length === 1 ? "" : "s"} ${unknown.join(", ")}. ` +
|
|
59
|
+
`Supported values: ${NEON_SERVICES.join(", ")}, ${NO_SERVICES}.`);
|
|
60
|
+
}
|
|
61
|
+
return NEON_SERVICES.filter((service) => names.includes(service));
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* One indentation level in the emitted `neon.ts`. Two spaces, which is what every renderer
|
|
65
|
+
* here produces and what `config_template.format.test.ts` holds them to.
|
|
66
|
+
*/
|
|
67
|
+
const INDENT = " ";
|
|
68
|
+
/**
|
|
69
|
+
* Prefix each line with `level` indentation levels. Nesting is expressed as a number at the
|
|
70
|
+
* one place that knows the structure, rather than as literal spaces at every push site — a
|
|
71
|
+
* miscounted space is otherwise invisible in review and only shows up in a user's file.
|
|
72
|
+
*/
|
|
73
|
+
const at = (level, ...lines) => lines.map((line) => INDENT.repeat(level) + line);
|
|
74
|
+
/** Wrap `body` in an object-literal block named `key`, indented from `level`. */
|
|
75
|
+
const block = (level, key, body) => [
|
|
76
|
+
...at(level, `${key}: {`),
|
|
77
|
+
...body,
|
|
78
|
+
...at(level, "},"),
|
|
79
|
+
];
|
|
80
|
+
/** The `preview` block for the selected services, or "" when none of them is a preview feature. */
|
|
81
|
+
const renderPreview = (services) => {
|
|
82
|
+
const lines = [];
|
|
83
|
+
if (services.includes("ai-gateway")) {
|
|
84
|
+
lines.push(...at(2, "aiGateway: true,"));
|
|
85
|
+
}
|
|
86
|
+
if (services.includes("functions")) {
|
|
87
|
+
lines.push(...block(2, "functions", [
|
|
88
|
+
...at(3, `${FUNCTION_SLUG}: { name: "${FUNCTION_NAME}", source: "./${FUNCTION_FILENAME}" },`),
|
|
89
|
+
]));
|
|
90
|
+
}
|
|
91
|
+
if (services.includes("storage")) {
|
|
92
|
+
lines.push(...block(2, "buckets", [
|
|
93
|
+
...at(3, `// "private" is the default; use "public_read" for anonymous reads`, `${BUCKET_NAME}: { access: "private" },`),
|
|
94
|
+
]));
|
|
95
|
+
}
|
|
96
|
+
if (lines.length === 0) {
|
|
97
|
+
return "";
|
|
98
|
+
}
|
|
99
|
+
return `${block(1, "preview", lines).join("\n")}\n`;
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* Render the `neon.ts` policy `config init` writes. With no services this is the starter
|
|
103
|
+
* policy — an explicit `auth: false`, no `preview` block, and a `branch` closure that gives
|
|
104
|
+
* new non-default branches a 7-day TTL — so the picker's "skip everything" answer and a
|
|
105
|
+
* non-interactive run produce the identical file.
|
|
106
|
+
*/
|
|
107
|
+
export const renderNeonConfig = (services) => `import { defineConfig } from "${CONFIG_PACKAGE}/v1";
|
|
108
|
+
|
|
109
|
+
export default defineConfig({
|
|
110
|
+
// Declare your Neon services here
|
|
111
|
+
auth: ${services.includes("auth")},
|
|
112
|
+
${renderPreview(services)} // Branch policy: per-branch tuning
|
|
113
|
+
branch: (branch) => {
|
|
114
|
+
if (branch.isDefault) {
|
|
115
|
+
// Default branch: no overrides, uses project defaults
|
|
116
|
+
return {};
|
|
117
|
+
}
|
|
118
|
+
if (!branch.exists) {
|
|
119
|
+
// New non-default branches: auto-expire
|
|
120
|
+
// Run \`neon checkout <name>\` to create a new branch with these settings
|
|
121
|
+
return { ttl: "7d" };
|
|
122
|
+
}
|
|
123
|
+
// Existing branch: no changes
|
|
124
|
+
return {};
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
`;
|
|
128
|
+
/** Render a scalar the way it has to appear in TypeScript source. */
|
|
129
|
+
const renderScalar = (value) => typeof value === "string" ? `"${value}"` : String(value);
|
|
130
|
+
/**
|
|
131
|
+
* Render an object key. Live names are not identifiers: a Neon bucket may be called
|
|
132
|
+
* `smoke-uploads`, which as a bare key is a subtraction and a syntax error. Anything that
|
|
133
|
+
* isn't a plain identifier gets quoted.
|
|
134
|
+
*/
|
|
135
|
+
const renderKey = (name) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name);
|
|
136
|
+
/**
|
|
137
|
+
* The `branch` closure for a policy seeded from live state, or "" when the branch carries no
|
|
138
|
+
* tuning worth declaring.
|
|
139
|
+
*
|
|
140
|
+
* `protected` is read but never declared: it is a fact about one branch, while a policy
|
|
141
|
+
* `protected` applies to every branch the policy runs against. It becomes a comment instead.
|
|
142
|
+
*/
|
|
143
|
+
const renderSeededBranch = (view) => {
|
|
144
|
+
const settings = [];
|
|
145
|
+
if (view.branch?.parent !== undefined) {
|
|
146
|
+
settings.push(...at(2, `parent: ${renderScalar(view.branch.parent)},`));
|
|
147
|
+
}
|
|
148
|
+
if (view.branch?.ttl !== undefined) {
|
|
149
|
+
settings.push(...at(2, `ttl: ${renderScalar(view.branch.ttl)},`));
|
|
150
|
+
}
|
|
151
|
+
const compute = view.branch?.postgres?.computeSettings;
|
|
152
|
+
const computeFields = Object.entries(compute ?? {}).filter(([, value]) => value !== undefined);
|
|
153
|
+
if (computeFields.length > 0) {
|
|
154
|
+
settings.push(...block(2, "postgres", [
|
|
155
|
+
...block(3, "computeSettings", computeFields.flatMap(([field, value]) => at(4, `${field}: ${renderScalar(value)},`))),
|
|
156
|
+
]));
|
|
157
|
+
}
|
|
158
|
+
if (settings.length === 0) {
|
|
159
|
+
return "";
|
|
160
|
+
}
|
|
161
|
+
// An arrow returning an object literal, so the closing line is `}),` rather than `},`.
|
|
162
|
+
return `${[...at(1, "branch: () => ({"), ...settings, ...at(1, "}),")].join("\n")}\n`;
|
|
163
|
+
};
|
|
164
|
+
/** The `preview` block for a policy seeded from live state. */
|
|
165
|
+
const renderSeededPreview = (view, branchName) => {
|
|
166
|
+
const lines = [];
|
|
167
|
+
const buckets = Object.entries(view.preview?.buckets ?? {});
|
|
168
|
+
if (buckets.length > 0) {
|
|
169
|
+
lines.push(...block(2, "buckets", buckets.flatMap(([name, bucket]) => at(3, `${renderKey(name)}: { access: "${bucket.access}" },`))));
|
|
170
|
+
}
|
|
171
|
+
// A deployed function cannot be declared from live state: `source` is a path in the
|
|
172
|
+
// user's project and the branch only knows the uploaded bundle. Listing the slugs as a
|
|
173
|
+
// commented-out block is the most a read-back can honestly produce.
|
|
174
|
+
const functions = Object.entries(view.preview?.functions ?? {});
|
|
175
|
+
if (functions.length > 0) {
|
|
176
|
+
lines.push(...at(2, `// ${branchName} has ${functions.length} deployed function${functions.length === 1 ? "" : "s"}.`, "// Declaring one needs the local source path, which the branch does not know:", "// functions: {", ...functions.map(([slug, fn]) => `// ${renderKey(slug)}: { name: "${fn.name}", source: "./${slug}.ts" },`), "// },"));
|
|
177
|
+
}
|
|
178
|
+
if (lines.length === 0) {
|
|
179
|
+
return "";
|
|
180
|
+
}
|
|
181
|
+
return `${block(1, "preview", lines).join("\n")}\n`;
|
|
182
|
+
};
|
|
183
|
+
/**
|
|
184
|
+
* Render a `neon.ts` from a branch's live state (`config init --from-branch`).
|
|
185
|
+
*
|
|
186
|
+
* Only what the branch can actually report is declared. Three things are deliberately
|
|
187
|
+
* absent, each for its own reason:
|
|
188
|
+
*
|
|
189
|
+
* - **The AI Gateway** has no branch-level enabled state to read — it is always available and
|
|
190
|
+
* credential-gated — so `pullConfig` cannot tell whether a policy would enable it.
|
|
191
|
+
* - **Functions** cannot round-trip (no `source` path on the remote); they are listed as a
|
|
192
|
+
* commented-out block.
|
|
193
|
+
* - **`protected`** is branch state rather than policy intent, so it is reported as a comment.
|
|
194
|
+
*
|
|
195
|
+
* A branch with nothing to report (no services, no tuning) renders the starter policy rather
|
|
196
|
+
* than an empty `defineConfig({})`: seeding found nothing, and the caller says so.
|
|
197
|
+
*/
|
|
198
|
+
export const renderNeonConfigFromView = (view, branchName) => {
|
|
199
|
+
const services = [
|
|
200
|
+
view.auth ? " auth: true," : "",
|
|
201
|
+
view.dataApi ? " dataApi: true," : "",
|
|
202
|
+
].filter((line) => line !== "");
|
|
203
|
+
const preview = renderSeededPreview(view, branchName);
|
|
204
|
+
const branch = renderSeededBranch(view);
|
|
205
|
+
if (services.length === 0 && preview === "" && branch === "") {
|
|
206
|
+
return { source: renderNeonConfig([]), seeded: false };
|
|
207
|
+
}
|
|
208
|
+
const protectedNote = view.branch?.protected
|
|
209
|
+
? `// ${branchName} is protected on Neon. Not declared here: a policy \`protected\` would\n// apply to every branch this policy is applied to.\n`
|
|
210
|
+
: "";
|
|
211
|
+
const body = [
|
|
212
|
+
...services,
|
|
213
|
+
...(preview === "" ? [] : [preview.trimEnd()]),
|
|
214
|
+
...(branch === "" ? [] : [branch.trimEnd()]),
|
|
215
|
+
].join("\n");
|
|
216
|
+
return {
|
|
217
|
+
source: `import { defineConfig } from "${CONFIG_PACKAGE}/v1";
|
|
218
|
+
|
|
219
|
+
// Seeded by \`neon config init --from-branch\` from ${branchName}.
|
|
220
|
+
// The AI Gateway is not readable from a branch (always available, credential-gated), so add
|
|
221
|
+
// \`preview: { aiGateway: true }\` if the policy should declare it.
|
|
222
|
+
${protectedNote}export default defineConfig({
|
|
223
|
+
${body}
|
|
224
|
+
});
|
|
225
|
+
`,
|
|
226
|
+
seeded: true,
|
|
227
|
+
};
|
|
228
|
+
};
|
|
229
|
+
/**
|
|
230
|
+
* The handler written alongside `neon.ts` when `functions` is selected. It has to exist:
|
|
231
|
+
* `FunctionDef.source` is only resolved when `config apply` / `deploy` bundles it, so a
|
|
232
|
+
* declared function with no file on disk fails at deploy time rather than at authoring time.
|
|
233
|
+
*
|
|
234
|
+
* A default-exported function rather than `export default { fetch }`: both are resolved (see
|
|
235
|
+
* `resolveFetchHandler`), and the bare function is less to read and less to get wrong. It is
|
|
236
|
+
* named rather than anonymous so a project's linter has nothing to say about it, and takes no
|
|
237
|
+
* parameter because a scaffold shipping an unused `req` fails a `noUnusedParameters` project.
|
|
238
|
+
*/
|
|
239
|
+
export const FUNCTION_TEMPLATE = `export default async function hello(): Promise<Response> {
|
|
240
|
+
return new Response("Hello from Neon Functions");
|
|
241
|
+
}
|
|
242
|
+
`;
|
package/dist/context.js
CHANGED
|
@@ -29,8 +29,26 @@ export const isCurrentBranchProbe = (args) => args.currentBranch === true &&
|
|
|
29
29
|
* never calls the Neon API. Gated on the exact command path so the global auth
|
|
30
30
|
* middleware and the single-project resolver can skip it (it runs with no API
|
|
31
31
|
* client), mirroring {@link isCurrentBranchProbe}.
|
|
32
|
+
*
|
|
33
|
+
* `--from-branch` is the exception: it seeds the policy from a branch's live state, so it
|
|
34
|
+
* needs both credentials and a resolved project. The raw argv is checked alongside the parsed
|
|
35
|
+
* flag because this runs from middleware that executes before validation, where the parsed
|
|
36
|
+
* value may not be populated yet (the same reason `analytics.ts` scans argv for
|
|
37
|
+
* `--current-branch`).
|
|
38
|
+
*/
|
|
39
|
+
export const isConfigInit = (args) => args._[0] === "config" &&
|
|
40
|
+
args._[1] === "init" &&
|
|
41
|
+
args.fromBranch !== true &&
|
|
42
|
+
!process.argv.includes("--from-branch");
|
|
43
|
+
/**
|
|
44
|
+
* `neon profile …` manages credentials on disk and never calls the Neon API, so the global
|
|
45
|
+
* auth middleware must skip it — mirroring {@link isConfigInit}.
|
|
46
|
+
*
|
|
47
|
+
* More than a nicety: without this, listing your profiles would launch a browser login, and
|
|
48
|
+
* removing a broken profile would demand you sign into it first. Removing a profile whose
|
|
49
|
+
* access has already lapsed is the main reason to remove one.
|
|
32
50
|
*/
|
|
33
|
-
export const
|
|
51
|
+
export const isProfileCommand = (args) => args._[0] === "profile" || args._[0] === "profiles";
|
|
34
52
|
const CONTEXT_FILE = ".neon";
|
|
35
53
|
const GITIGNORE_FILE = ".gitignore";
|
|
36
54
|
const canAccessFile = (file) => {
|
package/dist/index.js
CHANGED
|
@@ -67,6 +67,11 @@ builder = builder
|
|
|
67
67
|
group: "Global options:",
|
|
68
68
|
type: "string",
|
|
69
69
|
default: defaultDir,
|
|
70
|
+
})
|
|
71
|
+
.option("profile", {
|
|
72
|
+
describe: "Named credentials to use, from profiles.json (default: NEON_PROFILE, else DEFAULT)",
|
|
73
|
+
group: "Global options:",
|
|
74
|
+
type: "string",
|
|
70
75
|
})
|
|
71
76
|
.option("force-auth", {
|
|
72
77
|
describe: "Force authentication",
|
package/dist/profiles.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* # Profiles — several Neon accounts in one config directory
|
|
3
|
+
*
|
|
4
|
+
* A profile is **a pointer to a credentials file**. Nothing more. That constraint is what
|
|
5
|
+
* keeps the feature small: there is no mirror, no per-profile directory tree, no persistent
|
|
6
|
+
* "active profile" state to fall out of sync, and no migration.
|
|
7
|
+
*
|
|
8
|
+
* ```
|
|
9
|
+
* ~/.config/neon/
|
|
10
|
+
* ├── credentials.json # this IS the DEFAULT profile, not a copy of it
|
|
11
|
+
* ├── credentials.work.json # created by `neon auth --profile work`
|
|
12
|
+
* └── profiles.json # created only once a second profile exists
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* `profiles.json` maps a name to a path, and the path may point anywhere — which is what
|
|
16
|
+
* makes adopting an existing directory a one-line edit rather than an import command:
|
|
17
|
+
*
|
|
18
|
+
* ```json
|
|
19
|
+
* {
|
|
20
|
+
* "version": 1,
|
|
21
|
+
* "profiles": {
|
|
22
|
+
* "DEFAULT": { "credentials": "credentials.json" },
|
|
23
|
+
* "work": {
|
|
24
|
+
* "credentials": "../neonctl-databricks/credentials.json",
|
|
25
|
+
* "label": "someone@example.com"
|
|
26
|
+
* }
|
|
27
|
+
* }
|
|
28
|
+
* }
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* ## Selection
|
|
32
|
+
*
|
|
33
|
+
* `--profile` → `NEON_PROFILE` → `DEFAULT`. Per invocation, like `AWS_PROFILE`; there is no
|
|
34
|
+
* `profile use` command, so nothing persists that could disagree with what you typed.
|
|
35
|
+
*
|
|
36
|
+
* ## Compatibility
|
|
37
|
+
*
|
|
38
|
+
* An install with no `profiles.json` is already a valid `DEFAULT`-only state: `DEFAULT`
|
|
39
|
+
* resolves to `credentials.json` in the config directory (including an existing one in the
|
|
40
|
+
* legacy `neonctl` directory — see `@neon/config/paths`). Nothing is created until a second
|
|
41
|
+
* profile is, and nothing is ever moved.
|
|
42
|
+
*/
|
|
43
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
44
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
45
|
+
import { resolveConfigFile } from "@neon/config/paths";
|
|
46
|
+
import { credentialsPath, defaultDir } from "./config.js";
|
|
47
|
+
import { log } from "./log.js";
|
|
48
|
+
export const PROFILES_FILE = "profiles.json";
|
|
49
|
+
/** The implicit profile. Backed by plain `credentials.json`, with or without a profiles file. */
|
|
50
|
+
export const DEFAULT_PROFILE = "DEFAULT";
|
|
51
|
+
/** Profile names become part of a filename, so keep them boring. */
|
|
52
|
+
const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
53
|
+
/** Which profile this invocation should use: `--profile` → `NEON_PROFILE` → `DEFAULT`. */
|
|
54
|
+
export const selectProfileName = (flag, env = process.env) => nonEmpty(flag) ?? nonEmpty(env.NEON_PROFILE) ?? DEFAULT_PROFILE;
|
|
55
|
+
export const assertValidProfileName = (name) => {
|
|
56
|
+
if (!NAME_PATTERN.test(name)) {
|
|
57
|
+
throw new Error(`Invalid profile name "${name}". Use letters, digits, dot, dash or underscore, starting with a letter or digit.`);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
/** Where `profiles.json` lives for this config directory (whether or not it exists yet). */
|
|
61
|
+
export const profilesFilePath = (dir) => resolveConfigFile(PROFILES_FILE, dir === defaultDir ? {} : { dir }).path;
|
|
62
|
+
/**
|
|
63
|
+
* Read `profiles.json`, or `null` when there isn't one — the normal single-account state.
|
|
64
|
+
* A malformed file is reported and treated as absent rather than breaking every command;
|
|
65
|
+
* the worst case is that a named profile is "not found", which is recoverable, whereas
|
|
66
|
+
* throwing here would lock the user out of `neon auth` itself.
|
|
67
|
+
*/
|
|
68
|
+
export const readProfiles = (dir) => {
|
|
69
|
+
const path = profilesFilePath(dir);
|
|
70
|
+
if (!existsSync(path))
|
|
71
|
+
return null;
|
|
72
|
+
try {
|
|
73
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
74
|
+
if (parsed === null ||
|
|
75
|
+
typeof parsed !== "object" ||
|
|
76
|
+
Array.isArray(parsed))
|
|
77
|
+
throw new Error("not an object");
|
|
78
|
+
const profiles = parsed.profiles;
|
|
79
|
+
if (profiles === null ||
|
|
80
|
+
typeof profiles !== "object" ||
|
|
81
|
+
Array.isArray(profiles))
|
|
82
|
+
throw new Error("missing `profiles`");
|
|
83
|
+
return { version: 1, profiles };
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
log.warning("Ignoring malformed %s: %s", path, err instanceof Error ? err.message : String(err));
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
/** Resolve a profile to an absolute credentials path. Throws when a named profile is unknown. */
|
|
91
|
+
export const resolveProfile = (dir, name) => {
|
|
92
|
+
const file = readProfiles(dir);
|
|
93
|
+
const entry = file?.profiles[name];
|
|
94
|
+
if (entry) {
|
|
95
|
+
return {
|
|
96
|
+
name,
|
|
97
|
+
credentialsPath: resolveEntryPath(dir, entry.credentials),
|
|
98
|
+
...(entry.label ? { label: entry.label } : {}),
|
|
99
|
+
...(entry.userId ? { userId: entry.userId } : {}),
|
|
100
|
+
declared: true,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
// DEFAULT works with no profiles.json at all, and keeps working when one exists but
|
|
104
|
+
// doesn't mention it — that is the pre-profiles behaviour, unchanged.
|
|
105
|
+
if (name === DEFAULT_PROFILE) {
|
|
106
|
+
return {
|
|
107
|
+
name,
|
|
108
|
+
credentialsPath: credentialsPath(dir),
|
|
109
|
+
declared: false,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const known = file
|
|
113
|
+
? Object.keys(file.profiles).join(", ")
|
|
114
|
+
: DEFAULT_PROFILE;
|
|
115
|
+
throw new Error(`Unknown profile "${name}". Known profiles: ${known}. Create it with \`neon auth --profile ${name}\`.`);
|
|
116
|
+
};
|
|
117
|
+
/** Default location for a new named profile's credentials file. */
|
|
118
|
+
export const newProfileCredentialsPath = (dir, name) => resolve(dir, `credentials.${name}.json`);
|
|
119
|
+
/**
|
|
120
|
+
* Record a profile, creating `profiles.json` if this is the first named one.
|
|
121
|
+
*
|
|
122
|
+
* When the file is created, `DEFAULT` is written explicitly and pointed at wherever
|
|
123
|
+
* `credentials.json` actually is. That matters for an install predating the directory
|
|
124
|
+
* rename: `profiles.json` is created in `neon/` while the credentials are still in
|
|
125
|
+
* `neonctl/`, so `DEFAULT` is recorded as `../neonctl/credentials.json` rather than a
|
|
126
|
+
* relative name that would resolve to a file that isn't there.
|
|
127
|
+
*/
|
|
128
|
+
export const upsertProfile = (dir, name, entry) => {
|
|
129
|
+
assertValidProfileName(name);
|
|
130
|
+
const path = profilesFilePath(dir);
|
|
131
|
+
const file = readProfiles(dir) ?? {
|
|
132
|
+
version: 1,
|
|
133
|
+
profiles: {
|
|
134
|
+
[DEFAULT_PROFILE]: {
|
|
135
|
+
credentials: relativeToProfiles(path, credentialsPath(dir)),
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
file.profiles[name] = {
|
|
140
|
+
credentials: relativeToProfiles(path, entry.credentials),
|
|
141
|
+
...(entry.label ? { label: entry.label } : {}),
|
|
142
|
+
...(entry.userId ? { userId: entry.userId } : {}),
|
|
143
|
+
};
|
|
144
|
+
writeProfiles(path, file);
|
|
145
|
+
};
|
|
146
|
+
/** Remove an entry. Returns false when it wasn't there. */
|
|
147
|
+
export const removeProfileEntry = (dir, name) => {
|
|
148
|
+
const path = profilesFilePath(dir);
|
|
149
|
+
const file = readProfiles(dir);
|
|
150
|
+
if (!file?.profiles[name])
|
|
151
|
+
return false;
|
|
152
|
+
delete file.profiles[name];
|
|
153
|
+
writeProfiles(path, file);
|
|
154
|
+
return true;
|
|
155
|
+
};
|
|
156
|
+
/**
|
|
157
|
+
* True when only `DEFAULT` is left, so `profiles.json` no longer earns its place. Mirrors
|
|
158
|
+
* lazy creation: a single-account install has no profiles file, before or after.
|
|
159
|
+
*/
|
|
160
|
+
export const onlyDefaultRemains = (file) => {
|
|
161
|
+
const names = Object.keys(file.profiles);
|
|
162
|
+
return (names.length === 0 ||
|
|
163
|
+
(names.length === 1 && names[0] === DEFAULT_PROFILE));
|
|
164
|
+
};
|
|
165
|
+
export const listProfiles = (dir) => {
|
|
166
|
+
const file = readProfiles(dir);
|
|
167
|
+
if (!file)
|
|
168
|
+
return [resolveProfile(dir, DEFAULT_PROFILE)];
|
|
169
|
+
const names = Object.keys(file.profiles);
|
|
170
|
+
if (!names.includes(DEFAULT_PROFILE))
|
|
171
|
+
names.unshift(DEFAULT_PROFILE);
|
|
172
|
+
return names.map((name) => resolveProfile(dir, name));
|
|
173
|
+
};
|
|
174
|
+
const writeProfiles = (path, file) => {
|
|
175
|
+
writeFileSync(path, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
|
|
176
|
+
};
|
|
177
|
+
const resolveEntryPath = (dir, entry) => isAbsolute(entry) ? entry : resolve(profilesDir(dir), entry);
|
|
178
|
+
/** `profiles.json` may sit in the legacy directory, so entries resolve against its own dir. */
|
|
179
|
+
const profilesDir = (dir) => resolve(profilesFilePath(dir), "..");
|
|
180
|
+
/** Keep entries relative when they sit near `profiles.json`; absolute paths stay absolute. */
|
|
181
|
+
const relativeToProfiles = (profilesPath, target) => {
|
|
182
|
+
const rel = relative(resolve(profilesPath, ".."), target);
|
|
183
|
+
return rel && !isAbsolute(rel) ? rel : target;
|
|
184
|
+
};
|
|
185
|
+
function nonEmpty(value) {
|
|
186
|
+
if (typeof value !== "string")
|
|
187
|
+
return undefined;
|
|
188
|
+
const trimmed = value.trim();
|
|
189
|
+
return trimmed === "" ? undefined : trimmed;
|
|
190
|
+
}
|
package/dist/utils/enrichers.js
CHANGED
|
@@ -42,9 +42,7 @@ export const branchIdFromProps = async (props) => {
|
|
|
42
42
|
return props.branchId;
|
|
43
43
|
};
|
|
44
44
|
export const resolveBranchRef = async (props) => {
|
|
45
|
-
const branch =
|
|
46
|
-
? props.branch
|
|
47
|
-
: props.id;
|
|
45
|
+
const branch = typeof props.branch === "string" ? props.branch : props.id;
|
|
48
46
|
const { data } = await props.apiClient.listProjectBranches({
|
|
49
47
|
projectId: props.projectId,
|
|
50
48
|
});
|