diff --git a/.changeset/neon-context-preserve-fields.md b/.changeset/neon-context-preserve-fields.md new file mode 100644 index 00000000..d10e2c4f --- /dev/null +++ b/.changeset/neon-context-preserve-fields.md @@ -0,0 +1,5 @@ +--- +"neon": patch +--- + +Writing the `.neon` context file via `link`, `checkout`, or `set-context` now preserves fields the command doesn't own instead of overwriting the whole file. Managed keys (`orgId`, `projectId`, `branch`, `branchId`) are still governed by the write, but foreign keys, most importantly the ephemeral `_init` state `neon init` stashes, are carried forward. `neon link --clear` still resets the file wholesale. diff --git a/packages/cli/src/commands/link.ts b/packages/cli/src/commands/link.ts index 575e9afb..a73f8d95 100644 --- a/packages/cli/src/commands/link.ts +++ b/packages/cli/src/commands/link.ts @@ -12,10 +12,10 @@ import { isNeonApiError, messageFromBody } from "../api.js"; import { applyContext, type Context, + clearContextFile, contextBranch, readContextFile, setContext, - updateContextFile, } from "../context.js"; import { isCi } from "../env.js"; import { log } from "../log.js"; @@ -322,7 +322,7 @@ const canResolveNonInteractively = ( // ---------------------------------------------------------------------------- const clearContext = (contextFile: string): void => { - updateContextFile(contextFile, {}); + clearContextFile(contextFile); process.stdout.write( `Cleared ${contextFile}. The directory is no longer linked to a Neon org/project/branch.\n`, ); diff --git a/packages/cli/src/context.test.ts b/packages/cli/src/context.test.ts index d4b19d30..9d2ea843 100644 --- a/packages/cli/src/context.test.ts +++ b/packages/cli/src/context.test.ts @@ -11,9 +11,11 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { applyContext, + clearContextFile, currentContextFile, ensureGitignored, isCurrentBranchProbe, + updateContextFile, walkContextFile, } from "./context.js"; @@ -353,3 +355,111 @@ describe("applyContext", () => { ); }); }); + +describe("updateContextFile foreign-key preservation", () => { + let workspace: string; + + beforeEach(() => { + workspace = mkdtempSync(join(tmpdir(), "neonctl-update-")); + }); + + afterEach(() => { + rmSync(workspace, { recursive: true, force: true }); + }); + + test("preserves foreign keys (e.g. init's _init) while writing managed fields", () => { + const file = join(workspace, ".neon"); + writeFileSync( + file, + JSON.stringify({ orgId: "org-old", _init: { features: ["auth"] } }), + ); + + updateContextFile(file, { + orgId: "org-new", + projectId: "proj-1", + branch: "main", + }); + + expect(JSON.parse(readFileSync(file, "utf-8"))).toEqual({ + orgId: "org-new", + projectId: "proj-1", + branch: "main", + _init: { features: ["auth"] }, + }); + }); + + test("drops managed fields absent from the write, keeps foreign ones", () => { + const file = join(workspace, ".neon"); + writeFileSync( + file, + JSON.stringify({ + orgId: "org-1", + projectId: "proj-1", + branch: "feat", + _init: { step: "getting-started" }, + }), + ); + + // Re-link the same project without a branch: the stale branch must clear, + // but the foreign _init must survive. + updateContextFile(file, { orgId: "org-1", projectId: "proj-1" }); + + expect(JSON.parse(readFileSync(file, "utf-8"))).toEqual({ + orgId: "org-1", + projectId: "proj-1", + _init: { step: "getting-started" }, + }); + }); + + test("replaces the legacy branchId rather than preserving it as foreign", () => { + const file = join(workspace, ".neon"); + writeFileSync( + file, + JSON.stringify({ projectId: "proj-1", branchId: "br-old" }), + ); + + updateContextFile(file, { projectId: "proj-1", branch: "main" }); + + expect(JSON.parse(readFileSync(file, "utf-8"))).toEqual({ + projectId: "proj-1", + branch: "main", + }); + }); + + test("writes managed fields as-is when the file does not yet exist", () => { + const file = join(workspace, ".neon"); + updateContextFile(file, { orgId: "org-1", projectId: "proj-1" }); + expect(JSON.parse(readFileSync(file, "utf-8"))).toEqual({ + orgId: "org-1", + projectId: "proj-1", + }); + }); +}); + +describe("clearContextFile", () => { + let workspace: string; + + beforeEach(() => { + workspace = mkdtempSync(join(tmpdir(), "neonctl-clear-")); + }); + + afterEach(() => { + rmSync(workspace, { recursive: true, force: true }); + }); + + test("wipes the file wholesale, dropping foreign keys too", () => { + const file = join(workspace, ".neon"); + writeFileSync( + file, + JSON.stringify({ + orgId: "org-1", + projectId: "proj-1", + _init: { features: ["auth"] }, + }), + ); + + clearContextFile(file); + + expect(JSON.parse(readFileSync(file, "utf-8"))).toEqual({}); + }); +}); diff --git a/packages/cli/src/context.ts b/packages/cli/src/context.ts index e23c7ea0..3610f705 100644 --- a/packages/cli/src/context.ts +++ b/packages/cli/src/context.ts @@ -205,15 +205,72 @@ export const enrichFromContext = ( } }; +/** + * The context fields these commands own. On write we replace exactly these keys + * from the supplied `context`; every *other* key already in the file (e.g. the + * ephemeral `_init` state `neon init` stashes, or anything a user hand-added) is + * carried forward untouched. `branchId` is listed as managed — not to preserve + * it, but so the legacy field is replaced/dropped like the others rather than + * lingering as a "foreign" key (see {@link Context.branchId}). + */ +const MANAGED_CONTEXT_KEYS = new Set([ + "orgId", + "projectId", + "branch", + "branchId", +]); + +/** The keys in an existing `.neon` that a context write must not disturb. */ +const readForeignKeys = (file: string): Record => { + let raw: unknown; + try { + raw = JSON.parse(readFileSync(file, "utf-8")); + } catch { + return {}; + } + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + return {}; + } + const foreign: Record = {}; + for (const [key, value] of Object.entries(raw)) { + if (!MANAGED_CONTEXT_KEYS.has(key)) { + foreign[key] = value; + } + } + return foreign; +}; + +/** + * Persist the managed context fields to `.neon` while preserving any foreign + * keys already in the file. + * + * The managed keys ({@link MANAGED_CONTEXT_KEYS}) are governed entirely by + * `context`: a field absent from `context` is dropped from the file, so + * re-linking a project without a branch still clears a stale one. Everything + * else in the file — most importantly the ephemeral `_init` state that + * `neon init` writes — is read back and merged in, so a single `neon link` no + * longer clobbers an in-progress init. To wipe the file wholesale (foreign keys + * included), use {@link clearContextFile}. + */ export const updateContextFile = (file: string, context: Context) => { - writeFileSync(file, JSON.stringify(context, null, 2)); + const merged = { ...readForeignKeys(file), ...context }; + writeFileSync(file, JSON.stringify(merged, null, 2)); +}; + +/** + * Reset `.neon` to an empty context, dropping foreign keys too — the `--clear` + * "forget this directory" path, distinct from the field-preserving + * {@link updateContextFile}. + */ +export const clearContextFile = (file: string) => { + writeFileSync(file, JSON.stringify({}, null, 2)); }; /** * Shared primitive used by `link`, the deprecated `set-context`, and `checkout` - * to persist context. Mirrors the destructive write semantics of - * `updateContextFile` — any field not present in `context` is dropped from the - * file. + * to persist context. Delegates to {@link updateContextFile}, so the managed + * fields in `context` are written while foreign keys (like init's `_init`) are + * preserved. * * `.gitignore` scaffolding only happens when the context file is being * *created* (it didn't exist before this write). On updates to an existing