@hs-x/codegen 0.2.6 → 0.3.0

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/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { mkdir, writeFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- export const CODEGEN_VERSION = '0.2.6';
3
+ import { normalizeSyncSchedule } from '@hs-x/sdk';
4
+ export const CODEGEN_VERSION = '0.3.0';
4
5
  export async function generateProjectArtifacts(options) {
5
6
  const manifest = {
6
7
  generatedAt: new Date().toISOString(),
@@ -17,7 +18,10 @@ export async function generateProjectArtifacts(options) {
17
18
  await writeFile(join(outDir, 'refs.d.ts'), renderRefsTypes(options.workers, options));
18
19
  await writeFile(join(outDir, 'refs.js'), renderRefsModule(options.workers, options));
19
20
  if (options.resourceScope) {
20
- await writeFile(join(outDir, 'alchemy.run.ts'), renderAlchemyProgram({ ...options.resourceScope, workers: options.workers }));
21
+ await writeFile(join(outDir, 'alchemy.run.ts'), renderAlchemyProgram({
22
+ ...options.resourceScope,
23
+ workers: options.workers,
24
+ }));
21
25
  }
22
26
  return manifest;
23
27
  }
@@ -51,6 +55,39 @@ export function workerCloudflareResourcePlan(scope, worker) {
51
55
  export function cloudflareResourceName(scope, resource) {
52
56
  return `hsx-${toResourceSlug(scope.hsXAccountId)}-${toResourceSlug(resource)}`;
53
57
  }
58
+ /**
59
+ * Translate a worker's declared sync schedules into a Cloudflare cron-trigger
60
+ * plan (ADR-023 S04). Only pull-capable syncs are scheduled — a push source is
61
+ * webhook-driven, and `event` / `manual` schedules produce no trigger. Two
62
+ * syncs that share a schedule collapse into a single cron entry that dispatches
63
+ * both: Cloudflare caps cron triggers per worker (5 on the base tier), so
64
+ * emitting one trigger per distinct cadence — rather than one per sync — both
65
+ * respects the cap and avoids duplicate scheduler wakeups. Schedules are
66
+ * normalized (and thus validated) here, so an unhonorable cadence that somehow
67
+ * reached deploy still throws rather than silently dropping the trigger.
68
+ */
69
+ export function planWorkerCronTriggers(worker) {
70
+ const byCron = new Map();
71
+ for (const capability of worker.capabilities) {
72
+ if (capability.kind !== 'sync')
73
+ continue;
74
+ // Push sources ingest via the authenticated webhook route, never a cron.
75
+ if (capability.source?.kind === 'push')
76
+ continue;
77
+ if (capability.schedule === undefined)
78
+ continue;
79
+ const normalized = normalizeSyncSchedule(capability.schedule);
80
+ if (normalized.kind !== 'cron')
81
+ continue;
82
+ const ids = byCron.get(normalized.cron) ?? new Set();
83
+ ids.add(capability.id);
84
+ byCron.set(normalized.cron, ids);
85
+ }
86
+ const dispatch = [...byCron.entries()]
87
+ .map(([cron, ids]) => ({ cron, syncIds: [...ids].sort() }))
88
+ .sort((a, b) => (a.cron < b.cron ? -1 : a.cron > b.cron ? 1 : 0));
89
+ return { crons: dispatch.map((entry) => entry.cron), dispatch };
90
+ }
54
91
  export function renderCloudflareWorkerEntrypoint(options) {
55
92
  return `// Generated by hs-x. Do not edit by hand.
56
93
  import {
@@ -60,6 +97,7 @@ import {
60
97
  createHubSpotTokenRefresh,
61
98
  createKvRuntimeSealedTokenBlobStore,
62
99
  createKvRuntimeTokenStateOwnerStore,
100
+ loggerRateLimitMetrics,
63
101
  createRuntimeTokenService,
64
102
  createSingleBucketRuntimeRateLimiter,
65
103
  RUNTIME_VERSION,
@@ -76,6 +114,9 @@ function getRuntime(env: Record<string, unknown>) {
76
114
  runtime ??= createRuntime({
77
115
  worker,
78
116
  env,
117
+ ${options.requireActiveCardInstall === false ? ' cards: { requireActiveInstall: false },\n' : ''}
118
+ ${renderAppEventBatching(options.appEventBatching)}
119
+ ${renderRedaction(options.redaction)}
79
120
  ${renderLinkedHeartbeat(options.heartbeat)}
80
121
  ${renderLinkedCheckpoint(options.heartbeat)}
81
122
  ...(hostedBilling ? { billing: hostedBilling.billing } : {}),
@@ -113,7 +154,9 @@ ${options.heartbeat?.mode === 'linked' ? ' ...hsxInstalledPortalTelem
113
154
  rateLimiter: createSingleBucketRuntimeRateLimiter({
114
155
  capacity: 100,
115
156
  refillPerSecond: 10,
157
+ ${renderHubSpotRateLimitBucket(options.rateLimits)}
116
158
  }),
159
+ ${renderManagedHubSpotRateLimitOptions(options.rateLimits)}
117
160
  },
118
161
  }
119
162
  : {}),
@@ -165,7 +208,41 @@ export default {
165
208
  ${options.heartbeat?.mode === 'anonymous' ? 'void emitAnonymousHeartbeat(env);' : ''}
166
209
  return getRuntime(env).fetch(request, ctx);
167
210
  },
168
- };
211
+ ${renderScheduledHandler(options.syncCron)}};
212
+ `;
213
+ }
214
+ /**
215
+ * ADR-023 S04 scheduled handler. A Cloudflare cron fire carries the exact
216
+ * trigger string in `event.cron`; we map it to the syncs planned for that
217
+ * cadence and dispatch each through the runtime's `/sync/<id>/run` route with
218
+ * an empty body. That empty-body path is the install fan-out
219
+ * (`executeSyncDispatch`): it enumerates live installs and runs each active
220
+ * one exactly once under the S01 single-winner lease, isolating per-portal
221
+ * failures. `Promise.allSettled` keeps one sync's failure from aborting the
222
+ * others on the same tick. No trigger, no handler — a webhook/manual-only
223
+ * worker stays a pure `fetch` worker.
224
+ */
225
+ function renderScheduledHandler(plan) {
226
+ if (!plan || plan.dispatch.length === 0)
227
+ return '';
228
+ const table = JSON.stringify(Object.fromEntries(plan.dispatch.map((entry) => [entry.cron, [...entry.syncIds]])));
229
+ return ` async scheduled(event: { readonly cron: string; readonly scheduledTime: number }, env: Record<string, unknown>, ctx: { waitUntil(promise: Promise<unknown>): void }) {
230
+ const dispatch: Record<string, readonly string[]> = ${table};
231
+ const syncIds = dispatch[event.cron] ?? [];
232
+ const runtime = getRuntime(env);
233
+ await Promise.allSettled(
234
+ syncIds.map((id) =>
235
+ runtime.fetch(
236
+ new Request("https://hsx.internal/sync/" + encodeURIComponent(id) + "/run", {
237
+ method: "POST",
238
+ headers: { "content-type": "application/json" },
239
+ body: "{}",
240
+ }),
241
+ ctx,
242
+ ),
243
+ ),
244
+ );
245
+ },
169
246
  `;
170
247
  }
171
248
  function renderLinkedCheckpoint(heartbeat) {
@@ -225,6 +302,47 @@ function renderHostedBilling(billing) {
225
302
  runtimeToken: readRuntimeBillingToken(env, ${JSON.stringify(runtimeTokenBinding)}),
226
303
  })`;
227
304
  }
305
+ function renderAppEventBatching(batching) {
306
+ if (batching === undefined)
307
+ return '';
308
+ if (batching === false)
309
+ return ' appEventBatching: false,\n';
310
+ return ` appEventBatching: ${JSON.stringify(batching)},\n`;
311
+ }
312
+ function renderRedaction(redaction) {
313
+ if (!redaction || !redaction.fields || redaction.fields.length === 0)
314
+ return '';
315
+ return ` redaction: ${JSON.stringify(redaction)},\n`;
316
+ }
317
+ function renderHubSpotRateLimitBucket(rateLimits) {
318
+ const bucket = rateLimits?.bucket;
319
+ if (!bucket)
320
+ return '';
321
+ const rateLimiterBucket = {
322
+ ...(bucket.capacity !== undefined ? { capacity: bucket.capacity } : {}),
323
+ ...(bucket.refillPerSecond !== undefined ? { refillPerSecond: bucket.refillPerSecond } : {}),
324
+ ...(bucket.searchCapacity !== undefined ? { searchCapacity: bucket.searchCapacity } : {}),
325
+ ...(bucket.searchRefillPerSecond !== undefined
326
+ ? { searchRefillPerSecond: bucket.searchRefillPerSecond }
327
+ : {}),
328
+ };
329
+ return Object.keys(rateLimiterBucket).length
330
+ ? ` ...${JSON.stringify(rateLimiterBucket)},\n`
331
+ : '';
332
+ }
333
+ function renderManagedHubSpotRateLimitOptions(rateLimits) {
334
+ const lines = [];
335
+ if (rateLimits?.bucket?.requestedTokensPerCall !== undefined) {
336
+ lines.push(` requestedTokensPerCall: ${rateLimits.bucket.requestedTokensPerCall},`);
337
+ }
338
+ if (rateLimits?.retry !== undefined) {
339
+ lines.push(` retry: ${JSON.stringify(rateLimits.retry)},`);
340
+ }
341
+ if (rateLimits?.metrics !== undefined && rateLimits.metrics !== false) {
342
+ lines.push(' metrics: loggerRateLimitMetrics(),');
343
+ }
344
+ return lines.length ? `${lines.join('\n')}\n` : '';
345
+ }
228
346
  // ADR-015/ADR-014 tenant data plane. Emitted ONLY for linked workers (the
229
347
  // optional platform); an unlinked direct-to-HubSpot Worker gets none of this.
230
348
  // Each option then activates at runtime only when its bindings are present, so
@@ -238,10 +356,14 @@ function renderTenantDataPlaneImports(heartbeat) {
238
356
  return '';
239
357
  return `import {
240
358
  createControlPlaneRuntimeInstalledPortalTelemetryEmitter,
359
+ createD1RuntimeAppStore,
241
360
  createD1RuntimeFlagConfigStore,
242
361
  createD1RuntimeInstalledPortalUserStore,
243
362
  createD1RuntimePlatformEventOccurrenceStore,
363
+ createD1RuntimeSyncPoisonStore,
364
+ createD1RuntimeSyncStateStore,
244
365
  createD1RuntimeWebhookDedupStore,
366
+ createD1RuntimeWebhookDeliveryLog,
245
367
  createKvRuntimeFlagSnapshotStore,
246
368
  } from "@hs-x/runtime";
247
369
  `;
@@ -250,9 +372,38 @@ function renderTenantDataPlaneOptions(heartbeat) {
250
372
  if (heartbeat?.mode !== 'linked')
251
373
  return '';
252
374
  return ` // Webhook trigger dedup: durable per-delivery-id claims in tenant D1
253
- // (migration 0004) — \`dedup: 'strict'\` holds across isolates.
375
+ // (migration 0004) — \`dedup: 'strict'\` holds across isolates. The
376
+ // delivery LOG (migration 0008) is the redacted history behind the
377
+ // dashboard delivery view — best-effort writes, never payload bodies.
378
+ ...(env.TENANT_DB
379
+ ? {
380
+ webhook: {
381
+ dedupStore: createD1RuntimeWebhookDedupStore(env.TENANT_DB as never),
382
+ deliveryLog: createD1RuntimeWebhookDeliveryLog(env.TENANT_DB as never),
383
+ },
384
+ }
385
+ : {}),
386
+ // ctx.store: durable, install-scoped app key/value storage in tenant D1
387
+ // (migration 0005). Falls back to per-isolate memory when TENANT_DB is absent.
254
388
  ...(env.TENANT_DB
255
- ? { webhook: { dedupStore: createD1RuntimeWebhookDedupStore(env.TENANT_DB as never) } }
389
+ ? { appStore: (installId) => createD1RuntimeAppStore(env.TENANT_DB as never, installId) }
390
+ : {}),
391
+ // ADR-023 durable sync execution state — safe cursor, single-winner fenced
392
+ // lease, chunk checkpoints (migration 0006) — plus the poison-row DLQ
393
+ // (migration 0007) for rows that fail validation or a permanent upsert, in
394
+ // tenant D1. When TENANT_DB is absent the runtime falls back to its
395
+ // per-isolate memory stores (dev). Install enumeration for scheduled runs
396
+ // rides installOAuth's owner store; delivery rides managedHubSpot.
397
+ ...(env.TENANT_DB
398
+ ? {
399
+ sync: {
400
+ store: createD1RuntimeSyncStateStore(env.TENANT_DB as never),
401
+ poisonStore: createD1RuntimeSyncPoisonStore(env.TENANT_DB as never),
402
+ // Durable cross-isolate replay protection for signed push
403
+ // deliveries — reuses the webhook dedup table (push:-namespaced).
404
+ dedupStore: createD1RuntimeWebhookDedupStore(env.TENANT_DB as never),
405
+ },
406
+ }
256
407
  : {}),
257
408
  // ADR-015 ctx.flags + Path-B evaluate endpoint (KV snapshot read).
258
409
  ...(isHsxFlagsKvConfigured(env)
@@ -477,7 +628,8 @@ export function generateServerlessWorkerProject(input) {
477
628
  'hsproject.json': renderHubSpotProjectConfig(input.legacyApp.name),
478
629
  'hsx.config.ts': renderServerlessAppConfig(input.legacyApp.name),
479
630
  'src/app/app-hsmeta.json': renderServerlessAppMetadata(input.legacyApp.name),
480
- 'src/workers/functions.ts': renderServerlessWorker(portable),
631
+ 'src/workers/functions.ts': renderServerlessWorker(portable, input.serverless.sources),
632
+ ...renderPreservedServerlessHelpers(input.serverless.sources),
481
633
  },
482
634
  };
483
635
  }
@@ -511,25 +663,47 @@ function renderServerlessAppMetadata(appName) {
511
663
  },
512
664
  }, null, 2)}\n`;
513
665
  }
514
- function renderServerlessWorker(portable) {
666
+ function renderServerlessWorker(portable, sources, cardCalls = new Map()) {
515
667
  const tools = portable
516
668
  .map(([name, fn]) => {
517
669
  const slug = toKebabCase(name);
670
+ const source = fn.file ? sources?.[fn.file] : undefined;
671
+ const isCardBackend = cardCalls.has(name);
672
+ const inferredInput = mergeInferredMigrationFields(cardCalls.get(name), source ? inferLegacyParameterFields(source) : undefined);
673
+ const inputFields = [...inferredInput.entries()]
674
+ .sort(([a], [b]) => a.localeCompare(b))
675
+ .map(([field, type]) => ` ${JSON.stringify(field)}: { type: ${JSON.stringify(type)} },`)
676
+ .join('\n');
677
+ const preservedImports = source ? collectLegacyLibImports(source) : [];
678
+ const preservedImportsNote = preservedImports.length > 0
679
+ ? `\n // Preserved local helper(s): ${preservedImports
680
+ .map((path) => `./legacy/${path.slice(2)}`)
681
+ .join(', ')}. Update imports when porting the body.`
682
+ : '';
518
683
  const secretsNote = fn.secrets && fn.secrets.length > 0
519
684
  ? `\n// Secrets used by the legacy function: ${fn.secrets.join(', ')}.\n// Store each with \`hs-x secrets\` so deploys inject them into this Worker.`
