@microsoft/power-apps-native-host 0.4.0 → 0.5.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.
Files changed (107) hide show
  1. package/README.md +60 -0
  2. package/compatibility/current.json +1 -1
  3. package/lib/NOTICE.txt +2957 -371
  4. package/lib/commonjs/auth/AuthContext.js +49 -22
  5. package/lib/commonjs/auth/targetAuthorization.js +53 -0
  6. package/lib/commonjs/bridge/NativePowerAppsBridge.js +33 -2
  7. package/lib/commonjs/bridge/PowerPagesBridge.js +1 -3
  8. package/lib/commonjs/bridge/setupNativeHost.js +6 -0
  9. package/lib/commonjs/config/expoConfig.js +14 -0
  10. package/lib/commonjs/config/runtimeTenantMode.js +16 -0
  11. package/lib/commonjs/config/runtimeTenantMode.web.js +9 -0
  12. package/lib/commonjs/config/tenantMode.js +68 -0
  13. package/lib/commonjs/connections/PowerPlatformConnectionApi.js +24 -27
  14. package/lib/commonjs/connections/connectionMetadata.js +2 -2
  15. package/lib/commonjs/connections/resolveConnections.js +8 -3
  16. package/lib/commonjs/connections/types.js +9 -2
  17. package/lib/commonjs/connections/useConnectionRefs.js +19 -10
  18. package/lib/commonjs/index.js +39 -0
  19. package/lib/commonjs/platform/CanvasAppResolution.js +33 -0
  20. package/lib/commonjs/platform/CanvasAppResolutionApi.js +69 -0
  21. package/lib/commonjs/platform/DataverseGlobalDiscoveryApi.js +50 -13
  22. package/lib/commonjs/platform/GlobalDiscovery.js +40 -1
  23. package/lib/commonjs/provider/PowerAppsHostProvider.js +47 -8
  24. package/lib/commonjs/provider/PowerAppsProvider.js +172 -6
  25. package/lib/commonjs/target/DefaultPowerAppsExperience.js +564 -0
  26. package/lib/commonjs/target/PowerAppsExperience.js +41 -0
  27. package/lib/commonjs/target/PowerAppsTargetBootstrap.js +534 -0
  28. package/lib/commonjs/target/errors.js +24 -0
  29. package/lib/commonjs/target/types.js +1 -0
  30. package/lib/module/auth/AuthContext.js +49 -22
  31. package/lib/module/auth/targetAuthorization.js +48 -0
  32. package/lib/module/bridge/NativePowerAppsBridge.js +33 -2
  33. package/lib/module/bridge/PowerPagesBridge.js +1 -3
  34. package/lib/module/bridge/setupNativeHost.js +5 -0
  35. package/lib/module/config/expoConfig.js +14 -0
  36. package/lib/module/config/runtimeTenantMode.js +12 -0
  37. package/lib/module/config/runtimeTenantMode.web.js +5 -0
  38. package/lib/module/config/tenantMode.js +61 -0
  39. package/lib/module/connections/PowerPlatformConnectionApi.js +24 -27
  40. package/lib/module/connections/connectionMetadata.js +2 -2
  41. package/lib/module/connections/resolveConnections.js +9 -4
  42. package/lib/module/connections/types.js +9 -2
  43. package/lib/module/connections/useConnectionRefs.js +20 -11
  44. package/lib/module/index.js +4 -1
  45. package/lib/module/platform/CanvasAppResolution.js +27 -0
  46. package/lib/module/platform/CanvasAppResolutionApi.js +65 -0
  47. package/lib/module/platform/DataverseGlobalDiscoveryApi.js +49 -14
  48. package/lib/module/platform/GlobalDiscovery.js +37 -1
  49. package/lib/module/provider/PowerAppsHostProvider.js +47 -8
  50. package/lib/module/provider/PowerAppsProvider.js +176 -10
  51. package/lib/module/target/DefaultPowerAppsExperience.js +559 -0
  52. package/lib/module/target/PowerAppsExperience.js +34 -0
  53. package/lib/module/target/PowerAppsTargetBootstrap.js +529 -0
  54. package/lib/module/target/errors.js +19 -0
  55. package/lib/module/target/types.js +1 -0
  56. package/lib/typescript/commonjs/auth/AuthContext.d.ts +11 -2
  57. package/lib/typescript/commonjs/auth/targetAuthorization.d.ts +15 -0
  58. package/lib/typescript/commonjs/bridge/NativePowerAppsBridge.d.ts +6 -0
  59. package/lib/typescript/commonjs/bridge/setupNativeHost.d.ts +2 -0
  60. package/lib/typescript/commonjs/config/runtimeTenantMode.d.ts +3 -0
  61. package/lib/typescript/commonjs/config/runtimeTenantMode.web.d.ts +3 -0
  62. package/lib/typescript/commonjs/config/tenantMode.d.ts +14 -0
  63. package/lib/typescript/commonjs/connections/PowerPlatformConnectionApi.d.ts +5 -2
  64. package/lib/typescript/commonjs/connections/connectionMetadata.d.ts +1 -1
  65. package/lib/typescript/commonjs/connections/types.d.ts +7 -1
  66. package/lib/typescript/commonjs/connections/useConnectionRefs.d.ts +11 -2
  67. package/lib/typescript/commonjs/index.d.ts +5 -1
  68. package/lib/typescript/commonjs/platform/CanvasAppResolution.d.ts +4 -0
  69. package/lib/typescript/commonjs/platform/CanvasAppResolutionApi.d.ts +6 -0
  70. package/lib/typescript/commonjs/platform/DataverseGlobalDiscoveryApi.d.ts +12 -2
  71. package/lib/typescript/commonjs/platform/GlobalDiscovery.d.ts +12 -0
  72. package/lib/typescript/commonjs/provider/PowerAppsHostProvider.d.ts +9 -1
  73. package/lib/typescript/commonjs/provider/PowerAppsProvider.d.ts +13 -15
  74. package/lib/typescript/commonjs/target/DefaultPowerAppsExperience.d.ts +6 -0
  75. package/lib/typescript/commonjs/target/PowerAppsExperience.d.ts +61 -0
  76. package/lib/typescript/commonjs/target/PowerAppsTargetBootstrap.d.ts +15 -0
  77. package/lib/typescript/commonjs/target/errors.d.ts +13 -0
  78. package/lib/typescript/commonjs/target/types.d.ts +34 -0
  79. package/lib/typescript/module/auth/AuthContext.d.ts +11 -2
  80. package/lib/typescript/module/auth/targetAuthorization.d.ts +15 -0
  81. package/lib/typescript/module/bridge/NativePowerAppsBridge.d.ts +6 -0
  82. package/lib/typescript/module/bridge/setupNativeHost.d.ts +2 -0
  83. package/lib/typescript/module/config/runtimeTenantMode.d.ts +3 -0
  84. package/lib/typescript/module/config/runtimeTenantMode.web.d.ts +3 -0
  85. package/lib/typescript/module/config/tenantMode.d.ts +14 -0
  86. package/lib/typescript/module/connections/PowerPlatformConnectionApi.d.ts +5 -2
  87. package/lib/typescript/module/connections/connectionMetadata.d.ts +1 -1
  88. package/lib/typescript/module/connections/types.d.ts +7 -1
  89. package/lib/typescript/module/connections/useConnectionRefs.d.ts +11 -2
  90. package/lib/typescript/module/index.d.ts +5 -1
  91. package/lib/typescript/module/platform/CanvasAppResolution.d.ts +4 -0
  92. package/lib/typescript/module/platform/CanvasAppResolutionApi.d.ts +6 -0
  93. package/lib/typescript/module/platform/DataverseGlobalDiscoveryApi.d.ts +12 -2
  94. package/lib/typescript/module/platform/GlobalDiscovery.d.ts +12 -0
  95. package/lib/typescript/module/provider/PowerAppsHostProvider.d.ts +9 -1
  96. package/lib/typescript/module/provider/PowerAppsProvider.d.ts +13 -15
  97. package/lib/typescript/module/target/DefaultPowerAppsExperience.d.ts +6 -0
  98. package/lib/typescript/module/target/PowerAppsExperience.d.ts +61 -0
  99. package/lib/typescript/module/target/PowerAppsTargetBootstrap.d.ts +15 -0
  100. package/lib/typescript/module/target/errors.d.ts +13 -0
  101. package/lib/typescript/module/target/types.d.ts +34 -0
  102. package/package.json +12 -5
  103. package/scripts/build-codegen-package.js +34 -3
  104. package/scripts/dev.js +405 -0
  105. package/scripts/upgrade-authoring.js +85 -28
  106. package/scripts/wrap.js +42 -5
  107. package/src/config/generated-config.d.ts +21 -0
