gencow 0.1.253 → 0.1.254

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/core/index.js CHANGED
@@ -446,90 +446,6 @@ function isWakeAppDeferredResult(result) {
446
446
  return !result.ok && (result.status === "capacity_rejected" || result.status === "queue_timeout" || result.status === "convergence_pending");
447
447
  }
448
448
 
449
- // ../core/src/platform-capacity-profile.ts
450
- var PLATFORM_CAPACITY_PROFILE_ENV = "GENCOW_CAPACITY_PROFILE";
451
- var PLATFORM_CAPACITY_ENV_KEYS = [
452
- PLATFORM_CAPACITY_PROFILE_ENV,
453
- "MAX_CONCURRENT_RUNNING",
454
- "MAX_CONCURRENT_WAKE",
455
- "MAX_WAKE_QUEUE_MS",
456
- "MIN_AVAILABLE_RAM_MB",
457
- "WAKE_RETRY_AFTER_SEC",
458
- "EVICTION_THRESHOLD_MB",
459
- "GENCOW_SLEEP_TIMEOUT_MINUTES",
460
- "GENCOW_SLEEP_MAX_PER_CYCLE",
461
- "WARM_POOL_MIN_IDLE",
462
- "WARM_POOL_MAX",
463
- "DEPLOY_CANDIDATE_RESERVE",
464
- "GENCOW_APP_PORT_RANGE",
465
- "COWBOX_WARM_POOL_PORT_RANGE"
466
- ];
467
- var PLATFORM_CAPACITY_PRESETS = Object.freeze({
468
- prod: Object.freeze({
469
- profile: "prod",
470
- maxConcurrentRunning: 600,
471
- maxConcurrentWake: 20,
472
- maxWakeQueueMs: 5e3,
473
- minAvailableRamMB: 8192,
474
- evictionThresholdMB: 16384,
475
- sleepTimeoutMinutes: 15,
476
- sleepMaxPerCycle: 50,
477
- warmPoolMinIdle: 10,
478
- warmPoolMax: 650,
479
- deployCandidateReserve: 40
480
- }),
481
- dev: Object.freeze({
482
- profile: "dev",
483
- maxConcurrentRunning: 60,
484
- maxConcurrentWake: 5,
485
- maxWakeQueueMs: 3e3,
486
- minAvailableRamMB: 2048,
487
- evictionThresholdMB: 4096,
488
- sleepTimeoutMinutes: 10,
489
- sleepMaxPerCycle: 20,
490
- warmPoolMinIdle: 3,
491
- warmPoolMax: 70,
492
- deployCandidateReserve: 5
493
- }),
494
- local: Object.freeze({
495
- profile: "local",
496
- maxConcurrentRunning: null,
497
- maxConcurrentWake: null,
498
- maxWakeQueueMs: 5e3,
499
- minAvailableRamMB: null,
500
- evictionThresholdMB: null,
501
- sleepTimeoutMinutes: 30,
502
- sleepMaxPerCycle: 10,
503
- warmPoolMinIdle: 3,
504
- warmPoolMax: 10,
505
- deployCandidateReserve: 0
506
- })
507
- });
508
- function normalizeDomain(value) {
509
- return (value || "").trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
510
- }
511
- function detectPlatformCapacityProfile(env = process.env) {
512
- const explicit = env[PLATFORM_CAPACITY_PROFILE_ENV]?.trim().toLowerCase();
513
- if (explicit) {
514
- if (["prod", "production", "128gb"].includes(explicit)) return "prod";
515
- if (["dev", "development", "16gb"].includes(explicit)) return "dev";
516
- if (["local", "test", "disabled"].includes(explicit)) return "local";
517
- throw new Error(`Invalid ${PLATFORM_CAPACITY_PROFILE_ENV}: ${explicit}. Expected prod, dev, or local.`);
518
- }
519
- const baseDomain = normalizeDomain(env.BASE_DOMAIN);
520
- if (baseDomain === "gencow.dev") return "dev";
521
- if (baseDomain === "gencow.app") return "prod";
522
- const platformUrl = normalizeDomain(env.GENCOW_PLATFORM_URL);
523
- if (platformUrl === "gencow.dev") return "dev";
524
- if (platformUrl === "gencow.app") return "prod";
525
- if (env.NODE_ENV === "production" && env.IS_PLATFORM === "true") return "prod";
526
- return "local";
527
- }
528
- function resolvePlatformCapacityPreset(env = process.env) {
529
- const profile = detectPlatformCapacityProfile(env);
530
- return { ...PLATFORM_CAPACITY_PRESETS[profile] };
531
- }
532
-
533
449
  // ../core/src/grounded-answer-types.ts
