@gakim-digital/dexter-bridge 0.12.2 → 0.13.1

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/src/cli.js CHANGED
@@ -6,17 +6,27 @@ import {
6
6
  BRIDGE_BUILD_FINGERPRINT,
7
7
  BRIDGE_CAPABILITIES,
8
8
  BRIDGE_VERSION,
9
+ DEXTER_HOSTS,
10
+ bridgeDisplayName,
9
11
  clearConfig,
10
12
  defaultConfigDir,
13
+ dexterHostConfigDir,
11
14
  normalizeBridgeProduct,
12
15
  normalizeAgentName,
16
+ normalizeDexterHost,
13
17
  readConfig,
14
18
  resolveAgentName,
15
19
  resolveApiBaseUrl,
16
20
  resolveCompanionModelName,
17
21
  saveConfigPatch,
18
22
  } from './config.js';
19
- import { checkAgentAvailability, checkAllAgents, executeRun, processEnvWithCliPath } from './agent.js';
23
+ import {
24
+ checkAgentAvailability,
25
+ checkAllAgents,
26
+ executeRun,
27
+ processEnvWithCliPath,
28
+ stopAgentProcesses,
29
+ } from './agent.js';
20
30
  import { setUpCursorCli } from './providers/cursorAcp.js';
21
31
  import {
22
32
  claimPairing,
@@ -231,8 +241,10 @@ async function executeClaimedRun({
231
241
  preventIdleSleep = false,
232
242
  }) {
233
243
  const awakeGuard = preventIdleSleep ? startRunAwakeGuard() : null;
244
+ const running = Promise.resolve().then(() => execute(run, executeOptions));
245
+ activeRuns.add(running);
234
246
  try {
235
- await execute(run, executeOptions);
247
+ await running;
236
248
  return true;
237
249
  } catch (error) {
238
250
  logger?.error?.('run_execution_failed', {
@@ -244,6 +256,7 @@ async function executeClaimedRun({
244
256
  console.error(`${productName} run ${run?.runId || 'unknown'} failed; continuing to poll.`);
245
257
  return false;
246
258
  } finally {
259
+ activeRuns.delete(running);
247
260
  awakeGuard?.close();
248
261
  }
249
262
  }
@@ -252,6 +265,28 @@ function wait(delayMs) {
252
265
  return new Promise((resolve) => setTimeout(resolve, delayMs));
253
266
  }
254
267
 
268
+ const activeRuns = new Set();
269
+ const SHUTDOWN_GRACE_MS = 5_000;
270
+ let signalsHandled = false;
271
+
272
+ // Stopping the bridge stops its agents and lets their runs report the
273
+ // cancellation before the process exits. A second signal exits at once.
274
+ function stopRunsOnSignals() {
275
+ if (signalsHandled) return;
276
+ signalsHandled = true;
277
+ let stopping = false;
278
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
279
+ process.on(signal, () => {
280
+ const exitCode = 128 + os.constants.signals[signal];
281
+ if (stopping) process.exit(exitCode);
282
+ stopping = true;
283
+ stopAgentProcesses();
284
+ void Promise.race([Promise.allSettled([...activeRuns]), wait(SHUTDOWN_GRACE_MS)])
285
+ .then(() => process.exit(exitCode));
286
+ });
287
+ }
288
+ }
289
+
255
290
  function isInvalidPairingError(error) {
256
291
  return isCompanionPairingInvalidError(error);
257
292
  }
@@ -280,7 +315,8 @@ function runCommand(command, args) {
280
315
 
281
316
  // Upgrades the Homebrew install at most once an hour. On success the process
282
317
  // exits non-zero so launchd restarts the service on the new version.
283
- async function selfUpdateWithHomebrew(configDir, logger) {
318
+ async function selfUpdateWithHomebrew(logger) {
319
+ const configDir = defaultConfigDir();
284
320
  const lastAttempt = Date.parse(readConfig(configDir).lastSelfUpdateAt || '');
285
321
  if (Number.isFinite(lastAttempt) && Date.now() - lastAttempt < SELF_UPDATE_INTERVAL_MS) return;
286
322
  saveConfigPatch({ lastSelfUpdateAt: new Date().toISOString() }, configDir);
@@ -293,18 +329,23 @@ async function selfUpdateWithHomebrew(configDir, logger) {
293
329
  throw restart;
294
330
  }
295
331
 
296
- async function waitForPairing(configDir) {
332
+ function productConfigDirs(rootDir) {
333
+ return DEXTER_HOSTS.map((host) => dexterHostConfigDir(rootDir, host));
334
+ }
335
+
336
+ // Resolves with the first folder that holds a pairing.
337
+ async function waitForPairing(configDirs) {
297
338
  console.log('Waiting for a new pairing from Dexter...');
298
339
  while (true) {
299
- const token = readConfig(configDir).deviceToken;
300
- if (token) return token;
340
+ const paired = configDirs.find((dir) => readConfig(dir).deviceToken);
341
+ if (paired) return paired;
301
342
  await wait(PAIRING_WAIT_POLL_MS);
302
343
  }
303
344
  }
304
345
 
305
346
  function clearInvalidPairing(configDir, cause, rejectedToken) {
306
347
  const current = readConfig(configDir);
307
- const product = normalizeBridgeProduct(current.product);
348
+ const name = bridgeDisplayName(current);
308
349
  // Another bridge command may have just saved a fresh pairing; keep it.
309
350
  if (!rejectedToken || current.deviceToken === rejectedToken) {
310
351
  saveConfigPatch({
@@ -314,7 +355,7 @@ function clearInvalidPairing(configDir, cause, rejectedToken) {
314
355
  }, configDir);
315
356
  }
316
357
  const error = new Error(
317
- `This ${product.name} Bridge pairing was disconnected. Reopen ${product.name} and run the new pairing command.`,
358
+ `This ${name} Bridge pairing was disconnected. Reopen ${name} and run the new pairing command.`,
318
359
  { cause },
319
360
  );
320
361
  error.code = 'FRAMER_COMPANION_NOT_PAIRED';
@@ -550,27 +591,30 @@ async function pairCommand({ apiBaseUrl, args, flags, configDir }) {
550
591
  runtimeProfiles: availability.runtimeProfiles.map(publicRuntimeProfile),
551
592
  runtimeSelection,
552
593
  });
594
+ const product = normalizeBridgeProduct(result.product);
595
+ const dexterHost = product.id === 'dexter' ? normalizeDexterHost(result.product?.host) : null;
596
+ const pairedDir = dexterHost ? dexterHostConfigDir(configDir, dexterHost) : configDir;
553
597
  saveConfigPatch({
554
598
  apiBaseUrl,
555
599
  agent,
556
600
  model,
557
601
  runtimeSelection,
558
- product: normalizeBridgeProduct(result.product),
602
+ product,
603
+ dexterHost,
559
604
  deviceToken: result.deviceToken,
560
605
  device: result.device,
561
606
  agentCommands: availability.agentCommands,
562
607
  pairedAt: new Date().toISOString(),
563
- }, configDir);
564
- const product = normalizeBridgeProduct(result.product);
565
- console.log(`Paired ${product.name} Bridge: ${result.device?.name || 'device'}`);
608
+ }, pairedDir);
609
+ console.log(`Paired ${bridgeDisplayName({ product, dexterHost })} Bridge: ${result.device?.name || 'device'}`);
566
610
  if (runtimeSelection) {
567
611
  console.log(`Runtime: ${runtimeProfile?.label || runtimeLabel(runtimeSelection)}`);
568
612
  }
569
613
  console.log(`API: ${apiBaseUrl}`);
614
+ return pairedDir;
570
615
  }
571
616
 
572
617
  async function statusCommand({ apiBaseUrl, config, configDir }) {
573
- const product = normalizeBridgeProduct(config.product);
574
618
  const deviceToken = requireDeviceToken(config);
575
619
  const runtimeSelection = requestedRuntime({}, config);
576
620
  const agent = runtimeSelection
@@ -598,8 +642,8 @@ async function statusCommand({ apiBaseUrl, config, configDir }) {
598
642
  if (isInvalidPairingError(error)) throw clearInvalidPairing(configDir, error);
599
643
  throw error;
600
644
  }
601
- console.log(`Status: ${result.device?.online ? 'online' : 'paired'}`);
602
- console.log(`Device: ${result.device?.name || `${product.name} Bridge`}`);
645
+ console.log(`${bridgeDisplayName(config)}: ${result.device?.online ? 'online' : 'paired'}`);
646
+ console.log(`Device: ${result.device?.name || `${bridgeDisplayName(config)} Bridge`}`);
603
647
  if (runtimeSelection) {
604
648
  console.log(`Runtime: ${runtimeProfile?.label || runtimeLabel(runtimeSelection)}`);
605
649
  }
@@ -613,20 +657,20 @@ async function startCommand({ apiBaseUrl, config, flags, configDir }) {
613
657
  if (!deviceToken) {
614
658
  // First run: pair interactively so `npx @gakim-digital/dexter-bridge` alone is
615
659
  if (runsAsBackgroundService()) {
616
- deviceToken = await waitForPairing(configDir);
617
- activeConfig = readConfig(configDir);
660
+ configDir = await waitForPairing(productConfigDirs(configDir));
618
661
  } else {
619
662
  if (!process.stdin.isTTY) requireDeviceToken(config);
620
663
  console.log('This local agent bridge is not paired yet.');
621
664
  console.log('Open the product connection screen and generate a new bridge command.');
622
665
  const code = await promptForPairingCode();
623
666
  if (!code) requireDeviceToken(config);
624
- await pairCommand({ apiBaseUrl, args: [code], flags, configDir });
625
- activeConfig = readConfig(configDir);
626
- deviceToken = activeConfig.deviceToken;
667
+ configDir = await pairCommand({ apiBaseUrl, args: [code], flags, configDir });
627
668
  }
669
+ activeConfig = readConfig(configDir);
670
+ deviceToken = activeConfig.deviceToken;
628
671
  }
629
672
  const product = normalizeBridgeProduct(activeConfig.product);
673
+ const label = bridgeDisplayName(activeConfig);
630
674
  const runtimeSelection = requestedRuntime(flags, activeConfig);
631
675
  if (product.connectionScope === 'app-builder' && !runtimeSelection) {
632
676
  const error = new Error('This App Builder connection requires --runtime codex, --runtime claude-code, or --runtime opencode.');
@@ -673,8 +717,56 @@ async function startCommand({ apiBaseUrl, config, flags, configDir }) {
673
717
  };
674
718
  let pollFailureCount = 0;
675
719
  let nextCatalogRefreshAt = Date.now() + MODEL_CATALOG_REFRESH_MS;
720
+ let catalogRefresh = null;
721
+ // Agent probes can take tens of seconds, so the catalog refreshes in the
722
+ // background and never delays a queued run.
723
+ const refreshModelCatalog = (requestedAt) => {
724
+ catalogRefresh ||= (async () => {
725
+ try {
726
+ const refreshedAvailability = await inspectAvailability(activeConfig, runtimeSelection, {
727
+ forceModelRefresh: true,
728
+ previousRuntimeProfiles: availability.runtimeProfiles,
729
+ });
730
+ const refreshedRuntimeProfile = runtimeSelection
731
+ ? requireAvailableRuntime(refreshedAvailability, runtimeSelection)
732
+ : null;
733
+ const refreshedCheck = refreshedRuntimeProfile
734
+ ? null
735
+ : requireAvailableAgent(refreshedAvailability, agent);
736
+ availability = refreshedAvailability;
737
+ runtimeProfile = refreshedRuntimeProfile;
738
+ check = refreshedCheck;
739
+ model = runtimeProfile
740
+ ? runtimeProfile.models.find((candidate) => candidate.id === requestedModel)?.id
741
+ || runtimeProfile.models[0].id
742
+ : availableModel(check, requestedModel);
743
+ metadata = availability.metadata;
744
+ profiles = runtimeProfileMap(availability.runtimeProfiles);
745
+ await heartbeat(apiBaseUrl, {
746
+ deviceToken,
747
+ status: 'ready',
748
+ agent,
749
+ model,
750
+ metadata,
751
+ runtimeProfiles: availability.runtimeProfiles.map(publicRuntimeProfile),
752
+ allowInsecureHttp: flags['allow-insecure-http'],
753
+ });
754
+ pollLogger.info(requestedAt ? 'model_catalog_refresh_completed' : 'model_catalog_refreshed', {
755
+ ...(requestedAt ? { requestedAt } : {}),
756
+ models: availability.runtimeProfiles.flatMap((profile) =>
757
+ profile.models.map((candidate) => candidate.id)),
758
+ });
759
+ } catch (error) {
760
+ pollLogger.warn('model_catalog_refresh_failed', { error: errorMeta(error) });
761
+ } finally {
762
+ nextCatalogRefreshAt = Date.now() + MODEL_CATALOG_REFRESH_MS;
763
+ catalogRefresh = null;
764
+ }
765
+ })();
766
+ return catalogRefresh;
767
+ };
676
768
 
677
- console.log(`${product.name} Bridge connected to ${apiBaseUrl}`);
769
+ console.log(`${label} Bridge connected to ${apiBaseUrl}`);
678
770
  console.log(
679
771
  `${runtimeSelection ? 'Runtime' : 'Agent'}: ${
680
772
  runtimeSelection ? runtimeProfile?.label || runtimeLabel(runtimeSelection) : agent
@@ -686,37 +778,7 @@ async function startCommand({ apiBaseUrl, config, flags, configDir }) {
686
778
 
687
779
  try {
688
780
  while (true) {
689
- if (Date.now() >= nextCatalogRefreshAt) {
690
- try {
691
- const refreshedAvailability = await inspectAvailability(activeConfig, runtimeSelection, {
692
- forceModelRefresh: true,
693
- previousRuntimeProfiles: availability.runtimeProfiles,
694
- });
695
- const refreshedRuntimeProfile = runtimeSelection
696
- ? requireAvailableRuntime(refreshedAvailability, runtimeSelection)
697
- : null;
698
- const refreshedCheck = refreshedRuntimeProfile
699
- ? null
700
- : requireAvailableAgent(refreshedAvailability, agent);
701
- availability = refreshedAvailability;
702
- runtimeProfile = refreshedRuntimeProfile;
703
- check = refreshedCheck;
704
- model = runtimeProfile
705
- ? runtimeProfile.models.find((candidate) => candidate.id === requestedModel)?.id
706
- || runtimeProfile.models[0].id
707
- : availableModel(check, requestedModel);
708
- metadata = availability.metadata;
709
- profiles = runtimeProfileMap(availability.runtimeProfiles);
710
- pollLogger.info('model_catalog_refreshed', {
711
- models: availability.runtimeProfiles.flatMap((profile) =>
712
- profile.models.map((candidate) => candidate.id)),
713
- });
714
- } catch (error) {
715
- pollLogger.warn('model_catalog_refresh_failed', { error: errorMeta(error) });
716
- } finally {
717
- nextCatalogRefreshAt = Date.now() + MODEL_CATALOG_REFRESH_MS;
718
- }
719
- }
781
+ if (Date.now() >= nextCatalogRefreshAt) void refreshModelCatalog();
720
782
  let poll;
721
783
  try {
722
784
  poll = await pollRun(apiBaseUrl, {
@@ -726,6 +788,7 @@ async function startCommand({ apiBaseUrl, config, flags, configDir }) {
726
788
  model,
727
789
  metadata,
728
790
  runtimeProfiles: availability.runtimeProfiles.map(publicRuntimeProfile),
791
+ refreshingModelCatalog: Boolean(catalogRefresh),
729
792
  });
730
793
  pollFailureCount = 0;
731
794
  } catch (error) {
@@ -744,64 +807,29 @@ async function startCommand({ apiBaseUrl, config, flags, configDir }) {
744
807
  const disconnected = clearInvalidPairing(configDir, error, deviceToken);
745
808
  console.log(disconnected.message);
746
809
  if (!runsAsBackgroundService()) return;
747
- deviceToken = await waitForPairing(configDir);
810
+ await waitForPairing([configDir]);
811
+ deviceToken = readConfig(configDir).deviceToken;
748
812
  pollFailureCount = 0;
749
813
  continue;
750
814
  }
751
815
  if (isUpdateRequiredError(error) && runsAsBackgroundService()) {
752
- await selfUpdateWithHomebrew(configDir, pollLogger);
816
+ await selfUpdateWithHomebrew(pollLogger);
753
817
  }
754
818
  if (once) throw error;
755
819
  pollFailureCount += 1;
756
820
  const retryInMs = pollBackoffMs(pollFailureCount);
757
821
  pollLogger.error('poll_failed', { error: errorMeta(error) });
758
822
  if (pollFailureCount === 1 || (pollFailureCount & (pollFailureCount - 1)) === 0) {
759
- console.error(`${product.name} polling failed; retrying in ${Math.round(retryInMs / 1000)}s.`);
823
+ console.error(`${label} polling failed; retrying in ${Math.round(retryInMs / 1000)}s.`);
760
824
  }
761
825
  await wait(retryInMs);
762
826
  continue;
763
827
  }
764
828
  if (poll?.control?.refreshModelCatalog) {
765
- try {
766
- const refreshedAvailability = await inspectAvailability(activeConfig, runtimeSelection, {
767
- forceModelRefresh: true,
768
- previousRuntimeProfiles: availability.runtimeProfiles,
769
- });
770
- availability = refreshedAvailability;
771
- runtimeProfile = runtimeSelection
772
- ? requireAvailableRuntime(refreshedAvailability, runtimeSelection)
773
- : null;
774
- check = runtimeProfile
775
- ? null
776
- : requireAvailableAgent(refreshedAvailability, agent);
777
- model = runtimeProfile
778
- ? runtimeProfile.models.find((candidate) => candidate.id === requestedModel)?.id
779
- || runtimeProfile.models[0].id
780
- : availableModel(check, requestedModel);
781
- metadata = availability.metadata;
782
- profiles = runtimeProfileMap(availability.runtimeProfiles);
783
- await heartbeat(apiBaseUrl, {
784
- deviceToken,
785
- status: 'ready',
786
- agent,
787
- model,
788
- metadata,
789
- runtimeProfiles: availability.runtimeProfiles.map(publicRuntimeProfile),
790
- allowInsecureHttp: flags['allow-insecure-http'],
791
- });
792
- pollLogger.info('model_catalog_refresh_completed', {
793
- requestedAt: poll.control.refreshModelCatalog.requestedAt,
794
- models: availability.runtimeProfiles.flatMap((profile) =>
795
- profile.models.map((candidate) => candidate.id)),
796
- });
797
- } catch (error) {
798
- pollLogger.warn('model_catalog_refresh_failed', { error: errorMeta(error) });
799
- } finally {
800
- nextCatalogRefreshAt = Date.now() + MODEL_CATALOG_REFRESH_MS;
801
- }
829
+ void refreshModelCatalog(poll.control.refreshModelCatalog.requestedAt);
802
830
  }
803
831
  if (poll.run) {
804
- console.log(`Claimed run ${poll.run.runId}.`);
832
+ console.log(`Claimed ${label} run ${poll.run.runId}.`);
805
833
  const runtimeProfile = poll.run?.runtimeProfile || null;
806
834
  const runAgent = normalizeAgentName(
807
835
  poll.run?.companion?.agent
@@ -841,7 +869,7 @@ async function startCommand({ apiBaseUrl, config, flags, configDir }) {
841
869
  continue;
842
870
  }
843
871
  if (once) {
844
- console.log(`No pending ${product.name} runs.`);
872
+ console.log(`No pending ${label} runs.`);
845
873
  return;
846
874
  }
847
875
  }
@@ -851,6 +879,22 @@ async function startCommand({ apiBaseUrl, config, flags, configDir }) {
851
879
  }
852
880
  }
853
881
 
882
+ function pairedConfigDirs(rootDir) {
883
+ return productConfigDirs(rootDir).filter((dir) => readConfig(dir).deviceToken);
884
+ }
885
+
886
+ // One polling loop per paired product, so Dexter for Framer and Dexter for
887
+ // Webflow both stay online from a single bridge process.
888
+ async function startPairedBridges({ apiFlag, flags, rootDir }) {
889
+ stopRunsOnSignals();
890
+ const paired = pairedConfigDirs(rootDir);
891
+ await Promise.all((paired.length ? paired : [rootDir]).map((configDir) => {
892
+ const config = readConfig(configDir);
893
+ const apiBaseUrl = resolveApiBaseUrl({ flagValue: apiFlag, config });
894
+ return startCommand({ apiBaseUrl, config: { ...config, apiBaseUrl }, flags, configDir });
895
+ }));
896
+ }
897
+
854
898
  function printDiagnostics(diagnostics) {
855
899
  const marks = { info: '✓', warn: '!', error: '✗' };
856
900
  for (const check of diagnostics.checks) {
@@ -897,31 +941,37 @@ export async function runCli(argv) {
897
941
  if (parsed.args[0]) {
898
942
  await pairCommand({ apiBaseUrl, args: parsed.args, flags: parsed.flags, configDir });
899
943
  }
900
- await startCommand({
901
- apiBaseUrl,
902
- config: { ...readConfig(configDir), apiBaseUrl },
903
- flags: parsed.flags,
904
- configDir,
905
- });
944
+ await startPairedBridges({ apiFlag: parsed.flags.api, flags: parsed.flags, rootDir: configDir });
906
945
  return;
907
- case 'pair':
908
- await pairCommand({ apiBaseUrl, args: parsed.args, flags: parsed.flags, configDir });
909
- console.log(`Run \`dexter-bridge start\` to bring ${normalizeBridgeProduct(readConfig(configDir).product).name} online.`);
946
+ case 'pair': {
947
+ const pairedDir = await pairCommand({ apiBaseUrl, args: parsed.args, flags: parsed.flags, configDir });
948
+ console.log(`Run \`dexter-bridge start\` to bring ${bridgeDisplayName(readConfig(pairedDir))} online.`);
910
949
  return;
950
+ }
911
951
  case 'start':
912
- await startCommand({ apiBaseUrl, config: { ...config, apiBaseUrl }, flags: parsed.flags, configDir });
952
+ await startPairedBridges({ apiFlag: parsed.flags.api, flags: parsed.flags, rootDir: configDir });
913
953
  return;
914
- case 'status':
915
- await statusCommand({ apiBaseUrl, config, configDir });
954
+ case 'status': {
955
+ const paired = pairedConfigDirs(configDir);
956
+ if (!paired.length) requireDeviceToken(config);
957
+ for (const dir of paired) {
958
+ const dirConfig = readConfig(dir);
959
+ await statusCommand({
960
+ apiBaseUrl: resolveApiBaseUrl({ flagValue: parsed.flags.api, config: dirConfig }),
961
+ config: dirConfig,
962
+ configDir: dir,
963
+ });
964
+ }
916
965
  return;
966
+ }
917
967
  case 'doctor':
918
968
  await doctorCommand(requestedRuntime(parsed.flags, config));
919
969
  return;
920
970
  case 'logout':
921
- {
922
- const product = normalizeBridgeProduct(config.product);
923
- clearConfig(configDir);
924
- console.log(`${product.name} Bridge local pairing removed.`);
971
+ for (const dir of productConfigDirs(configDir)) {
972
+ const dirConfig = readConfig(dir);
973
+ clearConfig(dir);
974
+ if (dirConfig.deviceToken) console.log(`${bridgeDisplayName(dirConfig)} Bridge local pairing removed.`);
925
975
  }
926
976
  return;
927
977
  default:
package/src/config.js CHANGED
@@ -22,7 +22,9 @@ export const BRIDGE_BUILD_FINGERPRINT = crypto
22
22
  './harnessMcpServer.js',
23
23
  './framerTools.js',
24
24
  './framerMcpClient.js',
25
- './framerBehaviorContract.js',
25
+ './framerRequirementContract.js',
26
+ './webflowTools.js',
27
+ './taskEvidence.js',
26
28
  './outcomeWorkspace.js',
27
29
  './runtimeProfiles.js',
28
30
  './providers/index.js',
@@ -45,8 +47,9 @@ export const BRIDGE_CAPABILITIES = [
45
47
  'framer-mcp-v1',
46
48
  'framer-project-bootstrap-v1',
47
49
  'framer-project-verification-v1',
48
- 'framer-interaction-verification-v4',
50
+ 'framer-requirement-verification-v1',
49
51
  'framer-read-website-v1',
52
+ 'webflow-site-v2',
50
53
  'project-shell-v1',
51
54
  'build-fingerprint-v1',
52
55
  'tool-schema-parity-v1',
@@ -261,6 +264,23 @@ export function normalizeBridgeProduct(value) {
261
264
  return BRIDGE_PRODUCTS.dexter;
262
265
  }
263
266
 
267
+ export const DEXTER_HOSTS = ['framer', 'webflow'];
268
+
269
+ export function normalizeDexterHost(value) {
270
+ return value === 'webflow' ? 'webflow' : 'framer';
271
+ }
272
+
273
+ // Each Dexter product keeps its own pairing so pairing one never replaces the other.
274
+ export function dexterHostConfigDir(rootDir, host) {
275
+ return normalizeDexterHost(host) === 'webflow' ? path.join(rootDir, 'webflow') : rootDir;
276
+ }
277
+
278
+ export function bridgeDisplayName(config = {}) {
279
+ const product = normalizeBridgeProduct(config.product);
280
+ if (product.id !== 'dexter') return product.name;
281
+ return normalizeDexterHost(config.dexterHost) === 'webflow' ? 'Dexter for Webflow' : 'Dexter for Framer';
282
+ }
283
+
264
284
  export function configFilePath(configDir = defaultConfigDir()) {
265
285
  return path.join(configDir, 'config.json');
266
286
  }