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.
@@ -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 { homedir } from "node:os";
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
- export const defaultDir = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "neonctl");
7
- export const ensureConfigDir = ({ "config-dir": configDir, "force-auth": forceAuth, }) => {
8
- if (!existsSync(configDir) && (!isCi() || forceAuth)) {
9
- mkdirSync(configDir, { recursive: true });
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 isConfigInit = (args) => args._[0] === "config" && args._[1] === "init";
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",
@@ -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
+ }
@@ -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 = "branch" in props && typeof props.branch === "string"
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
  });