@hs-x/cli 0.2.3 → 0.2.5

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.
Files changed (49) hide show
  1. package/dist/cli/index.d.ts.map +1 -1
  2. package/dist/cli/index.js +4 -1
  3. package/dist/cli/index.js.map +1 -1
  4. package/dist/cli/kit.d.ts.map +1 -1
  5. package/dist/cli/kit.js +1 -1
  6. package/dist/cli/kit.js.map +1 -1
  7. package/dist/cloudflare-oauth.d.ts.map +1 -1
  8. package/dist/cloudflare-oauth.js +10 -9
  9. package/dist/cloudflare-oauth.js.map +1 -1
  10. package/dist/commands/connect.d.ts +2 -0
  11. package/dist/commands/connect.d.ts.map +1 -1
  12. package/dist/commands/connect.js +29 -1
  13. package/dist/commands/connect.js.map +1 -1
  14. package/dist/commands/control-plane-read.d.ts +60 -9
  15. package/dist/commands/control-plane-read.d.ts.map +1 -1
  16. package/dist/commands/control-plane-read.js +219 -26
  17. package/dist/commands/control-plane-read.js.map +1 -1
  18. package/dist/commands/deploy.d.ts +51 -0
  19. package/dist/commands/deploy.d.ts.map +1 -1
  20. package/dist/commands/deploy.js +462 -23
  21. package/dist/commands/deploy.js.map +1 -1
  22. package/dist/commands/dev.d.ts.map +1 -1
  23. package/dist/commands/dev.js +156 -18
  24. package/dist/commands/dev.js.map +1 -1
  25. package/dist/commands/help-command.d.ts.map +1 -1
  26. package/dist/commands/help-command.js +33 -11
  27. package/dist/commands/help-command.js.map +1 -1
  28. package/dist/commands/migrate.d.ts +1 -1
  29. package/dist/commands/migrate.d.ts.map +1 -1
  30. package/dist/commands/migrate.js +119 -11
  31. package/dist/commands/migrate.js.map +1 -1
  32. package/dist/commands/status.d.ts +7 -1
  33. package/dist/commands/status.d.ts.map +1 -1
  34. package/dist/commands/status.js +94 -6
  35. package/dist/commands/status.js.map +1 -1
  36. package/dist/constants.d.ts +1 -1
  37. package/dist/constants.js +1 -1
  38. package/dist/dev/invoke.d.ts +9 -0
  39. package/dist/dev/invoke.d.ts.map +1 -1
  40. package/dist/dev/invoke.js +67 -0
  41. package/dist/dev/invoke.js.map +1 -1
  42. package/dist/errors-registry.d.ts.map +1 -1
  43. package/dist/errors-registry.js +29 -0
  44. package/dist/errors-registry.js.map +1 -1
  45. package/dist/project-binding.d.ts +7 -1
  46. package/dist/project-binding.d.ts.map +1 -1
  47. package/dist/project-binding.js +10 -5
  48. package/dist/project-binding.js.map +1 -1
  49. package/package.json +7 -7
@@ -412,6 +412,11 @@ export async function deployCommand({ argv, root, json, }) {
412
412
  projectId = answer;
413
413
  }
414
414
  }
