Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/neon-context-preserve-fields.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions packages/cli/src/commands/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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`,
);
Expand Down
110 changes: 110 additions & 0 deletions packages/cli/src/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest";

import {
applyContext,
clearContextFile,
currentContextFile,
ensureGitignored,
isCurrentBranchProbe,
updateContextFile,
walkContextFile,
} from "./context.js";

Expand Down Expand Up @@ -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({});
});
});
65 changes: 61 additions & 4 deletions packages/cli/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>([
"orgId",
"projectId",
"branch",
"branchId",
]);

/** The keys in an existing `.neon` that a context write must not disturb. */
const readForeignKeys = (file: string): Record<string, unknown> => {
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<string, unknown> = {};
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
Expand Down
Loading