Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/cli-git-lifecycle-hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"neonctl": minor
---

Sync the Neon branch to your git branch + run `neon.ts` lifecycle hooks (Preview).

- New `neonctl git` command group: `install` / `uninstall` (manage a sentinel-guarded `post-checkout` hook, honoring `core.hooksPath`), `sync` (check out the Neon branch for the current git branch — defaulting the name to `toNeonBranchName(gitBranch)` — with an opt-in `--pull` fast-forward), `status` (read-only git + mapping facts), and `cleanup` (prune stale `.neon` mappings, and with `--prune-neon-branches` delete orphaned Neon branches, never the default or a protected one).
- `checkout` and `deploy` now invoke the policy's lifecycle hooks at their existing seams: `checkout.before` may rename or abort before the branch is resolved; `checkout.after` runs once the branch + env are resolved (e.g. migrate); `deploy.before` / `deploy.after` bracket the apply. The connection `env` is resolved in memory and passed to `after` hooks even under `--no-env-pull`, typed as the exact `NeonEnv<typeof config>`.
- `.neon` gains a `git` block (`{ follow, map }`, real Neon branch slugs as values); `set-context` / `link` preserve it on write.
- Consumes the hooks contract from `@neondatabase/config` + `@neondatabase/config-runtime` directly as in-repo workspace dependencies (replaces the prior `pnpm link` stand-in).
11 changes: 11 additions & 0 deletions .changeset/lifecycle-hooks-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@neondatabase/config": minor
---

Add lifecycle hooks (Preview) and a `toNeonBranchName` / `slugify` helper.

- New top-level `hooks` policy field with `checkout` and `deploy` phases, each exposing a `before` (influence/abort) and `after` (observe) hook. A hook is a function `(ctx) => …` or a shell command (string or array). Hooks are the imperative companion to the pure `branch()` closure and are read by the runtime only on the real `checkout` / `deploy` commands — never during `plan` / `status` / `inspect`.
- New hook context types: `Hooks<C>`, `CheckoutHooks<C>`, `DeployHooks<C>`, `CheckoutBeforeContext`, `CheckoutBeforeResult`, `CheckoutAfterContext<C>`, `DeployBeforeContext`, `DeployAfterContext<C>`, `GitContext`, `HookBranch`, `Hook`, `ShellHook`. The `after` contexts are generic over the policy so their `env` is the **exact** `NeonEnv<typeof config>` — `env.auth` / `env.dataApi` / `env.storage` / `env.aiGateway` are present iff the policy enables them (no `unknown` escape hatch).
- The canonical `NeonEnv<C>` type family now lives in this package (`NeonEnv`, `NeonPostgresEnv`, `NeonBranchEnv`, `NeonAuthEnv`, `NeonDataApiEnv`, `NeonStorageEnv`, `NeonAiGatewayEnv`, `FunctionSlugOf`, `NeonFunctionEnv`). It's a pure, runtime-free shape derived from `Config`, so config can type a `neon.ts` hook's `env` exactly without a `config` → `env` dependency cycle. `@neondatabase/env` re-exports these, so its public surface is unchanged.
- New `toNeonBranchName` helper (and `ToNeonBranchNameOptions`) that derives a valid, stable Neon branch name from an arbitrary string (e.g. a git branch) — shared with the CLI's git → Neon mapping. Pass `preserveSlashes: false` for a single flat token.
- `schemas.hooks` is added to the exported schema namespace.
10 changes: 10 additions & 0 deletions .changeset/lifecycle-hooks-runtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@neondatabase/config-runtime": minor
---

Add a lifecycle-hook runner that executes `@neondatabase/config` hooks.

- `runHook(hook, ctx, options?)` runs a hook's function form (awaited, value returned) or shell-command form, returning the function result (or `undefined` for shell hooks).
- `runShellHook(hook, options?)` runs a shell command (or sequential array) **non-interactively** — stdin is detached and `CI=1` is set, so an accidental interactive command (`drizzle-kit push`) fails fast instead of hanging — with Neon env vars injected into the command environment.
- `HookExecutionError` carries the failing command and its exit code/signal.
- Re-exports the hook + branch-name types/helpers (`toNeonBranchName`, `Hooks`, `GitContext`, …) from `@neondatabase/config` for one-stop CLI imports.
5 changes: 5 additions & 0 deletions .changeset/neon-env-types-canonical.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@neondatabase/env": patch
---

