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.
@@ -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 body = await readJsonObjectResponse(res);
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
- ? appNotFoundError()
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(appNotFoundError());
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",
@@ -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 = await res.json().catch(() => ({}));
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
- const data = await res.json();
103
- const app = data.result || data;
104
- if (!app) {
105
- errorImpl("App not found.");
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 = value.operationId === undefined ? null : safeText(value.operationId, 80);
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
- for (let attempt = 0; attempt < 900; attempt += 1) {
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
- await new Promise((resolveDelay) => setTimeoutImpl(resolveDelay, retryDelayMs));
97
- response = await platformFetch(creds, expected.pollUrl, { method: "GET" });
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
  }
@@ -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(` Checks include schema graph validation, migration source/history preview, unsafe DB access,`);
47
- deps.logImpl(` forbidden modules, source anti-patterns, deprecated backend APIs, and Runtime BOM drift.\n`);
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
- writeFileSyncImpl(pkgPath, `${JSON.stringify(applied.packageJson, null, 2)}\n`, "utf8");
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
- deps.infoImpl("Run pnpm install to refresh pnpm-lock.yaml.");
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
- cwd,
286
- backendDir: backendRoot,
287
- cliVersion: deps.cliVersion ?? "0.0.0",
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(`Migration format: ${migrationPreview.migrationFormat} (${migrationPreview.migrationCount})`);
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("Canonical generation resolves whether empty history is deployable or requires a v1 migration.");
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 (deps.loadInternalBundleImpl ?? deps.loadCodegenBundleImpl ?? loadCodegenBundleDefault)();
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 readJsonObjectResponse(response) {
2
- let value;
1
+ export async function readJsonValueResponse(response) {
3
2
  try {
4
- value = await response.json();
3
+ return { parsed: true, value: await response.json() };
5
4
  } catch {
6
- return null;
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
  }
@@ -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(` ${DIM}--template, -t${RESET} Select template (default, task-app, admin-tool, fullstack, ai-chat)`);
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
- execSyncImpl = execSync,
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: !noInstall,
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
- execSyncImpl("pnpm install", { cwd: projectDir, stdio: ["ignore", "pipe", "pipe"] });
586
+ installAndVerifyProjectDependencies(projectDir, { execFileSyncImpl });
585
587
  successImpl("Dependencies installed");
586
- } catch {
588
+ } catch (caught) {
587
589
  installStatus = "failed";
588
- warnImpl(
589
- "Package install failed — " +
590
- (isCwd ? "pnpm install" : "cd " + name + " && pnpm install") +
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
+ }