534
450
  var DEFAULT_GROUNDING_BUDGET = {
535
451
  maxVerifyLoops: 2,
@@ -6189,9 +6105,6 @@ export {
6189
6105
  GencowValidationError,
6190
6106
  JobAdmissionError,
6191
6107
  MAX_FILE_SIZE,
6192
- PLATFORM_CAPACITY_ENV_KEYS,
6193
- PLATFORM_CAPACITY_PRESETS,
6194
- PLATFORM_CAPACITY_PROFILE_ENV,
6195
6108
  RLS_SESSION_KEY_RE,
6196
6109
  RealtimeChannelEventValidationError,
6197
6110
  STORAGE_LIST_DEFAULT_LIMIT,
@@ -6251,7 +6164,6 @@ export {
6251
6164
  defineRealtimeSubscriptions,
6252
6165
  deriveWorkflowStatus,
6253
6166
  deserializeWorkflowValue,
6254
- detectPlatformCapacityProfile,
6255
6167
  eqRlsContextColumn,
6256
6168
  filterTenantRuntimeEnvVars,
6257
6169
  formatBytes,
@@ -6305,7 +6217,6 @@ export {
6305
6217
  resetRealtimeChannelRegistryForTests,
6306
6218
  resetRealtimeSubscriptionRegistryForTests,
6307
6219
  resolveExplicitRealtimeChannelKey,
6308
- resolvePlatformCapacityPreset,
6309
6220
  resolveRealtimeChannelKey,
6310
6221
  resolveRealtimeQueryScopeHash,
6311
6222
  resolveRealtimeQuerySubscriptionKey,
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
3
- import { resolve } from "path";
3
+ import { basename, resolve } from "path";
4
4
  import {
5
5
  fetchAppDiagnosticLogsViaRpc,
6
6
  formatAppDiagnosticLogBlock,
@@ -22,6 +22,7 @@ import { resolveProjectMetadataPath, resolveProjectSelection } from "./project-c
22
22
  import { updateEnvLocalUrl } from "./cli-project-runtime.mjs";
23
23
 
24
24
  const DEFAULT_APP_CREATE_TIMEOUT_MS = 60_000;
25
+ const SAFE_CLI_OPTION_PATTERN = /^--?[A-Za-z][A-Za-z0-9-]{0,40}$/u;
25
26
 
26
27
  function resolveAppCreateTimeoutMs(value) {
27
28
  const parsed = Number(value);
@@ -38,6 +39,11 @@ function invalidAppResponse(operation, response, kind) {
38
39
  return `CLI_APP_RESPONSE_INVALID: ${operation} returned an invalid ${kind} response (${appResponseStatus(response)})`;
39
40
  }
40
41
 
42
+ function isTerminalAppDeleteResponse(body) {
43
+ if (!body || typeof body !== "object" || Array.isArray(body)) return false;
44
+ return body.retryable === false || body.retryClass === "terminal-client";
45
+ }
46
+
41
47
  export function formatAppDeployedAgo(dateStr, now = Date.now()) {
42
48
  if (!dateStr) return `${DIM}never${RESET}`;
43
49
  const diff = now - new Date(dateStr).getTime();
@@ -316,9 +322,17 @@ export function createAppCommand({
316
322
  }
317
323
 
318
324
  if (subcmd === "create") {
319
- const name = rest[0];
325
+ const unsupportedOption = rest.find((value) => value.startsWith("-"));
326
+ const name = rest.find((value) => !value.startsWith("-"));
327
+ if (unsupportedOption !== undefined) {
328
+ const optionLabel = SAFE_CLI_OPTION_PATTERN.test(unsupportedOption) ? `${unsupportedOption} ` : "";
329
+ errorImpl(
330
+ `Unknown option for app create: ${optionLabel}is not supported. The app name is the only argument.\nCode: APP_CREATE_USAGE_INVALID\nUsage: gencow app create <name>`,
331
+ );
332
+ return 1;
333
+ }
320
334
  if (!name) {
321
- errorImpl("Usage: gencow app create <name>");
335
+ errorImpl("Usage: gencow app create <name>\nCode: APP_CREATE_USAGE_INVALID");
322
336
  return 1;
323
337
  }
324
338
 
@@ -384,13 +398,15 @@ export function createAppCommand({
384
398
  successImpl(`${config.rootDir}/index.ts — created (starter)`);
385
399
  }
386
400
 
387
- const configPath = projectSelection.configPath ?? resolve(projectDir, "gencow.config.ts");
388
- if (existsSync(configPath)) {
401
+ // The same resolved config file is used for the write and for the message, so the
402
+ // reported name always matches the file that was actually changed.
403
+ const configPath = projectSelection.configPath ?? null;
404
+ if (configPath && existsSync(configPath)) {
389
405
  const src = readFileSync(configPath, "utf8");
390
406
  const nextSrc = addDeployAppToConfigSource(src, appId);
391
407
  if (nextSrc !== src) {
392
408
  writeFileSync(configPath, nextSrc);
393
- successImpl(`gencow.config.ts — added deploy.app = "${appId}"`);
409
+ successImpl(`${basename(configPath)} — added deploy.app = "${appId}"`);
394
410
  }
395
411
  }
396
412
 
@@ -400,8 +416,11 @@ export function createAppCommand({
400
416
  ${GREEN}▸${RESET} App ID: ${appId}
401
417
  ${GREEN}▸${RESET} API: ${appUrl}
402
418
  ${dashboardLine}
403
- ${GREEN}▸${RESET} .env: VITE_API_URL set
404
-
419
+ ${GREEN}▸${RESET} .env: VITE_API_URL set (dev app)
420
+ ${GREEN}▸${RESET} Prod app: not created yet. Your first ${GREEN}gencow deploy --prod${RESET} creates it.
421
+ `);
422
+ logImpl(`
423
+ ${DIM}gencow deploy --prod — create the prod app and deploy to it${RESET}
405
424
  ${DIM}pnpm gencow dev — watch & auto-deploy to cloud + live logs${RESET}
406
425
  `);
407
426
  return 0;
@@ -448,6 +467,12 @@ ${dashboardLine}
448
467
  delRes,
449
468
  { expectedAppId: name },
450
469
  );
470
+ // A typed terminal failure (for example APP_NOT_FOUND) is not retryable, whatever its HTTP
471
+ // status. Telling the user to replay the same request contradicts the server classification.
472
+ if (isTerminalAppDeleteResponse(delData)) {
473
+ errorImpl(`${diagnosis}\nNext command: gencow app list`);
474
+ return 1;
475
+ }
451
476
  errorImpl(delRes.ok || delRes.status >= 500
452
477
  ? `${diagnosis}\nRequest ID: ${requestId}. Retry the same request: ${retryCommand}`
453
478
  : diagnosis);
@@ -1,6 +1,8 @@
1
1
  import { formatProcedureRpcDiagnostic } from "./procedure-rpc-diagnostic.mjs";
2
2
 
3
3
  const SAFE_CORRELATION_ID = /^[A-Za-z0-9_-]{8,128}$/u;
4
+ // A project-relative directory that can be pasted into a shell command as-is.
5
+ const SAFE_DISPLAY_DIR = /^(?![/\\])(?!.*(?:^|\/)\.\.(?:\/|$))[A-Za-z0-9._/-]{1,240}$/u;
4
6
  const SAFE_PLATFORM_ERROR_DETAILS = new Map([
5
7
  ["ACCOUNT_DELETED", "This account has been deleted."],
6
8
  ["ACCOUNT_DELETION_PENDING", "This account is pending deletion."],
@@ -47,8 +49,62 @@ const SAFE_LOCAL_ERROR_CODES = new Map([
47
49
  pathLabel: "Conflicting source",
48
50
  },
49
51
  ],
52
+ [
53
+ "CODEGEN_SOURCE_SYNTAX_ERROR",
54
+ {
55
+ message: "Deploy stopped before upload: a backend source file has a syntax error.",
56
+ stage: "codegen",
57
+ action:
58
+ "Retryable: yes, after the source at the location above is fixed — run `npx gencow@latest codegen` to confirm it passes, then rerun your deploy command (for example `npx gencow@latest deploy --prod`).",
59
+ locationLabel: "Source location",
60
+ },
61
+ ],
62
+ [
63
+ "CODEGEN_SOURCE_LOAD_FAILED",
64
+ {
65
+ message: "Deploy stopped before upload: a backend source file throws while it is loaded.",
66
+ stage: "codegen",
67
+ action:
68
+ "Retryable: yes, after the top-level code that throws during module load is fixed — run `npx gencow@latest codegen` to confirm it passes, then rerun your deploy command. The throw location is not available; check the backend files that run code at import time.",
69
+ },
70
+ ],
71
+ [
72
+ "CODEGEN_GENERATION_FAILED",
73
+ {
74
+ message: "Deploy stopped before upload: backend code generation failed.",
75
+ stage: "codegen",
76
+ action:
77
+ "Retryable: no, until `npx gencow@latest codegen` passes locally — run it, fix what it reports, then rerun your deploy command.",
78
+ },
79
+ ],
80
+ [
81
+ "STATIC_ARTIFACT_INDEX_MISSING",
82
+ {
83
+ message: "Static deploy stopped before upload: the build output has no index.html at its root.",
84
+ stage: "artifact",
85
+ action: (caught) => {
86
+ const dir = safeDisplayDir(caught);
87
+ const target = dir ?? "<build-output-dir>";
88
+ return `Retryable: yes, after the build writes index.html into ${target} — run \`ls ${target}/index.html\` to confirm, then rerun \`gencow static ${target}\`.`;
89
+ },
90
+ },
91
+ ],
50
92
  ]);
51
93
 
94
+ function safeDisplayDir(caught) {
95
+ const { displayDir } = caught;
96
+ return typeof displayDir === "string" && SAFE_DISPLAY_DIR.test(displayDir) ? displayDir : null;
97
+ }
98
+ const SAFE_SOURCE_LOCATION_PATH = /^(?![/\\])(?!.*(?:^|\/)\.\.(?:\/|$))[^\u0000-\u001f\u007f]{1,240}$/u;
99
+
100
+ function safeSourceLocation(caught) {
101
+ const { sourceFile, line, column } = caught;
102
+ if (typeof sourceFile !== "string" || !SAFE_SOURCE_LOCATION_PATH.test(sourceFile)) return null;
103
+ if (!Number.isInteger(line) || line < 1) return null;
104
+ if (column !== undefined && (!Number.isInteger(column) || column < 1)) return null;
105
+ return `${sourceFile}:${line}${column === undefined ? "" : `:${column}`}`;
106
+ }
107
+
52
108
  function safePlatformOrigin(value) {
53
109
  try {
54
110
  const url = new URL(value);
@@ -58,6 +114,10 @@ function safePlatformOrigin(value) {
58
114
  }
59
115
  }
60
116
 
117
+ export function formatCommandError(caught) {
118
+ return commandErrorMessage(caught);
119
+ }
120
+
61
121
  function commandErrorMessage(caught) {
62
122
  const procedureDiagnostic = formatProcedureRpcDiagnostic(caught);
63
123
  if (procedureDiagnostic) return procedureDiagnostic;
@@ -67,10 +127,12 @@ function commandErrorMessage(caught) {
67
127
  localDiagnostic.message,
68
128
  `Code: ${caught.code}`,
69
129
  `Stage: ${localDiagnostic.stage}`,
70
- "Database mutation: NONE",
71
- "Incumbent serving: PRESERVED",
72
- localDiagnostic.action,
73
130
  ];
131
+ const location = safeSourceLocation(caught);
132
+ if (location) lines.push(`${localDiagnostic.locationLabel ?? "Source location"}: ${location}`);
133
+ const action =
134
+ typeof localDiagnostic.action === "function" ? localDiagnostic.action(caught) : localDiagnostic.action;
135
+ lines.push("Database mutation: NONE", "Incumbent serving: PRESERVED", action);
74
136
  const diagnosticPaths = Array.isArray(caught.missingPaths)
75
137
  ? caught.missingPaths
76
138
  : Array.isArray(caught.conflictingPaths)
@@ -262,7 +262,7 @@ async function loadJsConfig(absPath, mtimeMs) {
262
262
 
263
263
  export async function loadConfigModuleForRuntime(options = {}) {
264
264
  const {
265
- importSourceConfigImpl = () => import("../../internal/src/config/index.ts"),
265
+ importSourceConfigImpl = () => import("../../shared/src/config/index.ts"),
266
266
  importRuntimeConfigImpl = () => import("../runtime/config.mjs"),
267
267
  } = options;
268
268
  if (hasWorkspaceConfigSourceRuntime(options)) {
@@ -0,0 +1,101 @@
1
+ // Shared confirmation gate for CLI commands that require an explicit yes before
2
+ // a production mutation. It decides whether to skip, prompt, or fail fast with a
3
+ // typed diagnostic, so a non-interactive run never waits on a prompt forever.
4
+
5
+ export const CONFIRMATION_REQUIRED_NON_INTERACTIVE = "CONFIRMATION_REQUIRED_NON_INTERACTIVE";
6
+
7
+ export const CONFIRMATION_MODE = Object.freeze({
8
+ BYPASS: "BYPASS",
9
+ NON_INTERACTIVE: "NON_INTERACTIVE",
10
+ PROMPT: "PROMPT",
11
+ });
12
+
13
+ // Order matters: explicit --yes/-y passes first, then (only where a command opts in)
14
+ // CI=true passes, then a non-TTY stdin fails fast, and only an interactive terminal
15
+ // gets the existing prompt. CI=true is never an approval for a production DB change, so
16
+ // db:push leaves allowCiBypass false. Only deploy's first prod create opts in.
17
+ // An injected createInterface stands in for the terminal prompt channel.
18
+ export function resolveConfirmationMode({
19
+ args = [],
20
+ processEnv = process.env,
21
+ createInterfaceImpl,
22
+ allowCiBypass = false,
23
+ } = {}) {
24
+ if (args.includes("--yes") || args.includes("-y") || (allowCiBypass && processEnv.CI === "true")) {
25
+ return CONFIRMATION_MODE.BYPASS;
26
+ }
27
+ const interactive = createInterfaceImpl ? true : Boolean(process.stdin.isTTY);
28
+ return interactive ? CONFIRMATION_MODE.PROMPT : CONFIRMATION_MODE.NON_INTERACTIVE;
29
+ }
30
+
31
+ // Resolves to the raw answer string, or null when input ended before any answer
32
+ // (EOF or readline close). Null must never be treated as a silent wait.
33
+ export function askConfirmation({ createInterface, prompt, output }) {
34
+ return new Promise((resolveAnswer) => {
35
+ let settled = false;
36
+ const settle = (value) => {
37
+ if (settled) return;
38
+ settled = true;
39
+ resolveAnswer(value);
40
+ };
41
+ const rl = createInterface({ input: process.stdin, output });
42
+ rl.on?.("close", () => settle(null));
43
+ rl.question(prompt, (answer) => {
44
+ settle(answer);
45
+ rl.close();
46
+ });
47
+ });
48
+ }
49
+
50
+ export function buildConfirmationRequiredDiagnostic({
51
+ correlationId,
52
+ inputEnded = false,
53
+ retryCommand,
54
+ subject,
55
+ }) {
56
+ const reasonDetail = inputEnded
57
+ ? "Input ended before an answer was given, so nothing was confirmed."
58
+ : "stdin is not a terminal, so the confirmation prompt was not shown.";
59
+ return {
60
+ diagnosticVersion: 1,
61
+ code: CONFIRMATION_REQUIRED_NON_INTERACTIVE,
62
+ failureClass: "USER_FIXABLE",
63
+ phase: "CONFIRM",
64
+ databaseState: "UNCHANGED",
65
+ currentServingState: "UNCHANGED",
66
+ safeToRetryNow: false,
67
+ retryPolicy: "AFTER_FIX",
68
+ retryCondition:
69
+ "Run the same command again with --yes in a non-interactive shell, or answer the prompt in an interactive terminal.",
70
+ correlationId,
71
+ reason: {
72
+ code: "confirmation_required_non_interactive",
73
+ summary: subject,
74
+ detail: reasonDetail,
75
+ },
76
+ nextAction: { kind: "RETRY", command: retryCommand },
77
+ redaction: { serverReturnedRawSql: false, localExcerpt: "NONE" },
78
+ };
79
+ }
80
+
81
+ export function renderConfirmationRequiredLines(diagnostic) {
82
+ return {
83
+ headline: diagnostic.reason.summary,
84
+ lines: [
85
+ `Reason: ${diagnostic.reason.detail}`,
86
+ `Code: ${diagnostic.code}`,
87
+ `Phase: ${diagnostic.phase}`,
88
+ `Database state: ${diagnostic.databaseState}`,
89
+ `Current serving state: ${diagnostic.currentServingState}`,
90
+ `Retry condition: ${diagnostic.retryCondition}`,
91
+ `Next action: ${diagnostic.nextAction.command}`,
92
+ `Correlation ID: ${diagnostic.correlationId}`,
93
+ ],
94
+ };
95
+ }
96
+
97
+ export function renderConfirmationRequired(diagnostic, { errorImpl, logImpl }) {
98
+ const { headline, lines } = renderConfirmationRequiredLines(diagnostic);
99
+ errorImpl(headline);
100
+ lines.forEach((line) => logImpl(line));
101
+ }
@@ -103,6 +103,9 @@ function renderDbPushHelp(logImpl = log) {
103
103
  logImpl(` ${BOLD}Usage:${RESET} gencow db:push [options]\n`);
104
104
  logImpl(` ${BOLD}Options:${RESET}`);
105
105
  logImpl(` ${DIM}--prod${RESET} Push to production DB (confirmation required)`);
106
+ logImpl(
107
+ ` ${DIM}--yes, -y${RESET} Confirm --prod non-interactively (required when stdin is not a TTY; CI=true does not confirm)`,
108
+ );
106
109
  logImpl(` ${DIM}--local${RESET} Push to local dev DB`);
107
110
  logImpl(` ${DIM}--app, -a${RESET} Target specific app`);
108
111
  logImpl(` ${DIM}--existing-bundle${RESET} Push committed migrations without running a generator`);
@@ -3,9 +3,14 @@ import {
3
3
  buildLocalExecutionFailureDiagnostic,
4
4
  } from "./migration-client-diagnostic.mjs";
5
5
  import { resolveBackendEnvFile } from "./backend-env-resolver.mjs";
6
- import { buildMigrationNoResponseDiagnostic } from "./migration-diagnostic.mjs";
6
+ import {
7
+ buildMigrationNoResponseDiagnostic,
8
+ buildMigrationReceiptEnvironmentMismatchDiagnostic,
9
+ buildMigrationStatusUnsupportedResponseDiagnostic,
10
+ } from "./migration-diagnostic.mjs";
7
11
  import { buildMigrationStatusCommand, fetchMigrationOperationStatus } from "./migration-operation-client.mjs";
8
12
  import { BOLD, CYAN, DIM, RED, RESET } from "./output.mjs";
13
+ import { askConfirmation } from "./confirm-gate.mjs";
9
14
  import { emitMigrationAdvisories, extractMigrationAdvisories } from "./migration-advisory.mjs";
10
15
 
11
16
  const NO_CHANGE_MARKERS = ["No schema changes", "nothing to migrate", "No changes detected"];
@@ -15,18 +20,20 @@ export function isNoChangeGenerationError(error) {
15
20
  return NO_CHANGE_MARKERS.some((marker) => message.includes(marker));
16
21
  }
17
22
 
23
+ // Resolves to true (typed "yes"), false (any other answer), or null when input
24
+ // ended before an answer. Callers must treat null as a non-cancel failure.
18
25
  export async function promptDbPushConfirm(appId, createInterfaceImpl, plain = false) {
19
26
  const createInterface = createInterfaceImpl ?? (await import("readline")).createInterface;
20
- const rl = createInterface({ input: process.stdin, output: plain ? process.stderr : process.stdout });
21
- return await new Promise((resolveAnswer) => {
22
- const prompt = plain
23
- ? `\nPush schema to PRODUCTION database (${appId})?\nType "yes" to confirm: `
24
- : `\n ${RED}!${RESET} Push schema to ${BOLD}PRODUCTION${RESET} database (${appId})?\n Type "yes" to confirm: `;
25
- rl.question(prompt, (answer) => {
26
- rl.close();
27
- resolveAnswer(answer.trim().toLowerCase() === "yes");
28
- });
27
+ const prompt = plain
28
+ ? `\nPush schema to PRODUCTION database (${appId})?\nType "yes" to confirm: `
29
+ : `\n ${RED}!${RESET} Push schema to ${BOLD}PRODUCTION${RESET} database (${appId})?\n Type "yes" to confirm: `;
30
+ const answer = await askConfirmation({
31
+ createInterface,
32
+ prompt,
33
+ output: plain ? process.stderr : process.stdout,
29
34
  });
35
+ if (answer === null) return null;
36
+ return answer.trim().toLowerCase() === "yes";
30
37
  }
31
38
 
32
39
  export function handleLocalMigrationCommand({
@@ -114,10 +121,36 @@ export async function handleMigrationStatus({
114
121
  receiptId,
115
122
  cliVersion: deps.cliVersion,
116
123
  });
117
- if (!status) {
124
+ if (status.kind === "unreachable") {
118
125
  emitFailure(buildMigrationNoResponseDiagnostic(correlationId(), { receiptId, statusCommand }));
119
126
  return;
120
127
  }
128
+ if (status.kind === "server_diagnostic") {
129
+ // The server answered with a typed diagnostic: render it as-is, never replace it with NOT_RECEIVED.
130
+ emitFailure({ ...status.diagnostic, receiptId });
131
+ return;
132
+ }
133
+ if (status.kind === "unsupported") {
134
+ emitFailure(buildMigrationStatusUnsupportedResponseDiagnostic(correlationId(), { receiptId, statusCommand }));
135
+ return;
136
+ }
137
+ if (status.kind === "environment_mismatch") {
138
+ // The receipt's own environment is authoritative; the command is rebuilt from it, not from this call's flags.
139
+ emitFailure(
140
+ buildMigrationReceiptEnvironmentMismatchDiagnostic(correlationId(), {
141
+ receiptId,
142
+ receiptEnvironment: status.receipt.environment,
143
+ requestedEnvironment: environment,
144
+ statusCommand: buildMigrationStatusCommand({
145
+ appId,
146
+ environment: status.receipt.environment,
147
+ receiptId,
148
+ jsonMode,
149
+ }),
150
+ }),
151
+ );
152
+ return;
153
+ }
121
154
  if (status.kind === "blocked") {
122
155
  emitFailure({ ...status.diagnostic, receiptId: status.receipt.receiptId });
123
156
  return;
@@ -10,6 +10,7 @@ import {
10
10
  } from "./drizzle-generate.mjs";
11
11
  import {
12
12
  buildMigrationNoResponseDiagnostic,
13
+ buildMigrationCheckInvalidResponseDiagnostic,
13
14
  buildMigrationCheckNoResponseDiagnostic,
14
15
  buildMigrationCheckUnsupportedResponseDiagnostic,
15
16
  buildMigrationUnsupportedResponseDiagnostic,
@@ -43,6 +44,12 @@ import {
43
44
  promptDbPushConfirm,
44
45
  } from "./db-migration-command-steps.mjs";
45
46
  import { resolveConfiguredSchemaPaths, resolveDrizzleSchemaPaths } from "./cli-schema-paths.mjs";
47
+ import {
48
+ CONFIRMATION_MODE,
49
+ buildConfirmationRequiredDiagnostic,
50
+ renderConfirmationRequired,
51
+ resolveConfirmationMode,
52
+ } from "./confirm-gate.mjs";
46
53
  import { emitMigrationResponsibilityNotice } from "./migration-advisory.mjs";
47
54
  import { readProjectMetadata } from "./project-context.mjs";
48
55
 
@@ -258,24 +265,44 @@ function createDbMigrationCommand(deps, command) {
258
265
  emitMigrationResponsibilityNotice(deps.warnImpl);
259
266
  }
260
267
 
261
- if (
262
- !isCheck &&
263
- !statusReceiptId &&
264
- isProd &&
265
- !(await promptDbPushConfirm(appId, deps.createInterfaceImpl, jsonMode))
266
- ) {
267
- if (jsonMode) {
268
- emitJson({
269
- diagnosticVersion: 1,
270
- ok: false,
271
- command: "db:push",
272
- code: "USER_CANCELLED",
273
- databaseState: "UNCHANGED",
268
+ if (!isCheck && !statusReceiptId && isProd) {
269
+ const confirmMode = resolveConfirmationMode({
270
+ args,
271
+ createInterfaceImpl: deps.createInterfaceImpl,
272
+ });
273
+ const confirmed =
274
+ confirmMode === CONFIRMATION_MODE.BYPASS
275
+ ? true
276
+ : confirmMode === CONFIRMATION_MODE.PROMPT
277
+ ? await promptDbPushConfirm(appId, deps.createInterfaceImpl, jsonMode)
278
+ : null;
279
+ if (confirmed !== true) {
280
+ // Cancellation and unconfirmed exits both end non-zero; the JSON shape is unchanged.
281
+ failureEmitted = true;
282
+ if (confirmed === false) {
283
+ if (jsonMode) {
284
+ emitJson({
285
+ diagnosticVersion: 1,
286
+ ok: false,
287
+ command: "db:push",
288
+ code: "USER_CANCELLED",
289
+ databaseState: "UNCHANGED",
290
+ });
291
+ } else {
292
+ deps.warnImpl("Cancelled.");
293
+ }
294
+ return;
295
+ }
296
+ const diagnostic = buildConfirmationRequiredDiagnostic({
297
+ correlationId: clientCorrelationId(),
298
+ inputEnded: confirmMode === CONFIRMATION_MODE.PROMPT,
299
+ retryCommand: `${buildDbPushRetryCommand({ appId, args, isProd, jsonMode: false })} --yes`,
300
+ subject: `Production schema push to ${appId} needs explicit confirmation.`,
274
301
  });
275
- } else {
276
- deps.warnImpl("Cancelled.");
302
+ if (jsonMode) emitJson(diagnostic);
303
+ else renderConfirmationRequired(diagnostic, deps);
304
+ return;
277
305
  }
278
- return;
279
306
  }
280
307
 
281
308
  let creds;
@@ -519,20 +546,33 @@ function createDbMigrationCommand(deps, command) {
519
546
  cliVersion: deps.cliVersion,
520
547
  });
521
548
  remoteRequestStarted = false;
522
- if (status?.kind === "applied") {
549
+ if (status.kind === "applied") {
523
550
  recoveredSuccess = { migration: status.migration, receipt: status.receipt };
524
- } else if (status?.kind === "blocked") {
551
+ } else if (status.kind === "blocked") {
525
552
  emitFailure({ ...status.diagnostic, receiptId: status.receipt.receiptId });
526
553
  return;
527
554
  } else {
555
+ // The push outcome is still unproven, so the recovery state stays; a typed status answer is kept visible.
528
556
  emitFailure(
529
- buildMigrationNoResponseDiagnostic(clientCorrelationId(), { receiptId, statusCommand }),
557
+ buildMigrationNoResponseDiagnostic(clientCorrelationId(), {
558
+ receiptId,
559
+ statusCommand,
560
+ statusLookup:
561
+ status.kind === "server_diagnostic"
562
+ ? { code: status.diagnostic.code, correlationId: status.diagnostic.correlationId }
563
+ : undefined,
564
+ }),
530
565
  );
531
566
  return;
532
567
  }
533
568
  }
534
569
 
535
570
  const data = recoveredSuccess ? null : await res.json().catch(() => ({}));
571
+ const hasJsonObjectBody = data !== null && typeof data === "object" && !Array.isArray(data);
572
+ if (isCheck && !recoveredSuccess && !hasJsonObjectBody) {
573
+ emitFailure(buildMigrationCheckInvalidResponseDiagnostic(clientCorrelationId(), retryCommand));
574
+ return;
575
+ }
536
576
  if (!recoveredSuccess && !res.ok) {
537
577
  const diagnostic = isActionableMigrationDiagnostic(data)
538
578
  ? enrichMigrationDiagnostic(
@@ -546,12 +586,12 @@ function createDbMigrationCommand(deps, command) {
546
586
  )
547
587
  : isCheck
548
588
  ? buildMigrationCheckUnsupportedResponseDiagnostic(
549
- typeof data.correlationId === "string" && data.correlationId.length <= 200
589
+ typeof data?.correlationId === "string" && data.correlationId.length <= 200
550
590
  ? data.correlationId
551
591
  : clientCorrelationId(),
552
592
  )
553
593
  : buildMigrationUnsupportedResponseDiagnostic(
554
- typeof data.correlationId === "string" && data.correlationId.length <= 200
594
+ typeof data?.correlationId === "string" && data.correlationId.length <= 200
555
595
  ? data.correlationId
556
596
  : clientCorrelationId(),
557
597
  { receiptId, statusCommand },