520
685
  : '';
686
+ const stubReturn = isCardBackend ? 'return { ok: true };' : 'return ok({ ok: true });';
687
+ const outputSchema = isCardBackend
688
+ ? ''
689
+ : ` output: {
690
+ ok: { type: "boolean" },
691
+ },
692
+ `;
521
693
  return `${secretsNote}
522
- worker.tool(${JSON.stringify(slug)}, {
694
+ worker.${isCardBackend ? 'cardBackend' : 'tool'}(${JSON.stringify(slug)}, {
523
695
  label: ${JSON.stringify(`Migrated: ${name}`)},
524
- input: {},
525
- output: {
526
- ok: { type: "boolean" },
696
+ input: {
697
+ ${inputFields}
527
698
  },
528
- async handler() {
699
+ ${outputSchema}
700
+ async handler({ input }) {
529
701
  // TODO(migration): port the body of ${JSON.stringify(fn.file ?? name)} here.
702
+ ${preservedImportsNote}
530
703
  // The legacy handler signature was \`async (context) => result\`; HS-X
531
- // passes { input, enrolledObject } and returns ok(...)/err(...).
532
- return ok({ ok: true });
704
+ // exposes its former context.parameters values as input.
705
+ void input;
706
+ ${stubReturn}
533
707
  },
534
708
  });`;
