neon 6.1.0 → 6.2.1
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/dist/_chunks/{env-DRpFX-_d.js → env-CtOH9xsl.js} +2 -1
- package/dist/_chunks/{env_services-DlXECBPi.js → env-N19A7qND.js} +13 -120
- package/dist/commands/auth.js +3 -1
- package/dist/commands/checkout.js +76 -6
- package/dist/commands/config.js +41 -0
- package/dist/commands/dev.js +2 -2
- package/dist/commands/env.js +3 -2
- package/dist/commands/git.js +356 -0
- package/dist/commands/index.js +2 -0
- package/dist/commands/link.js +2 -1
- package/dist/context.js +92 -3
- package/dist/dev/env.js +1 -1
- package/dist/env_services.js +120 -1
- package/dist/utils/branch_name.js +29 -0
- package/dist/utils/git.js +220 -0
- package/dist/utils/hooks.js +230 -0
- package/package.json +6 -6
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { t as __exportAll } from "../_chunks/rolldown-runtime-8H4AJuhK.js";
|
|
2
|
+
import { isCi } from "../env.js";
|
|
3
|
+
import { log } from "../log.js";
|
|
4
|
+
import { CONTEXT_FILE, contextBranch, gitBranchMap, gitBranchMapping, isUnfollowedGitHookSync, readContextFile, setGitBranchMap, setGitBranchMapping, setGitFollow } from "../context.js";
|
|
5
|
+
import { writer } from "../writer.js";
|
|
6
|
+
import { neonSafeBranchName } from "../utils/branch_name.js";
|
|
7
|
+
import { currentGitBranch, gitPull, gitRepoRoot, hasUpstream, installPostCheckoutHook, isGitRepo, isManagedHook, localGitBranches, postCheckoutHookPath, readGitContext, removePostCheckoutHook } from "../utils/git.js";
|
|
8
|
+
import { quoteFlagValue } from "./link.js";
|
|
9
|
+
import { handler as handler$1 } from "./checkout.js";
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import chalk from "chalk";
|
|
13
|
+
import prompts from "prompts";
|
|
14
|
+
//#region src/commands/git.ts
|
|
15
|
+
var git_exports = /* @__PURE__ */ __exportAll({
|
|
16
|
+
aliases: () => aliases,
|
|
17
|
+
builder: () => builder,
|
|
18
|
+
cleanup: () => cleanup,
|
|
19
|
+
command: () => "git",
|
|
20
|
+
describe: () => describe,
|
|
21
|
+
handler: () => handler,
|
|
22
|
+
install: () => install,
|
|
23
|
+
partitionBranchesToPrune: () => partitionBranchesToPrune,
|
|
24
|
+
status: () => status,
|
|
25
|
+
sync: () => sync,
|
|
26
|
+
uninstall: () => uninstall
|
|
27
|
+
});
|
|
28
|
+
const command = "git";
|
|
29
|
+
const describe = "Sync the checked-out Neon branch to your git branch (Preview)";
|
|
30
|
+
const aliases = [];
|
|
31
|
+
const builder = (argv) => argv.usage("$0 git <sub-command> [options]").command("install", "Install a git post-checkout hook that syncs the Neon branch on `git checkout`", (yargs) => yargs.epilogue("Requires a project already linked at the repository root — run `neon link` or `neon checkout <branch>` there first."), (args) => {
|
|
32
|
+
install(args);
|
|
33
|
+
}).command("uninstall", "Remove the git post-checkout hook installed by `git install`", (yargs) => yargs, (args) => {
|
|
34
|
+
uninstall(args);
|
|
35
|
+
}).command("sync", "Check out the Neon branch mapped to the current git branch (run by the hook)", (yargs) => yargs.options({
|
|
36
|
+
"env-pull": {
|
|
37
|
+
describe: "Pull the branch's Neon env vars into a local .env after sync. On by default.",
|
|
38
|
+
type: "boolean",
|
|
39
|
+
default: true
|
|
40
|
+
},
|
|
41
|
+
pull: {
|
|
42
|
+
describe: "Run `git pull --ff-only` before syncing so local files (incl. migration files) match the branch before any checkout.after migration runs. Without the flag, prompts when run manually in a TTY and skips in the hook / CI.",
|
|
43
|
+
type: "boolean"
|
|
44
|
+
},
|
|
45
|
+
quiet: {
|
|
46
|
+
describe: "Suppress sync's own routine status lines (git pull result, detached HEAD notice). Warnings and the delegated checkout's own output are unaffected. Used by the installed git hook.",
|
|
47
|
+
type: "boolean",
|
|
48
|
+
default: false
|
|
49
|
+
}
|
|
50
|
+
}), (args) => sync(args)).command("status", "Show the git context, hook state, and current git → Neon mapping", (yargs) => yargs, (args) => {
|
|
51
|
+
status(args);
|
|
52
|
+
}).command("cleanup", "Prune git → Neon mappings whose local git branch is gone; optionally delete the orphaned Neon branches", (yargs) => yargs.options({
|
|
53
|
+
"prune-neon-branches": {
|
|
54
|
+
describe: "Also delete the orphaned Neon branches (never the default or a protected branch).",
|
|
55
|
+
type: "boolean",
|
|
56
|
+
default: false
|
|
57
|
+
},
|
|
58
|
+
yes: {
|
|
59
|
+
describe: "Skip the confirmation prompt before deleting Neon branches.",
|
|
60
|
+
type: "boolean",
|
|
61
|
+
default: false
|
|
62
|
+
}
|
|
63
|
+
}), (args) => cleanup(args));
|
|
64
|
+
const handler = (args) => args;
|
|
65
|
+
const requireRepoRoot = () => {
|
|
66
|
+
const cwd = process.cwd();
|
|
67
|
+
if (!isGitRepo(cwd)) throw new Error("Not inside a git repository. Run `neon git` from a git work tree.");
|
|
68
|
+
const repoRoot = gitRepoRoot(cwd);
|
|
69
|
+
if (!repoRoot) throw new Error("Could not resolve the git repository root.");
|
|
70
|
+
return repoRoot;
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* `.neon` at exactly the repo root — never walk-up-resolved. The single source of truth for
|
|
74
|
+
* all git-hooks state: `git.follow` and the git → Neon map. A `post-checkout` hook always
|
|
75
|
+
* runs with `cwd` at the repo root, so every function here (`install`, `uninstall`, `sync`,
|
|
76
|
+
* `status`, `cleanup`) reads and writes this exact path, never `props.contextFile`'s
|
|
77
|
+
* walk-up resolution — an ancestor directory's unrelated `.neon` (e.g. a parent monorepo
|
|
78
|
+
* folder) must never be mistaken for this repo's own state.
|
|
79
|
+
*
|
|
80
|
+
* Deliberately never *creates* this file: `install` requires it to already exist (from a
|
|
81
|
+
* prior `neon link` / `neon checkout` run at the repo root), and `sync` skips persisting a
|
|
82
|
+
* mapping when it's absent. Auto-creating a bare `{ git: {...} }` file with no `projectId`
|
|
83
|
+
* would plant a **new**, closer `.neon` that shadows a real project link recorded in an
|
|
84
|
+
* ancestor directory (or a linked subdirectory) for every other command's normal
|
|
85
|
+
* context resolution — silently breaking `checkout` / `deploy` / `status` in that
|
|
86
|
+
* directory. Requiring the link first also keeps this file and the project context file
|
|
87
|
+
* the same file whenever hooks state exists, which is what makes `cleanup`'s project-match
|
|
88
|
+
* check (see below) actually correct.
|
|
89
|
+
*/
|
|
90
|
+
const repoRootContextFile = (repoRoot) => join(repoRoot, CONTEXT_FILE);
|
|
91
|
+
const install = (_props) => {
|
|
92
|
+
const repoRoot = requireRepoRoot();
|
|
93
|
+
const gitStateFile = repoRootContextFile(repoRoot);
|
|
94
|
+
if (!readContextFile(gitStateFile).projectId) {
|
|
95
|
+
const quoted = quoteFlagValue(gitStateFile);
|
|
96
|
+
throw new Error(`No project linked at the repository root (${gitStateFile}).\nRun \`neon link --context-file ${quoted}\` (an explicit path, since a plain \`neon link\` walks up and may update a different, ancestor \`.neon\` instead) or \`neon checkout <branch> --context-file ${quoted}\` first, then re-run \`neon git install\`.`);
|
|
97
|
+
}
|
|
98
|
+
const result = installPostCheckoutHook(repoRoot);
|
|
99
|
+
if (result.status === "conflict") throw new Error(`A non-neon \`post-checkout\` hook already exists at ${result.hookPath}.\nRemove or rename it first, then re-run \`neon git install\`.`);
|
|
100
|
+
setGitFollow(gitStateFile, true);
|
|
101
|
+
log.info("Git → Neon sync %s. `git checkout <branch>` will now check out the mapped Neon branch.\nHook: %s", result.status === "installed" ? "installed" : "updated", postCheckoutHookPath(repoRoot));
|
|
102
|
+
};
|
|
103
|
+
const uninstall = (_props) => {
|
|
104
|
+
const repoRoot = requireRepoRoot();
|
|
105
|
+
const result = removePostCheckoutHook(repoRoot);
|
|
106
|
+
const gitStateFile = repoRootContextFile(repoRoot);
|
|
107
|
+
if (existsSync(gitStateFile)) setGitFollow(gitStateFile, false);
|
|
108
|
+
switch (result) {
|
|
109
|
+
case "removed":
|
|
110
|
+
log.info("Removed the neon git post-checkout hook. Git → Neon sync is off.");
|
|
111
|
+
break;
|
|
112
|
+
case "foreign":
|
|
113
|
+
log.warning("Left the existing `post-checkout` hook in place (not managed by neon). Git → Neon sync flag cleared.");
|
|
114
|
+
break;
|
|
115
|
+
case "absent": log.info("No neon git hook was installed. Git → Neon sync flag cleared.");
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* Check out the Neon branch that corresponds to the current git branch. Invoked by the
|
|
120
|
+
* installed `post-checkout` hook (with `NEON_GIT_HOOK=1`) and runnable by hand. Resolves the
|
|
121
|
+
* Neon branch name via the persisted map (sticky), delegates to `neon checkout` (whose
|
|
122
|
+
* `checkout.before` hook may further map the name and whose `checkout.after` hook runs
|
|
123
|
+
* migrations, etc.), then records the resulting git → Neon mapping so it stays stable.
|
|
124
|
+
*/
|
|
125
|
+
/** `sync`'s own routine status lines (pull result, detached HEAD) — skipped under
|
|
126
|
+
* `--quiet`. Warnings, errors, and the delegated `checkout`'s own output are unaffected. */
|
|
127
|
+
const syncLog = (props, ...args) => {
|
|
128
|
+
if (props.quiet) return;
|
|
129
|
+
log.info(...args);
|
|
130
|
+
};
|
|
131
|
+
const sync = async (props) => {
|
|
132
|
+
if (isUnfollowedGitHookSync({ _: ["git", "sync"] })) {
|
|
133
|
+
log.debug("Skipping git sync: this repo has not run `neon git install` (git.follow is not set).");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const cwd = process.cwd();
|
|
137
|
+
if (!isGitRepo(cwd)) throw new Error("Not inside a git repository.");
|
|
138
|
+
const repoRoot = gitRepoRoot(cwd) ?? cwd;
|
|
139
|
+
const gitStateFile = repoRootContextFile(repoRoot);
|
|
140
|
+
const context = readContextFile(gitStateFile);
|
|
141
|
+
const gitBranch = currentGitBranch(cwd);
|
|
142
|
+
if (!gitBranch) {
|
|
143
|
+
syncLog(props, "Detached HEAD — no git branch to sync. Skipping.");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (await resolveShouldPull(props, cwd, gitBranch)) {
|
|
147
|
+
const outcome = gitPull(cwd);
|
|
148
|
+
switch (outcome.status) {
|
|
149
|
+
case "pulled":
|
|
150
|
+
syncLog(props, "%s git pull --ff-only (%s)", chalk.dim("→"), gitBranch);
|
|
151
|
+
break;
|
|
152
|
+
case "no-upstream":
|
|
153
|
+
syncLog(props, "No upstream for %s — skipping git pull.", gitBranch);
|
|
154
|
+
break;
|
|
155
|
+
case "failed": log.warning("git pull --ff-only failed (continuing with sync): %s", outcome.detail);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const inputName = gitBranchMapping(context, gitBranch) ?? neonSafeBranchName(gitBranch);
|
|
159
|
+
await handler$1({
|
|
160
|
+
...props,
|
|
161
|
+
id: inputName,
|
|
162
|
+
envPull: props.envPull ?? true
|
|
163
|
+
});
|
|
164
|
+
const rootProjectId = readContextFile(gitStateFile).projectId;
|
|
165
|
+
const pinnedContext = readContextFile(props.contextFile);
|
|
166
|
+
if (!rootProjectId || pinnedContext.projectId !== rootProjectId) log.warning("Not persisting the git → Neon mapping: this checkout targeted project %s, not a project linked at the repo root (%s).", pinnedContext.projectId ?? "(unknown)", rootProjectId ?? "none");
|
|
167
|
+
else {
|
|
168
|
+
const resolved = contextBranch(pinnedContext);
|
|
169
|
+
if (resolved) setGitBranchMapping(gitStateFile, gitBranch, resolved);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
/**
|
|
173
|
+
* Decide whether `git sync` should `git pull` first. Mirrors the rest of the CLI's
|
|
174
|
+
* auto-vs-interactive philosophy (cf. `checkout` / `link`): an explicit flag always wins;
|
|
175
|
+
* otherwise we only *prompt* in a real manual TTY, and never auto-pull in the post-checkout
|
|
176
|
+
* hook or CI (no network surprises in automation). Skips when there's nothing upstream.
|
|
177
|
+
*/
|
|
178
|
+
const resolveShouldPull = async (props, cwd, gitBranch) => {
|
|
179
|
+
if (props.pull === true) return true;
|
|
180
|
+
if (props.pull === false) return false;
|
|
181
|
+
if (!hasUpstream(cwd)) return false;
|
|
182
|
+
if (process.env["NEON_GIT_HOOK"] === "1" || isCi() || !process.stdout.isTTY) return false;
|
|
183
|
+
const { pull } = await prompts({
|
|
184
|
+
type: "confirm",
|
|
185
|
+
name: "pull",
|
|
186
|
+
message: `Pull latest for "${gitBranch}" (git pull --ff-only) before syncing?`,
|
|
187
|
+
initial: false
|
|
188
|
+
});
|
|
189
|
+
return Boolean(pull);
|
|
190
|
+
};
|
|
191
|
+
const GIT_STATUS_FIELDS = [
|
|
192
|
+
"gitBranch",
|
|
193
|
+
"hookInstalled",
|
|
194
|
+
"followOnCheckout",
|
|
195
|
+
"mappedNeonBranch",
|
|
196
|
+
"mappings"
|
|
197
|
+
];
|
|
198
|
+
const status = (props) => {
|
|
199
|
+
const cwd = process.cwd();
|
|
200
|
+
const git = readGitContext(cwd);
|
|
201
|
+
if (!git.available) throw new Error("Not inside a git repository.");
|
|
202
|
+
const repoRoot = git.repoRoot ?? cwd;
|
|
203
|
+
const hookPath = postCheckoutHookPath(repoRoot);
|
|
204
|
+
const context = readContextFile(repoRootContextFile(repoRoot));
|
|
205
|
+
const currentBranch = git.branch;
|
|
206
|
+
writer(props).end({
|
|
207
|
+
gitBranch: currentBranch ?? null,
|
|
208
|
+
hookInstalled: isManagedHook(hookPath),
|
|
209
|
+
followOnCheckout: context.git?.follow === true,
|
|
210
|
+
mappedNeonBranch: currentBranch ? gitBranchMapping(context, currentBranch) ?? null : null,
|
|
211
|
+
mappings: context.git?.map ?? {}
|
|
212
|
+
}, {
|
|
213
|
+
fields: GIT_STATUS_FIELDS,
|
|
214
|
+
renderColumns: {
|
|
215
|
+
gitBranch: (v) => v.gitBranch ?? "(detached)",
|
|
216
|
+
mappedNeonBranch: (v) => v.gitBranch === null ? "(detached — sync skips detached HEAD)" : v.mappedNeonBranch ?? "(unmapped — will derive on next sync)",
|
|
217
|
+
mappings: (v) => Object.entries(v.mappings).map(([gitBranch, neonBranch]) => `${gitBranch} → ${neonBranch}`).join("\n") || "(none)"
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
};
|
|
221
|
+
/**
|
|
222
|
+
* Split the orphaned Neon branches (those mapped from now-deleted git branches) into the ones
|
|
223
|
+
* safe to delete and the ones to skip. Default and protected branches are **never** deleted.
|
|
224
|
+
* Pure (no I/O) so it's unit-testable.
|
|
225
|
+
*/
|
|
226
|
+
const partitionBranchesToPrune = (branches, orphanNeonNames) => {
|
|
227
|
+
const toDelete = [];
|
|
228
|
+
const skipped = [];
|
|
229
|
+
for (const branch of branches) {
|
|
230
|
+
if (!orphanNeonNames.has(branch.name)) continue;
|
|
231
|
+
if (branch.default) skipped.push({
|
|
232
|
+
name: branch.name,
|
|
233
|
+
reason: "default branch"
|
|
234
|
+
});
|
|
235
|
+
else if (branch.protected) skipped.push({
|
|
236
|
+
name: branch.name,
|
|
237
|
+
reason: "protected"
|
|
238
|
+
});
|
|
239
|
+
else toDelete.push(branch);
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
toDelete,
|
|
243
|
+
skipped
|
|
244
|
+
};
|
|
245
|
+
};
|
|
246
|
+
/**
|
|
247
|
+
* Prune the git → Neon workflow state. By default this only cleans `.neon`: mapping entries
|
|
248
|
+
* whose git branch no longer exists locally are dropped. With `--prune-neon-branches` it also
|
|
249
|
+
* deletes the orphaned Neon branches — never the default branch and never a protected one.
|
|
250
|
+
*/
|
|
251
|
+
const cleanup = async (props) => {
|
|
252
|
+
const cwd = process.cwd();
|
|
253
|
+
if (!isGitRepo(cwd)) throw new Error("Not inside a git repository.");
|
|
254
|
+
const repoRoot = gitRepoRoot(cwd) ?? cwd;
|
|
255
|
+
const gitStateFile = repoRootContextFile(repoRoot);
|
|
256
|
+
const context = readContextFile(gitStateFile);
|
|
257
|
+
const map = gitBranchMap(context);
|
|
258
|
+
const entries = Object.entries(map);
|
|
259
|
+
if (entries.length === 0) {
|
|
260
|
+
log.info("No git → Neon mappings to clean up.");
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const local = new Set(localGitBranches(cwd));
|
|
264
|
+
const stale = entries.filter(([gitBranch]) => !local.has(gitBranch));
|
|
265
|
+
if (stale.length === 0) {
|
|
266
|
+
log.info("All %d mapping(s) still have a local git branch — nothing to prune.", entries.length);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const kept = Object.fromEntries(entries.filter(([gitBranch]) => local.has(gitBranch)));
|
|
270
|
+
const keptNeonNames = new Set(Object.values(kept));
|
|
271
|
+
const staleButShared = stale.filter(([, neonBranch]) => keptNeonNames.has(neonBranch));
|
|
272
|
+
const orphaned = stale.filter(([, neonBranch]) => !keptNeonNames.has(neonBranch));
|
|
273
|
+
if (!props.pruneNeonBranches) {
|
|
274
|
+
setGitBranchMap(gitStateFile, kept);
|
|
275
|
+
log.info("Pruned %d stale mapping(s) from .neon:", stale.length);
|
|
276
|
+
for (const [gitBranch, neonBranch] of stale) log.info(" %s → %s", gitBranch, neonBranch);
|
|
277
|
+
if (orphaned.length > 0) log.info("These Neon branch(es) are no longer mapped and were not deleted: %s. Delete them with `neon branches delete <name>`, or run `neon git cleanup --prune-neon-branches` next time before a mapping-only cleanup.", orphaned.map(([, neonBranch]) => neonBranch).join(", "));
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (staleButShared.length > 0) {
|
|
281
|
+
setGitBranchMap(gitStateFile, {
|
|
282
|
+
...kept,
|
|
283
|
+
...Object.fromEntries(orphaned)
|
|
284
|
+
});
|
|
285
|
+
log.info("Pruned %d stale mapping(s) whose Neon branch is still in use elsewhere:", staleButShared.length);
|
|
286
|
+
for (const [gitBranch, neonBranch] of staleButShared) log.info(" %s → %s", gitBranch, neonBranch);
|
|
287
|
+
}
|
|
288
|
+
if (!props.projectId) throw new Error("Cannot delete Neon branches: no project in context. Run `neon link` first.");
|
|
289
|
+
if (!context.projectId) throw new Error(`Cannot delete Neon branches: the git → Neon map has no recorded project (the repo-root .neon at ${gitStateFile} has no projectId). Run \`neon link --context-file ${quoteFlagValue(gitStateFile)}\` to establish it, or \`neon git cleanup\` with no --prune-neon-branches to only prune the local mapping.`);
|
|
290
|
+
if (context.projectId !== props.projectId) throw new Error(`Cannot delete Neon branches: this git → Neon map was recorded against project ${context.projectId}, but the active project is ${props.projectId}. Run \`neon link\` (or drop --project-id) to target ${context.projectId}, or \`neon git cleanup\` with no --prune-neon-branches to only prune the local mapping.`);
|
|
291
|
+
if (orphaned.length === 0) {
|
|
292
|
+
log.info("No orphaned Neon branches to delete.");
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const orphanNeonNames = new Set(orphaned.map(([, neonBranch]) => neonBranch));
|
|
296
|
+
const branches = (await props.apiClient.listProjectBranches({ projectId: props.projectId })).data.branches;
|
|
297
|
+
const { toDelete, skipped } = partitionBranchesToPrune(branches, orphanNeonNames);
|
|
298
|
+
for (const { name, reason } of skipped) log.warning("Keeping Neon branch %s (%s) — never auto-deleted.", name, reason);
|
|
299
|
+
if (toDelete.length === 0) {
|
|
300
|
+
log.info("No orphaned Neon branches to delete.");
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (!await confirmPrune(props, toDelete)) {
|
|
304
|
+
log.info("Aborted — no Neon branches were deleted, and no mappings were pruned.");
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const currentMap = {
|
|
308
|
+
...kept,
|
|
309
|
+
...Object.fromEntries(orphaned)
|
|
310
|
+
};
|
|
311
|
+
const failed = [];
|
|
312
|
+
let prunedCount = 0;
|
|
313
|
+
for (const branch of toDelete) {
|
|
314
|
+
const branchMappings = orphaned.filter(([, neonBranch]) => neonBranch === branch.name);
|
|
315
|
+
try {
|
|
316
|
+
await props.apiClient.deleteProjectBranch(props.projectId, branch.id);
|
|
317
|
+
log.info("Deleted Neon branch %s (%s).", branch.name, branch.id);
|
|
318
|
+
prunedCount += branchMappings.length;
|
|
319
|
+
for (const [gitBranch, neonBranch] of branchMappings) {
|
|
320
|
+
log.info(" Pruned %s → %s", gitBranch, neonBranch);
|
|
321
|
+
delete currentMap[gitBranch];
|
|
322
|
+
}
|
|
323
|
+
} catch (err) {
|
|
324
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
325
|
+
log.error("Failed to delete Neon branch %s: %s", branch.name, message);
|
|
326
|
+
failed.push({
|
|
327
|
+
name: branch.name,
|
|
328
|
+
error: message
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
setGitBranchMap(gitStateFile, currentMap);
|
|
332
|
+
}
|
|
333
|
+
if (prunedCount > 0) log.info("Pruned %d stale mapping(s) from .neon.", prunedCount);
|
|
334
|
+
if (failed.length > 0) throw new Error(`Failed to delete ${failed.length} Neon branch(es): ${failed.map((f) => `${f.name} (${f.error})`).join(", ")}. Re-run \`neon git cleanup --prune-neon-branches\` to retry.`);
|
|
335
|
+
};
|
|
336
|
+
/**
|
|
337
|
+
* Confirm deleting the orphaned Neon branches. Deletion is destructive, so an explicit
|
|
338
|
+
* `--yes` wins; otherwise prompt in an interactive terminal, and refuse (with a warning) in
|
|
339
|
+
* CI / non-interactive contexts rather than deleting without consent.
|
|
340
|
+
*/
|
|
341
|
+
const confirmPrune = async (props, toDelete) => {
|
|
342
|
+
if (props.yes) return true;
|
|
343
|
+
if (isCi() || !process.stdout.isTTY) {
|
|
344
|
+
log.warning("Refusing to delete %d Neon branch(es) non-interactively. Re-run with --yes to confirm.", toDelete.length);
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
const { ok } = await prompts({
|
|
348
|
+
type: "confirm",
|
|
349
|
+
name: "ok",
|
|
350
|
+
message: `Delete ${toDelete.length} orphaned Neon branch(es): ${toDelete.map((branch) => branch.name).join(", ")}?`,
|
|
351
|
+
initial: false
|
|
352
|
+
});
|
|
353
|
+
return Boolean(ok);
|
|
354
|
+
};
|
|
355
|
+
//#endregion
|
|
356
|
+
export { aliases, builder, cleanup, command, describe, handler, install, partitionBranchesToPrune, status, sync, git_exports as t, uninstall };
|
package/dist/commands/index.js
CHANGED
|
@@ -22,6 +22,7 @@ import { t as deploy_exports } from "./deploy.js";
|
|
|
22
22
|
import { t as dev_exports } from "./dev.js";
|
|
23
23
|
import { t as diff_exports } from "./diff.js";
|
|
24
24
|
import { t as functions_exports } from "./functions.js";
|
|
25
|
+
import { t as git_exports } from "./git.js";
|
|
25
26
|
import { t as init_exports } from "./init.js";
|
|
26
27
|
import { t as inspect_exports } from "./inspect.js";
|
|
27
28
|
import { t as ip_allow_exports } from "./ip_allow.js";
|
|
@@ -62,6 +63,7 @@ var commands_default = [
|
|
|
62
63
|
psql_exports,
|
|
63
64
|
set_context_exports,
|
|
64
65
|
checkout_exports,
|
|
66
|
+
git_exports,
|
|
65
67
|
link_exports,
|
|
66
68
|
open_exports,
|
|
67
69
|
claim_exports,
|
package/dist/commands/link.js
CHANGED
|
@@ -21,6 +21,7 @@ var link_exports = /* @__PURE__ */ __exportAll({
|
|
|
21
21
|
command: () => command,
|
|
22
22
|
describe: () => describe,
|
|
23
23
|
handler: () => handler,
|
|
24
|
+
quoteFlagValue: () => quoteFlagValue,
|
|
24
25
|
runLink: () => runLink,
|
|
25
26
|
shouldOfferConfigInit: () => shouldOfferConfigInit
|
|
26
27
|
});
|
|
@@ -887,4 +888,4 @@ const mustString = (value, name) => {
|
|
|
887
888
|
return value;
|
|
888
889
|
};
|
|
889
890
|
//#endregion
|
|
890
|
-
export { builder, command, describe, handler, runLink, shouldOfferConfigInit, link_exports as t };
|
|
891
|
+
export { builder, command, describe, handler, quoteFlagValue, runLink, shouldOfferConfigInit, link_exports as t };
|
package/dist/context.js
CHANGED
|
@@ -52,6 +52,34 @@ const isClaimCommand = (args) => args._[0] === "claim" || args._[0] === "claimab
|
|
|
52
52
|
*/
|
|
53
53
|
const isApiKeysCommand = (args) => args._[0] === "api-keys" || args._[0] === "api-key";
|
|
54
54
|
const isMcpCommand = (args) => args._[0] === "mcp";
|
|
55
|
+
/**
|
|
56
|
+
* `neon git install/uninstall/status` only touch the local git hooks directory and `.neon`
|
|
57
|
+
* — never the Neon API — so they must work (and never pop a browser login) without
|
|
58
|
+
* credentials, the same reasoning as {@link isProfileCommand}. `sync` and `cleanup` are
|
|
59
|
+
* excluded: both can reach the API (`sync` always delegates to `checkout`; `cleanup` does
|
|
60
|
+
* too with `--prune-neon-branches`).
|
|
61
|
+
*/
|
|
62
|
+
const isGitLocalCommand = (args) => args._[0] === "git" && (args._[1] === "install" || args._[1] === "uninstall" || args._[1] === "status");
|
|
63
|
+
/** The env var the installed `post-checkout` hook sets before invoking `neon git sync`. */
|
|
64
|
+
const GIT_HOOK_ENV_FLAG = "NEON_GIT_HOOK";
|
|
65
|
+
/**
|
|
66
|
+
* True only for a `neon git sync` invocation triggered by the installed `post-checkout`
|
|
67
|
+
* hook, in a repo that has **not** itself run `neon git install` — the case that must never
|
|
68
|
+
* touch the network (auth included). Checked before authentication runs at all, since
|
|
69
|
+
* `core.hooksPath` can point at a directory shared by several repositories: the hook script
|
|
70
|
+
* firing here does not by itself mean *this* repo opted in.
|
|
71
|
+
*
|
|
72
|
+
* The `.neon` read is deliberately **not** the usual walk-up resolution
|
|
73
|
+
* ({@link currentContextFile}): a `post-checkout` hook always runs with `cwd` at the
|
|
74
|
+
* repository's top level, and opt-in must be recorded by *this* repo's own `.neon` — an
|
|
75
|
+
* ancestor directory's `.neon` (found by the walk-up, e.g. a parent monorepo folder that
|
|
76
|
+
* happens to hold one for an unrelated project) must never authorize it.
|
|
77
|
+
*/
|
|
78
|
+
const isUnfollowedGitHookSync = (args, cwd = process.cwd()) => {
|
|
79
|
+
if (!(args._[0] === "git" && args._[1] === "sync")) return false;
|
|
80
|
+
if (process.env[GIT_HOOK_ENV_FLAG] !== "1") return false;
|
|
81
|
+
return readContextFile(resolve(cwd, CONTEXT_FILE)).git?.follow !== true;
|
|
82
|
+
};
|
|
55
83
|
const isSkillsCommand = (args) => args._[0] === "skills" || args._[0] === "skill";
|
|
56
84
|
const isPluginsCommand = (args) => args._[0] === "plugins" || args._[0] === "plugin";
|
|
57
85
|
const isAskCommand = (args) => args._[0] === "ask";
|
|
@@ -163,12 +191,73 @@ const updateContextFile = (file, context) => {
|
|
|
163
191
|
* `.neon` we never touch `.gitignore`, so a user who deliberately un-ignored
|
|
164
192
|
* the file (e.g. to commit shared context) won't have the entry re-added on
|
|
165
193
|
* every subsequent command.
|
|
194
|
+
*
|
|
195
|
+
* The {@link Context.git} block is additionally **preserved** when the caller doesn't
|
|
196
|
+
* provide one, so a plain `link` / `set-context` / `checkout` never wipes the git → Neon
|
|
197
|
+
* mapping a user (or `neon git sync`) built up. Pass an explicit `git` to replace it.
|
|
198
|
+
*
|
|
199
|
+
* That preservation is skipped when `context.projectId` differs from the file's existing
|
|
200
|
+
* `projectId`: the mapping's Neon branch **names** are only meaningful within the project
|
|
201
|
+
* they were recorded against, and carrying them into a different project could point
|
|
202
|
+
* `neon git cleanup --prune-neon-branches` at a same-named branch that was never actually
|
|
203
|
+
* mapped there.
|
|
166
204
|
*/
|
|
167
205
|
const applyContext = (file, context) => {
|
|
168
206
|
const isNewFile = !existsSync(file);
|
|
169
|
-
|
|
207
|
+
const existing = isNewFile ? {} : readContextFile(file);
|
|
208
|
+
const projectChanged = existing.projectId !== void 0 && context.projectId !== existing.projectId;
|
|
209
|
+
const merged = context.git === void 0 && existing.git !== void 0 && !projectChanged ? {
|
|
210
|
+
...context,
|
|
211
|
+
git: existing.git
|
|
212
|
+
} : context;
|
|
213
|
+
updateContextFile(file, merged);
|
|
170
214
|
if (isNewFile) ensureGitignored(file);
|
|
171
215
|
};
|
|
216
|
+
/** Look up the Neon branch a git branch maps to in the persisted `.neon` context. */
|
|
217
|
+
const gitBranchMapping = (context, gitBranch) => context.git?.map?.[gitBranch];
|
|
218
|
+
/** The full git → Neon mapping from the context (a copy; empty when unset). */
|
|
219
|
+
const gitBranchMap = (context) => ({ ...context.git?.map });
|
|
220
|
+
/** Replace the git → Neon mapping in `.neon`, preserving `git.follow` and the rest. */
|
|
221
|
+
const setGitBranchMap = (file, map) => {
|
|
222
|
+
const context = readContextFile(file);
|
|
223
|
+
applyContext(file, {
|
|
224
|
+
...context,
|
|
225
|
+
git: {
|
|
226
|
+
...context.git,
|
|
227
|
+
map
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
};
|
|
231
|
+
/**
|
|
232
|
+
* Record a git branch -> Neon branch mapping in `.neon`, merging into any existing map and
|
|
233
|
+
* preserving the rest of the context. Passes an explicit `git` so {@link applyContext}
|
|
234
|
+
* replaces (rather than just preserves) the block with the new entry folded in.
|
|
235
|
+
*/
|
|
236
|
+
const setGitBranchMapping = (file, gitBranch, neonBranch) => {
|
|
237
|
+
const context = readContextFile(file);
|
|
238
|
+
const git = {
|
|
239
|
+
...context.git,
|
|
240
|
+
map: {
|
|
241
|
+
...context.git?.map,
|
|
242
|
+
[gitBranch]: neonBranch
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
applyContext(file, {
|
|
246
|
+
...context,
|
|
247
|
+
git
|
|
248
|
+
});
|
|
249
|
+
};
|
|
250
|
+
/** Set the `git.follow` flag (whether the post-checkout hook drives `neon git sync`). */
|
|
251
|
+
const setGitFollow = (file, follow) => {
|
|
252
|
+
const context = readContextFile(file);
|
|
253
|
+
applyContext(file, {
|
|
254
|
+
...context,
|
|
255
|
+
git: {
|
|
256
|
+
...context.git,
|
|
257
|
+
follow
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
};
|
|
172
261
|
/**
|
|
173
262
|
* Low-level writer for callers that already hold the resolved identifiers and
|
|
174
263
|
* just need to record them — e.g. `init` or `projects create`, which create a
|
|
@@ -231,7 +320,7 @@ const ensureGitignoreEntry = (path, entry) => {
|
|
|
231
320
|
};
|
|
232
321
|
const basenameOf = (file) => {
|
|
233
322
|
const parts = file.split(/[\\/]/);
|
|
234
|
-
return parts[parts.length - 1] ||
|
|
323
|
+
return parts[parts.length - 1] || ".neon";
|
|
235
324
|
};
|
|
236
325
|
const hasGitignoreEntry = (content, entry) => {
|
|
237
326
|
return content.split(/\r?\n/).some((line) => gitignoreCovers(line.trim(), entry));
|
|
@@ -261,4 +350,4 @@ const globToRegExp = (pattern) => {
|
|
|
261
350
|
return new RegExp(`^${source}$`);
|
|
262
351
|
};
|
|
263
352
|
//#endregion
|
|
264
|
-
export { applyContext, contextBranch, currentContextFile, enrichFromContext, ensureDirectoryGitignored, ensureGitignored, isApiKeysCommand, isAskCommand, isClaimCommand, isConfigInit, isCurrentBranchProbe, isInspectDbUrl, isMcpCommand, isMcpOauth, isPluginsCommand, isProfileCommand, isSkillsCommand, readContextFile, setContext, updateContextFile, walkContextFile };
|
|
353
|
+
export { CONTEXT_FILE, applyContext, contextBranch, currentContextFile, enrichFromContext, ensureDirectoryGitignored, ensureGitignored, gitBranchMap, gitBranchMapping, isApiKeysCommand, isAskCommand, isClaimCommand, isConfigInit, isCurrentBranchProbe, isGitLocalCommand, isInspectDbUrl, isMcpCommand, isMcpOauth, isPluginsCommand, isProfileCommand, isSkillsCommand, isUnfollowedGitHookSync, readContextFile, setContext, setGitBranchMap, setGitBranchMapping, setGitFollow, updateContextFile, walkContextFile };
|
package/dist/dev/env.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as resolveDevEnv, i as dropClaimableUnsupported, n as MissingBranchContextError, o as resolveNeonEnvVars, r as ServiceNotOnBranchError, t as DevEnvMismatchError } from "../_chunks/env-
|
|
1
|
+
import { a as resolveDevEnv, i as dropClaimableUnsupported, n as MissingBranchContextError, o as resolveNeonEnvVars, r as ServiceNotOnBranchError, t as DevEnvMismatchError } from "../_chunks/env-CtOH9xsl.js";
|
|
2
2
|
export { DevEnvMismatchError, MissingBranchContextError, ServiceNotOnBranchError, dropClaimableUnsupported, resolveDevEnv, resolveNeonEnvVars };
|
package/dist/env_services.js
CHANGED
|
@@ -1,2 +1,121 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { NEON_SERVICES } from "./neon_services.js";
|
|
2
|
+
import { f as parseFunctionBaseUrlKey, t as NEON_ENV_VAR_KEYS, u as isFunctionBaseUrlKey } from "./_chunks/env-N19A7qND.js";
|
|
3
|
+
//#region src/env_services.ts
|
|
4
|
+
const ENV_PULL_SERVICES = NEON_SERVICES;
|
|
5
|
+
const ENV_PULL_KEYS = [
|
|
6
|
+
...Object.values(NEON_ENV_VAR_KEYS.postgres),
|
|
7
|
+
NEON_ENV_VAR_KEYS.branch.name,
|
|
8
|
+
...Object.values(NEON_ENV_VAR_KEYS.auth),
|
|
9
|
+
...Object.values(NEON_ENV_VAR_KEYS.dataApi),
|
|
10
|
+
...Object.values(NEON_ENV_VAR_KEYS.storage),
|
|
11
|
+
...Object.values(NEON_ENV_VAR_KEYS.aiGateway)
|
|
12
|
+
];
|
|
13
|
+
const ENV_PULL_KEY_HELP = `${ENV_PULL_KEYS.join(", ")}, or NEON_FUNCTION_<SLUG>_BASE_URL`;
|
|
14
|
+
/** The OS-level env vars each service contributes to a pulled `.env`. */
|
|
15
|
+
const SERVICE_ENV_KEYS = {
|
|
16
|
+
postgres: Object.values(NEON_ENV_VAR_KEYS.postgres),
|
|
17
|
+
auth: Object.values(NEON_ENV_VAR_KEYS.auth),
|
|
18
|
+
"data-api": Object.values(NEON_ENV_VAR_KEYS.dataApi),
|
|
19
|
+
"object-storage": Object.values(NEON_ENV_VAR_KEYS.storage),
|
|
20
|
+
"ai-gateway": Object.values(NEON_ENV_VAR_KEYS.aiGateway),
|
|
21
|
+
functions: []
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* The subset of {@link SERVICE_ENV_KEYS} a pull *owns*, and so may prune from the target file
|
|
25
|
+
* when the branch no longer has it. Object storage is deliberately absent: it is emitted under
|
|
26
|
+
* the third-party `AWS_*` names, which collide with credentials a user may set by hand, so
|
|
27
|
+
* `env pull` only ever writes them. Function URL keys cannot live here because slugs are
|
|
28
|
+
* discovered at runtime.
|
|
29
|
+
*/
|
|
30
|
+
const SERVICE_OWNED_ENV_KEYS = {
|
|
31
|
+
...SERVICE_ENV_KEYS,
|
|
32
|
+
"object-storage": [],
|
|
33
|
+
functions: []
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Branch identity. Not a service — every branch has a name — so a scoped pull refreshes it
|
|
37
|
+
* alongside whatever services were selected.
|
|
38
|
+
*/
|
|
39
|
+
const BRANCH_ENV_KEY = NEON_ENV_VAR_KEYS.branch.name;
|
|
40
|
+
const ENV_KEY_SERVICE = {
|
|
41
|
+
DATABASE_URL: "postgres",
|
|
42
|
+
DATABASE_URL_UNPOOLED: "postgres",
|
|
43
|
+
NEON_BRANCH: null,
|
|
44
|
+
NEON_AUTH_BASE_URL: "auth",
|
|
45
|
+
NEON_AUTH_JWKS_URL: "auth",
|
|
46
|
+
NEON_DATA_API_URL: "data-api",
|
|
47
|
+
AWS_ACCESS_KEY_ID: "object-storage",
|
|
48
|
+
AWS_SECRET_ACCESS_KEY: "object-storage",
|
|
49
|
+
AWS_ENDPOINT_URL_S3: "object-storage",
|
|
50
|
+
AWS_REGION: "object-storage",
|
|
51
|
+
NEON_AI_GATEWAY_TOKEN: "ai-gateway",
|
|
52
|
+
NEON_AI_GATEWAY_BASE_URL: "ai-gateway"
|
|
53
|
+
};
|
|
54
|
+
const serviceForEnvKey = (key) => isFunctionBaseUrlKey(key) ? "functions" : ENV_KEY_SERVICE[key];
|
|
55
|
+
/** Services that must be resolved to produce the selected env keys. */
|
|
56
|
+
const servicesForEnvKeys = (keys) => ENV_PULL_SERVICES.filter((service) => keys.some((key) => serviceForEnvKey(key) === service));
|
|
57
|
+
/** Every env var the selected services contribute, plus branch identity. */
|
|
58
|
+
const envServiceKeys = (services) => {
|
|
59
|
+
const keys = /* @__PURE__ */ new Set([BRANCH_ENV_KEY]);
|
|
60
|
+
for (const service of services) for (const key of SERVICE_ENV_KEYS[service]) keys.add(key);
|
|
61
|
+
return keys;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* The env vars a pull scoped to `services` may prune. Narrower than the unscoped set on
|
|
65
|
+
* purpose: `env pull -s ai-gateway` says nothing about `DATABASE_URL`, so it must leave it
|
|
66
|
+
* alone rather than treat its absence from this pull as "the branch no longer has it".
|
|
67
|
+
* Function URL keys cannot live here because slugs are discovered at runtime.
|
|
68
|
+
*/
|
|
69
|
+
const ownedEnvServiceKeys = (services) => services.flatMap((service) => SERVICE_OWNED_ENV_KEYS[service]);
|
|
70
|
+
const envKeysForSelection = (services, envKeys) => {
|
|
71
|
+
const selected = services.length > 0 ? envServiceKeys(services) : /* @__PURE__ */ new Set();
|
|
72
|
+
for (const key of envKeys) selected.add(key);
|
|
73
|
+
if (selected.has(NEON_ENV_VAR_KEYS.storage.accessKeyId) !== selected.has(NEON_ENV_VAR_KEYS.storage.secretAccessKey)) throw new Error("AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY must be selected together: they are two halves of one newly issued object-storage credential. Add the missing key to --env, or use --service object-storage.");
|
|
74
|
+
const staticKeys = ENV_PULL_KEYS.filter((key) => selected.has(key));
|
|
75
|
+
const functionKeys = [...selected].filter(isFunctionBaseUrlKey).sort((left, right) => left.localeCompare(right));
|
|
76
|
+
return [...staticKeys, ...functionKeys];
|
|
77
|
+
};
|
|
78
|
+
/** Parse repeated or comma-separated `--env` values into canonical env-key order. */
|
|
79
|
+
const parseEnvPullKeys = (raw, flag) => {
|
|
80
|
+
const names = raw.flatMap((value) => value.split(",")).map((name) => name.trim()).filter((name) => name !== "");
|
|
81
|
+
const supported = `Supported values: ${ENV_PULL_KEY_HELP}.`;
|
|
82
|
+
if (names.length === 0) throw new Error(`${flag} needs at least one env variable. ${supported}`);
|
|
83
|
+
const unknown = names.filter((name) => !ENV_PULL_KEYS.some((key) => key === name) && !isFunctionBaseUrlKey(name));
|
|
84
|
+
if (unknown.length > 0) {
|
|
85
|
+
const displayNames = unknown.map(redactUnknownEnvValue);
|
|
86
|
+
const suggestions = [...new Set(unknown.map(suggestEnvPullKey).filter((key) => key !== null))];
|
|
87
|
+
const suggestion = suggestions.length > 0 ? ` Did you mean ${suggestions.join(" or ")}?` : "";
|
|
88
|
+
throw new Error(`Unknown env variable${unknown.length === 1 ? "" : "s"} ${displayNames.join(", ")}.${suggestion} ${supported}`);
|
|
89
|
+
}
|
|
90
|
+
const staticKeys = ENV_PULL_KEYS.filter((key) => names.includes(key));
|
|
91
|
+
const functionKeys = [...new Set(names.filter(isFunctionBaseUrlKey))].sort((left, right) => left.localeCompare(right));
|
|
92
|
+
return [...staticKeys, ...functionKeys];
|
|
93
|
+
};
|
|
94
|
+
const isKnownEnvPullKey = (key) => ENV_PULL_KEYS.some((supportedKey) => supportedKey === key) || isFunctionBaseUrlKey(key);
|
|
95
|
+
const redactUnknownEnvValue = (value) => {
|
|
96
|
+
const separator = value.indexOf("=");
|
|
97
|
+
if (separator !== -1) {
|
|
98
|
+
const key = value.slice(0, separator);
|
|
99
|
+
return isKnownEnvPullKey(key) ? `${key}=<redacted>` : "<redacted invalid value>";
|
|
100
|
+
}
|
|
101
|
+
return "<redacted invalid value>";
|
|
102
|
+
};
|
|
103
|
+
const suggestEnvPullKey = (value) => {
|
|
104
|
+
if (value.includes("=")) return null;
|
|
105
|
+
if (parseFunctionBaseUrlKey(value) !== null) return null;
|
|
106
|
+
const closest = ENV_PULL_KEYS.map((key) => [key, editDistance(value, key)]).sort((a, b) => a[1] - b[1])[0];
|
|
107
|
+
return closest && closest[1] <= 2 ? closest[0] : null;
|
|
108
|
+
};
|
|
109
|
+
const editDistance = (left, right) => {
|
|
110
|
+
let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
|
|
111
|
+
for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
|
|
112
|
+
const current = [leftIndex];
|
|
113
|
+
for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) current[rightIndex] = Math.min((previous[rightIndex] ?? 0) + 1, (current[rightIndex - 1] ?? 0) + 1, (previous[rightIndex - 1] ?? 0) + (left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1));
|
|
114
|
+
previous = current;
|
|
115
|
+
}
|
|
116
|
+
return previous[right.length] ?? right.length;
|
|
117
|
+
};
|
|
118
|
+
/** Narrow yargs' array option value without accepting any other runtime shape. */
|
|
119
|
+
const envPullFlagValue = (value) => Array.isArray(value) ? value.map(String) : void 0;
|
|
120
|
+
//#endregion
|
|
2
121
|
export { BRANCH_ENV_KEY, ENV_PULL_KEYS, ENV_PULL_KEY_HELP, ENV_PULL_SERVICES, envKeysForSelection, envPullFlagValue, envServiceKeys, ownedEnvServiceKeys, parseEnvPullKeys, serviceForEnvKey, servicesForEnvKeys };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
//#region src/utils/branch_name.ts
|
|
2
|
+
const MAX_LENGTH = 256;
|
|
3
|
+
const FALLBACK_NAME = "branch";
|
|
4
|
+
/** Reduce one path segment to `[a-z0-9-]`, collapsing other runs to `-` and trimming `-`. */
|
|
5
|
+
const sanitizeSegment = (segment) => segment.replace(/[^a-z0-9]+/gi, "-").replace(/^-+/, "").replace(/-+$/, "");
|
|
6
|
+
/**
|
|
7
|
+
* Convert a git branch name into a valid, stable Neon branch name — lowercased, each
|
|
8
|
+
* `/`-separated segment reduced to `[a-z0-9-]` (runs of other characters collapsed to a
|
|
9
|
+
* single `-`, leading/trailing `-` trimmed), `/` preserved as a segment separator so a
|
|
10
|
+
* hierarchical branch like `feature/billing-ui` round-trips unchanged, empty results fall
|
|
11
|
+
* back to `"branch"`, and the result is clamped to 256 characters (Neon's branch-name
|
|
12
|
+
* limit). This is the computation behind `GitContext.neonSafeBranchName` (from
|
|
13
|
+
* `@neon/config`) — call it directly only where you don't already have a `GitContext`
|
|
14
|
+
* (e.g. `neon git sync` deriving the default Neon branch name for the current git branch).
|
|
15
|
+
*
|
|
16
|
+
* @example neonSafeBranchName("feature/PROJ-123 Add Billing") // "feature/proj-123-add-billing"
|
|
17
|
+
* @example neonSafeBranchName("Hotfix!!") // "hotfix"
|
|
18
|
+
*/
|
|
19
|
+
const neonSafeBranchName = (gitBranch) => {
|
|
20
|
+
const name = gitBranch.toLowerCase().split("/").map(sanitizeSegment).filter((segment) => segment.length > 0).join("/");
|
|
21
|
+
if (name.length === 0) return FALLBACK_NAME;
|
|
22
|
+
if (name.length > MAX_LENGTH) {
|
|
23
|
+
const clamped = name.slice(0, MAX_LENGTH).replace(/[-/]+$/, "");
|
|
24
|
+
return clamped.length === 0 ? FALLBACK_NAME : clamped;
|
|
25
|
+
}
|
|
26
|
+
return name;
|
|
27
|
+
};
|
|
28
|
+
//#endregion
|
|
29
|
+
export { neonSafeBranchName };
|