diff --git a/packages/cli/src/__tests__/cmd-delete-cov.test.ts b/packages/cli/src/__tests__/cmd-delete-cov.test.ts index 15e669af..d5427359 100644 --- a/packages/cli/src/__tests__/cmd-delete-cov.test.ts +++ b/packages/cli/src/__tests__/cmd-delete-cov.test.ts @@ -380,4 +380,50 @@ describe("confirmAndDelete edge cases", () => { const result = await confirmAndDelete(record, mockManifest, handler); expect(result).toBe(false); }); + + it("fails fast when GCP record is missing project metadata", async () => { + clack.confirm.mockResolvedValue(true); + const record = makeRecord({ + cloud: "gcp", + connection: { + ip: "10.0.0.1", + user: "root", + server_name: "spawn-gcp-123", + server_id: "gcp-123", + cloud: "gcp", + metadata: { + zone: "us-central1-a", + // project intentionally omitted + }, + }, + }); + + // ensureDeleteCredentials throws before the spinner starts, + // so the error propagates as a rejection from confirmAndDelete + await expect(confirmAndDelete(record, mockManifest)).rejects.toThrow("Cannot determine GCP project"); + }); + + it("succeeds when GCP record has project metadata", async () => { + clack.confirm.mockResolvedValue(true); + // With a custom handler that simulates successful deletion, + // the project metadata path should not throw + const handler = mock(async () => true); + const record = makeRecord({ + cloud: "gcp", + connection: { + ip: "10.0.0.1", + user: "root", + server_name: "spawn-gcp-456", + server_id: "gcp-456", + cloud: "gcp", + metadata: { + zone: "us-central1-a", + project: "my-gcp-project", + }, + }, + }); + + const result = await confirmAndDelete(record, mockManifest, handler); + expect(result).toBe(true); + }); }); diff --git a/packages/cli/src/commands/delete.ts b/packages/cli/src/commands/delete.ts index 8d6e7887..cae5fbb7 100644 --- a/packages/cli/src/commands/delete.ts +++ b/packages/cli/src/commands/delete.ts @@ -43,14 +43,19 @@ async function ensureDeleteCredentials(record: SpawnRecord): Promise { case "gcp": { const zone = conn.metadata?.zone || "us-central1-a"; const project = conn.metadata?.project || ""; + if (!project) { + throw new Error( + "Cannot determine GCP project for this instance.\n\n" + + "The history entry is missing project metadata. Without it, deletion\n" + + "could target the wrong project.\n\n" + + "To fix: delete the instance manually from the GCP Console:\n" + + " https://console.cloud.google.com/compute/instances", + ); + } validateMetadataValue(zone, "GCP zone"); - if (project) { - validateMetadataValue(project, "GCP project"); - } + validateMetadataValue(project, "GCP project"); process.env.GCP_ZONE = zone; - if (project) { - process.env.GCP_PROJECT = project; - } + process.env.GCP_PROJECT = project; await gcpEnsureGcloudCli(); await gcpAuthenticate(); break; @@ -125,27 +130,25 @@ async function execDeleteServer(record: SpawnRecord): Promise { case "gcp": { const zone = conn.metadata?.zone || "us-central1-a"; const project = conn.metadata?.project || ""; + if (!project) { + throw new Error( + "Cannot determine GCP project for this instance.\n\n" + + "The history entry is missing project metadata. Without it, deletion\n" + + "could target the wrong project.\n\n" + + "To fix: delete the instance manually from the GCP Console:\n" + + " https://console.cloud.google.com/compute/instances", + ); + } // SECURITY: Validate metadata values to prevent injection via tampered history validateMetadataValue(zone, "GCP zone"); - if (project) { - validateMetadataValue(project, "GCP project"); - } + validateMetadataValue(project, "GCP project"); return tryDelete(async () => { process.env.GCP_ZONE = zone; - if (project) { - process.env.GCP_PROJECT = project; - } + process.env.GCP_PROJECT = project; await gcpEnsureGcloudCli(); await gcpAuthenticate(); - // Deletion runs under a spinner — suppress interactive prompts - const prevNonInteractive = process.env.SPAWN_NON_INTERACTIVE; - process.env.SPAWN_NON_INTERACTIVE = "1"; + // resolveProject reads GCP_PROJECT directly — no fallback needed const resolveResult = await asyncTryCatch(() => gcpResolveProject()); - if (prevNonInteractive === undefined) { - delete process.env.SPAWN_NON_INTERACTIVE; - } else { - process.env.SPAWN_NON_INTERACTIVE = prevNonInteractive; - } if (!resolveResult.ok) { throw resolveResult.error; } diff --git a/packages/cli/src/gcp/gcp.ts b/packages/cli/src/gcp/gcp.ts index 293f8317..ceb8bf78 100644 --- a/packages/cli/src/gcp/gcp.ts +++ b/packages/cli/src/gcp/gcp.ts @@ -1207,6 +1207,10 @@ export async function destroyInstance(name?: string): Promise { throw new Error("No instance name"); } + if (!_state.project) { + throw new Error("No GCP project set — cannot determine which project to delete from"); + } + logStep(`Destroying GCP instance '${instanceName}'...`); const result = await gcloud([ "compute",