Source the `NeonEnv` type family from `@neondatabase/config` (its new canonical home) and re-export it. No public API change — `NeonEnv`, `NeonPostgresEnv`, `NeonAuthEnv`, `NeonDataApiEnv`, `NeonStorageEnv`, `NeonAiGatewayEnv`, `NeonBranchEnv`, `FunctionSlugOf`, and `NeonFunctionEnv` are still exported from `@neondatabase/env/v1` with identical shapes (verified by the existing type tests). Moving the *type* (a pure shape derived from `Config`) to `config` lets `config` type `neon.ts` lifecycle-hook `env` as the exact `NeonEnv<typeof config>` without a dependency cycle; `@neondatabase/env` keeps the runtime that produces it (`fetchEnv` / `parseEnv` / `toEntries`).
6 changes: 3 additions & 3 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@
"dependencies": {
"@hono/node-server": "2.0.4",
"@neondatabase/api-client": "2.7.1",
"@neondatabase/config": "0.8.0",
"@neondatabase/config-runtime": "0.8.0",
"@neondatabase/env": "0.7.0",
"@neondatabase/config": "workspace:*",
"@neondatabase/config-runtime": "workspace:*",
"@neondatabase/env": "workspace:*",
"@segment/analytics-node": "1.3.0",
"axios": "1.7.2",
"axios-debug-log": "1.0.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ exports[`set_context > deprecation > warns (to stderr) and still performs the ra
}"
`;

exports[`set_context > non-destructive to git workflow state > preserves an existing git block 1`] = `""`;

exports[`set_context > should set the context to organization > create projects selecting organization from the context 1`] = `
"project:
id: new-project-789012
Expand Down
56 changes: 56 additions & 0 deletions packages/cli/src/commands/checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ import {
createBranchFromPolicyOnCheckout,
} from './config.js';
import { handler as linkHandler } from './link.js';
import { GIT_HOOK_ENV_FLAG, readGitContext } from '../utils/git.js';
import {
buildHookBranch,
loadHooks,
resolveHookEnv,
runCheckoutAfterHook,
runCheckoutBeforeHook,
} from '../utils/hooks.js';

type CheckoutProps = CommonProps & {
projectId?: string;
Expand Down Expand Up @@ -90,6 +98,32 @@ export const handler = async (props: CheckoutProps) => {
// nothing resolves, fall back to an interactive `neonctl link`.
const projectId = await resolveProjectId(props);

// Lifecycle hooks (Preview): read git facts + load the neon.ts `hooks` block once. The
// `checkout.before` hook may rewrite the branch name (e.g. map a git branch to a Neon
// slug) before we resolve it; it runs before resolution because the name isn't pinned yet.
const cwd = process.cwd();
const git = readGitContext(cwd, {
triggeredByGitHook: process.env[GIT_HOOK_ENV_FLAG] === '1',
});
const hooks = await loadHooks(cwd);
if (props.id) {
const renamed = await runCheckoutBeforeHook({
hooks,
inputName: props.id,
git,
cwd,
});
if (renamed && renamed !== props.id) {
log.info(
'%s checkout.before hook mapped %s → %s',
chalk.dim('→'),
chalk.cyan(props.id),
chalk.cyan.bold(renamed),
);
props.id = renamed;
}
}

const { branchId, branchName, created, policyApplied } =
await resolveBranchId(props, projectId);

Expand Down Expand Up @@ -136,6 +170,28 @@ export const handler = async (props: CheckoutProps) => {
branch: branchId,
envPull: props.envPull,
});

// `checkout.after` hook (Preview): runs once the branch is pinned and env is resolved.
// Env is resolved in-memory here even under `--no-env-pull`, so a migration hook always
// has a connection string. A hook failure degrades to a warning (the checkout stands).
if (hooks?.checkout?.after) {
const env = await resolveHookEnv({
cwd,
projectId,
branchId,
...(props.apiKey ? { apiKey: props.apiKey } : {}),
...(props.apiHost ? { apiHost: props.apiHost } : {}),
});
if (env) {
const branch = await buildHookBranch({
apiClient: props.apiClient,
projectId,
branchId,
created,
});
await runCheckoutAfterHook({ hooks, branch, env, git, cwd });
}
}
};

