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.
@@ -362,6 +362,7 @@ ${dashboardLine}
362
362
  delData,
363
363
  invalidAppResponse("app delete", delRes, delRes.ok ? "success" : "error"),
364
364
  delRes,
365
+ { expectedAppId: name },
365
366
  ),
366
367
  );
367
368
  return 1;
@@ -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 { APP_DELETE_OPERATION_PATTERN } from "./app-response-error.mjs";
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
- !isAllowedStatusPath(value.statusPath, value.operationId, expectedAppId)
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
- (value.state === "blocked_active_work" && value.code === "PLATFORM_ACTION_REQUIRED")) &&
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
- 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;
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
- lines.push(`Next action: ${legacySupportAction ? "INSPECT_STATE" : body.userAction}`);
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 = /["'`]([^"'`]+)["'`]/g;
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.length > 0 ? values : null;
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 schema = extractStringArray("schema") ?? extract("schema");
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 suffix = command ? ` ${command}` : "";
78
- return [
84
+ const lines = [
79
85
  `Gencow CLI is older than npm latest (current ${currentVersion}, latest ${latestVersion}).`,
80
- `Use project-local/latest CLI for verification: bunx gencow@latest${suffix}`,
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
- ].join("\n");
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
- return { status: "unverified", error: caught };
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
- symlinkSyncImpl(sourceNodeModules, resolve(candidateRoot, "node_modules"), "dir");
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([...MIGRATION_NEXT_ACTION_KINDS, "OPERATOR_ACTION", "VIEW_DELETE_STATUS"]);
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
- selfService?.action.command
233
- ? `Next command: ${selfService.action.command}`
234
- : `Next action: ${contract.userAction}`,
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 { hasProjectDatabaseSchemaSource } from "./project-migration-manifest.mjs";
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: "Update the CLI to the exact supported range shown above",
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",