gencow 0.1.236 → 0.1.238
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/lib/app-command.mjs +1 -0
- package/lib/app-delete-diagnostic.mjs +103 -0
- package/lib/app-delete-operation.mjs +10 -24
- package/lib/app-response-error.mjs +25 -4
- package/lib/cli-command-runner.mjs +48 -0
- package/lib/cli-project-runtime.mjs +20 -7
- package/lib/cli-update-guidance.mjs +7 -0
- package/lib/cli-version-check.mjs +18 -6
- package/lib/deploy-existing-bundle-candidate.mjs +15 -2
- package/lib/deploy-failure-diagnostic.mjs +26 -4
- package/lib/deploy-package-runtime.mjs +27 -17
- package/lib/migration-diagnostic.mjs +2 -1
- package/lib/project-migration-manifest.mjs +114 -7
- package/package.json +15 -14
- package/runtime/config.mjs +4 -1
- package/runtime/server.mjs +1507 -519
- package/runtime/tooling.mjs +7 -2
- package/templates/admin-tool/index.ts +5 -5
- package/templates/fullstack/index.ts +7 -7
package/lib/app-command.mjs
CHANGED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
export const APP_DELETE_OPERATION_PATTERN =
|
|
2
|
+
/^app_delete_(?:[a-f0-9]{32}|[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12})$/u;
|
|
3
|
+
|
|
4
|
+
const APP_ID_PATTERN = /^(?=.{3,63}$)[a-z][a-z0-9]*(?:-[a-z0-9]+){2,7}$/u;
|
|
5
|
+
const CORRELATION_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/u;
|
|
6
|
+
|
|
7
|
+
export function isAppDeleteDiagnosticAppId(value) {
|
|
8
|
+
return APP_ID_PATTERN.test(value ?? "");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function isAllowedAppDeleteStatusPath(value, operationId, expectedAppId) {
|
|
12
|
+
if (value === undefined) return true;
|
|
13
|
+
if (typeof value !== "string" || value.length > 256 || value.includes("?") || value.includes("#")) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
const match = /^\/api\/apps\/([^/]+)\/delete\/([^/]+)$/u.exec(value);
|
|
17
|
+
if (!match) return false;
|
|
18
|
+
try {
|
|
19
|
+
const appId = decodeURIComponent(match[1]);
|
|
20
|
+
return (
|
|
21
|
+
APP_ID_PATTERN.test(appId) &&
|
|
22
|
+
(!expectedAppId || appId === expectedAppId) &&
|
|
23
|
+
decodeURIComponent(match[2]) === operationId
|
|
24
|
+
);
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isCanonicalTimestamp(value) {
|
|
31
|
+
if (typeof value !== "string" || value.length > 64) return false;
|
|
32
|
+
const date = new Date(value);
|
|
33
|
+
return Number.isFinite(date.getTime()) && date.toISOString() === value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The Platform's blocked receipt is an untrusted transport value until all of
|
|
37
|
+
// its identity, app scope, and safe-action fields agree. Keep that boundary in
|
|
38
|
+
// one module so polling and terminal rendering cannot drift apart.
|
|
39
|
+
export function readBlockedAppDeleteDiagnostic(value, expectedAppId) {
|
|
40
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
41
|
+
if (
|
|
42
|
+
value.success !== false ||
|
|
43
|
+
value.deleted !== expectedAppId ||
|
|
44
|
+
!isAppDeleteDiagnosticAppId(expectedAppId) ||
|
|
45
|
+
!APP_DELETE_OPERATION_PATTERN.test(value.operationId ?? "") ||
|
|
46
|
+
!CORRELATION_ID_PATTERN.test(value.correlationId ?? "") ||
|
|
47
|
+
value.state !== "blocked_active_work" ||
|
|
48
|
+
value.code !== "PLATFORM_ACTION_REQUIRED" ||
|
|
49
|
+
value.failureClass !== "PLATFORM_ACTION_REQUIRED" ||
|
|
50
|
+
value.action !== "VIEW_DELETE_STATUS" ||
|
|
51
|
+
typeof value.statusPath !== "string" ||
|
|
52
|
+
!isAllowedAppDeleteStatusPath(value.statusPath, value.operationId, expectedAppId) ||
|
|
53
|
+
!isCanonicalTimestamp(value.nextReconcileAt)
|
|
54
|
+
) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
code: value.code,
|
|
59
|
+
operationId: value.operationId,
|
|
60
|
+
correlationId: value.correlationId,
|
|
61
|
+
state: value.state,
|
|
62
|
+
nextReconcileAt: value.nextReconcileAt,
|
|
63
|
+
statusCommand: `gencow app status ${expectedAppId}`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isBlockedAppDeleteDiagnosticCandidate(value) {
|
|
68
|
+
return Boolean(
|
|
69
|
+
value &&
|
|
70
|
+
typeof value === "object" &&
|
|
71
|
+
!Array.isArray(value) &&
|
|
72
|
+
(value.code === "PLATFORM_ACTION_REQUIRED" ||
|
|
73
|
+
value.failureClass === "PLATFORM_ACTION_REQUIRED" ||
|
|
74
|
+
value.action === "VIEW_DELETE_STATUS"),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function formatBlockedAppDeleteDiagnosticLines(diagnostic) {
|
|
79
|
+
return [
|
|
80
|
+
"App deletion is blocked while Gencow reconciles the existing delete operation.",
|
|
81
|
+
`Code: ${diagnostic.code}`,
|
|
82
|
+
`Operation ID: ${diagnostic.operationId}`,
|
|
83
|
+
`Correlation ID: ${diagnostic.correlationId}`,
|
|
84
|
+
`State: ${diagnostic.state}`,
|
|
85
|
+
"State impact: deletion is not complete; current serving state is preserved.",
|
|
86
|
+
`Next reconciliation: ${diagnostic.nextReconcileAt}`,
|
|
87
|
+
"Retry class: platform-action",
|
|
88
|
+
"Next action: VIEW_DELETE_STATUS",
|
|
89
|
+
`Next command: ${diagnostic.statusCommand}`,
|
|
90
|
+
];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function formatInvalidBlockedAppDeleteDiagnosticLines(value, expectedAppId) {
|
|
94
|
+
const lines = ["App deletion status could not be verified safely. Check the app status before retrying."];
|
|
95
|
+
if (/^[A-Za-z][A-Za-z0-9_]{2,127}$/u.test(value.code ?? "")) lines.push(`Code: ${value.code}`);
|
|
96
|
+
if (APP_DELETE_OPERATION_PATTERN.test(value.operationId ?? "")) {
|
|
97
|
+
lines.push(`Operation ID: ${value.operationId}`);
|
|
98
|
+
}
|
|
99
|
+
if (CORRELATION_ID_PATTERN.test(value.correlationId ?? "")) {
|
|
100
|
+
lines.push(`Correlation ID: ${value.correlationId}`);
|
|
101
|
+
}
|
|
102
|
+
return lines;
|
|
103
|
+
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { isExactAppDeleteResponse } from "./app-response-contract.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
APP_DELETE_OPERATION_PATTERN,
|
|
4
|
+
isAllowedAppDeleteStatusPath,
|
|
5
|
+
readBlockedAppDeleteDiagnostic,
|
|
6
|
+
} from "./app-delete-diagnostic.mjs";
|
|
3
7
|
import { readJsonObjectResponse } from "./http-response-json.mjs";
|
|
4
8
|
|
|
5
9
|
const APP_DELETE_ACTIVE_STATES = new Set([
|
|
@@ -25,27 +29,6 @@ const APP_DELETE_RESPONSE_STATES = new Set([
|
|
|
25
29
|
]);
|
|
26
30
|
const CORRELATION_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/u;
|
|
27
31
|
const DIAGNOSTIC_CODE_PATTERN = /^[A-Za-z][A-Za-z0-9_]{2,127}$/u;
|
|
28
|
-
const APP_ID_PATTERN = /^(?=.{3,63}$)[a-z][a-z0-9]*(?:-[a-z0-9]+){2,7}$/u;
|
|
29
|
-
|
|
30
|
-
function isAllowedStatusPath(value, operationId, expectedAppId) {
|
|
31
|
-
if (value === undefined) return true;
|
|
32
|
-
if (typeof value !== "string" || value.length > 256 || value.includes("?") || value.includes("#")) {
|
|
33
|
-
return false;
|
|
34
|
-
}
|
|
35
|
-
const match = /^\/api\/apps\/([^/]+)\/delete\/([^/]+)$/u.exec(value);
|
|
36
|
-
if (!match) return false;
|
|
37
|
-
try {
|
|
38
|
-
const appId = decodeURIComponent(match[1]);
|
|
39
|
-
return (
|
|
40
|
-
APP_ID_PATTERN.test(appId) &&
|
|
41
|
-
(!expectedAppId || appId === expectedAppId) &&
|
|
42
|
-
decodeURIComponent(match[2]) === operationId
|
|
43
|
-
);
|
|
44
|
-
} catch {
|
|
45
|
-
return false;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
32
|
export function parseAppDeleteOperationEnvelope(value, expectedAppId) {
|
|
50
33
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
51
34
|
if (value.success !== undefined && value.success !== false) return null;
|
|
@@ -55,7 +38,7 @@ export function parseAppDeleteOperationEnvelope(value, expectedAppId) {
|
|
|
55
38
|
!CORRELATION_ID_PATTERN.test(value.correlationId ?? "") ||
|
|
56
39
|
(value.deleted !== undefined && value.deleted !== expectedAppId) ||
|
|
57
40
|
(value.code !== undefined && !DIAGNOSTIC_CODE_PATTERN.test(value.code)) ||
|
|
58
|
-
!
|
|
41
|
+
!isAllowedAppDeleteStatusPath(value.statusPath, value.operationId, expectedAppId)
|
|
59
42
|
) {
|
|
60
43
|
return null;
|
|
61
44
|
}
|
|
@@ -89,6 +72,7 @@ function isMatchingTerminalResponse(value, accepted, expectedAppId) {
|
|
|
89
72
|
}
|
|
90
73
|
|
|
91
74
|
function isMatchingTerminalFailureResponse(value, accepted, expectedAppId) {
|
|
75
|
+
const blockedDiagnostic = readBlockedAppDeleteDiagnostic(value, expectedAppId);
|
|
92
76
|
return (
|
|
93
77
|
value &&
|
|
94
78
|
typeof value === "object" &&
|
|
@@ -98,7 +82,9 @@ function isMatchingTerminalFailureResponse(value, accepted, expectedAppId) {
|
|
|
98
82
|
value.operationId === accepted.operationId &&
|
|
99
83
|
value.correlationId === accepted.correlationId &&
|
|
100
84
|
(APP_DELETE_TERMINAL_FAILURE_STATES.has(value.state) ||
|
|
101
|
-
(
|
|
85
|
+
(blockedDiagnostic &&
|
|
86
|
+
blockedDiagnostic.operationId === accepted.operationId &&
|
|
87
|
+
blockedDiagnostic.correlationId === accepted.correlationId)) &&
|
|
102
88
|
DIAGNOSTIC_CODE_PATTERN.test(value.code ?? "")
|
|
103
89
|
);
|
|
104
90
|
}
|
|
@@ -1,5 +1,14 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { formatLatestCliInvocation } from "./cli-update-guidance.mjs";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
APP_DELETE_OPERATION_PATTERN,
|
|
5
|
+
formatBlockedAppDeleteDiagnosticLines,
|
|
6
|
+
formatInvalidBlockedAppDeleteDiagnosticLines,
|
|
7
|
+
isBlockedAppDeleteDiagnosticCandidate,
|
|
8
|
+
readBlockedAppDeleteDiagnostic,
|
|
9
|
+
} from "./app-delete-diagnostic.mjs";
|
|
10
|
+
|
|
11
|
+
export { APP_DELETE_OPERATION_PATTERN } from "./app-delete-diagnostic.mjs";
|
|
3
12
|
|
|
4
13
|
const RETRY_CLASSES = new Set(["operator-action", "platform-action", "retryable-infra", "terminal-client"]);
|
|
5
14
|
const USER_ACTIONS = new Set([
|
|
@@ -8,19 +17,27 @@ const USER_ACTIONS = new Set([
|
|
|
8
17
|
"INSPECT_STATE",
|
|
9
18
|
"OPERATOR_ACTION",
|
|
10
19
|
"RETRY",
|
|
20
|
+
"UPDATE_CLI",
|
|
11
21
|
"UPGRADE_CLI",
|
|
12
22
|
"VIEW_DELETE_STATUS",
|
|
13
23
|
"VIEW_STATUS",
|
|
14
24
|
"WAIT",
|
|
15
25
|
]);
|
|
16
26
|
|
|
17
|
-
export function appResponseError(body, fallback, response = null) {
|
|
27
|
+
export function appResponseError(body, fallback, response = null, options = {}) {
|
|
18
28
|
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
19
29
|
if (response?.status === 503) {
|
|
20
30
|
return `${fallback}\nCode: CLI_PLATFORM_UNAVAILABLE\nRetryable: yes`;
|
|
21
31
|
}
|
|
22
32
|
return fallback;
|
|
23
33
|
}
|
|
34
|
+
const blockedDeleteDiagnostic = readBlockedAppDeleteDiagnostic(body, options.expectedAppId);
|
|
35
|
+
if (blockedDeleteDiagnostic) {
|
|
36
|
+
return formatBlockedAppDeleteDiagnosticLines(blockedDeleteDiagnostic).join("\n");
|
|
37
|
+
}
|
|
38
|
+
if (isBlockedAppDeleteDiagnosticCandidate(body)) {
|
|
39
|
+
return formatInvalidBlockedAppDeleteDiagnosticLines(body, options.expectedAppId).join("\n");
|
|
40
|
+
}
|
|
24
41
|
const hasPublicCode = /^[A-Za-z][A-Za-z0-9_]{2,127}$/u.test(body.code ?? "");
|
|
25
42
|
// A syntactically valid code does not authenticate the accompanying message.
|
|
26
43
|
// 503 bodies can be partial upstream envelopes, so never cross that public
|
|
@@ -54,7 +71,11 @@ export function appResponseError(body, fallback, response = null) {
|
|
|
54
71
|
if (RETRY_CLASSES.has(body.retryClass) && USER_ACTIONS.has(body.userAction)) {
|
|
55
72
|
const legacySupportAction = body.userAction === "CONTACT_SUPPORT";
|
|
56
73
|
lines.push(`Retry class: ${legacySupportAction ? "platform-action" : body.retryClass}`);
|
|
57
|
-
|
|
74
|
+
if (body.userAction === "UPDATE_CLI" || body.userAction === "UPGRADE_CLI") {
|
|
75
|
+
lines.push(`Next command: ${formatLatestCliInvocation()}`);
|
|
76
|
+
} else {
|
|
77
|
+
lines.push(`Next action: ${legacySupportAction ? "INSPECT_STATE" : body.userAction}`);
|
|
78
|
+
}
|
|
58
79
|
if (Number.isSafeInteger(body.retryAfterMs) && body.retryAfterMs >= 0 && body.retryAfterMs <= 600_000) {
|
|
59
80
|
lines.push(`Retry after: ${body.retryAfterMs}ms`);
|
|
60
81
|
}
|
|
@@ -6,6 +6,27 @@ const SAFE_PLATFORM_ERROR_DETAILS = new Map([
|
|
|
6
6
|
["APP_RELEASE_CAPABILITY_UNAVAILABLE", "Release capability is temporarily unavailable."],
|
|
7
7
|
["APP_RELEASE_POLICY_READ_UNAVAILABLE", "Release policy is temporarily unavailable."],
|
|
8
8
|
]);
|
|
9
|
+
const SAFE_LOCAL_ERROR_CODES = new Map([
|
|
10
|
+
[
|
|
11
|
+
"DEPLOY_SCHEMA_SOURCE_MISSING",
|
|
12
|
+
{
|
|
13
|
+
message: "Deploy stopped before upload: configured schema source is missing.",
|
|
14
|
+
stage: "schema",
|
|
15
|
+
action: "Retryable: no — fix gencow.config.js and rerun deploy.",
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
[
|
|
19
|
+
"DEPLOY_CAPABILITY_CONFLICT",
|
|
20
|
+
{
|
|
21
|
+
message:
|
|
22
|
+
"Deploy stopped before upload: the empty schema declaration conflicts with an Auth schema source.",
|
|
23
|
+
stage: "schema",
|
|
24
|
+
action:
|
|
25
|
+
"Retryable: no — remove the unused Auth schema or declare the schema it requires, then rerun deploy.",
|
|
26
|
+
pathLabel: "Conflicting source",
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
]);
|
|
9
30
|
|
|
10
31
|
function safePlatformOrigin(value) {
|
|
11
32
|
try {
|
|
@@ -17,6 +38,33 @@ function safePlatformOrigin(value) {
|
|
|
17
38
|
}
|
|
18
39
|
|
|
19
40
|
function commandErrorMessage(caught) {
|
|
41
|
+
const localDiagnostic = SAFE_LOCAL_ERROR_CODES.get(caught?.code);
|
|
42
|
+
if (localDiagnostic) {
|
|
43
|
+
const lines = [
|
|
44
|
+
localDiagnostic.message,
|
|
45
|
+
`Code: ${caught.code}`,
|
|
46
|
+
`Stage: ${localDiagnostic.stage}`,
|
|
47
|
+
"Database mutation: NONE",
|
|
48
|
+
"Incumbent serving: PRESERVED",
|
|
49
|
+
localDiagnostic.action,
|
|
50
|
+
];
|
|
51
|
+
const diagnosticPaths = Array.isArray(caught.missingPaths)
|
|
52
|
+
? caught.missingPaths
|
|
53
|
+
: Array.isArray(caught.conflictingPaths)
|
|
54
|
+
? caught.conflictingPaths
|
|
55
|
+
: [];
|
|
56
|
+
const safePaths = diagnosticPaths.filter(
|
|
57
|
+
(path) =>
|
|
58
|
+
typeof path === "string" &&
|
|
59
|
+
path.length <= 240 &&
|
|
60
|
+
path.startsWith("gencow/") &&
|
|
61
|
+
!path.includes("..") &&
|
|
62
|
+
!/[\u0000-\u001f\u007f]/u.test(path),
|
|
63
|
+
);
|
|
64
|
+
const pathLabel = localDiagnostic.pathLabel ?? "Missing source";
|
|
65
|
+
for (const path of safePaths.slice(0, 8)) lines.push(`${pathLabel}: ${path}`);
|
|
66
|
+
return lines.join("\n");
|
|
67
|
+
}
|
|
20
68
|
if (caught instanceof Error && (caught.message === "APP_RELEASE_AUTH_REQUIRED" || caught.status === 401)) {
|
|
21
69
|
const lines = ["Login expired or invalid for this Platform."];
|
|
22
70
|
const platformOrigin = safePlatformOrigin(caught.platformUrl);
|
|
@@ -59,13 +59,13 @@ function parseTsConfigViaRegex(src) {
|
|
|
59
59
|
const match = src.match(new RegExp(`${key}:\\s*\\[([\\s\\S]*?)\\]`));
|
|
60
60
|
if (!match) return null;
|
|
61
61
|
const values = [];
|
|
62
|
-
const entryRegex = /["'`]([^"'`]
|
|
62
|
+
const entryRegex = /["'`]([^"'`]*)["'`]/g;
|
|
63
63
|
let entry = entryRegex.exec(match[1]);
|
|
64
64
|
while (entry) {
|
|
65
65
|
values.push(entry[1]);
|
|
66
66
|
entry = entryRegex.exec(match[1]);
|
|
67
67
|
}
|
|
68
|
-
return values
|
|
68
|
+
return values;
|
|
69
69
|
};
|
|
70
70
|
const extractTopLevelStringArray = (key) => {
|
|
71
71
|
let depth = 0;
|
|
@@ -182,7 +182,8 @@ function parseTsConfigViaRegex(src) {
|
|
|
182
182
|
if (functionsDir !== null) result.functionsDir = functionsDir;
|
|
183
183
|
const envFile = extract("envFile");
|
|
184
184
|
if (envFile !== null) result.envFile = envFile;
|
|
185
|
-
const
|
|
185
|
+
const schemaStringMatch = src.match(/\bschema:\s*["'`]([^"'`]*)["'`]/u);
|
|
186
|
+
const schema = extractStringArray("schema") ?? schemaStringMatch?.[1] ?? extract("schema");
|
|
186
187
|
if (schema !== null) result.schema = schema;
|
|
187
188
|
const storage = extract("storage");
|
|
188
189
|
if (storage !== null) result.storage = storage;
|
|
@@ -281,6 +282,17 @@ async function loadConfigModule() {
|
|
|
281
282
|
return configModulePromise;
|
|
282
283
|
}
|
|
283
284
|
|
|
285
|
+
function markConfigSource(config, { schemaDeclared }) {
|
|
286
|
+
if (!config || typeof config !== "object") return config;
|
|
287
|
+
Object.defineProperty(config, "__gencowConfigMetadata", {
|
|
288
|
+
configurable: false,
|
|
289
|
+
enumerable: false,
|
|
290
|
+
value: Object.freeze({ schemaDeclared }),
|
|
291
|
+
writable: false,
|
|
292
|
+
});
|
|
293
|
+
return config;
|
|
294
|
+
}
|
|
295
|
+
|
|
284
296
|
export function resolveConfiguredAuditorImplementation(config = {}) {
|
|
285
297
|
return resolveConfiguredAuditorImplementationShared(config);
|
|
286
298
|
}
|
|
@@ -340,12 +352,12 @@ export async function loadConfig(options = {}) {
|
|
|
340
352
|
throw caught;
|
|
341
353
|
}
|
|
342
354
|
warnImpl(`${caught.message} — using defaults`);
|
|
343
|
-
return getDefaultConfig();
|
|
355
|
+
return markConfigSource(getDefaultConfig(), { schemaDeclared: false });
|
|
344
356
|
}
|
|
345
357
|
|
|
346
358
|
if (!path) {
|
|
347
359
|
warnImpl("No gencow.config.js found — using defaults");
|
|
348
|
-
return getDefaultConfig();
|
|
360
|
+
return markConfigSource(getDefaultConfig(), { schemaDeclared: false });
|
|
349
361
|
}
|
|
350
362
|
|
|
351
363
|
// Per-process cache keyed by (path, mtimeMs). Same CLI invocation can call
|
|
@@ -375,7 +387,7 @@ export async function loadConfig(options = {}) {
|
|
|
375
387
|
throw caught;
|
|
376
388
|
}
|
|
377
389
|
warnImpl(`${message} — using defaults`);
|
|
378
|
-
return getDefaultConfig();
|
|
390
|
+
return markConfigSource(getDefaultConfig(), { schemaDeclared: false });
|
|
379
391
|
}
|
|
380
392
|
|
|
381
393
|
let parsed;
|
|
@@ -402,9 +414,10 @@ export async function loadConfig(options = {}) {
|
|
|
402
414
|
throw caught;
|
|
403
415
|
}
|
|
404
416
|
warnImpl(`${message} — using defaults`);
|
|
405
|
-
return getDefaultConfig();
|
|
417
|
+
return markConfigSource(getDefaultConfig(), { schemaDeclared: false });
|
|
406
418
|
}
|
|
407
419
|
|
|
420
|
+
markConfigSource(parsed, { schemaDeclared: Object.hasOwn(raw ?? {}, "schema") });
|
|
408
421
|
cache.set(path, { mtimeMs, parsed });
|
|
409
422
|
return parsed;
|
|
410
423
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
const SAFE_CLI_COMMAND = /^[a-z][a-z0-9:-]{0,63}$/u;
|
|
2
|
+
|
|
3
|
+
export function formatLatestCliInvocation(command) {
|
|
4
|
+
const safeCommand =
|
|
5
|
+
typeof command === "string" && SAFE_CLI_COMMAND.test(command) ? command : "<same command>";
|
|
6
|
+
return `bunx gencow@latest ${safeCommand}`;
|
|
7
|
+
}
|
|
@@ -3,8 +3,11 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
|
3
3
|
import { homedir } from "os";
|
|
4
4
|
import { dirname, resolve } from "path";
|
|
5
5
|
|
|
6
|
+
import { formatLatestCliInvocation } from "./cli-update-guidance.mjs";
|
|
7
|
+
|
|
6
8
|
const DEFAULT_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
7
9
|
const DEFAULT_NPM_TIMEOUT_MS = 1500;
|
|
10
|
+
const RELEASE_SENSITIVE_COMMANDS = new Set(["db", "deploy", "dev", "dev:cloud", "static"]);
|
|
8
11
|
|
|
9
12
|
function isEnabled(value) {
|
|
10
13
|
return value === "1" || value === "true" || value === "yes";
|
|
@@ -37,6 +40,10 @@ export function shouldWarnForStaleCli({ currentVersion, latestVersion }) {
|
|
|
37
40
|
return compareCliSemver(latestVersion, currentVersion) > 0;
|
|
38
41
|
}
|
|
39
42
|
|
|
43
|
+
export function shouldRepeatStaleCliWarning(command) {
|
|
44
|
+
return RELEASE_SENSITIVE_COMMANDS.has(command);
|
|
45
|
+
}
|
|
46
|
+
|
|
40
47
|
export function resolveVersionCheckCachePath({ homeDir = homedir(), processEnv = process.env } = {}) {
|
|
41
48
|
return processEnv.GENCOW_VERSION_CHECK_CACHE || resolve(homeDir, ".gencow", "version-check.json");
|
|
42
49
|
}
|
|
@@ -74,13 +81,16 @@ function fetchLatestVersion({ execFileSyncImpl = execFileSync, timeoutMs = DEFAU
|
|
|
74
81
|
}
|
|
75
82
|
|
|
76
83
|
export function formatStaleCliWarning({ currentVersion, latestVersion, command }) {
|
|
77
|
-
const
|
|
78
|
-
return [
|
|
84
|
+
const lines = [
|
|
79
85
|
`Gencow CLI is older than npm latest (current ${currentVersion}, latest ${latestVersion}).`,
|
|
80
|
-
`
|
|
86
|
+
`Run: ${formatLatestCliInvocation(command)}`,
|
|
81
87
|
"If this came from a global install, check: which gencow && gencow --version",
|
|
82
88
|
"Skip this check with GENCOW_SKIP_VERSION_CHECK=true.",
|
|
83
|
-
]
|
|
89
|
+
];
|
|
90
|
+
if (shouldRepeatStaleCliWarning(command)) {
|
|
91
|
+
lines.splice(1, 0, "This deployment or migration command may require the current CLI contract.");
|
|
92
|
+
}
|
|
93
|
+
return lines.join("\n");
|
|
84
94
|
}
|
|
85
95
|
|
|
86
96
|
export function maybeCheckCliVersion({
|
|
@@ -119,7 +129,9 @@ export function maybeCheckCliVersion({
|
|
|
119
129
|
cache.checkedAt = now;
|
|
120
130
|
writeCache(cachePath, cache, { mkdirSyncImpl, writeFileSyncImpl });
|
|
121
131
|
} catch (caught) {
|
|
122
|
-
|
|
132
|
+
if (!shouldWarnForStaleCli({ currentVersion, latestVersion })) {
|
|
133
|
+
return { status: "unverified", error: caught };
|
|
134
|
+
}
|
|
123
135
|
}
|
|
124
136
|
}
|
|
125
137
|
|
|
@@ -136,7 +148,7 @@ export function maybeCheckCliVersion({
|
|
|
136
148
|
|
|
137
149
|
const warnedRecently =
|
|
138
150
|
cache.warnedForVersion === latestVersion && isFresh(cache.warnedAt, now, cacheMaxAgeMs);
|
|
139
|
-
if (!warnedRecently) {
|
|
151
|
+
if (shouldRepeatStaleCliWarning(command) || !warnedRecently) {
|
|
140
152
|
warnImpl(message);
|
|
141
153
|
cache.warnedAt = now;
|
|
142
154
|
cache.warnedForVersion = latestVersion;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { cpSync, existsSync, mkdtempSync, rmSync, symlinkSync } from "fs";
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, symlinkSync } from "fs";
|
|
2
2
|
import { tmpdir } from "os";
|
|
3
3
|
import { relative, resolve, sep } from "path";
|
|
4
4
|
|
|
@@ -17,6 +17,8 @@ export function createExistingBundleCandidate(
|
|
|
17
17
|
{
|
|
18
18
|
cpSyncImpl = cpSync,
|
|
19
19
|
existsSyncImpl = existsSync,
|
|
20
|
+
mkdirSyncImpl = mkdirSync,
|
|
21
|
+
readdirSyncImpl = readdirSync,
|
|
20
22
|
mkdtempSyncImpl = mkdtempSync,
|
|
21
23
|
rmSyncImpl = rmSync,
|
|
22
24
|
symlinkSyncImpl = symlinkSync,
|
|
@@ -32,7 +34,18 @@ export function createExistingBundleCandidate(
|
|
|
32
34
|
});
|
|
33
35
|
const sourceNodeModules = resolve(projectRoot, "node_modules");
|
|
34
36
|
if (existsSyncImpl(sourceNodeModules)) {
|
|
35
|
-
|
|
37
|
+
// Keep the dependency tree available to the CLI without exposing a
|
|
38
|
+
// top-level symlink to the release-source inventory. Older published
|
|
39
|
+
// CLIs correctly exclude a real node_modules directory but reject its
|
|
40
|
+
// symlink as an unsafe source entry.
|
|
41
|
+
const candidateNodeModules = resolve(candidateRoot, "node_modules");
|
|
42
|
+
mkdirSyncImpl(candidateNodeModules);
|
|
43
|
+
for (const entry of readdirSyncImpl(sourceNodeModules)) {
|
|
44
|
+
symlinkSyncImpl(
|
|
45
|
+
resolve(sourceNodeModules, entry),
|
|
46
|
+
resolve(candidateNodeModules, entry),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
36
49
|
}
|
|
37
50
|
} catch (error) {
|
|
38
51
|
rmSyncImpl(stagingParent, { recursive: true, force: true });
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { MIGRATION_NEXT_ACTION_KINDS, MIGRATION_RETRY_POLICIES } from "@gencow/migration-contract";
|
|
2
2
|
|
|
3
3
|
import { APP_DELETE_OPERATION_PATTERN } from "./app-response-error.mjs";
|
|
4
|
+
import { formatLatestCliInvocation } from "./cli-update-guidance.mjs";
|
|
4
5
|
import { formatDeployCorrelationId } from "./deploy-correlation-diagnostic.mjs";
|
|
5
6
|
|
|
6
7
|
const DEPLOY_FAILURE_STAGES = new Set([
|
|
@@ -14,7 +15,12 @@ const DEPLOY_FAILURE_STAGES = new Set([
|
|
|
14
15
|
"controller",
|
|
15
16
|
]);
|
|
16
17
|
const RETRY_CLASSES = new Set(["terminal-client", "retryable-infra", "platform-action"]);
|
|
17
|
-
const USER_ACTIONS = new Set([
|
|
18
|
+
const USER_ACTIONS = new Set([
|
|
19
|
+
...MIGRATION_NEXT_ACTION_KINDS,
|
|
20
|
+
"OPERATOR_ACTION",
|
|
21
|
+
"UPGRADE_CLI",
|
|
22
|
+
"VIEW_DELETE_STATUS",
|
|
23
|
+
]);
|
|
18
24
|
const STRUCTURED_CONTRACT_FIELDS = [
|
|
19
25
|
"code",
|
|
20
26
|
"stage",
|
|
@@ -166,6 +172,13 @@ function appendSelfServiceState(lines, selfService) {
|
|
|
166
172
|
}
|
|
167
173
|
}
|
|
168
174
|
|
|
175
|
+
function isCliUpgradeContract(contract) {
|
|
176
|
+
return (
|
|
177
|
+
contract.code === "DEPLOY_CLI_UPGRADE_REQUIRED" &&
|
|
178
|
+
(contract.userAction === "UPDATE_CLI" || contract.userAction === "UPGRADE_CLI")
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
169
182
|
export function renderDeployFailureDiagnosticLines(responseBody, statusText, options = {}) {
|
|
170
183
|
const contract = readDeployFailureContract(responseBody, options.expectedAppName);
|
|
171
184
|
const fallbackMessage = safeText(statusText, 120) ?? "Request failed";
|
|
@@ -226,12 +239,21 @@ export function renderDeployFailureDiagnosticLines(responseBody, statusText, opt
|
|
|
226
239
|
lines.push(`Code: ${contract.code}`, `Stage: ${contract.stage}`);
|
|
227
240
|
const selfService = readSelfServiceDiagnostic(responseBody, contract);
|
|
228
241
|
appendSelfServiceState(lines, selfService);
|
|
242
|
+
if (isCliUpgradeContract(contract) && !selfService) {
|
|
243
|
+
lines.push(
|
|
244
|
+
"Database mutation: NONE",
|
|
245
|
+
"Incumbent serving: PRESERVED",
|
|
246
|
+
"Retry policy: AFTER_FIX — Update the CLI before retrying.",
|
|
247
|
+
);
|
|
248
|
+
}
|
|
229
249
|
const correlationLine = formatDeployCorrelationId(contract.correlationId);
|
|
230
250
|
if (correlationLine) lines.push(correlationLine);
|
|
231
251
|
lines.push(
|
|
232
|
-
|
|
233
|
-
? `Next command: ${
|
|
234
|
-
:
|
|
252
|
+
isCliUpgradeContract(contract)
|
|
253
|
+
? `Next command: ${formatLatestCliInvocation("deploy")}`
|
|
254
|
+
: selfService?.action.command
|
|
255
|
+
? `Next command: ${selfService.action.command}`
|
|
256
|
+
: `Next action: ${contract.userAction}`,
|
|
235
257
|
);
|
|
236
258
|
return lines;
|
|
237
259
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execSync } from "child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
|
|
3
4
|
import { dirname, resolve } from "path";
|
|
4
5
|
import { fetchAppDiagnosticLogsViaRest, normalizeDiagnosticLogLines } from "./app-diagnostics.mjs";
|
|
@@ -24,7 +25,10 @@ import { submitDeploymentWithIdempotentRetry } from "./deployment-admission-retr
|
|
|
24
25
|
import { acquireDeployReceipt, releaseDeployReceipt } from "./deploy-receipt-store.mjs";
|
|
25
26
|
import { CYAN, DIM, RED, RESET, YELLOW, error, info, log, success, warn } from "./output.mjs";
|
|
26
27
|
import { platformFetch, rpcMutation } from "./platform-client.mjs";
|
|
27
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
getProjectSchemaPreflightError,
|
|
30
|
+
hasProjectDatabaseSchemaSource,
|
|
31
|
+
} from "./project-migration-manifest.mjs";
|
|
28
32
|
import { hasWorkspaceSourceRuntime } from "./runtime-mode.mjs";
|
|
29
33
|
import { verifyMigrationBundleArchiveRoundTrip } from "./migration-bundle-roundtrip.mjs";
|
|
30
34
|
import { assertToolingRuntimeCapabilities, loadToolingRuntime } from "./tooling-runtime.mjs";
|
|
@@ -146,6 +150,7 @@ export function createDeployPackageRuntime({
|
|
|
146
150
|
loadInternalBundleImpl,
|
|
147
151
|
loadDeployAuditorImpl,
|
|
148
152
|
runCodegenForDeployImpl,
|
|
153
|
+
randomUUIDImpl = randomUUID,
|
|
149
154
|
} = {}) {
|
|
150
155
|
let migrationProjectConfig = null;
|
|
151
156
|
let preparedDeployCodegenArtifacts = null;
|
|
@@ -199,6 +204,26 @@ export function createDeployPackageRuntime({
|
|
|
199
204
|
const cwd = cwdImpl();
|
|
200
205
|
cleanupPreparedDeploySource();
|
|
201
206
|
const { config, genEnv } = await loadMigrationGenerationContext(cwd);
|
|
207
|
+
const hasSchemaSource = hasProjectDatabaseSchemaSource({
|
|
208
|
+
cwd,
|
|
209
|
+
config: migrationProjectConfig,
|
|
210
|
+
existsSyncImpl,
|
|
211
|
+
resolvePathImpl,
|
|
212
|
+
});
|
|
213
|
+
if (!hasSchemaSource) {
|
|
214
|
+
const selection = detectProjectConfigFile({ cwd, existsSyncImpl, resolvePathImpl });
|
|
215
|
+
const preflightError = getProjectSchemaPreflightError({
|
|
216
|
+
cwd,
|
|
217
|
+
config: migrationProjectConfig,
|
|
218
|
+
hasSelectedConfigFile: Boolean(selection.selectedFile),
|
|
219
|
+
existsSyncImpl,
|
|
220
|
+
resolvePathImpl,
|
|
221
|
+
});
|
|
222
|
+
if (preflightError) throw preflightError;
|
|
223
|
+
migrationProjectConfig = null;
|
|
224
|
+
logImpl(`${DIM} ok No schema source — migration generation not required${RESET}`);
|
|
225
|
+
return SUPPORTED_DRIZZLE_KIT_GENERATOR_VERSION;
|
|
226
|
+
}
|
|
202
227
|
if (config) {
|
|
203
228
|
preparedDeployCodegenArtifacts = await prepareDeployCodegenArtifacts({
|
|
204
229
|
backendRoot: resolveConfiguredBackendRoot(config),
|
|
@@ -218,22 +243,6 @@ export function createDeployPackageRuntime({
|
|
|
218
243
|
sourceRevision,
|
|
219
244
|
});
|
|
220
245
|
}
|
|
221
|
-
if (
|
|
222
|
-
!hasProjectDatabaseSchemaSource({
|
|
223
|
-
cwd,
|
|
224
|
-
config: migrationProjectConfig,
|
|
225
|
-
existsSyncImpl,
|
|
226
|
-
resolvePathImpl,
|
|
227
|
-
})
|
|
228
|
-
) {
|
|
229
|
-
const selection = detectProjectConfigFile({ cwd, existsSyncImpl, resolvePathImpl });
|
|
230
|
-
if (selection.selectedFile && migrationProjectConfig?.schema) {
|
|
231
|
-
throw new Error("Configured schema source is missing from the project");
|
|
232
|
-
}
|
|
233
|
-
migrationProjectConfig = null;
|
|
234
|
-
logImpl(`${DIM} ok No schema source — migration generation not required${RESET}`);
|
|
235
|
-
return SUPPORTED_DRIZZLE_KIT_GENERATOR_VERSION;
|
|
236
|
-
}
|
|
237
246
|
const { execSync: execGen } = { execSync: execSyncImpl };
|
|
238
247
|
infoImpl("Generating schema migrations...");
|
|
239
248
|
try {
|
|
@@ -366,6 +375,7 @@ export function createDeployPackageRuntime({
|
|
|
366
375
|
);
|
|
367
376
|
infoImpl("Code: BACKEND_CAPABILITY_EMPTY");
|
|
368
377
|
infoImpl("Stage: candidate");
|
|
378
|
+
infoImpl(`Correlation ID: deploy_${randomUUIDImpl()}`);
|
|
369
379
|
infoImpl("Next action: EDIT_AND_CHECK");
|
|
370
380
|
infoImpl("Add a backend procedure, or use gencow static <build-directory> for a frontend-only app.");
|
|
371
381
|
exitImpl(1);
|
|
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { readFileSync, realpathSync } from "node:fs";
|
|
3
3
|
import { relative, resolve, sep } from "node:path";
|
|
4
4
|
import { escapeTerminalControls, safeDiagnosticText } from "./migration-diagnostic-contract.mjs";
|
|
5
|
+
import { formatLatestCliInvocation } from "./cli-update-guidance.mjs";
|
|
5
6
|
|
|
6
7
|
export {
|
|
7
8
|
extractActionableMigrationDiagnostic,
|
|
@@ -371,7 +372,7 @@ export function renderMigrationDiagnosticLines(diagnostic) {
|
|
|
371
372
|
diagnostic.code === "MIGRATION_LOCAL_EXECUTION_FAILED"
|
|
372
373
|
? "Inspect the local Drizzle journal and database catalog before retrying"
|
|
373
374
|
: "Run the platform's read-only migration-state inspection before any new mutation request",
|
|
374
|
-
UPDATE_CLI:
|
|
375
|
+
UPDATE_CLI: `Run ${formatLatestCliInvocation()} and then rerun the same command`,
|
|
375
376
|
VIEW_STATUS: "View the exact operation receipt before any new mutation request",
|
|
376
377
|
RETRY: "Regenerate a stable bundle and retry from a fresh plan",
|
|
377
378
|
EDIT_AND_CHECK: "Apply one suggested fix, review the diff, and create a fresh plan",
|