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 +0 -89
- package/lib/app-command.mjs +33 -8
- package/lib/cli-command-runner.mjs +65 -3
- package/lib/cli-project-runtime.mjs +1 -1
- package/lib/confirm-gate.mjs +101 -0
- package/lib/db-command.mjs +3 -0
- package/lib/db-migration-command-steps.mjs +44 -11
- package/lib/db-push-command.mjs +61 -21
- package/lib/deploy-codegen-failure.mjs +80 -0
- package/lib/deploy-codegen-preflight.mjs +3 -4
- package/lib/deploy-failure-diagnostic.mjs +30 -10
- package/lib/deploy-first-prod-confirm.mjs +56 -0
- package/lib/deploy-runtime.mjs +30 -29
- package/lib/deployment-admission-retry.mjs +25 -1
- package/lib/migration-diagnostic.mjs +92 -1
- package/lib/migration-operation-client.mjs +15 -10
- package/lib/runtime-mode.mjs +1 -1
- package/lib/static-artifact-manifest.mjs +8 -2
- package/lib/static-command.mjs +2 -1
- package/lib/static-deploy-command.mjs +1 -0
- package/package.json +5 -5
- package/runtime/config.mjs +1 -1
- package/runtime/dependency-version-report.mjs +1 -1
- package/runtime/server.mjs +225 -245
- package/runtime/tooling.mjs +91 -127
- package/scripts/bundle-server.mjs +3 -3
- package/scripts/{bundle-internal.mjs → bundle-shared.mjs} +1 -1
- package/scripts/tooling-codegen-runtime.ts +13 -3
- package/scripts/tooling-entry.ts +6 -6
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,
|
package/lib/app-command.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
388
|
-
|
|
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(
|
|
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("../../
|
|
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
|
+
}
|
package/lib/db-command.mjs
CHANGED
|
@@ -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 {
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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 (
|
|
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;
|
package/lib/db-push-command.mjs
CHANGED
|
@@ -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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
276
|
-
deps
|
|
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
|
|
549
|
+
if (status.kind === "applied") {
|
|
523
550
|
recoveredSuccess = { migration: status.migration, receipt: status.receipt };
|
|
524
|
-
} else if (status
|
|
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(), {
|
|
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
|
|
589
|
+
typeof data?.correlationId === "string" && data.correlationId.length <= 200
|
|
550
590
|
? data.correlationId
|
|
551
591
|
: clientCorrelationId(),
|
|
552
592
|
)
|
|
553
593
|
: buildMigrationUnsupportedResponseDiagnostic(
|
|
554
|
-
typeof data
|
|
594
|
+
typeof data?.correlationId === "string" && data.correlationId.length <= 200
|
|
555
595
|
? data.correlationId
|
|
556
596
|
: clientCorrelationId(),
|
|
557
597
|
{ receiptId, statusCommand },
|