package/scripts/dev.js ADDED
@@ -0,0 +1,405 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { spawn } = require('node:child_process');
5
+ const { createConnection, createServer, isIP } = require('node:net');
6
+ const path = require('node:path');
7
+ const { setTimeout: delay } = require('node:timers/promises');
8
+
9
+ const DEFAULT_PORT = 8081;
10
+
11
+ function parseArgs(args) {
12
+ const expoArgs = [];
13
+ let tunnel = false;
14
+ let tenantId;
15
+
16
+ for (let index = 0; index < args.length; index += 1) {
17
+ const argument = args[index];
18
+ if (argument === '--tunnel') {
19
+ tunnel = true;
20
+ } else if (argument === '--tunnel-tenant') {
21
+ tenantId = args[index + 1];
22
+ if (!tenantId || tenantId.startsWith('--')) {
23
+ throw new Error('--tunnel-tenant requires an Entra tenant ID.');
24
+ }
25
+ index += 1;
26
+ } else {
27
+ expoArgs.push(argument);
28
+ }
29
+ }
30
+
31
+ const portIndex = expoArgs.findIndex(argument => argument === '--port' || argument === '-p');
32
+ const inlinePort = expoArgs.find(argument => argument.startsWith('--port='));
33
+ const port = portIndex >= 0
34
+ ? Number(expoArgs[portIndex + 1])
35
+ : inlinePort
36
+ ? Number(inlinePort.slice('--port='.length))
37
+ : DEFAULT_PORT;
38
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
39
+ throw new Error('Expo port must be an integer between 1 and 65535.');
40
+ }
41
+ if (tenantId && !tunnel) {
42
+ throw new Error('--tunnel-tenant requires --tunnel.');
43
+ }
44
+
45
+ return { expoArgs, port, tenantId, tunnel };
46
+ }
47
+
48
+ function buildLaunchUrl(tunnelUrl, connectToken) {
49
+ const endpoint = new URL(tunnelUrl);
50
+ if (endpoint.protocol !== 'https:' || !endpoint.hostname.endsWith('.devtunnels.ms')) {
51
+ throw new Error(`Unexpected Dev Tunnels endpoint: ${tunnelUrl}`);
52
+ }
53
+ if (!connectToken) {
54
+ throw new Error('Dev Tunnels did not return a connect token.');
55
+ }
56
+ const launchUrl = new URL('powerapps-dev-player://launch');
57
+ launchUrl.searchParams.set('url', endpoint.toString());
58
+ launchUrl.searchParams.set('tunnelToken', connectToken);
59
+ return launchUrl.toString();
60
+ }
61
+
62
+ function selectTunnelUrl(portForwardingUris) {
63
+ const tunnelUrl = portForwardingUris?.find(uri => {
64
+ try {
65
+ const endpoint = new URL(uri);
66
+ return endpoint.protocol === 'https:'
67
+ && (!endpoint.port || endpoint.port === '443')
68
+ && endpoint.hostname.endsWith('.devtunnels.ms');
69
+ } catch {
70
+ return false;
71
+ }
72
+ });
73
+ if (!tunnelUrl) {
74
+ throw new Error('Dev Tunnels did not return an HTTPS forwarding URL on port 443.');
75
+ }
76
+ return tunnelUrl;
77
+ }
78
+
79
+ function usesLanHost(expoArgs) {
80
+ if (expoArgs.includes('--localhost') || expoArgs.includes('--offline')) return false;
81
+ if (expoArgs.includes('--lan')) return true;
82
+
83
+ const hostIndex = expoArgs.indexOf('--host');
84
+ const inlineHost = expoArgs.find(argument => argument.startsWith('--host='));
85
+ const host = hostIndex >= 0
86
+ ? expoArgs[hostIndex + 1]
87
+ : inlineHost?.slice('--host='.length);
88
+ return host ? host === 'lan' : true;
89
+ }
90
+
91
+ async function resolveLanUrl(port, resolveNetwork) {
92
+ const lanNetwork = resolveNetwork ?? require('lan-network').lanNetwork;
93
+ const { address } = await lanNetwork();
94
+ const addressFamily = isIP(address);
95
+ if (!addressFamily || address === '127.0.0.1' || address === '::1') {
96
+ throw new Error('Could not determine a LAN address. Use --tunnel or pass --host localhost.');
97
+ }
98
+ const hostname = addressFamily === 6 ? `[${address}]` : address;
99
+ return `http://${hostname}:${port}/`;
100
+ }
101
+
102
+ function startExpo(projectRoot, expoArgs, environment = {}, spawnProcess = spawn) {
103
+ const expoCli = require.resolve('expo/bin/cli', { paths: [projectRoot] });
104
+ return spawnProcess(process.execPath, [expoCli, 'start', ...expoArgs], {
105
+ cwd: projectRoot,
106
+ env: { ...process.env, ...environment },
107
+ stdio: 'inherit',
108
+ });
109
+ }
110
+
111
+ function hasExplicitPort(expoArgs) {
112
+ return expoArgs.some(argument => argument === '--port' || argument === '-p' || argument.startsWith('--port='));
113
+ }
114
+
115
+ function isPortAvailable(port) {
116
+ return new Promise(resolve => {
117
+ const server = createServer();
118
+ server.once('error', () => resolve(false));
119
+ server.listen(port, () => server.close(() => resolve(true)));
120
+ });
121
+ }
122
+
123
+ async function resolveMetroOptions(options, checkPort = isPortAvailable) {
124
+ if (hasExplicitPort(options.expoArgs)) {
125
+ if (!await checkPort(options.port)) {
126
+ throw new Error(`Expo port ${options.port} is already in use. Choose another port with --port.`);
127
+ }
128
+ return { expoArgs: options.expoArgs, port: options.port };
129
+ }
130
+
131
+ for (let port = options.port; port <= 65535; port += 1) {
132
+ if (await checkPort(port)) {
133
+ return { expoArgs: [...options.expoArgs, '--port', String(port)], port };
134
+ }
135
+ }
136
+ throw new Error(`No available Expo port was found at or above ${options.port}.`);
137
+ }
138
+
139
+ async function waitForMetro(port, expo, timeoutMs = 60_000) {
140
+ const deadline = Date.now() + timeoutMs;
141
+ while (Date.now() < deadline) {
142
+ if (expo.exitCode !== null) {
143
+ throw new Error(`Expo exited before Metro started on port ${port}.`);
144
+ }
145
+ const connected = await new Promise(resolve => {
146
+ const socket = createConnection({ host: '127.0.0.1', port });
147
+ socket.once('connect', () => {
148
+ socket.destroy();
149
+ resolve(true);
150
+ });
151
+ socket.once('error', () => resolve(false));
152
+ });
153
+ if (connected) return;
154
+ await delay(100);
155
+ }
156
+ throw new Error(`Timed out waiting for Metro to start on port ${port}.`);
157
+ }
158
+
159
+ async function startExpoThenTunnel(startMetro, waitUntilReady, createTunnel) {
160
+ const expo = startMetro();
161
+ try {
162
+ await waitUntilReady(expo);
163
+ return { expo, tunnelState: await createTunnel() };
164
+ } catch (error) {
165
+ expo.kill?.('SIGTERM');
166
+ throw error;
167
+ }
168
+ }
169
+
170
+ async function startLan(projectRoot, options, resolveUrl = resolveLanUrl, spawnProcess = spawn) {
171
+ const qrcode = require('qrcode-terminal');
172
+ const lanUrl = await resolveUrl(options.port);
173
+ console.log(`\nLAN development server: ${lanUrl}`);
174
+ console.log('Scan this QR code with Power Apps Dev Player:');
175
+ qrcode.generate(lanUrl, { small: true });
176
+ console.log();
177
+ return startExpo(projectRoot, options.expoArgs, {
178
+ EXPO_NO_QR_CODE: '1',
179
+ EXPO_PACKAGER_PROXY_URL: lanUrl,
180
+ }, spawnProcess);
181
+ }
182
+
183
+ function isTunnelLimitError(error) {
184
+ if (!error) return false;
185
+ const text = [
186
+ error.message,
187
+ error.response?.data?.detail,
188
+ error.response?.data?.title,
189
+ typeof error.response?.data === 'string' ? error.response.data : '',
190
+ ]
191
+ .filter(Boolean)
192
+ .join(' ')
193
+ .toLowerCase();
194
+
195
+ return /tunnelsperuser(?:percluster)?/.test(text);
196
+ }
197
+
198
+ function getTunnelCreatedTime(tunnel) {
199
+ if (!tunnel?.created) return 0;
200
+ const time = new Date(tunnel.created).getTime();
201
+ return Number.isNaN(time) ? 0 : time;
202
+ }
203
+
204
+ function getTunnelHostConnectionCount(tunnel) {
205
+ const count = tunnel?.status?.hostConnectionCount;
206
+ if (typeof count === 'number') return count;
207
+ return typeof count?.current === 'number' ? count.current : null;
208
+ }
209
+
210
+ async function deleteOlderTunnel(managementClient, tunnels) {
211
+ if (!Array.isArray(tunnels) || tunnels.length === 0) {
212
+ return null;
213
+ }
214
+
215
+ const candidates = tunnels
216
+ .filter(t => Array.isArray(t.labels)
217
+ && t.labels.includes('power-apps-native')
218
+ && getTunnelHostConnectionCount(t) === 0)
219
+ .sort((a, b) => getTunnelCreatedTime(a) - getTunnelCreatedTime(b));
220
+
221
+ for (const candidate of candidates) {
222
+ try {
223
+ const result = await managementClient.deleteTunnel(candidate);
224
+ if (result === true) return candidate;
225
+ } catch (error) {
226
+ console.warn(
227
+ `[power-apps-dev] Failed to delete idle managed tunnel (${candidate.tunnelId || candidate.name || 'unnamed'}).`,
228
+ error,
229
+ );
230
+ }
231
+ }
232
+
233
+ return null;
234
+ }
235
+
236
+ async function createTunnelWithLimitHandling(managementClient, tunnelDefinition, options) {
237
+ try {
238
+ return await managementClient.createTunnel(tunnelDefinition, options);
239
+ } catch (error) {
240
+ if (!isTunnelLimitError(error) || !tunnelDefinition.clusterId) {
241
+ throw error;
242
+ }
243
+
244
+ let tunnels;
245
+ try {
246
+ tunnels = await managementClient.listTunnels(tunnelDefinition.clusterId);
247
+ } catch (listError) {
248
+ console.warn('[power-apps-dev] Failed to list tunnels during quota recovery.', listError);
249
+ throw error;
250
+ }
251
+ const deleted = await deleteOlderTunnel(managementClient, tunnels);
252
+ if (!deleted) {
253
+ console.warn('[power-apps-dev] No idle managed tunnel was available; skipping tunnel deletion.');
254
+ throw error;
255
+ }
256
+
257
+ console.warn(
258
+ `[power-apps-dev] Dev Tunnel limit reached. Deleted older tunnel (${deleted.tunnelId || deleted.name || 'unnamed'}) to make room.`,
259
+ );
260
+
261
+ return await managementClient.createTunnel(tunnelDefinition, options);
262
+ }
263
+ }
264
+
265
+ async function startAuthenticatedTunnel(projectRoot, options) {
266
+ const { AzureCliCredential } = require('@azure/identity');
267
+ const {
268
+ TunnelAccessScopes,
269
+ TunnelProtocol,
270
+ TunnelServiceProperties,
271
+ } = require('@microsoft/dev-tunnels-contracts');
272
+ const {
273
+ ManagementApiVersions,
274
+ TunnelAccessTokenProperties,
275
+ TunnelManagementHttpClient,
276
+ } = require('@microsoft/dev-tunnels-management');
277
+ const { TunnelRelayTunnelHost } = require('@microsoft/dev-tunnels-connections');
278
+ const qrcode = require('qrcode-terminal');
279
+ const packageJson = require('../package.json');
280
+
281
+ const metroOptions = await resolveMetroOptions(options);
282
+ const credential = new AzureCliCredential(options.tenantId ? { tenantId: options.tenantId } : {});
283
+ const scope = `${TunnelServiceProperties.production.serviceAppId}/.default`;
284
+ const getAuthorization = async () => {
285
+ const result = await credential.getToken(scope);
286
+ if (!result?.token) {
287
+ throw new Error('Azure CLI did not return a Dev Tunnels access token. Run `az login` and try again.');
288
+ }
289
+ return `Bearer ${result.token}`;
290
+ };
291
+ const managementClient = new TunnelManagementHttpClient(
292
+ [{ name: packageJson.name, version: packageJson.version }],
293
+ ManagementApiVersions.Version20230927preview,
294
+ getAuthorization,
295
+ );
296
+
297
+ let tunnel;
298
+ let host;
299
+ let expo;
300
+ let cleaningUp = false;
301
+ const cleanup = async () => {
302
+ if (cleaningUp) return;
303
+ cleaningUp = true;
304
+ expo?.kill('SIGTERM');
305
+ await host?.dispose().catch(() => undefined);
306
+ if (tunnel) await managementClient.deleteTunnel(tunnel).catch(() => undefined);
307
+ await managementClient.dispose().catch(() => undefined);
308
+ };
309
+
310
+ try {
311
+ const started = await startExpoThenTunnel(
312
+ () => startExpo(projectRoot, metroOptions.expoArgs, { EXPO_NO_QR_CODE: '1' }),
313
+ child => waitForMetro(metroOptions.port, child),
314
+ async () => {
315
+ tunnel = await createTunnelWithLimitHandling(
316
+ managementClient,
317
+ { labels: ['power-apps-native', 'development'] },
318
+ { tokenScopes: [TunnelAccessScopes.Host] },
319
+ );
320
+ const tunnelPort = await managementClient.createTunnelPort(
321
+ tunnel,
322
+ { portNumber: metroOptions.port, protocol: TunnelProtocol.Http, isDefault: true },
323
+ { tokenScopes: [TunnelAccessScopes.Connect] },
324
+ );
325
+ tunnel.ports = [tunnelPort];
326
+ host = new TunnelRelayTunnelHost(managementClient);
327
+ await host.connect(tunnel);
328
+ return tunnelPort;
329
+ },
330
+ );
331
+ expo = started.expo;
332
+ const tunnelPort = started.tunnelState;
333
+
334
+ const tunnelUrl = selectTunnelUrl(tunnelPort.portForwardingUris);
335
+ const connectToken = TunnelAccessTokenProperties.getTunnelAccessToken(
336
+ tunnelPort,
337
+ TunnelAccessScopes.Connect,
338
+ );
339
+
340
+ const launchUrl = buildLaunchUrl(tunnelUrl, connectToken);
341
+ console.log(`\nAuthenticated Dev Tunnel: ${tunnelUrl}`);
342
+ console.log('Scan this QR code with Power Apps Dev Player:');
343
+ qrcode.generate(launchUrl, { small: true });
344
+ console.log('\nThe tunnel is private and will be deleted when this process exits.\n');
345
+
346
+ const exitCode = await new Promise((resolve, reject) => {
347
+ const signals = ['SIGINT', 'SIGTERM'];
348
+ const finish = result => {
349
+ for (const signal of signals) process.removeListener(signal, stop);
350
+ resolve(result);
351
+ };
352
+ const stop = () => {
353
+ expo.kill('SIGTERM');
354
+ finish(0);
355
+ };
356
+ for (const signal of signals) process.once(signal, stop);
357
+ expo.once('error', error => {
358
+ for (const signal of signals) process.removeListener(signal, stop);
359
+ reject(error);
360
+ });
361
+ expo.once('exit', code => finish(code ?? 1));
362
+ });
363
+ await cleanup();
364
+ return exitCode;
365
+ } catch (error) {
366
+ await cleanup();
367
+ throw error;
368
+ }
369
+ }
370
+
371
+ async function main() {
372
+ const options = parseArgs(process.argv.slice(2));
373
+ const projectRoot = process.cwd();
374
+ if (!options.tunnel) {
375
+ const expo = usesLanHost(options.expoArgs)
376
+ ? await startLan(projectRoot, options)
377
+ : startExpo(projectRoot, options.expoArgs);
378
+ expo.once('exit', code => process.exit(code ?? 1));
379
+ return;
380
+ }
381
+
382
+ process.exitCode = await startAuthenticatedTunnel(projectRoot, options);
383
+ }
384
+
385
+ if (require.main === module) {
386
+ main().catch(error => {
387
+ console.error(`[power-apps-dev] ${error instanceof Error ? error.message : String(error)}`);
388
+ process.exitCode = 1;
389
+ });
390
+ }
391
+
392
+ module.exports = {
393
+ buildLaunchUrl,
394
+ createTunnelWithLimitHandling,
395
+ deleteOlderTunnel,
396
+ isTunnelLimitError,
397
+ parseArgs,
398
+ resolveMetroOptions,
399
+ resolveLanUrl,
400
+ selectTunnelUrl,
401
+ startExpo,
402
+ startExpoThenTunnel,
403
+ startLan,
404
+ usesLanHost,
405
+ };
@@ -10,7 +10,7 @@ const { HOST_PACKAGE, TEMPLATE_VERSION_PATH, assertSafeFilePath, hashContent, is
10
10
 
11
11
  const AUTHORING_LOCK_PATH = 'packages/power-apps-native-host/compatibility/.migration-build.lock';
12
12
  const AUTHORING_JOURNAL_PATH = 'packages/power-apps-native-host/compatibility/.migration-build-journal.json';
13
- const TEMPLATE_PATH = 'templates/expo-app-standalone';
13
+ const TEMPLATE_PATH = 'packages/power-apps-native-template/template';
14
14
  const TSCONFIG_PATH = 'tsconfig.json';
15
15
  const GITIGNORE_PATH = '.gitignore';
16
16
  const GITIGNORE_START = '# POWER APPS MANAGED START - DO NOT REMOVE OR RENAME THE COMMENT';
@@ -18,10 +18,14 @@ const GITIGNORE_END = '# POWER APPS MANAGED END - DO NOT REMOVE OR RENAME THE CO
18
18
  const MANAGED_PACKAGE_FIELDS = ['main'];
19
19
  const PROFILE_PATH = 'packages/power-apps-native-host/compatibility/current.json';
20
20
  const TEMPLATE_BASELINE_TAG_PREFIX = 'standalone-template-migration-baseline-v';
21
- const LEGACY_TEMPLATE_BASELINE_TAG_PATTERN = /^template-migration-baseline-v\d+$/;
22
21
  const RELEASE_HOST_VERSION = '__RELEASE_HOST_VERSION__';
23
22
  const NATIVE_RUNTIME_FINGERPRINT_SCRIPT = 'scripts/repository/native-runtime-fingerprint.js';
24
23
  const NATIVE_RUNTIME_PLATFORMS = ['android', 'ios'];
24
+ const SCAFFOLD_ONLY_TEMPLATE_PATHS = new Set([
25
+ 'AGENTS.md',
26
+ 'CLAUDE.md',
27
+ '.github/copilot-instructions.md',
28
+ ]);
25
29
  const CUSTOMER_SECTIONS = [
26
30
  {
27
31
  name: 'customer app settings',
@@ -72,33 +76,84 @@ function inferBuildBaseTag(repositoryRoot) {
72
76
  if (!Number.isInteger(profile.targetTemplateVersion)) throw new Error('Current compatibility profile has an invalid targetTemplateVersion');
73
77
  const currentTag = templateBaselineTag(profile.targetTemplateVersion);
74
78
  if (git(repositoryRoot, ['show-ref', '--verify', '--quiet', `refs/tags/${currentTag}`], true) !== null) return currentTag;
75
- const legacyTags = git(repositoryRoot, ['tag', '--merged', 'HEAD', '--list', 'template-migration-baseline-v*', '--sort=-version:refname'])
76
- .trim().split('\n').filter(Boolean);
77
- const legacyTag = legacyTags.find(tag => {
78
- if (!LEGACY_TEMPLATE_BASELINE_TAG_PATTERN.test(tag)) return false;
79
- const base = resolveVersionTag(repositoryRoot, tag);
80
- return readTemplateState(repositoryRoot, base.commit)?.templateVersion === profile.targetTemplateVersion;
81
- });
82
- if (legacyTag) return legacyTag;
83
79
  throw new Error(`Could not infer template migration baseline. Expected ${currentTag}.`);
84
80
  }
85
81
 
86
82
  function getTemplateChanges(repositoryRoot, fromTag) {
87
83
  const base = resolveVersionTag(repositoryRoot, fromTag);
88
- const tracked = git(repositoryRoot, ['diff', '--no-renames', '--name-only', '--diff-filter=ACDMT', base.commit, '--', TEMPLATE_PATH])
89
- .trim().split('\n').filter(Boolean);
90
- const untracked = git(repositoryRoot, ['ls-files', '--others', '--exclude-standard', '--', TEMPLATE_PATH])
91
- .trim().split('\n').filter(Boolean);
92
- const paths = [...new Set([...tracked, ...untracked])].sort();
84
+ const basePaths = templatePathsAtCommit(repositoryRoot, base.commit);
85
+ const targetPaths = templatePathsAtWorktree(repositoryRoot);
86
+ const paths = [...new Set([...basePaths, ...targetPaths])]
87
+ .filter(relativePath => !isScaffoldOnlyTemplatePath(relativePath))
88
+ .filter(relativePath => {
89
+ const baseContent = readGitFile(repositoryRoot, base.commit, relativePath);
90
+ const targetContent = readTemplateFile(repositoryRoot, relativePath);
91
+ return !buffersEqual(baseContent, targetContent)
92
+ || normalizedFileMode(fileMode(repositoryRoot, base.commit, relativePath))
93
+ !== normalizedFileMode(fileMode(repositoryRoot, null, relativePath));
94
+ })
95
+ .sort();
93
96
  return {
94
97
  baseTag: base.tag,
95
98
  baseCommit: base.commit,
96
- files: paths.map(filePath => {
97
- return { path: filePath.slice(`${TEMPLATE_PATH}/`.length) };
98
- }),
99
+ files: paths.map(relativePath => ({ path: relativePath })),
99
100
  };
100
101
  }
101
102
 
103
+ function isScaffoldOnlyTemplatePath(relativePath) {
104
+ // Git uses '/', while a native Windows caller may supply '\'. Keep exact-path matching.
105
+ return SCAFFOLD_ONLY_TEMPLATE_PATHS.has(relativePath.replace(/\\/g, '/'));
106
+ }
107
+
108
+ function storedTemplatePath(relativePath) {
109
+ return relativePath === GITIGNORE_PATH ? 'gitignore' : relativePath;
110
+ }
111
+
112
+ function logicalTemplatePath(relativePath) {
113
+ return relativePath === 'gitignore' ? GITIGNORE_PATH : relativePath;
114
+ }
115
+
116
+ function templatePathsAtCommit(repositoryRoot, commit) {
117
+ const paths = git(repositoryRoot, ['ls-tree', '-r', '--name-only', commit, '--', TEMPLATE_PATH])
118
+ .trim().split('\n').filter(Boolean)
119
+ .map(filePath => filePath.slice(`${TEMPLATE_PATH}/`.length))
120
+ .map(logicalTemplatePath)
121
+ .sort();
122
+ if (!paths.includes(TEMPLATE_VERSION_PATH)) {
123
+ throw new Error(`Commit ${commit} does not contain the standalone template at ${TEMPLATE_PATH}`);
124
+ }
125
+ return paths;
126
+ }
127
+
128
+ function templatePathsAtWorktree(repositoryRoot) {
129
+ const tracked = git(repositoryRoot, ['ls-files', '--', TEMPLATE_PATH])
130
+ .trim().split('\n').filter(Boolean);
131
+ const untracked = git(repositoryRoot, ['ls-files', '--others', '--exclude-standard', '--', TEMPLATE_PATH])
132
+ .trim().split('\n').filter(Boolean);
133
+ return [...new Set([...tracked, ...untracked])]
134
+ .map(filePath => filePath.slice(`${TEMPLATE_PATH}/`.length))
135
+ .map(logicalTemplatePath)
136
+ .sort();
137
+ }
138
+
139
+ function readTemplateFile(repositoryRoot, relativePath) {
140
+ const filePath = path.join(repositoryRoot, TEMPLATE_PATH, storedTemplatePath(relativePath));
141
+ const stat = fs.lstatSync(filePath, { throwIfNoEntry: false });
142
+ if (!stat) return null;
143
+ // Compare link text, never follow a template link outside the canonical tree.
144
+ return stat.isSymbolicLink() ? Buffer.from(fs.readlinkSync(filePath)) : fs.readFileSync(filePath);
145
+ }
146
+
147
+ function buffersEqual(left, right) {
148
+ if (left === null || right === null) return left === right;
149
+ return left.equals(right);
150
+ }
151
+
152
+ function normalizedFileMode(mode) {
153
+ // Strip only the regular-file type prefix; retain symlink/submodule types.
154
+ return mode?.replace(/^100/, '000') || null;
155
+ }
156
+
102
157
  function assertNoCustomerOwnedChanges(files) {
103
158
  const protectedFiles = files.filter(file => isCustomerOwnedPath(file.path));
104
159
  if (protectedFiles.length) {
@@ -107,18 +162,22 @@ function assertNoCustomerOwnedChanges(files) {
107
162
  }
108
163
 
109
164
  function readGitFile(repositoryRoot, commit, relativeTemplatePath) {
110
- const result = childProcess.spawnSync('git', ['show', `${commit}:${TEMPLATE_PATH}/${relativeTemplatePath}`], { cwd: repositoryRoot, encoding: null });
165
+ const storedPath = storedTemplatePath(relativeTemplatePath);
166
+ const result = childProcess.spawnSync('git', ['show', `${commit}:${TEMPLATE_PATH}/${storedPath}`], { cwd: repositoryRoot, encoding: null });
111
167
  if (result.error) throw result.error;
112
168
  return result.status === 0 ? result.stdout : null;
113
169
  }
114
170
 
115
171
  function fileMode(repositoryRoot, commit, relativePath) {
116
172
  if (commit) {
117
- const output = git(repositoryRoot, ['ls-tree', commit, `${TEMPLATE_PATH}/${relativePath}`], true);
173
+ const storedPath = storedTemplatePath(relativePath);
174
+ const output = git(repositoryRoot, ['ls-tree', commit, `${TEMPLATE_PATH}/${storedPath}`], true);
118
175
  return output?.trim().split(/\s+/)[0] || null;
119
176
  }
120
- const absolutePath = path.join(repositoryRoot, TEMPLATE_PATH, relativePath);
121
- return fs.existsSync(absolutePath) ? (fs.statSync(absolutePath).mode & 0o777).toString(8).padStart(6, '0') : null;
177
+ const absolutePath = path.join(repositoryRoot, TEMPLATE_PATH, storedTemplatePath(relativePath));
178
+ const stat = fs.lstatSync(absolutePath, { throwIfNoEntry: false });
179
+ if (!stat) return null;
180
+ return stat.isSymbolicLink() ? '120000' : (stat.mode & 0o777).toString(8).padStart(6, '0');
122
181
  }
123
182
 
124
183
  async function runAuthoringCommand(command, startPath, options) {
@@ -360,10 +419,7 @@ function buildMigrationStep(repositoryRoot, options) {
360
419
  const fromTag = options.fromTag || inferBuildBaseTag(repositoryRoot);
361
420
  const templateChanges = getTemplateChanges(repositoryRoot, fromTag);
362
421
  const base = { tag: templateChanges.baseTag, commit: templateChanges.baseCommit };
363
- const isLegacyBootstrap = !options.fromTag && LEGACY_TEMPLATE_BASELINE_TAG_PATTERN.test(fromTag);
364
- const migrationFiles = isLegacyBootstrap
365
- ? templateChanges.files.filter(file => !isCustomerOwnedPath(file.path))
366
- : templateChanges.files;
422
+ const migrationFiles = templateChanges.files;
367
423
  assertNoCustomerOwnedChanges(migrationFiles);
368
424
  if (!migrationFiles.length) {
369
425
  if (options.skipIfNoChanges) {
@@ -407,8 +463,8 @@ function buildMigrationStep(repositoryRoot, options) {
407
463
  let gitignoreTransition = null;
408
464
  for (const relativePath of paths) {
409
465
  const baseContent = readGitFile(repositoryRoot, base.commit, relativePath);
410
- const targetPath = path.join(repositoryRoot, TEMPLATE_PATH, relativePath);
411
- if (fs.existsSync(targetPath) && fs.lstatSync(targetPath).isSymbolicLink()) {
466
+ const targetPath = path.join(repositoryRoot, TEMPLATE_PATH, storedTemplatePath(relativePath));
467
+ if (fs.lstatSync(targetPath, { throwIfNoEntry: false })?.isSymbolicLink()) {
412
468
  throw new Error(`Symbolic link template change ${relativePath} is not supported`);
413
469
  }
414
470
  const targetContent = fs.existsSync(targetPath) ? fs.readFileSync(targetPath) : null;
@@ -622,6 +678,7 @@ module.exports = {
622
678
  buildPackageTransitions,
623
679
  buildTsconfigTransitions,
624
680
  getTemplateChanges,
681
+ isScaffoldOnlyTemplatePath,
625
682
  nextNativeRuntime,
626
683
  runAuthoringCommand,
627
684
  updateProfileFromPackage,
package/scripts/wrap.js CHANGED
@@ -52,6 +52,11 @@ const { execFileSync } = require('child_process');
52
52
  const path = require('path');
53
53
  const fs = require('fs');
54
54
  const os = require('os');
55
+ const {
56
+ MULTI_TENANT_AUTHORITY,
57
+ resolveMultiTenantConfig,
58
+ resolveTenantMode,
59
+ } = require('../lib/commonjs/config/tenantMode.js');
55
60
 
56
61
  // ─── Parse args ───────────────────────────────────────────────────────────────
57
62
  const projectRoot = process.cwd();
@@ -62,6 +67,23 @@ if (platform !== 'android' && platform !== 'ios') {
62
67
  process.exit(1);
63
68
  }
64
69
 
70
+ const appConfigPath = path.join(projectRoot, 'app.json');
71
+ if (!fs.existsSync(appConfigPath)) {
72
+ console.error('✗ app.json not found.');
73
+ process.exit(1);
74
+ }
75
+
76
+ let appConfig;
77
+ let tenantMode;
78
+ try {
79
+ appConfig = JSON.parse(fs.readFileSync(appConfigPath, 'utf8'));
80
+ tenantMode = resolveTenantMode(appConfig);
81
+ if (tenantMode === 'multi') resolveMultiTenantConfig(appConfig);
82
+ } catch (e) {
83
+ console.error(`✗ Invalid app.json Power Apps build configuration: ${e.message}`);
84
+ process.exit(1);
85
+ }
86
+
65
87
  // ─── Load wrap.config.json ────────────────────────────────────────────────────
66
88
  const configPath = path.join(projectRoot, 'wrap.config.json');
67
89
  if (!fs.existsSync(configPath)) {
@@ -86,17 +108,27 @@ for (const field of ['bundleIdentifier', 'displayName']) {
86
108
  process.exit(1);
87
109
  }
88
110
  }
89
- if (!config.msal?.clientId || !config.msal?.tenantId) {
90
- console.error('✗ wrap.config.json: missing required fields msal.clientId / msal.tenantId');
111
+ if (!config.msal?.clientId) {
112
+ console.error('✗ wrap.config.json: missing required field msal.clientId');
113
+ process.exit(1);
114
+ }
115
+ if (tenantMode === 'single' && !config.msal.tenantId) {
116
+ console.error('✗ wrap.config.json: msal.tenantId is required in single-tenant mode');
117
+ process.exit(1);
118
+ }
119
+ if (tenantMode === 'multi' && config.msal.tenantId && config.msal.tenantId !== MULTI_TENANT_AUTHORITY) {
120
+ console.error(`✗ wrap.config.json: msal.tenantId must be omitted or "${MULTI_TENANT_AUTHORITY}" in multi-tenant mode`);
91
121
  process.exit(1);
92
122
  }
93
123
 
124
+ const runtimeTenantId = tenantMode === 'multi' ? MULTI_TENANT_AUTHORITY : config.msal.tenantId;
125
+
94
126
  const {
95
127
  bundleIdentifier,
96
128
  displayName,
97
129
  version = '1.0.0',
98
130
  versionCode = 1,
99
- msal: { clientId, tenantId },
131
+ msal: { clientId },
100
132
  outputPath: outputPathRel = './dist',
101
133
  } = config;
102
134
 
@@ -132,7 +164,10 @@ function requireCommand(cmd, hint) {
132
164
  // ─── Step: write auth.config.json ─────────────────────────────────────────────
133
165
  function updateAuthConfig() {
134
166
  const authPath = path.join(projectRoot, 'auth.config.json');
135
- fs.writeFileSync(authPath, JSON.stringify({ msal: { clientId, tenantId } }, null, 2) + '\n');
167
+ fs.writeFileSync(
168
+ authPath,
169
+ JSON.stringify({ authContractVersion: 2, tenantMode, msal: { clientId, tenantId: runtimeTenantId } }, null, 2) + '\n',
170
+ );
136
171
  ok(`auth.config.json updated (clientId: ${clientId})`);
137
172
  }
138
173
 
@@ -156,7 +191,9 @@ function runPrebuild(plat) {
156
191
  // ADAL / MAM — picked up by app.config.js → baked into Info.plist (iOS)
157
192
  // and Android string resources (via the power-apps-native-host config plugin).
158
193
  ADAL_CLIENT_ID: clientId,
159
- ADAL_TENANT_ID: tenantId,
194
+ ADAL_TENANT_ID: runtimeTenantId,
195
+ POWERAPPS_TENANT_MODE: tenantMode,
196
+ POWERAPPS_AUTH_CONTRACT_VERSION: '2',
160
197
  // iOS auth-broker redirect URI — msauth.<bundleId>://auth
161
198
  IOS_ADAL_REDIRECT_URI: `msauth.${bundleIdentifier}://auth`,
162
199
  // App icon — 1024×1024 PNG; Expo generates all platform sizes at prebuild time.