535
709
  })
@@ -537,7 +711,8 @@ worker.tool(${JSON.stringify(slug)}, {
537
711
  return `import { defineWorker, ok } from "@hs-x/sdk";
538
712
 
539
713
  // Generated by \`hs-x migrate\` from a legacy serverless.json source.
540
- // Each tool below replaces one appFunction; port the original function
714
+ // Each capability below replaces one appFunction; card-called functions use
715
+ // cardBackend while unclassified functions remain tools. Port the original
541
716
  // bodies, then test them with \`hs-x dev\`.
542
717
  const worker = defineWorker("functions");
543
718
  ${tools}
@@ -545,6 +720,320 @@ ${tools}
545
720
  export default worker;
546
721
  `;
547
722
  }
723
+ function matchingDelimiter(source, start, open, close) {
724
+ let depth = 0;
725
+ let quote;
726
+ let escaped = false;
727
+ for (let index = start; index < source.length; index += 1) {
728
+ const char = source[index] ?? '';
729
+ if (quote) {
730
+ if (escaped) {
731
+ escaped = false;
732
+ }
733
+ else if (char === '\\') {
734
+ escaped = true;
735
+ }
736
+ else if (char === quote) {
737
+ quote = undefined;
738
+ }
739
+ continue;
740
+ }
741
+ if (char === '"' || char === "'" || char === '`') {
742
+ quote = char;
743
+ continue;
744
+ }
745
+ if (char === open)
746
+ depth += 1;
747
+ if (char === close) {
748
+ depth -= 1;
749
+ if (depth === 0)
750
+ return index;
751
+ }
752
+ }
753
+ return -1;
754
+ }
755
+ function topLevelColon(value) {
756
+ let braces = 0;
757
+ let brackets = 0;
758
+ let parens = 0;
759
+ let quote;
760
+ let escaped = false;
761
+ for (let index = 0; index < value.length; index += 1) {
762
+ const char = value[index] ?? '';
763
+ if (quote) {
764
+ if (escaped)
765
+ escaped = false;
766
+ else if (char === '\\')
767
+ escaped = true;
768
+ else if (char === quote)
769
+ quote = undefined;
770
+ continue;
771
+ }
772
+ if (char === '"' || char === "'" || char === '`')
773
+ quote = char;
774
+ else if (char === '{')
775
+ braces += 1;
776
+ else if (char === '}')
777
+ braces -= 1;
778
+ else if (char === '[')
779
+ brackets += 1;
780
+ else if (char === ']')
781
+ brackets -= 1;
782
+ else if (char === '(')
783
+ parens += 1;
784
+ else if (char === ')')
785
+ parens -= 1;
786
+ else if (char === ':' && braces === 0 && brackets === 0 && parens === 0)
787
+ return index;
788
+ }
789
+ return -1;
790
+ }
791
+ function splitTopLevelObject(value) {
792
+ const parts = [];
793
+ let start = 0;
794
+ let braces = 0;
795
+ let brackets = 0;
796
+ let parens = 0;
797
+ let quote;
798
+ let escaped = false;
799
+ for (let index = 0; index < value.length; index += 1) {
800
+ const char = value[index] ?? '';
801
+ if (quote) {
802
+ if (escaped)
803
+ escaped = false;
804
+ else if (char === '\\')
805
+ escaped = true;
806
+ else if (char === quote)
807
+ quote = undefined;
808
+ continue;
809
+ }
810
+ if (char === '"' || char === "'" || char === '`')
811
+ quote = char;
812
+ else if (char === '{')
813
+ braces += 1;
814
+ else if (char === '}')
815
+ braces -= 1;
816
+ else if (char === '[')
817
+ brackets += 1;
818
+ else if (char === ']')
819
+ brackets -= 1;
820
+ else if (char === '(')
821
+ parens += 1;
822
+ else if (char === ')')
823
+ parens -= 1;
824
+ else if (char === ',' && braces === 0 && brackets === 0 && parens === 0) {
825
+ parts.push(value.slice(start, index));
826
+ start = index + 1;
827
+ }
828
+ }
829
+ parts.push(value.slice(start));
830
+ return parts;
831
+ }
832
+ function inferMigrationExpressionType(expression) {
833
+ const value = expression.trim();
834
+ if (/^(?:["'`]|String\s*\(|toStringValue\s*\()/.test(value))
835
+ return 'string';
836
+ if (/^(?:[-+]?(?:\d+\.?\d*|\.\d+)\b|Number\s*\()/.test(value))
837
+ return 'number';
838
+ if (/^(?:true|false)\b/.test(value))
839
+ return 'boolean';
840
+ return 'json';
841
+ }
842
+ function parseMigrationParameterObject(objectSource) {
843
+ const fields = new Map();
844
+ for (const rawProperty of splitTopLevelObject(objectSource)) {
845
+ const property = rawProperty.trim();
846
+ if (!property || property.startsWith('...'))
847
+ continue;
848
+ const colon = topLevelColon(property);
849
+ const rawName = (colon >= 0 ? property.slice(0, colon) : property).trim();
850
+ const nameMatch = rawName.match(/^(?:([A-Za-z_$][\w$]*)|["']([^"']+)["'])$/);
851
+ const name = nameMatch?.[1] ?? nameMatch?.[2];
852
+ if (!name)
853
+ continue;
854
+ const expression = colon >= 0 ? property.slice(colon + 1) : property;
855
+ fields.set(name, inferMigrationExpressionType(expression));
856
+ }
857
+ return fields;
858
+ }
859
+ function mergeInferredMigrationField(prior, next) {
860
+ if (!prior || prior === next)
861
+ return next;
862
+ if (prior === 'json')
863
+ return next;
864
+ if (next === 'json')
865
+ return prior;
866
+ return 'json';
867
+ }
868
+ function mergeInferredMigrationFields(...maps) {
869
+ const merged = new Map();
870
+ for (const fields of maps) {
871
+ for (const [name, type] of fields ?? []) {
872
+ merged.set(name, mergeInferredMigrationField(merged.get(name), type));
873
+ }
874
+ }
875
+ return merged;
876
+ }
877
+ function collectFrontendServerlessCalls(sources) {
878
+ let count = 0;
879
+ const fieldsByFunction = new Map();
880
+ for (const source of Object.values(sources ?? {})) {
881
+ let cursor = 0;
882
+ while (cursor < source.length) {
883
+ const callIndex = source.indexOf('runServerlessFunction', cursor);
884
+ if (callIndex < 0)
885
+ break;
886
+ const open = source.indexOf('(', callIndex + 'runServerlessFunction'.length);
887
+ if (open < 0 || !/^\s*$/.test(source.slice(callIndex + 21, open))) {
888
+ cursor = callIndex + 21;
889
+ continue;
890
+ }
891
+ const close = matchingDelimiter(source, open, '(', ')');
892
+ if (close < 0)
893
+ break;
894
+ const argument = source.slice(open + 1, close);
895
+ const name = argument.match(/\bname\s*:\s*(["'`])([^"'`]+)\1/)?.[2];
896
+ if (name) {
897
+ count += 1;
898
+ const current = fieldsByFunction.get(name) ?? new Map();
899
+ const parameters = /\bparameters\s*:/.exec(argument);
900
+ if (parameters) {
901
+ const afterColon = argument.slice((parameters.index ?? 0) + parameters[0].length);
902
+ const leading = afterColon.length - afterColon.trimStart().length;
903
+ if (afterColon[leading] === '{') {
904
+ const end = matchingDelimiter(afterColon, leading, '{', '}');
905
+ if (end >= 0) {
906
+ const inferred = parseMigrationParameterObject(afterColon.slice(leading + 1, end));
907
+ for (const [field, type] of inferred) {
908
+ current.set(field, mergeInferredMigrationField(current.get(field), type));
909
+ }
910
+ }
911
+ }
912
+ }
913
+ fieldsByFunction.set(name, current);
914
+ }
915
+ cursor = close + 1;
916
+ }
917
+ }
918
+ return { count, fieldsByFunction };
919
+ }
920
+ function inferLegacyParameterFields(source) {
921
+ const fields = new Map();
922
+ const pattern = /\bcontext\.parameters(?:\?\.)?\.?([A-Za-z_$][\w$]*)/g;
923
+ for (const match of source.matchAll(pattern)) {
924
+ const name = match[1];
925
+ if (!name)
926
+ continue;
927
+ const before = source.slice(Math.max(0, (match.index ?? 0) - 80), match.index ?? 0);
928
+ const type = /Number\s*\([^)]*$/.test(before)
929
+ ? 'number'
930
+ : /(?:String|toStringValue)\s*\([^)]*$/.test(before)
931
+ ? 'string'
932
+ : 'json';
933
+ fields.set(name, mergeInferredMigrationField(fields.get(name), type));
934
+ }
935
+ return fields;
936
+ }
937
+ export const MIGRATED_RUNTIME_ORIGIN_PLACEHOLDER = 'https://runtime-origin.invalid';
938
+ export const MIGRATED_SERVERLESS_SHIM_PATH = 'src/app/cards/_hsx-migration-run-serverless.ts';
939
+ export function renderMigratedRunServerlessShim(runtimeOrigin, backendIds = {}) {
940
+ return `// Generated by hs-x migrate. The deploy command stamps the resolved
941
+ // workers.dev origin into this compatibility bridge before HubSpot upload.
942
+ import { hubspot } from "@hubspot/ui-extensions";
943
+
944
+ const RUNTIME_ORIGIN = ${JSON.stringify(stripTrailingSlash(runtimeOrigin))};
945
+ const BACKEND_IDS: Readonly<Record<string, string>> = ${JSON.stringify(backendIds, null, 2)};
946
+
947
+ export interface LegacyServerlessFunctionResult<TResponse = unknown> {
948
+ readonly status: "SUCCESS" | "ERROR";
949
+ readonly message?: string;
950
+ readonly response?: TResponse;
951
+ }
952
+
953
+ export async function runServerlessFunction<TResponse = unknown>({
954
+ name,
955
+ parameters,
956
+ }: {
957
+ readonly name: string;
958
+ readonly parameters?: Record<string, unknown>;
959
+ }): Promise<LegacyServerlessFunctionResult<TResponse>> {
960
+ try {
961
+ const backendId = BACKEND_IDS[name] ?? name;
962
+ const response = await hubspot.fetch(
963
+ \`\${RUNTIME_ORIGIN}/_hsx/cards/\${encodeURIComponent(backendId)}\`,
964
+ {
965
+ method: "POST",
966
+ body: { input: parameters ?? {} },
967
+ },
968
+ );
969
+ const payload = (await response.json().catch(() => undefined)) as
970
+ | { ok?: boolean; result?: TResponse; message?: string; error?: string }
971
+ | undefined;
972
+ if (!response.ok || payload?.ok !== true) {
973
+ return {
974
+ status: "ERROR",
975
+ message: payload?.message ?? payload?.error ?? \`Backend request failed (\${response.status}).\`,
976
+ };
977
+ }
978
+ return { status: "SUCCESS", response: payload.result };
979
+ } catch (error) {
980
+ return {
981
+ status: "ERROR",
982
+ message: error instanceof Error ? error.message : String(error),
983
+ };
984
+ }
985
+ }
986
+ `;
987
+ }
988
+ export function stampMigratedRunServerlessShimOrigin(source, runtimeOrigin) {
989
+ const value = JSON.stringify(stripTrailingSlash(runtimeOrigin));
990
+ return source.replace(/const RUNTIME_ORIGIN = ["'][^"']*["'];/, `const RUNTIME_ORIGIN = ${value};`);
991
+ }
992
+ function rewriteMigratedCardProvider(source, entrypoint) {
993
+ const provider = /(hubspot\.extend(?:<[^>]+>)?\s*\(\s*\(\s*\{)([^}]*)(\}\s*\)\s*=>)/m;
994
+ const match = provider.exec(source);
995
+ if (!match || !/\brunServerlessFunction\b/.test(match[2] ?? ''))
996
+ return source;
997
+ const rewrittenProps = (match[2] ?? '')
998
+ .split(',')
999
+ .map((part) => part.trim())
1000
+ .filter((part) => part && part !== 'runServerlessFunction')
1001
+ .join(', ');
1002
+ let rewritten = source.replace(provider, `$1${rewrittenProps}$3`);
1003
+ const beforeProp = rewritten;
1004
+ rewritten = rewritten.replace(/runServerlessFunction=\{\s*runServerlessFunction(\s+as\s+[^}]+)?\}/g, (_whole, cast) => `runServerlessFunction={hsxRunServerlessFunction${cast ?? ''}}`);
1005
+ if (rewritten === beforeProp)
1006
+ return source;
1007
+ const depth = Math.max(0, entrypoint.replace(/\\/g, '/').split('/').length - 1);
1008
+ const modulePath = `${depth === 0 ? './' : '../'.repeat(depth)}_hsx-migration-run-serverless`;
1009
+ return `import { runServerlessFunction as hsxRunServerlessFunction } from ${JSON.stringify(modulePath)};
1010
+ ${rewritten}`;
1011
+ }
1012
+ function canRewriteMigratedCardProvider(sources, entrypoint) {
1013
+ const source = sources[entrypoint];
1014
+ return (typeof source === 'string' &&
1015
+ /hubspot\.extend(?:<[^>]+>)?\s*\(\s*\(\s*\{[^}]*\brunServerlessFunction\b[^}]*\}\s*\)\s*=>/m.test(source) &&
1016
+ /runServerlessFunction=\{\s*runServerlessFunction(?:\s+as\s+[^}]+)?\}/.test(source));
1017
+ }
1018
+ function collectLegacyLibImports(source) {
1019
+ const imports = new Set();
1020
+ const pattern = /(?:require\s*\(\s*|from\s+)(['"])(\.\/lib\/[^'"]+)\1/g;
1021
+ for (const match of source.matchAll(pattern)) {
1022
+ if (match[2])
1023
+ imports.add(match[2]);
1024
+ }
1025
+ return [...imports].sort();
1026
+ }
1027
+ function renderPreservedServerlessHelpers(sources) {
1028
+ const files = {};
1029
+ for (const [rawPath, contents] of Object.entries(sources ?? {})) {
1030
+ const path = rawPath.replace(/\\/g, '/').replace(/^\.\/+/, '');
1031
+ if (!path.startsWith('lib/') || path.split('/').includes('..'))
1032
+ continue;
1033
+ files[`src/workers/legacy/${path}`] = contents;
1034
+ }
1035
+ return files;
1036
+ }
548
1037
  /** Read scope a card needs for the object it renders on (validator's map). */
549
1038
  function cardReadScope(objectType) {
550
1039
  const t = objectType.toLowerCase();
@@ -576,6 +1065,36 @@ export function generateMigratedHsxProject(input) {
576
1065
  };
577
1066
  const scopes = new Set(['crm.objects.contacts.read']);
578
1067
  const permittedFetch = new Set();
1068
+ const migratedCardRuntimeDependencies = collectMigratedCardRuntimeDependencies(input.projectCards ?? []);
1069
+ const callsByCard = new Map();
1070
+ const providerRewritableCards = new Set();
1071
+ const allCardCalls = new Map();
1072
+ for (const projectCard of input.projectCards ?? []) {
1073
+ const scan = collectFrontendServerlessCalls(projectCard.sources);
1074
+ callsByCard.set(projectCard, scan);
1075
+ if (projectCard.entrypoint &&
1076
+ canRewriteMigratedCardProvider(projectCard.sources ?? {}, projectCard.entrypoint)) {
1077
+ providerRewritableCards.add(projectCard);
1078
+ }
1079
+ for (const [name, fields] of scan.fieldsByFunction) {
1080
+ allCardCalls.set(name, mergeInferredMigrationFields(allCardCalls.get(name), fields));
1081
+ }
1082
+ }
1083
+ const portableServerlessNames = new Set(Object.entries(input.serverless?.appFunctions ?? {})
1084
+ .filter(([, fn]) => typeof fn.file === 'string' && fn.file.length > 0 && fn.endpoint === undefined)
1085
+ .map(([name]) => name));
1086
+ const routedCardCalls = new Map([...allCardCalls].filter(([name]) => portableServerlessNames.has(name)));
1087
+ const shimBackendNames = new Set();
1088
+ for (const projectCard of providerRewritableCards) {
1089
+ for (const name of callsByCard.get(projectCard)?.fieldsByFunction.keys() ?? []) {
1090
+ if (portableServerlessNames.has(name))
1091
+ shimBackendNames.add(name);
1092
+ }
1093
+ }
1094
+ if (shimBackendNames.size > 0) {
1095
+ permittedFetch.add(MIGRATED_RUNTIME_ORIGIN_PLACEHOLDER);
1096
+ files[MIGRATED_SERVERLESS_SHIM_PATH] = renderMigratedRunServerlessShim(MIGRATED_RUNTIME_ORIGIN_PLACEHOLDER, Object.fromEntries([...shimBackendNames].map((name) => [name, toKebabCase(name)])));
1097
+ }
579
1098
  const card = input.legacyCard;
580
1099
  if (card && typeof card.fetchUrl === 'string' && card.fetchUrl.length > 0) {
581
1100
  const cardSlug = toKebabCase(card.title || card.id);
@@ -590,7 +1109,7 @@ export function generateMigratedHsxProject(input) {
590
1109
  scopes.add(scope);
591
1110
  }
592
1111
  if (!files['src/app/cards/package.json']) {
593
- files['src/app/cards/package.json'] = renderMigratedCardsPackage();
1112
+ files['src/app/cards/package.json'] = renderMigratedCardsPackage(migratedCardRuntimeDependencies);
594
1113
  }
595
1114
  files[`src/app/cards/${componentName}.tsx`] = renderMigratedCardComponent(componentName, card.fetchUrl);
596
1115
  files[`src/app/cards/${cardSlug}-hsmeta.json`] = renderMigratedCardMetadata({
@@ -607,14 +1126,24 @@ export function generateMigratedHsxProject(input) {
607
1126
  const name = projectCard.name || projectCard.uid || 'Migrated Card';
608
1127
  const slug = toKebabCase(projectCard.uid || name);
609
1128
  const sources = projectCard.sources ?? {};
610
- // Carry the React component tree over wholesale; identical paths from
611
- // sibling cards sharing a directory dedupe naturally.
612
- for (const [rel, contents] of Object.entries(sources)) {
613
- files[`src/app/cards/${rel}`] = contents;
614
- }
615
1129
  const entryFile = Object.keys(sources).includes(projectCard.entrypoint)
616
1130
  ? projectCard.entrypoint
617
1131
  : (Object.keys(sources).find((f) => /\.(t|j)sx?$/.test(f)) ?? projectCard.entrypoint);
1132
+ // Carry the React component tree over wholesale; identical paths from
1133
+ // sibling cards sharing a directory dedupe naturally. For a card that
1134
+ // called portable serverless functions, rewrite only the provider in the
1135
+ // entrypoint and leave every hook/call site untouched.
1136
+ const cardCalls = callsByCard.get(projectCard);
1137
+ const cardHasRoutedBackend = providerRewritableCards.has(projectCard) &&
1138
+ [...(cardCalls?.fieldsByFunction.keys() ?? [])].some((name) => portableServerlessNames.has(name));
1139
+ for (const [rel, contents] of Object.entries(sources)) {
1140
+ if (rel === 'package.json' || rel.endsWith('/package.json'))
1141
+ continue;
1142
+ files[`src/app/cards/${rel}`] =
1143
+ rel === entryFile && cardHasRoutedBackend
1144
+ ? rewriteMigratedCardProvider(contents, entryFile)
1145
+ : contents;
1146
+ }
618
1147
  const objectTypes = projectCard.objectTypes?.length ? projectCard.objectTypes : ['contacts'];
619
1148
  for (const objectType of objectTypes) {
620
1149
  const scope = cardReadScope(objectType);
@@ -622,7 +1151,7 @@ export function generateMigratedHsxProject(input) {
622
1151
  scopes.add(scope);
623
1152
  }
624
1153
  if (!files['src/app/cards/package.json']) {
625
- files['src/app/cards/package.json'] = renderMigratedCardsPackage();
1154
+ files['src/app/cards/package.json'] = renderMigratedCardsPackage(migratedCardRuntimeDependencies);
626
1155
  }
627
1156
  files[`src/app/cards/${slug}-hsmeta.json`] = `${JSON.stringify({
628
1157
  uid: slug,
@@ -641,7 +1170,8 @@ export function generateMigratedHsxProject(input) {
641
1170
  .filter(([, fn]) => typeof fn.file === 'string' && fn.file.length > 0 && fn.endpoint === undefined)
642
1171
  .sort(([a], [b]) => a.localeCompare(b));
643
1172
  if (portable.length > 0) {
644
- files['src/workers/functions.ts'] = renderServerlessWorker(portable);
1173
+ files['src/workers/functions.ts'] = renderServerlessWorker(portable, input.serverless.sources, routedCardCalls);
1174
+ Object.assign(files, renderPreservedServerlessHelpers(input.serverless.sources));
645
1175
  }
646
1176
  }
647
1177
  if (input.webhooks) {
@@ -667,11 +1197,22 @@ export function generateMigratedHsxProject(input) {
667
1197
  // Workers, or upload — the original run-003 blocker; the composed generator
668
1198
  // must emit it like the single-kind generators did.
669
1199
  files['package.json'] = renderMigratedRootPackage(input.legacyApp.name);
670
- files['hsx.config.ts'] = renderComposedAppConfig(appConfig.name ?? input.legacyApp.name, appConfig, [...scopes].sort(), [...permittedFetch].sort());
1200
+ files['hsx.config.ts'] = renderComposedAppConfig(appConfig.name ?? input.legacyApp.name, appConfig, [...scopes].sort(), [...permittedFetch].sort(), (input.projectCards ?? []).map((projectCard) => {
1201
+ const name = projectCard.name || projectCard.uid || 'Migrated Card';
1202
+ const calledBackend = [...(callsByCard.get(projectCard)?.fieldsByFunction.keys() ?? [])].find((candidate) => providerRewritableCards.has(projectCard) && portableServerlessNames.has(candidate));
1203
+ return {
1204
+ id: toKebabCase(projectCard.uid || name),
1205
+ name,
1206
+ location: projectCard.location ?? 'crm.record.tab',
1207
+ objectTypes: projectCard.objectTypes?.length ? projectCard.objectTypes : ['contacts'],
1208
+ entrypoint: `./src/app/cards/${projectCard.entrypoint ?? 'Card.tsx'}`,
1209
+ ...(calledBackend ? { backend: toKebabCase(calledBackend) } : {}),
1210
+ };
1211
+ }));
671
1212
  files['src/app/app-hsmeta.json'] = renderComposedAppMetadata(appConfig.name ?? input.legacyApp.name, appConfig, [...scopes].sort(), [...permittedFetch].sort());
672
1213
  return { files };
673
1214
  }
674
- function renderComposedAppConfig(appName, appConfig, scopes, permittedFetch) {
1215
+ function renderComposedAppConfig(appName, appConfig, scopes, permittedFetch, cards = []) {
675
1216
  const distribution = appConfig.distribution === 'marketplace' ? 'marketplace' : 'private';
676
1217
  const auth = appConfig.authType === 'static' || !appConfig.redirectUrls?.length ? 'static' : 'oauth';
677
1218
  const description = appConfig.description
@@ -690,14 +1231,25 @@ function renderComposedAppConfig(appName, appConfig, scopes, permittedFetch) {
690
1231
  img.length > 0 ? ` img: ${JSON.stringify(img)},` : undefined,
691
1232
  ].filter((entry) => entry !== undefined);
692
1233
  const permitted = permittedEntries.length > 0 ? `\n permittedUrls: {\n${permittedEntries.join('\n')}\n },` : '';
693
- return `import { defineApp } from "@hs-x/sdk";
1234
+ const cardDeclarations = cards.length > 0
1235
+ ? `\n cards: [\n${cards
1236
+ .map((cardDefinition) => ` card({
1237
+ id: ${JSON.stringify(cardDefinition.id)},
1238
+ name: ${JSON.stringify(cardDefinition.name)},
1239
+ location: ${JSON.stringify(cardDefinition.location)},
1240
+ objectTypes: ${JSON.stringify(cardDefinition.objectTypes)},
1241
+ entrypoint: ${JSON.stringify(cardDefinition.entrypoint)},${cardDefinition.backend ? `\n backend: ${JSON.stringify(cardDefinition.backend)},` : ''}
1242
+ }),`)
1243
+ .join('\n')}\n ],`
1244
+ : '';
1245
+ return `import { ${cards.length > 0 ? 'card, ' : ''}defineApp } from "@hs-x/sdk";
694
1246
 
695
1247
  export default defineApp({
696
1248
  name: ${JSON.stringify(appName.replace(/\s+Legacy$/i, ''))},${description}
697
1249
  distribution: ${JSON.stringify(distribution)},
698
1250
  auth: ${JSON.stringify(auth)},
699
1251
  platformVersion: "2026.03",
700
- scopes: ${JSON.stringify(scopes)},${optional}${permitted}
1252
+ scopes: ${JSON.stringify(scopes)},${optional}${permitted}${cardDeclarations}
701
1253
  });
702
1254
  `;
703
1255
  }
@@ -877,6 +1429,29 @@ export function generateHubSpotRuntimeProject(options) {
877
1429
  renderAppEventMetadata(event);
878
1430
  }
879
1431
  const cards = options.cards ?? [];
1432
+ const linkedCards = cards.filter((card) => card.backend);
1433
+ if (linkedCards.length > 0) {
1434
+ // C03 (ADR-018): a card that declares a `backend` is validated against the
1435
+ // worker capabilities and the app's fetch allowlist BEFORE any file is
1436
+ // emitted, so a broken link fails codegen rather than shipping a card whose
1437
+ // `hubspot.fetch` is dead. The effective allowlist mirrors the app-hsmeta
1438
+ // computation (the runtime origin is always auto-permitted).
1439
+ const effectiveFetch = [
1440
+ options.runtimeBaseUrl,
1441
+ ...(options.permittedUrls?.fetch ?? []).filter((url) => url !== options.runtimeBaseUrl),
1442
+ ];
1443
+ const errors = validateCardBackends({
1444
+ cards,
1445
+ workers: options.workers,
1446
+ runtimeOrigin: options.runtimeBaseUrl,
1447
+ fetchAllowlist: effectiveFetch,
1448
+ });
1449
+ if (errors.length > 0) {
1450
+ throw new Error(`Card backend linkage is invalid:\n${errors
1451
+ .map((error) => ` - [${error.code}] ${error.message}`)
1452
+ .join('\n')}`);
1453
+ }
1454
+ }
880
1455
  for (const card of cards) {
881
1456
  files[`src/app/cards/${toKebabCase(card.id)}-hsmeta.json`] = renderRuntimeCardMetadata(card);
882
1457
  }
@@ -884,6 +1459,16 @@ export function generateHubSpotRuntimeProject(options) {
884
1459
  // A single package.json is shared by all cards (HubSpot's convention).
885
1460
  files['src/app/cards/package.json'] = renderCardsPackage();
886
1461
  }
1462
+ if (linkedCards.length > 0) {
1463
+ // The typed, secret-free client the card components import to reach their
1464
+ // backend. Server/client separation is the file boundary: this module holds
1465
+ // only the public runtime origin + dispatch path.
1466
+ files['src/app/cards/_hsx-backend.ts'] = renderCardBackendClient({
1467
+ cards: linkedCards,
1468
+ workers: options.workers,
1469
+ runtimeOrigin: options.runtimeBaseUrl,
1470
+ });
1471
+ }
887
1472
  return { files };
888
1473
  }
889
1474
  /** The component filename a card's hsmeta `entrypoint` points at, e.g. `DealSummary`. */
@@ -918,6 +1503,150 @@ function renderCardsPackage() {
918
1503
  },
919
1504
  }, null, 2)}\n`;
920
1505
  }
1506
+ function originOf(url) {
1507
+ try {
1508
+ return new URL(url).origin;
1509
+ }
1510
+ catch {
1511
+ return url.replace(/\/+$/, '');
1512
+ }
1513
+ }
1514
+ function stripTrailingSlash(url) {
1515
+ return url.replace(/\/+$/, '');
1516
+ }
1517
+ /**
1518
+ * Cross-check every card that declares a `backend` against the worker
1519
+ * capabilities and the app's fetch allowlist. Pure and side-effect-free so both
1520
+ * codegen (which throws) and callers doing author-time linting can consume it.
1521
+ *
1522
+ * Errors:
1523
+ * - `card.backend.missing` — no `card-backend` capability with that id.
1524
+ * - `card.backend.permission-missing` — the runtime origin the generated
1525
+ * `hubspot.fetch` targets is not in `permittedUrls.fetch`, so HubSpot would
1526
+ * block the call.
1527
+ * - `card.backend.scope-mismatch` — the backend declares `objectTypes` and the
1528
+ * card renders on an object type the backend does not serve.
1529
+ */
1530
+ export function validateCardBackends(input) {
1531
+ const backends = new Map();
1532
+ for (const capability of cardBackendCapabilities(input.workers)) {
1533
+ backends.set(capability.id, capability);
1534
+ }
1535
+ const runtimeOrigin = originOf(input.runtimeOrigin);
1536
+ const originPermitted = input.fetchAllowlist.some((url) => originOf(url) === runtimeOrigin);
1537
+ const diagnostics = [];
1538
+ for (const card of input.cards) {
1539
+ if (!card.backend)
1540
+ continue;
1541
+ const backendId = card.backend;
1542
+ const backend = backends.get(backendId);
1543
+ if (!backend) {
1544
+ diagnostics.push({
1545
+ code: 'card.backend.missing',
1546
+ severity: 'error',
1547
+ cardId: card.id,
1548
+ backendId,
1549
+ message: `Card "${card.id}" declares backend "${backendId}", but no cardBackend capability with that id is defined in any worker.`,
1550
+ });
1551
+ continue;
1552
+ }
1553
+ if (!originPermitted) {
1554
+ diagnostics.push({
1555
+ code: 'card.backend.permission-missing',
1556
+ severity: 'error',
1557
+ cardId: card.id,
1558
+ backendId,
1559
+ message: `Card "${card.id}" calls backend "${backendId}" at ${runtimeOrigin}, but that origin is not in permittedUrls.fetch; HubSpot would block the UI extension's hubspot.fetch. Add it to permittedUrls.fetch.`,
1560
+ });
1561
+ }
1562
+ // A backend that declares `objectTypes` constrains which cards may call it;
1563
+ // a backend without them serves any object and skips this check.
1564
+ if (backend.objectTypes && backend.objectTypes.length > 0) {
1565
+ const served = new Set(backend.objectTypes.map(toHubSpotObjectType));
1566
+ const unserved = card.objectTypes.filter((type) => !served.has(toHubSpotObjectType(type)));
1567
+ if (unserved.length > 0) {
1568
+ diagnostics.push({
1569
+ code: 'card.backend.scope-mismatch',
1570
+ severity: 'error',
1571
+ cardId: card.id,
1572
+ backendId,
1573
+ message: `Card "${card.id}" renders on ${unserved.join(', ')}, but backend "${backendId}" only serves ${backend.objectTypes.join(', ')}. Add the object type(s) to the cardBackend or narrow the card.`,
1574
+ });
1575
+ }
1576
+ }
1577
+ }
1578
+ return diagnostics;
1579
+ }
1580
+ /**
1581
+ * The typed, secret-free client a UI-extension card imports to call its backend.
1582
+ * One exported callable per referenced `card-backend`, each wrapping
1583
+ * `hubspot.fetch(<runtimeOrigin>/_hsx/cards/<id>)`. The server/client boundary
1584
+ * is the file boundary: this module never contains handler source, credentials,
1585
+ * signing keys, or env — only the public origin and dispatch path.
1586
+ */
1587
+ export function renderCardBackendClient(input) {
1588
+ const backends = new Map();
1589
+ for (const capability of cardBackendCapabilities(input.workers)) {
1590
+ backends.set(capability.id, capability);
1591
+ }
1592
+ const referenced = new Map();
1593
+ for (const card of input.cards) {
1594
+ if (!card.backend)
1595
+ continue;
1596
+ const backend = backends.get(card.backend);
1597
+ if (backend)
1598
+ referenced.set(backend.id, backend);
1599
+ }
1600
+ const callables = [...referenced.values()]
1601
+ .map((backend) => {
1602
+ const fn = toIdentifier(backend.id);
1603
+ const path = cardBackendDispatchPath(backend.id);
1604
+ const label = backend.label ? ` (${backend.label})` : '';
1605
+ return `/** Call the \`${backend.id}\` card backend${label}. */
1606
+ export function ${fn}<TResponse = unknown>(
1607
+ body?: Record<string, unknown>,
1608
+ ): Promise<CardBackendResponse<TResponse>> {
1609
+ return callCardBackend<TResponse>(${JSON.stringify(path)}, body);
1610
+ }
1611
+ `;
1612
+ })
1613
+ .join('\n');
1614
+ return `// Generated by hs-x. Do not edit.
1615
+ //
1616
+ // Typed card→backend client for the UI-extension bundle. It references only the
1617
+ // public runtime origin and the ADR-018 card dispatch path — nothing from the
1618
+ // server side crosses this file boundary. Import a callable into a card
1619
+ // component and call it like a fetch; HS-X authenticates the request on the
1620
+ // runtime (v3 signature + active-install binding).
1621
+ import { hubspot } from '@hubspot/ui-extensions';
1622
+
1623
+ const RUNTIME_ORIGIN = ${JSON.stringify(stripTrailingSlash(input.runtimeOrigin))};
1624
+
1625
+ export interface CardBackendResponse<TData = unknown> {
1626
+ readonly ok: boolean;
1627
+ readonly status: number;
1628
+ readonly data: TData;
1629
+ }
1630
+
1631
+ async function callCardBackend<TData>(
1632
+ path: string,
1633
+ body?: Record<string, unknown>,
1634
+ ): Promise<CardBackendResponse<TData>> {
1635
+ // hubspot.fetch serializes \`body\` itself (its contract types \`body\` as an
1636
+ // object, not a string). Passing a pre-stringified body double-encodes it —
1637
+ // the runtime then parses a JSON string, not a record, and rejects it with
1638
+ // 400 invalid_payload. So hand the object straight through.
1639
+ const response = await hubspot.fetch(\`\${RUNTIME_ORIGIN}\${path}\`, {
1640
+ method: 'POST',
1641
+ ...(body === undefined ? {} : { body }),
1642
+ });
1643
+ const data = (await response.json().catch(() => undefined)) as TData;
1644
+ return { ok: response.ok, status: response.status, data };
1645
+ }
1646
+
1647
+ ${callables}`;
1648
+ }
1649
+ // ─────────────────────────────────────────────────────────────────────────────
921
1650
  // HubSpot project names become part of URLs and must agree with the scaffolded
922
1651
  // package.json name and the deploy's project identity. Slugify to a lowercase,
923
1652
  // url-safe form so e.g. "HSX E2E 006" -> "hsx-e2e-006" rather than a spaced name
@@ -959,9 +1688,14 @@ function renderRuntimeAppMetadata(options) {
959
1688
  conditionallyRequiredScopes: [],
960
1689
  },
961
1690
  permittedUrls: {
962
- fetch: [options.runtimeBaseUrl],
963
- iframe: [],
964
- img: [],
1691
+ // The worker URL is always fetch-permitted (UI extensions and the
1692
+ // runtime call it); author-declared fetch hosts are merged in.
1693
+ fetch: [
1694
+ options.runtimeBaseUrl,
1695
+ ...(options.permittedUrls?.fetch ?? []).filter((url) => url !== options.runtimeBaseUrl),
1696
+ ],
1697
+ iframe: [...(options.permittedUrls?.iframe ?? [])],
1698
+ img: [...(options.permittedUrls?.img ?? [])],
965
1699
  },
966
1700
  support: {
967
1701
  supportEmail: 'support@example.com',
@@ -1035,7 +1769,9 @@ function renderAppObjectMetadata(object) {
1035
1769
  ...(object.appPrefix ? { appPrefix: object.appPrefix } : {}),
1036
1770
  primaryDisplayLabelPropertyName: object.primaryDisplayLabelPropertyName,
1037
1771
  ...(object.secondaryDisplayLabelPropertyNames
1038
- ? { secondaryDisplayLabelPropertyNames: object.secondaryDisplayLabelPropertyNames }
1772
+ ? {
1773
+ secondaryDisplayLabelPropertyNames: object.secondaryDisplayLabelPropertyNames,
1774
+ }
1039
1775
  : {}),
1040
1776
  ...(object.requiredProperties ? { requiredProperties: object.requiredProperties } : {}),
1041
1777
  ...(object.searchableProperties
@@ -1191,14 +1927,24 @@ function hubSpotWorkflowInputFields(fields) {
1191
1927
  else if (options) {
1192
1928
  typeDefinition.options = options;
1193
1929
  }
1194
- const supportedValueTypes = Array.isArray(record.supportedValueTypes)
1930
+ const declaredValueTypes = Array.isArray(record.supportedValueTypes)
1195
1931
  ? record.supportedValueTypes.filter((value) => typeof value === 'string')
1196
- : ['STATIC_VALUE'];
1932
+ : [];
1933
+ // HubSpot rejects an input field that declares more than one
1934
+ // supportedValueType ("must have exactly one supportedValueType"), so fail
1935
+ // here with a clear message rather than letting the HubSpot build error out.
1936
+ if (declaredValueTypes.length > 1) {
1937
+ throw new Error(`Workflow action input field "${name}" declares ${declaredValueTypes.length} supportedValueTypes (${declaredValueTypes.join(', ')}). HubSpot requires exactly one per field — choose STATIC_VALUE (user enters a value) or OBJECT_PROPERTY (user picks a property or a prior action's output).`);
1938
+ }
1939
+ const supportedValueTypes = declaredValueTypes.length === 1 ? declaredValueTypes : ['STATIC_VALUE'];
1940
+ // Match HubSpot's platform default: input fields are optional unless the
1941
+ // author opts in. (HubSpot's custom-action schema treats isRequired as
1942
+ // false-by-default.) Honor either `isRequired` or the `required` alias.
1197
1943
  const isRequired = typeof record.isRequired === 'boolean'
1198
1944
  ? record.isRequired
1199
1945
  : typeof record.required === 'boolean'
1200
1946
  ? record.required
1201
- : true;
1947
+ : false;
1202
1948
  return {
1203
1949
  typeDefinition,
1204
1950
  supportedValueTypes,
@@ -1337,7 +2083,45 @@ function renderMigratedRootPackage(appName) {
1337
2083
  },
1338
2084
  }, null, 2)}\n`;
1339
2085
  }
1340
- function renderMigratedCardsPackage() {
2086
+ function collectMigratedCardRuntimeDependencies(cards) {
2087
+ const importedPackages = new Set();
2088
+ const declaredDependencies = new Map();
2089
+ for (const card of cards) {
2090
+ for (const [file, contents] of Object.entries(card.sources ?? {})) {
2091
+ if (file === 'package.json' || file.endsWith('/package.json')) {
2092
+ try {
2093
+ const pkg = JSON.parse(contents);
2094
+ for (const [name, version] of Object.entries({
2095
+ ...(pkg.dependencies ?? {}),
2096
+ ...(pkg.optionalDependencies ?? {}),
2097
+ })) {
2098
+ if (typeof version === 'string')
2099
+ declaredDependencies.set(name, version);
2100
+ }
2101
+ }
2102
+ catch {
2103
+ // Invalid legacy package metadata is ignored; the normalized base
2104
+ // dependencies still produce a valid card package.
2105
+ }
2106
+ continue;
2107
+ }
2108
+ for (const match of contents.matchAll(/(?:\bfrom\s+|\bimport\s*\(\s*|\brequire\s*\(\s*)(["'])([^"'./][^"']*)\1/g)) {
2109
+ const specifier = match[2];
2110
+ if (!specifier)
2111
+ continue;
2112
+ const segments = specifier.split('/');
2113
+ importedPackages.add(specifier.startsWith('@') ? segments.slice(0, 2).join('/') : (segments[0] ?? specifier));
2114
+ }
2115
+ }
2116
+ }
2117
+ return Object.fromEntries([...declaredDependencies]
2118
+ .filter(([name]) => importedPackages.has(name) &&
2119
+ name !== '@hubspot/ui-extensions-dev-server' &&
2120
+ name !== '@hubspot/ui-extensions' &&
2121
+ name !== 'react')
2122
+ .sort(([a], [b]) => a.localeCompare(b)));
2123
+ }
2124
+ function renderMigratedCardsPackage(runtimeDependencies = {}) {
1341
2125
  return `${JSON.stringify({
1342
2126
  name: 'hs-x-migrated-cards',
1343
2127
  version: '0.1.0',
@@ -1345,6 +2129,7 @@ function renderMigratedCardsPackage() {
1345
2129
  dependencies: {
1346
2130
  '@hubspot/ui-extensions': 'latest',
1347
2131
  react: '^18.2.0',
2132
+ ...runtimeDependencies,
1348
2133
  },
1349
2134
  }, null, 2)}\n`;
1350
2135
  }