@mpgd/cli 0.4.1 → 0.6.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
@@ -949,6 +949,7 @@ function toTemplatePath(value) {
949
949
  }
950
950
  function createTargetCommandEnv(values) {
951
951
  const targetsFile = path.resolve(readOptionalString(values['targets-file']) ?? 'mpgd.targets.json');
952
+ const gameRoot = path.dirname(targetsFile);
952
953
  const kitPath = resolveKitPathForTarget(values);
953
954
  const resolvedTargetsFile = readOptionalString(values['resolved-targets-file']);
954
955
  const preparedTargetsFile = preparePlatformTargetsFile({
@@ -960,10 +961,75 @@ function createTargetCommandEnv(values) {
960
961
  });
961
962
  return {
962
963
  ...process.env,
964
+ ...createGameOwnedReleaseEnv(gameRoot, process.env),
963
965
  MPGD_KIT_PATH: kitPath,
964
966
  MPGD_PLATFORM_TARGETS_FILE: preparedTargetsFile,
965
967
  };
966
968
  }
969
+ function createGameOwnedReleaseEnv(gameRoot, baseEnv) {
970
+ const monetizationFiles = resolveGameOwnedMonetizationFiles(gameRoot, baseEnv);
971
+ const sourceGitSha = readOptionalString(baseEnv.MPGD_SOURCE_GIT_SHA)
972
+ ?? readSourceGitSha(gameRoot);
973
+ const appVersion = readOptionalString(baseEnv.APP_VERSION)
974
+ ?? readGamePackageVersion(gameRoot);
975
+ return {
976
+ ...monetizationFiles,
977
+ MPGD_SOURCE_GIT_SHA: sourceGitSha,
978
+ ...(appVersion === undefined ? {} : { APP_VERSION: appVersion }),
979
+ };
980
+ }
981
+ function resolveGameOwnedMonetizationFiles(gameRoot, baseEnv) {
982
+ const configuredCatalog = readConfiguredPath(baseEnv.MPGD_PRODUCT_CATALOG_FILE);
983
+ const configuredPlacements = readConfiguredPath(baseEnv.MPGD_AD_PLACEMENTS_FILE);
984
+ if ((configuredCatalog === undefined) !== (configuredPlacements === undefined)) {
985
+ throw new Error('MPGD_PRODUCT_CATALOG_FILE and MPGD_AD_PLACEMENTS_FILE must be configured together.');
986
+ }
987
+ if (configuredCatalog !== undefined && configuredPlacements !== undefined) {
988
+ return {
989
+ MPGD_PRODUCT_CATALOG_FILE: path.resolve(gameRoot, configuredCatalog),
990
+ MPGD_AD_PLACEMENTS_FILE: path.resolve(gameRoot, configuredPlacements),
991
+ };
992
+ }
993
+ const catalogFile = path.join(gameRoot, 'mpgd.catalog.json');
994
+ const placementsFile = path.join(gameRoot, 'mpgd.ad-placements.json');
995
+ const hasCatalog = existsSync(catalogFile);
996
+ const hasPlacements = existsSync(placementsFile);
997
+ if (hasCatalog !== hasPlacements) {
998
+ throw new Error(`Game target builds must provide both mpgd.catalog.json and mpgd.ad-placements.json: ${gameRoot}`);
999
+ }
1000
+ if (!hasCatalog) {
1001
+ return {};
1002
+ }
1003
+ return {
1004
+ MPGD_PRODUCT_CATALOG_FILE: catalogFile,
1005
+ MPGD_AD_PLACEMENTS_FILE: placementsFile,
1006
+ };
1007
+ }
1008
+ function readConfiguredPath(value) {
1009
+ const normalized = value?.trim();
1010
+ return normalized === undefined || normalized.length === 0 ? undefined : normalized;
1011
+ }
1012
+ function readSourceGitSha(gameRoot) {
1013
+ const result = spawnSync('git', ['-C', gameRoot, 'rev-parse', '--short', 'HEAD'], {
1014
+ encoding: 'utf8',
1015
+ stdio: ['ignore', 'pipe', 'ignore'],
1016
+ });
1017
+ if (result.error !== undefined || result.status !== 0) {
1018
+ return 'uncommitted';
1019
+ }
1020
+ return result.stdout.trim() || 'uncommitted';
1021
+ }
1022
+ function readGamePackageVersion(gameRoot) {
1023
+ const packageFile = path.join(gameRoot, 'package.json');
1024
+ if (!existsSync(packageFile)) {
1025
+ return undefined;
1026
+ }
1027
+ const packageJson = readJsonForCli(packageFile);
1028
+ if (typeof packageJson !== 'object' || packageJson === null || Array.isArray(packageJson)) {
1029
+ return undefined;
1030
+ }
1031
+ return readOptionalString(packageJson.version);
1032
+ }
967
1033
  function preparePlatformTargetsFile(input) {
968
1034
  const parsed = readJsonForCli(input.targetsFile);
969
1035
  const gameRoot = path.dirname(input.targetsFile);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/cli",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "Gunshi CLI for mpgd-kit starter generation and target workflow orchestration.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -49,18 +49,18 @@
49
49
  "@types/node": "^24.0.0",
50
50
  "ttsc": "0.16.9",
51
51
  "typescript": "7.0.1-rc",
52
- "@mpgd/adapter-ait": "0.3.3",
53
- "@mpgd/adapter-browser": "0.3.2",
54
- "@mpgd/adapter-capacitor": "0.3.3",
55
- "@mpgd/analytics": "0.3.2",
56
- "@mpgd/adapter-devvit": "0.3.3",
57
- "@mpgd/bridge": "0.4.0",
58
- "@mpgd/catalog": "0.3.2",
52
+ "@mpgd/adapter-browser": "0.4.0",
53
+ "@mpgd/adapter-devvit": "0.4.0",
54
+ "@mpgd/adapter-capacitor": "0.4.0",
55
+ "@mpgd/analytics": "0.3.3",
56
+ "@mpgd/adapter-ait": "0.4.0",
57
+ "@mpgd/bridge": "0.5.0",
58
+ "@mpgd/catalog": "0.3.3",
59
+ "@mpgd/i18n": "0.4.1",
60
+ "@mpgd/game-services": "0.4.0",
59
61
  "@mpgd/phaser-assets": "0.4.0",
60
- "@mpgd/platform": "0.3.2",
61
- "@mpgd/target-config": "0.3.4",
62
- "@mpgd/i18n": "0.3.3",
63
- "@mpgd/game-services": "0.3.3"
62
+ "@mpgd/platform": "0.4.0",
63
+ "@mpgd/target-config": "0.5.0"
64
64
  },
65
65
  "main": "./dist/index.js",
66
66
  "types": "./dist/index.d.ts",
@@ -84,6 +84,9 @@ const viewport = resolveTargetViewportPlan({
84
84
  height: measured.height,
85
85
  source: measured.source,
86
86
  runtime: runtime.config.runtime,
87
+ orientationPolicy: {
88
+ mode: 'prefer-landscape',
89
+ },
87
90
  });
88
91
  ```
89
92
 
@@ -104,6 +107,15 @@ Orientation is measured from the container: height greater than width is
104
107
  be treated as an embedded webview, so do not assume desktop Reddit always means
105
108
  an expanded game surface.
106
109
 
110
+ Orientation policy is a runtime contract, not a guarantee that every shell can
111
+ hard-lock the device. Use `responsive` when both orientations are playable,
112
+ `prefer-landscape` or `prefer-portrait` when layout guidance should favor one
113
+ orientation, and `lock-landscape` or `lock-portrait` when the game should show a
114
+ soft rotate prompt on mismatch. The generated PWA manifest also declares
115
+ `"orientation": "landscape"` for installed web and Microsoft Store shells; keep
116
+ that manifest value aligned with the runtime policy if you make a portrait-first
117
+ game.
118
+
107
119
  ## Reddit Devvit
108
120
 
109
121
  This starter owns its Devvit app root in `apps/target-devvit`. That directory
@@ -12,6 +12,8 @@ pnpm --dir ../mpgd-kit mpgd target doctor --targets-file "$PWD/mpgd.targets.json
12
12
 
13
13
  For Apps in Toss changes, use the apps-in-toss MCP before implementation and
14
14
  keep SDK calls inside adapters or target wrappers.
15
+ Resolve identity and launch intents during bootstrap, treat inbound share data as
16
+ untrusted, and keep notification delivery on the server.
15
17
 
16
18
  For Reddit Devvit changes, keep Devvit SDK calls inside the target wrapper and
17
19
  continue to expose game-facing behavior through `PlatformGateway`. The Devvit
@@ -21,3 +23,6 @@ after login before upload or playtest.
21
23
  For Microsoft Store changes, keep the first pass as a PWA/web target that uses
22
24
  the browser adapter. Add a dedicated Store commerce adapter only when wiring
23
25
  Digital Goods API and Payment Request through backend ledger verification.
26
+
27
+ Verify the first screen reports the viewport orientation policy, and treat
28
+ locked orientation modes as soft prompts instead of unsafe WebView hard locks.
@@ -9,6 +9,8 @@ Boundaries:
9
9
  - Purchases, rewarded ad grants, and leaderboard records should go through
10
10
  backend ledger APIs before mutating durable game save state.
11
11
  - Platform SDK imports belong in adapters or target wrappers.
12
+ - Orientation policy should be chosen before adding resize behavior; treat
13
+ locked modes as soft prompts unless a platform adapter supports hard locks.
12
14
 
13
15
  Useful commands:
14
16
 
@@ -25,6 +25,14 @@
25
25
  "id": "runtime.platform.gateway",
26
26
  "owner": "src/platform/installPlatform.ts"
27
27
  },
28
+ {
29
+ "id": "platform.gateway.bootstrap-intents",
30
+ "owner": "src/main.ts"
31
+ },
32
+ {
33
+ "id": "runtime.viewport.orientation-policy",
34
+ "owner": "src/main.ts"
35
+ },
28
36
  {
29
37
  "id": "platform.devvit.app-root",
30
38
  "owner": "apps/target-devvit"
@@ -168,7 +168,7 @@ async function handleBridgeRequest(input: BridgeRequest): Promise<BridgeResponse
168
168
  nativeLeaderboard: true,
169
169
  achievements: false,
170
170
  cloudSave: true,
171
- socialShare: true,
171
+ socialShare: false,
172
172
  haptics: false,
173
173
  localizedContent: true,
174
174
  });
@@ -186,6 +186,51 @@ async function handleBridgeRequest(input: BridgeRequest): Promise<BridgeResponse
186
186
  });
187
187
  }
188
188
 
189
+ case 'identity.getSession': {
190
+ const playerId = currentPlayerId();
191
+
192
+ return ok(
193
+ input,
194
+ playerId === undefined
195
+ ? {
196
+ identityLevel: 'guest',
197
+ trustLevel: 'local',
198
+ }
199
+ : {
200
+ identityLevel: 'authenticated',
201
+ playerId,
202
+ trustLevel: 'server-verified',
203
+ },
204
+ );
205
+ }
206
+
207
+ case 'identity.requestUpgrade': {
208
+ const authenticated = currentPlayerId() !== undefined;
209
+
210
+ return ok(input, {
211
+ status: authenticated ? 'completed' : 'unavailable',
212
+ reloadExpected: false,
213
+ });
214
+ }
215
+
216
+ case 'presentation.getLaunchIntent':
217
+ return ok(input, { entry: 'home' });
218
+
219
+ case 'presentation.requestGameSurface':
220
+ return ok(input, 'unavailable');
221
+
222
+ case 'share.share':
223
+ return ok(input, { status: 'unavailable' });
224
+
225
+ case 'share.readInboundShare':
226
+ return ok(input, null);
227
+
228
+ case 'notifications.getStatus':
229
+ return ok(input, 'approval-required');
230
+
231
+ case 'notifications.requestSubscription':
232
+ return ok(input, 'unavailable');
233
+
189
234
  case 'commerce.getProducts':
190
235
  case 'commerce.getEntitlements':
191
236
  return ok(input, []);
@@ -22,6 +22,7 @@ export default defineConfig({
22
22
  ssr: 'src/server/index.ts',
23
23
  outDir: 'dist/server',
24
24
  target: 'node22',
25
+ minify: 'esbuild',
25
26
  sourcemap: false,
26
27
  emptyOutDir: true,
27
28
  rollupOptions: {
@@ -29,7 +30,7 @@ export default defineConfig({
29
30
  output: {
30
31
  format: 'cjs',
31
32
  entryFileNames: 'index.cjs',
32
- inlineDynamicImports: true,
33
+ codeSplitting: false,
33
34
  },
34
35
  },
35
36
  },
@@ -4,6 +4,7 @@ declare const __APP_TARGET__: string;
4
4
  declare const __MPGD_CONFIG_TARGET__: string;
5
5
  declare const __APP_VERSION__: string;
6
6
  declare const __BUILD_ID__: string;
7
+ declare const __SOURCE_GIT_SHA__: string;
7
8
  declare const __DEBUG_BUILD__: boolean;
8
9
 
9
10
  interface ImportMetaEnv {
@@ -4,6 +4,8 @@ export type GameMessageKey =
4
4
  | 'title'
5
5
  | 'target'
6
6
  | 'viewport'
7
+ | 'orientationPolicy'
8
+ | 'orientationMismatch'
7
9
  | 'backend'
8
10
  | 'features'
9
11
  | 'featuresNone'
@@ -21,6 +23,8 @@ const messages = {
21
23
  title: '__GAME_TITLE__',
22
24
  target: 'Target: {target}',
23
25
  viewport: 'Viewport: {sizeClass} {orientation} - controls {controls}',
26
+ orientationPolicy: 'Orientation policy: {mode}',
27
+ orientationMismatch: 'Rotate to {orientation} for {mode}',
24
28
  backend: 'Game Services: {mode}',
25
29
  features: 'Features: {features}',
26
30
  featuresNone: 'none',
@@ -37,6 +41,8 @@ const messages = {
37
41
  title: '__GAME_TITLE__',
38
42
  target: '타깃: {target}',
39
43
  viewport: '뷰포트: {sizeClass} {orientation} - 컨트롤 {controls}',
44
+ orientationPolicy: '방향 정책: {mode}',
45
+ orientationMismatch: '{mode}: {orientation} 방향으로 회전',
40
46
  backend: 'Game Services: {mode}',
41
47
  features: '기능: {features}',
42
48
  featuresNone: '없음',
@@ -2,7 +2,11 @@ import './styles.css';
2
2
 
3
3
  import { createAnalyticsReporter, createBufferedAnalyticsSink } from '@mpgd/analytics';
4
4
  import { resolveMpgdLocale, type Locale } from '@mpgd/i18n';
5
- import { resolveTargetViewportPlan } from '@mpgd/target-config';
5
+ import type { IdentitySession, LaunchIntent, PlatformGateway } from '@mpgd/platform';
6
+ import {
7
+ resolveTargetViewportPlan,
8
+ type TargetViewportOrientationPolicy,
9
+ } from '@mpgd/target-config';
6
10
 
7
11
  import { t } from './i18n/messages';
8
12
  import { createClientId } from './runtime/id';
@@ -25,15 +29,23 @@ async function bootstrap(): Promise<void> {
25
29
  const runtimeConfig = detectRuntime();
26
30
  const platform = await installPlatform(runtimeConfig);
27
31
  const runtime = await platform.getTargetRuntime();
32
+ const orientationPolicy = {
33
+ mode: 'prefer-landscape',
34
+ } as const satisfies TargetViewportOrientationPolicy;
28
35
  const viewport = resolveTargetViewportPlan({
29
36
  ...measureGameViewport(),
30
37
  runtime: runtime.config.runtime,
38
+ orientationPolicy,
31
39
  });
32
40
  const player =
33
41
  (await platform.identity.getPlayer()) ?? {
34
42
  playerId: 'local-player',
35
43
  displayName: 'Local Player',
36
44
  };
45
+ const [identitySession, launchIntent] = await Promise.all([
46
+ resolveIdentitySession(platform, player.playerId),
47
+ resolveLaunchIntent(platform),
48
+ ]);
37
49
  locale = resolveMpgdLocale(runtime.capabilities);
38
50
  const analyticsSink = createBufferedAnalyticsSink();
39
51
  const analytics = createAnalyticsReporter({
@@ -43,7 +55,7 @@ async function bootstrap(): Promise<void> {
43
55
  });
44
56
  const gameServices = createStarterGameServices({
45
57
  gateway: platform,
46
- playerId: player.playerId,
58
+ playerId: identitySession.playerId ?? player.playerId,
47
59
  });
48
60
 
49
61
  await analytics.track({
@@ -61,6 +73,8 @@ async function bootstrap(): Promise<void> {
61
73
  runtime,
62
74
  viewport,
63
75
  player,
76
+ identitySession,
77
+ launchIntent,
64
78
  locale,
65
79
  gameServices,
66
80
  analytics,
@@ -73,6 +87,35 @@ async function bootstrap(): Promise<void> {
73
87
  }
74
88
  }
75
89
 
90
+ async function resolveIdentitySession(
91
+ platform: PlatformGateway,
92
+ fallbackPlayerId: string,
93
+ ): Promise<IdentitySession> {
94
+ try {
95
+ return (await platform.identity.getSession?.()) ?? createGuestSession(fallbackPlayerId);
96
+ } catch (error) {
97
+ console.warn('[platform] identity session unavailable; using guest fallback.', error);
98
+ return createGuestSession(fallbackPlayerId);
99
+ }
100
+ }
101
+
102
+ async function resolveLaunchIntent(platform: PlatformGateway): Promise<LaunchIntent> {
103
+ try {
104
+ return (await platform.presentation?.getLaunchIntent()) ?? { entry: 'home' };
105
+ } catch (error) {
106
+ console.warn('[platform] launch intent unavailable; using home fallback.', error);
107
+ return { entry: 'home' };
108
+ }
109
+ }
110
+
111
+ function createGuestSession(playerId: string): IdentitySession {
112
+ return {
113
+ identityLevel: 'guest',
114
+ playerId,
115
+ trustLevel: 'local',
116
+ };
117
+ }
118
+
76
119
  function renderBootstrapError(error: unknown, locale: Locale): void {
77
120
  const message = error instanceof Error ? error.message : String(error);
78
121
  const root = document.querySelector<HTMLDivElement>('#game');
@@ -1,6 +1,6 @@
1
1
  import type { AnalyticsReporter, BufferedAnalyticsSink } from '@mpgd/analytics';
2
2
  import type { Locale } from '@mpgd/i18n';
3
- import type { PlayerIdentity } from '@mpgd/platform';
3
+ import type { IdentitySession, LaunchIntent, PlayerIdentity } from '@mpgd/platform';
4
4
  import type {
5
5
  TargetConfiguredGateway,
6
6
  TargetRuntimeSnapshot,
@@ -16,6 +16,8 @@ export interface StarterContext {
16
16
  readonly runtime: TargetRuntimeSnapshot;
17
17
  readonly viewport: TargetViewportPlan;
18
18
  readonly player: PlayerIdentity;
19
+ readonly identitySession: IdentitySession;
20
+ readonly launchIntent: LaunchIntent;
19
21
  readonly locale: Locale;
20
22
  readonly gameServices: StarterGameServices;
21
23
  readonly analytics: AnalyticsReporter;
@@ -21,6 +21,16 @@ export class LobbyScene extends Phaser.Scene {
21
21
  .filter((feature) => feature.enabled)
22
22
  .map((feature) => feature.feature)
23
23
  .join(', ') || t(locale, 'featuresNone');
24
+ const orientation = context.viewport.orientation;
25
+ const orientationText =
26
+ orientation.shouldShowRotatePrompt && orientation.preferredOrientation !== undefined
27
+ ? t(locale, 'orientationMismatch', {
28
+ mode: orientation.mode,
29
+ orientation: orientation.preferredOrientation,
30
+ })
31
+ : t(locale, 'orientationPolicy', {
32
+ mode: orientation.mode,
33
+ });
24
34
 
25
35
  this.add
26
36
  .text(480, 106, t(locale, 'title'), {
@@ -61,14 +71,21 @@ export class LobbyScene extends Phaser.Scene {
61
71
  )
62
72
  .setOrigin(0.5);
63
73
  this.add
64
- .text(480, 300, t(locale, 'backend', { mode: context.gameServices.mode }), {
74
+ .text(480, 300, orientationText, {
75
+ color: orientation.shouldShowRotatePrompt ? '#f59e0b' : '#d6dee8',
76
+ fontFamily: 'Arial, sans-serif',
77
+ fontSize: '18px',
78
+ })
79
+ .setOrigin(0.5);
80
+ this.add
81
+ .text(480, 342, t(locale, 'backend', { mode: context.gameServices.mode }), {
65
82
  color: context.gameServices.mode === 'disabled' ? '#f59e0b' : '#2dd4bf',
66
83
  fontFamily: 'Arial, sans-serif',
67
84
  fontSize: '18px',
68
85
  })
69
86
  .setOrigin(0.5);
70
87
  this.add
71
- .text(480, 342, t(locale, 'tapToStart'), {
88
+ .text(480, 384, t(locale, 'tapToStart'), {
72
89
  color: '#ffffff',
73
90
  fontFamily: 'Arial, sans-serif',
74
91
  fontSize: '22px',
@@ -1,3 +1,6 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+
1
4
  import ttsc from '@ttsc/unplugin/vite';
2
5
  import { defineConfig } from 'vite';
3
6
 
@@ -12,11 +15,15 @@ export default defineConfig(({ mode }) => {
12
15
  plugins: false,
13
16
  }),
14
17
  ],
18
+ resolve: {
19
+ alias: createCatalogAliases(),
20
+ },
15
21
  define: {
16
22
  __APP_TARGET__: JSON.stringify(process.env.APP_TARGET ?? 'browser'),
17
23
  __MPGD_CONFIG_TARGET__: JSON.stringify(process.env.MPGD_CONFIG_TARGET ?? ''),
18
24
  __APP_VERSION__: JSON.stringify(process.env.APP_VERSION ?? '0.0.0-dev'),
19
25
  __BUILD_ID__: JSON.stringify(process.env.BUILD_ID ?? 'local'),
26
+ __SOURCE_GIT_SHA__: JSON.stringify(process.env.MPGD_SOURCE_GIT_SHA ?? 'uncommitted'),
20
27
  __DEBUG_BUILD__: JSON.stringify(!isProduction),
21
28
  },
22
29
  build: {
@@ -35,3 +42,53 @@ export default defineConfig(({ mode }) => {
35
42
  },
36
43
  };
37
44
  });
45
+
46
+ function createCatalogAliases(): Record<string, string> {
47
+ const productCatalogFile = readConfiguredPath(process.env.MPGD_PRODUCT_CATALOG_FILE);
48
+ const adPlacementsFile = readConfiguredPath(process.env.MPGD_AD_PLACEMENTS_FILE);
49
+
50
+ if ((productCatalogFile === undefined) !== (adPlacementsFile === undefined)) {
51
+ throw new Error(
52
+ 'MPGD_PRODUCT_CATALOG_FILE and MPGD_AD_PLACEMENTS_FILE must be configured together.',
53
+ );
54
+ }
55
+
56
+ if (productCatalogFile === undefined || adPlacementsFile === undefined) {
57
+ return {};
58
+ }
59
+
60
+ return {
61
+ '@mpgd/catalog/catalog.json': resolveCatalogPath(productCatalogFile, adPlacementsFile),
62
+ '@mpgd/catalog/placements.json': resolveCatalogPath(adPlacementsFile, productCatalogFile),
63
+ };
64
+ }
65
+
66
+ function readConfiguredPath(value: string | undefined): string | undefined {
67
+ const normalized = value?.trim();
68
+ return normalized === undefined || normalized.length === 0 ? undefined : normalized;
69
+ }
70
+
71
+ function resolveCatalogPath(path: string, pairedPath: string): string {
72
+ return resolve(resolveCatalogBaseDir(path, pairedPath), path);
73
+ }
74
+
75
+ function resolveCatalogBaseDir(path: string, pairedPath: string): string {
76
+ const fallbackBaseDir = process.cwd();
77
+ const candidates = [
78
+ fallbackBaseDir,
79
+ readConfiguredPath(process.env.INIT_CWD),
80
+ readConfiguredPath(process.env.PWD),
81
+ ];
82
+
83
+ for (const candidate of candidates) {
84
+ if (
85
+ candidate !== undefined
86
+ && existsSync(resolve(candidate, path))
87
+ && existsSync(resolve(candidate, pairedPath))
88
+ ) {
89
+ return candidate;
90
+ }
91
+ }
92
+
93
+ return fallbackBaseDir;
94
+ }