/**
Expand Down
54 changes: 54 additions & 0 deletions packages/cli/src/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ import { bundleEntry } from '../utils/esbuild.js';
import { zipBundle } from '../utils/zip.js';
import { writer } from '../writer.js';
import { autoPullEnvAfterPin } from './env.js';
import { GIT_HOOK_ENV_FLAG, readGitContext } from '../utils/git.js';
import {
buildHookBranch,
resolveHookEnv,
runDeployAfterHook,
runDeployBeforeHook,
} from '../utils/hooks.js';

/**
* Bundle a function with neonctl's OWN bundler (the shared esbuild helper) so the
Expand Down Expand Up @@ -276,6 +283,24 @@ export const applyCmd = async (props: ConfigProps): Promise<void> => {
const branch = await resolveBranchRef(props);
announceTargetBranch(props, branch, 'Applying to branch');
const branchId = branch.branchId;

// Lifecycle hooks (Preview): the `deploy` phase brackets the apply. `before` can validate
// or abort (a throw propagates); `after` observes the resolved env + PushResult.
const cwd = props.cwd ?? process.cwd();
const hooks = config.hooks;
const git = readGitContext(cwd, {
triggeredByGitHook: process.env[GIT_HOOK_ENV_FLAG] === '1',
});
if (hooks?.deploy?.before) {
const hookBranch = await buildHookBranch({
apiClient: props.apiClient,
projectId: props.projectId,
branchId,
created: false,
});
await runDeployBeforeHook({ hooks, branch: hookBranch, git, cwd });
}

const result = await apply(config, {
projectId: props.projectId,
branchId,
Expand All @@ -293,6 +318,35 @@ export const applyCmd = async (props: ConfigProps): Promise<void> => {
// usable for local dev. `--no-env-pull` opts out; a pull failure degrades to a warning
// (the apply already succeeded). See autoPullEnvAfterPin.
await autoPullEnvAfterPin({ ...props, envPull: props.envPull !== false });

// `deploy.after` hook (Preview): runs after a successful apply. Env is resolved in-memory
// regardless of `--env-pull`; a hook failure degrades to a warning (the apply stands).
if (hooks?.deploy?.after) {
const env = await resolveHookEnv({
cwd,
projectId: props.projectId,
branchId,
...(props.apiKey ? { apiKey: props.apiKey } : {}),
...(props.apiHost ? { apiHost: props.apiHost } : {}),
...(props.runtimeApi ? { api: props.runtimeApi } : {}),
});
if (env) {
const hookBranch = await buildHookBranch({
apiClient: props.apiClient,
projectId: props.projectId,
branchId,
created: false,
});
await runDeployAfterHook({
hooks,
branch: hookBranch,
env,
result,
git,
cwd,
});
}
}
};

type ReportMode = 'plan' | 'apply';
Expand Down
43 changes: 43 additions & 0 deletions packages/cli/src/commands/git.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, test } from 'vitest';

import { partitionBranchesToPrune } from './git.js';

describe('partitionBranchesToPrune', () => {
const branches = [
{ id: 'br-main', name: 'main', default: true },
{ id: 'br-prod', name: 'production', protected: true },
{ id: 'br-x', name: 'preview/x' },
{ id: 'br-y', name: 'preview/y' },
{ id: 'br-live', name: 'preview/live' }, // mapped from a still-present git branch
];

test('deletes only orphaned, non-default, non-protected branches', () => {
const orphans = new Set(['main', 'production', 'preview/x', 'preview/y']);
const { toDelete, skipped } = partitionBranchesToPrune(branches, orphans);

expect(toDelete.map((b) => b.name).sort()).toEqual([
'preview/x',
'preview/y',
]);
expect(skipped).toEqual([
{ name: 'main', reason: 'default branch' },
{ name: 'production', reason: 'protected' },
]);
});

test('never touches a branch that is not orphaned', () => {
const { toDelete } = partitionBranchesToPrune(
branches,
new Set(['preview/x']),
);
expect(toDelete.map((b) => b.name)).toEqual(['preview/x']);
// `preview/live` is not in the orphan set → kept.
expect(toDelete.some((b) => b.name === 'preview/live')).toBe(false);
});

test('returns nothing to delete when there are no orphans', () => {
const { toDelete, skipped } = partitionBranchesToPrune(branches, new Set());
expect(toDelete).toEqual([]);
expect(skipped).toEqual([]);
});
});
Loading
Loading