415
+ if (projectId && accountId && !resolvedAcct.overrodeBinding) {
416
+ // Sticky binding, project half: project-scoped reads (status/logs/routes/
417
+ // drift) resolve their project id from here when no flag is passed.
418
+ await writeProjectBinding(root, accountId, projectId).catch(() => { });
419
+ }
415
420
  if (!json && willChangeAnything && !flagYes && isInteractive()) {
416
421
  const summary = buildDeployPlanSummary({
417
422
  accountId,
@@ -437,13 +442,31 @@ export async function deployCommand({ argv, root, json, }) {
437
442
  return cancelledResult(argv, 'deploy');
438
443
  }
439
444
  }
440
- const portalSchemaApply = applySchema
441
- ? await applyPortalSchemaPlan({
442
- argv,
443
- plan: portalSchemaPlan,
444
- source: portalSchemaRead?.source,
445
- })
446
- : undefined;
445
+ // The schema apply is the first phase that touches the portal. A failure here
446
+ // used to throw a raw HubSpot error out of the command — no HSX_E code, no
447
+ // phase/PLAN output (cold-stranger run #3, finding A). Render the curated
448
+ // failure WITH the plan that was being applied instead.
449
+ let portalSchemaApply;
450
+ if (applySchema) {
451
+ try {
452
+ portalSchemaApply = await applyPortalSchemaPlan({
453
+ argv,
454
+ plan: portalSchemaPlan,
455
+ source: portalSchemaRead?.source,
456
+ });
457
+ }
458
+ catch (error) {
459
+ return portalSchemaApplyFailure({
460
+ argv,
461
+ json,
462
+ root,
463
+ startedAt: commandStartedAt,
464
+ workers,
465
+ portalSchemaPlan,
466
+ error,
467
+ });
468
+ }
469
+ }
447
470
  const manifest = await generateProjectArtifacts({
448
471
  root,
449
472
  workers,
@@ -906,6 +929,11 @@ function renderDeployHuman(input) {
906
929
  .map((worker) => `${worker.workerName}${worker.dryRun ? ' (dry-run)' : ''}`)
907
930
  .join(', ');
908
931
  reporter.info(`Cloudflare deploy: ${deployed}`);
932
+ for (const worker of input.cloudflareDeployResult.workers) {
933
+ if (worker.unauthenticatedInvoke) {
934
+ reporter.warn('HSX_W_DEPLOY_UNAUTHENTICATED_INVOKE', `${worker.workerName}: workflow-action invoke endpoints accept unsigned requests until the app's OAuth client secret is configured (redeploy with HSX_HUBSPOT_CLIENT_SECRET / --hubspot-client-secret after the OAuth install).`);
935
+ }
936
+ }
909
937
  }
910
938
  if (!input.linked && input.cloudflareDeployResult) {
911
939
  reporter.info(`Deploy history for this machine: ${machineDashboardUrl(input.machineId)}`);
@@ -927,6 +955,22 @@ function renderDeployHuman(input) {
927
955
  if (upload.deployId !== undefined) {
928
956
  reporter.info(`HubSpot deploy id: ${upload.deployId}`);
929
957
  }
958
+ else if (hubspotBuildStatus === 'SUCCESS' &&
959
+ upload.deploySkipped &&
960
+ upload.deployStatus !== undefined) {
961
+ const autoStatus = readStatus(upload.deployStatus) ?? 'UNKNOWN';
962
+ if (autoStatus === 'SUCCESS') {
963
+ reporter.info(`HubSpot deploy: auto-deploy SUCCESS (build #${upload.buildId})`);
964
+ }
965
+ else {
966
+ reporter.warn('HSX_W_DEPLOY_HUBSPOT_AUTODEPLOY_FAILED', `HubSpot auto-deploy finished ${autoStatus} — the build is green but the app did NOT ship. Check the deploy log in the HubSpot UI (project ${upload.projectName}).`);
967
+ for (const subbuild of readSubbuildStatuses(upload.deployStatus)) {
968
+ if (subbuild.status !== 'SUCCESS') {
969
+ reporter.info(` [x] ${subbuild.name}: ${subbuild.errorMessage ?? subbuild.status}`);
970
+ }
971
+ }
972
+ }
973
+ }
930
974
  else if (hubspotBuildStatus === 'SUCCESS' && upload.deploySkipped) {
931
975
  reporter.info('HubSpot deploy: skipped (upload-only or auto-deploy handles it)');
932
976
  }
@@ -1077,13 +1121,24 @@ async function applyPortalSchemaPlan({ argv, plan, source, }) {
1077
1121
  });
1078
1122
  const applied = [];
1079
1123
  const skipped = [];
1124
+ // Each HubSpot write is wrapped so a failure carries WHICH action failed, the
1125
+ // target object type (scope errors are per-object), and what already applied
1126
+ // before it — the curated failure renderer needs all three.
1127
+ const apply = async (label, objectType, run) => {
1128
+ try {
1129
+ await run();
1130
+ }
1131
+ catch (cause) {
1132
+ throw new PortalSchemaApplyError({ action: label, objectType, applied: [...applied], cause });
1133
+ }
1134
+ applied.push(label);
1135
+ };
1080
1136
  for (const action of plan.actions) {
1081
1137
  if (action.kind === 'will-create-object') {
1082
- await client.schemaManagement.createSchema({
1138
+ await apply(`create-object:${action.objectType}`, action.objectType, () => client.schemaManagement.createSchema({
1083
1139
  name: action.objectType,
1084
1140
  labels: portalObjectLabels(action.objectType),
1085
- });
1086
- applied.push(`create-object:${action.objectType}`);
1141
+ }));
1087
1142
  continue;
1088
1143
  }
1089
1144
  if (!action.propertyName || !action.declaredType) {
@@ -1092,25 +1147,23 @@ async function applyPortalSchemaPlan({ argv, plan, source, }) {
1092
1147
  }
1093
1148
  if (action.kind === 'will-create-property') {
1094
1149
  const fieldType = defaultHubSpotFieldType(action.declaredType);
1095
- await client.schemaManagement.createProperty({
1150
+ await apply(`create-property:${action.objectType}.${action.propertyName}`, action.objectType, () => client.schemaManagement.createProperty({
1096
1151
  objectType: action.objectType,
1097
1152
  name: action.propertyName,
1098
1153
  label: propertyLabel(action.propertyName),
1099
1154
  type: action.declaredType,
1100
1155
  ...(fieldType ? { fieldType } : {}),
1101
- });
1102
- applied.push(`create-property:${action.objectType}.${action.propertyName}`);
1156
+ }));
1103
1157
  continue;
1104
1158
  }
1105
1159
  if (action.kind === 'will-alter-property') {
1106
1160
  const fieldType = defaultHubSpotFieldType(action.declaredType);
1107
- await client.schemaManagement.updateProperty({
1161
+ await apply(`alter-property:${action.objectType}.${action.propertyName}`, action.objectType, () => client.schemaManagement.updateProperty({
1108
1162
  objectType: action.objectType,
1109
1163
  propertyName: action.propertyName,
1110
1164
  type: action.declaredType,
1111
1165
  ...(fieldType ? { fieldType } : {}),
1112
- });
1113
- applied.push(`alter-property:${action.objectType}.${action.propertyName}`);
1166
+ }));
1114
1167
  }
1115
1168
  }
1116
1169
  return {
@@ -1119,6 +1172,198 @@ async function applyPortalSchemaPlan({ argv, plan, source, }) {
1119
1172
  skipped,
1120
1173
  };
1121
1174
  }
1175
+ /** A portal-schema apply action failed against HubSpot. */
1176
+ class PortalSchemaApplyError extends Error {
1177
+ action;
1178
+ objectType;
1179
+ /** Actions that DID apply before the failure (partial-apply visibility). */
1180
+ applied;
1181
+ cause;
1182
+ constructor(input) {
1183
+ super(input.cause instanceof Error ? input.cause.message : String(input.cause));
1184
+ this.name = 'PortalSchemaApplyError';
1185
+ this.action = input.action;
1186
+ this.objectType = input.objectType;
1187
+ this.applied = input.applied;
1188
+ this.cause = input.cause;
1189
+ }
1190
+ }
1191
+ /**
1192
+ * The raw HubSpot response body off a failed schema call. local-dev-lib wraps
1193
+ * axios failures in `HubSpotHttpError` and lifts `response.data` to `.data`;
1194
+ * a raw axios error keeps it at `.cause.response.data`.
1195
+ */
1196
+ function hubSpotErrorBody(error) {
1197
+ if (!isRecord(error))
1198
+ return undefined;
1199
+ if (error.data !== undefined)
1200
+ return error.data;
1201
+ const cause = error.cause;
1202
+ if (isRecord(cause) && isRecord(cause.response))
1203
+ return cause.response.data;
1204
+ return undefined;
1205
+ }
1206
+ /**
1207
+ * Extract HubSpot's MISSING_SCOPES payload. Captured live (cold-stranger run #3
1208
+ * repro — POST crm/v3/properties/contacts with a PAK lacking the write scope):
1209
+ *
1210
+ * { "status": "error",
1211
+ * "message": "This app hasn't been granted all required scopes to make this call. …",
1212
+ * "category": "MISSING_SCOPES",
1213
+ * "correlationId": "…",
1214
+ * "errors": [{ "message": "One or more of the following scopes are required.",
1215
+ * "context": { "requiredGranularScopes": ["crm.schemas.contacts.write", …] } }] }
1216
+ *
1217
+ * The list is ANY-OF (any one scope grants the call). Older error shapes use
1218
+ * `requiredScopes` (the only key local-dev-lib knows — which is why its own
1219
+ * message renders an EMPTY scope list for granular-scope errors).
1220
+ * Returns undefined when the error is not a scope error.
1221
+ */
1222
+ function hubSpotMissingScopes(error) {
1223
+ const body = hubSpotErrorBody(error);
1224
+ if (!isRecord(body))
1225
+ return undefined;
1226
+ const scopes = [];
1227
+ if (Array.isArray(body.errors)) {
1228
+ for (const entry of body.errors) {
1229
+ if (!isRecord(entry) || !isRecord(entry.context))
1230
+ continue;
1231
+ for (const key of ['requiredGranularScopes', 'requiredScopes']) {
1232
+ const list = entry.context[key];
1233
+ if (!Array.isArray(list))
1234
+ continue;
1235
+ for (const scope of list) {
1236
+ if (typeof scope === 'string')
1237
+ scopes.push(scope);
1238
+ }
1239
+ }
1240
+ }
1241
+ }
1242
+ if (scopes.length === 0 && body.category !== 'MISSING_SCOPES')
1243
+ return undefined;
1244
+ return {
1245
+ scopes: [...new Set(scopes)],
1246
+ ...(typeof body.correlationId === 'string' ? { correlationId: body.correlationId } : {}),
1247
+ };
1248
+ }
1249
+ // Object types with a dedicated property-settings write scope
1250
+ // (crm.schemas.<type>.write — "create, delete, or make changes to property
1251
+ // settings"; crm.objects.<type>.write is RECORD writes and does not grant
1252
+ // property creation). Everything else falls under crm.schemas.custom.write.
1253
+ const HUBSPOT_SCHEMA_SCOPE_OBJECTS = new Set([
1254
+ 'appointments',
1255
+ 'carts',
1256
+ 'commercepayments',
1257
+ 'companies',
1258
+ 'contacts',
1259
+ 'courses',
1260
+ 'deals',
1261
+ 'invoices',
1262
+ 'line_items',
1263
+ 'listings',
1264
+ 'orders',
1265
+ 'quotes',
1266
+ 'services',
1267
+ 'subscriptions',
1268
+ 'tickets',
1269
+ 'users',
1270
+ ]);
1271
+ /** The property-definition write scope `--apply-schema` needs for an object type. */
1272
+ function propertyWriteScopeFor(objectType) {
1273
+ return HUBSPOT_SCHEMA_SCOPE_OBJECTS.has(objectType)
1274
+ ? `crm.schemas.${objectType}.write`
1275
+ : 'crm.schemas.custom.write';
1276
+ }
1277
+ /**
1278
+ * Narrow HubSpot's any-of scope list to what actually unblocks THIS apply:
1279
+ * the schemas-write scope for the target object when present, else every scope
1280
+ * that names the object, else the full response list (else the doc-grounded
1281
+ * default for the object type when the response carried none).
1282
+ */
1283
+ function relevantMissingScopes(all, objectType) {
1284
+ const expected = propertyWriteScopeFor(objectType);
1285
+ if (all.includes(expected))
1286
+ return [expected];
1287
+ const mentioning = all.filter((scope) => scope.includes(`.${objectType}.`));
1288
+ if (mentioning.length > 0)
1289
+ return mentioning;
1290
+ return all.length > 0 ? all : [expected];
1291
+ }
1292
+ const HUBSPOT_PAK_URL = 'https://app.hubspot.com/l/personal-access-key';
1293
+ /**
1294
+ * Curated rendering for a failed `--apply-schema` leg (cold-stranger run #3,
1295
+ * finding A): the phase + PLAN output still prints, partial progress is shown,
1296
+ * and a HubSpot MISSING_SCOPES response becomes HSX_E_DEPLOY_SCHEMA_SCOPES
1297
+ * naming the exact scopes to grant — never a raw truncated HubSpot error.
1298
+ */
1299
+ function portalSchemaApplyFailure(input) {
1300
+ const applyError = input.error instanceof PortalSchemaApplyError ? input.error : undefined;
1301
+ const cause = applyError?.cause ?? input.error;
1302
+ const scopeError = hubSpotMissingScopes(cause);
1303
+ const causeMessage = cause instanceof Error ? cause.message : String(cause);
1304
+ const hubSpotDetail = `${causeMessage.split('\n')[0]}${scopeError?.correlationId ? ` (correlationId ${scopeError.correlationId})` : ''}`;
1305
+ let code;
1306
+ let message;
1307
+ let hint;
1308
+ let missingScopes = [];
1309
+ if (scopeError && applyError) {
1310
+ code = 'HSX_E_DEPLOY_SCHEMA_SCOPES';
1311
+ missingScopes = relevantMissingScopes(scopeError.scopes, applyError.objectType);
1312
+ const scopesText = missingScopes.length === 1
1313
+ ? `the ${missingScopes[0]} scope`
1314
+ : `one of these scopes: ${missingScopes.join(', ')}`;
1315
+ message = `HubSpot rejected ${applyError.action}: your personal access key cannot write ${applyError.objectType} property definitions. Grant ${scopesText}.`;
1316
+ hint = `Edit your personal access key at ${HUBSPOT_PAK_URL} and add ${missingScopes.length === 1 ? missingScopes[0] : 'one of the scopes above'}, then re-run. To preview without applying: hs-x deploy --plan --portal-schema-live.`;
1317
+ }
1318
+ else {
1319
+ code = 'HSX_E_DEPLOY_SCHEMA_APPLY';
1320
+ message = applyError
1321
+ ? `Portal schema apply failed at ${applyError.action}: ${causeMessage.split('\n')[0]}`
1322
+ : causeMessage;
1323
+ hint = 'Preview the schema plan without applying: hs-x deploy --plan --portal-schema-live.';
1324
+ }
1325
+ const exitCode = code === 'HSX_E_DEPLOY_SCHEMA_SCOPES' ? 10 : 1;
1326
+ if (input.json) {
1327
+ write(`${JSON.stringify({
1328
+ schema_version: 1,
1329
+ command: 'deploy',
1330
+ ok: false,
1331
+ ...(input.portalSchemaPlan ? { portalSchemaPlan: input.portalSchemaPlan } : {}),
1332
+ ...(applyError && applyError.applied.length > 0
1333
+ ? { portalSchemaApplied: applyError.applied }
1334
+ : {}),
1335
+ error: {
1336
+ code,
1337
+ message,
1338
+ hint,
1339
+ ...(missingScopes.length > 0 ? { missing_scopes: missingScopes } : {}),
1340
+ detail: hubSpotDetail,
1341
+ },
1342
+ }, null, 2)}\n`);
1343
+ return { exitCode };
1344
+ }
1345
+ const reporter = createReporter({ command: 'deploy', argv: input.argv, startedAt: input.startedAt });
1346
+ reporter.header(basename(input.root));
1347
+ const capCount = input.workers.reduce((count, worker) => count + worker.capabilities.length, 0);
1348
+ reporter
1349
+ .step('Validating project')
1350
+ .ok(`${input.workers.length} workers, ${capCount} capabilities`);
1351
+ if (input.portalSchemaPlan) {
1352
+ reporter.info(renderPortalSchemaPlan(input.portalSchemaPlan).trimEnd());
1353
+ }
1354
+ if (applyError && applyError.applied.length > 0) {
1355
+ for (const done of applyError.applied)
1356
+ reporter.info(`APPLIED ${done}`);
1357
+ }
1358
+ reporter.step('Applying portal schema').fail(applyError?.action);
1359
+ reporter.error(code, message, {
1360
+ hint,
1361
+ cause: hubSpotDetail,
1362
+ docs_url: `https://hs-x.dev/errors/${code}`,
1363
+ });
1364
+ reporter.done(undefined, exitCode);
1365
+ return { exitCode };
1366
+ }
1122
1367
  function renderPortalSchemaPlan(plan) {
1123
1368
  if (plan.items.length === 0) {
1124
1369
  return 'Portal schema: no changes or warnings.\n';
@@ -1367,6 +1612,37 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, source
1367
1612
  tenantDataPlane?.flagsKvNamespaceName,
1368
1613
  tenantDataPlane?.tenantD1DatabaseName,
1369
1614
  ].filter((name) => typeof name === 'string' && name.toLowerCase().startsWith(accountResourcePrefix));
1615
+ // wrangler does NOT auto-apply D1 migrations on deploy (found live:
1616
+ // tenant DBs had zero tables across every real deploy since ADR-014) —
1617
+ // apply them explicitly before the worker code that expects them lands.
1618
+ const migrationsDirExists = await stat(join(root, 'node_modules', '@hs-x', 'runtime', 'migrations'))
1619
+ .then((entry) => entry.isDirectory())
1620
+ .catch(() => false);
1621
+ if (tenantDataPlane?.tenantD1DatabaseName && !dryRun && !migrationsDirExists) {
1622
+ process.stderr.write('warning: @hs-x/runtime did not ship its tenant migrations (pre-0.2.4 package) — tenant D1 tables were NOT applied; upgrade @hs-x/runtime.\n');
1623
+ }
1624
+ if (tenantDataPlane?.tenantD1DatabaseName && !dryRun && migrationsDirExists) {
1625
+ try {
1626
+ await runCloudflareCommand([
1627
+ 'bun',
1628
+ 'x',
1629
+ 'wrangler',
1630
+ 'd1',
1631
+ 'migrations',
1632
+ 'apply',
1633
+ tenantDataPlane.tenantD1DatabaseName,
1634
+ '--remote',
1635
+ '--config',
1636
+ relative(root, configPath),
1637
+ ], { cwd: root, env: cloudflareDeployEnv(argv) });
1638
+ }
1639
+ catch (error) {
1640
+ const message = error instanceof Error ? error.message : String(error);
1641
+ if (!/No migrations to apply/i.test(message)) {
1642
+ throw new Error(`Tenant D1 migrations failed: ${message.slice(-300)}`);
1643
+ }
1644
+ }
1645
+ }
1370
1646
  const command = [
1371
1647
  'bun',
1372
1648
  'x',
@@ -1470,13 +1746,29 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, source
1470
1746
  installKvNamespaceName: installRuntimeBinding.installKvNamespaceName,
1471
1747
  tokenKeySecretName: installRuntimeBinding.tokenKeySecretName,
1472
1748
  runtimeBaseUrl: resolvedRuntimeUrl,
1473
- ...(tenantDataPlane ? { syncGrantSecretName: tenantDataPlane.syncGrantSecretName } : {}),
1749
+ ...(tenantDataPlane
1750
+ ? {
1751
+ syncGrantSecretName: tenantDataPlane.syncGrantSecretName,
1752
+ // Read-through legs need these registered: exemplars read the
1753
+ // tenant D1; flag tooling reads the flags KV.
1754
+ tenantD1DatabaseId: tenantDataPlane.tenantD1DatabaseId,
1755
+ tenantD1DatabaseName: tenantDataPlane.tenantD1DatabaseName,
1756
+ flagsKvNamespaceId: tenantDataPlane.flagsKvNamespaceId,
1757
+ ...(tenantDataPlane.flagsKvNamespaceName
1758
+ ? { flagsKvNamespaceName: tenantDataPlane.flagsKvNamespaceName }
1759
+ : {}),
1760
+ }
1761
+ : {}),
1474
1762
  });
1475
1763
  }
1476
1764
  catch (error) {
1477
1765
  process.stderr.write(`warning: could not register the tenant Worker URL with the control plane: ${error instanceof Error ? error.message : String(error)}\n`);
1478
1766
  }
1479
1767
  }
1768
+ // Without the client secret on the Worker, the runtime accepts UNSIGNED
1769
+ // workflow-action invokes (HubSpot must still be able to call them pre-OAuth)
1770
+ // — surface that as a one-line warning instead of leaving it silent.
1771
+ const hasWorkflowActions = worker.capabilities.some((capability) => capability.kind === 'tool');
1480
1772
  return {
1481
1773
  workerName,
1482
1774
  workerSourcePath: relative(root, workerSourcePath),
@@ -1487,6 +1779,9 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, source
1487
1779
  dryRun,
1488
1780
  stdout: output.stdout,
1489
1781
  stderr: output.stderr,
1782
+ ...(hasWorkflowActions && !resolvedHubSpotClientSecret && !dryRun
1783
+ ? { unauthenticatedInvoke: true }
1784
+ : {}),
1490
1785
  };
1491
1786
  }
1492
1787
  async function discoverWorkerSourcePaths(root) {
@@ -1655,6 +1950,10 @@ async function putDeployInstallRuntimeBinding(input) {
1655
1950
  tokenKeySecretName: input.tokenKeySecretName,
1656
1951
  ...(input.runtimeBaseUrl ? { runtimeBaseUrl: input.runtimeBaseUrl } : {}),
1657
1952
  ...(input.syncGrantSecretName ? { syncGrantSecretName: input.syncGrantSecretName } : {}),
1953
+ ...(input.tenantD1DatabaseId ? { tenantD1DatabaseId: input.tenantD1DatabaseId } : {}),
1954
+ ...(input.tenantD1DatabaseName ? { tenantD1DatabaseName: input.tenantD1DatabaseName } : {}),
1955
+ ...(input.flagsKvNamespaceId ? { flagsKvNamespaceId: input.flagsKvNamespaceId } : {}),
1956
+ ...(input.flagsKvNamespaceName ? { flagsKvNamespaceName: input.flagsKvNamespaceName } : {}),
1658
1957
  },
1659
1958
  });
1660
1959
  const body = await response.json();
@@ -1836,6 +2135,14 @@ export function renderWranglerConfig(input) {
1836
2135
  `name = "${tomlString(input.workerName)}"`,
1837
2136
  `main = "${tomlString(input.entrypointPath)}"`,
1838
2137
  `compatibility_date = "${tomlString(input.compatibilityDate)}"`,
2138
+ // nodejs_compat: guide + scaffold code reads `process.env.X` at module
2139
+ // scope (e.g. defineSource hmac secrets); without it the Worker fails
2140
+ // validation with "process is not defined" (Cloudflare error 10021,
2141
+ // cold-stranger run #2 blocker A). With nodejs_compat and a
2142
+ // compatibility_date >= 2025-04-01, Workers auto-populate process.env
2143
+ // from bindings/secrets; the explicit populate flag keeps that true even
2144
+ // when --compatibility-date is set to an older date.
2145
+ 'compatibility_flags = [ "nodejs_compat", "nodejs_compat_populate_process_env" ]',
1839
2146
  ];
1840
2147
  if (input.installKvNamespaceId) {
1841
2148
  lines.push('', '[[kv_namespaces]]', 'binding = "INSTALL_KV"', `id = "${tomlString(input.installKvNamespaceId)}"`);
@@ -1891,7 +2198,7 @@ async function readDeployHubSpotOAuthSecret(input) {
1891
2198
  function extractWorkerUrl(output) {
1892
2199
  return /https:\/\/[a-zA-Z0-9.-]+\.workers\.dev\b/.exec(output)?.[0];
1893
2200
  }
1894
- async function ensureHubSpotRuntimeProjectArtifacts({ argv, root, app, workers, cloudflareDeployResult, needsRuntime, controlPlanePlan, }) {
2201
+ export async function ensureHubSpotRuntimeProjectArtifacts({ argv, root, app, workers, cloudflareDeployResult, needsRuntime, controlPlanePlan, }) {
1895
2202
  if (!needsRuntime) {
1896
2203
  return;
1897
2204
  }
@@ -1912,6 +2219,14 @@ async function ensureHubSpotRuntimeProjectArtifacts({ argv, root, app, workers,
1912
2219
  });
1913
2220
  for (const [file, contents] of Object.entries(generated.files)) {
1914
2221
  const path = join(root, file);
2222
+ // src/app/cards/package.json is user-owned once it exists: HubSpot's own
2223
+ // build errors instruct users to add card dependencies (e.g. hs-uix) to
2224
+ // it, and regenerating the fixed-dep version here silently dropped those
2225
+ // deps so the card never built (cold-stranger run #2 blocker B). Keep the
2226
+ // user's file verbatim; only generate one when absent.
2227
+ if (file === 'src/app/cards/package.json' && (await stat(path).catch(() => undefined))) {
2228
+ continue;
2229
+ }
1915
2230
  await mkdir(dirname(path), { recursive: true });
1916
2231
  await writeFile(path, contents);
1917
2232
  }
@@ -2288,6 +2603,14 @@ async function executeHubSpotOnlyUpload({ argv, root, local, uploadOnly, }) {
2288
2603
  : 0;
2289
2604
  if (appId)
2290
2605
  await persistHubSpotAppBinding(root, { appId, projectName, developerAccountId });
2606
+ // Build SUCCESS does NOT mean the app shipped: HubSpot's auto-deploy can
2607
+ // still fail (found live: "Apps are not allowed to have 0 redirect URLs"
2608
+ // failed deploy #4 while the CLI reported green). Verify the deploy too.
2609
+ // Auto-deploy fires on uploads too — verify it regardless of uploadOnly
2610
+ // (the f1-data 0-redirect failure rode exactly this path).
2611
+ const autoDeployStatus = autoDeployEnabled && readStatus(buildStatus) === 'SUCCESS'
2612
+ ? await waitForHubSpotAutoDeploy({ client, projectName, buildStatus })
2613
+ : undefined;
2291
2614
  return {
2292
2615
  projectName,
2293
2616
  archivePath,
@@ -2297,7 +2620,7 @@ async function executeHubSpotOnlyUpload({ argv, root, local, uploadOnly, }) {
2297
2620
  buildId: upload.buildId,
2298
2621
  deployId: undefined,
2299
2622
  buildStatus,
2300
- deployStatus: undefined,
2623
+ deployStatus: autoDeployStatus,
2301
2624
  deploySkipped: Boolean(uploadOnly || autoDeployEnabled),
2302
2625
  local,
2303
2626
  };
@@ -2338,6 +2661,40 @@ async function optionalHubSpotAppId(client, projectName, appUid) {
2338
2661
  return 0;
2339
2662
  }
2340
2663
  }
2664
+ /**
2665
+ * Poll the auto-deploy triggered by a successful build until it reaches a
2666
+ * terminal state. The build status carries the deploy task locator
2667
+ * (deployStatusTaskLocator.id); absent/transient lookups resolve undefined —
2668
+ * the caller treats that as "could not verify", never as success.
2669
+ */
2670
+ async function waitForHubSpotAutoDeploy({ client, projectName, buildStatus, timeoutMs = 60_000, intervalMs = 2_000, }) {
2671
+ const locator = buildStatus && typeof buildStatus === 'object'
2672
+ ? buildStatus.deployStatusTaskLocator
2673
+ : undefined;
2674
+ const deployId = locator && typeof locator.id === 'number'
2675
+ ? locator.id
2676
+ : locator && typeof locator.id === 'string'
2677
+ ? Number.parseInt(locator.id, 10)
2678
+ : undefined;
2679
+ if (deployId === undefined || Number.isNaN(deployId))
2680
+ return undefined;
2681
+ const deadline = Date.now() + timeoutMs;
2682
+ let last;
2683
+ while (Date.now() < deadline) {
2684
+ try {
2685
+ last = await client.projects.getDeployStatus({ projectName, deployId });
2686
+ }
2687
+ catch {
2688
+ last = undefined;
2689
+ }
2690
+ const status = readStatus(last);
2691
+ if (status && status !== 'PENDING' && status !== 'BUILDING' && status !== 'DEPLOYING') {
2692
+ return last;
2693
+ }
2694
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
2695
+ }
2696
+ return last;
2697
+ }
2341
2698
  async function waitForHubSpotBuildStatus({ client, projectName, buildId, timeoutMs, intervalMs, }) {
2342
2699
  const startedAt = Date.now();
2343
2700
  let latest;
@@ -2716,6 +3073,7 @@ async function requestHostedControlPlaneDeployRecordAndMaybePromote({ request, p
2716
3073
  signedManifest: request.manifest,
2717
3074
  signature: `sig_${plan.deployId}`,
2718
3075
  bundleKey: deployBundleKey({ projectId: request.projectId, deployId: plan.deployId }),
3076
+ ...(await collectDeployProvenance(userId)),
2719
3077
  },
2720
3078
  });
2721
3079
  const body = await response.json();
@@ -2854,14 +3212,59 @@ function stringProperty(source, propertyName) {
2854
3212
  return match ? { [propertyName]: match } : {};
2855
3213
  }
2856
3214
  function schemaProperty(source) {
2857
- const body = /schema\s*:\s*\{([\s\S]*?)\}\s*(?:,|$)/.exec(source)?.[1];
3215
+ // Balanced-brace extraction. The previous non-greedy regex stopped at the
3216
+ // first `}` inside an object-form field (`score: { type: "number", … }`),
3217
+ // truncating the schema and mis-reading nested keys (`type`) as property
3218
+ // names — which made portal-schema plans wrong for any schema that mixes
3219
+ // shorthand strings with object declarations.
3220
+ const body = objectLiteralBody(source, 'schema');
2858
3221
  if (!body) {
2859
3222
  return {};
2860
3223
  }
2861
3224
  const schema = {};
2862
- for (const match of body.matchAll(/([A-Za-z_$][\w$]*)\s*:\s*["'`]([^"'`]+)["'`]/g)) {
2863
- if (match[1] && match[2]) {
2864
- schema[match[1]] = match[2];
3225
+ let index = 0;
3226
+ while (index < body.length) {
3227
+ const entry = /^[\s,]*([A-Za-z_$][\w$]*)\s*:\s*/.exec(body.slice(index));
3228
+ if (!entry || !entry[1])
3229
+ break;
3230
+ const name = entry[1];
3231
+ const valueStart = index + entry[0].length;
3232
+ const char = body[valueStart];
3233
+ if (char === '"' || char === "'" || char === '`') {
3234
+ const close = body.indexOf(char, valueStart + 1);
3235
+ if (close === -1)
3236
+ break;
3237
+ schema[name] = body.slice(valueStart + 1, close);
3238
+ index = close + 1;
3239
+ }
3240
+ else if (char === '{') {
3241
+ let depth = 0;
3242
+ let end = -1;
3243
+ for (let i = valueStart; i < body.length; i += 1) {
3244
+ if (body[i] === '{')
3245
+ depth += 1;
3246
+ else if (body[i] === '}') {
3247
+ depth -= 1;
3248
+ if (depth === 0) {
3249
+ end = i;
3250
+ break;
3251
+ }
3252
+ }
3253
+ }
3254
+ if (end === -1)
3255
+ break;
3256
+ const inner = body.slice(valueStart + 1, end);
3257
+ const type = /type\s*:\s*["'`]([^"'`]+)["'`]/.exec(inner)?.[1];
3258
+ const property = /property\s*:\s*["'`]([^"'`]+)["'`]/.exec(inner)?.[1];
3259
+ schema[name] = { ...(type ? { type } : {}), ...(property ? { property } : {}) };
3260
+ index = end + 1;
3261
+ }
3262
+ else {
3263
+ // Unsupported value shape (identifier, number); skip to the next entry.
3264
+ const nextComma = body.indexOf(',', valueStart);
3265
+ if (nextComma === -1)
3266
+ break;
3267
+ index = nextComma + 1;
2865
3268
  }
2866
3269
  }
2867
3270
  return Object.keys(schema).length > 0 ? { schema } : {};
@@ -3169,4 +3572,40 @@ const deployPromoteSubCmd = Command.make('promote', {
3169
3572
  yield* runQuarantined(opts.json, () => deployCommand({ argv, root, json: opts.json }));
3170
3573
  })));
3171
3574
  export const deployCmd = deployBaseCmd.pipe(Command.withSubcommands([deployPromoteSubCmd]));
3575
+ /**
3576
+ * Provenance for the active-deploy card, collected where it's cheapest: the
3577
+ * CLI knows its own version, the git state of the tree it deployed, and the
3578
+ * session identity. Everything best-effort — a non-git tree stamps nothing.
3579
+ */
3580
+ async function collectDeployProvenance(userId) {
3581
+ const git = async (args) => {
3582
+ try {
3583
+ const proc = Bun.spawn({ cmd: ['git', ...args], stdout: 'pipe', stderr: 'ignore' });
3584
+ const [out, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
3585
+ const trimmed = out.trim();
3586
+ return code === 0 && trimmed ? trimmed : undefined;
3587
+ }
3588
+ catch {
3589
+ return undefined;
3590
+ }
3591
+ };
3592
+ const [gitCommit, gitBranch] = await Promise.all([
3593
+ git(['rev-parse', '--short', 'HEAD']),
3594
+ git(['rev-parse', '--abbrev-ref', 'HEAD']),
3595
+ ]);
3596
+ let deployedBy;
3597
+ try {
3598
+ const { loadStore, activeSession } = await import('../account-store.js');
3599
+ deployedBy = activeSession(await loadStore())?.email ?? userId;
3600
+ }
3601
+ catch {
3602
+ deployedBy = userId;
3603
+ }
3604
+ return {
3605
+ cliVersion: `hs-x@${CLI_VERSION}`,
3606
+ ...(gitCommit ? { gitCommit } : {}),
3607
+ ...(gitBranch ? { gitBranch } : {}),
3608
+ ...(deployedBy ? { deployedBy } : {}),
3609
+ };
3610
+ }
3172
3611
  //# sourceMappingURL=deploy.js.map