gencow 0.1.190 → 0.1.192
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 +11 -8
- package/lib/app-delete-operation.mjs +18 -0
- package/lib/app-response-contract.mjs +5 -0
- package/lib/canonical-drizzle-generator-paths.mjs +7 -0
- package/lib/canonical-drizzle-generator.config.mjs +3 -1
- package/lib/cli-artifact-guard.mjs +1 -0
- package/lib/deploy-command.mjs +23 -6
- package/lib/deploy-failure-diagnostic.mjs +19 -1
- package/lib/deployment-operation-poll.mjs +59 -15
- package/lib/doctor-command.mjs +38 -14
- package/lib/http-response-json.mjs +9 -4
- package/lib/init-command.mjs +11 -11
- package/lib/project-dependency-lock-sync.mjs +122 -0
- package/package.json +3 -3
- package/runtime/server.mjs +347 -118
- package/server/index.js.map +7 -0
package/lib/app-command.mjs
CHANGED
|
@@ -8,13 +8,14 @@ import {
|
|
|
8
8
|
} from "./app-diagnostics.mjs";
|
|
9
9
|
import { resolveCreatedAppId, resolveCreatedAppResponse } from "./app-create-response.mjs";
|
|
10
10
|
import {
|
|
11
|
+
appNotFoundDiagnostic,
|
|
11
12
|
isExactAppDeleteResponse,
|
|
12
13
|
parseAppListResponse,
|
|
13
14
|
parseAppLookupResponse,
|
|
14
15
|
} from "./app-response-contract.mjs";
|
|
15
16
|
import { pollAppDeleteOperation } from "./app-delete-operation.mjs";
|
|
16
17
|
import { appResponseError } from "./app-response-error.mjs";
|
|
17
|
-
import { readJsonObjectResponse } from "./http-response-json.mjs";
|
|
18
|
+
import { readJsonObjectResponse, readJsonValueResponse } from "./http-response-json.mjs";
|
|
18
19
|
import { BOLD, CYAN, DIM, GREEN, RED, RESET, error, info, log, success, warn } from "./output.mjs";
|
|
19
20
|
import { loadCreds, rpcMutation, rpcQuery, saveCreds, requireCreds } from "./platform-client.mjs";
|
|
20
21
|
import { resolveProjectMetadataPath, resolveProjectSelection } from "./project-context.mjs";
|
|
@@ -32,10 +33,6 @@ function invalidAppResponse(operation, response, kind) {
|
|
|
32
33
|
return `CLI_APP_RESPONSE_INVALID: ${operation} returned an invalid ${kind} response (${appResponseStatus(response)})`;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
function appNotFoundError() {
|
|
36
|
-
return "App not found or not owned by you.\nCode: APP_NOT_FOUND";
|
|
37
|
-
}
|
|
38
|
-
|
|
39
36
|
export function formatAppDeployedAgo(dateStr, now = Date.now()) {
|
|
40
37
|
if (!dateStr) return `${DIM}never${RESET}`;
|
|
41
38
|
const diff = now - new Date(dateStr).getTime();
|
|
@@ -324,19 +321,25 @@ ${dashboardLine}
|
|
|
324
321
|
return;
|
|
325
322
|
}
|
|
326
323
|
const res = await rpcQueryImpl(creds, "apps.get", { name });
|
|
327
|
-
const
|
|
324
|
+
const decoded = await readJsonValueResponse(res);
|
|
325
|
+
const body = decoded.parsed ? decoded.value : null;
|
|
328
326
|
if (!res.ok) {
|
|
329
327
|
errorImpl(
|
|
330
328
|
res.status === 404
|
|
331
|
-
?
|
|
329
|
+
? appNotFoundDiagnostic()
|
|
332
330
|
: appResponseError(body, invalidAppResponse("app status", res, "error")),
|
|
333
331
|
);
|
|
334
332
|
processRef.exit(1);
|
|
335
333
|
return;
|
|
336
334
|
}
|
|
335
|
+
if (!decoded.parsed) {
|
|
336
|
+
errorImpl(invalidAppResponse("app status", res, "success"));
|
|
337
|
+
processRef.exit(1);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
337
340
|
const lookup = parseAppLookupResponse(body, name);
|
|
338
341
|
if (lookup.kind === "not_found") {
|
|
339
|
-
errorImpl(
|
|
342
|
+
errorImpl(appNotFoundDiagnostic());
|
|
340
343
|
processRef.exit(1);
|
|
341
344
|
return;
|
|
342
345
|
}
|
|
@@ -16,6 +16,7 @@ const APP_DELETE_ACTIVE_STATES = new Set([
|
|
|
16
16
|
"failed_tenant_db_cleanup",
|
|
17
17
|
"failed_catalog_cleanup",
|
|
18
18
|
]);
|
|
19
|
+
const APP_DELETE_TERMINAL_FAILURE_STATES = new Set(["blocked_catalog_outcome_unknown"]);
|
|
19
20
|
const CORRELATION_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/u;
|
|
20
21
|
const DIAGNOSTIC_CODE_PATTERN = /^[A-Za-z][A-Za-z0-9_]{2,127}$/u;
|
|
21
22
|
const APP_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+){2}$/u;
|
|
@@ -74,6 +75,20 @@ function isMatchingTerminalResponse(value, accepted, expectedAppId) {
|
|
|
74
75
|
);
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
function isMatchingTerminalFailureResponse(value, accepted, expectedAppId) {
|
|
79
|
+
return (
|
|
80
|
+
value &&
|
|
81
|
+
typeof value === "object" &&
|
|
82
|
+
!Array.isArray(value) &&
|
|
83
|
+
value.success === false &&
|
|
84
|
+
value.deleted === expectedAppId &&
|
|
85
|
+
value.operationId === accepted.operationId &&
|
|
86
|
+
value.correlationId === accepted.correlationId &&
|
|
87
|
+
APP_DELETE_TERMINAL_FAILURE_STATES.has(value.state) &&
|
|
88
|
+
DIAGNOSTIC_CODE_PATTERN.test(value.code ?? "")
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
77
92
|
export async function pollAppDeleteOperation({
|
|
78
93
|
creds,
|
|
79
94
|
initialBody,
|
|
@@ -99,6 +114,9 @@ export async function pollAppDeleteOperation({
|
|
|
99
114
|
if (!statusRes?.ok || !statusData) return invalidPollResponse(accepted);
|
|
100
115
|
successfulPollResponses += 1;
|
|
101
116
|
if (isMatchingTerminalResponse(statusData, accepted, name)) return statusData;
|
|
117
|
+
if (isMatchingTerminalFailureResponse(statusData, accepted, name)) {
|
|
118
|
+
return { ...statusData, error: statusData.code };
|
|
119
|
+
}
|
|
102
120
|
const operation = parseAppDeleteOperationEnvelope(statusData, name);
|
|
103
121
|
if (
|
|
104
122
|
!operation ||
|
|
@@ -10,6 +10,10 @@ function optionalString(value) {
|
|
|
10
10
|
return value == null || typeof value === "string";
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
export function appNotFoundDiagnostic() {
|
|
14
|
+
return "App not found or not owned by you.\nCode: APP_NOT_FOUND";
|
|
15
|
+
}
|
|
16
|
+
|
|
13
17
|
export function parseAppListResponse(value) {
|
|
14
18
|
if (!Array.isArray(value)) return null;
|
|
15
19
|
return value.every((row) => {
|
|
@@ -26,6 +30,7 @@ export function parseAppListResponse(value) {
|
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
export function parseAppLookupResponse(value, expectedAppId) {
|
|
33
|
+
if (value === null) return { kind: "not_found" };
|
|
29
34
|
const body = record(value);
|
|
30
35
|
if (!body) return { kind: "invalid_response" };
|
|
31
36
|
const hasEnvelope = Object.prototype.hasOwnProperty.call(body, "result");
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { posix, win32 } from "node:path";
|
|
2
|
+
|
|
3
|
+
export function toDrizzleProjectPath(projectRoot, canonicalFile) {
|
|
4
|
+
const pathApi = win32.isAbsolute(projectRoot) ? win32 : posix;
|
|
5
|
+
const projectPath = pathApi.relative(projectRoot, canonicalFile).replaceAll("\\", "/");
|
|
6
|
+
return projectPath.startsWith(".") ? projectPath : `./${projectPath}`;
|
|
7
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { existsSync, lstatSync, realpathSync } from "node:fs";
|
|
2
2
|
import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
|
|
3
3
|
|
|
4
|
+
import { toDrizzleProjectPath } from "./canonical-drizzle-generator-paths.mjs";
|
|
5
|
+
|
|
4
6
|
export const GENCOW_DRIZZLE_CONFIG_CONTRACT_VERSION = 1;
|
|
5
7
|
|
|
6
8
|
function requiredEnvironment(name) {
|
|
@@ -38,7 +40,7 @@ function resolveSchemaFiles(projectRoot) {
|
|
|
38
40
|
}
|
|
39
41
|
if (!seen.has(canonical)) {
|
|
40
42
|
seen.add(canonical);
|
|
41
|
-
files.push(canonical);
|
|
43
|
+
files.push(toDrizzleProjectPath(projectRoot, canonical));
|
|
42
44
|
}
|
|
43
45
|
}
|
|
44
46
|
if (files.length === 0) throw new Error("Gencow migration schema list is empty");
|
|
@@ -6,6 +6,7 @@ export const REQUIRED_CLI_TARBALL_FILES = Object.freeze([
|
|
|
6
6
|
"lib/cron-manifest.mjs",
|
|
7
7
|
"lib/dev-cloud-bundle.mjs",
|
|
8
8
|
"lib/canonical-drizzle-generator.config.mjs",
|
|
9
|
+
"lib/canonical-drizzle-generator-paths.mjs",
|
|
9
10
|
"runtime/tooling.mjs",
|
|
10
11
|
"runtime/server.mjs",
|
|
11
12
|
"templateFeature/ai/manifest.json",
|
package/lib/deploy-command.mjs
CHANGED
|
@@ -5,7 +5,9 @@ import {
|
|
|
5
5
|
isAppFailureStatus,
|
|
6
6
|
selectAppDiagnosticLogLines,
|
|
7
7
|
} from "./app-diagnostics.mjs";
|
|
8
|
+
import { appNotFoundDiagnostic, parseAppLookupResponse } from "./app-response-contract.mjs";
|
|
8
9
|
import { resolveCloudAppTarget } from "./cloud-targets.mjs";
|
|
10
|
+
import { readJsonValueResponse } from "./http-response-json.mjs";
|
|
9
11
|
|
|
10
12
|
export function renderDeployHelp() {
|
|
11
13
|
log(`\n${BOLD}${CYAN}gencow deploy${RESET} — Deploy backend or fullstack app to cloud\n`);
|
|
@@ -92,20 +94,35 @@ export async function handleDeployReadonlySubcommand(params) {
|
|
|
92
94
|
|
|
93
95
|
infoImpl(`Checking "${appId}" status (${envLabel})...`);
|
|
94
96
|
const res = await rpcQueryImpl(creds, "apps.get", { name: appId });
|
|
97
|
+
const decoded = await readJsonValueResponse(res);
|
|
98
|
+
const body = decoded.parsed ? decoded.value : null;
|
|
95
99
|
if (!res.ok) {
|
|
96
|
-
const errData =
|
|
100
|
+
const errData = body && typeof body === "object" && !Array.isArray(body) ? body : {};
|
|
97
101
|
errorImpl(`Failed to fetch status: ${errData.error || res.statusText}`);
|
|
98
102
|
exitImpl(1);
|
|
99
103
|
return true;
|
|
100
104
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
105
|
+
if (!decoded.parsed) {
|
|
106
|
+
errorImpl(
|
|
107
|
+
`CLI_APP_RESPONSE_INVALID: deploy status returned an invalid success response (HTTP ${res.status})`,
|
|
108
|
+
);
|
|
109
|
+
exitImpl(1);
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
const lookup = parseAppLookupResponse(body, appId);
|
|
113
|
+
if (lookup.kind === "not_found") {
|
|
114
|
+
errorImpl(appNotFoundDiagnostic());
|
|
115
|
+
exitImpl(1);
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
if (lookup.kind === "invalid_response") {
|
|
119
|
+
errorImpl(
|
|
120
|
+
`CLI_APP_RESPONSE_INVALID: deploy status returned an invalid success response (HTTP ${res.status})`,
|
|
121
|
+
);
|
|
106
122
|
exitImpl(1);
|
|
107
123
|
return true;
|
|
108
124
|
}
|
|
125
|
+
const app = lookup.app;
|
|
109
126
|
logImpl("");
|
|
110
127
|
logImpl(` ${BOLD}App Status${RESET}`);
|
|
111
128
|
logImpl(` ──────────────────────`);
|
|
@@ -42,7 +42,12 @@ function readDeployFailureContract(value, expectedAppName) {
|
|
|
42
42
|
const retryClass = safeText(value.retryClass, 32);
|
|
43
43
|
const userAction = safeText(value.userAction, 32);
|
|
44
44
|
const correlationId = value.correlationId === undefined ? null : safeText(value.correlationId, 128);
|
|
45
|
-
const operationId =
|
|
45
|
+
const operationId =
|
|
46
|
+
Number.isSafeInteger(value.operationId) && value.operationId > 0
|
|
47
|
+
? String(value.operationId)
|
|
48
|
+
: value.operationId === undefined
|
|
49
|
+
? null
|
|
50
|
+
: safeText(value.operationId, 80);
|
|
46
51
|
if (
|
|
47
52
|
!code?.match(/^[A-Z][A-Z0-9_]{2,99}$/u) ||
|
|
48
53
|
!stage ||
|
|
@@ -120,6 +125,19 @@ export function renderDeployFailureDiagnosticLines(responseBody, statusText, opt
|
|
|
120
125
|
lines.push(`Next command: gencow app delete ${options.expectedAppName} --force`);
|
|
121
126
|
return lines;
|
|
122
127
|
}
|
|
128
|
+
if (contract.userAction === "VIEW_STATUS" && contract.operationId?.match(/^[1-9][0-9]*$/u)) {
|
|
129
|
+
const publicMessage = safeText(responseBody.error, 240) ?? fallbackMessage;
|
|
130
|
+
const lines = [
|
|
131
|
+
`Deploy failed: ${publicMessage}`,
|
|
132
|
+
`Code: ${contract.code}`,
|
|
133
|
+
`Stage: ${contract.stage}`,
|
|
134
|
+
`Operation ID: ${contract.operationId}`,
|
|
135
|
+
];
|
|
136
|
+
const correlationLine = formatDeployCorrelationId(contract.correlationId);
|
|
137
|
+
if (correlationLine) lines.push(correlationLine);
|
|
138
|
+
lines.push("Next command: gencow deploy status");
|
|
139
|
+
return lines;
|
|
140
|
+
}
|
|
123
141
|
const publicMessage = contract.legacySupportAction
|
|
124
142
|
? PLATFORM_RECOVERY_MESSAGE
|
|
125
143
|
: (safeText(responseBody.error, 240) ?? fallbackMessage);
|
|
@@ -5,6 +5,7 @@ const TERMINAL_STATES = new Set(["succeeded", "failed", "blocked", "cancelled"])
|
|
|
5
5
|
const OPERATION_ID = /^[1-9][0-9]*$/u;
|
|
6
6
|
const CORRELATION_ID = /^[A-Za-z0-9_-]{8,128}$/u;
|
|
7
7
|
const APP_NAME = /^[a-z0-9][a-z0-9-]{0,62}$/u;
|
|
8
|
+
export const DEFAULT_DEPLOYMENT_OPERATION_POLL_DEADLINE_MS = 12 * 60 * 1000;
|
|
8
9
|
|
|
9
10
|
function invalidOperationResponse(message) {
|
|
10
11
|
return {
|
|
@@ -68,6 +69,10 @@ export async function pollAcceptedDeploymentOperation({
|
|
|
68
69
|
expectedAppName,
|
|
69
70
|
platformFetch,
|
|
70
71
|
setTimeoutImpl,
|
|
72
|
+
deadlineSetTimeoutImpl = setTimeout,
|
|
73
|
+
deadlineClearTimeoutImpl = clearTimeout,
|
|
74
|
+
nowImpl = () => performance.now(),
|
|
75
|
+
totalDeadlineMs = DEFAULT_DEPLOYMENT_OPERATION_POLL_DEADLINE_MS,
|
|
71
76
|
}) {
|
|
72
77
|
const expected = {
|
|
73
78
|
operationId: null,
|
|
@@ -78,8 +83,53 @@ export async function pollAcceptedDeploymentOperation({
|
|
|
78
83
|
if (typeof expected.appName !== "string" || !APP_NAME.test(expected.appName)) {
|
|
79
84
|
return invalidOperationResponse("Deployment operation app identity is invalid");
|
|
80
85
|
}
|
|
86
|
+
if (!Number.isSafeInteger(totalDeadlineMs) || totalDeadlineMs < 1) {
|
|
87
|
+
return invalidOperationResponse("Deployment operation deadline is invalid");
|
|
88
|
+
}
|
|
81
89
|
let response = initialResponse;
|
|
82
|
-
|
|
90
|
+
const startedAtMs = nowImpl();
|
|
91
|
+
const deadlineMs = startedAtMs + totalDeadlineMs;
|
|
92
|
+
let lastIdentity = null;
|
|
93
|
+
const pendingResponse = () => ({
|
|
94
|
+
ok: false,
|
|
95
|
+
status: 503,
|
|
96
|
+
statusText: "Service Unavailable",
|
|
97
|
+
json: async () => ({
|
|
98
|
+
error: "Deployment is still running. The server operation was not cancelled.",
|
|
99
|
+
code: "PLATFORM_DEPLOYMENT_OPERATION_PENDING",
|
|
100
|
+
stage: "controller",
|
|
101
|
+
retryClass: "retryable-infra",
|
|
102
|
+
userAction: "VIEW_STATUS",
|
|
103
|
+
operationId: lastIdentity?.operationId,
|
|
104
|
+
correlationId: lastIdentity?.correlationId,
|
|
105
|
+
appName: lastIdentity?.appName,
|
|
106
|
+
state: lastIdentity?.state,
|
|
107
|
+
statusCommand: "gencow deploy status",
|
|
108
|
+
serverOperationContinues: true,
|
|
109
|
+
}),
|
|
110
|
+
});
|
|
111
|
+
const fetchWithinDeadline = async () => {
|
|
112
|
+
const remainingMs = Math.max(0, deadlineMs - nowImpl());
|
|
113
|
+
if (remainingMs === 0) return null;
|
|
114
|
+
const controller = new AbortController();
|
|
115
|
+
let timer;
|
|
116
|
+
const timedOut = new Promise((resolve) => {
|
|
117
|
+
timer = deadlineSetTimeoutImpl(() => {
|
|
118
|
+
controller.abort(new Error("PLATFORM_DEPLOYMENT_OPERATION_POLL_DEADLINE_EXCEEDED"));
|
|
119
|
+
resolve(null);
|
|
120
|
+
}, remainingMs);
|
|
121
|
+
timer?.unref?.();
|
|
122
|
+
});
|
|
123
|
+
try {
|
|
124
|
+
return await Promise.race([
|
|
125
|
+
platformFetch(creds, expected.pollUrl, { method: "GET", signal: controller.signal }),
|
|
126
|
+
timedOut,
|
|
127
|
+
]);
|
|
128
|
+
} finally {
|
|
129
|
+
if (timer !== undefined) deadlineClearTimeoutImpl(timer);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
while (true) {
|
|
83
133
|
const body = await readJsonObjectResponse(response);
|
|
84
134
|
const identity = readIdentity(body);
|
|
85
135
|
const nonterminal = isNonterminalResponse(response, body, identity);
|
|
@@ -90,11 +140,17 @@ export async function pollAcceptedDeploymentOperation({
|
|
|
90
140
|
expected.operationId ??= identity.operationId;
|
|
91
141
|
expected.correlationId ??= identity.correlationId;
|
|
92
142
|
expected.pollUrl ??= identity.pollUrl;
|
|
143
|
+
lastIdentity = identity;
|
|
144
|
+
if (nowImpl() >= deadlineMs) return pendingResponse();
|
|
93
145
|
const retryAfter = Number(response.headers?.get?.("Retry-After"));
|
|
94
146
|
const retryDelayMs =
|
|
95
147
|
Number.isFinite(retryAfter) && retryAfter >= 1 && retryAfter <= 10 ? retryAfter * 1000 : 1000;
|
|
96
|
-
|
|
97
|
-
|
|
148
|
+
const remainingMs = Math.max(0, deadlineMs - nowImpl());
|
|
149
|
+
await new Promise((resolveDelay) => setTimeoutImpl(resolveDelay, Math.min(retryDelayMs, remainingMs)));
|
|
150
|
+
if (nowImpl() >= deadlineMs) return pendingResponse();
|
|
151
|
+
const polledResponse = await fetchWithinDeadline();
|
|
152
|
+
if (!polledResponse) return pendingResponse();
|
|
153
|
+
response = polledResponse;
|
|
98
154
|
continue;
|
|
99
155
|
}
|
|
100
156
|
if (
|
|
@@ -112,16 +168,4 @@ export async function pollAcceptedDeploymentOperation({
|
|
|
112
168
|
json: async () => body,
|
|
113
169
|
};
|
|
114
170
|
}
|
|
115
|
-
return {
|
|
116
|
-
ok: false,
|
|
117
|
-
status: 503,
|
|
118
|
-
statusText: "Service Unavailable",
|
|
119
|
-
json: async () => ({
|
|
120
|
-
error: "Deployment operation did not reach a terminal state",
|
|
121
|
-
code: "PLATFORM_DEPLOYMENT_OPERATION_PENDING",
|
|
122
|
-
stage: "controller",
|
|
123
|
-
retryClass: "retryable-infra",
|
|
124
|
-
userAction: "RETRY",
|
|
125
|
-
}),
|
|
126
|
-
};
|
|
127
171
|
}
|
package/lib/doctor-command.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
formatCheckReport,
|
|
11
11
|
planBomSync,
|
|
12
12
|
} from "./runtime-bom.mjs";
|
|
13
|
+
import { syncStandalonePackageAndLock } from "./project-dependency-lock-sync.mjs";
|
|
13
14
|
import {
|
|
14
15
|
APPROVED_DEPLOY_PACKAGE_MANAGER,
|
|
15
16
|
UNSUPPORTED_DEPLOY_LOCKFILES,
|
|
@@ -17,8 +18,7 @@ import {
|
|
|
17
18
|
|
|
18
19
|
export function inspectPackageManagerAuthority({ cwd, existsSyncImpl, readFileSyncImpl }) {
|
|
19
20
|
const packagePath = resolve(cwd, "package.json");
|
|
20
|
-
const legacyLocks = UNSUPPORTED_DEPLOY_LOCKFILES
|
|
21
|
-
.filter((file) => existsSyncImpl(resolve(cwd, file)));
|
|
21
|
+
const legacyLocks = UNSUPPORTED_DEPLOY_LOCKFILES.filter((file) => existsSyncImpl(resolve(cwd, file)));
|
|
22
22
|
const hasPnpmLock = existsSyncImpl(resolve(cwd, "pnpm-lock.yaml"));
|
|
23
23
|
let declared = "";
|
|
24
24
|
if (existsSyncImpl(packagePath)) {
|
|
@@ -43,8 +43,12 @@ function renderDoctorHelp(deps) {
|
|
|
43
43
|
);
|
|
44
44
|
deps.logImpl(` ${deps.BOLD}Usage:${deps.RESET} gencow doctor [options]\n`);
|
|
45
45
|
deps.logImpl(` Runs analyzer-backed Doctor checks before local/dev/deploy-style flows.\n`);
|
|
46
|
-
deps.logImpl(
|
|
47
|
-
|
|
46
|
+
deps.logImpl(
|
|
47
|
+
` Checks include schema graph validation, migration source/history preview, unsafe DB access,`,
|
|
48
|
+
);
|
|
49
|
+
deps.logImpl(
|
|
50
|
+
` forbidden modules, source anti-patterns, deprecated backend APIs, and Runtime BOM drift.\n`,
|
|
51
|
+
);
|
|
48
52
|
deps.logImpl(` Deploy authority is ${APPROVED_DEPLOY_PACKAGE_MANAGER} + pnpm-lock.yaml only.\n`);
|
|
49
53
|
deps.logImpl(` ${deps.BOLD}Options:${deps.RESET}`);
|
|
50
54
|
deps.logImpl(
|
|
@@ -133,6 +137,7 @@ async function runRuntimeBomDoctorSection(params) {
|
|
|
133
137
|
existsSyncImpl,
|
|
134
138
|
readFileSyncImpl,
|
|
135
139
|
writeFileSyncImpl,
|
|
140
|
+
execFileSyncImpl,
|
|
136
141
|
} = params;
|
|
137
142
|
|
|
138
143
|
deps.logImpl(`${deps.BOLD}Runtime BOM${deps.RESET}`);
|
|
@@ -226,10 +231,17 @@ async function runRuntimeBomDoctorSection(params) {
|
|
|
226
231
|
}
|
|
227
232
|
|
|
228
233
|
const applied = applyBomSyncToPackageJson(packageJson, plan);
|
|
229
|
-
|
|
234
|
+
const syncResult = syncStandalonePackageAndLock(
|
|
235
|
+
{ cwd, nextPackageJson: applied.packageJson },
|
|
236
|
+
{ existsSyncImpl, readFileSyncImpl, writeFileSyncImpl, execFileSyncImpl },
|
|
237
|
+
);
|
|
230
238
|
for (const warning of applied.warnings) deps.warnImpl?.(warning);
|
|
231
239
|
deps.successImpl(`Synced managed dependencies in package.json to ${bom.releaseId}`);
|
|
232
|
-
|
|
240
|
+
if (syncResult.synchronizedLock) {
|
|
241
|
+
deps.successImpl("pnpm-lock.yaml remains deploy-valid.");
|
|
242
|
+
} else {
|
|
243
|
+
deps.infoImpl("Run pnpm install to refresh pnpm-lock.yaml.");
|
|
244
|
+
}
|
|
233
245
|
deps.logImpl("");
|
|
234
246
|
return { blocked: false, synced: true };
|
|
235
247
|
}
|
|
@@ -240,6 +252,7 @@ export function createDoctorCommand(deps) {
|
|
|
240
252
|
const existsSyncImpl = deps.existsSyncImpl ?? existsSync;
|
|
241
253
|
const readFileSyncImpl = deps.readFileSyncImpl ?? readFileSync;
|
|
242
254
|
const writeFileSyncImpl = deps.writeFileSyncImpl ?? writeFileSync;
|
|
255
|
+
const execFileSyncImpl = deps.execFileSyncImpl;
|
|
243
256
|
const warnImpl = deps.warnImpl ?? deps.infoImpl;
|
|
244
257
|
|
|
245
258
|
return async function doctor(...doctorArgs) {
|
|
@@ -281,17 +294,23 @@ export function createDoctorCommand(deps) {
|
|
|
281
294
|
deps.infoImpl(`Backend root: ${backendRoot}/`);
|
|
282
295
|
deps.infoImpl(`Schema: ${schemaDisplay}`);
|
|
283
296
|
try {
|
|
284
|
-
const migrationPreview = (deps.buildSchemaDeploymentPreviewImpl ?? buildProjectSchemaDeploymentPreview)(
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
297
|
+
const migrationPreview = (deps.buildSchemaDeploymentPreviewImpl ?? buildProjectSchemaDeploymentPreview)(
|
|
298
|
+
{
|
|
299
|
+
cwd,
|
|
300
|
+
backendDir: backendRoot,
|
|
301
|
+
cliVersion: deps.cliVersion ?? "0.0.0",
|
|
302
|
+
},
|
|
303
|
+
);
|
|
289
304
|
deps.infoImpl(`Migration schema source: ${migrationPreview.schemaSource}`);
|
|
290
305
|
deps.infoImpl(`Generator: drizzle-kit ${migrationPreview.generatorVersion}`);
|
|
291
|
-
deps.infoImpl(
|
|
306
|
+
deps.infoImpl(
|
|
307
|
+
`Migration format: ${migrationPreview.migrationFormat} (${migrationPreview.migrationCount})`,
|
|
308
|
+
);
|
|
292
309
|
deps.infoImpl(`Migration decision preview: ${migrationPreview.decision}`);
|
|
293
310
|
if (migrationPreview.decision === "canonical_check_required") {
|
|
294
|
-
deps.infoImpl(
|
|
311
|
+
deps.infoImpl(
|
|
312
|
+
"Canonical generation resolves whether empty history is deployable or requires a v1 migration.",
|
|
313
|
+
);
|
|
295
314
|
}
|
|
296
315
|
deps.infoImpl(
|
|
297
316
|
`Fix mode: ${
|
|
@@ -322,10 +341,15 @@ export function createDoctorCommand(deps) {
|
|
|
322
341
|
existsSyncImpl,
|
|
323
342
|
readFileSyncImpl,
|
|
324
343
|
writeFileSyncImpl,
|
|
344
|
+
execFileSyncImpl,
|
|
325
345
|
});
|
|
326
346
|
bomBlocked = bomResult.blocked;
|
|
327
347
|
|
|
328
|
-
const bundled = await (
|
|
348
|
+
const bundled = await (
|
|
349
|
+
deps.loadInternalBundleImpl ??
|
|
350
|
+
deps.loadCodegenBundleImpl ??
|
|
351
|
+
loadCodegenBundleDefault
|
|
352
|
+
)();
|
|
329
353
|
|
|
330
354
|
const result = await bundled.runAppDoctor({
|
|
331
355
|
projectRoot: cwd,
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
export async function
|
|
2
|
-
let value;
|
|
1
|
+
export async function readJsonValueResponse(response) {
|
|
3
2
|
try {
|
|
4
|
-
value
|
|
3
|
+
return { parsed: true, value: await response.json() };
|
|
5
4
|
} catch {
|
|
6
|
-
return
|
|
5
|
+
return { parsed: false };
|
|
7
6
|
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function readJsonObjectResponse(response) {
|
|
10
|
+
const decoded = await readJsonValueResponse(response);
|
|
11
|
+
if (!decoded.parsed) return null;
|
|
12
|
+
const { value } = decoded;
|
|
8
13
|
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
9
14
|
}
|
package/lib/init-command.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { execSync } from "child_process";
|
|
2
1
|
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, symlinkSync, writeFileSync } from "fs";
|
|
3
2
|
import { basename, dirname, resolve } from "path";
|
|
4
3
|
import { fileURLToPath } from "url";
|
|
5
4
|
import { getBackendEnvBaseRelativePath, logEnvFileOperationWithType } from "./backend-env-resolver.mjs";
|
|
6
5
|
import { collectFeatureDependencies, installFeatures, loadFeatureCatalog } from "./install-features.mjs";
|
|
7
6
|
import { BOLD, CYAN, DIM, GREEN, RESET, error, info, log, success, warn } from "./output.mjs";
|
|
7
|
+
import { installAndVerifyProjectDependencies } from "./project-dependency-lock-sync.mjs";
|
|
8
8
|
|
|
9
9
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
10
|
|
|
@@ -200,7 +200,9 @@ function renderInitHelp(logImpl) {
|
|
|
200
200
|
logImpl(` ${BOLD}Arguments:${RESET}`);
|
|
201
201
|
logImpl(` ${CYAN}name${RESET} Project name (creates directory) or "." for current dir\n`);
|
|
202
202
|
logImpl(` ${BOLD}Options:${RESET}`);
|
|
203
|
-
logImpl(
|
|
203
|
+
logImpl(
|
|
204
|
+
` ${DIM}--template, -t${RESET} Select template (default, task-app, admin-tool, fullstack, ai-chat)`,
|
|
205
|
+
);
|
|
204
206
|
logImpl(` ${DIM}--force, -f${RESET} Initialize in non-empty directory`);
|
|
205
207
|
logImpl(` ${DIM}--no-install${RESET} Skip dependency install (CI/canary)\n`);
|
|
206
208
|
logImpl(` ${BOLD}Examples:${RESET}`);
|
|
@@ -284,7 +286,7 @@ export function createInitCommand({
|
|
|
284
286
|
createInterfaceImpl,
|
|
285
287
|
cwdImpl = () => process.cwd(),
|
|
286
288
|
errorImpl = error,
|
|
287
|
-
|
|
289
|
+
execFileSyncImpl,
|
|
288
290
|
exitImpl = (code) => process.exit(code),
|
|
289
291
|
infoImpl = info,
|
|
290
292
|
logImpl = log,
|
|
@@ -471,7 +473,7 @@ export function createInitCommand({
|
|
|
471
473
|
await installFeatures(template.features, projectDir, {
|
|
472
474
|
catalog,
|
|
473
475
|
templateFeatureDir,
|
|
474
|
-
installPackages:
|
|
476
|
+
installPackages: false,
|
|
475
477
|
cwdImpl: () => projectDir,
|
|
476
478
|
existsSyncImpl: existsSync,
|
|
477
479
|
loadConfig: async () => ({ rootDir: "gencow", envFile: "gencow/.env" }),
|
|
@@ -581,15 +583,13 @@ export function createInitCommand({
|
|
|
581
583
|
} else {
|
|
582
584
|
infoImpl("Installing dependencies...");
|
|
583
585
|
try {
|
|
584
|
-
|
|
586
|
+
installAndVerifyProjectDependencies(projectDir, { execFileSyncImpl });
|
|
585
587
|
successImpl("Dependencies installed");
|
|
586
|
-
} catch {
|
|
588
|
+
} catch (caught) {
|
|
587
589
|
installStatus = "failed";
|
|
588
|
-
warnImpl(
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
" to install manually",
|
|
592
|
-
);
|
|
590
|
+
warnImpl(`${caught?.code ?? "CLI_INIT_DEPENDENCY_INSTALL_FAILED"}: Dependency setup failed.`);
|
|
591
|
+
exitImpl(1);
|
|
592
|
+
return;
|
|
593
593
|
}
|
|
594
594
|
}
|
|
595
595
|
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { execFileSync } from "child_process";
|
|
2
|
+
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "fs";
|
|
3
|
+
import { resolve } from "path";
|
|
4
|
+
|
|
5
|
+
import { APPROVED_DEPLOY_PACKAGE_MANAGER, inspectPnpmDependencyLock } from "@gencow/migration-contract";
|
|
6
|
+
|
|
7
|
+
const PACKAGE_FILE = "package.json";
|
|
8
|
+
const LOCK_FILE = "pnpm-lock.yaml";
|
|
9
|
+
|
|
10
|
+
function approvedPnpmSelector() {
|
|
11
|
+
return APPROVED_DEPLOY_PACKAGE_MANAGER;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function corepackCommand(platform) {
|
|
15
|
+
return platform === "win32" ? "corepack.cmd" : "corepack";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readProjectDependencyFiles(projectDir, deps) {
|
|
19
|
+
const packagePath = resolve(projectDir, PACKAGE_FILE);
|
|
20
|
+
const lockPath = resolve(projectDir, LOCK_FILE);
|
|
21
|
+
return {
|
|
22
|
+
packagePath,
|
|
23
|
+
lockPath,
|
|
24
|
+
packageText: deps.readFileSyncImpl(packagePath, "utf8"),
|
|
25
|
+
lockText: deps.existsSyncImpl(lockPath) ? deps.readFileSyncImpl(lockPath, "utf8") : null,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function assertDeployValidLock(projectDir, deps, failureCode) {
|
|
30
|
+
const files = readProjectDependencyFiles(projectDir, deps);
|
|
31
|
+
const admission = inspectPnpmDependencyLock({
|
|
32
|
+
packageJsonText: files.packageText,
|
|
33
|
+
pnpmLockText: files.lockText,
|
|
34
|
+
});
|
|
35
|
+
if (!admission.ok) {
|
|
36
|
+
const error = new Error(failureCode);
|
|
37
|
+
error.code = failureCode;
|
|
38
|
+
error.reason = admission.reason;
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
return admission;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function hasWorkspaceDependency(packageJson) {
|
|
45
|
+
return ["dependencies", "devDependencies", "optionalDependencies"].some((group) =>
|
|
46
|
+
Object.values(packageJson[group] ?? {}).some(
|
|
47
|
+
(specifier) => typeof specifier === "string" && specifier.startsWith("workspace:"),
|
|
48
|
+
),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isStandaloneDeployProject(packageJson) {
|
|
53
|
+
return (
|
|
54
|
+
packageJson?.packageManager === APPROVED_DEPLOY_PACKAGE_MANAGER && !hasWorkspaceDependency(packageJson)
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function restoreFile(path, previous, deps) {
|
|
59
|
+
if (previous === null) {
|
|
60
|
+
if (deps.existsSyncImpl(path)) deps.unlinkSyncImpl(path);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
deps.writeFileSyncImpl(path, previous, "utf8");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function dependencyIoDeps(overrides = {}) {
|
|
67
|
+
return {
|
|
68
|
+
execFileSyncImpl: overrides.execFileSyncImpl ?? execFileSync,
|
|
69
|
+
existsSyncImpl: overrides.existsSyncImpl ?? existsSync,
|
|
70
|
+
readFileSyncImpl: overrides.readFileSyncImpl ?? readFileSync,
|
|
71
|
+
platform: overrides.platform ?? process.platform,
|
|
72
|
+
unlinkSyncImpl: overrides.unlinkSyncImpl ?? unlinkSync,
|
|
73
|
+
writeFileSyncImpl: overrides.writeFileSyncImpl ?? writeFileSync,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function installAndVerifyProjectDependencies(projectDir, overrides = {}) {
|
|
78
|
+
const deps = dependencyIoDeps(overrides);
|
|
79
|
+
try {
|
|
80
|
+
deps.execFileSyncImpl(
|
|
81
|
+
corepackCommand(deps.platform),
|
|
82
|
+
[approvedPnpmSelector(), "install", "--ignore-workspace"],
|
|
83
|
+
{
|
|
84
|
+
cwd: projectDir,
|
|
85
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
86
|
+
},
|
|
87
|
+
);
|
|
88
|
+
} catch {
|
|
89
|
+
const error = new Error("CLI_INIT_DEPENDENCY_INSTALL_FAILED");
|
|
90
|
+
error.code = "CLI_INIT_DEPENDENCY_INSTALL_FAILED";
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
return assertDeployValidLock(projectDir, deps, "CLI_INIT_LOCK_POSTCONDITION_FAILED");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function syncStandalonePackageAndLock({ cwd, nextPackageJson }, overrides = {}) {
|
|
97
|
+
const deps = dependencyIoDeps(overrides);
|
|
98
|
+
const current = readProjectDependencyFiles(cwd, deps);
|
|
99
|
+
const nextPackageText = `${JSON.stringify(nextPackageJson, null, 2)}\n`;
|
|
100
|
+
|
|
101
|
+
if (!isStandaloneDeployProject(nextPackageJson)) {
|
|
102
|
+
deps.writeFileSyncImpl(current.packagePath, nextPackageText, "utf8");
|
|
103
|
+
return { synchronizedLock: false };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
deps.writeFileSyncImpl(current.packagePath, nextPackageText, "utf8");
|
|
108
|
+
deps.execFileSyncImpl(
|
|
109
|
+
corepackCommand(deps.platform),
|
|
110
|
+
[approvedPnpmSelector(), "install", "--lockfile-only", "--ignore-scripts", "--ignore-workspace"],
|
|
111
|
+
{ cwd, stdio: ["ignore", "pipe", "pipe"] },
|
|
112
|
+
);
|
|
113
|
+
const admission = assertDeployValidLock(cwd, deps, "CLI_RUNTIME_BOM_LOCK_SYNC_FAILED");
|
|
114
|
+
return { synchronizedLock: true, admission };
|
|
115
|
+
} catch {
|
|
116
|
+
restoreFile(current.packagePath, current.packageText, deps);
|
|
117
|
+
restoreFile(current.lockPath, current.lockText, deps);
|
|
118
|
+
const error = new Error("CLI_RUNTIME_BOM_LOCK_SYNC_FAILED");
|
|
119
|
+
error.code = "CLI_RUNTIME_BOM_LOCK_SYNC_FAILED";
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
}
|